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:
Codeman maintainer
2026-08-20 11:21:29 +02:00
parent 7936a75e28
commit fa7e700834
8 changed files with 269 additions and 28 deletions
+59 -1
View File
@@ -415,6 +415,15 @@ export class Session extends EventEmitter {
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
// strip (see _handleTerminalOutput / isAltScreenStripMode)
private _altScreenSeqCarry: string = '';
/**
* Mouse-tracking DECSET modes the CLI currently has ON, as observed while
* STRIPPING them out of the stream below. Kept as a set rather than a boolean
* because a TUI may enable 1002 and later disable 1000 (a mode it never
* enabled); tracking is on while any of them is.
*/
private _cliMouseModes = new Set<number>();
private _cliMouseTracking = false;
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -1285,6 +1294,7 @@ export class Session extends EventEmitter {
niceValue: this._niceConfig.niceValue,
color: this._color,
flickerFilterEnabled: this._flickerFilterEnabled,
cliMouseTracking: this._cliMouseTracking || undefined,
cliVersion: this._cliVersion || undefined,
cliModel: this._cliModel || undefined,
cliAccountType: this._cliAccountType || undefined,
@@ -1546,6 +1556,46 @@ export class Session extends EventEmitter {
};
}
/**
* Remember whether the CLI currently wants to be told about mouse clicks.
*
* The strip in {@link _handleTerminalOutput} is the ONLY place these sequences
* exist. After it, neither the browser nor xterm can ever learn that the CLI
* asked for mouse tracking, so `terminal.modes.mouseTrackingMode` is
* permanently 'none' for a stripped mode. The browser hand-encodes SGR reports
* to compensate (`_sendSyntheticSgrTap` in terminal-ui.js), and with no state
* to consult it had to do that on EVERY click, delivering mouse reports to a
* CLI that never asked for them. Publishing this through `toState()` is what
* lets the browser report a click only when the CLI is listening.
*
* Only the TRACKING modes count. 1005/1006 select an encoding and 1007 is
* alt-scroll; a CLI that picks SGR encoding without turning a tracking mode on
* is not asking about clicks, and counting those would put the stray reports
* straight back.
*
* This must stay in lockstep with the strip regex that calls it: a sequence
* removed from the stream but not recorded here is one the browser can neither
* see nor be told about.
*/
private _recordStrippedMouseMode(seq: string): void {
// eslint-disable-next-line no-control-regex
const match = /\x1b\[\?(\d+)([hl])$/.exec(seq);
if (!match) return;
const mode = Number(match[1]);
if (mode !== 1000 && mode !== 1001 && mode !== 1002 && mode !== 1003) return;
if (match[2] === 'h') this._cliMouseModes.add(mode);
else this._cliMouseModes.delete(mode);
this._syncCliMouseTracking();
}
/** Emit only on a real transition: a TUI re-emitting its enable on every repaint costs nothing. */
private _syncCliMouseTracking(): void {
const active = this._cliMouseModes.size > 0;
if (active === this._cliMouseTracking) return;
this._cliMouseTracking = active;
this.emit('mouseTrackingChanged', active);
}
private _handleTerminalOutput(data: string): void {
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
@@ -1598,7 +1648,10 @@ export class Session extends EventEmitter {
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
this._recordStrippedMouseMode(seq);
return '';
});
}
}
@@ -2525,6 +2578,11 @@ export class Session extends EventEmitter {
this._messages = [];
this._lineBuffer = '';
this._altScreenSeqCarry = '';
// A restarted pane starts with no mouse mode: the new program has not asked
// for one yet, and carrying the old CLI's state over would report clicks
// into a program that never enabled tracking.
this._cliMouseModes.clear();
this._syncCliMouseTracking();
this._markActivity(true);
}
+8
View File
@@ -500,6 +500,14 @@ export interface SessionState {
color?: SessionColor;
/** Flicker filter enabled (buffers output after screen clears) */
flickerFilterEnabled?: boolean;
/**
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
* by the server on its way out of the stream (those sequences are stripped for
* claude/codex/gemini, so the browser can never see them itself). The browser
* hand-encodes a click report ONLY when this is true; without it, every click
* sent mouse reports to a CLI that never asked for them.
*/
cliMouseTracking?: boolean;
/** Claude Code CLI version (parsed from terminal, e.g., "2.1.27") */
cliVersion?: string;
/** Claude model in use (parsed from terminal, e.g., "Opus 4.5") */
+32 -8
View File
@@ -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;
+17
View File
@@ -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);