From 8427dc7a9a867caa9484c435ad97459db773a0b0 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Thu, 14 May 2026 16:29:21 +0200 Subject: [PATCH] Disable mouse reporting on terminal reattach Restored terminal panes could come back with mouse reporting still on. With no TUI attached to consume the events, moving the mouse printed raw CSI escape sequences into the shell, and reset/stty sane didn't fix it. The server now appends a mouse-mode reset to the history returned in a terminal snapshot, so it's applied whenever a terminal is opened, reattached, or restored. The web side also clears mouse-tracking modes when replaying a snapshot, since xterm.js doesn't reliably clear them on a full reset. Added unit tests for the reattach and restore paths. --- .../src/terminal/Layers/Manager.test.ts | 77 ++++++++++++++++--- apps/server/src/terminal/Layers/Manager.ts | 19 ++++- .../components/terminal/terminalRuntime.ts | 7 +- packages/shared/src/terminalThreads.ts | 15 ++++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2937c90b..fec3e1cf 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -8,6 +8,7 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; +import { MOUSE_REPORTING_RESET_SEQUENCE } from "@t3tools/shared/terminalThreads"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -178,6 +179,16 @@ function multiTerminalHistoryLogPath( return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); } +/** + * Snapshots returned for a restored / re-attached session append a mouse-mode + * reset to the replayed history so a freshly attached xterm never starts with + * mouse reporting enabled. Tests that assert on restored history use this to + * express the expected transcript without repeating the reset sequence. + */ +function withMouseReset(history: string): string { + return `${history}${MOUSE_REPORTING_RESET_SEQUENCE}`; +} + describe("TerminalManager", () => { const tempDirs: string[] = []; @@ -472,8 +483,7 @@ describe("TerminalManager", () => { await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); - expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); + expect(reopened.history).toBe(withMouseReset("line2\nline3\nline4\n")); manager.dispose(); }); @@ -494,7 +504,7 @@ describe("TerminalManager", () => { await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - expect(reopened.history).toBe("prompt \u001b[32mok\u001b[0m done\n"); + expect(reopened.history).toBe(withMouseReset("prompt \u001b[32mok\u001b[0m done\n")); manager.dispose(); }); @@ -516,7 +526,7 @@ describe("TerminalManager", () => { await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - expect(reopened.history).toBe("before clear\nprompt \u001b[36mdone\u001b[0m\n"); + expect(reopened.history).toBe(withMouseReset("before clear\nprompt \u001b[36mdone\u001b[0m\n")); manager.dispose(); }); @@ -536,7 +546,7 @@ describe("TerminalManager", () => { const reopened = await manager.open(openInput()); expect(reopened.history).toBe( - "instant prompt\nwarning output\nfinal prompt \u001b[35m❯\u001b[0m ", + withMouseReset("instant prompt\nwarning output\nfinal prompt \u001b[35m❯\u001b[0m "), ); manager.dispose(); @@ -556,7 +566,9 @@ describe("TerminalManager", () => { await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - expect(reopened.history).toBe("first prompt\r\u001b[0m\u001b[38;5;175m❯\u001b[0m "); + expect(reopened.history).toBe( + withMouseReset("first prompt\r\u001b[0m\u001b[38;5;175m❯\u001b[0m "), + ); manager.dispose(); }); @@ -575,7 +587,7 @@ describe("TerminalManager", () => { await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - expect(reopened.history).toBe("before \u001b(Bafter\n"); + expect(reopened.history).toBe(withMouseReset("before \u001b(Bafter\n")); manager.dispose(); }); @@ -594,7 +606,54 @@ describe("TerminalManager", () => { await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); - expect(reopened.history).toBe("before \u001b(Bafter\n"); + expect(reopened.history).toBe(withMouseReset("before \u001b(Bafter\n")); + + manager.dispose(); + }); + + it("appends a mouse-mode reset when re-attaching to a surviving terminal session", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // A TUI enabled mouse reporting; it is no longer attached to consume events. + process.emitData("mouse-tui prompt\n"); + + // Re-attaching to the still-running session must not spawn a new pty and + // must hand back history that disables mouse reporting in the fresh xterm. + const reattached = await manager.open(openInput()); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(reattached.history).toBe(withMouseReset("mouse-tui prompt\n")); + expect(reattached.history.endsWith(MOUSE_REPORTING_RESET_SEQUENCE)).toBe(true); + + manager.dispose(); + }); + + it("appends a mouse-mode reset when restoring a terminal session from persisted history", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("restored prompt\n"); + await manager.close({ threadId: "thread-1" }); + + // Restoring from disk spawns a fresh pty, but the replayed history must + // still leave the re-created xterm with mouse reporting disabled. + const restored = await manager.open(openInput()); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(restored.history).toBe(withMouseReset("restored prompt\n")); + + manager.dispose(); + }); + + it("does not append a mouse-mode reset to an empty terminal history", async () => { + const { manager } = makeManager(); + const snapshot = await manager.open(openInput()); + expect(snapshot.history).toBe(""); manager.dispose(); }); @@ -708,7 +767,7 @@ describe("TerminalManager", () => { const snapshot = await manager.open(openInput()); - expect(snapshot.history).toBe("legacy-line\n"); + expect(snapshot.history).toBe(withMouseReset("legacy-line\n")); expect(fs.existsSync(nextPath)).toBe(true); expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); expect(fs.existsSync(legacyPath)).toBe(false); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 500279f8..a187b498 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -21,6 +21,7 @@ import { deriveTerminalProcessIdentity, deriveTerminalTitleSignalIdentity, terminalCliKindFromValue, + MOUSE_REPORTING_RESET_SEQUENCE, T3CODE_TERMINAL_HOOK_OSC_PREFIX, T3CODE_TERMINAL_CLI_KIND_ENV_KEY, type TerminalActivityState, @@ -824,6 +825,22 @@ function sanitizePersistedTerminalHistory(history: string): string { return sanitizeTerminalHistoryChunk("", history).visibleText; } +/** + * Build the history payload returned in a session snapshot. + * + * Snapshots are replayed into a freshly attached xterm on open / re-attach / + * restore. Restored history can leave that xterm with mouse reporting enabled + * (the TUI that originally turned it on is no longer attached to consume the + * events), so raw `CSI < … M/m` mouse sequences spill into the shell as + * garbage. Appending a mouse-mode reset guarantees the re-attached terminal + * starts with mouse reporting off; it is a no-op for an empty history (a brand + * new session has nothing to replay). + */ +function snapshotHistory(history: string): string { + if (history.length === 0) return history; + return `${history}${MOUSE_REPORTING_RESET_SEQUENCE}`; +} + interface TerminalManagerEvents { event: [event: TerminalEvent]; } @@ -1888,7 +1905,7 @@ export class TerminalManagerRuntime extends EventEmitter cwd: session.cwd, status: session.status, pid: session.pid, - history: session.history, + history: snapshotHistory(session.history), exitCode: session.exitCode, exitSignal: session.exitSignal, updatedAt: session.updatedAt, diff --git a/apps/web/src/components/terminal/terminalRuntime.ts b/apps/web/src/components/terminal/terminalRuntime.ts index 2496dcad..d01c3e8a 100644 --- a/apps/web/src/components/terminal/terminalRuntime.ts +++ b/apps/web/src/components/terminal/terminalRuntime.ts @@ -13,6 +13,7 @@ import { defaultTerminalTitleForCliKind, consumeTerminalIdentityInput, deriveTerminalOutputIdentity, + MOUSE_REPORTING_RESET_SEQUENCE, } from "@t3tools/shared/terminalThreads"; import { Terminal } from "@xterm/xterm"; @@ -56,7 +57,11 @@ function resetForSnapshotReplay(entry: TerminalRuntimeEntry): void { entry.outputIdentityBuffer = ""; clearPendingWrites(entry); clearDeferredWrites(entry); - entry.terminal.write("\u001bc"); + // RIS (`\u001bc`) resets the emulator, but xterm.js does not reliably clear + // DEC private mouse-tracking modes on RIS. Explicitly disable mouse reporting + // so a re-attached terminal never starts in a state where mouse movement + // spews raw `CSI < … M/m` sequences into the shell. + entry.terminal.write(`\u001bc${MOUSE_REPORTING_RESET_SEQUENCE}`); } function replaySnapshotHistory(entry: TerminalRuntimeEntry, history: string): void { diff --git a/packages/shared/src/terminalThreads.ts b/packages/shared/src/terminalThreads.ts index af17399d..6f5aecc3 100644 --- a/packages/shared/src/terminalThreads.ts +++ b/packages/shared/src/terminalThreads.ts @@ -11,6 +11,21 @@ export type TerminalVisualState = "idle" | TerminalActivityState; export type TerminalAgentHookEventType = "Start" | "Stop" | "PermissionRequest"; export const T3CODE_TERMINAL_CLI_KIND_ENV_KEY = "T3CODE_TERMINAL_CLI_KIND"; export const T3CODE_TERMINAL_HOOK_OSC_PREFIX = "633;T3CODE_AGENT_EVENT="; + +/** + * Escape sequence that disables every xterm mouse-reporting mode. + * + * Covers X10/normal/button/any-event tracking plus the SGR and urxvt extended + * coordinate encodings. Replaying restored terminal history into a freshly + * attached xterm can leave it with mouse reporting enabled — the TUI that + * originally turned it on (e.g. `claude`) is no longer attached to consume the + * events, so raw `CSI < … M/m` sequences spill into the shell as garbage. + * Emitting this on the re-attach / snapshot-replay path guarantees the restored + * terminal starts with mouse reporting off. + */ +export const MOUSE_REPORTING_RESET_SEQUENCE = + "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l"; + export const MANAGED_TERMINAL_COMMAND_NAME_BY_CLI_KIND: Record = { codex: "codex", claude: "claude",