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