Make mobile terminals load faster (#1147)

* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
This commit is contained in:
Mohamed Boudra
2026-05-23 18:41:10 +08:00
committed by GitHub
parent 84f1dfc8cb
commit 846c9b9da3
32 changed files with 1304 additions and 357 deletions
@@ -13,13 +13,15 @@ import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -64,6 +66,7 @@ type BridgeInboundMessage =
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
@@ -158,6 +161,7 @@ export default function TerminalEmulator({
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -272,19 +276,37 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
sendToWebView({ type: "writeOutput", streamKey, text });
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
}),
[sendToWebView, streamKey],
);
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
@@ -19,7 +19,10 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -29,7 +32,8 @@ import {
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -254,8 +258,12 @@ export default function TerminalEmulator({
domBridgeRef,
(): DOMImperativeFactory => ({
writeOutput: (...args) => {
const text = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text });
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
@@ -274,8 +282,11 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
+21 -2
View File
@@ -25,7 +25,9 @@ import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -171,6 +173,9 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
@@ -354,11 +359,18 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => {
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
emulatorRef.current?.writeOutput(data);
},
onRestore: ({ terminalId: restoreTerminalId, data }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (!isWorkspaceFocused || terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
@@ -367,6 +379,12 @@ export function TerminalPane({
}
emulatorRef.current?.renderSnapshot(state);
},
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
size: measuredTerminalSizeRef.current,
});
},
onStatusChange: handleStreamControllerStatus,
});
@@ -386,6 +404,7 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isWorkspaceFocused,
supportsTerminalRestoreModes,
workspaceTerminalSession.snapshots,
]);
@@ -1,7 +1,7 @@
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class WebglAddon {
@@ -49,6 +49,10 @@ function nextFrame(): Promise<void> {
});
}
function terminalOutput(text: string): Uint8Array {
return encodeTerminalOutput(text);
}
async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise<void> {
const startedAt = performance.now();
const timeoutMs = input.timeoutMs ?? 2_000;
@@ -253,7 +257,7 @@ describe("terminal emulator runtime in a real browser", () => {
expect(mounted.terminalKeys).toEqual([]);
mounted.runtime.write({ text: "\x1b[>7u" });
mounted.runtime.write({ data: terminalOutput("\x1b[>7u") });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -279,7 +283,7 @@ describe("terminal emulator runtime in a real browser", () => {
]);
mounted.terminalKeys.length = 0;
mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" });
mounted.runtime.write({ data: terminalOutput("\x1b[=0;0u\x1b[?9001h") });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -321,7 +325,7 @@ describe("terminal emulator runtime in a real browser", () => {
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.write({ text: bytes });
mounted.runtime.write({ data: terminalOutput(bytes) });
await nextFrame();
await nextFrame();
@@ -75,10 +75,10 @@ vi.mock("@xterm/xterm", () => ({
},
}));
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
interface StubTerminal {
write: (text: string, callback?: () => void) => void;
write: (data: string | Uint8Array, callback?: () => void) => void;
reset: () => void;
resize?: (cols: number, rows: number) => void;
focus: () => void;
@@ -102,8 +102,8 @@ function createRuntimeWithTerminal(): {
let resetCalls = 0;
const terminal: StubTerminal & { resetCalls: number } = {
write: (text: string, callback?: () => void) => {
writeTexts.push(text);
write: (data: string | Uint8Array, callback?: () => void) => {
writeTexts.push(decodeTerminalOutput(data));
if (callback) {
writeCallbacks.push(callback);
}
@@ -131,6 +131,17 @@ function createRuntimeWithTerminal(): {
};
}
function terminalOutput(text: string): Uint8Array {
return encodeTerminalOutput(text);
}
function decodeTerminalOutput(data: string | Uint8Array): string {
if (typeof data === "string") {
return data;
}
return new TextDecoder().decode(data);
}
describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window;
@@ -151,7 +162,7 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
text: "first",
data: terminalOutput("first"),
onCommitted: () => {
committed.push("first");
},
@@ -162,7 +173,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({
text: "second",
data: terminalOutput("second"),
onCommitted: () => {
committed.push("second");
},
@@ -188,7 +199,7 @@ describe("terminal-emulator-runtime", () => {
const onCommitted = vi.fn();
runtime.write({
text: "stuck",
data: terminalOutput("stuck"),
onCommitted,
});
@@ -208,7 +219,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({ text: "\x1b[>7u" });
runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.renderSnapshot({
state: {
rows: 2,
@@ -234,13 +245,13 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
text: "first",
data: terminalOutput("first"),
onCommitted: () => {
committed.push("first");
},
});
runtime.write({
text: "second",
data: terminalOutput("second"),
onCommitted: () => {
committed.push("second");
},
@@ -262,11 +273,11 @@ describe("terminal-emulator-runtime", () => {
const onCommittedB = vi.fn();
runtime.write({
text: "a",
data: terminalOutput("a"),
onCommitted: onCommittedA,
});
runtime.write({
text: "b",
data: terminalOutput("b"),
onCommitted: onCommittedB,
});
@@ -301,6 +312,15 @@ describe("terminal-emulator-runtime", () => {
expect(writeTexts[0]).toContain("hi");
});
it("restores server-rendered ANSI snapshots through the snapshot write path", () => {
const { runtime, terminal, writeTexts } = createRuntimeWithTerminal();
runtime.restoreOutput({ data: terminalOutput("restored screen") });
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toEqual(["\u001bcrestored screen"]);
});
it("forces a refit when resize is requested", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
@@ -24,6 +24,8 @@ import {
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
export type TerminalOutputData = Uint8Array;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
@@ -67,7 +69,7 @@ interface TerminalEmulatorRuntimeDisposables {
interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot";
text: string;
data: TerminalOutputData;
rows?: number;
cols?: number;
suppressInput?: boolean;
@@ -96,7 +98,23 @@ const isAppleHandheld =
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const RESET_TERMINAL_ANSI = "\u001bc";
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
const terminalOutputEncoder = new TextEncoder();
export function encodeTerminalOutput(text: string): TerminalOutputData {
return terminalOutputEncoder.encode(text);
}
function prependTerminalOutput(
prefix: TerminalOutputData,
data: TerminalOutputData,
): TerminalOutputData {
const output = new Uint8Array(prefix.length + data.length);
output.set(prefix, 0);
output.set(data, prefix.length);
return output;
}
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
@@ -140,6 +158,7 @@ export class TerminalEmulatorRuntime {
private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
@@ -528,14 +547,18 @@ export class TerminalEmulatorRuntime {
};
}
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
if (input.text.length === 0) {
write(input: {
data: TerminalOutputData;
suppressInput?: boolean;
onCommitted?: () => void;
}): void {
if (input.data.length === 0) {
input.onCommitted?.();
return;
}
this.outputOperations.push({
type: "write",
text: input.text,
data: input.data,
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -545,7 +568,7 @@ export class TerminalEmulatorRuntime {
clear(input?: { onCommitted?: () => void }): void {
this.outputOperations.push({
type: "clear",
text: "",
data: EMPTY_TERMINAL_OUTPUT,
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -557,11 +580,25 @@ export class TerminalEmulatorRuntime {
this.clear(input);
return;
}
this.outputOperations.push({
type: "snapshot",
text: `${RESET_TERMINAL_ANSI}${renderTerminalSnapshotToAnsi(input.state)}`,
this.restoreOutput({
data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
rows: input.state.rows,
cols: input.state.cols,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
}
restoreOutput(input: {
data: TerminalOutputData;
rows?: number;
cols?: number;
onCommitted?: () => void;
}): void {
this.outputOperations.push({
type: "snapshot",
data: prependTerminalOutput(RESET_TERMINAL_OUTPUT, input.data),
rows: input.rows,
cols: input.cols,
suppressInput: true,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -643,6 +680,7 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
}
@@ -664,7 +702,7 @@ export class TerminalEmulatorRuntime {
this.inFlightOutputOperation = operation;
const previousSuppressInput = this.suppressInput;
if (operation.type === "write") {
if (operation.suppressInput) {
this.suppressInput = Boolean(operation.suppressInput);
}
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
@@ -679,6 +717,7 @@ export class TerminalEmulatorRuntime {
};
if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
@@ -687,6 +726,7 @@ export class TerminalEmulatorRuntime {
}
if (operation.type === "snapshot") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
try {
@@ -703,8 +743,9 @@ export class TerminalEmulatorRuntime {
}
}
const text = operation.text;
const data = operation.data;
if (operation.type === "write") {
const text = this.inputModeDecoder.decode(data, { stream: true });
const result = this.inputModeTracker.feed(text);
if (result.changed) {
this.emitInputModeChange();
@@ -715,7 +756,7 @@ export class TerminalEmulatorRuntime {
}, OUTPUT_OPERATION_TIMEOUT_MS);
try {
terminal.write(text, () => {
terminal.write(data, () => {
finalizeOperation(operation);
});
} catch {
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: false,
size: { rows: 24, cols: 80 },
}),
).toBeUndefined();
});
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
});
});
it("omits size until the terminal has been measured", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
size: null,
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
});
});
});
@@ -0,0 +1,22 @@
import type { SubscribeTerminalRequest } from "@server/shared/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean;
size: { rows: number; cols: number } | null;
}
export function resolveTerminalRestoreOptions(
input: ResolveTerminalRestoreOptionsInput,
): SubscribeTerminalRequest["restore"] | undefined {
if (!input.supportsTerminalRestoreModes) {
return undefined;
}
return {
mode: "visible-snapshot",
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
...(input.size ? { size: input.size } : {}),
};
}
@@ -1,205 +1 @@
import type { TerminalCell, TerminalState } from "@server/shared/messages";
interface TerminalStyle {
fg: number | undefined;
bg: number | undefined;
fgMode: number | undefined;
bgMode: number | undefined;
bold: boolean;
italic: boolean;
underline: boolean;
dim: boolean;
inverse: boolean;
strikethrough: boolean;
}
const DEFAULT_STYLE: TerminalStyle = {
fg: undefined,
bg: undefined,
fgMode: undefined,
bgMode: undefined,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
strikethrough: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
lines.push("\r\n");
}
}
lines.push("\u001b[0m");
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
if (cursorPresentationAnsi) {
lines.push(cursorPresentationAnsi);
}
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
lines.push("\u001b[?7h");
return lines.join("");
}
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
if (!cursor.style) {
return null;
}
const cursorStyleCode = (() => {
if (cursor.style === "block") {
return cursor.blink === false ? 2 : 1;
}
if (cursor.style === "underline") {
return cursor.blink === false ? 4 : 3;
}
return cursor.blink === false ? 6 : 5;
})();
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
output.push("\u001b[0m");
}
return output.join("");
}
function getTerminalRowLength(row: TerminalCell[]): number {
for (let index = row.length - 1; index >= 0; index -= 1) {
const cell = row[index];
if (!cell) {
continue;
}
if (cell.char !== " ") {
return index + 1;
}
if (
cell.fg !== undefined ||
cell.bg !== undefined ||
cell.fgMode !== undefined ||
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline ||
cell.dim ||
cell.inverse ||
cell.strikethrough
) {
return index + 1;
}
}
return 0;
}
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
return {
fg: cell.fg,
bg: cell.bg,
fgMode: cell.fgMode,
bgMode: cell.bgMode,
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
dim: Boolean(cell.dim),
inverse: Boolean(cell.inverse),
strikethrough: Boolean(cell.strikethrough),
};
}
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
return (
left.fg === right.fg &&
left.bg === right.bg &&
left.fgMode === right.fgMode &&
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline &&
left.dim === right.dim &&
left.inverse === right.inverse &&
left.strikethrough === right.strikethrough
);
}
function styleToAnsi(style: TerminalStyle): string {
const codes = ["0"];
if (style.bold) {
codes.push("1");
}
if (style.dim) {
codes.push("2");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.inverse) {
codes.push("7");
}
if (style.strikethrough) {
codes.push("9");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));
}
if (style.bg !== undefined && style.bgMode !== undefined) {
codes.push(...colorToSgr(style.bgMode, style.bg, true));
}
return `\u001b[${codes.join(";")}m`;
}
function colorToSgr(mode: number, value: number, background: boolean): string[] {
if (mode === 1) {
if (value >= 8) {
return [String((background ? 100 : 90) + (value - 8))];
}
return [String((background ? 40 : 30) + value)];
}
if (mode === 2) {
return [background ? "48" : "38", "5", String(value)];
}
if (mode === 3) {
return [
background ? "48" : "38",
"2",
String((value >> 16) & 0xff),
String((value >> 8) & 0xff),
String(value & 0xff),
];
}
return [];
}
export { renderTerminalSnapshotToAnsi } from "@server/shared/terminal-snapshot";
@@ -14,19 +14,36 @@ interface TerminalSnapshot {
cursor: { row: number; col: number };
}
function terminalCellText(cell: { char: string }): string {
return cell.char;
}
function terminalRowText(row: Array<{ char: string }>): string {
return row.map(terminalCellText).join("");
}
function terminalSnapshotText(state: TerminalSnapshot): string {
return state.grid.map(terminalRowText).join("\n");
}
function terminalOutput(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot };
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
| { terminalId: string; type: "restore"; data: Uint8Array };
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: string[] = [];
public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = [];
public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
async subscribeTerminal(terminalId: string) {
this.subscribeCalls.push(terminalId);
async subscribeTerminal(terminalId: string, options?: unknown) {
this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) });
const result = this.nextSubscribeResults.shift();
if (!result) {
throw new Error("Missing fake subscribe result");
@@ -61,7 +78,8 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; text: string }> = [];
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
@@ -70,10 +88,13 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => {
@@ -81,7 +102,7 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
},
});
return { client, controller, outputs, snapshots, statuses };
return { client, controller, outputs, restores, snapshots, statuses };
}
async function flushAsyncWork(): Promise<void> {
@@ -108,16 +129,18 @@ describe("terminal-stream-controller", () => {
cursor: { row: 0, col: 5 },
},
});
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
type: "output",
data: new TextEncoder().encode(" world"),
data: outputData,
});
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.outputs).toEqual([{ terminalId: "term-1", text: " world" }]);
expect(harness.outputs[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -135,7 +158,7 @@ describe("terminal-stream-controller", () => {
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -152,7 +175,7 @@ describe("terminal-stream-controller", () => {
harness.controller.handleTerminalExit({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -160,6 +183,55 @@ describe("terminal-stream-controller", () => {
});
});
it("requests configured restore options and forwards restore output", async () => {
const client = new FakeTerminalStreamClient();
const harness = createHarness({ client });
client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 80 }),
getRestoreOptions: () => ({
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
}),
onOutput: (output) => harness.outputs.push(output),
onRestore: (restore) => harness.restores.push(restore),
onSnapshot: ({ terminalId, state }) => {
harness.snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
});
controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const restoreData = terminalOutput("restored");
client.emit({
terminalId: "term-1",
type: "restore",
data: restoreData,
});
controller.dispose();
expect(client.subscribeCalls).toEqual([
{
terminalId: "term-1",
options: {
restore: {
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
},
},
},
]);
expect(harness.restores[0]?.data).toBe(restoreData);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.outputs).toEqual([]);
});
it("unsubscribes when switching terminals and on dispose", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
@@ -1,7 +1,11 @@
import type { TerminalState } from "@server/shared/messages";
import type { SubscribeTerminalRequest, TerminalState } from "@server/shared/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
export interface TerminalStreamControllerClient {
subscribeTerminal: (terminalId: string) => Promise<{
subscribeTerminal: (
terminalId: string,
options?: { restore?: SubscribeTerminalRequest["restore"] },
) => Promise<{
terminalId: string;
error?: string | null;
}>;
@@ -14,7 +18,8 @@ export interface TerminalStreamControllerClient {
handler: (
event:
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState },
| { terminalId: string; type: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array },
) => void,
) => () => void;
}
@@ -33,15 +38,16 @@ export interface TerminalStreamControllerStatus {
export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; text: string }) => void;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
}
const TERMINAL_EXITED_ERROR = "Terminal exited";
export class TerminalStreamController {
private readonly decoder = new TextDecoder();
private readonly unsubscribeStreamEvents: () => void;
private terminalId: string | null = null;
private disposed = false;
@@ -52,13 +58,17 @@ export class TerminalStreamController {
return;
}
if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return;
}
const text = this.decoder.decode(event.data, { stream: true });
if (text.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, text });
if (event.type === "restore") {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
}
});
}
@@ -70,7 +80,6 @@ export class TerminalStreamController {
const nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId;
this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId);
}
@@ -78,9 +87,10 @@ export class TerminalStreamController {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
void this.options.client
.subscribeTerminal(nextTerminalId)
.subscribeTerminal(nextTerminalId, restore ? { restore } : undefined)
.then((payload) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
@@ -126,7 +136,6 @@ export class TerminalStreamController {
if (this.disposed || input.terminalId !== this.terminalId) {
return;
}
this.decoder.decode();
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: input.terminalId,
@@ -140,7 +149,6 @@ export class TerminalStreamController {
return;
}
this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId;
this.terminalId = null;
if (terminalId) {
@@ -3,7 +3,10 @@ import xtermCss from "@xterm/xterm/css/xterm.css";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "@/utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../runtime/terminal-emulator-runtime";
import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
} from "../runtime/terminal-emulator-runtime";
interface MountMessage {
type: "mount";
@@ -19,6 +22,7 @@ type InboundMessage =
| MountMessage
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
@@ -154,7 +158,10 @@ class TerminalWebViewBridge {
): void {
switch (message.type) {
case "writeOutput":
this.runtime?.write({ text: message.text });
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
break;
case "restoreOutput":
this.runtime?.restoreOutput({ data: encodeTerminalOutput(message.text) });
break;
case "renderSnapshot":
this.runtime?.renderSnapshot({ state: message.state });
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -67,6 +67,7 @@ import type {
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
SubscribeTerminalRequest,
CloseItemsResponse,
KillTerminalResponse,
CaptureTerminalResponse,
@@ -3770,13 +3771,19 @@ export class DaemonClient {
async subscribeTerminal(
terminalId: string,
requestId?: string,
optionsOrRequestId?:
| { restore?: SubscribeTerminalRequest["restore"]; requestId?: string }
| string,
): Promise<SubscribeTerminalPayload> {
const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined;
const requestId =
typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId;
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "subscribe_terminal_request",
terminalId,
requestId: resolvedRequestId,
...(restore ? { restore } : {}),
});
const payload = await this.sendCorrelatedRequest({
requestId: resolvedRequestId,
@@ -4363,6 +4370,8 @@ export class DaemonClient {
frameKind = "output";
} else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
frameKind = "snapshot";
} else if (frame.opcode === TerminalStreamOpcode.Restore) {
frameKind = "output";
}
this.runtimeMetrics?.recordBinaryFrame(
frameKind,
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { TerminalStreamOpcode } from "../shared/binary-frames/index.js";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
describe("terminal-stream-router", () => {
test("routes restore frames as restore events", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const payload = new TextEncoder().encode("restored screen");
router.setSlot("term-1", 7);
router.onEvent((event) => events.push(event));
router.handleFrame({
opcode: TerminalStreamOpcode.Restore,
slot: 7,
payload,
});
expect(events).toEqual([
{
terminalId: "term-1",
type: "restore",
data: payload,
},
]);
});
});
@@ -9,7 +9,8 @@ import type { TerminalInput, TerminalState } from "../shared/messages.js";
export type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState };
| { terminalId: string; type: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array };
export class TerminalStreamRouter {
private readonly terminalSlots = new Map<string, number>();
@@ -97,6 +98,15 @@ export class TerminalStreamRouter {
return;
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
this.emit({
terminalId,
type: "restore",
data: frame.payload,
});
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) {
@@ -650,6 +650,7 @@ test("receives server_info on websocket connect", async () => {
const serverInfo = client.getLastServerInfoMessage();
expect(serverInfo).not.toBeNull();
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
await client.close();
}, 15000);
@@ -46,13 +46,13 @@ function extractStateText(state: Pick<TerminalState, "grid" | "scrollback">): st
}
async function waitForCondition(
predicate: () => boolean,
predicate: () => boolean | Promise<boolean>,
timeoutMs: number,
intervalMs = 25,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -377,6 +377,9 @@ function getFrameText(frame: TerminalStreamFrame): string {
}
return extractStateText(state);
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
return new TextDecoder().decode(frame.payload);
}
return "";
}
@@ -672,6 +675,11 @@ async function subscribeRawTerminal(
ws: WebSocket,
terminalId: string,
requestId: string,
restore?: {
mode: "live" | "visible-snapshot" | "full-snapshot";
scrollbackLines?: number;
size?: { rows: number; cols: number };
},
): Promise<number> {
const ready = waitForRawSessionMessage(
ws,
@@ -688,6 +696,7 @@ async function subscribeRawTerminal(
type: "subscribe_terminal_request",
terminalId,
requestId,
...(restore ? { restore } : {}),
},
}),
);
@@ -753,6 +762,95 @@ test("client connects and receives a snapshot of the current terminal state", as
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("live terminal restore skips the initial snapshot", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
try {
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf 'before-live\\n'\r",
});
await new Promise((resolve) => setTimeout(resolve, 300));
const slot = await subscribeRawTerminal(ws, terminalId, "subscribe-live", { mode: "live" });
const outputFramesPromise = collectRawBinaryFrames(
ws,
(frames) => frames.some((frame) => getFrameText(frame).includes("after-live")),
10000,
);
ws.send(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
slot,
payload: "printf 'after-live\\n'\r",
}),
);
const outputFrames = await outputFramesPromise;
const outputText = outputFrames.map((frame) => getFrameText(frame)).join("");
expect(outputText).toContain("after-live");
expect(outputText).not.toContain("before-live");
expect(
outputFrames.some(
(frame) =>
frame.opcode === TerminalStreamOpcode.Snapshot ||
frame.opcode === TerminalStreamOpcode.Restore,
),
).toBe(false);
} finally {
await closeWebSocket(ws);
}
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("visible terminal restore sends bounded ANSI history", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
command: "/bin/sh",
args: [
"-lc",
"i=1; while [ $i -le 1200 ]; do printf 'restore-line-%04d\\n' $i; i=$((i+1)); done; sleep 30",
],
});
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
try {
await waitForCondition(async () => {
const capture = await ctx.client.captureTerminal(terminalId);
return capture.lines.join("\n").includes("restore-line-1200");
}, 10000);
const restoreFramePromise = waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
await subscribeRawTerminal(ws, terminalId, "subscribe-visible", {
mode: "visible-snapshot",
scrollbackLines: 5,
size: { rows: 10, cols: 80 },
});
const restoreFrame = await restoreFramePromise;
const restoreText = getFrameText(restoreFrame);
const restoredLines = restoreText.match(/restore-line-\d{4}/g) ?? [];
expect(restoredLines[0]).toBe("restore-line-1187");
expect(restoredLines.at(-1)).toBe("restore-line-1200");
expect(restoreText).not.toContain("restore-line-1186");
expect(restoreText).not.toContain("restore-line-0001");
expect(restoredLines.length).toBeLessThanOrEqual(15);
} finally {
await closeWebSocket(ws);
}
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("propagates debounced terminal titles through list responses and snapshots", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
@@ -1066,13 +1164,13 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
});
await new Promise((resolve) => setTimeout(resolve, 750));
rawSocket!.resume();
const catchUpFrame = await waitForRawBinaryFrame(
const catchUpFramePromise = waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
15000,
);
rawSocket!.resume();
const catchUpFrame = await catchUpFramePromise;
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
await closeWebSocket(ws);
@@ -1081,6 +1081,8 @@ export class VoiceAssistantWebSocketServer {
checkoutGithubSetAutoMerge: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
},
};
}
@@ -13,6 +13,7 @@ export const TerminalStreamOpcode = {
Input: 0x02,
Resize: 0x03,
Snapshot: 0x04,
Restore: 0x05,
} as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
@@ -57,7 +58,8 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot
value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.Restore
);
}
@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { ServerInfoStatusPayloadSchema, SubscribeTerminalRequestSchema } from "./messages.js";
describe("terminal restore schemas", () => {
test("accepts legacy terminal subscribe requests without restore options", () => {
expect(
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
}),
).toEqual({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
});
});
test("accepts kebab-case terminal restore modes", () => {
for (const mode of ["live", "visible-snapshot", "full-snapshot"] as const) {
expect(
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: `req-${mode}`,
restore: {
mode,
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
},
}).restore?.mode,
).toBe(mode);
}
});
test("rejects camel-case terminal restore modes", () => {
expect(() =>
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: {
mode: "visibleSnapshot",
},
}),
).toThrow();
});
test("accepts terminal restore mode feature metadata", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
features: {
"terminal-restore-modes": true,
},
}).features?.["terminal-restore-modes"],
).toBe(true);
});
});
+16
View File
@@ -1754,6 +1754,20 @@ export const SubscribeTerminalRequestSchema = z.object({
type: z.literal("subscribe_terminal_request"),
terminalId: z.string(),
requestId: z.string(),
restore: z
.object({
mode: z.enum(["live", "visible-snapshot", "full-snapshot"]),
scrollbackLines: z.number().int().nonnegative().optional(),
size: z
.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
})
.strict()
.optional(),
})
.strict()
.optional(),
});
export const UnsubscribeTerminalRequestSchema = z.object({
@@ -2079,6 +2093,8 @@ export const ServerInfoStatusPayloadSchema = z
checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
})
.optional(),
})
@@ -0,0 +1,205 @@
import type { TerminalCell, TerminalState } from "./messages.js";
interface TerminalStyle {
fg: number | undefined;
bg: number | undefined;
fgMode: number | undefined;
bgMode: number | undefined;
bold: boolean;
italic: boolean;
underline: boolean;
dim: boolean;
inverse: boolean;
strikethrough: boolean;
}
const DEFAULT_STYLE: TerminalStyle = {
fg: undefined,
bg: undefined,
fgMode: undefined,
bgMode: undefined,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
strikethrough: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
lines.push("\r\n");
}
}
lines.push("\u001b[0m");
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
if (cursorPresentationAnsi) {
lines.push(cursorPresentationAnsi);
}
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
lines.push("\u001b[?7h");
return lines.join("");
}
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
if (!cursor.style) {
return null;
}
const cursorStyleCode = (() => {
if (cursor.style === "block") {
return cursor.blink === false ? 2 : 1;
}
if (cursor.style === "underline") {
return cursor.blink === false ? 4 : 3;
}
return cursor.blink === false ? 6 : 5;
})();
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
output.push("\u001b[0m");
}
return output.join("");
}
function getTerminalRowLength(row: TerminalCell[]): number {
for (let index = row.length - 1; index >= 0; index -= 1) {
const cell = row[index];
if (!cell) {
continue;
}
if (cell.char !== " ") {
return index + 1;
}
if (
cell.fg !== undefined ||
cell.bg !== undefined ||
cell.fgMode !== undefined ||
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline ||
cell.dim ||
cell.inverse ||
cell.strikethrough
) {
return index + 1;
}
}
return 0;
}
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
return {
fg: cell.fg,
bg: cell.bg,
fgMode: cell.fgMode,
bgMode: cell.bgMode,
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
dim: Boolean(cell.dim),
inverse: Boolean(cell.inverse),
strikethrough: Boolean(cell.strikethrough),
};
}
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
return (
left.fg === right.fg &&
left.bg === right.bg &&
left.fgMode === right.fgMode &&
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline &&
left.dim === right.dim &&
left.inverse === right.inverse &&
left.strikethrough === right.strikethrough
);
}
function styleToAnsi(style: TerminalStyle): string {
const codes = ["0"];
if (style.bold) {
codes.push("1");
}
if (style.dim) {
codes.push("2");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.inverse) {
codes.push("7");
}
if (style.strikethrough) {
codes.push("9");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));
}
if (style.bg !== undefined && style.bgMode !== undefined) {
codes.push(...colorToSgr(style.bgMode, style.bg, true));
}
return `\u001b[${codes.join(";")}m`;
}
function colorToSgr(mode: number, value: number, background: boolean): string[] {
if (mode === 1) {
if (value >= 8) {
return [String((background ? 100 : 90) + (value - 8))];
}
return [String((background ? 40 : 30) + value)];
}
if (mode === 2) {
return [background ? "48" : "38", "5", String(value)];
}
if (mode === 3) {
return [
background ? "48" : "38",
"2",
String((value >> 16) & 0xff),
String((value >> 8) & 0xff),
String(value & 0xff),
];
}
return [];
}
@@ -1,4 +1,9 @@
import { createTerminal, type TerminalSession, type TerminalStateSnapshot } from "./terminal.js";
import {
createTerminal,
type TerminalSession,
type TerminalStateSnapshot,
type TerminalStateSnapshotOptions,
} from "./terminal.js";
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { resolve, sep, win32, posix } from "node:path";
@@ -29,7 +34,10 @@ export interface TerminalManager {
}): Promise<TerminalSession>;
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null>;
setTerminalTitle(id: string, title: string): boolean;
killTerminal(id: string): void;
killTerminalAndWait(
@@ -208,8 +216,11 @@ export function createTerminalManager(): TerminalManager {
return terminalsById.get(id);
},
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot() ?? null;
async getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
},
setTerminalTitle(id: string, title: string): boolean {
@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import { TerminalStreamOpcode, decodeTerminalStreamFrame } from "../shared/binary-frames/index.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
import {
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
} from "./terminal-restore.js";
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
describe("terminal restore policy", () => {
test("uses ready snapshots only for restore-aware subscriptions", () => {
expect(resolveTerminalSubscriptionSnapshotMode(undefined)).toBe("state");
expect(resolveTerminalSubscriptionSnapshotMode({ mode: "live" })).toBe("ready");
});
test("resolves bounded restore snapshot options", () => {
expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull();
expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined();
expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({
scrollbackLines: 200,
});
expect(
resolveTerminalRestoreSnapshotOptions({
mode: "visible-snapshot",
scrollbackLines: 999,
}),
).toEqual({ scrollbackLines: 500 });
});
test("promotes live restore to visible restore after output overflow", () => {
expect(resolveRestoreAfterOutputOverflow({ mode: "live" })).toEqual({
mode: "visible-snapshot",
});
expect(resolveRestoreAfterOutputOverflow({ mode: "full-snapshot" })).toEqual({
mode: "full-snapshot",
});
});
test("encodes restore snapshots as restore frames", () => {
const frame = decodeTerminalStreamFrame(
encodeTerminalRestoreFrame({
slot: 4,
snapshot: {
state: terminalState("restored"),
revision: 1,
},
}),
);
expect(frame?.opcode).toBe(TerminalStreamOpcode.Restore);
expect(frame?.slot).toBe(4);
expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored");
});
});
@@ -0,0 +1,75 @@
import type { SubscribeTerminalRequest } from "../server/messages.js";
import {
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import { renderTerminalSnapshotToAnsi } from "../shared/terminal-snapshot.js";
import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js";
export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024;
const DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
const MAX_VISIBLE_RESTORE_SCROLLBACK_LINES = 500;
export type TerminalRestoreOptions = NonNullable<SubscribeTerminalRequest["restore"]>;
export type TerminalSubscriptionSnapshotMode = "state" | "ready";
export function resolveTerminalSubscriptionSnapshotMode(
restore: TerminalRestoreOptions | undefined,
): TerminalSubscriptionSnapshotMode {
return restore ? "ready" : "state";
}
export function resolveRestoreAfterOutputOverflow(
restore: TerminalRestoreOptions | undefined,
): TerminalRestoreOptions | undefined {
if (restore?.mode === "live") {
return { mode: "visible-snapshot" };
}
return restore;
}
export function resolveTerminalRestoreSnapshotOptions(
restore: TerminalRestoreOptions,
): TerminalStateSnapshotOptions | null | undefined {
if (restore.mode === "live") {
return null;
}
if (restore.mode === "visible-snapshot") {
return {
scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines),
};
}
return undefined;
}
export function encodeLegacyTerminalSnapshotFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: input.slot,
payload: encodeTerminalSnapshotPayload(input.snapshot.state),
});
}
export function encodeTerminalRestoreFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Restore,
slot: input.slot,
payload: renderTerminalSnapshotToAnsi(input.snapshot.state),
});
}
function resolveVisibleRestoreScrollbackLines(value: number | undefined): number {
if (typeof value !== "number") {
return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES;
}
return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES);
}
@@ -0,0 +1,142 @@
import { describe, expect, test, vi } from "vitest";
import type pino from "pino";
import type { SessionOutboundMessage } from "../server/messages.js";
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager } from "./terminal-manager.js";
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
function createLogger(): pino.Logger {
return {
warn: vi.fn(),
error: vi.fn(),
} as unknown as pino.Logger;
}
describe("terminal-session-controller restore", () => {
test("delivers output produced while restore is in flight after the restore frame", async () => {
let terminalListener: ((message: ServerMessage) => void) | null = null;
const snapshot = deferred<TerminalStateSnapshot | null>();
const binaryFrames: TerminalStreamFrame[] = [];
const outboundMessages: SessionOutboundMessage[] = [];
const terminal: TerminalSession = {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"),
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
const terminalManager: TerminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => snapshot.promise),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
};
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes);
if (frame) {
binaryFrames.push(frame);
}
},
hasBinaryChannel: () => true,
isPathWithinRoot: () => false,
sessionLogger: createLogger(),
});
await controller.dispatch({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: {
mode: "visible-snapshot",
scrollbackLines: 200,
},
});
await Promise.resolve();
expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1);
terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 });
snapshot.resolve({ state: terminalState("restore-before"), revision: 1 });
await snapshot.promise;
await new Promise((resolve) => setTimeout(resolve, 20));
expect(outboundMessages).toContainEqual({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 0,
error: null,
requestId: "req-1",
},
});
expect(binaryFrames.map((frame) => frame.opcode)).toEqual([
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
]);
expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before");
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n");
});
});
@@ -17,11 +17,19 @@ import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "../ser
import {
TerminalStreamOpcode,
decodeTerminalResizePayload,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
import {
MAX_TERMINAL_OUTPUT_FRAME_BYTES,
encodeLegacyTerminalSnapshotFrame,
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
type TerminalRestoreOptions,
} from "./terminal-restore.js";
import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
@@ -38,10 +46,18 @@ interface ActiveTerminalStream {
unsubscribe: () => void;
needsSnapshot: boolean;
snapshotInFlight: boolean;
readyRevision?: number;
restore?: TerminalRestoreOptions;
bufferedOutputs: BufferedTerminalOutput[];
outputBytesSinceSnapshot: number;
outputCoalescer: TerminalOutputCoalescer;
}
interface SnapshotSendResult {
shouldContinue: boolean;
replayRevision?: number;
}
export interface TerminalSessionControllerOptions {
terminalManager: TerminalManager | null;
emit: (msg: SessionOutboundMessage) => void;
@@ -488,7 +504,21 @@ export class TerminalSessionController {
}
this.ensureExitSubscription(session);
const slot = this.bindActiveStream(session);
if (msg.restore?.size) {
const currentSize = session.getSize();
if (
currentSize.rows !== msg.restore.size.rows ||
currentSize.cols !== msg.restore.size.cols
) {
session.send({
type: "resize",
rows: msg.restore.size.rows,
cols: msg.restore.size.cols,
});
}
}
const slot = this.bindActiveStream(session, { restore: msg.restore });
if (slot === null) {
this.sessionLogger.warn(
{
@@ -628,7 +658,10 @@ export class TerminalSessionController {
}
}
private bindActiveStream(terminal: TerminalSession): number | null {
private bindActiveStream(
terminal: TerminalSession,
options?: { restore?: TerminalRestoreOptions },
): number | null {
if (!this.hasBinaryChannel()) {
return null;
}
@@ -638,6 +671,7 @@ export class TerminalSessionController {
const existingStream = this.activeStreams.get(existingSlot);
if (existingStream) {
existingStream.needsSnapshot = true;
existingStream.restore = options?.restore;
return existingSlot;
}
this.idToSlot.delete(terminal.id);
@@ -654,13 +688,23 @@ export class TerminalSessionController {
unsubscribe: () => {},
needsSnapshot: true,
snapshotInFlight: false,
readyRevision: undefined,
restore: options?.restore,
bufferedOutputs: [],
outputBytesSinceSnapshot: 0,
outputCoalescer: new TerminalOutputCoalescer({
timers: { setTimeout, clearTimeout },
onFlush: ({ payload }) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
activeStream.outputBytesSinceSnapshot += payload.byteLength;
if (activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES) {
activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore);
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
@@ -675,31 +719,35 @@ export class TerminalSessionController {
this.activeStreams.set(slot, activeStream);
this.idToSlot.set(terminal.id, slot);
activeStream.unsubscribe = terminal.subscribe((message) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
if (message.type === "snapshot") {
activeStream.outputCoalescer.flush();
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
if (message.type === "titleChange") {
return;
}
if (message.data.length === 0) {
return;
}
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
activeStream.bufferedOutputs.push({
data: message.data,
revision: message.revision,
});
return;
}
activeStream.outputCoalescer.handle(message.data);
});
activeStream.unsubscribe = terminal.subscribe(
(message) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
if (message.type === "snapshot" || message.type === "snapshotReady") {
activeStream.readyRevision = message.revision;
activeStream.outputCoalescer.flush();
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
if (message.type === "titleChange") {
return;
}
if (message.data.length === 0) {
return;
}
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
activeStream.bufferedOutputs.push({
data: message.data,
revision: message.revision,
});
return;
}
activeStream.outputCoalescer.handle(message.data);
},
{ initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) },
);
return slot;
}
@@ -722,43 +770,23 @@ export class TerminalSessionController {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
if (activeStream.restore && activeStream.readyRevision === undefined) {
return;
}
activeStream.outputCoalescer.flush();
activeStream.snapshotInFlight = true;
try {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
const restore = activeStream.restore;
const snapshotResult = restore
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
: await this.emitLegacySnapshot(activeStream, terminalManager);
if (!snapshotResult.shouldContinue) {
return;
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: activeStream.slot,
payload: encodeTerminalSnapshotPayload(snapshot.state),
}),
);
const replayPreamble = terminal.getReplayPreamble();
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (output.revision !== undefined && output.revision <= snapshot.revision) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
}
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision);
activeStream.needsSnapshot = false;
activeStream.outputBytesSinceSnapshot = 0;
} catch (error) {
this.sessionLogger.warn(
{ err: error, terminalId: activeStream.terminalId },
@@ -770,6 +798,85 @@ export class TerminalSessionController {
}
}
private async emitLegacySnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
): Promise<SnapshotSendResult> {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
this.emitBinary(
encodeLegacyTerminalSnapshotFrame({
slot: activeStream.slot,
snapshot,
}),
);
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private async emitRestoreSnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
restore: TerminalRestoreOptions,
): Promise<SnapshotSendResult> {
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore);
if (snapshotOptions === null) {
return { shouldContinue: true };
}
const snapshot = await terminalManager.getTerminalState(
activeStream.terminalId,
snapshotOptions,
);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
this.emitBinary(
encodeTerminalRestoreFrame({
slot: activeStream.slot,
snapshot,
}),
);
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private replayTerminalOutputAfterSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
replayRevision: number | undefined,
): void {
const replayPreamble = terminal.getReplayPreamble();
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (
replayRevision !== undefined &&
output.revision !== undefined &&
output.revision <= replayRevision
) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
}
}
private allocateSlot(): number | null {
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
const slot = (this.nextSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
@@ -176,7 +176,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
type: "response",
requestId: message.requestId,
ok: true,
result: session?.getStateSnapshot() ?? null,
result: session?.getStateSnapshot(message.options) ?? null,
});
return;
}
@@ -3,6 +3,7 @@ import type {
ServerMessage,
ClientMessage,
TerminalStateSnapshot,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import type { TerminalState } from "../shared/messages.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -61,6 +62,7 @@ export type TerminalWorkerRequest =
type: "getTerminalState";
requestId: string;
terminalId: string;
options?: TerminalStateSnapshotOptions;
}
| {
type: "captureTerminal";
+36 -10
View File
@@ -33,6 +33,14 @@ export interface TerminalStateSnapshot {
revision: number;
}
export interface TerminalStateSnapshotOptions {
scrollbackLines?: number;
}
export interface TerminalSubscribeOptions {
initialSnapshot?: "state" | "ready";
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
@@ -41,6 +49,7 @@ export type ClientMessage =
export type ServerMessage =
| { type: "output"; data: string; revision?: number }
| { type: "snapshot"; state: TerminalState; revision?: number }
| { type: "snapshotReady"; revision?: number }
| { type: "titleChange"; title?: string };
export interface TerminalSession {
@@ -48,13 +57,13 @@ export interface TerminalSession {
name: string;
cwd: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getStateSnapshot(): TerminalStateSnapshot;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
getReplayPreamble(): string;
getTitle(): string | undefined;
setTitle(title: string): void;
@@ -292,14 +301,21 @@ function extractGrid(terminal: TerminalType): TerminalCell[][] {
return grid;
}
function extractScrollback(terminal: TerminalType): TerminalCell[][] {
function extractScrollback(
terminal: TerminalType,
options?: { scrollbackLines?: number },
): TerminalCell[][] {
const scrollback: TerminalCell[][] = [];
const buffer = terminal.buffer.active;
// baseY is the first row of the visible viewport (0-indexed)
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
const scrollbackLines = buffer.baseY;
const startRow =
typeof options?.scrollbackLines === "number"
? Math.max(0, scrollbackLines - options.scrollbackLines)
: 0;
for (let row = 0; row < scrollbackLines; row++) {
for (let row = startRow; row < scrollbackLines; row++) {
const rowCells: TerminalCell[] = [];
const line = buffer.getLine(row);
for (let col = 0; col < terminal.cols; col++) {
@@ -813,20 +829,22 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
function getState(): TerminalState {
function getState(snapshotOptions?: TerminalStateSnapshotOptions): TerminalState {
return {
rows: terminal.rows,
cols: terminal.cols,
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal),
scrollback: extractScrollback(terminal, {
scrollbackLines: snapshotOptions?.scrollbackLines,
}),
cursor: extractCursorState(terminal),
...(title ? { title } : {}),
};
}
function getStateSnapshot(): TerminalStateSnapshot {
function getStateSnapshot(snapshotOptions?: TerminalStateSnapshotOptions): TerminalStateSnapshot {
return {
state: getState(),
state: getState(snapshotOptions),
revision: stateRevision,
};
}
@@ -891,10 +909,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
function subscribe(listener: (msg: ServerMessage) => void): () => void {
function subscribe(
listener: (msg: ServerMessage) => void,
subscribeOptions?: TerminalSubscribeOptions,
): () => void {
let active = true;
let snapshotDelivered = false;
const queuedMessages: ServerMessage[] = [];
const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state";
const subscriptionListener = (msg: ServerMessage): void => {
if (!active) {
return;
@@ -911,7 +933,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
terminal.write("", () => {
if (!disposed && active && listeners.has(subscriptionListener)) {
snapshotDelivered = true;
listener({ type: "snapshot", ...getStateSnapshot() });
if (initialSnapshot === "ready") {
listener({ type: "snapshotReady", revision: stateRevision });
} else {
listener({ type: "snapshot", ...getStateSnapshot() });
}
for (const message of queuedMessages.splice(0)) {
listener(message);
}
@@ -200,8 +200,18 @@ export function createWorkerTerminalManager(
}
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
},
subscribe(listener: (msg: ServerMessage) => void): () => void {
subscribe(
listener: (msg: ServerMessage) => void,
options?: { initialSnapshot?: "state" | "ready" },
): () => void {
record.messageListeners.add(listener);
if (options?.initialSnapshot === "ready") {
queueMicrotask(() => {
if (record.messageListeners.has(listener)) {
listener({ type: "snapshotReady", revision: 0 });
}
});
}
return () => {
record.messageListeners.delete(listener);
};
@@ -244,9 +254,14 @@ export function createWorkerTerminalManager(
getState(): TerminalState {
return record.state;
},
getStateSnapshot(): TerminalStateSnapshot {
getStateSnapshot(options?: { scrollbackLines?: number }): TerminalStateSnapshot {
const scrollbackLines = options?.scrollbackLines;
const scrollback =
typeof scrollbackLines === "number"
? record.state.scrollback.slice(-scrollbackLines)
: record.state.scrollback;
return {
state: record.state,
state: { ...record.state, scrollback },
revision: 0,
};
},
@@ -539,10 +554,14 @@ export function createWorkerTerminalManager(
return recordsById.get(id)?.session;
},
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
async getTerminalState(
id: string,
options?: { scrollbackLines?: number },
): Promise<TerminalStateSnapshot | null> {
return (await sendRequest({
type: "getTerminalState",
terminalId: id,
...(options ? { options } : {}),
})) as TerminalWorkerStateResult;
},