diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 1cf7e8dffec..4cd42edf988 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1702,3 +1702,61 @@ 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[?9l\u001b[?1000l\u001b[?1001l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l\u001b[?1016l", + ); +}); + +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("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("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"; + + 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 [9, 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"; + + 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..98e5a1faac3 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -323,6 +323,46 @@ function terminalWireLabel(session: TerminalSessionState): string { return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); } +/** Only a tracking mode makes a terminal emit reports; an encoding just shapes them. */ +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`) + .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 + * 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 (!enablesMouseReporting(history)) return history; + return `${history}${MOUSE_REPORTING_RESET}`; +} + function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { return { threadId: session.threadId, @@ -331,7 +371,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), @@ -2370,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({