Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 68 additions & 9 deletions apps/server/src/terminal/Layers/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
});
Expand All @@ -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();
});
Expand All @@ -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();
Expand All @@ -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();
});
Expand All @@ -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();
});
Expand All @@ -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();
});
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/terminal/Layers/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -1888,7 +1905,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
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,
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/terminal/terminalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
defaultTerminalTitleForCliKind,
consumeTerminalIdentityInput,
deriveTerminalOutputIdentity,
MOUSE_REPORTING_RESET_SEQUENCE,
} from "@t3tools/shared/terminalThreads";
import { Terminal } from "@xterm/xterm";

Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/src/terminalThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TerminalCliKind, string> = {
codex: "codex",
claude: "claude",
Expand Down
Loading