mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
fix(terminal): report a click to the CLI only when it asked for the mouse
Found while verifying Auto Copy in a browser: a plain left click in a
claude/codex/gemini pane sent a synthetic SGR mouse report into the PTY
whether or not the program in that pane had ever enabled mouse tracking.
When the pane holds a plain shell (the CLI exited, or a shell was started
inside a session of that mode) readline prints the report as literal text
and it garbles the next line typed:
$ [<0;88;20Mecho hello
bash: 0: No such file or directory
The cause is that the browser could not know. The full strip
(isAltScreenStripMode) removes the mouse DECSETs from the stream, so
xterm's modes.mouseTrackingMode is permanently 'none' for those modes and
_sendSyntheticSgrTap() hand-encodes reports to stand in for xterm's own
encoder. With no state to consult it had to do that on every click.
What the strip removes, the server now remembers.
_recordStrippedMouseMode() records each sequence as it is stripped,
toState() publishes it as cliMouseTracking, and the browser's
_shouldReportMouseToCli() (renamed from _sessionUsesServerMouseStrip)
requires it at all three report sites: the desktop click, the touchend
tap, and the mobile tap classifier.
Details that are easy to get wrong:
* Only the tracking modes count (1000/1001/1002/1003). 1005/1006 select
an encoding and 1007 is alt-scroll; a CLI that picks SGR encoding
without turning tracking on is not asking about clicks, and counting
those would put the stray reports straight back.
* Modes are held in a Set, so a TUI disabling a mode it never enabled
cannot clear the ones that are really on.
* The change broadcasts immediately instead of through
broadcastSessionStateDebounced: the flag flips when a dialog opens, and
the user can click that dialog well inside the 500ms debounce window.
* It fails toward silence. After a server restart the flag is false until
the CLI re-emits its DECSET, which tmux does at client attach.
Verified against a live claude 2.x session: the CLI holds a tracking mode
on continuously, so its clicks are still reported byte for byte as
before, while a bash prompt in the same stripped mode now reports
nothing and types cleanly. The flag also propagates live over SSE in both
directions, checked by toggling ?1002h/?1002l from inside the pane.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4044,7 +4044,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
||||
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
||||
if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input';
|
||||
if (!mouseTrackingOn && !this._shouldReportMouseToCli()) return 'input';
|
||||
|
||||
const buffer = this.terminal.buffer?.active;
|
||||
if (!buffer?.getLine) return 'input';
|
||||
@@ -4344,9 +4344,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// xterm's mouse encoder owns live DECSET modes. The synthetic DOM click
|
||||
// follows the same path as a desktop click.
|
||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||
} else if (shouldActivate && this._sessionUsesServerMouseStrip()) {
|
||||
} else if (shouldActivate && this._shouldReportMouseToCli()) {
|
||||
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
|
||||
// report directly to the PTY while retaining local touch scrollback.
|
||||
// report directly to the PTY while retaining local touch scrollback. Only
|
||||
// while the CLI actually has tracking on (see _shouldReportMouseToCli).
|
||||
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
@@ -4414,9 +4415,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
// output stream has mouse-tracking DECSET sequences stripped before reaching the
|
||||
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
|
||||
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
|
||||
_sessionUsesServerMouseStrip() {
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
||||
return mode === 'claude' || mode === 'codex' || mode === 'gemini';
|
||||
/**
|
||||
* True when the browser has to hand-encode a click report for the CLI.
|
||||
*
|
||||
* Two conditions, and dropping either one is a bug that has already happened:
|
||||
*
|
||||
* 1. The session's mode is one whose mouse DECSETs the server STRIPS out of
|
||||
* the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why
|
||||
* xterm's own encoder is permanently idle here and something has to stand
|
||||
* in for it.
|
||||
* 2. The CLI actually has a mouse-tracking mode on right now. The server
|
||||
* records that as it strips (`_recordStrippedMouseMode` in session.ts) and
|
||||
* publishes it as `cliMouseTracking`. Without this half the browser
|
||||
* reported EVERY click, so a CLI sitting at its composer with no dialog
|
||||
* open, or a pane that has fallen back to a shell prompt, received mouse
|
||||
* reports it never asked for. A shell prints those as literal text
|
||||
* (`[<0;88;20M`) and they garble the next line typed.
|
||||
*
|
||||
* Fails toward silence: an unknown or stale flag reports nothing rather than
|
||||
* injecting bytes. After a server restart the flag is false until the CLI
|
||||
* re-emits its DECSET, which closing and reopening a dialog does.
|
||||
*/
|
||||
_shouldReportMouseToCli() {
|
||||
const session = this.sessions?.get(this.activeSessionId);
|
||||
const mode = session?.mode || 'claude';
|
||||
if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false;
|
||||
return session?.cliMouseTracking === true;
|
||||
},
|
||||
|
||||
// True when xterm's viewport shows the live PTY screen (not scrolled up into
|
||||
@@ -4682,7 +4706,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
|
||||
// for a plain left-click when the server strips mouse DECSETs (see
|
||||
// _sessionUsesServerMouseStrip). Every skip below is a click that already has
|
||||
// _shouldReportMouseToCli). Every skip below is a click that already has
|
||||
// a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend
|
||||
// reported already), modified clicks (shift keeps xterm's selection
|
||||
// override), double/triple clicks (word/line selection), drag-selections,
|
||||
@@ -4696,7 +4720,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||
const mode = this.terminal.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return;
|
||||
if (!this._sessionUsesServerMouseStrip()) return;
|
||||
if (!this._shouldReportMouseToCli()) return;
|
||||
if (this.terminal.hasSelection?.()) return;
|
||||
if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
|
||||
if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return;
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface SessionListenerRefs {
|
||||
limitResumeCancelled: (data: { reason: string }) => void;
|
||||
respawnBreakerTripped: (data: { count: number }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
mouseTrackingChanged: (active: boolean) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
ralphCompletionDetected: (phrase: string) => void;
|
||||
@@ -342,6 +343,20 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* The CLI turned mouse tracking on or off (observed while stripping the
|
||||
* DECSETs out of the stream). Rides the full session state so the browser
|
||||
* learns it through the session object it already merges, with no new SSE
|
||||
* event to keep in sync across the two registries.
|
||||
*
|
||||
* Broadcast IMMEDIATELY, not debounced: this flips when a dialog opens, and
|
||||
* a user can click that dialog inside the 500ms debounce window, which is
|
||||
* exactly the click that has to be reported.
|
||||
*/
|
||||
mouseTrackingChanged: () => {
|
||||
deps.broadcast(SseEvent.SessionUpdated, { session: deps.getSessionStateWithRespawn(session) });
|
||||
},
|
||||
|
||||
// ─── Ralph Tracking Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
||||
@@ -453,6 +468,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('mouseTrackingChanged', refs.mouseTrackingChanged);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
@@ -487,6 +503,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('mouseTrackingChanged', refs.mouseTrackingChanged);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
|
||||
Reference in New Issue
Block a user