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:
parent
5a23ac4eb1
commit
3adad22eca
|
|
@ -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',
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -993,6 +993,7 @@ export default function TerminalPane({
|
|||
managerRef,
|
||||
containerRef,
|
||||
paneTransportsRef,
|
||||
replayingPanesRef,
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
toggleExpandPane
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue