diff --git a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts index bed6553c415..9d28a1a8b05 100644 --- a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts +++ b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts @@ -85,6 +85,19 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(false) }) + it('can opt synchronized chunks into model-backed restore for prototype coverage', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: true, + allowSynchronizedModelRestore: true, + data: '\x1b[?2026h\x1b[2J\x1b[H╭ rich 😀 ╮\r\n\x1b[?2026l' + }) + ).toBe(true) + }) + it('keeps startup query windows live', () => { expect( shouldSkipHiddenRendererOutput({ diff --git a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts index b78319bdb92..c4f81c10827 100644 --- a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts +++ b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts @@ -3,6 +3,7 @@ export type HiddenRendererSkipEligibility = { canRestoreHiddenOutput: boolean startupRendererQueryWindowActive: boolean synchronizedOutputActive: boolean + allowSynchronizedModelRestore?: boolean data: string } @@ -116,18 +117,21 @@ export function shouldSkipHiddenRendererOutput({ canRestoreHiddenOutput, startupRendererQueryWindowActive, synchronizedOutputActive, + allowSynchronizedModelRestore = false, data }: HiddenRendererSkipEligibility): boolean { if ( foreground || !canRestoreHiddenOutput || startupRendererQueryWindowActive || - // Why: DEC 2026 frames can arrive split across chunks; safe-looking rows - // may precede rich table/TUI bytes that need live xterm renderer state. - synchronizedOutputActive || data.length === 0 ) { return false } + if (synchronizedOutputActive) { + // Why: release behavior keeps split DEC 2026 frames live. The override is + // only for proving model-backed replay before shipping richer hidden skips. + return allowSynchronizedModelRestore + } return containsOnlyRestorableHiddenOutput(data) } 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 967da282a5d..4d3dc9c2a25 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -533,6 +533,8 @@ describe('connectPanePty', () => { } delete (globalThis as unknown as { window?: unknown }).window delete (globalThis as Record).__ptyConnectDiag + delete (globalThis as Record) + .__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__ }) it('does not retain PTY connect diagnostics unless e2e debug state is enabled', async () => { @@ -3132,6 +3134,82 @@ describe('connectPanePty', () => { } }) + it('can prototype hidden rich synchronized restore from the headless model', async () => { + ;(globalThis as Record).__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__ = + true + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const richHiddenFrame = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ model-backed rich 😀 ███░ │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h', + '\x1b[?2026l' + ].join('') + const visibleTrigger = 'visible-trigger\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: richHiddenFrame, + cols: 96, + rows: 18, + seq: richHiddenFrame.length + visibleTrigger.length, + source: 'headless' + }) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(richHiddenFrame, { + seq: richHiddenFrame.length, + rawLength: richHiddenFrame.length + }) + await flushAsyncTicks(2) + + expect(pane.terminal.write).not.toHaveBeenCalled() + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleTrigger, { + seq: richHiddenFrame.length + visibleTrigger.length, + rawLength: visibleTrigger.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.resize).toHaveBeenCalledWith(96, 18) + expect(pane.terminal.write).toHaveBeenCalledWith(richHiddenFrame, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + disposable.dispose() + }) + it('queues visible split-pane PTY bytes when the pane is not active', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 6d48109a9ae..d825916a3d9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -420,6 +420,15 @@ function recordPtyConnectDiagnostic(message: string): void { } } +function shouldAllowPrototypeSynchronizedHiddenModelRestore(): boolean { + const target = globalThis as { + __ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__?: boolean + } + return ( + import.meta.env.DEV && target.__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__ === true + ) +} + // Why: when multiple panes/tabs need the same deferred SSH connection, // the first one calls ssh.connect() and subsequent ones must wait for it // rather than returning early (which would leave them disconnected). This @@ -2609,6 +2618,7 @@ export function connectPanePty( canRestoreHiddenOutput: canUseHiddenOutputSnapshot(transport.getPtyId()), startupRendererQueryWindowActive: isHiddenStartupRendererQueryWindowActive(), synchronizedOutputActive: synchronizedHiddenOutput, + allowSynchronizedModelRestore: shouldAllowPrototypeSynchronizedHiddenModelRestore(), data }) if (shouldSkipHiddenOutput) { diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index c1e608a03d8..a1fc9e86d3d 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -19,6 +19,7 @@ import { } from './helpers/terminal' type HiddenTuiWindow = Window & { + __ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__?: boolean __terminalPtyDataInjection?: { inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean } @@ -82,6 +83,15 @@ async function resetHiddenDebug(page: Page): Promise { }) } +async function setPrototypeSynchronizedHiddenModelRestore( + page: Page, + enabled: boolean +): Promise { + await page.evaluate((enabled) => { + ;(window as HiddenTuiWindow).__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__ = enabled + }, enabled) +} + function writeHiddenFrameScript(scriptPath: string, runId: string): void { const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame)) writeFileSync( @@ -379,6 +389,98 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) + test('prototypes rich synchronized TUI restore from the headless model', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const hiddenPane = hiddenSnapshot.panes[0] + if (!hiddenPane?.ptyId) { + throw new Error('hidden rich model prototype pane did not bind a PTY') + } + await switchToWorktree(orcaPage, firstWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'first worktree did not become active before hidden rich model prototype' + }) + .toBe(firstWorktreeId) + + const runId = randomUUID() + const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24` + const scriptPath = path.join(testRepoPath, `.orca-hidden-rich-model-${runId}.mjs`) + writeHiddenFrameScript(scriptPath, runId) + await resetHiddenDebug(orcaPage) + await setPrototypeSynchronizedHiddenModelRestore(orcaPage, true) + try { + await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + + await expect + .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 10_000, + message: 'prototype rich hidden TUI output should skip renderer writes' + }) + .toBeGreaterThan(0) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'prototype rich hidden TUI source did not come from headless model' + }) + .toBe('headless') + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'prototype rich headless TUI frame did not restore when visible' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame 024`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + await expect + .poll(() => readTuiCursorState(orcaPage), { + timeout: 5_000, + message: 'prototype rich headless TUI cursor stayed hidden after restore' + }) + .toMatchObject({ + hidden: false, + initialized: true + }) + + const screenshotPath = testInfo.outputPath('hidden-rich-model-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('hidden-rich-model-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + await setPrototypeSynchronizedHiddenModelRestore(orcaPage, false) + rmSync(scriptPath, { force: true }) + } + }) + test('keeps hidden terminal side effects live while hidden output may restore', async ({ orcaPage }) => {