diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 1f8d43c02..abd0ab60a 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3323,4 +3323,62 @@ describe('registerPtyHandlers', () => { await expect(pending).resolves.toBeNull() }) }) + + describe('serializeHeadlessBuffer IPC', () => { + function setup() { + const runtime = { + setPtyController: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn(), + serializeHeadlessTerminalBufferForRenderer: vi.fn().mockResolvedValue({ + data: 'headless', + cols: 100, + rows: 30 + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const handler = handlers.get('pty:serializeHeadlessBuffer') + if (!handler) { + throw new Error('expected pty:serializeHeadlessBuffer handler registration') + } + return { runtime, handler } + } + + it('delegates valid requests to the runtime headless terminal serializer', async () => { + const { runtime, handler } = setup() + + await expect(handler(null, { id: 'pty-1', scrollbackRows: 1000.8 })).resolves.toEqual({ + data: 'headless', + cols: 100, + rows: 30 + }) + + expect(runtime.serializeHeadlessTerminalBufferForRenderer).toHaveBeenCalledWith('pty-1', { + scrollbackRows: 1000 + }) + }) + + it('ignores invalid ids and unsafe scrollback values', async () => { + const { runtime, handler } = setup() + + await expect(handler(null, { id: 42, scrollbackRows: 1000 })).resolves.toBeNull() + await handler(null, { id: 'pty-2', scrollbackRows: -1 }) + await handler(null, { id: 'pty-3', scrollbackRows: Number.POSITIVE_INFINITY }) + + expect(runtime.serializeHeadlessTerminalBufferForRenderer).toHaveBeenCalledTimes(2) + expect(runtime.serializeHeadlessTerminalBufferForRenderer).toHaveBeenNthCalledWith( + 1, + 'pty-2', + {} + ) + expect(runtime.serializeHeadlessTerminalBufferForRenderer).toHaveBeenNthCalledWith( + 2, + 'pty-3', + {} + ) + }) + }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 30a884420..5bfe7cb5a 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -598,6 +598,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeHandler('pty:getCwd') + ipcMain.removeHandler('pty:serializeHeadlessBuffer') ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') @@ -1817,6 +1818,27 @@ export function registerPtyHandlers( } }) + ipcMain.handle( + 'pty:serializeHeadlessBuffer', + async ( + _event, + args: { id?: unknown; scrollbackRows?: unknown } + ): Promise<{ data: string; cols: number; rows: number } | null> => { + if (!runtime || typeof args?.id !== 'string') { + return null + } + const opts: { scrollbackRows?: number } = {} + if ( + typeof args.scrollbackRows === 'number' && + Number.isFinite(args.scrollbackRows) && + args.scrollbackRows >= 0 + ) { + opts.scrollbackRows = Math.floor(args.scrollbackRows) + } + return runtime.serializeHeadlessTerminalBufferForRenderer(args.id, opts) + } + ) + // Why: pre-signal handshake handlers. See // docs/mobile-prefer-renderer-scrollback.md and the rationale on // `pendingByPaneKey` above. The IPC contract is: renderer awaits declare diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b46299e67..9ef7f8bb2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2227,6 +2227,13 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + serializeHeadlessTerminalBufferForRenderer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number } | null> { + return this.serializeHeadlessTerminalBuffer(ptyId, opts) + } + async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { const leaf = this.resolveLeafForHandle(handle) if (!leaf?.ptyId) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 59e2767ec..329bfd4ac 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -769,6 +769,10 @@ export type PreloadApi = { hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise + serializeHeadlessBuffer: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise<{ data: string; cols: number; rows: number } | null> listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> onData: (callback: (data: { id: string; data: string }) => void) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 5063c46d0..3d43ba1a0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -676,6 +676,15 @@ const api = { * Returns `''` when the id is unknown or the platform cannot resolve one. */ getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), + serializeHeadlessBuffer: ( + id: string, + opts?: { scrollbackRows?: number } + ): Promise<{ data: string; cols: number; rows: number } | null> => + ipcRenderer.invoke('pty:serializeHeadlessBuffer', { + id, + scrollbackRows: opts?.scrollbackRows + }), + onData: (callback: (data: { id: string; data: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => callback(data) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index d8a07be77..cc2bb9e24 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -993,6 +993,7 @@ export default function TerminalPane({ managerRef, containerRef, paneTransportsRef, + replayingPanesRef, isActiveRef, isVisibleRef, toggleExpandPane diff --git a/src/renderer/src/components/terminal-pane/hidden-terminal-output-state.ts b/src/renderer/src/components/terminal-pane/hidden-terminal-output-state.ts new file mode 100644 index 000000000..0b79e72c8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-terminal-output-state.ts @@ -0,0 +1,218 @@ +import { e2eConfig } from '@/lib/e2e-config' + +type TerminalOutputTarget = { + write(data: string, callback?: () => void): void +} + +type HiddenTerminalState = { + ptyId: string + chunks: string[] + bytes: number + needsHydration: boolean + hydrating: boolean + hydrationToken: number +} + +type HiddenTerminalOutputDebugSnapshot = { + queuedWriteCount: number + queuedBytes: number + droppedBytes: number + hydrationCount: number + fallbackReplayCount: number + clearedCount: number +} + +type HiddenTerminalOutputDebugApi = { + reset: () => void + snapshot: () => HiddenTerminalOutputDebugSnapshot +} + +export type HiddenTerminalHydration = { + ptyId: string + fallbackData: string + token: number + fallbackChunkCount: number +} + +const MAX_FALLBACK_BYTES = 512 * 1024 +const hiddenStateByTerminal = new Map() +const debugEnabled = e2eConfig.exposeStore +let nextHydrationToken = 1 + +const debugState: HiddenTerminalOutputDebugSnapshot = { + queuedWriteCount: 0, + queuedBytes: 0, + droppedBytes: 0, + hydrationCount: 0, + fallbackReplayCount: 0, + clearedCount: 0 +} + +function resetDebugState(): void { + debugState.queuedWriteCount = 0 + debugState.queuedBytes = 0 + debugState.droppedBytes = 0 + debugState.hydrationCount = 0 + debugState.fallbackReplayCount = 0 + debugState.clearedCount = 0 +} + +function exposeDebugApi(): void { + if (!debugEnabled || typeof window === 'undefined') { + return + } + // Why: terminal perf e2e tests need to prove hidden output avoided visible + // xterm writes while production avoids retaining diagnostics indefinitely. + const target = window as unknown as { + __hiddenTerminalOutputDebug?: HiddenTerminalOutputDebugApi + } + target.__hiddenTerminalOutputDebug ??= { + reset: resetDebugState, + snapshot: () => ({ ...debugState }) + } +} + +function trimFallback(state: HiddenTerminalState): void { + while (state.bytes > MAX_FALLBACK_BYTES && state.chunks.length > 1) { + const dropped = state.chunks.shift() + if (!dropped) { + continue + } + state.bytes -= dropped.length + if (debugEnabled) { + debugState.droppedBytes += dropped.length + } + } + if (state.bytes > MAX_FALLBACK_BYTES && state.chunks.length === 1) { + const chunk = state.chunks[0] + const keepFrom = Math.max(0, chunk.length - MAX_FALLBACK_BYTES) + if (keepFrom > 0) { + state.chunks[0] = chunk.slice(keepFrom) + state.bytes = state.chunks[0].length + if (debugEnabled) { + debugState.droppedBytes += keepFrom + } + } + } +} + +export function queueHiddenTerminalOutput( + terminal: TerminalOutputTarget, + ptyId: string, + data: string +): void { + exposeDebugApi() + if (!data) { + return + } + let state = hiddenStateByTerminal.get(terminal) + if (!state || state.ptyId !== ptyId) { + state = { + ptyId, + chunks: [], + bytes: 0, + needsHydration: false, + hydrating: false, + hydrationToken: 0 + } + hiddenStateByTerminal.set(terminal, state) + } + state.needsHydration = true + state.chunks.push(data) + state.bytes += data.length + trimFallback(state) + if (debugEnabled) { + debugState.queuedWriteCount++ + debugState.queuedBytes += data.length + } +} + +export function consumeHiddenTerminalHydration( + terminal: TerminalOutputTarget +): HiddenTerminalHydration | null { + exposeDebugApi() + const state = hiddenStateByTerminal.get(terminal) + if (!state?.needsHydration) { + return null + } + state.needsHydration = false + state.hydrating = true + state.hydrationToken = nextHydrationToken++ + if (debugEnabled) { + debugState.hydrationCount++ + } + return { + ptyId: state.ptyId, + fallbackData: state.chunks.join(''), + token: state.hydrationToken, + fallbackChunkCount: state.chunks.length + } +} + +function finishHydration( + terminal: TerminalOutputTarget, + token: number, + consumedChunkCount: number +): string { + exposeDebugApi() + const state = hiddenStateByTerminal.get(terminal) + if (!state || state.hydrationToken !== token) { + return '' + } + const queuedDuringHydration = state.chunks.slice(consumedChunkCount).join('') + state.chunks.length = 0 + state.bytes = 0 + state.needsHydration = false + state.hydrating = false + state.hydrationToken = 0 + return queuedDuringHydration +} + +export function markHiddenTerminalFallbackReplayed( + terminal: TerminalOutputTarget, + hydration: HiddenTerminalHydration +): string { + const queuedDuringHydration = finishHydration( + terminal, + hydration.token, + hydration.fallbackChunkCount + ) + if (debugEnabled) { + debugState.fallbackReplayCount++ + } + return queuedDuringHydration +} + +export function markHiddenTerminalHydrated( + terminal: TerminalOutputTarget, + hydration: HiddenTerminalHydration +): string { + return finishHydration(terminal, hydration.token, hydration.fallbackChunkCount) +} + +export function cancelHiddenTerminalHydration( + terminal: TerminalOutputTarget, + hydration: HiddenTerminalHydration +): void { + exposeDebugApi() + const state = hiddenStateByTerminal.get(terminal) + if (!state || state.hydrationToken !== hydration.token) { + return + } + state.hydrating = false + state.hydrationToken = 0 + state.needsHydration = state.chunks.length > 0 +} + +export function isHiddenTerminalHydrating(terminal: TerminalOutputTarget): boolean { + return hiddenStateByTerminal.get(terminal)?.hydrating === true +} + +export function clearHiddenTerminalOutput(terminal: TerminalOutputTarget): void { + exposeDebugApi() + if (hiddenStateByTerminal.delete(terminal) && debugEnabled) { + debugState.clearedCount++ + } +} + +exposeDebugApi() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 686f63023..79e602c7e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2184,50 +2184,59 @@ describe('connectPanePty', () => { }) // Regression for foreground input lag with many background terminals: - // hidden panes still feed xterm, but their writes are scheduled through - // the shared output drain so 100 panes cannot all start xterm WriteBuffer - // setTimeout handlers in the same event-loop burst. - it('queues non-visible PTY bytes before writing them into xterm', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout + // hidden panes should not feed their visible xterm at all. Their current + // state is restored from the runtime headless model when the pane is shown. + it('does not write non-visible PTY bytes into xterm', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + capturedDataCallback.current?.('hello\r\n') - expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('hello\r\n') - expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n') + expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n') + }) - for (const fn of pendingTimeouts) { - fn() - } + it('keeps visible PTY bytes off xterm while hidden hydration is in flight', async () => { + const { connectPanePty } = await import('./pty-connection') + const { consumeHiddenTerminalHydration, queueHiddenTerminalOutput } = + await import('./hidden-terminal-output-state') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) - expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n') - } finally { - globalThis.setTimeout = originalSetTimeout - } + const pane = createPane(1) + queueHiddenTerminalOutput(pane.terminal, 'pty-id', 'hidden-before-reveal') + consumeHiddenTerminalHydration(pane.terminal) + const deps = createDeps({ + isVisibleRef: { current: true } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + capturedDataCallback.current?.('arrived-during-hydration\r\n') + + expect(pane.terminal.write).not.toHaveBeenCalledWith('arrived-during-hydration\r\n') }) it('writes visible split-pane PTY bytes immediately even when the tab is not active', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 71cee35dd..e25c1ba81 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -50,6 +50,11 @@ import { type AgentInterruptInputIntent } from '../../../../shared/agent-interrupt-intent' import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + clearHiddenTerminalOutput, + isHiddenTerminalHydrating, + queueHiddenTerminalOutput +} from './hidden-terminal-output-state' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -1146,6 +1151,7 @@ export function connectPanePty( }, () => { discardTerminalOutput(pane.terminal) + clearHiddenTerminalOutput(pane.terminal) pane.terminal.clear() } ) @@ -1230,6 +1236,7 @@ export function connectPanePty( // Why: drain any queued background bytes BEFORE the replay paint, so the // scheduler's deferred drain cannot land older bytes on top of the replay. flushTerminalOutput(pane.terminal) + clearHiddenTerminalOutput(pane.terminal) if (terminalOutputPrefersDomRenderer(data)) { manager.markPaneHasComplexScriptOutput(pane.id) } @@ -1246,6 +1253,16 @@ export function connectPanePty( const dataCallback = (data: string): void => { commandLifecycle.handlePtyData(data) + if (!deps.isVisibleRef.current || isHiddenTerminalHydrating(pane.terminal)) { + const ptyId = transport.getPtyId() + if (ptyId) { + if (terminalOutputPrefersDomRenderer(data)) { + manager.markPaneHasComplexScriptOutput(pane.id) + } + queueHiddenTerminalOutput(pane.terminal, ptyId, data) + return + } + } // Why: visibility is the right gate — split-pane layouts have multiple // visible-but-inactive panes whose output the user is watching. Only // hidden panes (background tabs) should be throttled. @@ -1884,6 +1901,7 @@ export function connectPanePty( pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() discardTerminalOutput(pane.terminal) + clearHiddenTerminalOutput(pane.terminal) if (agentTaskCompleteSettingsUnsubscribe !== null) { agentTaskCompleteSettingsUnsubscribe() agentTaskCompleteSettingsUnsubscribe = null diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 9e556bead..d74896e0c 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -1,6 +1,10 @@ +/* oxlint-disable max-lines -- Why: these tests exercise one hook's visibility, +hydration, scroll, paste, and file-drop effects against a shared mocked React +ref harness; splitting would duplicate brittle setup. */ import type * as ReactModule from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' +import { queueHiddenTerminalOutput } from './hidden-terminal-output-state' const mocks = vi.hoisted(() => ({ captureScrollState: vi.fn(), @@ -118,6 +122,7 @@ function useMountForFileDrop( managerRef: { current: manager as never }, containerRef: { current: null }, paneTransportsRef: { current: paneTransports }, + replayingPanesRef: { current: new Map() }, isActiveRef: { current: false }, isVisibleRef: { current: false }, toggleExpandPane: vi.fn() @@ -136,6 +141,9 @@ describe('useTerminalPaneGlobalEffects', () => { api: { ui: { onFileDrop: vi.fn(() => vi.fn()) + }, + pty: { + serializeHeadlessBuffer: vi.fn().mockResolvedValue(null) } } } @@ -186,6 +194,7 @@ describe('useTerminalPaneGlobalEffects', () => { managerRef: { current: manager as never }, containerRef: { current: null }, paneTransportsRef: { current: new Map() }, + replayingPanesRef: { current: new Map() }, isActiveRef, isVisibleRef, toggleExpandPane: vi.fn() @@ -206,6 +215,111 @@ describe('useTerminalPaneGlobalEffects', () => { expect(isVisibleRef.current).toBe(true) }) + it('hydrates hidden terminal output from the headless snapshot when becoming visible', async () => { + const terminal = { + name: 'terminal-a', + options: { scrollback: 1000 }, + write: vi.fn((_data: string, callback?: () => void) => callback?.()) + } + const pane = { id: 1, terminal } + const transport = { getPtyId: vi.fn(() => 'pty-1') } + const manager = { + getPanes: vi.fn(() => [pane]), + resumeRendering: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + const replayingPanesRef = { current: new Map() } + const isVisibleRef = { current: false } + queueHiddenTerminalOutput(terminal, 'pty-1', 'fallback-hidden-output') + window.api.pty.serializeHeadlessBuffer = vi.fn().mockResolvedValue({ + data: 'headless-current-output', + cols: 120, + rows: 40 + }) + + beginHookRender() + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + paneCount: 1, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map([[1, transport]]) as never }, + replayingPanesRef, + isActiveRef: { current: false }, + isVisibleRef, + toggleExpandPane: vi.fn() + }) + + await Promise.resolve() + await Promise.resolve() + + expect(window.api.pty.serializeHeadlessBuffer).toHaveBeenCalledWith('pty-1', { + scrollbackRows: 1000 + }) + expect(terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) + expect(terminal.write).toHaveBeenCalledWith('headless-current-output', expect.any(Function)) + expect(terminal.write).not.toHaveBeenCalledWith('fallback-hidden-output', expect.any(Function)) + }) + + it('keeps reveal-time output queued until the headless hydration completes', async () => { + const terminal = { + name: 'terminal-a', + options: { scrollback: 1000 }, + write: vi.fn((_data: string, callback?: () => void) => callback?.()) + } + const pane = { id: 1, terminal } + const transport = { getPtyId: vi.fn(() => 'pty-1') } + const manager = { + getPanes: vi.fn(() => [pane]), + resumeRendering: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + let resolveSnapshot!: (snapshot: { data: string; cols: number; rows: number } | null) => void + const snapshotPromise = new Promise<{ data: string; cols: number; rows: number } | null>( + (resolve) => { + resolveSnapshot = resolve + } + ) + window.api.pty.serializeHeadlessBuffer = vi.fn( + () => snapshotPromise + ) as typeof window.api.pty.serializeHeadlessBuffer + queueHiddenTerminalOutput(terminal, 'pty-1', 'fallback-hidden-output') + + beginHookRender() + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + paneCount: 1, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map([[1, transport]]) as never }, + replayingPanesRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + toggleExpandPane: vi.fn() + }) + queueHiddenTerminalOutput(terminal, 'pty-1', 'arrived-during-hydration') + + resolveSnapshot({ data: 'headless-current-output', cols: 120, rows: 40 }) + await Promise.resolve() + await Promise.resolve() + + expect(terminal.write).toHaveBeenCalledWith('headless-current-output', expect.any(Function)) + expect(terminal.write).toHaveBeenCalledWith('arrived-during-hydration', expect.any(Function)) + expect(terminal.write).not.toHaveBeenCalledWith('fallback-hidden-output', expect.any(Function)) + }) + it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => { const terminalA = { name: 'terminal-a' } const manager = { @@ -228,6 +342,7 @@ describe('useTerminalPaneGlobalEffects', () => { managerRef: { current: manager as never }, containerRef: { current: null }, paneTransportsRef: { current: new Map() }, + replayingPanesRef: { current: new Map() }, isActiveRef: { current: false }, isVisibleRef: { current: false }, paneCount: 1, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index af84cc9bb..b0a2da544 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -1,4 +1,7 @@ -import { useEffect, useRef } from 'react' +/* oxlint-disable max-lines -- Why: this hook owns global terminal-pane effects +that share the same visibility, focus, and manager refs. Splitting the effect +coordination would make resume/hydration ordering harder to audit. */ +import { useCallback, useEffect, useRef } from 'react' import { FOCUS_TERMINAL_PANE_EVENT, PASTE_TERMINAL_TEXT_EVENT, @@ -17,6 +20,14 @@ import { useAppStore } from '@/store' import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibility-memory' import { useTerminalContainerFitSync } from './use-terminal-container-fit-sync' +import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import { + cancelHiddenTerminalHydration, + clearHiddenTerminalOutput, + consumeHiddenTerminalHydration, + markHiddenTerminalFallbackReplayed, + markHiddenTerminalHydrated +} from './hidden-terminal-output-state' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -28,6 +39,7 @@ type UseTerminalPaneGlobalEffectsArgs = { managerRef: React.RefObject containerRef: React.RefObject paneTransportsRef: React.RefObject> + replayingPanesRef: ReplayingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject toggleExpandPane: (paneId: number) => void @@ -43,6 +55,7 @@ export function useTerminalPaneGlobalEffects({ managerRef, containerRef, paneTransportsRef, + replayingPanesRef, isActiveRef, isVisibleRef, toggleExpandPane @@ -69,6 +82,83 @@ export function useTerminalPaneGlobalEffects({ }) useTerminalContainerFitSync({ isVisible, managerRef, containerRef }) + const hydrateHiddenPane = useCallback( + (pane: ReturnType[number]): void => { + const hydration = consumeHiddenTerminalHydration(pane.terminal) + if (!hydration) { + return + } + const transport = paneTransportsRef.current.get(pane.id) + if (transport?.getPtyId() !== hydration.ptyId) { + clearHiddenTerminalOutput(pane.terminal) + return + } + const scrollbackRows = + typeof pane.terminal.options?.scrollback === 'number' + ? pane.terminal.options.scrollback + : undefined + void window.api.pty + .serializeHeadlessBuffer(hydration.ptyId, { scrollbackRows }) + .then((snapshot) => { + if (!isVisibleRef.current) { + cancelHiddenTerminalHydration(pane.terminal, hydration) + return + } + if (transport.getPtyId() !== hydration.ptyId) { + clearHiddenTerminalOutput(pane.terminal) + return + } + if (snapshot?.data) { + // Why: hidden output no longer painted into the visible xterm. + // Rehydrate from the runtime headless model once, under the replay + // guard, so terminal query replies do not leak into the shell. + replayIntoTerminal(pane, replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H') + replayIntoTerminal(pane, replayingPanesRef, snapshot.data) + const queuedDuringHydration = markHiddenTerminalHydrated(pane.terminal, hydration) + if (queuedDuringHydration) { + replayIntoTerminal(pane, replayingPanesRef, queuedDuringHydration) + } + return + } + if (hydration.fallbackData) { + replayIntoTerminal(pane, replayingPanesRef, hydration.fallbackData) + const queuedDuringHydration = markHiddenTerminalFallbackReplayed( + pane.terminal, + hydration + ) + if (queuedDuringHydration) { + replayIntoTerminal(pane, replayingPanesRef, queuedDuringHydration) + } + return + } + markHiddenTerminalFallbackReplayed(pane.terminal, hydration) + }) + .catch(() => { + if (!isVisibleRef.current) { + cancelHiddenTerminalHydration(pane.terminal, hydration) + return + } + if (transport.getPtyId() !== hydration.ptyId) { + clearHiddenTerminalOutput(pane.terminal) + return + } + if (hydration.fallbackData) { + replayIntoTerminal(pane, replayingPanesRef, hydration.fallbackData) + const queuedDuringHydration = markHiddenTerminalFallbackReplayed( + pane.terminal, + hydration + ) + if (queuedDuringHydration) { + replayIntoTerminal(pane, replayingPanesRef, queuedDuringHydration) + } + return + } + markHiddenTerminalFallbackReplayed(pane.terminal, hydration) + }) + }, + [isVisibleRef, paneTransportsRef, replayingPanesRef] + ) + useEffect(() => { const manager = managerRef.current if (!manager) { @@ -106,6 +196,7 @@ export function useTerminalPaneGlobalEffects({ if (position) { restoreScrollStateAfterLayout(pane.terminal, position) } + hydrateHiddenPane(pane) } }) wasVisibleRef.current = true diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c74b0ce04..5fefe9315 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1800,6 +1800,7 @@ function createPtyApi(): NonNullable['pty']> { hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), + serializeHeadlessBuffer: () => Promise.resolve(null), listSessions: () => Promise.resolve([]), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe,