feat(terminal): stream negotiated ANSI replay to clients

Restore configured client history up to host retention without collecting capable replay in the daemon. Preserve single-frame ANSI delivery for older clients and normalize older hosts at the SDK boundary.
This commit is contained in:
Mohamed Boudra
2026-10-30 09:56:54 +01:00
parent 9ba63a30fa
commit bfe2b10cef
44 changed files with 787 additions and 499 deletions
@@ -19,6 +19,7 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
@@ -361,10 +361,13 @@ function NativeTerminalEmulator({
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
createNativeTerminalOutputDrain({
write: async (chunk) => {
inputModeTrackerRef.current.feed(chunk);
await terminal.write(chunk);
emitInputModeChange();
},
reset: () => {
reset: (size) => {
inputModeTrackerRef.current.reset();
if (size) terminal.resize(size);
terminal.reset();
emitInputModeChange();
},
@@ -477,19 +480,12 @@ function NativeTerminalEmulator({
],
);
const enqueueOutputText = useCallback(
(text: string) => {
if (text.length === 0) {
return;
}
const inputModeUpdate = inputModeTrackerRef.current.feed(text);
if (inputModeUpdate.changed) {
emitInputModeChange();
}
outputDrainRef.current?.enqueueText(text);
},
[emitInputModeChange],
);
const enqueueOutputText = useCallback((text: string) => {
if (text.length === 0) {
return;
}
outputDrainRef.current?.enqueueText(text);
}, []);
const claimActiveTerminalSize = useCallback(() => {
activeResizeClaimTokenRef.current += 1;
@@ -512,6 +508,7 @@ function NativeTerminalEmulator({
const text = outputDecoderRef.current.decode(data, { stream: true });
enqueueOutputText(text);
},
replay: (event) => outputDrainRef.current?.replay(event),
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
@@ -40,6 +40,7 @@ import { focusWithRetries } from "../utils/web-focus";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
@@ -253,6 +254,18 @@ export default function TerminalEmulator({
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
replay: (event) => {
if (!event || typeof event !== "object" || Array.isArray(event)) return;
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
if (event.type === "data" && event.data instanceof Uint8Array)
runtimeRef.current?.replay({ type: "data", data: event.data });
if (event.type === "begin")
runtimeRef.current?.replay({
type: "begin",
rows: typeof event.rows === "number" ? event.rows : undefined,
cols: typeof event.cols === "number" ? event.cols : undefined,
});
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
@@ -299,6 +312,7 @@ export default function TerminalEmulator({
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
replay: (event) => runtimeRef.current?.replay(event),
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
+5 -43
View File
@@ -15,7 +15,6 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
@@ -60,7 +59,6 @@ import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
@@ -88,7 +86,6 @@ import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
@@ -316,10 +313,6 @@ export function TerminalPane({
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
[scopeKey],
);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
@@ -383,7 +376,6 @@ export function TerminalPane({
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -441,16 +433,8 @@ export function TerminalPane({
(change: TerminalRendererReadyChange) => {
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
return;
}
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
if (snapshot) {
emulatorRef.current?.renderSnapshot(snapshot);
}
},
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
[terminalStreamKey],
);
useEffect(() => {
@@ -564,7 +548,6 @@ export function TerminalPane({
);
const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
setModifiers({ ...EMPTY_MODIFIERS });
});
@@ -604,29 +587,10 @@ export function TerminalPane({
},
);
const handleStreamRestore = useStableEvent(
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
);
const handleStreamSnapshot = useStableEvent(
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
if (terminalIdRef.current !== snapshotTerminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
},
);
const getStreamRestoreOptions = useStableEvent(() =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
scrollbackLines: settings.terminalScrollbackLines,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused,
@@ -653,8 +617,9 @@ export function TerminalPane({
client,
getPreferredSize: getPreferredStreamSize,
onOutput: handleStreamOutput,
onRestore: handleStreamRestore,
onSnapshot: handleStreamSnapshot,
onReplay: ({ terminalId: id, replay }) => {
if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
},
onExit: handleStreamExit,
getRestoreOptions: getStreamRestoreOptions,
onStatusChange: handleStreamControllerStatus,
@@ -674,8 +639,6 @@ export function TerminalPane({
getStreamRestoreOptions,
handleStreamControllerStatus,
handleStreamOutput,
handleStreamRestore,
handleStreamSnapshot,
handleStreamExit,
isConnected,
]);
@@ -1216,7 +1179,6 @@ export function TerminalPane({
keyboardInset={keyboardInset}
isKeyboardVisible={isKeyboardVisible}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
@@ -127,7 +127,6 @@ import {
useWorkspaceSetupStore,
} from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -881,13 +880,6 @@ function shouldShowWorkspaceScreenHeader(input: {
return !input.isFocusModeEnabled || input.isMobile;
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
/**
* A pane the user acted inside owns the tab: it opens there, and an existing tab
* moves there. No pane means the open has no opinion beyond the focused pane.
@@ -1066,14 +1058,6 @@ function WorkspaceScreenContent({
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
const workspaceTerminalScopeKey = useMemo(
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
[normalizedServerId, normalizedWorkspaceId],
);
useWorkspaceTerminalSessionRetention({
scopeKey: workspaceTerminalScopeKey,
});
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const supportsProvidersSnapshot = useSessionStore(
@@ -1,18 +0,0 @@
import { useEffect } from "react";
import {
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "@/terminal/runtime/workspace-terminal-session";
export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void {
useEffect(() => {
if (!input.scopeKey) {
return;
}
retainWorkspaceTerminalSession({ scopeKey: input.scopeKey });
return () => {
releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! });
};
}, [input.scopeKey]);
}
@@ -268,3 +268,89 @@ describe("native terminal output drain", () => {
});
});
});
test("replays split Unicode and ANSI with one reset then live output", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 });
let resets = 0;
const drain = createNativeTerminalOutputDrain({
write: (text) => terminal.write(text),
reset: (size) => {
resets++;
if (size) terminal.resize(size);
terminal.reset();
},
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 });
const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n");
for (let i = 0; i < bytes.length; i++)
drain.replay({ type: "data", data: bytes.subarray(i, i + 1) });
drain.replay({ type: "end" });
drain.enqueueText("live");
await drain.flush();
expect(resets).toBe(1);
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("界🙂 replay");
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("live");
} finally {
drain.dispose();
terminal.dispose();
}
});
test("an empty replacement replay resets after an in-flight write finishes", async () => {
let finish!: () => void;
let started!: () => void;
const writing = new Promise<void>((resolve) => {
started = resolve;
});
const blocked = new Promise<void>((resolve) => {
finish = resolve;
});
let resets = 0;
const drain = createNativeTerminalOutputDrain({
write: async () => {
started();
await blocked;
},
reset: () => {
resets++;
},
getViewportState: () => null,
onPaint: () => {},
});
try {
drain.enqueueText("old terminal");
await writing;
drain.replay({ type: "begin" });
drain.replay({ type: "end" });
finish();
await drain.flush();
expect(resets).toBe(1);
expect(drain.getQueuedChars()).toBe(0);
} finally {
finish();
drain.dispose();
}
});
@@ -14,6 +14,7 @@ export interface NativeTerminalParseSample {
}
export interface NativeTerminalOutputDrain {
replay(event: import("@getpaseo/client").TerminalReplayEvent): void;
enqueueText(text: string): void;
restoreText(text: string): void;
restoreSnapshot(snapshot: TerminalState): void;
@@ -25,7 +26,7 @@ export interface NativeTerminalOutputDrain {
export interface NativeTerminalOutputDrainOptions {
write: (chunk: string) => Promise<void>;
reset: () => void;
reset: (size?: { rows: number; cols: number }) => void;
getViewportState: () => TerminalViewportState | null;
onPaint: (state: TerminalViewportState) => void;
onParse?: (sample: NativeTerminalParseSample) => void;
@@ -75,9 +76,11 @@ export function createNativeTerminalOutputDrain(
let paintFrame: number | null = null;
let generation = 0;
let resetBeforeNextWrite = false;
let replaySize: { rows: number; cols: number } | undefined;
const replayDecoder = new TextDecoder();
function resolveIdleIfReady(): void {
if (drainScheduled || draining || pendingText.length > 0) {
if (drainScheduled || draining || pendingText.length > 0 || resetBeforeNextWrite) {
return;
}
while (idleWaiters.length > 0) {
@@ -138,14 +141,16 @@ export function createNativeTerminalOutputDrain(
let parsedChunks = 0;
try {
if (resetBeforeNextWrite) {
options.reset(replaySize);
replaySize = undefined;
resetBeforeNextWrite = false;
requestPaint();
}
while (pendingText.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) {
return;
}
if (resetBeforeNextWrite) {
options.reset();
resetBeforeNextWrite = false;
}
const chunk = pendingText.slice(0, chunkChars);
pendingText = pendingText.slice(chunk.length);
const parseStart = nativeTerminalPerformanceNow();
@@ -163,7 +168,7 @@ export function createNativeTerminalOutputDrain(
}
} finally {
draining = false;
if (!disposed && pendingText.length > 0) {
if (!disposed && (pendingText.length > 0 || resetBeforeNextWrite)) {
scheduleDrain();
}
resolveIdleIfReady();
@@ -171,6 +176,17 @@ export function createNativeTerminalOutputDrain(
}
return {
replay(event): void {
if (event.type === "begin") {
replayDecoder.decode();
replaySize = event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined;
replacePendingText("");
} else if (event.type === "data") {
this.enqueueText(replayDecoder.decode(event.data, { stream: true }));
} else {
this.enqueueText(replayDecoder.decode());
}
},
enqueueText(text: string): void {
if (disposed || text.length === 0) {
return;
@@ -202,7 +218,7 @@ export function createNativeTerminalOutputDrain(
},
flush(): Promise<void> {
if (!drainScheduled && !draining && pendingText.length === 0) {
if (!drainScheduled && !draining && pendingText.length === 0 && !resetBeforeNextWrite) {
return Promise.resolve();
}
scheduleDrain();
@@ -795,3 +795,48 @@ describe("terminal emulator runtime in a real browser", () => {
expect(confirm).not.toHaveBeenCalled();
});
});
describe("incremental replay lifecycle", () => {
it.each(["clear", "remount"] as const)(
"recovers live input after interrupted replay and %s",
async (cleanup) => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") });
if (cleanup === "clear")
await new Promise<void>((resolve) => mounted.runtime.clear({ onCommitted: resolve }));
else {
mounted.runtime.unmount();
mounted.runtime.mount({
root: mounted.root,
host: mounted.host,
initialSnapshot: null,
scrollback: 1000,
theme: {},
});
}
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
);
getBrowserTerminal().input("user-input", true);
expect(mounted.inputs).toContain("user-input");
},
);
it("suppresses generated replies until queued replay chunks commit after end", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }),
);
expect(mounted.inputs).toEqual([]);
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }),
);
expect(mounted.inputs).toHaveLength(1);
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
});
@@ -911,6 +911,14 @@ export class TerminalEmulatorRuntime {
});
}
replay(event: import("@getpaseo/client").TerminalReplayEvent): void {
if (event.type === "begin") {
this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols });
} else if (event.type === "data") {
this.write({ data: event.data, suppressInput: true });
}
}
restoreOutput(input: {
data: TerminalOutputData;
rows?: number;
@@ -6,6 +6,7 @@ describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: false,
canClaimSize: true,
size: { rows: 24, cols: 80 },
@@ -13,16 +14,17 @@ describe("terminal restore options", () => {
).toBeUndefined();
});
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
it("requests visible snapshot restore with configured scrollback for capable daemons", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
size: { rows: 24, cols: 80 },
});
});
@@ -30,26 +32,39 @@ describe("terminal restore options", () => {
it("omits size until the terminal has been measured", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: null,
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
});
});
it("does not let a background attach resize the PTY", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: false,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
});
});
});
it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
scrollbackLines,
canClaimSize: false,
size: null,
}),
).toEqual({ mode: "visible-snapshot", scrollbackLines });
});
@@ -1,9 +1,8 @@
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean;
scrollbackLines: number;
canClaimSize: boolean;
size: { rows: number; cols: number } | null;
}
@@ -17,7 +16,7 @@ export function resolveTerminalRestoreOptions(
return {
mode: "visible-snapshot",
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
scrollbackLines: input.scrollbackLines,
...(input.canClaimSize && input.size ? { size: input.size } : {}),
};
}
@@ -7,34 +7,16 @@ import {
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
interface TerminalSnapshot {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
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: "restore"; data: Uint8Array };
type StreamEvent = import("@getpaseo/client").TerminalStreamEvent;
type TerminalStreamEvent = StreamEvent extends infer Event
? Event extends StreamEvent
? Omit<Event, "subscriptionId">
: never
: never;
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly updates = new Set<(message: SessionOutboundMessage) => void>();
@@ -115,32 +97,23 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const replays: import("@getpaseo/client").TerminalReplayEvent[] = [];
const statuses: TerminalStreamControllerStatus[] = [];
const exits: string[] = [];
const controller = new TerminalStreamController({
client,
onReplay: ({ replay }) => replays.push(replay),
onExit: (terminalId) => exits.push(terminalId),
getPreferredSize: () => ({ rows: 24, cols: 80 }),
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => {
statuses.push(status);
},
});
return { client, controller, outputs, restores, snapshots, statuses, exits };
return { client, controller, outputs, replays, statuses, exits };
}
async function flushAsyncWork(): Promise<void> {
@@ -149,24 +122,20 @@ async function flushAsyncWork(): Promise<void> {
}
describe("terminal-stream-controller", () => {
it("subscribes, resizes, and forwards snapshot/output events", async () => {
it("subscribes, resizes, and forwards replay/output events", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.client.emit({
terminalId: "term-1",
type: "snapshot",
state: {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
},
});
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 },
{ type: "data", data: terminalOutput("hello") },
{ type: "end" },
];
for (const event of replay)
harness.client.emit({ terminalId: "term-1", type: "replay", replay: event });
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
@@ -178,7 +147,7 @@ describe("terminal-stream-controller", () => {
expect(harness.client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.replays).toEqual(replay);
expect(harness.outputs[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({
@@ -236,23 +205,19 @@ describe("terminal-stream-controller", () => {
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),
});
},
onReplay: ({ replay }) => harness.replays.push(replay),
});
controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const restoreData = terminalOutput("restored");
client.emit({
terminalId: "term-1",
type: "restore",
data: restoreData,
});
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin" },
{ type: "data", data: restoreData },
{ type: "end" },
];
for (const event of replay)
client.emit({ terminalId: "term-1", type: "replay", replay: event });
controller.dispose();
expect(client.subscribeCalls).toEqual([
@@ -270,8 +235,7 @@ describe("terminal-stream-controller", () => {
expect(client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.restores[0]?.data).toBe(restoreData);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.replays).toEqual(replay);
expect(harness.outputs).toEqual([]);
});
@@ -1,5 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
import { i18n } from "@/i18n/i18next";
@@ -23,9 +23,11 @@ export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onExit?: (terminalId: string) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
onReplay?: (input: {
terminalId: string;
replay: import("@getpaseo/client").TerminalReplayEvent;
}) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
}
@@ -43,17 +45,11 @@ export class TerminalStreamController {
if (this.disposed || event.terminalId !== this.terminalId) {
return;
}
if (event.type === "snapshot") {
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
if (event.type === "replay") {
this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay });
return;
}
if (event.type === "restore") {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
if (event.type === "output" && event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
}
};
@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getWorkspaceTerminalSession,
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "./workspace-terminal-session";
describe("workspace-terminal-session", () => {
it("returns the same workspace session instance for the same scope", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
expect(second).toBe(first);
});
it("preserves snapshots across repeated lookups", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
});
});
it("evicts workspace terminal session state when the retain count returns to zero", () => {
const scopeKey = "workspace-release";
const first = getWorkspaceTerminalSession({
scopeKey,
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
retainWorkspaceTerminalSession({ scopeKey });
releaseWorkspaceTerminalSession({ scopeKey });
const second = getWorkspaceTerminalSession({
scopeKey,
});
expect(second).not.toBe(first);
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
});
});
@@ -1,79 +0,0 @@
import type { TerminalState } from "@getpaseo/protocol/messages";
export interface WorkspaceTerminalSnapshots {
get: (input: { terminalId: string }) => TerminalState | null;
set: (input: { terminalId: string; state: TerminalState }) => void;
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
}
export interface WorkspaceTerminalSession {
scopeKey: string;
snapshots: WorkspaceTerminalSnapshots;
}
interface WorkspaceTerminalSessionRecord {
snapshotByTerminalId: Map<string, TerminalState>;
session: WorkspaceTerminalSession;
}
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
const refCountByScopeKey = new Map<string, number>();
function createSnapshots(input: {
snapshotByTerminalId: Map<string, TerminalState>;
}): WorkspaceTerminalSnapshots {
return {
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
set: ({ terminalId, state }) => {
input.snapshotByTerminalId.set(terminalId, state);
},
clear: ({ terminalId }) => {
input.snapshotByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
input.snapshotByTerminalId.delete(terminalId);
}
}
},
};
}
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing.session;
}
const snapshotByTerminalId = new Map<string, TerminalState>();
const session: WorkspaceTerminalSession = {
scopeKey: input.scopeKey,
snapshots: createSnapshots({
snapshotByTerminalId,
}),
};
sessionsByScopeKey.set(input.scopeKey, {
snapshotByTerminalId,
session,
});
return session;
}
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
refCountByScopeKey.set(input.scopeKey, current + 1);
}
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
if (current > 1) {
refCountByScopeKey.set(input.scopeKey, current - 1);
return;
}
refCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
}