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 { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages"; import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; 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 { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url"; import { openExternalUrl } from "../utils/open-external-url";
export interface TerminalEmulatorHandle { export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void; writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void; renderSnapshot: (state: TerminalState | null) => void;
clear: () => void; clear: () => void;
} }
@@ -64,6 +66,7 @@ type BridgeInboundMessage =
} }
| { type: "unmount"; streamKey: string } | { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string } | { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string } | { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string } | { type: "focus"; streamKey: string }
@@ -158,6 +161,7 @@ export default function TerminalEmulator({
const bridgeReadyVersionRef = useRef(0); const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0); const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]); const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountedStreamKeyRef = useRef<string | null>(null); const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -272,19 +276,37 @@ export default function TerminalEmulator({
useImperativeHandle( useImperativeHandle(
ref, ref,
(): TerminalEmulatorHandle => ({ (): TerminalEmulatorHandle => ({
writeOutput: (text: string) => { writeOutput: (data: TerminalOutputData) => {
sendToWebView({ type: "writeOutput", streamKey, text }); 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) => { renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state }); sendToWebView({ type: "renderSnapshot", streamKey, state });
}, },
clear: () => { clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey }); sendToWebView({ type: "clear", streamKey });
}, },
}), }),
[sendToWebView, streamKey], [sendToWebView, streamKey],
); );
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => { useEffect(() => {
if (bridgeReadyVersion <= 0) return; if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current); const mountMessage = createMountMessage(mountConfigRef.current);
@@ -19,7 +19,10 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages"; import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; 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 type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url"; import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus"; import { focusWithRetries } from "../utils/web-focus";
@@ -29,7 +32,8 @@ import {
} from "./web-desktop-scrollbar.math"; } from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle { export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void; writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void; renderSnapshot: (state: TerminalState | null) => void;
clear: () => void; clear: () => void;
} }
@@ -254,8 +258,12 @@ export default function TerminalEmulator({
domBridgeRef, domBridgeRef,
(): DOMImperativeFactory => ({ (): DOMImperativeFactory => ({
writeOutput: (...args) => { writeOutput: (...args) => {
const text = args[0]; const data = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text }); if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
}, },
renderSnapshot: (...args) => { renderSnapshot: (...args) => {
const state = args[0]; const state = args[0];
@@ -274,8 +282,11 @@ export default function TerminalEmulator({
useImperativeHandle( useImperativeHandle(
ref, ref,
(): TerminalEmulatorHandle => ({ (): TerminalEmulatorHandle => ({
writeOutput: (text: string) => { writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ text }); runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
}, },
renderSnapshot: (state: TerminalState | null) => { renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state }); runtimeRef.current?.renderSnapshot({ state });
+21 -2
View File
@@ -25,7 +25,9 @@ import {
TerminalStreamController, TerminalStreamController,
type TerminalStreamControllerStatus, type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller"; } from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme"; import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -171,6 +173,9 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(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 scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]); const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
@@ -354,11 +359,18 @@ export function TerminalPane({
const controller = new TerminalStreamController({ const controller = new TerminalStreamController({
client, client,
getPreferredSize: () => measuredTerminalSizeRef.current, getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => { onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) { if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return; 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 }) => { onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
@@ -367,6 +379,12 @@ export function TerminalPane({
} }
emulatorRef.current?.renderSnapshot(state); emulatorRef.current?.renderSnapshot(state);
}, },
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
size: measuredTerminalSizeRef.current,
});
},
onStatusChange: handleStreamControllerStatus, onStatusChange: handleStreamControllerStatus,
}); });
@@ -386,6 +404,7 @@ export function TerminalPane({
handleStreamControllerStatus, handleStreamControllerStatus,
isConnected, isConnected,
isWorkspaceFocused, isWorkspaceFocused,
supportsTerminalRestoreModes,
workspaceTerminalSession.snapshots, workspaceTerminalSession.snapshots,
]); ]);
@@ -1,7 +1,7 @@
import { page } from "@vitest/browser/context"; import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; 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", () => ({ vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class WebglAddon { 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> { async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise<void> {
const startedAt = performance.now(); const startedAt = performance.now();
const timeoutMs = input.timeoutMs ?? 2_000; const timeoutMs = input.timeoutMs ?? 2_000;
@@ -253,7 +257,7 @@ describe("terminal emulator runtime in a real browser", () => {
expect(mounted.terminalKeys).toEqual([]); expect(mounted.terminalKeys).toEqual([]);
mounted.runtime.write({ text: "\x1b[>7u" }); mounted.runtime.write({ data: terminalOutput("\x1b[>7u") });
await waitFor({ await waitFor({
predicate: () => predicate: () =>
mounted.inputModeChanges.some( mounted.inputModeChanges.some(
@@ -279,7 +283,7 @@ describe("terminal emulator runtime in a real browser", () => {
]); ]);
mounted.terminalKeys.length = 0; 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({ await waitFor({
predicate: () => predicate: () =>
mounted.inputModeChanges.some( mounted.inputModeChanges.some(
@@ -321,7 +325,7 @@ describe("terminal emulator runtime in a real browser", () => {
await waitFor({ predicate: () => mounted.sizes.length > 0 }); await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.write({ text: bytes }); mounted.runtime.write({ data: terminalOutput(bytes) });
await nextFrame(); await nextFrame();
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 { interface StubTerminal {
write: (text: string, callback?: () => void) => void; write: (data: string | Uint8Array, callback?: () => void) => void;
reset: () => void; reset: () => void;
resize?: (cols: number, rows: number) => void; resize?: (cols: number, rows: number) => void;
focus: () => void; focus: () => void;
@@ -102,8 +102,8 @@ function createRuntimeWithTerminal(): {
let resetCalls = 0; let resetCalls = 0;
const terminal: StubTerminal & { resetCalls: number } = { const terminal: StubTerminal & { resetCalls: number } = {
write: (text: string, callback?: () => void) => { write: (data: string | Uint8Array, callback?: () => void) => {
writeTexts.push(text); writeTexts.push(decodeTerminalOutput(data));
if (callback) { if (callback) {
writeCallbacks.push(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", () => { describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window; const originalWindow = (globalThis as { window?: unknown }).window;
@@ -151,7 +162,7 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = []; const committed: string[] = [];
runtime.write({ runtime.write({
text: "first", data: terminalOutput("first"),
onCommitted: () => { onCommitted: () => {
committed.push("first"); committed.push("first");
}, },
@@ -162,7 +173,7 @@ describe("terminal-emulator-runtime", () => {
}, },
}); });
runtime.write({ runtime.write({
text: "second", data: terminalOutput("second"),
onCommitted: () => { onCommitted: () => {
committed.push("second"); committed.push("second");
}, },
@@ -188,7 +199,7 @@ describe("terminal-emulator-runtime", () => {
const onCommitted = vi.fn(); const onCommitted = vi.fn();
runtime.write({ runtime.write({
text: "stuck", data: terminalOutput("stuck"),
onCommitted, onCommitted,
}); });
@@ -208,7 +219,7 @@ describe("terminal-emulator-runtime", () => {
}, },
}); });
runtime.write({ text: "\x1b[>7u" }); runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.renderSnapshot({ runtime.renderSnapshot({
state: { state: {
rows: 2, rows: 2,
@@ -234,13 +245,13 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = []; const committed: string[] = [];
runtime.write({ runtime.write({
text: "first", data: terminalOutput("first"),
onCommitted: () => { onCommitted: () => {
committed.push("first"); committed.push("first");
}, },
}); });
runtime.write({ runtime.write({
text: "second", data: terminalOutput("second"),
onCommitted: () => { onCommitted: () => {
committed.push("second"); committed.push("second");
}, },
@@ -262,11 +273,11 @@ describe("terminal-emulator-runtime", () => {
const onCommittedB = vi.fn(); const onCommittedB = vi.fn();
runtime.write({ runtime.write({
text: "a", data: terminalOutput("a"),
onCommitted: onCommittedA, onCommitted: onCommittedA,
}); });
runtime.write({ runtime.write({
text: "b", data: terminalOutput("b"),
onCommitted: onCommittedB, onCommitted: onCommittedB,
}); });
@@ -301,6 +312,15 @@ describe("terminal-emulator-runtime", () => {
expect(writeTexts[0]).toContain("hi"); 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", () => { it("forces a refit when resize is requested", () => {
const runtime = new TerminalEmulatorRuntime(); const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn(); const fitAndEmitResize = vi.fn();
@@ -24,6 +24,8 @@ import {
} from "@/utils/terminal-keys"; } from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
export type TerminalOutputData = Uint8Array;
export interface TerminalEmulatorRuntimeMountInput { export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement; root: HTMLDivElement;
host: HTMLDivElement; host: HTMLDivElement;
@@ -67,7 +69,7 @@ interface TerminalEmulatorRuntimeDisposables {
interface TerminalOutputOperation { interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot"; type: "write" | "clear" | "snapshot";
text: string; data: TerminalOutputData;
rows?: number; rows?: number;
cols?: number; cols?: number;
suppressInput?: boolean; suppressInput?: boolean;
@@ -96,7 +98,23 @@ const isAppleHandheld =
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_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 = [ const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs. // Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
@@ -140,6 +158,7 @@ export class TerminalEmulatorRuntime {
private outputOperations: TerminalOutputOperation[] = []; private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null; private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null; private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false; private suppressInput = false;
private readonly inputModeTracker = new TerminalInputModeTracker(); private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState(); private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
@@ -528,14 +547,18 @@ export class TerminalEmulatorRuntime {
}; };
} }
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void { write(input: {
if (input.text.length === 0) { data: TerminalOutputData;
suppressInput?: boolean;
onCommitted?: () => void;
}): void {
if (input.data.length === 0) {
input.onCommitted?.(); input.onCommitted?.();
return; return;
} }
this.outputOperations.push({ this.outputOperations.push({
type: "write", type: "write",
text: input.text, data: input.data,
suppressInput: input.suppressInput ?? false, suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
}); });
@@ -545,7 +568,7 @@ export class TerminalEmulatorRuntime {
clear(input?: { onCommitted?: () => void }): void { clear(input?: { onCommitted?: () => void }): void {
this.outputOperations.push({ this.outputOperations.push({
type: "clear", type: "clear",
text: "", data: EMPTY_TERMINAL_OUTPUT,
suppressInput: false, suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}), ...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
}); });
@@ -557,11 +580,25 @@ export class TerminalEmulatorRuntime {
this.clear(input); this.clear(input);
return; return;
} }
this.outputOperations.push({ this.restoreOutput({
type: "snapshot", data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
text: `${RESET_TERMINAL_ANSI}${renderTerminalSnapshotToAnsi(input.state)}`,
rows: input.state.rows, rows: input.state.rows,
cols: input.state.cols, 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, suppressInput: true,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
}); });
@@ -643,6 +680,7 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize = null; this.fitAndEmitResize = null;
this.lastSize = null; this.lastSize = null;
this.suppressInput = false; this.suppressInput = false;
this.inputModeDecoder.decode();
this.inputModeTracker.reset(); this.inputModeTracker.reset();
this.emitInputModeChange(); this.emitInputModeChange();
} }
@@ -664,7 +702,7 @@ export class TerminalEmulatorRuntime {
this.inFlightOutputOperation = operation; this.inFlightOutputOperation = operation;
const previousSuppressInput = this.suppressInput; const previousSuppressInput = this.suppressInput;
if (operation.type === "write") { if (operation.suppressInput) {
this.suppressInput = Boolean(operation.suppressInput); this.suppressInput = Boolean(operation.suppressInput);
} }
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => { const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
@@ -679,6 +717,7 @@ export class TerminalEmulatorRuntime {
}; };
if (operation.type === "clear") { if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset(); this.inputModeTracker.reset();
this.emitInputModeChange(); this.emitInputModeChange();
terminal.reset(); terminal.reset();
@@ -687,6 +726,7 @@ export class TerminalEmulatorRuntime {
} }
if (operation.type === "snapshot") { if (operation.type === "snapshot") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset(); this.inputModeTracker.reset();
this.emitInputModeChange(); this.emitInputModeChange();
try { try {
@@ -703,8 +743,9 @@ export class TerminalEmulatorRuntime {
} }
} }
const text = operation.text; const data = operation.data;
if (operation.type === "write") { if (operation.type === "write") {
const text = this.inputModeDecoder.decode(data, { stream: true });
const result = this.inputModeTracker.feed(text); const result = this.inputModeTracker.feed(text);
if (result.changed) { if (result.changed) {
this.emitInputModeChange(); this.emitInputModeChange();
@@ -715,7 +756,7 @@ export class TerminalEmulatorRuntime {
}, OUTPUT_OPERATION_TIMEOUT_MS); }, OUTPUT_OPERATION_TIMEOUT_MS);
try { try {
terminal.write(text, () => { terminal.write(data, () => {
finalizeOperation(operation); finalizeOperation(operation);
}); });
} catch { } 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"; export { renderTerminalSnapshotToAnsi } from "@server/shared/terminal-snapshot";
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 [];
}
@@ -14,19 +14,36 @@ interface TerminalSnapshot {
cursor: { row: number; col: number }; 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 = type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array } | { 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 { class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: string[] = []; public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = [];
public unsubscribeCalls: string[] = []; public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = []; public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = []; public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
async subscribeTerminal(terminalId: string) { async subscribeTerminal(terminalId: string, options?: unknown) {
this.subscribeCalls.push(terminalId); this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) });
const result = this.nextSubscribeResults.shift(); const result = this.nextSubscribeResults.shift();
if (!result) { if (!result) {
throw new Error("Missing fake subscribe result"); throw new Error("Missing fake subscribe result");
@@ -61,7 +78,8 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) { function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new 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 snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = []; const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({ const controller = new TerminalStreamController({
@@ -70,10 +88,13 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
onOutput: (output) => { onOutput: (output) => {
outputs.push(output); outputs.push(output);
}, },
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => { onSnapshot: ({ terminalId, state }) => {
snapshots.push({ snapshots.push({
terminalId, terminalId,
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"), text: terminalSnapshotText(state),
}); });
}, },
onStatusChange: (status) => { 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> { async function flushAsyncWork(): Promise<void> {
@@ -108,16 +129,18 @@ describe("terminal-stream-controller", () => {
cursor: { row: 0, col: 5 }, cursor: { row: 0, col: 5 },
}, },
}); });
const outputData = terminalOutput(" world");
harness.client.emit({ harness.client.emit({
terminalId: "term-1", terminalId: "term-1",
type: "output", 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.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]); 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({ expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1", terminalId: "term-1",
isAttaching: false, isAttaching: false,
@@ -135,7 +158,7 @@ describe("terminal-stream-controller", () => {
harness.controller.setTerminal({ terminalId: "term-1" }); harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork(); await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]); expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({ expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1", terminalId: "term-1",
isAttaching: false, isAttaching: false,
@@ -152,7 +175,7 @@ describe("terminal-stream-controller", () => {
harness.controller.handleTerminalExit({ terminalId: "term-1" }); harness.controller.handleTerminalExit({ terminalId: "term-1" });
await flushAsyncWork(); await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]); expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({ expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1", terminalId: "term-1",
isAttaching: false, 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 () => { it("unsubscribes when switching terminals and on dispose", async () => {
const harness = createHarness(); const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); 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 { export interface TerminalStreamControllerClient {
subscribeTerminal: (terminalId: string) => Promise<{ subscribeTerminal: (
terminalId: string,
options?: { restore?: SubscribeTerminalRequest["restore"] },
) => Promise<{
terminalId: string; terminalId: string;
error?: string | null; error?: string | null;
}>; }>;
@@ -14,7 +18,8 @@ export interface TerminalStreamControllerClient {
handler: ( handler: (
event: event:
| { terminalId: string; type: "output"; data: Uint8Array } | { 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,
) => () => void; ) => () => void;
} }
@@ -33,15 +38,16 @@ export interface TerminalStreamControllerStatus {
export interface TerminalStreamControllerOptions { export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient; client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null; getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; text: string }) => void; onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void; onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void; onStatusChange?: (status: TerminalStreamControllerStatus) => void;
} }
const TERMINAL_EXITED_ERROR = "Terminal exited"; const TERMINAL_EXITED_ERROR = "Terminal exited";
export class TerminalStreamController { export class TerminalStreamController {
private readonly decoder = new TextDecoder();
private readonly unsubscribeStreamEvents: () => void; private readonly unsubscribeStreamEvents: () => void;
private terminalId: string | null = null; private terminalId: string | null = null;
private disposed = false; private disposed = false;
@@ -52,13 +58,17 @@ export class TerminalStreamController {
return; return;
} }
if (event.type === "snapshot") { if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return; return;
} }
const text = this.decoder.decode(event.data, { stream: true }); if (event.type === "restore") {
if (text.length > 0) { if (event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, text }); 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 nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId; const previousTerminalId = this.terminalId;
this.terminalId = nextTerminalId; this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) { if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId); this.options.client.unsubscribeTerminal(previousTerminalId);
} }
@@ -78,9 +87,10 @@ export class TerminalStreamController {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return; return;
} }
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null }); this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
void this.options.client void this.options.client
.subscribeTerminal(nextTerminalId) .subscribeTerminal(nextTerminalId, restore ? { restore } : undefined)
.then((payload) => { .then((payload) => {
if (this.disposed || this.terminalId !== nextTerminalId) { if (this.disposed || this.terminalId !== nextTerminalId) {
return; return;
@@ -126,7 +136,6 @@ export class TerminalStreamController {
if (this.disposed || input.terminalId !== this.terminalId) { if (this.disposed || input.terminalId !== this.terminalId) {
return; return;
} }
this.decoder.decode();
this.terminalId = null; this.terminalId = null;
this.options.onStatusChange?.({ this.options.onStatusChange?.({
terminalId: input.terminalId, terminalId: input.terminalId,
@@ -140,7 +149,6 @@ export class TerminalStreamController {
return; return;
} }
this.disposed = true; this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId; const terminalId = this.terminalId;
this.terminalId = null; this.terminalId = null;
if (terminalId) { if (terminalId) {
@@ -3,7 +3,10 @@ import xtermCss from "@xterm/xterm/css/xterm.css";
import type { TerminalState } from "@server/shared/messages"; import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "@/utils/terminal-keys"; import type { PendingTerminalModifiers } from "@/utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../runtime/terminal-emulator-runtime"; import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
} from "../runtime/terminal-emulator-runtime";
interface MountMessage { interface MountMessage {
type: "mount"; type: "mount";
@@ -19,6 +22,7 @@ type InboundMessage =
| MountMessage | MountMessage
| { type: "unmount"; streamKey: string } | { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string } | { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string } | { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string } | { type: "focus"; streamKey: string }
@@ -154,7 +158,10 @@ class TerminalWebViewBridge {
): void { ): void {
switch (message.type) { switch (message.type) {
case "writeOutput": 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; break;
case "renderSnapshot": case "renderSnapshot":
this.runtime?.renderSnapshot({ state: message.state }); 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, ListTerminalsResponse,
CreateTerminalResponse, CreateTerminalResponse,
SubscribeTerminalResponse, SubscribeTerminalResponse,
SubscribeTerminalRequest,
CloseItemsResponse, CloseItemsResponse,
KillTerminalResponse, KillTerminalResponse,
CaptureTerminalResponse, CaptureTerminalResponse,
@@ -3770,13 +3771,19 @@ export class DaemonClient {
async subscribeTerminal( async subscribeTerminal(
terminalId: string, terminalId: string,
requestId?: string, optionsOrRequestId?:
| { restore?: SubscribeTerminalRequest["restore"]; requestId?: string }
| string,
): Promise<SubscribeTerminalPayload> { ): Promise<SubscribeTerminalPayload> {
const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined;
const requestId =
typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId;
const resolvedRequestId = this.createRequestId(requestId); const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "subscribe_terminal_request", type: "subscribe_terminal_request",
terminalId, terminalId,
requestId: resolvedRequestId, requestId: resolvedRequestId,
...(restore ? { restore } : {}),
}); });
const payload = await this.sendCorrelatedRequest({ const payload = await this.sendCorrelatedRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
@@ -4363,6 +4370,8 @@ export class DaemonClient {
frameKind = "output"; frameKind = "output";
} else if (frame.opcode === TerminalStreamOpcode.Snapshot) { } else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
frameKind = "snapshot"; frameKind = "snapshot";
} else if (frame.opcode === TerminalStreamOpcode.Restore) {
frameKind = "output";
} }
this.runtimeMetrics?.recordBinaryFrame( this.runtimeMetrics?.recordBinaryFrame(
frameKind, 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 = export type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array } | { 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 { export class TerminalStreamRouter {
private readonly terminalSlots = new Map<string, number>(); private readonly terminalSlots = new Map<string, number>();
@@ -97,6 +98,15 @@ export class TerminalStreamRouter {
return; return;
} }
if (frame.opcode === TerminalStreamOpcode.Restore) {
this.emit({
terminalId,
type: "restore",
data: frame.payload,
});
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) { if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload); const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) { if (!state) {
@@ -650,6 +650,7 @@ test("receives server_info on websocket connect", async () => {
const serverInfo = client.getLastServerInfoMessage(); const serverInfo = client.getLastServerInfoMessage();
expect(serverInfo).not.toBeNull(); expect(serverInfo).not.toBeNull();
expect(serverInfo?.serverId.length).toBeGreaterThan(0); expect(serverInfo?.serverId.length).toBeGreaterThan(0);
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
await client.close(); await client.close();
}, 15000); }, 15000);
@@ -46,13 +46,13 @@ function extractStateText(state: Pick<TerminalState, "grid" | "scrollback">): st
} }
async function waitForCondition( async function waitForCondition(
predicate: () => boolean, predicate: () => boolean | Promise<boolean>,
timeoutMs: number, timeoutMs: number,
intervalMs = 25, intervalMs = 25,
): Promise<void> { ): Promise<void> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
if (predicate()) { if (await predicate()) {
return; return;
} }
await new Promise((resolve) => setTimeout(resolve, intervalMs)); await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -377,6 +377,9 @@ function getFrameText(frame: TerminalStreamFrame): string {
} }
return extractStateText(state); return extractStateText(state);
} }
if (frame.opcode === TerminalStreamOpcode.Restore) {
return new TextDecoder().decode(frame.payload);
}
return ""; return "";
} }
@@ -672,6 +675,11 @@ async function subscribeRawTerminal(
ws: WebSocket, ws: WebSocket,
terminalId: string, terminalId: string,
requestId: string, requestId: string,
restore?: {
mode: "live" | "visible-snapshot" | "full-snapshot";
scrollbackLines?: number;
size?: { rows: number; cols: number };
},
): Promise<number> { ): Promise<number> {
const ready = waitForRawSessionMessage( const ready = waitForRawSessionMessage(
ws, ws,
@@ -688,6 +696,7 @@ async function subscribeRawTerminal(
type: "subscribe_terminal_request", type: "subscribe_terminal_request",
terminalId, terminalId,
requestId, 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 }); rmSync(cwd, { recursive: true, force: true });
}, 30000); }, 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 () => { test("propagates debounced terminal titles through list responses and snapshots", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, undefined, undefined, { 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)); await new Promise((resolve) => setTimeout(resolve, 750));
rawSocket!.resume(); const catchUpFramePromise = waitForRawBinaryFrame(
const catchUpFrame = await waitForRawBinaryFrame(
ws, ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
15000, 15000,
); );
rawSocket!.resume();
const catchUpFrame = await catchUpFramePromise;
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
await closeWebSocket(ws); await closeWebSocket(ws);
@@ -1081,6 +1081,8 @@ export class VoiceAssistantWebSocketServer {
checkoutGithubSetAutoMerge: true, checkoutGithubSetAutoMerge: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true, 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, Input: 0x02,
Resize: 0x03, Resize: 0x03,
Snapshot: 0x04, Snapshot: 0x04,
Restore: 0x05,
} as const; } as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode]; export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
@@ -57,7 +58,8 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Output || value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.Input || value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize || 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"), type: z.literal("subscribe_terminal_request"),
terminalId: z.string(), terminalId: z.string(),
requestId: 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({ export const UnsubscribeTerminalRequestSchema = z.object({
@@ -2079,6 +2093,8 @@ export const ServerInfoStatusPayloadSchema = z
checkoutGithubSetAutoMerge: z.boolean().optional(), checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(), daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
}) })
.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 { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { resolve, sep, win32, posix } from "node:path"; import { resolve, sep, win32, posix } from "node:path";
@@ -29,7 +34,10 @@ export interface TerminalManager {
}): Promise<TerminalSession>; }): Promise<TerminalSession>;
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void; registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined; 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; setTerminalTitle(id: string, title: string): boolean;
killTerminal(id: string): void; killTerminal(id: string): void;
killTerminalAndWait( killTerminalAndWait(
@@ -208,8 +216,11 @@ export function createTerminalManager(): TerminalManager {
return terminalsById.get(id); return terminalsById.get(id);
}, },
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> { async getTerminalState(
return terminalsById.get(id)?.getStateSnapshot() ?? null; id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
}, },
setTerminalTitle(id: string, title: string): boolean { 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 { import {
TerminalStreamOpcode, TerminalStreamOpcode,
decodeTerminalResizePayload, decodeTerminalResizePayload,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame, encodeTerminalStreamFrame,
type TerminalStreamFrame, type TerminalStreamFrame,
} from "../shared/binary-frames/index.js"; } from "../shared/binary-frames/index.js";
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.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 { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
@@ -38,10 +46,18 @@ interface ActiveTerminalStream {
unsubscribe: () => void; unsubscribe: () => void;
needsSnapshot: boolean; needsSnapshot: boolean;
snapshotInFlight: boolean; snapshotInFlight: boolean;
readyRevision?: number;
restore?: TerminalRestoreOptions;
bufferedOutputs: BufferedTerminalOutput[]; bufferedOutputs: BufferedTerminalOutput[];
outputBytesSinceSnapshot: number;
outputCoalescer: TerminalOutputCoalescer; outputCoalescer: TerminalOutputCoalescer;
} }
interface SnapshotSendResult {
shouldContinue: boolean;
replayRevision?: number;
}
export interface TerminalSessionControllerOptions { export interface TerminalSessionControllerOptions {
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
emit: (msg: SessionOutboundMessage) => void; emit: (msg: SessionOutboundMessage) => void;
@@ -488,7 +504,21 @@ export class TerminalSessionController {
} }
this.ensureExitSubscription(session); 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) { if (slot === null) {
this.sessionLogger.warn( 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()) { if (!this.hasBinaryChannel()) {
return null; return null;
} }
@@ -638,6 +671,7 @@ export class TerminalSessionController {
const existingStream = this.activeStreams.get(existingSlot); const existingStream = this.activeStreams.get(existingSlot);
if (existingStream) { if (existingStream) {
existingStream.needsSnapshot = true; existingStream.needsSnapshot = true;
existingStream.restore = options?.restore;
return existingSlot; return existingSlot;
} }
this.idToSlot.delete(terminal.id); this.idToSlot.delete(terminal.id);
@@ -654,13 +688,23 @@ export class TerminalSessionController {
unsubscribe: () => {}, unsubscribe: () => {},
needsSnapshot: true, needsSnapshot: true,
snapshotInFlight: false, snapshotInFlight: false,
readyRevision: undefined,
restore: options?.restore,
bufferedOutputs: [], bufferedOutputs: [],
outputBytesSinceSnapshot: 0,
outputCoalescer: new TerminalOutputCoalescer({ outputCoalescer: new TerminalOutputCoalescer({
timers: { setTimeout, clearTimeout }, timers: { setTimeout, clearTimeout },
onFlush: ({ payload }) => { onFlush: ({ payload }) => {
if (this.activeStreams.get(slot) !== activeStream) { if (this.activeStreams.get(slot) !== activeStream) {
return; 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( this.emitBinary(
encodeTerminalStreamFrame({ encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output, opcode: TerminalStreamOpcode.Output,
@@ -675,31 +719,35 @@ export class TerminalSessionController {
this.activeStreams.set(slot, activeStream); this.activeStreams.set(slot, activeStream);
this.idToSlot.set(terminal.id, slot); this.idToSlot.set(terminal.id, slot);
activeStream.unsubscribe = terminal.subscribe((message) => { activeStream.unsubscribe = terminal.subscribe(
if (this.activeStreams.get(slot) !== activeStream) { (message) => {
return; if (this.activeStreams.get(slot) !== activeStream) {
} return;
if (message.type === "snapshot") { }
activeStream.outputCoalescer.flush(); if (message.type === "snapshot" || message.type === "snapshotReady") {
activeStream.needsSnapshot = true; activeStream.readyRevision = message.revision;
void this.trySendSnapshot(activeStream); activeStream.outputCoalescer.flush();
return; activeStream.needsSnapshot = true;
} void this.trySendSnapshot(activeStream);
if (message.type === "titleChange") { return;
return; }
} if (message.type === "titleChange") {
if (message.data.length === 0) { return;
return; }
} if (message.data.length === 0) {
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) { return;
activeStream.bufferedOutputs.push({ }
data: message.data, if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
revision: message.revision, activeStream.bufferedOutputs.push({
}); data: message.data,
return; revision: message.revision,
} });
activeStream.outputCoalescer.handle(message.data); return;
}); }
activeStream.outputCoalescer.handle(message.data);
},
{ initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) },
);
return slot; return slot;
} }
@@ -722,43 +770,23 @@ export class TerminalSessionController {
this.detachStream(activeStream.terminalId, { emitExit: true }); this.detachStream(activeStream.terminalId, { emitExit: true });
return; return;
} }
if (activeStream.restore && activeStream.readyRevision === undefined) {
return;
}
activeStream.outputCoalescer.flush(); activeStream.outputCoalescer.flush();
activeStream.snapshotInFlight = true; activeStream.snapshotInFlight = true;
try { try {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId); const restore = activeStream.restore;
if (this.activeStreams.get(activeStream.slot) !== activeStream) { const snapshotResult = restore
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
: await this.emitLegacySnapshot(activeStream, terminalManager);
if (!snapshotResult.shouldContinue) {
return; return;
} }
if (!snapshot) { this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision);
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);
}
activeStream.needsSnapshot = false; activeStream.needsSnapshot = false;
activeStream.outputBytesSinceSnapshot = 0;
} catch (error) { } catch (error) {
this.sessionLogger.warn( this.sessionLogger.warn(
{ err: error, terminalId: activeStream.terminalId }, { 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 { private allocateSlot(): number | null {
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) { for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
const slot = (this.nextSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS; const slot = (this.nextSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
@@ -176,7 +176,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
type: "response", type: "response",
requestId: message.requestId, requestId: message.requestId,
ok: true, ok: true,
result: session?.getStateSnapshot() ?? null, result: session?.getStateSnapshot(message.options) ?? null,
}); });
return; return;
} }
@@ -3,6 +3,7 @@ import type {
ServerMessage, ServerMessage,
ClientMessage, ClientMessage,
TerminalStateSnapshot, TerminalStateSnapshot,
TerminalStateSnapshotOptions,
} from "./terminal.js"; } from "./terminal.js";
import type { TerminalState } from "../shared/messages.js"; import type { TerminalState } from "../shared/messages.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -61,6 +62,7 @@ export type TerminalWorkerRequest =
type: "getTerminalState"; type: "getTerminalState";
requestId: string; requestId: string;
terminalId: string; terminalId: string;
options?: TerminalStateSnapshotOptions;
} }
| { | {
type: "captureTerminal"; type: "captureTerminal";
+36 -10
View File
@@ -33,6 +33,14 @@ export interface TerminalStateSnapshot {
revision: number; revision: number;
} }
export interface TerminalStateSnapshotOptions {
scrollbackLines?: number;
}
export interface TerminalSubscribeOptions {
initialSnapshot?: "state" | "ready";
}
export type ClientMessage = export type ClientMessage =
| { type: "input"; data: string } | { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number } | { type: "resize"; rows: number; cols: number }
@@ -41,6 +49,7 @@ export type ClientMessage =
export type ServerMessage = export type ServerMessage =
| { type: "output"; data: string; revision?: number } | { type: "output"; data: string; revision?: number }
| { type: "snapshot"; state: TerminalState; revision?: number } | { type: "snapshot"; state: TerminalState; revision?: number }
| { type: "snapshotReady"; revision?: number }
| { type: "titleChange"; title?: string }; | { type: "titleChange"; title?: string };
export interface TerminalSession { export interface TerminalSession {
@@ -48,13 +57,13 @@ export interface TerminalSession {
name: string; name: string;
cwd: string; cwd: string;
send(msg: ClientMessage): void; send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void; subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void; onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void; onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number }; getSize(): { rows: number; cols: number };
getState(): TerminalState; getState(): TerminalState;
getStateSnapshot(): TerminalStateSnapshot; getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
getReplayPreamble(): string; getReplayPreamble(): string;
getTitle(): string | undefined; getTitle(): string | undefined;
setTitle(title: string): void; setTitle(title: string): void;
@@ -292,14 +301,21 @@ function extractGrid(terminal: TerminalType): TerminalCell[][] {
return grid; return grid;
} }
function extractScrollback(terminal: TerminalType): TerminalCell[][] { function extractScrollback(
terminal: TerminalType,
options?: { scrollbackLines?: number },
): TerminalCell[][] {
const scrollback: TerminalCell[][] = []; const scrollback: TerminalCell[][] = [];
const buffer = terminal.buffer.active; const buffer = terminal.buffer.active;
// baseY is the first row of the visible viewport (0-indexed) // baseY is the first row of the visible viewport (0-indexed)
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible // Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
const scrollbackLines = buffer.baseY; 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 rowCells: TerminalCell[] = [];
const line = buffer.getLine(row); const line = buffer.getLine(row);
for (let col = 0; col < terminal.cols; col++) { 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 { return {
rows: terminal.rows, rows: terminal.rows,
cols: terminal.cols, cols: terminal.cols,
grid: extractGrid(terminal), grid: extractGrid(terminal),
scrollback: extractScrollback(terminal), scrollback: extractScrollback(terminal, {
scrollbackLines: snapshotOptions?.scrollbackLines,
}),
cursor: extractCursorState(terminal), cursor: extractCursorState(terminal),
...(title ? { title } : {}), ...(title ? { title } : {}),
}; };
} }
function getStateSnapshot(): TerminalStateSnapshot { function getStateSnapshot(snapshotOptions?: TerminalStateSnapshotOptions): TerminalStateSnapshot {
return { return {
state: getState(), state: getState(snapshotOptions),
revision: stateRevision, 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 active = true;
let snapshotDelivered = false; let snapshotDelivered = false;
const queuedMessages: ServerMessage[] = []; const queuedMessages: ServerMessage[] = [];
const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state";
const subscriptionListener = (msg: ServerMessage): void => { const subscriptionListener = (msg: ServerMessage): void => {
if (!active) { if (!active) {
return; return;
@@ -911,7 +933,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
terminal.write("", () => { terminal.write("", () => {
if (!disposed && active && listeners.has(subscriptionListener)) { if (!disposed && active && listeners.has(subscriptionListener)) {
snapshotDelivered = true; 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)) { for (const message of queuedMessages.splice(0)) {
listener(message); listener(message);
} }
@@ -200,8 +200,18 @@ export function createWorkerTerminalManager(
} }
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message }); 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); record.messageListeners.add(listener);
if (options?.initialSnapshot === "ready") {
queueMicrotask(() => {
if (record.messageListeners.has(listener)) {
listener({ type: "snapshotReady", revision: 0 });
}
});
}
return () => { return () => {
record.messageListeners.delete(listener); record.messageListeners.delete(listener);
}; };
@@ -244,9 +254,14 @@ export function createWorkerTerminalManager(
getState(): TerminalState { getState(): TerminalState {
return record.state; 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 { return {
state: record.state, state: { ...record.state, scrollback },
revision: 0, revision: 0,
}; };
}, },
@@ -539,10 +554,14 @@ export function createWorkerTerminalManager(
return recordsById.get(id)?.session; 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({ return (await sendRequest({
type: "getTerminalState", type: "getTerminalState",
terminalId: id, terminalId: id,
...(options ? { options } : {}),
})) as TerminalWorkerStateResult; })) as TerminalWorkerStateResult;
}, },