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