Make mobile terminals load faster (#1147)

* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
This commit is contained in:
Mohamed Boudra
2026-05-23 18:41:10 +08:00
committed by GitHub
parent 84f1dfc8cb
commit 846c9b9da3
32 changed files with 1304 additions and 357 deletions
@@ -13,13 +13,15 @@ import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -64,6 +66,7 @@ type BridgeInboundMessage =
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
@@ -158,6 +161,7 @@ export default function TerminalEmulator({
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -272,19 +276,37 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
sendToWebView({ type: "writeOutput", streamKey, text });
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
}),
[sendToWebView, streamKey],
);
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
@@ -19,7 +19,10 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -29,7 +32,8 @@ import {
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -254,8 +258,12 @@ export default function TerminalEmulator({
domBridgeRef,
(): DOMImperativeFactory => ({
writeOutput: (...args) => {
const text = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text });
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
@@ -274,8 +282,11 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
+21 -2
View File
@@ -25,7 +25,9 @@ import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -171,6 +173,9 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
@@ -354,11 +359,18 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => {
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
emulatorRef.current?.writeOutput(data);
},
onRestore: ({ terminalId: restoreTerminalId, data }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (!isWorkspaceFocused || terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
@@ -367,6 +379,12 @@ export function TerminalPane({
}
emulatorRef.current?.renderSnapshot(state);
},
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
size: measuredTerminalSizeRef.current,
});
},
onStatusChange: handleStreamControllerStatus,
});
@@ -386,6 +404,7 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isWorkspaceFocused,
supportsTerminalRestoreModes,
workspaceTerminalSession.snapshots,
]);
@@ -1,7 +1,7 @@
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class WebglAddon {
@@ -49,6 +49,10 @@ function nextFrame(): Promise<void> {
});
}
function terminalOutput(text: string): Uint8Array {
return encodeTerminalOutput(text);
}
async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise<void> {
const startedAt = performance.now();
const timeoutMs = input.timeoutMs ?? 2_000;
@@ -253,7 +257,7 @@ describe("terminal emulator runtime in a real browser", () => {
expect(mounted.terminalKeys).toEqual([]);
mounted.runtime.write({ text: "\x1b[>7u" });
mounted.runtime.write({ data: terminalOutput("\x1b[>7u") });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -279,7 +283,7 @@ describe("terminal emulator runtime in a real browser", () => {
]);
mounted.terminalKeys.length = 0;
mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" });
mounted.runtime.write({ data: terminalOutput("\x1b[=0;0u\x1b[?9001h") });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -321,7 +325,7 @@ describe("terminal emulator runtime in a real browser", () => {
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.write({ text: bytes });
mounted.runtime.write({ data: terminalOutput(bytes) });
await nextFrame();
await nextFrame();
@@ -75,10 +75,10 @@ vi.mock("@xterm/xterm", () => ({
},
}));
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
interface StubTerminal {
write: (text: string, callback?: () => void) => void;
write: (data: string | Uint8Array, callback?: () => void) => void;
reset: () => void;
resize?: (cols: number, rows: number) => void;
focus: () => void;
@@ -102,8 +102,8 @@ function createRuntimeWithTerminal(): {
let resetCalls = 0;
const terminal: StubTerminal & { resetCalls: number } = {
write: (text: string, callback?: () => void) => {
writeTexts.push(text);
write: (data: string | Uint8Array, callback?: () => void) => {
writeTexts.push(decodeTerminalOutput(data));
if (callback) {
writeCallbacks.push(callback);
}
@@ -131,6 +131,17 @@ function createRuntimeWithTerminal(): {
};
}
function terminalOutput(text: string): Uint8Array {
return encodeTerminalOutput(text);
}
function decodeTerminalOutput(data: string | Uint8Array): string {
if (typeof data === "string") {
return data;
}
return new TextDecoder().decode(data);
}
describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window;
@@ -151,7 +162,7 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
text: "first",
data: terminalOutput("first"),
onCommitted: () => {
committed.push("first");
},
@@ -162,7 +173,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({
text: "second",
data: terminalOutput("second"),
onCommitted: () => {
committed.push("second");
},
@@ -188,7 +199,7 @@ describe("terminal-emulator-runtime", () => {
const onCommitted = vi.fn();
runtime.write({
text: "stuck",
data: terminalOutput("stuck"),
onCommitted,
});
@@ -208,7 +219,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({ text: "\x1b[>7u" });
runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.renderSnapshot({
state: {
rows: 2,
@@ -234,13 +245,13 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
text: "first",
data: terminalOutput("first"),
onCommitted: () => {
committed.push("first");
},
});
runtime.write({
text: "second",
data: terminalOutput("second"),
onCommitted: () => {
committed.push("second");
},
@@ -262,11 +273,11 @@ describe("terminal-emulator-runtime", () => {
const onCommittedB = vi.fn();
runtime.write({
text: "a",
data: terminalOutput("a"),
onCommitted: onCommittedA,
});
runtime.write({
text: "b",
data: terminalOutput("b"),
onCommitted: onCommittedB,
});
@@ -301,6 +312,15 @@ describe("terminal-emulator-runtime", () => {
expect(writeTexts[0]).toContain("hi");
});
it("restores server-rendered ANSI snapshots through the snapshot write path", () => {
const { runtime, terminal, writeTexts } = createRuntimeWithTerminal();
runtime.restoreOutput({ data: terminalOutput("restored screen") });
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toEqual(["\u001bcrestored screen"]);
});
it("forces a refit when resize is requested", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
@@ -24,6 +24,8 @@ import {
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
export type TerminalOutputData = Uint8Array;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
@@ -67,7 +69,7 @@ interface TerminalEmulatorRuntimeDisposables {
interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot";
text: string;
data: TerminalOutputData;
rows?: number;
cols?: number;
suppressInput?: boolean;
@@ -96,7 +98,23 @@ const isAppleHandheld =
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const RESET_TERMINAL_ANSI = "\u001bc";
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
const terminalOutputEncoder = new TextEncoder();
export function encodeTerminalOutput(text: string): TerminalOutputData {
return terminalOutputEncoder.encode(text);
}
function prependTerminalOutput(
prefix: TerminalOutputData,
data: TerminalOutputData,
): TerminalOutputData {
const output = new Uint8Array(prefix.length + data.length);
output.set(prefix, 0);
output.set(data, prefix.length);
return output;
}
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
@@ -140,6 +158,7 @@ export class TerminalEmulatorRuntime {
private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
@@ -528,14 +547,18 @@ export class TerminalEmulatorRuntime {
};
}
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
if (input.text.length === 0) {
write(input: {
data: TerminalOutputData;
suppressInput?: boolean;
onCommitted?: () => void;
}): void {
if (input.data.length === 0) {
input.onCommitted?.();
return;
}
this.outputOperations.push({
type: "write",
text: input.text,
data: input.data,
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -545,7 +568,7 @@ export class TerminalEmulatorRuntime {
clear(input?: { onCommitted?: () => void }): void {
this.outputOperations.push({
type: "clear",
text: "",
data: EMPTY_TERMINAL_OUTPUT,
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -557,11 +580,25 @@ export class TerminalEmulatorRuntime {
this.clear(input);
return;
}
this.outputOperations.push({
type: "snapshot",
text: `${RESET_TERMINAL_ANSI}${renderTerminalSnapshotToAnsi(input.state)}`,
this.restoreOutput({
data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
rows: input.state.rows,
cols: input.state.cols,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
}
restoreOutput(input: {
data: TerminalOutputData;
rows?: number;
cols?: number;
onCommitted?: () => void;
}): void {
this.outputOperations.push({
type: "snapshot",
data: prependTerminalOutput(RESET_TERMINAL_OUTPUT, input.data),
rows: input.rows,
cols: input.cols,
suppressInput: true,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -643,6 +680,7 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
}
@@ -664,7 +702,7 @@ export class TerminalEmulatorRuntime {
this.inFlightOutputOperation = operation;
const previousSuppressInput = this.suppressInput;
if (operation.type === "write") {
if (operation.suppressInput) {
this.suppressInput = Boolean(operation.suppressInput);
}
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
@@ -679,6 +717,7 @@ export class TerminalEmulatorRuntime {
};
if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
@@ -687,6 +726,7 @@ export class TerminalEmulatorRuntime {
}
if (operation.type === "snapshot") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
try {
@@ -703,8 +743,9 @@ export class TerminalEmulatorRuntime {
}
}
const text = operation.text;
const data = operation.data;
if (operation.type === "write") {
const text = this.inputModeDecoder.decode(data, { stream: true });
const result = this.inputModeTracker.feed(text);
if (result.changed) {
this.emitInputModeChange();
@@ -715,7 +756,7 @@ export class TerminalEmulatorRuntime {
}, OUTPUT_OPERATION_TIMEOUT_MS);
try {
terminal.write(text, () => {
terminal.write(data, () => {
finalizeOperation(operation);
});
} catch {
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: false,
size: { rows: 24, cols: 80 },
}),
).toBeUndefined();
});
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
});
});
it("omits size until the terminal has been measured", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
size: null,
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
});
});
});
@@ -0,0 +1,22 @@
import type { SubscribeTerminalRequest } from "@server/shared/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean;
size: { rows: number; cols: number } | null;
}
export function resolveTerminalRestoreOptions(
input: ResolveTerminalRestoreOptionsInput,
): SubscribeTerminalRequest["restore"] | undefined {
if (!input.supportsTerminalRestoreModes) {
return undefined;
}
return {
mode: "visible-snapshot",
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
...(input.size ? { size: input.size } : {}),
};
}
@@ -1,205 +1 @@
import type { TerminalCell, TerminalState } from "@server/shared/messages";
interface TerminalStyle {
fg: number | undefined;
bg: number | undefined;
fgMode: number | undefined;
bgMode: number | undefined;
bold: boolean;
italic: boolean;
underline: boolean;
dim: boolean;
inverse: boolean;
strikethrough: boolean;
}
const DEFAULT_STYLE: TerminalStyle = {
fg: undefined,
bg: undefined,
fgMode: undefined,
bgMode: undefined,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
strikethrough: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
lines.push("\r\n");
}
}
lines.push("\u001b[0m");
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
if (cursorPresentationAnsi) {
lines.push(cursorPresentationAnsi);
}
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
lines.push("\u001b[?7h");
return lines.join("");
}
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
if (!cursor.style) {
return null;
}
const cursorStyleCode = (() => {
if (cursor.style === "block") {
return cursor.blink === false ? 2 : 1;
}
if (cursor.style === "underline") {
return cursor.blink === false ? 4 : 3;
}
return cursor.blink === false ? 6 : 5;
})();
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
output.push("\u001b[0m");
}
return output.join("");
}
function getTerminalRowLength(row: TerminalCell[]): number {
for (let index = row.length - 1; index >= 0; index -= 1) {
const cell = row[index];
if (!cell) {
continue;
}
if (cell.char !== " ") {
return index + 1;
}
if (
cell.fg !== undefined ||
cell.bg !== undefined ||
cell.fgMode !== undefined ||
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline ||
cell.dim ||
cell.inverse ||
cell.strikethrough
) {
return index + 1;
}
}
return 0;
}
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
return {
fg: cell.fg,
bg: cell.bg,
fgMode: cell.fgMode,
bgMode: cell.bgMode,
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
dim: Boolean(cell.dim),
inverse: Boolean(cell.inverse),
strikethrough: Boolean(cell.strikethrough),
};
}
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
return (
left.fg === right.fg &&
left.bg === right.bg &&
left.fgMode === right.fgMode &&
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline &&
left.dim === right.dim &&
left.inverse === right.inverse &&
left.strikethrough === right.strikethrough
);
}
function styleToAnsi(style: TerminalStyle): string {
const codes = ["0"];
if (style.bold) {
codes.push("1");
}
if (style.dim) {
codes.push("2");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.inverse) {
codes.push("7");
}
if (style.strikethrough) {
codes.push("9");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));
}
if (style.bg !== undefined && style.bgMode !== undefined) {
codes.push(...colorToSgr(style.bgMode, style.bg, true));
}
return `\u001b[${codes.join(";")}m`;
}
function colorToSgr(mode: number, value: number, background: boolean): string[] {
if (mode === 1) {
if (value >= 8) {
return [String((background ? 100 : 90) + (value - 8))];
}
return [String((background ? 40 : 30) + value)];
}
if (mode === 2) {
return [background ? "48" : "38", "5", String(value)];
}
if (mode === 3) {
return [
background ? "48" : "38",
"2",
String((value >> 16) & 0xff),
String((value >> 8) & 0xff),
String(value & 0xff),
];
}
return [];
}
export { renderTerminalSnapshotToAnsi } from "@server/shared/terminal-snapshot";
@@ -14,19 +14,36 @@ interface TerminalSnapshot {
cursor: { row: number; col: number };
}
function terminalCellText(cell: { char: string }): string {
return cell.char;
}
function terminalRowText(row: Array<{ char: string }>): string {
return row.map(terminalCellText).join("");
}
function terminalSnapshotText(state: TerminalSnapshot): string {
return state.grid.map(terminalRowText).join("\n");
}
function terminalOutput(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot };
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
| { terminalId: string; type: "restore"; data: Uint8Array };
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: string[] = [];
public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = [];
public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
async subscribeTerminal(terminalId: string) {
this.subscribeCalls.push(terminalId);
async subscribeTerminal(terminalId: string, options?: unknown) {
this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) });
const result = this.nextSubscribeResults.shift();
if (!result) {
throw new Error("Missing fake subscribe result");
@@ -61,7 +78,8 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; text: string }> = [];
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
@@ -70,10 +88,13 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => {
@@ -81,7 +102,7 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
},
});
return { client, controller, outputs, snapshots, statuses };
return { client, controller, outputs, restores, snapshots, statuses };
}
async function flushAsyncWork(): Promise<void> {
@@ -108,16 +129,18 @@ describe("terminal-stream-controller", () => {
cursor: { row: 0, col: 5 },
},
});
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
type: "output",
data: new TextEncoder().encode(" world"),
data: outputData,
});
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.outputs).toEqual([{ terminalId: "term-1", text: " world" }]);
expect(harness.outputs[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -135,7 +158,7 @@ describe("terminal-stream-controller", () => {
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -152,7 +175,7 @@ describe("terminal-stream-controller", () => {
harness.controller.handleTerminalExit({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -160,6 +183,55 @@ describe("terminal-stream-controller", () => {
});
});
it("requests configured restore options and forwards restore output", async () => {
const client = new FakeTerminalStreamClient();
const harness = createHarness({ client });
client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 80 }),
getRestoreOptions: () => ({
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
}),
onOutput: (output) => harness.outputs.push(output),
onRestore: (restore) => harness.restores.push(restore),
onSnapshot: ({ terminalId, state }) => {
harness.snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
});
controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const restoreData = terminalOutput("restored");
client.emit({
terminalId: "term-1",
type: "restore",
data: restoreData,
});
controller.dispose();
expect(client.subscribeCalls).toEqual([
{
terminalId: "term-1",
options: {
restore: {
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
},
},
},
]);
expect(harness.restores[0]?.data).toBe(restoreData);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.outputs).toEqual([]);
});
it("unsubscribes when switching terminals and on dispose", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
@@ -1,7 +1,11 @@
import type { TerminalState } from "@server/shared/messages";
import type { SubscribeTerminalRequest, TerminalState } from "@server/shared/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
export interface TerminalStreamControllerClient {
subscribeTerminal: (terminalId: string) => Promise<{
subscribeTerminal: (
terminalId: string,
options?: { restore?: SubscribeTerminalRequest["restore"] },
) => Promise<{
terminalId: string;
error?: string | null;
}>;
@@ -14,7 +18,8 @@ export interface TerminalStreamControllerClient {
handler: (
event:
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState },
| { terminalId: string; type: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array },
) => void,
) => () => void;
}
@@ -33,15 +38,16 @@ export interface TerminalStreamControllerStatus {
export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; text: string }) => void;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
}
const TERMINAL_EXITED_ERROR = "Terminal exited";
export class TerminalStreamController {
private readonly decoder = new TextDecoder();
private readonly unsubscribeStreamEvents: () => void;
private terminalId: string | null = null;
private disposed = false;
@@ -52,13 +58,17 @@ export class TerminalStreamController {
return;
}
if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return;
}
const text = this.decoder.decode(event.data, { stream: true });
if (text.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, text });
if (event.type === "restore") {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
}
});
}
@@ -70,7 +80,6 @@ export class TerminalStreamController {
const nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId;
this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId);
}
@@ -78,9 +87,10 @@ export class TerminalStreamController {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
void this.options.client
.subscribeTerminal(nextTerminalId)
.subscribeTerminal(nextTerminalId, restore ? { restore } : undefined)
.then((payload) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
@@ -126,7 +136,6 @@ export class TerminalStreamController {
if (this.disposed || input.terminalId !== this.terminalId) {
return;
}
this.decoder.decode();
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: input.terminalId,
@@ -140,7 +149,6 @@ export class TerminalStreamController {
return;
}
this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId;
this.terminalId = null;
if (terminalId) {
@@ -3,7 +3,10 @@ import xtermCss from "@xterm/xterm/css/xterm.css";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "@/utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../runtime/terminal-emulator-runtime";
import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
} from "../runtime/terminal-emulator-runtime";
interface MountMessage {
type: "mount";
@@ -19,6 +22,7 @@ type InboundMessage =
| MountMessage
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
@@ -154,7 +158,10 @@ class TerminalWebViewBridge {
): void {
switch (message.type) {
case "writeOutput":
this.runtime?.write({ text: message.text });
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
break;
case "restoreOutput":
this.runtime?.restoreOutput({ data: encodeTerminalOutput(message.text) });
break;
case "renderSnapshot":
this.runtime?.renderSnapshot({ state: message.state });
File diff suppressed because one or more lines are too long