From ba5ff2b1bd21ba6cf38715779ab15e0fa538c789 Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:32:21 +0200 Subject: [PATCH 1/3] fix(server): stop replayed history from re-arming mouse reporting Mouse reporting is terminal state, not text. A TUI killed before it could restore the mode leaves the enabling sequence in the session history, and reopening the terminal replays it into a fresh xterm. Every pointer move then echoes into the idle shell as a literal ESC[<..M. Clear the tracking modes at the end of a replayed snapshot, but only when no child process is running, so a live TUI keeps the mouse support the replay legitimately restores for it. Closes #4776. Co-Authored-By: Claude Opus 5 --- apps/server/src/terminal/Manager.test.ts | 24 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 21 ++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 1cf7e8dffec..fec09a4aada 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1702,3 +1702,27 @@ it.layer( }).pipe(Effect.provide(TestClock.layer())), ); }); + +const MOUSE_ENABLED_HISTORY = "$ lazygit\r\n\u001b[?1002h\u001b[?1006h"; + +it("clears mouse reporting from a replayed history once the shell is idle", () => { + const replayed = TerminalManager.replaySafeTerminalHistory(MOUSE_ENABLED_HISTORY, false); + + expect(replayed.startsWith(MOUSE_ENABLED_HISTORY)).toBe(true); + expect(replayed.slice(MOUSE_ENABLED_HISTORY.length)).toBe( + "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l", + ); +}); + +it("leaves mouse reporting alone while a child process can still consume it", () => { + assert.equal( + TerminalManager.replaySafeTerminalHistory(MOUSE_ENABLED_HISTORY, true), + MOUSE_ENABLED_HISTORY, + ); +}); + +it("does not touch a history that never enabled mouse reporting", () => { + const history = "$ echo hello\r\nhello\r\n\u001b[?2004h"; + + assert.equal(TerminalManager.replaySafeTerminalHistory(history, false), history); +}); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index caa5106bb9f..97356a053e4 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -323,6 +323,25 @@ function terminalWireLabel(session: TerminalSessionState): string { return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); } +/** Tracking modes plus the SGR encoding that reports arrive in. */ +const MOUSE_REPORTING_RESET = "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l"; +const MOUSE_REPORTING_ENABLES = ["1000", "1001", "1002", "1003", "1015", "1016"].map( + (mode) => `\u001b[?${mode}h`, +); + +/** + * Mouse reporting is terminal state, not text. Replaying a history whose TUI was + * killed before it could restore the mode re-arms reporting on the fresh terminal, + * and every pointer move then echoes into the shell as a literal `ESC[<…M`. A live + * child process may still legitimately want the mode, so only clear it for an + * idle shell, where nothing is left to read the reports. + */ +export function replaySafeTerminalHistory(history: string, hasRunningSubprocess: boolean): string { + if (hasRunningSubprocess) return history; + if (!MOUSE_REPORTING_ENABLES.some((sequence) => history.includes(sequence))) return history; + return `${history}${MOUSE_REPORTING_RESET}`; +} + function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { return { threadId: session.threadId, @@ -331,7 +350,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { worktreePath: session.worktreePath, status: session.status, pid: session.pid, - history: session.history, + history: replaySafeTerminalHistory(session.history, session.hasRunningSubprocess), exitCode: session.exitCode, exitSignal: session.exitSignal, label: terminalWireLabel(session), From 7a2a5796a38506e1b639542509c309b6db3f04f6 Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:03:52 +0200 Subject: [PATCH 2/3] fix(server): detect every mouse enable and gate on fresh subprocess state Review feedback on the first commit: - A DECSET may set several modes at once (`ESC[?1002;1006h`) and may use the 8-bit introducer, so parse the sequence instead of matching single-parameter spellings. - Separate tracking modes from encodings: only a tracking mode makes the terminal report, but the reset now clears both, including 1001 and 1016. - Refresh the subprocess state when a client attaches. The cached flag is only updated once a second, so a TUI that had just started could lose its mouse support and one that had just exited could still leave reporting armed. Co-Authored-By: Claude Opus 5 --- apps/server/src/terminal/Manager.test.ts | 30 ++++++++++++++++++- apps/server/src/terminal/Manager.ts | 38 ++++++++++++++++++++---- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index fec09a4aada..cdae7c14742 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1710,7 +1710,7 @@ it("clears mouse reporting from a replayed history once the shell is idle", () = expect(replayed.startsWith(MOUSE_ENABLED_HISTORY)).toBe(true); expect(replayed.slice(MOUSE_ENABLED_HISTORY.length)).toBe( - "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l", + "\u001b[?1000l\u001b[?1001l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l\u001b[?1016l", ); }); @@ -1721,6 +1721,34 @@ it("leaves mouse reporting alone while a child process can still consume it", () ); }); +it("detects mouse reporting enabled through a combined DECSET", () => { + const history = "$ lazygit\r\n\u001b[?1002;1006h"; + + expect(TerminalManager.replaySafeTerminalHistory(history, false)).not.toBe(history); +}); + +it("detects mouse reporting enabled through an 8-bit CSI introducer", () => { + const history = "$ lazygit\r\n\u009b?1003h"; + + expect(TerminalManager.replaySafeTerminalHistory(history, false)).not.toBe(history); +}); + +it("ignores an encoding mode that cannot make the terminal report on its own", () => { + const history = "$ echo hi\r\n\u001b[?1006h"; + + expect(TerminalManager.replaySafeTerminalHistory(history, false)).toBe(history); +}); + +it("resets every mouse mode it recognises as an enable", () => { + const reset = TerminalManager.replaySafeTerminalHistory("\u001b[?1002h", false).slice( + "\u001b[?1002h".length, + ); + + for (const mode of [1000, 1001, 1002, 1003, 1006, 1015, 1016]) { + expect(reset).toContain(`\u001b[?${mode}l`); + } +}); + it("does not touch a history that never enabled mouse reporting", () => { const history = "$ echo hello\r\nhello\r\n\u001b[?2004h"; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 97356a053e4..0ffe6b0cb65 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -323,11 +323,32 @@ function terminalWireLabel(session: TerminalSessionState): string { return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); } -/** Tracking modes plus the SGR encoding that reports arrive in. */ -const MOUSE_REPORTING_RESET = "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l"; -const MOUSE_REPORTING_ENABLES = ["1000", "1001", "1002", "1003", "1015", "1016"].map( - (mode) => `\u001b[?${mode}h`, -); +/** Only a tracking mode makes a terminal emit reports; an encoding just shapes them. */ +const MOUSE_TRACKING_MODES: ReadonlyArray = [1000, 1001, 1002, 1003]; +const MOUSE_ENCODING_MODES: ReadonlyArray = [1006, 1015, 1016]; +const MOUSE_REPORTING_RESET = [...MOUSE_TRACKING_MODES, ...MOUSE_ENCODING_MODES] + .map((mode) => `\u001b[?${mode}l`) + .join(""); +// A DECSET can carry several parameters at once (`ESC[?1002;1006h`) and may use the +// 7-bit `ESC [` or the 8-bit `\u009b` introducer, so match the shape of the sequence +// rather than a handful of single-parameter spellings. +// eslint-disable-next-line no-control-regex -- matching DECSET requires its control introducer. +const DECSET_PATTERN = /(?:\u001b\[|\u009b)\?([\d;]*)h/g; + +function enablesMouseReporting(history: string): boolean { + DECSET_PATTERN.lastIndex = 0; + for ( + let match = DECSET_PATTERN.exec(history); + match !== null; + match = DECSET_PATTERN.exec(history) + ) { + const parameters = (match[1] ?? "").split(";"); + if (parameters.some((parameter) => MOUSE_TRACKING_MODES.includes(Number(parameter)))) { + return true; + } + } + return false; +} /** * Mouse reporting is terminal state, not text. Replaying a history whose TUI was @@ -338,7 +359,7 @@ const MOUSE_REPORTING_ENABLES = ["1000", "1001", "1002", "1003", "1015", "1016"] */ export function replaySafeTerminalHistory(history: string, hasRunningSubprocess: boolean): string { if (hasRunningSubprocess) return history; - if (!MOUSE_REPORTING_ENABLES.some((sequence) => history.includes(sequence))) return history; + if (!enablesMouseReporting(history)) return history; return `${history}${MOUSE_REPORTING_RESET}`; } @@ -2389,6 +2410,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return attachEvent ? listener(attachEvent) : Effect.void; }); + // The cached subprocess flag is only refreshed by a poller, so gating the replay + // on it would use state up to one interval old. Refresh first: a TUI that just + // started must keep its mouse reporting, and one that just exited must not leave + // it armed. + yield* pollSubprocessActivity(); const initialSnapshot = yield* openOrAttachForStream(input); yield* listener({ From add5fa2be2571cb7dd1f304223bd445f0b4cb92d Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:44:01 +0200 Subject: [PATCH 3/3] fix(server): treat X10 as a mouse tracking mode Review feedback: mode 9 is a tracking mode xterm.js implements, so a history enabling it was left unreset and could keep reporting armed on an idle shell. The reset list derives from the tracking list, so it now clears ?9l too. Co-Authored-By: Claude Opus 5 --- apps/server/src/terminal/Manager.test.ts | 10 ++++++++-- apps/server/src/terminal/Manager.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index cdae7c14742..4cd42edf988 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1710,7 +1710,7 @@ it("clears mouse reporting from a replayed history once the shell is idle", () = expect(replayed.startsWith(MOUSE_ENABLED_HISTORY)).toBe(true); expect(replayed.slice(MOUSE_ENABLED_HISTORY.length)).toBe( - "\u001b[?1000l\u001b[?1001l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l\u001b[?1016l", + "\u001b[?9l\u001b[?1000l\u001b[?1001l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l\u001b[?1016l", ); }); @@ -1733,6 +1733,12 @@ it("detects mouse reporting enabled through an 8-bit CSI introducer", () => { expect(TerminalManager.replaySafeTerminalHistory(history, false)).not.toBe(history); }); +it("detects the X10 tracking mode", () => { + const history = "$ some-tui\r\n\u001b[?9h"; + + expect(TerminalManager.replaySafeTerminalHistory(history, false)).not.toBe(history); +}); + it("ignores an encoding mode that cannot make the terminal report on its own", () => { const history = "$ echo hi\r\n\u001b[?1006h"; @@ -1744,7 +1750,7 @@ it("resets every mouse mode it recognises as an enable", () => { "\u001b[?1002h".length, ); - for (const mode of [1000, 1001, 1002, 1003, 1006, 1015, 1016]) { + for (const mode of [9, 1000, 1001, 1002, 1003, 1006, 1015, 1016]) { expect(reset).toContain(`\u001b[?${mode}l`); } }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 0ffe6b0cb65..98e5a1faac3 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -324,7 +324,7 @@ function terminalWireLabel(session: TerminalSessionState): string { } /** Only a tracking mode makes a terminal emit reports; an encoding just shapes them. */ -const MOUSE_TRACKING_MODES: ReadonlyArray = [1000, 1001, 1002, 1003]; +const MOUSE_TRACKING_MODES: ReadonlyArray = [9, 1000, 1001, 1002, 1003]; const MOUSE_ENCODING_MODES: ReadonlyArray = [1006, 1015, 1016]; const MOUSE_REPORTING_RESET = [...MOUSE_TRACKING_MODES, ...MOUSE_ENCODING_MODES] .map((mode) => `\u001b[?${mode}l`)