From 404a51ff9fddb1f05316d8f29fcec7f2ccfea435 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:31:46 -0700 Subject: [PATCH] Skip plain hidden terminal renderer writes --- .../hidden-renderer-skip-eligibility.test.ts | 97 +++++++++++++++++++ .../hidden-renderer-skip-eligibility.ts | 49 ++++++++++ .../terminal-pane/pty-connection.test.ts | 56 +++++++---- .../terminal-pane/pty-connection.ts | 33 ++++--- ...icial-opencode-hidden-pressure-scenario.ts | 51 +++------- ...ificial-opencode-hidden-pressure-script.ts | 46 +++++++++ .../artificial-opencode-terminal-load.spec.ts | 17 +++- ...terminal-hidden-tui-visual-restore.spec.ts | 51 ++++++---- 8 files changed, 312 insertions(+), 88 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts create mode 100644 src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts create mode 100644 tests/e2e/artificial-opencode-hidden-pressure-script.ts 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 new file mode 100644 index 00000000000..4ab90d6ecdd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { shouldSkipHiddenRendererOutput } from './hidden-renderer-skip-eligibility' + +describe('shouldSkipHiddenRendererOutput', () => { + it('skips hidden plain ASCII output when a snapshot restore is available', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: 'line one\r\nline two\tok\n' + }) + ).toBe(true) + }) + + it('keeps visible or non-restorable output on the live renderer path', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: true, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: 'visible\r\n' + }) + ).toBe(false) + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: false, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: 'hidden\r\n' + }) + ).toBe(false) + }) + + it('keeps startup query windows and terminal-control chunks live', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: true, + synchronizedOutputActive: false, + data: 'plain\r\n' + }) + ).toBe(false) + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: true, + data: 'plain row inside synchronized frame\r\n' + }) + ).toBe(false) + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: '\x1b]0;title\x07' + }) + ).toBe(false) + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: '\x1b[?2026hredraw\x1b[?2026l' + }) + ).toBe(false) + }) + + it('keeps rewrite and unicode chunks live', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: 'progress 10%\rprogress 20%' + }) + ).toBe(false) + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: 'emoji 😀\r\n' + }) + ).toBe(false) + }) +}) 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 new file mode 100644 index 00000000000..328e817e748 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts @@ -0,0 +1,49 @@ +export type HiddenRendererSkipEligibility = { + foreground: boolean + canRestoreHiddenOutput: boolean + startupRendererQueryWindowActive: boolean + synchronizedOutputActive: boolean + data: string +} + +function isAllowedPlainHiddenOutputCode(code: number): boolean { + if (code === 0x09 || code === 0x0a) { + return true + } + return code >= 0x20 && code <= 0x7e +} + +function containsOnlyPlainHiddenOutput(data: string): boolean { + for (let index = 0; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code === 0x0d) { + if (data.charCodeAt(index + 1) !== 0x0a) { + return false + } + continue + } + if (!isAllowedPlainHiddenOutputCode(code)) { + return false + } + } + return true +} + +export function shouldSkipHiddenRendererOutput({ + foreground, + canRestoreHiddenOutput, + startupRendererQueryWindowActive, + synchronizedOutputActive, + data +}: HiddenRendererSkipEligibility): boolean { + if ( + foreground || + !canRestoreHiddenOutput || + startupRendererQueryWindowActive || + synchronizedOutputActive || + data.length === 0 + ) { + return false + } + return containsOnlyPlainHiddenOutput(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 56a26406d8f..d98531931a9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2945,7 +2945,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('keeps non-visible local PTY bytes on the live xterm path for release', async () => { + it('keeps hidden terminal-control bytes on the live xterm path', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -2975,14 +2975,15 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('hello\r\n') - expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n') + const controlOutput = '\x1b[2J\x1b[Hhello\r\n' + capturedDataCallback.current?.(controlOutput) + expect(pane.terminal.write).not.toHaveBeenCalledWith(controlOutput) for (const fn of pendingTimeouts) { fn() } - expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n') + expect(pane.terminal.write).toHaveBeenCalledWith(controlOutput) } finally { globalThis.setTimeout = originalSetTimeout } @@ -3306,7 +3307,7 @@ describe('connectPanePty', () => { binding.dispose() }) - it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => { + it('restores plain hidden output from the main snapshot when the pane returns', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -3347,20 +3348,24 @@ describe('connectPanePty', () => { }) await flushAsyncTicks(20) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining(`snapshot-with-${hidden}`), + expect.any(Function) + ) disposable.dispose() }) - it('writes ordinary hidden remote runtime output live instead of restoring a snapshot', async () => { + it('restores plain hidden remote runtime output from its serialized snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null } = { current: null } transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: 'remote snapshot\r\n', + data: 'remote snapshot with hidden remote output\r\n', cols: 120, rows: 40, seq: 40, @@ -3394,13 +3399,16 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote snapshot with hidden remote output'), + expect.any(Function) + ) disposable.dispose() }) - it('keeps inactive split-pane hidden output live instead of deferring snapshot restore', async () => { + it('defers inactive split-pane plain hidden output restore until the pane returns', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') let disposable: { dispose: () => void } | null = null @@ -3452,9 +3460,12 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 30)) await flushAsyncTicks(20) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('inactive snapshot'), + expect.any(Function) + ) } finally { disposable?.dispose() resetHiddenOutputRestoreSchedulerForTests() @@ -3519,7 +3530,7 @@ describe('connectPanePty', () => { } }) - it('does not retry remote snapshots for ordinary hidden runtime output', async () => { + it('retries null remote snapshots for skipped plain hidden runtime output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { @@ -3555,7 +3566,7 @@ describe('connectPanePty', () => { }) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() + expect(transport.serializeBuffer).toHaveBeenCalledTimes(1) expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('Orca skipped hidden terminal output'), expect.any(Function) @@ -3568,8 +3579,11 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 80)) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(firstLive, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote recovered snapshot'), + expect.any(Function) + ) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8778c102758..6849afe740a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -88,6 +88,7 @@ import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' import { isWslUncPath } from '../../../../shared/wsl-paths' +import { shouldSkipHiddenRendererOutput } from './hidden-renderer-skip-eligibility' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -622,6 +623,7 @@ export function connectPanePty( let pendingTerminalBellNotification = false let reattachIdleAgentCursorResetTimer: ReturnType | null = null let synchronizedForegroundOutputActive = false + let synchronizedHiddenOutputActive = false // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -2193,16 +2195,6 @@ export function connectPanePty( } } - function shouldSkipHiddenRendererOutput(foreground: boolean, data: string): boolean { - void foreground - void data - // Why: release correctness beats the hidden-output perf optimization. - // Real OpenCode tables still corrupt after workspace switching when PTY - // bytes bypass the renderer, so keep hidden panes on the live xterm path - // and leave snapshot skipping for a later perf branch. - return false - } - function skipHiddenRendererOutput(data: string): void { respondToSkippedMode2031Subscribe(data) markHiddenOutputRestoreNeeded() @@ -2607,7 +2599,20 @@ export function connectPanePty( const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) const restoreAppliesToCurrentPty = hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId - if (shouldSkipHiddenRendererOutput(foreground, data)) { + const synchronizedHiddenOutput = + !foreground && + (synchronizedHiddenOutputActive || + containsSynchronizedOutputStart(data) || + containsSynchronizedOutputEnd(data)) + if ( + shouldSkipHiddenRendererOutput({ + foreground, + canRestoreHiddenOutput: canUseHiddenOutputSnapshot(transport.getPtyId()), + startupRendererQueryWindowActive: isHiddenStartupRendererQueryWindowActive(), + synchronizedOutputActive: synchronizedHiddenOutput, + data + }) + ) { skipHiddenRendererOutput(data) } else if ( (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && @@ -2623,6 +2628,12 @@ export function connectPanePty( } else { writePtyOutputToXterm(data, foreground) } + if (!foreground) { + synchronizedHiddenOutputActive = shouldSynchronizedOutputRemainActive( + data, + synchronizedHiddenOutputActive + ) + } schedulePendingStartupCommandDelivery() } diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 9dd35f06455..e6cf4cae555 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -1,8 +1,12 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { rmSync } from 'node:fs' import path from 'node:path' +import { + type HiddenPressureOutputMode, + writePressureOutputScript +} from './artificial-opencode-hidden-pressure-script' import { ensureTerminalVisible, getActiveWorktreeId, @@ -77,38 +81,6 @@ type HiddenPressureAckGate = { const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 -export function pressureOutputScript(runId: string): string { - return ` -const paneIndex = process.argv[2] ?? '0' -const targetChars = Number(process.argv[3] ?? '0') -const delayMs = Number(process.argv[4] ?? '0') -const header = 'OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' -const chunkBody = '#'.repeat(8192) -let written = 0 -process.stdout.write(header) -function writeMore() { - let canContinue = true - while (canContinue && written < targetChars) { - const frame = String(written).padStart(8, '0') - const chunk = '\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n' - written += chunk.length - canContinue = process.stdout.write(chunk) - } - if (written < targetChars) { - process.stdout.once('drain', writeMore) - return - } - process.stdout.write('OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') -} -setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) -` -} - -export function writePressureOutputScript(scriptPath: string, runId: string): void { - mkdirSync(path.dirname(scriptPath), { recursive: true }) - writeFileSync(scriptPath, pressureOutputScript(runId)) -} - export async function runHiddenRealPtyPressureScenario< TMeasurement extends HiddenPressureMeasurement, TDebug extends HiddenPressureDebug, @@ -120,6 +92,7 @@ export async function runHiddenRealPtyPressureScenario< annotationSuffix, hiddenPaneCount, pressureOutputChars, + pressureOutputMode = 'tui', pressureStartDelayMs, testInfo, testRepoPath, @@ -129,6 +102,7 @@ export async function runHiddenRealPtyPressureScenario< annotationSuffix?: string hiddenPaneCount: number pressureOutputChars: number + pressureOutputMode?: HiddenPressureOutputMode pressureStartDelayMs: number testInfo: TestInfo testRepoPath: string @@ -158,7 +132,7 @@ export async function runHiddenRealPtyPressureScenario< `.orca-opencode-hidden-pressure-load-${runId}.mjs` ) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, pressureOutputMode) await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( @@ -197,8 +171,13 @@ export async function runHiddenRealPtyPressureScenario< ackGate ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) + if (pressureOutputMode === 'plain') { + expect(debug?.hiddenRendererSkipCount ?? 0).toBeGreaterThan(0) + expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(0) + } else { + expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) + expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) + } expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts new file mode 100644 index 00000000000..c0e9a9b0576 --- /dev/null +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -0,0 +1,46 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +export type HiddenPressureOutputMode = 'tui' | 'plain' + +export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { + const headerPrefix = mode === 'plain' ? '' : '\\x1b[0m' + const donePrefix = mode === 'plain' ? '' : '\\x1b[0m' + const chunkExpression = + mode === 'plain' + ? "'plain pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'" + return ` +const paneIndex = process.argv[2] ?? '0' +const targetChars = Number(process.argv[3] ?? '0') +const delayMs = Number(process.argv[4] ?? '0') +const header = '${headerPrefix}OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' +const chunkBody = '#'.repeat(8192) +let written = 0 +process.stdout.write(header) +function writeMore() { + let canContinue = true + while (canContinue && written < targetChars) { + const frame = String(written).padStart(8, '0') + const chunk = ${chunkExpression} + written += chunk.length + canContinue = process.stdout.write(chunk) + } + if (written < targetChars) { + process.stdout.once('drain', writeMore) + return + } + process.stdout.write('${donePrefix}OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') +} +setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) +` +} + +export function writePressureOutputScript( + scriptPath: string, + runId: string, + mode: HiddenPressureOutputMode +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, pressureOutputScript(runId, mode)) +} diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 5bdc679f5fc..dabff6a534e 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -727,7 +727,8 @@ test.describe('Artificial OpenCode terminal load', () => { testRepoPath: string, testInfo: TestInfo, hiddenPaneCount: number, - annotationSuffix?: string + annotationSuffix?: string, + pressureOutputMode?: 'tui' | 'plain' ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -735,6 +736,7 @@ test.describe('Artificial OpenCode terminal load', () => { annotationSuffix, hiddenPaneCount, pressureOutputChars: PRESSURE_OUTPUT_CHARS, + pressureOutputMode, pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, testInfo, deps: { @@ -764,6 +766,19 @@ test.describe('Artificial OpenCode terminal load', () => { HIDDEN_PRESSURE_PANES ) }) + test('skips renderer writes for plain hidden PTY output while preserving restore', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + '-plain', + 'plain' + ) + }) for (const paneCount of SCALE_HIDDEN_PRESSURE_PANES) { test(`keeps hidden restore responsive with ${paneCount} ACK-backpressured real PTYs`, async ({ orcaPage, diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index 093d9385d60..c1e608a03d8 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -84,7 +84,17 @@ async function resetHiddenDebug(page: Page): Promise { function writeHiddenFrameScript(scriptPath: string, runId: string): void { const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame)) - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), 250)\n` + ) +} + +function writeLowRiskFrameScript(scriptPath: string, frame: string): void { + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frame)}), 250)\n` + ) } async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise { @@ -229,13 +239,20 @@ test.describe('Hidden terminal TUI visual restore', () => { writeHiddenFrameScript(scriptPath, runId) await resetHiddenDebug(orcaPage) await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) await expect .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { timeout: 10_000, message: 'visually rich hidden TUI output should stay on the live xterm path' }) - .toBe(0) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkippedChars ?? 0, { + timeout: 10_000, + message: 'only incidental hidden shell prompt text may skip after the TUI exits' + }) + .toBeLessThan(512) await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -273,8 +290,9 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) - test('keeps newer live output correct after hidden output stayed live', async ({ - orcaPage + test('keeps newer live output correct after plain hidden output restores', async ({ + orcaPage, + testRepoPath }, testInfo: TestInfo) => { await waitForSessionReady(orcaPage) const firstWorktreeId = await waitForActiveWorktree(orcaPage) @@ -308,18 +326,18 @@ test.describe('Hidden terminal TUI visual restore', () => { const hiddenFrame = lowRiskRestoreFrame(runId, 40) const liveFrame = lowRiskRestoreFrame(runId, 41) const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41` + const scriptPath = path.join(testRepoPath, `.orca-low-risk-hidden-${runId}.mjs`) + writeLowRiskFrameScript(scriptPath, hiddenFrame) + await resetHiddenDebug(orcaPage) + await sendToTerminal(orcaPage, hiddenPane.ptyId, `node ${JSON.stringify(scriptPath)}\r`) await resetHiddenDebug(orcaPage) - await injectPaneData(orcaPage, paneKey, hiddenFrame, { - seq: hiddenFrame.length, - rawLength: hiddenFrame.length - }) await expect .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { timeout: 10_000, - message: 'hidden injected output should stay on the live xterm path for release' + message: 'plain hidden injected output should skip renderer writes' }) - .toBe(0) + .toBeGreaterThan(0) await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -332,7 +350,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => getTerminalContent(orcaPage, 12_000), { timeout: 10_000, - message: 'newer live TUI frame did not render after hidden output stayed live' + message: 'newer live TUI frame did not render after hidden output restored' }) .toContain(finalMarker) @@ -346,7 +364,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => readTuiCursorState(orcaPage), { timeout: 5_000, - message: 'live TUI cursor stayed hidden after hidden output stayed live' + message: 'live TUI cursor stayed hidden after hidden output restored' }) .toMatchObject({ hidden: false, @@ -358,9 +376,10 @@ test.describe('Hidden terminal TUI visual restore', () => { path: screenshotPath, contentType: 'image/png' }) + rmSync(scriptPath, { force: true }) }) - test('keeps hidden terminal side effects live while hidden output stays live', async ({ + test('keeps hidden terminal side effects live while hidden output may restore', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) @@ -396,12 +415,6 @@ test.describe('Hidden terminal TUI visual restore', () => { await resetHiddenDebug(orcaPage) await writeHiddenSideEffectBurst(orcaPage, hiddenPane.ptyId, hiddenTitle, marker) - await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { - timeout: 10_000, - message: 'hidden side-effect output should stay on the live xterm path for release' - }) - .toBe(0) await expect .poll(() => getRuntimePaneTitle(orcaPage, hiddenSnapshot.tabId, hiddenPane.numericPaneId), { timeout: 10_000,