perf: hydrate hidden terminal output on reveal (#2682)

* perf: hydrate hidden terminal output on reveal

* test: fix hidden terminal hydration typecheck
This commit is contained in:
Neil 2026-05-23 02:07:26 -07:00 committed by GitHub
parent 5a23ac4eb1
commit 3adad22eca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 592 additions and 39 deletions

View File

@ -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',
{}
)
})
})
})

View File

@ -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

View File

@ -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) {

View File

@ -769,6 +769,10 @@ export type PreloadApi = {
hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null>
getCwd: (id: string) => Promise<string>
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

View File

@ -676,6 +676,15 @@ const api = {
* Returns `''` when the id is unknown or the platform cannot resolve one. */
getCwd: (id: string): Promise<string> => 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)

View File

@ -993,6 +993,7 @@ export default function TerminalPane({
managerRef,
containerRef,
paneTransportsRef,
replayingPanesRef,
isActiveRef,
isVisibleRef,
toggleExpandPane

View File

@ -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<TerminalOutputTarget, HiddenTerminalState>()
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()

View File

@ -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<typeof setTimeout>
}) 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 () => {

View File

@ -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<string, Promise<string | null>>()
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

View File

@ -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<number, number>() }
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<number, number>() },
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,

View File

@ -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<PaneManager | null>
containerRef: React.RefObject<HTMLDivElement | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
replayingPanesRef: ReplayingPanesRef
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
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<PaneManager['getPanes']>[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

View File

@ -1800,6 +1800,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
hasChildProcesses: () => Promise.resolve(false),
getForegroundProcess: () => Promise.resolve(null),
getCwd: () => Promise.resolve('~'),
serializeHeadlessBuffer: () => Promise.resolve(null),
listSessions: () => Promise.resolve([]),
onData: () => noopUnsubscribe,
onReplay: () => noopUnsubscribe,