diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index cd5de5b528..c0c318d963 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -54,11 +54,11 @@ without requesting streams. Application caches and which agents are visible rema ## Terminal ANSI replay -`terminal_chunked_replay` is SDK-owned delivery behavior, separate from the daemon's -`features.terminalChunkedReplay`. The daemon sends new binary opcodes only to a source +`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source advertising the capability. The SDK keeps the established subscribe request against older ANSI hosts and normalizes their single Restore frame into begin/data/end. JSON snapshot wire declarations remain accepted, but JSON snapshot frames have no rendering runtime. +Terminals require `terminal-restore-modes`; older hosts show an update message without attaching. ## Owned observations diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index a60cf2ec58..12638161a1 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -4,7 +4,6 @@ import type { } from "../terminal/runtime/terminal-emulator-runtime"; import type { Ref } from "react"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; import type { @@ -20,8 +19,6 @@ 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; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -45,7 +42,6 @@ export interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx index d4aa03051b..359fbd1e03 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -13,7 +13,6 @@ import { type ViewStyle, } from "react-native"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker, terminalInputModeStatesEqual, @@ -120,43 +119,8 @@ function resolvePanResponderPoint( }; } -function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize { - return { - rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS, - cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS, - }; -} - -function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState { - const visibleRows = viewport.grid.length; - const currentViewport = { - firstRow: viewport.firstRow, - lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1), - }; - return { - viewport, - scroll: { - mode: "following", - firstRow: viewport.firstRow, - visibleRows, - oldestRow: viewport.oldestRow, - newestRow: viewport.newestRow, - currentViewport, - bottomViewport: currentViewport, - }, - }; -} - -function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState { - return { - rows: state.grid.length, - cols: state.cols, - firstRow: 0, - oldestRow: 0, - newestRow: Math.max(0, state.grid.length - 1), - grid: state.grid, - cursor: state.cursor, - }; +function initialNativeTerminalSize(): TerminalMeasuredSize { + return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS }; } function NativeTerminalEmulator({ @@ -169,7 +133,6 @@ function NativeTerminalEmulator({ fontSize, keyboardInset = 0, isKeyboardVisible = false, - initialSnapshot = null, onInput, onTerminalKey, onResize, @@ -181,20 +144,18 @@ function NativeTerminalEmulator({ resizeRequestToken = 0, }: TerminalEmulatorProps) { const terminalRef = useRef(null); - const mountInputRef = useRef({ streamKey, initialSnapshot }); + const mountInputRef = useRef({ streamKey }); if ( resolveNativeTerminalMountAction({ mountedStreamKey: mountInputRef.current.streamKey, nextStreamKey: streamKey, }) === "mount" ) { - mountInputRef.current = { streamKey, initialSnapshot }; + mountInputRef.current = { streamKey }; } const inputRef = useRef(null); - const terminalSizeRef = useRef(initialNativeTerminalSize(initialSnapshot)); - const resizePolicyRef = useRef( - createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)), - ); + const terminalSizeRef = useRef(initialNativeTerminalSize()); + const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize())); const activeResizeClaimTokenRef = useRef(0); const layoutRef = useRef(null); const metricsRef = useRef(null); @@ -220,11 +181,7 @@ function NativeTerminalEmulator({ ...inputModeTrackerRef.current.getState(), applicationCursorKeys: false, }); - const [terminalScreen, setTerminalScreen] = useState(() => { - return initialSnapshot - ? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot)) - : null; - }); + const [terminalScreen, setTerminalScreen] = useState(null); const [selectionRange, setSelectionRange] = useState(null); const selectionRangeRef = useRef(null); const hasNotifiedSelectionRef = useRef(false); @@ -365,6 +322,7 @@ function NativeTerminalEmulator({ await terminal.write(chunk); emitInputModeChange(); }, + restoreSize: () => terminal.resize(terminalSizeRef.current), reset: (size) => { inputModeTrackerRef.current.reset(); if (size) terminal.resize(size); @@ -434,51 +392,41 @@ function NativeTerminalEmulator({ [schedulePaint], ); - const resetTerminal = useCallback( - (snapshot: TerminalState | null) => { - outputDrainRef.current?.dispose(); - outputDrainRef.current = null; - terminalRef.current?.dispose(); - screenModelRef.current = null; - latestResolvedScreenRef.current = null; - clearSelection(); - const measuredSize = resolveMeasuredNativeTerminalSize({ - layout: layoutRef.current, - metrics: metricsRef.current, - }); - const size = - measuredSize ?? - (snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current); - terminalSizeRef.current = size; - resizePolicyRef.current = createTerminalResizePolicy(size); - outputDecoderRef.current.decode(); - const terminal = createNativeHeadlessTerminal({ - rows: size.rows, - cols: size.cols, - scrollbackLines, - }); - terminalRef.current = terminal; - screenModelRef.current = createNativeTerminalScreenModel({ terminal }); - const outputDrain = createOutputDrain(terminal); - outputDrainRef.current = outputDrain; - const screen = snapshot - ? screenStateFromViewport(viewportStateFromSnapshot(snapshot)) - : resolveTerminalScreen(terminal); - commitTerminalScreen(screen); - resetInputModeTracker(); - if (snapshot) { - outputDrain.restoreSnapshot(snapshot); - } - }, - [ - commitTerminalScreen, - createOutputDrain, - resetInputModeTracker, - resolveTerminalScreen, + const resetTerminal = useCallback(() => { + outputDrainRef.current?.dispose(); + outputDrainRef.current = null; + terminalRef.current?.dispose(); + screenModelRef.current = null; + latestResolvedScreenRef.current = null; + clearSelection(); + const measuredSize = resolveMeasuredNativeTerminalSize({ + layout: layoutRef.current, + metrics: metricsRef.current, + }); + const size = measuredSize ?? terminalSizeRef.current; + terminalSizeRef.current = size; + resizePolicyRef.current = createTerminalResizePolicy(size); + outputDecoderRef.current.decode(); + const terminal = createNativeHeadlessTerminal({ + rows: size.rows, + cols: size.cols, scrollbackLines, - clearSelection, - ], - ); + }); + terminalRef.current = terminal; + screenModelRef.current = createNativeTerminalScreenModel({ terminal }); + const outputDrain = createOutputDrain(terminal); + outputDrainRef.current = outputDrain; + const screen = resolveTerminalScreen(terminal); + commitTerminalScreen(screen); + resetInputModeTracker(); + }, [ + commitTerminalScreen, + createOutputDrain, + resetInputModeTracker, + resolveTerminalScreen, + scrollbackLines, + clearSelection, + ]); const enqueueOutputText = useCallback((text: string) => { if (text.length === 0) { @@ -509,27 +457,6 @@ function NativeTerminalEmulator({ enqueueOutputText(text); }, replay: (event) => outputDrainRef.current?.replay(event), - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - outputDrainRef.current?.restoreText(text); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - if (!state) { - resetTerminal(null); - return; - } - clearSelection(); - const outputDrain = outputDrainRef.current; - if (!outputDrain) { - resetTerminal(state); - return; - } - outputDrain.restoreSnapshot(state); - }, paste: (text: string) => { if (text.length === 0) { return; @@ -560,7 +487,6 @@ function NativeTerminalEmulator({ screenModelRef.current?.reset(); clearSelection(); resetInputModeTracker(); - terminalRef.current?.reset(); schedulePaint(); }, claimSize: claimActiveTerminalSize, @@ -579,14 +505,13 @@ function NativeTerminalEmulator({ claimActiveTerminalSizeForAction, clearSelection, resetInputModeTracker, - resetTerminal, schedulePaint, ], ); useEffect(() => { hasNotifiedMeasuredSizeRef.current = false; - resetTerminal(mountInputRef.current.initialSnapshot); + resetTerminal(); callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true }); return () => { callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 00e76d95db..fa6ac9faba 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -19,7 +19,6 @@ import type { DOMProps } from "expo/dom"; import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { @@ -41,8 +40,6 @@ 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; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -109,7 +106,6 @@ interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; @@ -148,16 +144,6 @@ declare global { interface Window {} } -function isTerminalState(value: unknown): value is TerminalState { - return ( - typeof value === "object" && - value !== null && - "rows" in value && - "cols" in value && - "grid" in value - ); -} - export default function TerminalEmulator({ ref, streamKey, @@ -173,7 +159,6 @@ export default function TerminalEmulator({ swipeGesturesEnabled = false, onSwipeLeft, onSwipeRight, - initialSnapshot = null, onFindRequest, onFindResult, onInput, @@ -229,8 +214,6 @@ export default function TerminalEmulator({ onOpenLocalFileLink, onFilePaste, }; - const initialSnapshotRef = useRef(initialSnapshot); - initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; @@ -266,18 +249,6 @@ export default function TerminalEmulator({ cols: typeof event.cols === "number" ? event.cols : undefined, }); }, - restoreOutput: (...args) => { - const data = args[0]; - if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (...args) => { - const state = args[0]; - if (state === null) { - runtimeRef.current?.renderSnapshot({ state: null }); - } else if (isTerminalState(state)) { - runtimeRef.current?.renderSnapshot({ state }); - } - }, paste: (...args) => { const text = args[0]; if (typeof text === "string" && text.length > 0) { @@ -313,12 +284,6 @@ export default function TerminalEmulator({ runtimeRef.current?.write({ data }); }, replay: (event) => runtimeRef.current?.replay(event), - restoreOutput: (data: TerminalOutputData) => { - runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (state: TerminalState | null) => { - runtimeRef.current?.renderSnapshot({ state }); - }, paste: (text: string) => { pasteText(text); }, @@ -485,7 +450,6 @@ export default function TerminalEmulator({ runtime.mount({ root, host, - initialSnapshot: initialSnapshotRef.current, scrollback: scrollbackLinesRef.current, theme: mountedThemeRef.current, fontFamily: fontFamilyRef.current, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1cb6f0da06..c5ff5ecdbb 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -617,6 +617,7 @@ export const ar: TranslationResources = { terminal: { hostDisconnected: "Host غير متصل", updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", + restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", fileUploaded: "تم رفع الملف", pasteFileFailed: "تعذر لصق الملف: {{reason}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 0511af34e2..33adbf4b31 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -614,6 +614,7 @@ export const en = { terminal: { hostDisconnected: "Host is not connected", updateHost: "Update the host to use the native terminal renderer.", + restoreRequiresUpdate: "Update the host to view terminals.", unableToSubscribe: "Unable to subscribe to terminal", fileUploaded: "File uploaded", pasteFileFailed: "Failed to paste file: {{reason}}", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ffe9cedc1e..1c81ba506d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -623,6 +623,7 @@ export const es: TranslationResources = { terminal: { hostDisconnected: "Hostno está conectado", updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", + restoreRequiresUpdate: "Actualiza el host para ver las terminales.", unableToSubscribe: "No se puede suscribir al terminal", fileUploaded: "Archivo subido", pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6c9df18211..f9d5f308a6 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -623,6 +623,7 @@ export const fr: TranslationResources = { terminal: { hostDisconnected: "L’hôte n’est pas connecté", updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", + restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.", unableToSubscribe: "Impossible de s’abonner au terminal", fileUploaded: "Fichier envoyé", pasteFileFailed: "Impossible de coller le fichier : {{reason}}", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index dd214ebac9..c425b260c0 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -623,6 +623,7 @@ export const ja: TranslationResources = { terminal: { hostDisconnected: "ホストが接続されていません", updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", + restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", fileUploaded: "ファイルをアップロードしました", pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d30635b2a5..1e697d6869 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -618,6 +618,7 @@ export const ko: TranslationResources = { terminal: { hostDisconnected: "호스트가 연결되어 있지 않습니다", updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", + restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.", unableToSubscribe: "터미널을 구독할 수 없습니다", fileUploaded: "파일을 업로드했습니다", pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1d9ce0cb95..3edfbd9f12 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -622,6 +622,7 @@ export const ptBR: TranslationResources = { terminal: { hostDisconnected: "Host não está conectado", updateHost: "Atualize o host para usar o renderizador de terminal nativo.", + restoreRequiresUpdate: "Atualize o host para visualizar os terminais.", unableToSubscribe: "Não foi possível assinar o terminal", fileUploaded: "Arquivo enviado", pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 766f8bdae7..bf61ec805e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -624,6 +624,7 @@ export const ru: TranslationResources = { terminal: { hostDisconnected: "Хост не подключён", updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", + restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.", unableToSubscribe: "Не удалось подключиться к терминалу", fileUploaded: "Файл загружен", pasteFileFailed: "Не удалось вставить файл: {{reason}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 64867d7d21..e31add4403 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -617,6 +617,7 @@ export const zhCN: TranslationResources = { terminal: { hostDisconnected: "Host 未连接", updateHost: "请更新主机以使用原生终端渲染器。", + restoreRequiresUpdate: "更新主机以查看终端。", unableToSubscribe: "无法订阅 Terminal", fileUploaded: "文件已上传", pasteFileFailed: "无法粘贴文件:{{reason}}", diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts index 2a324ef425..038c91f650 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts @@ -1,3 +1,4 @@ +import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy"; import { describe, expect, test } from "vitest"; import { @@ -11,7 +12,7 @@ import { NATIVE_TERMINAL_PARSE_CHUNK_CHARS, } from "./headless-terminal-output-drain"; import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model"; -import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; const ROWS = 12; const COLS = 80; @@ -94,6 +95,7 @@ function createScreenRestorer( const frameRecorder = createFrameRecorder(); let screen: TerminalScreenState | null = null; const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => { @@ -107,7 +109,12 @@ function createScreenRestorer( return { async restore(snapshot: Awaited>) { - drain.restoreSnapshot(snapshot); + drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols }); + drain.replay({ + type: "data", + data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(snapshot)), + }); + drain.replay({ type: "end" }); await drain.flush(); frameRecorder.frames[0]?.(); return screen; @@ -122,6 +129,7 @@ describe("native terminal output drain", () => { const frameRecorder = createFrameRecorder(); const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: async (chunk) => { writes.push(chunk); }, @@ -170,6 +178,7 @@ describe("native terminal output drain", () => { const paintedStates: TerminalViewportState[] = []; const frameRecorder = createFrameRecorder(); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => terminal.getViewportState(), @@ -202,6 +211,7 @@ describe("native terminal output drain", () => { const scheduledWork: Array<() => void> = []; const frameRecorder = createFrameRecorder(); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => terminal.getViewportState(), @@ -219,7 +229,12 @@ describe("native terminal output drain", () => { await terminal.write("old-one\r\nold-two\r\nold-three\r\n"); drain.enqueueText("stale-queued\r\n"); - drain.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot)); + drain.replay({ type: "begin" }); + drain.replay({ + type: "data", + data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(restoredSnapshot)), + }); + drain.replay({ type: "end" }); await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() }); const state = terminal.getViewportState(); @@ -273,6 +288,7 @@ 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({ + restoreSize: () => {}, write: (text) => terminal.write(text), reset: (size) => { resets++; @@ -330,6 +346,7 @@ test("an empty replacement replay resets after an in-flight write finishes", asy }); let resets = 0; const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: async () => { started(); await blocked; @@ -354,3 +371,114 @@ test("an empty replacement replay resets after an in-flight write finishes", asy drain.dispose(); } }); + +test("replay geometry returns to measured layout before live writes and unchanged-layout focus", async () => { + const measured = { rows: 24, cols: 40 }; + const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 1000 }); + const policy = createTerminalResizePolicy(measured); + const sizes: number[] = []; + const drain = createNativeTerminalOutputDrain({ + write: async (text) => { + sizes.push(terminal.getState().cols); + await terminal.write(text); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + restoreSize: () => terminal.resize(measured), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") }); + drain.replay({ type: "end" }); + drain.enqueueText("LIVE-" + "y".repeat(60)); + await drain.flush(); + expect(sizes).toEqual([120, 40]); + const focus = updateTerminalResizePolicy(policy, { + source: "claim", + size: measured, + claimToken: 1, + }); + expect(focus.measuredSizeChanged).toBe(false); + expect(focus.resizeClaim).toMatchObject(measured); + expect(terminal.getState()).toMatchObject(measured); + expect(terminal.getBufferBounds().cols).toBe(40); + const lines = terminal + .getState() + .grid.map((row) => + row + .map((cell) => cell.char) + .join("") + .trimEnd(), + ) + .join("\n"); + expect(lines).toContain("LIVE-" + "y".repeat(35) + "\n" + "y".repeat(25)); + } finally { + drain.dispose(); + terminal.dispose(); + } +}); + +test.each(["clear", "replacement", "dispose"] as const)( + "cancels queued replay geometry on %s", + async (action) => { + const local = { rows: 24, cols: 40 }; + const terminal = createNativeHeadlessTerminal({ ...local, scrollbackLines: 1000 }); + let finish!: () => void; + const block = new Promise((resolve) => { + finish = resolve; + }); + let started!: () => void; + const writing = new Promise((resolve) => { + started = resolve; + }); + let first = true; + let restores = 0; + const drain = createNativeTerminalOutputDrain({ + restoreSize: () => { + restores++; + terminal.resize(local); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + write: async (text) => { + await terminal.write(text); + if (first) { + first = false; + started(); + await block; + } + }, + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("old") }); + await writing; + drain.replay({ type: "end" }); + if (action === "clear") drain.clear(); + else if (action === "dispose") { + drain.dispose(); + drain.clear(); + } else { + drain.replay({ type: "begin", rows: 20, cols: 80 }); + drain.replay({ type: "data", data: new TextEncoder().encode("new") }); + drain.replay({ type: "end" }); + } + finish(); + await drain.flush(); + expect(restores).toBe(action === "dispose" ? 0 : 1); + if (action !== "dispose") expect(terminal.getState()).toMatchObject(local); + } finally { + finish(); + drain.dispose(); + terminal.dispose(); + } + }, +); diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts index 80a8c752a0..d637894526 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts @@ -1,5 +1,3 @@ -import type { TerminalState } from "@getpaseo/protocol/messages"; -import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; import type { TerminalViewportState } from "./headless-terminal-state"; import { nativeTerminalPerformanceNow } from "./terminal-performance"; @@ -16,8 +14,6 @@ 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; clear(): void; flush(): Promise; dispose(): void; @@ -26,6 +22,7 @@ export interface NativeTerminalOutputDrain { export interface NativeTerminalOutputDrainOptions { write: (chunk: string) => Promise; + restoreSize: () => void; reset: (size?: { rows: number; cols: number }) => void; getViewportState: () => TerminalViewportState | null; onPaint: (state: TerminalViewportState) => void; @@ -69,18 +66,21 @@ export function createNativeTerminalOutputDrain( const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS; const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN; const idleWaiters: Array<() => void> = []; - let pendingText = ""; + type Operation = + | { type: "write"; text: string } + | { type: "reset"; size?: { rows: number; cols: number } } + | { type: "restore-size" }; + let operations: Operation[] = []; + let queuedChars = 0; let drainScheduled = false; let draining = false; let disposed = false; 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 || resetBeforeNextWrite) { + if (drainScheduled || draining || operations.length > 0) { return; } while (idleWaiters.length > 0) { @@ -122,15 +122,6 @@ export function createNativeTerminalOutputDrain( }); } - function replacePendingText(text: string): void { - generation += 1; - pendingText = text; - resetBeforeNextWrite = true; - cancelPendingPaint(); - scheduleDrain(); - resolveIdleIfReady(); - } - async function drainPending(): Promise { if (disposed || draining) { return; @@ -141,18 +132,20 @@ 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; + while (operations.length > 0 && parsedChunks < chunksPerTurn) { + if (disposed || activeGeneration !== generation) return; + const operation = operations[0]!; + if (operation.type !== "write") { + operations.shift(); + if (operation.type === "reset") options.reset(operation.size); + else options.restoreSize(); + requestPaint(); + continue; } - const chunk = pendingText.slice(0, chunkChars); - pendingText = pendingText.slice(chunk.length); + const chunk = operation.text.slice(0, chunkChars); + operation.text = operation.text.slice(chunk.length); + queuedChars -= chunk.length; + if (!operation.text) operations.shift(); const parseStart = nativeTerminalPerformanceNow(); await options.write(chunk); if (disposed || activeGeneration !== generation) { @@ -161,14 +154,14 @@ export function createNativeTerminalOutputDrain( options.onParse?.({ durationMs: nativeTerminalPerformanceNow() - parseStart, parsedChars: chunk.length, - queuedChars: pendingText.length, + queuedChars, }); requestPaint(); parsedChunks += 1; } } finally { draining = false; - if (!disposed && (pendingText.length > 0 || resetBeforeNextWrite)) { + if (!disposed && operations.length > 0) { scheduleDrain(); } resolveIdleIfReady(); @@ -177,48 +170,48 @@ export function createNativeTerminalOutputDrain( return { replay(event): void { + if (disposed) return; if (event.type === "begin") { + generation++; replayDecoder.decode(); - replaySize = event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined; - replacePendingText(""); + operations = [ + { + type: "reset", + size: event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined, + }, + ]; + queuedChars = 0; + cancelPendingPaint(); } else if (event.type === "data") { this.enqueueText(replayDecoder.decode(event.data, { stream: true })); } else { this.enqueueText(replayDecoder.decode()); + operations.push({ type: "restore-size" }); } + scheduleDrain(); }, enqueueText(text: string): void { - if (disposed || text.length === 0) { - return; - } - pendingText += text; + if (disposed || !text) return; + const last = operations.at(-1); + if (last?.type === "write") last.text += text; + else operations.push({ type: "write", text }); + queuedChars += text.length; scheduleDrain(); }, - restoreText(text: string): void { - if (disposed) { - return; - } - replacePendingText(text); - }, - - restoreSnapshot(snapshot: TerminalState): void { - if (disposed) { - return; - } - replacePendingText(renderTerminalSnapshotToAnsi(snapshot)); - }, - clear(): void { + if (disposed) return; generation += 1; - pendingText = ""; - resetBeforeNextWrite = false; + operations = [{ type: "restore-size" }, { type: "reset" }]; + queuedChars = 0; + replayDecoder.decode(); cancelPendingPaint(); + scheduleDrain(); resolveIdleIfReady(); }, flush(): Promise { - if (!drainScheduled && !draining && pendingText.length === 0 && !resetBeforeNextWrite) { + if (!drainScheduled && !draining && operations.length === 0) { return Promise.resolve(); } scheduleDrain(); @@ -230,8 +223,8 @@ export function createNativeTerminalOutputDrain( dispose(): void { disposed = true; generation += 1; - pendingText = ""; - resetBeforeNextWrite = false; + operations = []; + queuedChars = 0; cancelPendingPaint(); while (idleWaiters.length > 0) { idleWaiters.shift()?.(); @@ -239,7 +232,7 @@ export function createNativeTerminalOutputDrain( }, getQueuedChars(): number { - return pendingText.length; + return queuedChars; }, }; } diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index dd79a77e79..cd148b9f9b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -166,7 +166,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { runtime.mount({ root, host, - initialSnapshot: null, scrollback: input.scrollback ?? 10_000, theme: { background: "#0b0b0b", @@ -750,30 +749,11 @@ describe("terminal emulator runtime in a real browser", () => { const reset = vi.fn(originalReset); terminal.reset = reset; - mounted.runtime.renderSnapshot({ - state: { - rows: terminal.rows, - cols: terminal.cols, - scrollback: [], - grid: [ - [ - { char: "p" }, - { char: "r" }, - { char: "o" }, - { char: "m" }, - { char: "p" }, - { char: "t" }, - ], - ], - cursor: { - row: 0, - col: 6, - }, - }, - }); - await nextFrame(); - + mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols }); + mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") }); + mounted.runtime.replay({ type: "end" }); expect(reset).not.toHaveBeenCalled(); + await nextFrame(); }); it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => { @@ -810,7 +790,6 @@ describe("incremental replay lifecycle", () => { mounted.runtime.mount({ root: mounted.root, host: mounted.host, - initialSnapshot: null, scrollback: 1000, theme: {}, }); @@ -840,3 +819,20 @@ describe("incremental replay lifecycle", () => { expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true); }); }); + +it("restores fitted web geometry after replay before live output", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await settleMountRefits(); + const local = latestSize(mounted.sizes); + mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 }); + mounted.runtime.replay({ type: "data", data: terminalOutput("x".repeat(100)) }); + mounted.runtime.replay({ type: "end" }); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }), + ); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); + expect(getBrowserTerminal().cols).toBeLessThan(120); + expect(getBrowserTerminal().rows).toBe(local.rows); + mounted.runtime.resize({ forceClaim: true, shouldClaim: true }); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index a11cedbfe2..a6939aaecb 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -302,25 +302,14 @@ describe("terminal-emulator-runtime", () => { expect(committed).toEqual(["first", "second", "clear", "third"]); }); - it("falls back to timeout commit for a barrier op when the gate sentinel never fires", () => { + it("unblocks queued replay writes if a begin barrier never commits", () => { vi.useFakeTimers(); - const { runtime, terminal } = createRuntimeWithTerminal(); - const onCommitted = vi.fn(); - - // restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still - // applies (and commits) after the 5s safety timeout instead of stalling forever. - runtime.restoreOutput({ - data: terminalOutput("stuck"), - onCommitted, - }); - - expect(onCommitted).not.toHaveBeenCalled(); - expect(terminal.resetCalls).toBe(0); - vi.advanceTimersByTime(5_000); - // The barrier now runs (write submitted) but its own commit still waits on xterm; advance - // again to fire the barrier's own write timeout. - vi.advanceTimersByTime(5_000); - expect(onCommitted).toHaveBeenCalledTimes(1); + const { runtime, writeTexts } = createRuntimeWithTerminal(); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("stuck") }); + expect(writeTexts).toEqual(["\x1bc"]); + vi.advanceTimersByTime(5000); + expect(writeTexts).toEqual(["\x1bc", "stuck"]); }); it("reports input mode changes from terminal output and resets them on snapshots", () => { @@ -343,18 +332,7 @@ describe("terminal-emulator-runtime", () => { }); runtime.write({ data: terminalOutput("\x1b[>7u") }); - runtime.renderSnapshot({ - state: { - rows: 2, - cols: 8, - scrollback: [], - grid: [[{ char: "$" }, { char: " " }]], - cursor: { - row: 0, - col: 2, - }, - }, - }); + runtime.replay({ type: "begin", rows: 2, cols: 8 }); // The plain write reports kitty flags synchronously during drain; the snapshot resets // them only after its barrier gate (the sentinel write callback) resolves. expect(inputModeChanges).toEqual([ @@ -410,40 +388,18 @@ describe("terminal-emulator-runtime", () => { expect(committed).toEqual(["second", "first"]); }); - it("applies a snapshot after the writes ahead of it, never interleaving the reset", () => { + it("keeps replay reset and data after preceding writes, without interleaving", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - const committed: string[] = []; - - runtime.write({ - data: terminalOutput("before-a"), - onCommitted: () => { - committed.push("before-a"); - }, - }); - runtime.write({ - data: terminalOutput("before-b"), - onCommitted: () => { - committed.push("before-b"); - }, - }); - runtime.restoreOutput({ - data: terminalOutput("snap"), - onCommitted: () => { - committed.push("snap"); - }, - }); - - // Plain writes are submitted up front; the snapshot's reset+write has not run yet. + runtime.write({ data: terminalOutput("before-a") }); + runtime.write({ data: terminalOutput("before-b") }); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("snap") }); + runtime.replay({ type: "end" }); expect(writeTexts).toEqual(["before-a", "before-b"]); - - // writeCallbacks: [0]=before-a, [1]=before-b, [2]=barrier gate sentinel. - // The snapshot is gated behind the prior writes; resolving the gate runs it after them. writeCallbacks[2]?.(); - expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]); - - // Snapshot commit fires only after its own write callback, strictly after the writes. + expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]); writeCallbacks[3]?.(); - expect(committed).toEqual(["snap"]); + expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]); expect(terminal.resetCalls).toBe(0); }); @@ -456,7 +412,7 @@ describe("terminal-emulator-runtime", () => { // No plain writes precede this barrier (mount), so there is nothing to gate: it starts // at once with no sentinel, flipping suppressInput synchronously. - runtime.restoreOutput({ data: terminalOutput("snapshot") }); + runtime.replay({ type: "begin" }); expect(readSuppressInput()).toBe(true); // writeCallbacks[0] is the barrier's own snapshot write; committing it restores input. @@ -471,7 +427,7 @@ describe("terminal-emulator-runtime", () => { // A plain write is now ungated, so the following barrier must wait on the sentinel. runtime.write({ data: terminalOutput("output") }); - runtime.restoreOutput({ data: terminalOutput("snapshot") }); + runtime.replay({ type: "begin" }); // The plain write commits first. Only the following sentinel opens the barrier. expect(readSuppressInput()).toBe(false); @@ -505,57 +461,40 @@ describe("terminal-emulator-runtime", () => { expect(onCommittedB).toHaveBeenCalledTimes(1); }); - it("clears ungated writes on unmount so remount barriers apply immediately", () => { + it("clears ungated writes on unmount so remount replay begins immediately", () => { const { runtime } = createRuntimeWithTerminal(); - runtime.write({ data: terminalOutput("before unmount") }); runtime.unmount(); - const remounted = attachStubTerminal(runtime); - runtime.restoreOutput({ data: terminalOutput("restored screen") }); - - expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]); - expect(remounted.writeCallbacks).toHaveLength(1); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("restored screen") }); + expect(remounted.writeTexts).toEqual(["\x1bc"]); + remounted.writeCallbacks[0]?.(); + expect(remounted.writeTexts).toEqual(["\x1bc", "restored screen"]); }); - it("replays snapshots through a single write without first painting a reset terminal", () => { + it("parses replay chunks serially without synchronously resetting the terminal", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - - runtime.renderSnapshot({ - state: { - rows: 2, - cols: 8, - scrollback: [], - grid: [ - [{ char: "h" }, { char: "i" }], - [{ char: "$" }, { char: " " }], - ], - cursor: { - row: 1, - col: 2, - }, - }, - }); - - // The snapshot is a barrier; its write applies after the gate sentinel (writeCallbacks[0]). - writeCallbacks[0]?.(); - + runtime.replay({ type: "begin", rows: 2, cols: 8 }); + runtime.replay({ type: "data", data: terminalOutput("hi") }); + runtime.replay({ type: "data", data: terminalOutput("\r\n$") }); + runtime.replay({ type: "end" }); expect(terminal.resetCalls).toBe(0); - expect(writeTexts).toHaveLength(1); - expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true); - expect(writeTexts[0]).toContain("hi"); + expect(writeTexts).toEqual(["\x1bc"]); + writeCallbacks[0]?.(); + expect(writeTexts).toEqual(["\x1bc", "hi"]); + writeCallbacks[1]?.(); + expect(writeTexts).toEqual(["\x1bc", "hi", "\r\n$"]); }); - it("restores server-rendered ANSI snapshots through the snapshot write path", () => { + it("restores worker-rendered ANSI through the replay lifecycle", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - - runtime.restoreOutput({ data: terminalOutput("restored screen") }); - - // restoreOutput is a barrier (suppressInput); it applies after the gate sentinel. + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("restored screen") }); + runtime.replay({ type: "end" }); writeCallbacks[0]?.(); - expect(terminal.resetCalls).toBe(0); - expect(writeTexts).toEqual(["\u001bcrestored screen"]); + expect(writeTexts).toEqual(["\x1bc", "restored screen"]); }); it("forces a refit when resize is requested", () => { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index ba215ea67b..18f843da48 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -7,7 +7,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; import { Terminal, type ITheme, type IMarker } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { type TerminalInputModeState, TerminalInputModeTracker, @@ -24,7 +23,6 @@ import { resolveMacTerminalEditingShortcut, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; import { createTerminalLocalFileLinkProvider, type TerminalLocalFileLinkSource, @@ -55,7 +53,6 @@ const FIND_HIGHLIGHT_LIMIT = 20_000; export interface TerminalEmulatorRuntimeMountInput { root: HTMLDivElement; host: HTMLDivElement; - initialSnapshot: TerminalState | null; scrollback: number; theme: ITheme; fontFamily?: string; @@ -134,7 +131,7 @@ interface TerminalEmulatorRuntimeDisposables { } interface TerminalOutputOperation { - type: "write" | "clear" | "snapshot"; + type: "write" | "clear" | "snapshot" | "fit"; data: TerminalOutputData; rows?: number; cols?: number; @@ -777,10 +774,6 @@ export class TerminalEmulatorRuntime { fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); }, 0); - if (input.initialSnapshot) { - this.renderSnapshot({ state: input.initialSnapshot }); - } - this.processOutputQueue(); const disposables: TerminalEmulatorRuntimeDisposables = { @@ -898,28 +891,18 @@ export class TerminalEmulatorRuntime { onFilePaste?.(file); } - renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { - if (!input.state) { - this.clear(input); - return; - } - this.restoreOutput({ - data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)), - rows: input.state.rows, - cols: input.state.cols, - ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), - }); - } - 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 }); + } else { + this.outputOperations.push({ type: "fit", data: EMPTY_TERMINAL_OUTPUT }); + this.processOutputQueue(); } } - restoreOutput(input: { + private restoreOutput(input: { data: TerminalOutputData; rows?: number; cols?: number; @@ -1184,6 +1167,17 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); }; + if (operation.type === "fit") { + // xterm resize flushes its write buffer synchronously. Leave the parser's + // commit callback before fitting, or that flush consumes the next write. + queueMicrotask(() => { + if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return; + this.fitAndEmitResize?.({ shouldClaim: false }); + finalizeOperation(operation); + }); + return; + } + if (operation.type === "clear") { this.inputModeDecoder.decode(); this.inputModeTracker.reset(); diff --git a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts index 8336925338..84b941a340 100644 --- a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts @@ -31,7 +31,6 @@ function mount(options?: { isMac: boolean }) { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 1000, theme: { background: "#ffffff", foreground: "#111111" }, }); diff --git a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts index 35629f01f8..62a65c6878 100644 --- a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts @@ -38,7 +38,6 @@ function mountTerminal(left: number): TerminalView { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 1000, fontFamily: "monospace", fontSize: 16, diff --git a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts index 35b3492b71..f9487f74b5 100644 --- a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts @@ -1,3 +1,4 @@ +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import { page } from "@vitest/browser/context"; import { afterEach, describe, expect, it } from "vitest"; import type { TerminalState } from "@getpaseo/protocol/messages"; @@ -57,7 +58,6 @@ function mount(width: number, height: number): Mounted { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 10_000, theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" }, }); @@ -90,9 +90,15 @@ function someRowContains(term: InspectableTerminal, text: string): boolean { return dumpRows(term).some((r) => r.text.includes(text)); } -function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) { +function replayCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) { return new Promise((resolve) => { - runtime.renderSnapshot({ state, onCommitted: resolve }); + runtime.replay({ type: "begin", rows: state.rows, cols: state.cols }); + runtime.replay({ + type: "data", + data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(state)), + }); + runtime.replay({ type: "end" }); + runtime.write({ data: new Uint8Array([0]), onCommitted: resolve }); }); } @@ -149,7 +155,7 @@ describe("terminal resize reflow repro (Paseo terminal)", () => { // 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the // long line wrapped at the server width as separate grid rows. - await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols)); + await replayCommitted(m.runtime, buildWrappedSnapshot(narrowCols)); await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]")); // 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped). diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts index 5e88532afc..7e5294b347 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts @@ -4,14 +4,14 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options"; describe("terminal restore options", () => { it("omits restore options for daemons without terminal restore modes", () => { - expect( + expect(() => resolveTerminalRestoreOptions({ scrollbackLines: 10000, supportsTerminalRestoreModes: false, canClaimSize: true, size: { rows: 24, cols: 80 }, }), - ).toBeUndefined(); + ).toThrow("Update the host to view terminals."); }); it("requests visible snapshot restore with configured scrollback for capable daemons", () => { diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.ts b/packages/app/src/terminal/runtime/terminal-restore-options.ts index aa2601e727..3fcd894eac 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; export interface ResolveTerminalRestoreOptionsInput { @@ -9,9 +10,9 @@ export interface ResolveTerminalRestoreOptionsInput { export function resolveTerminalRestoreOptions( input: ResolveTerminalRestoreOptionsInput, -): SubscribeTerminalRequest["restore"] | undefined { +): NonNullable { if (!input.supportsTerminalRestoreModes) { - return undefined; + throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate")); } return { diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts index c5fdbbd9fd..220b9d33c2 100644 --- a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts +++ b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts @@ -2,7 +2,7 @@ import { Terminal as HeadlessTerminal } from "@xterm/headless"; import { Terminal as ClientTerminal } from "@xterm/xterm"; import { describe, expect, it } from "vitest"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; interface SnapshotCell { char: string; diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.ts b/packages/app/src/terminal/runtime/terminal-snapshot.ts deleted file mode 100644 index 4b7a8e0b9b..0000000000 --- a/packages/app/src/terminal/runtime/terminal-snapshot.ts +++ /dev/null @@ -1 +0,0 @@ -export { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index d06575673a..ecd49c6fd5 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -1,3 +1,4 @@ +import { resolveTerminalRestoreOptions } from "./terminal-restore-options"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; @@ -283,3 +284,28 @@ it("reports observation failure without declaring the PTY exited, then allows re expect(h.statuses.at(-1)?.error).toBe(null); h.controller.dispose(); }); + +it("does not resize or attach on a JSON-era host and reports the required update", () => { + const client = new FakeTerminalStreamClient(); + const statuses: TerminalStreamControllerStatus[] = []; + const controller = new TerminalStreamController({ + client, + getPreferredSize: () => ({ rows: 24, cols: 40 }), + onOutput: () => {}, + getRestoreOptions: () => + resolveTerminalRestoreOptions({ + supportsTerminalRestoreModes: false, + scrollbackLines: 10000, + canClaimSize: true, + size: { rows: 24, cols: 40 }, + }), + onStatusChange: (status) => statuses.push(status), + }); + controller.setTerminal({ terminalId: "old-host-terminal" }); + expect(client.subscribeCalls).toEqual([]); + expect(client.resizeCalls).toEqual([]); + expect(statuses.at(-1)).toMatchObject({ + isAttaching: false, + error: "Update the host to view terminals.", + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 00a3de1558..b4b166cadf 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -66,10 +66,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 }); let subscription: ReturnType; try { + const restore = this.options.getRestoreOptions?.(); const preferredSize = restore?.size ?? this.options.getPreferredSize(); if (preferredSize) this.options.client.sendTerminalInput(nextTerminalId, { diff --git a/packages/app/src/utils/terminal-renderer-readiness.test.ts b/packages/app/src/utils/terminal-renderer-readiness.test.ts index 45866f8df6..d7e5f71833 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.test.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, - shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, } from "./terminal-renderer-readiness"; @@ -74,27 +73,6 @@ describe("terminal-renderer-readiness", () => { expect(current).toBeNull(); }); - it("replays snapshots only for ready events from the current renderer", () => { - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: true }, - terminalStreamKey: "scope:terminal-1", - }), - ).toBe(true); - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: false }, - terminalStreamKey: "scope:terminal-1", - }), - ).toBe(false); - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: true }, - terminalStreamKey: "scope:terminal-2", - }), - ).toBe(false); - }); - it("does not attach the stream before the matching renderer is ready", () => { expect( resolveTerminalStreamTarget({ diff --git a/packages/app/src/utils/terminal-renderer-readiness.ts b/packages/app/src/utils/terminal-renderer-readiness.ts index 7d4f855aba..197a8811f9 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.ts @@ -14,13 +14,6 @@ export function applyTerminalRendererReadyChange( return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey; } -export function shouldReplayTerminalSnapshotForRenderer(input: { - change: TerminalRendererReadyChange; - terminalStreamKey: string; -}): boolean { - return input.change.isReady && input.change.streamKey === input.terminalStreamKey; -} - export function resolveTerminalStreamTarget(input: { terminalId: string; terminalStreamKey: string; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 473c482c3b..d20c91d773 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3740,7 +3740,6 @@ export const ServerInfoStatusPayloadSchema = z resumeOnStartupSetting: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), - terminalChunkedReplay: z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. "terminal-input-mode-replay": z.boolean().optional(), // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index c7a0382142..11156cd127 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -11,7 +11,6 @@ import { type WSOutboundMessage, } from "@getpaseo/protocol/messages"; import { - decodeTerminalSnapshotPayload, decodeTerminalStreamFrame, encodeTerminalStreamFrame, TerminalStreamOpcode, @@ -423,17 +422,24 @@ async function collectRawBinaryFrames( }); } +async function decodeRestoreState( + frame: TerminalStreamFrame, + terminalId: string, +): Promise { + const size = ctx.daemon.daemon.terminalManager!.getTerminal(terminalId)!.getSize(); + const terminal = new HeadlessTerminal({ ...size, scrollback: 1000, allowProposedApi: true }); + try { + await new Promise((resolve) => terminal.write(frame.payload, resolve)); + return readHeadlessState(terminal); + } finally { + terminal.dispose(); + } +} + function getFrameText(frame: TerminalStreamFrame): string { if (frame.opcode === TerminalStreamOpcode.Output) { return new TextDecoder().decode(frame.payload); } - if (frame.opcode === TerminalStreamOpcode.Snapshot) { - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) { - return ""; - } - return extractStateText(state); - } if (frame.opcode === TerminalStreamOpcode.Restore) { return new TextDecoder().decode(frame.payload); } @@ -483,7 +489,7 @@ function startTerminalOutputCapture(ws: WebSocket, slot: number): TerminalOutput if (!frame || frame.slot !== slot) { return; } - if (frame.opcode === TerminalStreamOpcode.Snapshot) { + if (frame.opcode === TerminalStreamOpcode.Restore) { snapshotCount += 1; return; } @@ -671,7 +677,7 @@ async function measureRepeatCadenceForTerminal(input: { const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`); await waitForRawBinaryFrame( ws, - (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore, 10000, ); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -854,13 +860,7 @@ test("live terminal restore skips the initial snapshot", async () => { expect(outputText).toContain("after-live"); expect(outputText).not.toContain("before-live"); - expect( - outputFrames.some( - (frame) => - frame.opcode === TerminalStreamOpcode.Snapshot || - frame.opcode === TerminalStreamOpcode.Restore, - ), - ).toBe(false); + expect(outputFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false); } finally { await closeWebSocket(ws); } @@ -938,15 +938,12 @@ test("propagates debounced terminal titles through list responses and snapshots" } expect(listedTitle).toBe("Build Output"); - const snapshotPromise = waitForTerminalSnapshot( - ctx.client, - terminalId, - (state) => state.title === "Build Output", + const attached = await ctx.client.subscribeTerminal(terminalId); + expect(attached.error).toBeNull(); + const afterAttach = await ctx.client.listTerminals(cwd); + expect(afterAttach.terminals.find((terminal) => terminal.id === terminalId)?.title).toBe( + "Build Output", ); - await ctx.client.subscribeTerminal(terminalId); - const snapshot = await snapshotPromise; - - expect(snapshot.title).toBe("Build Output"); rmSync(cwd, { recursive: true, force: true }); }, 30000); @@ -1010,13 +1007,10 @@ test("subscribe response is sent before the initial snapshot frame", async () => return; } const frame = decodeTerminalStreamFrame(new Uint8Array(buffer)); - if (frame?.opcode !== TerminalStreamOpcode.Snapshot) { - return; - } - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state || !extractStateText(state).includes("hello-ordering")) { + if (frame?.opcode !== TerminalStreamOpcode.Restore) { return; } + if (!getFrameText(frame).includes("hello-ordering")) return; events.push("snapshot"); maybeResolve(); }; @@ -1150,7 +1144,7 @@ test("reconnected terminal streams replay active input modes after the snapshot" const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first"); await waitForRawBinaryFrame( firstWs, - (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Restore, ); sendRawTerminalInput(firstWs, firstSlot, "e"); await waitForRawBinaryFrame( @@ -1177,7 +1171,7 @@ test("reconnected terminal streams replay active input modes after the snapshot" const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second"); await waitForRawBinaryFrame( secondWs, - (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Restore, ); const replay = await waitForRawBinaryFrame( secondWs, @@ -1209,7 +1203,7 @@ test("reconnected terminal streams do not replay input modes before they are ena const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive"); await waitForRawBinaryFrame( ws, - (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore, ); await waitForNoRawBinaryFrame(ws, 300); } finally { @@ -1225,7 +1219,7 @@ test("fast output to a slow websocket client falls back to a snapshot", async () const ws = await connectRawWebSocket(ctx.daemon.port); await subscribeRawTerminal(ws, terminalId, "sub-raw"); - await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000); + await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000); const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } }) ._socket; @@ -1240,12 +1234,12 @@ test("fast output to a slow websocket client falls back to a snapshot", async () await new Promise((resolve) => setTimeout(resolve, 750)); const catchUpFramePromise = waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 15000, ); rawSocket!.resume(); const catchUpFrame = await catchUpFramePromise; - expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Restore); await closeWebSocket(ws); rmSync(cwd, { recursive: true, force: true }); @@ -1440,10 +1434,10 @@ test("websocket terminate then new connection gets snapshot with all prior outpu await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a"); const initialSnapshot = await waitForRawBinaryFrame( firstSocket, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); - expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Restore); ctx.client.sendTerminalInput(terminalId, { type: "input", @@ -1479,9 +1473,9 @@ test("websocket terminate then new connection gets snapshot with all prior outpu try { await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b"); const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000); - expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Restore); - const state = decodeTerminalSnapshotPayload(reconnectFrame.payload); + const state = await decodeRestoreState(reconnectFrame, terminalId); expect(state).not.toBeNull(); expect(extractStateText(state!)).toContain("before-drop"); expect(extractStateText(state!)).toContain("while-dead"); @@ -1550,11 +1544,11 @@ test("snapshot fidelity through websocket decode preserves dimensions and visibl await subscribeRawTerminal(ws, terminalId, "sub-fidelity"); const snapshotFrame = await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); - const state = decodeTerminalSnapshotPayload(snapshotFrame.payload); + const state = await decodeRestoreState(snapshotFrame, terminalId); expect(state).not.toBeNull(); expect(state).toMatchObject({ rows: 24, @@ -1582,7 +1576,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async await subscribeRawTerminal(ws, terminalId, "sub-exit"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1598,8 +1592,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async const exitMessage = await exitMessagePromise; expect(exitMessage.message.type).toBe("terminal_stream_exit"); - const subscribeResult = await ctx.client.subscribeTerminal(terminalId); - expect(subscribeResult.error).toBe("Terminal not found"); + await expect(ctx.client.subscribeTerminal(terminalId)).rejects.toThrow("Terminal not found"); await waitForNoRawBinaryFrame(ws, 750); } finally { await closeWebSocket(ws); @@ -1617,7 +1610,7 @@ test("empty input frame does not crash the server", async () => { const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1656,7 +1649,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as await subscribeRawTerminal(ws, terminalId, "sub-large-output"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1673,7 +1666,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as .reduce((sum, frame) => sum + frame.payload.byteLength, 0); return ( outputBytes >= 1024 * 1024 || - collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot) + collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore) ); }, 20000, @@ -1683,7 +1676,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as for (const frame of frames) { expect( frame.opcode === TerminalStreamOpcode.Output || - frame.opcode === TerminalStreamOpcode.Snapshot, + frame.opcode === TerminalStreamOpcode.Restore, ).toBe(true); } diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index 2a3128ea9f..fc73e44db4 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -822,86 +822,100 @@ test("same-workspace terminal directory observers do not share a teardown slot", } }); -test("terminal output has separate server IDs and binary slots, including in a shared session", async () => { - const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); - const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); - const peers: SubscriptionPeer[] = []; - try { - await admin.connect(); - const workspace = ( - await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) - ).workspace!; - const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { - workspaceId: workspace.id, - command: process.execPath, - args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], +test.each([false, true])( + "terminal output has separate server IDs and binary slots, including in a shared session (chunked=%s)", + async (chunked) => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", }); - expect(result.error).toBe(null); - const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); - peers.push(idle); - const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); - peers.push(peer); - for (const requestId of ["output-a", "output-b"]) { - await peer.request({ - type: "subscribe_terminal_request", - requestId, - terminalId: result.terminal!.id, + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { + workspaceId: workspace.id, + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], }); - } - const replies = peer.frames.flatMap((frame) => - frame.type === "session" && - frame.message.type === "subscribe_terminal_response" && - frame.message.payload.error === null - ? [frame.message.payload] - : [], - ); - expect(replies).toHaveLength(2); - const ids = replies.map( - (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, - ); - expect(ids.every((id) => typeof id === "string")).toBe(true); - expect(ids[0]).not.toBe(ids[1]); - expect(replies[0].slot).not.toBe(replies[1].slot); - await expect - .poll(() => - peer.binaryFrames - .map(decodeTerminalStreamFrame) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot) - .map((frame) => frame!.slot), - ) - .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); - expect(idle.binaryFrames).toEqual([]); - await peer.request({ - type: "subscription.release.request", - requestId: "release-output-a", - subscriptionId: ids[0], - }); - peer.binaryFrames.length = 0; - admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); - await expect - .poll( - () => + expect(result.error).toBe(null); + const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", { + terminal_chunked_replay: chunked, + }); + peers.push(idle); + const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", { + terminal_chunked_replay: chunked, + }); + peers.push(peer); + for (const requestId of ["output-a", "output-b"]) { + await peer.request({ + type: "subscribe_terminal_request", + requestId, + terminalId: result.terminal!.id, + }); + } + const replies = peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "subscribe_terminal_response" && + frame.message.payload.error === null + ? [frame.message.payload] + : [], + ); + expect(replies).toHaveLength(2); + const ids = replies.map( + (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + expect(replies[0].slot).not.toBe(replies[1].slot); + await expect + .poll(() => peer.binaryFrames .map(decodeTerminalStreamFrame) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) - .map((frame) => Buffer.from(frame!.payload).toString()) - .join(""), - { timeout: 5000 }, - ) - .toContain("remaining-observer"); - expect( - peer.binaryFrames - .map(decodeTerminalStreamFrame) - .every((frame) => frame?.slot === replies[1].slot), - ).toBe(true); - expect(idle.binaryFrames).toEqual([]); - await admin.killTerminal(result.terminal!.id); - } finally { - for (const peer of peers) peer.close(); - await admin.close(); - await daemon.close(); - } -}); + .filter( + (frame) => + frame?.opcode === + (chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore), + ) + .map((frame) => frame!.slot), + ) + .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); + expect(idle.binaryFrames).toEqual([]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-output-a", + subscriptionId: ids[0], + }); + peer.binaryFrames.length = 0; + admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); + await expect + .poll( + () => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => Buffer.from(frame!.payload).toString()) + .join(""), + { timeout: 5000 }, + ) + .toContain("remaining-observer"); + expect( + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .every((frame) => frame?.slot === replies[1].slot), + ).toBe(true); + expect(idle.binaryFrames).toEqual([]); + await admin.killTerminal(result.terminal!.id); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } + }, +); test("a modern connection and pure reads retain no client observation producers", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8cfaa000eb..b7024a638b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2016,7 +2016,6 @@ export class VoiceAssistantWebSocketServer { resumeOnStartupSetting: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, - terminalChunkedReplay: true, // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. "terminal-input-mode-replay": true, // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index b4adbfd221..d64ad0ea55 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -43,7 +43,9 @@ function replayFixture( } function createController( - options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, + options: TerminalSessionControllerOptions & { + emitBinary(frame: Uint8Array): void | Promise; + }, ) { const source = {}; const ownership = new SessionDelivery( @@ -676,7 +678,12 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { }); describe("terminal-session-controller backpressure snapshot fallback", () => { - async function setup(getClientBufferedAmount: () => number | null): Promise<{ + async function setup( + getClientBufferedAmount: () => number | null, + chunked = false, + pace?: () => Promise, + ): Promise<{ + messages: SessionOutboundMessage[]; pushOutput: (data: string) => void; frames: TerminalStreamFrame[]; }> { @@ -726,13 +733,18 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { }; const frames: TerminalStreamFrame[] = []; + const messages: SessionOutboundMessage[] = []; const controller = createController({ terminalManager, - emit: vi.fn(), + emit: (message) => { + messages.push(message); + }, + supportsChunkedReplay: () => chunked, emitBinary: (bytes) => { const frame = decodeTerminalStreamFrame(bytes); if (frame) { frames.push(frame); + if (frame.opcode === TerminalStreamOpcode.ReplayBegin) return pace?.(); } }, hasBinaryChannel: () => true, @@ -754,6 +766,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { return { pushOutput: (data) => terminalListener?.({ type: "output", data, revision: 2 }), frames, + messages, }; } @@ -818,6 +831,32 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true); }); + test("paces capable catch-up while the direct socket drains instead of ending the stream", async () => { + let buffered = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { pushOutput, frames, messages } = await setup( + () => buffered, + true, + () => (buffered ? gate : Promise.resolve()), + ); + buffered = 8 * 1024 * 1024; + pushOutput("y".repeat(300 * 1024)); + await waitForCoalescerFlush(); + expect(messages.filter((message) => message.type === "terminal_stream_exit")).toEqual([]); + expect(frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.ReplayBegin]); + buffered = 0; + release(); + await waitForCoalescerFlush(); + expect(frames.map((frame) => frame.opcode)).toContain(TerminalStreamOpcode.ReplayEnd); + frames.length = 0; + buffered = 8 * 1024 * 1024; // Small output must stay live after the replay resets its byte counter. + pushOutput("live-after-recovery"); + await waitForCoalescerFlush(); + expect(frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]); + }); }); function exitFixture(readReplay: () => Promise, backedUp = false) { diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 0989c68fc4..1e80af689b 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -1102,25 +1102,6 @@ export class TerminalSessionController { snapshotOptions, async (event) => { if (this.activeStreams.get(activeStream.slot) !== activeStream) return; - const buffered = this.options.getClientBufferedAmount?.(activeStream.owner.source); - if ( - chunked && - buffered !== null && - buffered !== undefined && - buffered > MAX_CLIENT_BUFFERED_BYTES - ) { - activeStream.owner.emit({ - type: "terminal_stream_exit", - payload: { - terminalId: activeStream.terminalId, - error: "Terminal replay transport overflow; attach again", - }, - }); - this.detachRegistration(activeStream); - void activeStream.owner.release(); - - return; - } await deliver(event); }, );