mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
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:
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user