mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +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:
@@ -147,3 +147,94 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whatever the strip removes, the server has to remember, because after it runs
|
||||
* nothing downstream can ever see it. The browser hand-encodes click reports for
|
||||
* these modes (`_sendSyntheticSgrTap`), and with no state to consult it did that
|
||||
* on EVERY click, delivering mouse reports to a CLI that never asked for them.
|
||||
*/
|
||||
describe('stripped mouse-tracking state', () => {
|
||||
const trackingOf = (session: Session) => session.toState().cliMouseTracking;
|
||||
|
||||
it('starts off, and stays off for output that never enables tracking', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
|
||||
handleOutput(session, 'plain output\x1b[?1049h\x1b[3J');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows the CLI enabling and disabling a tracking mode', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const changes: boolean[] = [];
|
||||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||
|
||||
handleOutput(session, '\x1b[?1002hdialog');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1002ldismissed');
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
expect(changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('ignores encoding and alt-scroll modes, which do not ask about clicks', () => {
|
||||
// 1005/1006 pick an ENCODING and 1007 is alt-scroll. Counting them would put
|
||||
// the stray reports straight back: a CLI can select SGR encoding without ever
|
||||
// asking to be told where the user clicked.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1006h\x1b[?1005h\x1b[?1007h');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays on until the LAST tracking mode goes away', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1000h\x1b[?1002h\x1b[?1006h');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
// A TUI may disable a mode it never enabled; that must not clear the rest.
|
||||
handleOutput(session, '\x1b[?1003l');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1000l');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1002l');
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits only on a real transition, so a repainting TUI costs nothing', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const changes: boolean[] = [];
|
||||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||
|
||||
handleOutput(session, '\x1b[?1002h\x1b[?1002h\x1b[?1002h');
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
});
|
||||
|
||||
it('sees a sequence split across PTY chunks, like the strip that carries it', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, 'before\x1b[?100');
|
||||
handleOutput(session, '2h after');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before after');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks nothing for a mode whose DECSETs are never stripped', () => {
|
||||
// shell keeps its mouse DECSETs, so xterm sees them and owns the reporting.
|
||||
// A flag set here would mean a SECOND, hand-encoded report on every click.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
|
||||
handleOutput(session, '\x1b[?1002hhtop');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
@@ -155,7 +155,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('classifies Claude background-agent status as content rather than keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
||||
4,
|
||||
@@ -168,7 +168,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
||||
@@ -178,7 +178,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
||||
@@ -193,7 +193,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
// commits an answer) as the only thing a phone could do.
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
|
||||
2
|
||||
@@ -219,7 +219,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('collapses TUI readback content without opening or retaining the keyboard', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
@@ -252,7 +252,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('closes the keyboard on a second tap of INERT transcript content', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
|
||||
app._sendInputAsync = vi.fn();
|
||||
|
||||
@@ -328,7 +328,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -339,7 +339,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(true);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
// touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based)
|
||||
app._sendSyntheticSgrTap(171, 101);
|
||||
|
||||
@@ -366,19 +366,60 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
||||
});
|
||||
|
||||
it('does not treat shell sessions as server-mouse-strip mode', () => {
|
||||
it('never hand-reports for a shell session, even with tracking somehow set', () => {
|
||||
// Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns
|
||||
// the mouse there and a second, hand-encoded report would double-report.
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]);
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(false);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
});
|
||||
|
||||
it('hand-reports only while the CLI actually has mouse tracking on', () => {
|
||||
// The server strips the DECSETs, so xterm can never see them and the browser
|
||||
// reported EVERY click. A claude pane sitting at its composer, or one that
|
||||
// fell back to a shell prompt, was receiving mouse reports it never asked
|
||||
// for; a shell prints those as literal text and they garble the next line.
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: false }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
});
|
||||
|
||||
it('desktop click: sends nothing while the CLI has no mouse mode on', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = createTerminalGrid(['some output', '', '\u276f '], 2);
|
||||
app._linkHovered = false;
|
||||
|
||||
app._handleDesktopTerminalClick({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 40,
|
||||
clientY: 10,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
});
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -453,7 +494,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -487,7 +528,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -771,7 +812,7 @@ describe('terminal link tap', () => {
|
||||
const { app, windowRef } = harness;
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = createTerminalGrid(lines, cursorY);
|
||||
app.terminal.registerLinkProvider = vi.fn();
|
||||
@@ -802,7 +843,7 @@ describe('terminal link tap', () => {
|
||||
const line = 'see https://example.com/x for more';
|
||||
const { app, sent } = linkHarness([line, '', '❯ ']);
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(true);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content');
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
Reference in New Issue
Block a user