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",