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