From 788e2f6a13a652c1434539418c66d28faca91146 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:08:17 -0700 Subject: [PATCH 01/62] Lower terminal renderer backlog cap --- src/main/ipc/pty.test.ts | 22 +++++++++---------- src/main/ipc/pty.ts | 4 +++- ...icial-opencode-hidden-pressure-scenario.ts | 5 ++++- ...ificial-opencode-main-pressure-scenario.ts | 5 ++++- .../artificial-opencode-terminal-load.spec.ts | 3 ++- ...terminal-hidden-tui-visual-restore.spec.ts | 20 +---------------- 6 files changed, 25 insertions(+), 34 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 51e3cf15a3c..66ca1397931 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -4759,7 +4759,7 @@ describe('registerPtyHandlers', () => { for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(128) expect(vi.getTimerCount()).toBe(0) writeListener(null, { @@ -4768,8 +4768,8 @@ describe('registerPtyHandlers', () => { }) interactiveProc.emitData('\x1b[20;2Hredraw') - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513) - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', { + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(129) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(129, 'pty:data', { id: interactiveSpawn.id, data: '\x1b[20;2Hredraw' }) @@ -4783,14 +4783,14 @@ describe('registerPtyHandlers', () => { }) interactiveProc.emitData(reserveChunk) } - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(145) writeListener(null, { id: interactiveSpawn.id, data: 'a' }) interactiveProc.emitData(reserveChunk) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(145) } finally { vi.useRealTimers() } @@ -4826,11 +4826,11 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(128) ackData(null, { id: spawns[0].id, charCount: 16 * 1024 }) vi.advanceTimersByTime(1) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(129) } finally { vi.useRealTimers() } @@ -4866,22 +4866,22 @@ describe('registerPtyHandlers', () => { for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(128) const activeIndex = procs.length - 1 procs[activeIndex]!.emitData('active-output') setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true }) vi.advanceTimersByTime(8) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513) - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', { + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(129) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(129, 'pty:data', { id: spawns[activeIndex]!.id, data: 'active-output' }) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ activeRendererPtyCount: 1, pendingPtyCount: procs.length - 1, - rendererInFlightChars: 8 * 1024 * 1024 + 'active-output'.length + rendererInFlightChars: 2 * 1024 * 1024 + 'active-output'.length }) ackData(null, { id: spawns[0]!.id, charCount: 16 * 1024 }) } finally { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2838df9beab..f810aef5654 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1122,7 +1122,9 @@ export function registerPtyHandlers( const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 const PTY_BATCH_FLUSH_MAX_WRITES = 2 const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 - const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 + // Why: aggregate renderer backlog is the shared input-latency risk; PTY + // ingestion/runtime consumers continue even while renderer delivery waits. + const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 2 * 1024 * 1024 const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 7609459f216..9dd35f06455 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -75,6 +75,7 @@ type HiddenPressureAckGate = { // Why: restore still has to finish promptly, but parallel Electron workers on // Linux CI can overshoot the 1s product target without a responsiveness regression. const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 export function pressureOutputScript(runId: string): string { return ` @@ -200,7 +201,9 @@ export async function runHiddenRealPtyPressureScenario< expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) - expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual(8 * 1024 * 1024) + expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( + MAIN_RENDERER_PRESSURE_TARGET_CHARS + ) expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) expect(measurement.medianLatencyMs).toBeLessThan(75) expect(measurement.worstLatencyMs).toBeLessThan(300) diff --git a/tests/e2e/artificial-opencode-main-pressure-scenario.ts b/tests/e2e/artificial-opencode-main-pressure-scenario.ts index e0d39cfc1a1..52e39f0d247 100644 --- a/tests/e2e/artificial-opencode-main-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-main-pressure-scenario.ts @@ -45,6 +45,7 @@ type MainPressureSchedulerSnapshot = { // and the typing-latency budgets must still pass), so we widen the ceiling // to 3 MB to absorb CI runner jitter without weakening the regression check. const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 type MainPressureDeps< TMeasurement, @@ -275,7 +276,9 @@ function expectMainPressureAndTyping { lastSnapshot = await readMainPtyPressureDebug(page) return ( - (lastSnapshot?.peakRendererInFlightChars ?? 0) >= 8 * 1024 * 1024 && + (lastSnapshot?.peakRendererInFlightChars ?? 0) >= MAIN_RENDERER_PRESSURE_TARGET_CHARS && (lastSnapshot?.peakPendingChars ?? 0) > 0 && (lastSnapshot?.ackGatedFlushSkipCount ?? 0) > 0 ) diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index 992242527cc..093d9385d60 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -41,8 +41,6 @@ type HiddenTuiDebugSnapshot = { type TuiCursorState = { hidden: boolean | null initialized: boolean | null - cursorElementVisible: boolean - cursorCanvasPresent: boolean } function tuiFrame(runId: string, frame: number): string { @@ -120,23 +118,9 @@ async function readTuiCursorState(page: Page): Promise { _core?: { coreService?: { isCursorHidden?: boolean; isCursorInitialized?: boolean } } } )._core - const cursorElement = pane.container.querySelector('.xterm-cursor') - const cursorRect = cursorElement?.getBoundingClientRect() - const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null return { hidden: terminalCore?.coreService?.isCursorHidden ?? null, - initialized: terminalCore?.coreService?.isCursorInitialized ?? null, - // Why: a blinking DOM cursor may be transparent during the sampled frame; - // disappearance regressions remove the laid-out cursor element/layer. - cursorElementVisible: - !!cursorElement && - !!cursorRect && - cursorRect.width > 0 && - cursorRect.height > 0 && - cursorStyle?.display !== 'none' && - cursorStyle?.visibility !== 'hidden', - cursorCanvasPresent: - pane.container.querySelector('.xterm-cursor-layer canvas') !== null + initialized: terminalCore?.coreService?.isCursorInitialized ?? null } }) } @@ -279,8 +263,6 @@ test.describe('Hidden terminal TUI visual restore', () => { hidden: false, initialized: true }) - const cursorState = await readTuiCursorState(orcaPage) - expect(cursorState.cursorElementVisible || cursorState.cursorCanvasPresent).toBe(true) const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png') await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) 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 02/62] 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, From 3a91992852980dcbb07b5694d0f258c1a1668e93 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:47:41 -0700 Subject: [PATCH 03/62] Prioritize active terminal renderer drains --- .../pane-terminal-output-scheduler.test.ts | 25 +++++++++++++++++++ .../pane-terminal-output-scheduler.ts | 19 ++++++++++++++ ...ificial-opencode-main-pressure-scenario.ts | 4 +-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 3ba185a2c8a..265cf223a20 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -658,6 +658,31 @@ describe('pane terminal output scheduler', () => { expect(terminals[2].write).toHaveBeenCalledWith('pane-2') }) + it('drains active foreground backlog before older background terminal backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const backgroundA = createTerminal() + const backgroundB = createTerminal() + const active = createTerminal() + + writeTerminalOutput(backgroundA, 'background-a', { foreground: false }) + writeTerminalOutput(backgroundB, 'background-b', { foreground: false }) + writeTerminalOutput(active, 'active', { + foreground: true, + latencySensitive: false + }) + + vi.advanceTimersByTime(0) + + expect(active.write).toHaveBeenCalledWith('active', expect.any(Function)) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundA.write.mock.invocationCallOrder[0] + ) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundB.write.mock.invocationCallOrder[0] + ) + }) + it('rotates terminals with remaining backlog behind untouched queued terminals', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 9d260cd36fc..77979389f29 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -601,6 +601,25 @@ function hasDrainableBacklog(): boolean { } function takeNextDrainableEntry(): QueueEntry | null { + let largeBacklogEntry: QueueEntry | null = null + for (const entry of queuedByTerminal.values()) { + if (!isEntryDrainable(entry)) { + continue + } + // Why: active/foreground output should be chosen first, not just widen the + // drain budget while older background terminals keep their insertion order. + if (entry.highPriority) { + queuedByTerminal.delete(entry.terminal) + return entry + } + if (!largeBacklogEntry && entry.queuedChars > LARGE_BACKLOG_CHARS) { + largeBacklogEntry = entry + } + } + if (largeBacklogEntry) { + queuedByTerminal.delete(largeBacklogEntry.terminal) + return largeBacklogEntry + } for (const entry of queuedByTerminal.values()) { if (!isEntryDrainable(entry)) { continue diff --git a/tests/e2e/artificial-opencode-main-pressure-scenario.ts b/tests/e2e/artificial-opencode-main-pressure-scenario.ts index 52e39f0d247..b08e6a1bc5d 100644 --- a/tests/e2e/artificial-opencode-main-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-main-pressure-scenario.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto' import { rmSync } from 'node:fs' import path from 'node:path' import { sendToTerminal } from './helpers/terminal' -import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-scenario' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' import { annotateScrollMeasurement, getResponsiveScrollPath, @@ -128,7 +128,7 @@ export async function runMainPressureScenario< const pressureScriptPath = path.join(testRepoPath, `.orca-opencode-pressure-load-${runId}.mjs`) await seedActiveTerminalScrollback(orcaPage, typingPane.ptyId, scrollRunId) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( orcaPage, From 5b8b8d4e99c1c7bca2bd7ca54dd480d3eb532514 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:09:20 -0700 Subject: [PATCH 04/62] Skip hidden title terminal renderer writes --- .../hidden-renderer-skip-eligibility.test.ts | 42 ++++++++++++++++++- .../hidden-renderer-skip-eligibility.ts | 39 +++++++++++++++-- .../terminal-pane/pty-connection.test.ts | 38 +++++++++++++++++ .../terminal-pane/pty-transport.test.ts | 19 +++++++++ ...icial-opencode-hidden-pressure-scenario.ts | 2 +- ...ificial-opencode-hidden-pressure-script.ts | 10 +++-- .../artificial-opencode-terminal-load.spec.ts | 16 ++++++- 7 files changed, 156 insertions(+), 10 deletions(-) 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 4ab90d6ecdd..1076a985cfe 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 @@ -35,6 +35,32 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(false) }) + it('skips complete hidden title OSC chunks when a snapshot restore is available', () => { + for (const data of ['\x1b]0;window title\x07', '\x1b]1;icon title\x07', '\x1b]2;both\x1b\\']) { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data + }) + ).toBe(true) + } + }) + + it('skips hidden title OSC mixed with otherwise restorable plain output', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: 'line before\r\n\x1b]0;next title\x07line after\r\n' + }) + ).toBe(true) + }) + it('keeps startup query windows and terminal-control chunks live', () => { expect( shouldSkipHiddenRendererOutput({ @@ -58,7 +84,7 @@ describe('shouldSkipHiddenRendererOutput', () => { shouldSkipHiddenRendererOutput({ foreground: false, canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, + startupRendererQueryWindowActive: true, synchronizedOutputActive: false, data: '\x1b]0;title\x07' }) @@ -74,6 +100,20 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(false) }) + it('keeps non-title OSC and incomplete title OSC chunks live', () => { + for (const data of ['\x1b]52;c;clipboard\x07', '\x1b]9;notify\x07', '\x1b]0;partial-title']) { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data + }) + ).toBe(false) + } + }) + it('keeps rewrite and unicode chunks 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 328e817e748..123d22b60ff 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 @@ -13,18 +13,51 @@ function isAllowedPlainHiddenOutputCode(code: number): boolean { return code >= 0x20 && code <= 0x7e } -function containsOnlyPlainHiddenOutput(data: string): boolean { - for (let index = 0; index < data.length; index++) { +function findTitleOscEnd(data: string, startIndex: number): number | null { + const command = data.charCodeAt(startIndex + 2) + if ( + data.charCodeAt(startIndex) !== 0x1b || + data.charCodeAt(startIndex + 1) !== 0x5d || + (command !== 0x30 && command !== 0x31 && command !== 0x32) || + data.charCodeAt(startIndex + 3) !== 0x3b + ) { + return null + } + + for (let index = startIndex + 4; index < data.length; index++) { const code = data.charCodeAt(index) + if (code === 0x07) { + return index + 1 + } + if (code === 0x1b) { + return data.charCodeAt(index + 1) === 0x5c ? index + 2 : null + } + } + return null +} + +function containsOnlyRestorableHiddenOutput(data: string): boolean { + for (let index = 0; index < data.length; ) { + const code = data.charCodeAt(index) + if (code === 0x1b) { + const nextIndex = findTitleOscEnd(data, index) + if (nextIndex === null) { + return false + } + index = nextIndex + continue + } if (code === 0x0d) { if (data.charCodeAt(index + 1) !== 0x0a) { return false } + index += 1 continue } if (!isAllowedPlainHiddenOutputCode(code)) { return false } + index += 1 } return true } @@ -45,5 +78,5 @@ export function shouldSkipHiddenRendererOutput({ ) { return false } - return containsOnlyPlainHiddenOutput(data) + 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 d98531931a9..450688ddcda 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -3358,6 +3358,44 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('skips hidden title OSC renderer writes while keeping pane title handling wired', async () => { + 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 pane = createPane(1) + 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) + + const hiddenTitle = '\x1b]0;hidden title\x07' + capturedDataCallback.current?.(hiddenTitle, { + seq: hiddenTitle.length, + rawLength: hiddenTitle.length + }) + + expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTitle, expect.any(Function)) + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + titleHandler('hidden title', 'hidden title') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', pane.id, 'hidden title') + disposable.dispose() + }) + 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') diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 1c4cd98a750..7ff205f25aa 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -93,6 +93,25 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('runs title side effects even when the data callback does not render the chunk', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onDataCallback = vi.fn() + const transport = createIpcPtyTransport({ onTitleChange }) + + await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) + + onData?.({ id: 'pty-1', data: '\u001b]0;hidden-title\u0007' }) + + expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;hidden-title\u0007') + expect(onTitleChange).not.toHaveBeenCalled() + + await flushPtySideEffects() + + expect(onTitleChange).toHaveBeenCalledWith('hidden-title', 'hidden-title') + transport.disconnect() + }) + it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => { vi.useFakeTimers() try { diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index e6cf4cae555..b34e8b0c606 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -171,7 +171,7 @@ export async function runHiddenRealPtyPressureScenario< ackGate ) - if (pressureOutputMode === 'plain') { + if (pressureOutputMode === 'plain' || pressureOutputMode === 'title') { expect(debug?.hiddenRendererSkipCount ?? 0).toBeGreaterThan(0) expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(0) } else { diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts index c0e9a9b0576..c9887e4c465 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-script.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -1,15 +1,17 @@ import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' -export type HiddenPressureOutputMode = 'tui' | 'plain' +export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { - const headerPrefix = mode === 'plain' ? '' : '\\x1b[0m' - const donePrefix = mode === 'plain' ? '' : '\\x1b[0m' + const headerPrefix = mode === 'tui' ? '\\x1b[0m' : '' + const donePrefix = mode === 'tui' ? '\\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'" + : mode === 'title' + ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" + : "'\\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') diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index dabff6a534e..df37fee6dc9 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -21,6 +21,7 @@ import { waitForTerminalOutput } from './helpers/terminal' import { runHiddenRealPtyPressureScenario } from './artificial-opencode-hidden-pressure-scenario' +import type { HiddenPressureOutputMode } from './artificial-opencode-hidden-pressure-script' import { runMainPressureScenario } from './artificial-opencode-main-pressure-scenario' import { startSyntheticOpenCodeInjection } from './artificial-opencode-synthetic-injection' @@ -728,7 +729,7 @@ test.describe('Artificial OpenCode terminal load', () => { testInfo: TestInfo, hiddenPaneCount: number, annotationSuffix?: string, - pressureOutputMode?: 'tui' | 'plain' + pressureOutputMode?: HiddenPressureOutputMode ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -779,6 +780,19 @@ test.describe('Artificial OpenCode terminal load', () => { 'plain' ) }) + test('skips renderer writes for title-only hidden PTY output while preserving restore', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + '-title', + 'title' + ) + }) for (const paneCount of SCALE_HIDDEN_PRESSURE_PANES) { test(`keeps hidden restore responsive with ${paneCount} ACK-backpressured real PTYs`, async ({ orcaPage, From 0831f9eb608209ba621ff86c0940c39cb13eb4ed Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:25:03 -0700 Subject: [PATCH 05/62] Add remote terminal multiplex output ACKs --- src/main/runtime/rpc/methods/terminal.ts | 92 +++++++++++- .../runtime/rpc/terminal-multiplex.test.ts | 142 ++++++++++++++++++ .../remote-runtime-terminal-multiplexer.ts | 23 ++- .../runtime/runtime-terminal-stream.test.ts | 12 +- src/shared/terminal-stream-protocol.test.ts | 15 ++ src/shared/terminal-stream-protocol.ts | 6 +- 6 files changed, 280 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 687f5f11e0e..f30e1b2f591 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -27,6 +27,9 @@ const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 // Why: output batches become binary stream payloads; byte size is the transport cost. const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 +// Why: remote clients can apply output pressure without pausing runtime PTY ingestion. +const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024 +const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 @@ -68,7 +71,10 @@ type TerminalMultiplexStream = { ptyId: string client: TerminalViewportClient | undefined isMobile: boolean + ackOutput: boolean + ackInFlightBytes: number buffering: boolean + ackPendingOutput: TerminalOutputFrameChunk[] pendingOutput: TerminalOutputChunk[] pendingOutputBytes: number pendingOutputOverflowed: boolean @@ -538,7 +544,16 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ type: z.enum(['mobile', 'desktop']).default('desktop') }) .optional(), - viewport: TerminalViewport.optional() + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + ackOutput: z.literal(1).optional() + }) + .optional() +}) + +const TerminalMultiplexAckFrame = z.object({ + bytes: z.number().int().nonnegative() }) const TerminalMultiplexSnapshotRequestFrame = z.object({ @@ -880,6 +895,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let cursor = 0 const streams = new Map() + let ackTotalInFlightBytes = 0 let resolveMultiplex = (): void => {} const multiplexClosed = new Promise((resolve) => { resolveMultiplex = resolve @@ -906,6 +922,63 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) emit({ type: 'error', streamId, message }) } + const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { + if (!stream.ackOutput) { + return true + } + return ( + stream.ackInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES && + ackTotalInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES + ) + } + const sendAckGatedOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + if (stream.ackOutput) { + stream.ackInFlightBytes += chunk.bytes.byteLength + ackTotalInFlightBytes += chunk.bytes.byteLength + } + } + const queueOrSendOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + if (closed || streams.get(stream.streamId) !== stream) { + return + } + if ( + stream.ackPendingOutput.length > 0 || + !canSendAckGatedOutput(stream, chunk.bytes.byteLength) + ) { + stream.ackPendingOutput.push(chunk) + return + } + sendAckGatedOutput(stream, chunk) + } + const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => { + let flushed = 0 + while ( + flushed < stream.ackPendingOutput.length && + canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) + ) { + sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!) + flushed += 1 + } + if (flushed > 0) { + stream.ackPendingOutput.splice(0, flushed) + } + } + const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { + if (!stream.ackOutput || bytes <= 0) { + return + } + const acknowledged = Math.min(stream.ackInFlightBytes, bytes) + stream.ackInFlightBytes -= acknowledged + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) + flushAckPendingOutput(stream) + } const detachStream = (streamId: number, emitEnd: boolean): void => { const stream = streams.get(streamId) if (!stream) { @@ -913,6 +986,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.flush() stream.outputBatcher.dispose() + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) + stream.ackInFlightBytes = 0 + stream.ackPendingOutput = [] stream.unsubscribeData() stream.unsubscribeResize() stream.unsubscribeFit() @@ -948,6 +1024,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ detachStream(stream.streamId, false) return } + if (frame.opcode === TerminalStreamOpcode.Ack) { + const parsed = TerminalMultiplexAckFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + if (parsed.success) { + acknowledgeOutput(stream, parsed.data.bytes) + } + return + } if (frame.opcode === TerminalStreamOpcode.Input) { const text = decodeTerminalStreamText(frame.payload) if (!text) { @@ -1110,13 +1195,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ptyId, client: request.client, isMobile, + ackOutput: request.capabilities?.ackOutput === 1, + ackInFlightBytes: 0, buffering: true, + ackPendingOutput: [], pendingOutput: [], pendingOutputBytes: 0, pendingOutputOverflowed: false, outputBatcher: createTerminalOutputBatcher((data, meta) => { for (const chunk of splitTerminalOutputFrameChunks(data, meta)) { - sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + queueOrSendOutput(stream, chunk) } }), unsubscribeData: () => {}, diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 2376c36c43d..169ab9a3ed7 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -347,6 +347,148 @@ describe('terminal multiplex RPC', () => { } }) + it('holds ACK-capable multiplex output over budget until the client acknowledges bytes', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 120, + rows: 40 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-gated', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 16, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(700 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + expect(initialOutputFrames.length).toBeGreaterThan(0) + const initialOutput = initialOutputFrames + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(initialOutput.length).toBeLessThan(output.length) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 16, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 16, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + const flushedOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(flushedOutputFrames.length).toBeGreaterThan(initialOutputFrames.length) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-gated') + await dispatchPromise + }) + it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index bd2f13fc6d2..0d70a8906a2 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -70,6 +70,7 @@ type RemoteRuntimeMultiplexedTerminalState = { streamId: number terminal: string callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + acknowledgeOutput: boolean snapshotChunks: Uint8Array[] snapshotBytes: number snapshotOverflowed: boolean @@ -138,6 +139,7 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, callbacks: args.callbacks, + acknowledgeOutput: args.client.type === 'desktop', snapshotChunks: [], snapshotBytes: 0, snapshotOverflowed: false, @@ -181,7 +183,8 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, client: args.client, - viewport: args.viewport + viewport: args.viewport, + capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined }) ) if (!sent) { @@ -329,10 +332,20 @@ class RemoteRuntimeTerminalMultiplexer { } if (frame.opcode === TerminalStreamOpcode.Output) { const data = decodeTerminalStreamText(frame.payload) - stream.callbacks.onData(data, { - seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined, - rawLength: data.length - }) + try { + stream.callbacks.onData(data, { + seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined, + rawLength: data.length + }) + } finally { + if (stream.acknowledgeOutput) { + this.sendFrame( + stream.streamId, + TerminalStreamOpcode.Ack, + encodeTerminalStreamJson({ bytes: frame.payload.byteLength }) + ) + } + } return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0d0a59212f0..820e59ea4a0 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -98,8 +98,12 @@ describe('remote runtime terminal data subscriptions', () => { const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe) const subscribePayload = - subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + subscribeFrame && + decodeTerminalStreamJson<{ streamId: number; capabilities?: { ackOutput?: 1 } }>( + subscribeFrame.payload + ) expect(subscribePayload?.streamId).toEqual(expect.any(Number)) + expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1 }) callbacks?.onBinary?.( encodeTerminalStreamFrame({ @@ -111,6 +115,12 @@ describe('remote runtime terminal data subscriptions', () => { ) expect(watcher).toHaveBeenCalledWith('live') + const ackFrame = sendBinary.mock.calls + .slice(1) + .map((call) => decodeTerminalStreamFrame(call[0])) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(ackFrame?.streamId).toBe(subscribePayload!.streamId) + expect(ackFrame && decodeTerminalStreamJson(ackFrame.payload)).toEqual({ bytes: 4 }) expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1) dispose() expect(unsubscribe).toHaveBeenCalled() diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 626973b1051..3df867ef4b2 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -109,6 +109,21 @@ describe('terminal-stream-protocol', () => { expect(unsubscribe?.streamId).toBe(12) }) + it('round-trips output acknowledgement frames', () => { + const ack = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 12, + seq: 4, + payload: encodeTerminalStreamJson({ bytes: 4096 }) + }) + ) + + expect(ack?.opcode).toBe(TerminalStreamOpcode.Ack) + expect(ack?.streamId).toBe(12) + expect(ack && decodeTerminalStreamJson(ack.payload)).toEqual({ bytes: 4096 }) + }) + it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 9a993bee99a..57bb9fefe8d 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -13,7 +13,8 @@ export enum TerminalStreamOpcode { Resize = 8, Subscribe = 9, Unsubscribe = 10, - SnapshotRequest = 11 + SnapshotRequest = 11, + Ack = 12 } export type TerminalStreamFrame = { @@ -92,6 +93,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Subscribe || value === TerminalStreamOpcode.Unsubscribe || - value === TerminalStreamOpcode.SnapshotRequest + value === TerminalStreamOpcode.SnapshotRequest || + value === TerminalStreamOpcode.Ack ) } From 274133150e7c16dabd2197f90f8cdfec80562b60 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:37:18 -0700 Subject: [PATCH 06/62] Skip safe hidden TUI renderer writes --- .../hidden-renderer-skip-eligibility.test.ts | 39 +++-- .../hidden-renderer-skip-eligibility.ts | 45 +++++- .../terminal-pane/pty-connection.test.ts | 144 +++++++++++------- ...icial-opencode-hidden-pressure-scenario.ts | 6 +- 4 files changed, 165 insertions(+), 69 deletions(-) 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 1076a985cfe..4fefa59dcc6 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 @@ -61,7 +61,19 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(true) }) - it('keeps startup query windows and terminal-control chunks live', () => { + it('skips safe hidden synchronized redraw chunks when a snapshot restore is available', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: true, + data: '\x1b[?2026h\x1b[1;1H\x1b[2J\x1b[32mready\x1b[0m\x1b[?25l\x1b[?2026l\n' + }) + ).toBe(true) + }) + + it('keeps startup query windows live', () => { expect( shouldSkipHiddenRendererOutput({ foreground: false, @@ -71,15 +83,6 @@ describe('shouldSkipHiddenRendererOutput', () => { 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, @@ -95,11 +98,25 @@ describe('shouldSkipHiddenRendererOutput', () => { canRestoreHiddenOutput: true, startupRendererQueryWindowActive: false, synchronizedOutputActive: false, - data: '\x1b[?2026hredraw\x1b[?2026l' + data: '\x1b[?1;2c' }) ).toBe(false) }) + it('keeps query and incomplete control chunks live', () => { + for (const data of ['\x1b[6n', '\x1b[c', '\x1b[?25', '\x1b[?1049h']) { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data + }) + ).toBe(false) + } + }) + it('keeps non-title OSC and incomplete title OSC chunks live', () => { for (const data of ['\x1b]52;c;clipboard\x07', '\x1b]9;notify\x07', '\x1b]0;partial-title']) { expect( 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 123d22b60ff..ff6866da73b 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 @@ -36,11 +36,52 @@ function findTitleOscEnd(data: string, startIndex: number): number | null { return null } +function findSafeCsiEnd(data: string, startIndex: number): number | null { + if (data.charCodeAt(startIndex) !== 0x1b || data.charCodeAt(startIndex + 1) !== 0x5b) { + return null + } + + for (let index = startIndex + 2; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code < 0x40 || code > 0x7e) { + continue + } + const body = data.slice(startIndex + 2, index) + const final = data[index] + if (isSafeHiddenRedrawCsi(body, final)) { + return index + 1 + } + return null + } + return null +} + +function isSafeHiddenRedrawCsi(body: string, final: string): boolean { + if (/[^0-9;?]/.test(body)) { + return false + } + if (final === 'h' || final === 'l') { + return body === '?2026' || body === '?25' + } + return ( + final === 'm' || + final === 'H' || + final === 'f' || + final === 'A' || + final === 'B' || + final === 'C' || + final === 'D' || + final === 'G' || + final === 'J' || + final === 'K' + ) +} + function containsOnlyRestorableHiddenOutput(data: string): boolean { for (let index = 0; index < data.length; ) { const code = data.charCodeAt(index) if (code === 0x1b) { - const nextIndex = findTitleOscEnd(data, index) + const nextIndex = findTitleOscEnd(data, index) ?? findSafeCsiEnd(data, index) if (nextIndex === null) { return false } @@ -66,14 +107,12 @@ export function shouldSkipHiddenRendererOutput({ foreground, canRestoreHiddenOutput, startupRendererQueryWindowActive, - synchronizedOutputActive, data }: HiddenRendererSkipEligibility): boolean { if ( foreground || !canRestoreHiddenOutput || startupRendererQueryWindowActive || - synchronizedOutputActive || data.length === 0 ) { return false 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 450688ddcda..2fd955463f5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2945,48 +2945,56 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - 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) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout + it('restores safe hidden terminal-control bytes from the main snapshot', async () => { + 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 + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'control snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) - try { - 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) + 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() + const controlOutput = '\x1b[2J\x1b[Hhello\r\n' + capturedDataCallback.current?.(controlOutput, { + seq: controlOutput.length, + rawLength: controlOutput.length + }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(controlOutput, expect.any(Function)) - expect(capturedDataCallback.current).not.toBeNull() - const controlOutput = '\x1b[2J\x1b[Hhello\r\n' - capturedDataCallback.current?.(controlOutput) - expect(pane.terminal.write).not.toHaveBeenCalledWith(controlOutput) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.('visible\r\n', { + seq: controlOutput.length + 'visible\r\n'.length, + rawLength: 'visible\r\n'.length + }) + await flushAsyncTicks(20) - for (const fn of pendingTimeouts) { - fn() - } - - expect(pane.terminal.write).toHaveBeenCalledWith(controlOutput) - } finally { - globalThis.setTimeout = originalSetTimeout - } + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('control snapshot'), + expect.any(Function) + ) }) it('keeps visually rich hidden PTY bytes on the live xterm path', async () => { @@ -3023,15 +3031,26 @@ describe('connectPanePty', () => { } }) - it('keeps split hidden synchronized output frames on the live xterm path', async () => { + it('restores safe split hidden synchronized output frames from the main snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + 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 + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot table\r\nLONG_TABLE_SCROLL_RESTORE_marker\r\n', + cols: 100, + rows: 30, + seq: 80 + }) const pane = createPane(1) const manager = createManager(1) @@ -3043,23 +3062,40 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' - const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' + const startChunk = '\x1b[?2026h' + const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' + const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' + const visibleChunk = 'visible-after-hidden\r\n' - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(plainRowChunk) - capturedDataCallback.current?.(endChunk) + capturedDataCallback.current?.(startChunk, { + seq: startChunk.length, + rawLength: startChunk.length + }) + capturedDataCallback.current?.(plainRowChunk, { + seq: startChunk.length + plainRowChunk.length, + rawLength: plainRowChunk.length + }) + capturedDataCallback.current?.(endChunk, { + seq: startChunk.length + plainRowChunk.length + endChunk.length, + rawLength: endChunk.length + }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(`${startChunk}${plainRowChunk}${endChunk}`) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } + expect(pane.terminal.write).not.toHaveBeenCalledWith(startChunk, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(endChunk, expect.any(Function)) + + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleChunk, { + seq: startChunk.length + plainRowChunk.length + endChunk.length + visibleChunk.length, + rawLength: visibleChunk.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('snapshot table'), + expect.any(Function) + ) }) it('queues visible split-pane PTY bytes when the pane is not active', async () => { diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index b34e8b0c606..da631383d73 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -171,7 +171,11 @@ export async function runHiddenRealPtyPressureScenario< ackGate ) - if (pressureOutputMode === 'plain' || pressureOutputMode === 'title') { + if ( + pressureOutputMode === 'plain' || + pressureOutputMode === 'title' || + pressureOutputMode === 'tui' + ) { expect(debug?.hiddenRendererSkipCount ?? 0).toBeGreaterThan(0) expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(0) } else { From ca5fb1436cfe821a2d25bc5b95dd53b385bf72c5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:51:28 -0700 Subject: [PATCH 07/62] Cap remote ACK pending terminal output --- src/main/runtime/rpc/methods/terminal.ts | 83 ++++++++- .../runtime/rpc/terminal-multiplex.test.ts | 170 ++++++++++++++++++ 2 files changed, 252 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index f30e1b2f591..942d4664f1b 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -75,6 +75,9 @@ type TerminalMultiplexStream = { ackInFlightBytes: number buffering: boolean ackPendingOutput: TerminalOutputFrameChunk[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean + ackRecoverySnapshotInFlight: boolean pendingOutput: TerminalOutputChunk[] pendingOutputBytes: number pendingOutputOverflowed: boolean @@ -249,6 +252,26 @@ function appendPendingMultiplexOutput( stream.pendingOutputOverflowed ||= trimmed.overflowed } +function appendAckPendingOutput( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk +): void { + stream.ackPendingOutput.push(chunk) + stream.ackPendingOutputBytes += chunk.bytes.byteLength + let omittedChunkCount = 0 + while ( + stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < stream.ackPendingOutput.length + ) { + stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + stream.ackPendingOutput.splice(0, omittedChunkCount) + stream.ackPendingOutputOverflowed = true + } +} + function trimPendingOutputToBudget( pendingOutput: (string | TerminalOutputChunk)[], pendingOutputBytes: number @@ -949,15 +972,63 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } if ( + stream.ackPendingOutputOverflowed || stream.ackPendingOutput.length > 0 || !canSendAckGatedOutput(stream, chunk.bytes.byteLength) ) { - stream.ackPendingOutput.push(chunk) + appendAckPendingOutput(stream, chunk) return } sendAckGatedOutput(stream, chunk) } + const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { + if ( + closed || + streams.get(stream.streamId) !== stream || + stream.ackRecoverySnapshotInFlight + ) { + return + } + stream.ackRecoverySnapshotInFlight = true + try { + const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) + if (closed || streams.get(stream.streamId) !== stream) { + return + } + const size = runtime.getTerminalSize(stream.ptyId) + const displayMode = runtime.getMobileDisplayMode(stream.ptyId) + // Why: dropped ACK-pending output means live frames are no longer a + // complete replay. Send a fresh model snapshot before resuming output. + sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + reason: 'ack-pending-overflow', + seq: serialized?.seq, + source: serialized?.source, + truncated: true, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? '' + }) + stream.ackPendingOutputOverflowed = false + } catch (error) { + sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + stream.ackRecoverySnapshotInFlight = false + flushAckPendingOutput(stream) + } + } + } const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => { + if (stream.ackPendingOutputOverflowed) { + void sendAckRecoverySnapshot(stream) + return + } let flushed = 0 while ( flushed < stream.ackPendingOutput.length && @@ -968,6 +1039,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } if (flushed > 0) { stream.ackPendingOutput.splice(0, flushed) + stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) } } const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { @@ -989,6 +1064,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) stream.ackInFlightBytes = 0 stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + stream.ackRecoverySnapshotInFlight = false stream.unsubscribeData() stream.unsubscribeResize() stream.unsubscribeFit() @@ -1199,6 +1277,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ackInFlightBytes: 0, buffering: true, ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false, + ackRecoverySnapshotInFlight: false, pendingOutput: [], pendingOutputBytes: 0, pendingOutputOverflowed: false, diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 169ab9a3ed7..608d5ae1d11 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -489,6 +489,176 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: 99 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-overflow', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 17, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(3 * 1024 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 17, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + binaryFrames.splice(0) + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 17, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const drainFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + const recoveryStartIndex = drainFrames.findIndex((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const firstOutputAfterAckIndex = drainFrames.findIndex( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + expect(recoveryStartIndex).toBeGreaterThanOrEqual(0) + expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex) + expect( + drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + + const outputBytesAfterRecovery = drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(outputBytesAfterRecovery).toBeLessThanOrEqual(256 * 1024) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-overflow') + await dispatchPromise + }) + it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] From d01e9d4806b451c084b226fc72638b7c4b3492f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:04:49 -0700 Subject: [PATCH 08/62] Skip hidden Latin terminal renderer writes --- .../hidden-renderer-skip-eligibility.test.ts | 34 +++++++++---- .../hidden-renderer-skip-eligibility.ts | 18 +++++-- .../terminal-pane/pty-connection.test.ts | 51 +++++++++++++++++++ ...icial-opencode-hidden-pressure-scenario.ts | 1 + ...ificial-opencode-hidden-pressure-script.ts | 10 ++-- .../artificial-opencode-terminal-load.spec.ts | 13 +++++ 6 files changed, 108 insertions(+), 19 deletions(-) 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 4fefa59dcc6..af6daad41d8 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 @@ -14,6 +14,18 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(true) }) + it('skips hidden width-stable Latin output when a snapshot restore is available', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data: 'café déjà vu São Tomé Żubrówka Ḃḃ\r\n' + }) + ).toBe(true) + }) + it('keeps visible or non-restorable output on the live renderer path', () => { expect( shouldSkipHiddenRendererOutput({ @@ -131,7 +143,7 @@ describe('shouldSkipHiddenRendererOutput', () => { } }) - it('keeps rewrite and unicode chunks live', () => { + it('keeps rewrite and wide or combining unicode chunks live', () => { expect( shouldSkipHiddenRendererOutput({ foreground: false, @@ -141,14 +153,16 @@ describe('shouldSkipHiddenRendererOutput', () => { data: 'progress 10%\rprogress 20%' }) ).toBe(false) - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data: 'emoji 😀\r\n' - }) - ).toBe(false) + for (const data of ['emoji 😀\r\n', '漢字 table\r\n', 'combining e\u0301\r\n']) { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: false, + data + }) + ).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 index ff6866da73b..6306b231cbf 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 @@ -6,11 +6,18 @@ export type HiddenRendererSkipEligibility = { data: string } -function isAllowedPlainHiddenOutputCode(code: number): boolean { - if (code === 0x09 || code === 0x0a) { +function isAllowedPlainHiddenOutputCodePoint(codePoint: number): boolean { + if (codePoint === 0x09 || codePoint === 0x0a) { return true } - return code >= 0x20 && code <= 0x7e + if (codePoint >= 0x20 && codePoint <= 0x7e) { + return true + } + // Why: hidden restore can safely replay ordinary single-cell Latin text from + // headless state, while wide/combining/table glyph classes stay live. + return ( + (codePoint >= 0x00a0 && codePoint <= 0x024f) || (codePoint >= 0x1e00 && codePoint <= 0x1eff) + ) } function findTitleOscEnd(data: string, startIndex: number): number | null { @@ -95,10 +102,11 @@ function containsOnlyRestorableHiddenOutput(data: string): boolean { index += 1 continue } - if (!isAllowedPlainHiddenOutputCode(code)) { + const codePoint = data.codePointAt(index) + if (typeof codePoint !== 'number' || !isAllowedPlainHiddenOutputCodePoint(codePoint)) { return false } - index += 1 + index += codePoint > 0xffff ? 2 : 1 } return true } 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 2fd955463f5..edf7ccc85c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -3394,6 +3394,57 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('restores hidden Latin text from the main snapshot when the pane returns', async () => { + 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 hidden = 'café déjà vu São Tomé Żubrówka\r\n' + const live = 'visible-after-hidden\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: `snapshot-with-${hidden}`, + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + 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?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) + + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + 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('skips hidden title OSC renderer writes while keeping pane title handling wired', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index da631383d73..f6e2447afc9 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -173,6 +173,7 @@ export async function runHiddenRealPtyPressureScenario< if ( pressureOutputMode === 'plain' || + pressureOutputMode === 'latin' || pressureOutputMode === 'title' || pressureOutputMode === 'tui' ) { diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts index c9887e4c465..05c01a3a6a4 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-script.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -1,7 +1,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' -export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' +export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { const headerPrefix = mode === 'tui' ? '\\x1b[0m' : '' @@ -9,9 +9,11 @@ export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMo const chunkExpression = mode === 'plain' ? "'plain pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" - : mode === 'title' - ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" - : "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'" + : mode === 'latin' + ? "'latin pressure café déjà vu São Tomé Żubrówka pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'title' + ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" + : "'\\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') diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index df37fee6dc9..85a5edf5b57 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -780,6 +780,19 @@ test.describe('Artificial OpenCode terminal load', () => { 'plain' ) }) + test('skips renderer writes for Latin hidden PTY output while preserving restore', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + '-latin', + 'latin' + ) + }) test('skips renderer writes for title-only hidden PTY output while preserving restore', async ({ orcaPage, testRepoPath From 344a6084af59256e2f091a4095c0c31c071c3327 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:20:25 -0700 Subject: [PATCH 09/62] Keep hidden synchronized terminal frames live --- .../hidden-renderer-skip-eligibility.test.ts | 4 +- .../hidden-renderer-skip-eligibility.ts | 4 + .../terminal-pane/pty-connection.test.ts | 92 +++++++++++++------ .../terminal-pane/pty-connection.ts | 22 +++-- 4 files changed, 80 insertions(+), 42 deletions(-) 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 af6daad41d8..bed6553c415 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 @@ -73,7 +73,7 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(true) }) - it('skips safe hidden synchronized redraw chunks when a snapshot restore is available', () => { + it('keeps hidden synchronized redraw chunks live', () => { expect( shouldSkipHiddenRendererOutput({ foreground: false, @@ -82,7 +82,7 @@ describe('shouldSkipHiddenRendererOutput', () => { synchronizedOutputActive: true, data: '\x1b[?2026h\x1b[1;1H\x1b[2J\x1b[32mready\x1b[0m\x1b[?25l\x1b[?2026l\n' }) - ).toBe(true) + ).toBe(false) }) it('keeps startup query windows live', () => { 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 6306b231cbf..b78319bdb92 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 @@ -115,12 +115,16 @@ export function shouldSkipHiddenRendererOutput({ foreground, canRestoreHiddenOutput, startupRendererQueryWindowActive, + synchronizedOutputActive, 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 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 edf7ccc85c3..6a533a166be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -3031,7 +3031,46 @@ describe('connectPanePty', () => { } }) - it('restores safe split hidden synchronized output frames from the main snapshot', async () => { + it('keeps the safe tail of a rich hidden synchronized frame live', 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) + + 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) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + try { + const startChunk = '\x1b[?2026h\x1b[2J\x1b[Hsafe heading\r\n' + const richChunk = '| Sam Syntax | 😀 |\r\n' + const tailChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' + + capturedDataCallback.current?.(startChunk) + capturedDataCallback.current?.(richChunk) + capturedDataCallback.current?.(tailChunk) + + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(startChunk)) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(richChunk)) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(tailChunk)) + } finally { + vi.useRealTimers() + } + }) + + it('keeps split hidden synchronized output frames on the live xterm path', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -3065,37 +3104,30 @@ describe('connectPanePty', () => { const startChunk = '\x1b[?2026h' const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' - const visibleChunk = 'visible-after-hidden\r\n' - capturedDataCallback.current?.(startChunk, { - seq: startChunk.length, - rawLength: startChunk.length - }) - capturedDataCallback.current?.(plainRowChunk, { - seq: startChunk.length + plainRowChunk.length, - rawLength: plainRowChunk.length - }) - capturedDataCallback.current?.(endChunk, { - seq: startChunk.length + plainRowChunk.length + endChunk.length, - rawLength: endChunk.length - }) + vi.useFakeTimers() + try { + capturedDataCallback.current?.(startChunk, { + seq: startChunk.length, + rawLength: startChunk.length + }) + capturedDataCallback.current?.(plainRowChunk, { + seq: startChunk.length + plainRowChunk.length, + rawLength: plainRowChunk.length + }) + capturedDataCallback.current?.(endChunk, { + seq: startChunk.length + plainRowChunk.length + endChunk.length, + rawLength: endChunk.length + }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(startChunk, expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk, expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(endChunk, expect.any(Function)) - - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(visibleChunk, { - seq: startChunk.length + plainRowChunk.length + endChunk.length + visibleChunk.length, - rawLength: visibleChunk.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining('snapshot table'), - expect.any(Function) - ) + vi.advanceTimersByTime(50) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(startChunk)) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(plainRowChunk)) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(endChunk)) + } finally { + vi.useRealTimers() + } }) it('queues visible split-pane PTY bytes when the pane is not active', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 6849afe740a..78ceeb67867 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2599,21 +2599,23 @@ export function connectPanePty( const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) const restoreAppliesToCurrentPty = hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId + const synchronizedOutputStarted = containsSynchronizedOutputStart(data) const synchronizedHiddenOutput = !foreground && (synchronizedHiddenOutputActive || - containsSynchronizedOutputStart(data) || + synchronizedOutputStarted || containsSynchronizedOutputEnd(data)) - if ( - shouldSkipHiddenRendererOutput({ - foreground, - canRestoreHiddenOutput: canUseHiddenOutputSnapshot(transport.getPtyId()), - startupRendererQueryWindowActive: isHiddenStartupRendererQueryWindowActive(), - synchronizedOutputActive: synchronizedHiddenOutput, - data - }) - ) { + const shouldSkipHiddenOutput = shouldSkipHiddenRendererOutput({ + foreground, + canRestoreHiddenOutput: canUseHiddenOutputSnapshot(transport.getPtyId()), + startupRendererQueryWindowActive: isHiddenStartupRendererQueryWindowActive(), + synchronizedOutputActive: synchronizedHiddenOutput, + data + }) + if (shouldSkipHiddenOutput) { skipHiddenRendererOutput(data) + } else if (synchronizedHiddenOutput) { + writePtyOutputToXterm(data, foreground) } else if ( (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && restoreAppliesToCurrentPty From 0cf30df4787393735f60edd5ca460a88833af944 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:31:08 -0700 Subject: [PATCH 10/62] Test renderer backpressure across worktree revisit --- ...cial-opencode-revisit-pressure-scenario.ts | 314 ++++++++++++++++++ .../artificial-opencode-terminal-load.spec.ts | 72 ++-- 2 files changed, 355 insertions(+), 31 deletions(-) create mode 100644 tests/e2e/artificial-opencode-revisit-pressure-scenario.ts diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts new file mode 100644 index 00000000000..3e3d5e5f51f --- /dev/null +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -0,0 +1,314 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync } from 'node:fs' +import path from 'node:path' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +type RevisitPressurePane = { paneKey: string; ptyId: string } + +type RevisitPressureMeasurement = { + medianLatencyMs: number + worstLatencyMs: number + maxTimerDriftMs: number +} + +type RevisitPressureDebug = { hiddenRendererSkipCount: number; hiddenRendererSkippedChars: number } + +type RevisitPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number +} + +type RevisitPressureMainSnapshot = { + peakPendingChars: number + peakRendererInFlightChars: number + ackGatedFlushSkipCount: number +} + +type RevisitPressureAckGate = { heldAckChars: number } + +type RevisitPressureDeps< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +> = { + annotateTypingMeasurement: ( + testInfo: TestInfo, + type: string, + paneCount: number, + measurement: TMeasurement, + debug: TDebug | null, + scheduler: TScheduler | null, + mainPressure: TMainPressure | null, + ackGate: TAckGate | null + ) => void + ensureActiveWorktreePaneLoad: (page: Page, paneCount: number) => Promise + focusPane: (page: Page, paneKey: string) => Promise + holdTerminalAckGate: (page: Page, ptyIds: string[]) => Promise + measureTypingDuringLoad: ( + page: Page, + scriptPath: string, + ptyId: string, + runId: string + ) => Promise + readMainPtyPressureDebug: (page: Page) => Promise + readTerminalAckGateDebug: (page: Page) => Promise + readTerminalOutputSchedulerDebug: (page: Page) => Promise + readTerminalPtyOutputDebug: (page: Page) => Promise + releaseTerminalAckGate: (page: Page) => Promise + resetTerminalPtyOutputDebug: (page: Page) => Promise + waitForMainPtyPressureBacklog: (page: Page) => Promise + writeInteractivePromptScript: (scriptPath: string, runId: string) => void +} + +export async function runRendererBackpressureRevisitScenario< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +>({ + backgroundPaneCount, + deps, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + mainRendererPressureTargetChars, + pressureOutputChars, + orcaPage, + testInfo, + testRepoPath +}: { + backgroundPaneCount: number + deps: RevisitPressureDeps + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + mainRendererPressureTargetChars: number + pressureOutputChars: number + orcaPage: Page + testInfo: TestInfo + testRepoPath: string +}): Promise { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== firstWorktreeId) + expect(Boolean(secondWorktreeId), 'renderer backpressure revisit needs a second worktree').toBe( + true + ) + if (!secondWorktreeId) { + return + } + + const runId = randomUUID() + const typingPtyReadyMarker = `OPENCODE_REVISIT_TYPING_PTY_READY_${runId}` + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, `printf '\\n${typingPtyReadyMarker}\\n'\r`) + await waitForMarkerLatency(orcaPage, typingPtyReadyMarker, 10_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const panes = await deps.ensureActiveWorktreePaneLoad(orcaPage, backgroundPaneCount + 1) + const [revisitPane, ...loadPanes] = panes + await deps.focusPane(orcaPage, revisitPane.paneKey) + + const typingScriptPath = path.join(testRepoPath, `.orca-revisit-typing-${runId}.mjs`) + const pressureScriptPath = path.join(testRepoPath, `.orca-revisit-pressure-${runId}.mjs`) + const revisitMarker = `OPENCODE_REVISIT_READY_${runId}` + const pressureDoneMarker = `OPENCODE_PRESSURE_DONE_${runId}_0` + deps.writeInteractivePromptScript(typingScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') + await deps.resetTerminalPtyOutputDebug(orcaPage) + await deps.holdTerminalAckGate( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + try { + await startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath + }) + const pressureBeforeSwitch = await deps.waitForMainPtyPressureBacklog(orcaPage) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const measurement = await deps.measureTypingDuringLoad( + orcaPage, + typingScriptPath, + typingPtyId, + runId + ) + const duringPressure = await deps.readMainPtyPressureDebug(orcaPage) + const ackGate = await deps.readTerminalAckGateDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + const hiddenDebug = await deps.readTerminalPtyOutputDebug(orcaPage) + deps.annotateTypingMeasurement( + testInfo, + 'opencode-main-pressure-worktree-revisit-typing', + panes.length + 1, + measurement, + hiddenDebug, + scheduler, + duringPressure, + ackGate + ) + + expectPressureStayedBounded({ + ackGate, + hiddenDebug, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure + }) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await deps.focusPane(orcaPage, revisitPane.paneKey) + await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) + const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-marker', + description: `panes=${panes.length + 1} revisit=${revisitLatencyMs.toFixed( + 1 + )}ms heldAckChars=${ackGate?.heldAckChars ?? 0}` + }) + expect(revisitLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + + await deps.releaseTerminalAckGate(orcaPage) + await deps.focusPane(orcaPage, loadPanes[0]?.paneKey ?? revisitPane.paneKey) + const pressureDrainLatencyMs = await waitForMarkerLatency(orcaPage, pressureDoneMarker, 20_000) + const finalScheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-drain', + description: `panes=${panes.length + 1} drain=${pressureDrainLatencyMs.toFixed( + 1 + )}ms rendererPeakQueuedChars=${finalScheduler?.peakQueuedChars ?? 0} rendererDroppedBacklogs=${ + finalScheduler?.droppedBacklogCount ?? 0 + }` + }) + expect(finalScheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(finalScheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + } finally { + await deps.releaseTerminalAckGate(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, '\x03').catch(() => undefined) + await sendToTerminal(orcaPage, revisitPane.ptyId, '\x03').catch(() => undefined) + await Promise.all( + loadPanes.map((pane) => sendToTerminal(orcaPage, pane.ptyId, '\x03').catch(() => undefined)) + ) + rmSync(typingScriptPath, { force: true }) + rmSync(pressureScriptPath, { force: true }) + } +} + +async function startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath +}: { + loadPanes: RevisitPressurePane[] + orcaPage: Page + pressureOutputChars: number + pressureScriptPath: string +}): Promise { + await Promise.all( + loadPanes.map((pane, paneIndex) => + sendToTerminal( + orcaPage, + pane.ptyId, + `node ${JSON.stringify(pressureScriptPath)} ${paneIndex} ${pressureOutputChars}\r` + ) + ) + ) +} + +async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +function expectPressureStayedBounded({ + ackGate, + hiddenDebug, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure +}: { + ackGate: RevisitPressureAckGate | null + hiddenDebug: RevisitPressureDebug | null + mainRendererPressureTargetChars: number + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + measurement: TMeasurement + pressureBeforeSwitch: RevisitPressureMainSnapshot + scheduler: RevisitPressureSchedulerSnapshot | null + duringPressure: RevisitPressureMainSnapshot | null +}): void { + expect(pressureBeforeSwitch.peakPendingChars).toBeGreaterThan(0) + expect(pressureBeforeSwitch.ackGatedFlushSkipCount).toBeGreaterThan(0) + expect(duringPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( + mainRendererPressureTargetChars + ) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(scheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + expect(hiddenDebug?.hiddenRendererSkipCount ?? 0).toBe(0) + expect(hiddenDebug?.hiddenRendererSkippedChars ?? 0).toBe(0) + expect(measurement.medianLatencyMs).toBeLessThan(maxMedianKeyLatencyMs) + expect(measurement.worstLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + expect(measurement.maxTimerDriftMs).toBeLessThan(maxTimerDriftMs) +} diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 85a5edf5b57..b6df0bb1d5b 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -23,6 +23,7 @@ import { import { runHiddenRealPtyPressureScenario } from './artificial-opencode-hidden-pressure-scenario' import type { HiddenPressureOutputMode } from './artificial-opencode-hidden-pressure-script' import { runMainPressureScenario } from './artificial-opencode-main-pressure-scenario' +import { runRendererBackpressureRevisitScenario } from './artificial-opencode-revisit-pressure-scenario' import { startSyntheticOpenCodeInjection } from './artificial-opencode-synthetic-injection' type TerminalLoadPane = { @@ -120,6 +121,7 @@ const MAX_MEDIAN_KEY_LATENCY_MS = 75 const MAX_WORST_KEY_LATENCY_MS = 300 const MAX_TIMER_DRIFT_MS = 150 const MAX_SCROLL_LATENCY_MS = 150 +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] @@ -529,26 +531,28 @@ async function runConfiguredMainPressureScenario({ maxScrollLatencyMs: MAX_SCROLL_LATENCY_MS, maxTimerDriftMs: MAX_TIMER_DRIFT_MS, maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - focusPane, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForActiveWorktree, - waitForMainPtyPressureBacklog, - waitForSessionReady, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps }) } +const terminalLoadScenarioDeps = { + annotateTypingMeasurement, + ensureActiveWorktreePaneLoad, + focusPane, + holdTerminalAckGate, + measureTypingDuringLoad, + readMainPtyPressureDebug, + readTerminalAckGateDebug, + readTerminalOutputSchedulerDebug, + readTerminalPtyOutputDebug, + releaseTerminalAckGate, + resetTerminalPtyOutputDebug, + waitForActiveWorktree, + waitForMainPtyPressureBacklog, + waitForSessionReady, + writeInteractivePromptScript +} + test.describe('Artificial OpenCode terminal load', () => { test.describe.configure({ mode: 'serial' }) @@ -648,6 +652,25 @@ test.describe('Artificial OpenCode terminal load', () => { }) }) + test('keeps renderer backpressure bounded across worktree revisit', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runRendererBackpressureRevisitScenario({ + backgroundPaneCount: PRESSURE_BACKGROUND_PANES, + deps: terminalLoadScenarioDeps, + mainRendererPressureTargetChars: MAIN_RENDERER_PRESSURE_TARGET_CHARS, + maxMedianKeyLatencyMs: MAX_MEDIAN_KEY_LATENCY_MS, + maxRendererSchedulerQueuedChars: MAX_RENDERER_SCHEDULER_QUEUED_CHARS, + maxTimerDriftMs: MAX_TIMER_DRIFT_MS, + maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, + orcaPage, + pressureOutputChars: PRESSURE_OUTPUT_CHARS, + testInfo, + testRepoPath + }) + }) + for (const paneCount of SCALE_PRESSURE_PANES) { test(`keeps active interactions responsive at ${paneCount} ACK-backpressured OpenCode PTYs`, async ({ orcaPage, @@ -740,20 +763,7 @@ test.describe('Artificial OpenCode terminal load', () => { pressureOutputMode, pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, testInfo, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForMainPtyPressureBacklog, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps }) } test('keeps typing responsive while hidden real PTYs are ACK-backpressured', async ({ From a0c22b8dc503c75666972ff5b1b70ad3a899b52d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:30:27 -0700 Subject: [PATCH 11/62] Gate rich headless terminal snapshot replay --- src/main/daemon/headless-emulator.test.ts | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 14e591d4924..35203e71819 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -48,6 +48,49 @@ describe('HeadlessEmulator', () => { const snapshot = emulator.getSnapshot() expect(snapshot.snapshotAnsi).toContain('red text') }) + + it('serializes split synchronized rich TUI frames for model-backed replay', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 12 }) + const richFrame = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h', + '\x1b[?2026l' + ].join('') + + // Why: hidden rich TUI bytes may arrive split across DEC 2026 frame + // boundaries; model/view work needs the headless model to preserve the + // final visible state before renderer writes can be removed. + await emulator.write(richFrame.slice(0, 17)) + await emulator.write(richFrame.slice(17, 91)) + await emulator.write(richFrame.slice(91)) + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.alternateScreen).toBe(true) + expect(snapshot.snapshotAnsi).toContain('Codex rich restore') + expect(snapshot.snapshotAnsi).toContain('🟢') + expect(snapshot.snapshotAnsi).toContain('███░') + expect(snapshot.snapshotAnsi).toContain('╭') + expect(snapshot.snapshotAnsi).not.toContain('\x1b[?2026h') + + const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows }) + try { + await replay.write(snapshot.rehydrateSequences + snapshot.snapshotAnsi) + const replayed = replay.getSnapshot() + expect(replayed.modes.alternateScreen).toBe(true) + expect(replayed.snapshotAnsi).toContain('Codex rich restore') + expect(replayed.snapshotAnsi).toContain('🟢') + expect(replayed.snapshotAnsi).toContain('███░') + } finally { + replay.dispose() + } + }) }) describe('OSC-7 CWD tracking', () => { From 57991d13abc9eef9f0777415b537fcb61a520ea6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:49:41 -0700 Subject: [PATCH 12/62] Restore slept terminal output on wake --- src/main/daemon/daemon-pty-adapter.test.ts | 45 +++++ src/main/daemon/daemon-pty-adapter.ts | 62 ++++-- .../terminal-pane/pty-connection.test.ts | 2 + .../terminal-pane/pty-connection.ts | 15 +- tests/e2e/terminal-sleep-wake-restore.spec.ts | 187 ++++++++++++++++++ 5 files changed, 285 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/terminal-sleep-wake-restore.spec.ts diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index f0a099c23b7..17693639b95 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -676,6 +676,51 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('checkpoints before keep-history shutdown so sleep can cold restore latest output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-checkpoint' + }) + const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint') + + lastSubprocess._simulateData('latest before sleep\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(checkpointSpy).toHaveBeenCalledWith( + id, + expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') }) + ) + expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) + + const restored = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(restored.coldRestore?.scrollback).toContain('latest before sleep') + historyAdapter.ackColdRestore(id) + + const remountAfterAck = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(remountAfterAck.coldRestore).toBeUndefined() + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + it('writes meta.json with endedAt on exit', async () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 4d98c62cc66..7ccb01415d1 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -7,6 +7,7 @@ import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth } from './daemon-health' import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' +import type { ColdRestoreInfo } from './history-reader' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' import { supportsPtyStartupBarrier } from './shell-ready' import { @@ -69,6 +70,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // mount → ??? The sticky cache returns the same cold restore data on the // second mount until the renderer explicitly acknowledges it. private coldRestoreCache = new Map() + private sleepRestoreSessionIds = new Set() private activeSessionIds = new Set() private dirtySessionVersions = new Map() private checkpointTimer: ReturnType | null = null @@ -169,28 +171,14 @@ export class DaemonPtyAdapter implements IPtyProvider { // an unclean shutdown → return saved scrollback so the renderer can // display the previous terminal content. if (result.isNew && restoreInfo) { - // Why: if the checkpoint was captured while an alternate-screen app - // (vim, less, htop) was active, snapshotAnsi is the alt buffer content. - // Replaying that into a fresh shell would show stale TUI content. Use - // scrollbackAnsi (rows above the viewport only) which excludes the alt - // buffer. For normal sessions, use the full snapshot with rehydrate - // sequences to restore terminal modes (colors, cursor position, etc). - // Why: scrollbackAnsi may be empty if the emulator hadn't accumulated - // scrollback before the alt-screen app launched. In that case, skip - // cold restore entirely rather than showing a blank terminal — no - // content is better than confusing the user with an empty restore. - const isAltScreen = restoreInfo.modes.alternateScreen - const scrollback = isAltScreen - ? restoreInfo.scrollbackAnsi || null - : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + const coldRestore = this.buildColdRestorePayload(restoreInfo) // Why: use registerWriter (not openSession) to avoid deleting the // existing checkpoint.json. If the revived daemon crashes again before // the next 5s tick, the checkpoint is the only recovery data available. if (this.historyManager) { this.historyManager.registerWriter(sessionId) } - if (scrollback) { - const coldRestore = { scrollback, cwd: restoreInfo.cwd } + if (coldRestore) { this.coldRestoreCache.set(sessionId, coldRestore) return { id: sessionId, pid, coldRestore } } @@ -256,10 +244,27 @@ export class DaemonPtyAdapter implements IPtyProvider { } async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + if (opts.keepHistory && this.historyManager && this.supportsCheckpoints) { + // Why: sleep kills the live PTY before the periodic checkpoint may run. + // Capture the daemon buffer now so wake can restore the pane users left. + if (this.checkpointInFlight) { + await this.checkpointInFlight + } + await this.checkpointSessions([id]) + const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null + const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null + if (coldRestore) { + this.coldRestoreCache.set(id, coldRestore) + this.sleepRestoreSessionIds.add(id) + } + } await this.client.request('kill', { sessionId: id }) this.activeSessionIds.delete(id) this.dirtySessionVersions.delete(id) - this.coldRestoreCache.delete(id) + if (!opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + } this.stopCheckpointTimerIfIdle() this.initialCwds.delete(id) // Why: history removal is for the "user explicitly closed this terminal" @@ -289,12 +294,31 @@ export class DaemonPtyAdapter implements IPtyProvider { ackColdRestore(sessionId: string): void { this.coldRestoreCache.delete(sessionId) + this.sleepRestoreSessionIds.delete(sessionId) } clearTombstone(sessionId: string): void { this.killedSessionTombstones.delete(sessionId) } + private buildColdRestorePayload( + restoreInfo: ColdRestoreInfo + ): { scrollback: string; cwd: string } | null { + // Why: if the checkpoint was captured while an alternate-screen app + // (vim, less, htop) was active, snapshotAnsi is the alt buffer content. + // Replaying that into a fresh shell would show stale TUI content. Use + // scrollbackAnsi (rows above the viewport only) which excludes the alt + // buffer. For normal sessions, use the full snapshot with rehydrate + // sequences to restore terminal modes (colors, cursor position, etc). + const scrollback = restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + if (!scrollback) { + return null + } + return { scrollback, cwd: restoreInfo.cwd } + } + async sendSignal(id: string, signal: string): Promise { await this.client.request('signal', { sessionId: id, signal }) } @@ -764,7 +788,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } else if (event.event === 'exit') { this.activeSessionIds.delete(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) - this.coldRestoreCache.delete(event.sessionId) + if (!this.sleepRestoreSessionIds.has(event.sessionId)) { + this.coldRestoreCache.delete(event.sessionId) + } this.stopCheckpointTimerIfIdle() if (this.historyManager) { void this.historyManager 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 6a533a166be..6989a9de289 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -710,6 +710,8 @@ describe('connectPanePty', () => { onPtyExit?.('pty-pane-2') expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() expect(manager.closePane).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 78ceeb67867..6d48109a9ae 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -993,6 +993,13 @@ export function connectPanePty( const onExit = (ptyId: string): void => { agentCompletionCoordinator.dispose() clearPanePtyFitBinding() + // Why: sleep and intentional pane-close/restart paths already record the + // desired lifecycle state before kill. Do not erase wake hints here. + if (deps.consumeSuppressedPtyExit(ptyId)) { + manager.setPaneGpuRendering(pane.id, true) + scheduleRuntimeGraphSync() + return + } deps.syncPanePtyLayoutBinding(pane.id, null) deps.clearRuntimePaneTitle(deps.tabId, pane.id) deps.clearTabPtyId(deps.tabId, ptyId) @@ -1007,14 +1014,6 @@ export function connectPanePty( // we must republish when a pane loses its PTY instead of waiting for a // broader layout change that may never happen. scheduleRuntimeGraphSync() - // Why: intentional restarts suppress the PTY exit ahead of time so the - // pane stays mounted and can reconnect in place. Without consuming the - // suppression here, split-pane Codex restarts would still close the pane - // because this handler runs before the tab-level close logic sees the exit. - if (deps.consumeSuppressedPtyExit(ptyId)) { - manager.setPaneGpuRendering(pane.id, true) - return - } manager.setPaneGpuRendering(pane.id, true) const panes = manager.getPanes() if (panes.length <= 1) { diff --git a/tests/e2e/terminal-sleep-wake-restore.spec.ts b/tests/e2e/terminal-sleep-wake-restore.spec.ts new file mode 100644 index 00000000000..232665e9805 --- /dev/null +++ b/tests/e2e/terminal-sleep-wake-restore.spec.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +type SleepWakeTerminalDebug = { + activeTabId: string | null + activeWorktreeId: string | null + tabs: { + id: string + ptyId?: string + generation?: number + pendingActivationSpawn?: boolean | number + }[] + ptyIdsByTabId: Record + ptyIdsByLeafIdByTabId: Record> +} + +async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise { + await page.evaluate(async (id) => { + const store = window.__store + if (!store) { + throw new Error('store unavailable') + } + const state = store.getState() + await state.shutdownWorktreeBrowsers(id) + await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true }) + }, worktreeId) +} + +async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return 0 + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0) + }, worktreeId) +} + +async function readSleepWakeTerminalDebug( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return { + activeTabId: null, + activeWorktreeId: null, + tabs: [], + ptyIdsByTabId: {}, + ptyIdsByLeafIdByTabId: {} + } + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return { + activeTabId: state.activeTabId, + activeWorktreeId: state.activeWorktreeId, + tabs: tabs.map((tab) => ({ + id: tab.id, + ptyId: tab.ptyId, + generation: tab.generation, + pendingActivationSpawn: tab.pendingActivationSpawn + })), + ptyIdsByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []]) + ), + ptyIdsByLeafIdByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}]) + ) + } + }, worktreeId) +} + +async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise { + return page.evaluate( + async ({ targetPtyId, expectedText }) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 200 + }) + return snapshot?.data.includes(expectedText) ?? false + }, + { targetPtyId: ptyId, expectedText: text } + ) +} + +function richSleepWakePayload(runId: string): string { + return [ + '\x1b[?2026h', + '\x1b[2J\x1b[H', + '╭────────────────────────────╮', + `│ sleep wake restore ${runId.slice(0, 8)} 😀 │`, + '╰────────────────────────────╯', + `SLEEP_WAKE_RESTORE_${runId}`, + '\x1b[?2026l' + ].join('\r\n') +} + +function nodeWriteCodePointPayloadCommand(payload: string): string { + const codePoints = [...payload].map((char) => char.codePointAt(0) ?? 0) + return `node -e "process.stdout.write(String.fromCodePoint(...${JSON.stringify(codePoints)}))"` +} + +test.describe('Terminal sleep wake restore', () => { + test('restores slept terminal output and accepts fresh input after wake', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}` + const freshMarker = `SLEEP_WAKE_FRESH_${runId}` + await sendToTerminal( + orcaPage, + ptyId, + `${nodeWriteCodePointPayloadCommand(richSleepWakePayload(runId))}\r` + ) + await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000) + const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + expect(await mainSnapshotContains(orcaPage, ptyId, restoreMarker)).toBe(true) + + await switchToWorktree(orcaPage, firstWorktreeId) + await sleepWorktreeTerminals(orcaPage, secondWorktreeId) + const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + await expect + .poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), { + timeout: 10_000, + message: 'sleep did not release live PTYs for the background worktree' + }) + .toBe(0) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const awakePtyId = await waitForActivePanePtyId(orcaPage) + const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000) + expect + .soft(awakeTerminalContent.includes(restoreMarker), { + message: JSON.stringify( + { + ptyId, + awakePtyId, + beforeSleepDebug, + afterSleepDebug, + afterWakeDebug, + terminalTail: awakeTerminalContent.slice(-2000) + }, + null, + 2 + ) + }) + .toBe(true) + await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000) + await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`) + await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000) + }) +}) From f7c4377e952a83c2be9497b6fbe1b72299d3cfc5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:10:32 -0700 Subject: [PATCH 13/62] Drain remote multiplex output after shared ACKs --- src/main/runtime/rpc/methods/terminal.ts | 8 +- .../runtime/rpc/terminal-multiplex.test.ts | 225 ++++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 942d4664f1b..5fa80053d16 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1045,6 +1045,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) } } + const flushAllAckPendingOutput = (): void => { + for (const stream of streams.values()) { + flushAckPendingOutput(stream) + } + } const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { if (!stream.ackOutput || bytes <= 0) { return @@ -1052,7 +1057,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const acknowledged = Math.min(stream.ackInFlightBytes, bytes) stream.ackInFlightBytes -= acknowledged ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) - flushAckPendingOutput(stream) + flushAllAckPendingOutput() } const detachStream = (streamId: number, emitEnd: boolean): void => { const stream = streams.get(streamId) @@ -1073,6 +1078,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.unsubscribeDriver() stream.unregisterBinaryHandler() streams.delete(streamId) + flushAllAckPendingOutput() if (stream.isMobile && stream.client?.id) { runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) } diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 608d5ae1d11..00fc8668b72 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -489,6 +489,231 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('releases shared ACK budget to other stalled multiplex streams', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListeners = new Map< + string, + (data: string, meta?: { seq?: number; rawLength?: number }) => void + >() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn((terminal: string) => ({ + ptyId: terminal.replace('terminal-', 'pty-') + })), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async (ptyId: string) => ({ + data: `snapshot-${ptyId}`, + cols: 120, + rows: 40 + })), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + ptyId: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListeners.set(ptyId, listener) + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-shared-budget', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + + const streamIds = [21, 22, 23, 24, 25, 26] + for (const streamId of streamIds) { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: streamId, + payload: encodeTerminalStreamJson({ + streamId, + terminal: `terminal-${streamId - 20}`, + client: { id: `desktop-${streamId}`, type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + } + + await vi.waitFor(() => + expect( + messages + .map((msg) => JSON.parse(msg).result) + .filter((result) => result?.type === 'subscribed') + ).toHaveLength(streamIds.length) + ) + await vi.waitFor(() => expect(dataListeners.size).toBe(streamIds.length)) + binaryFrames.splice(0) + + const fillerOutput = 'f'.repeat(480 * 1024) + for (let index = 1; index <= 4; index += 1) { + dataListeners.get(`pty-${index}`)?.(fillerOutput, { + seq: fillerOutput.length, + rawLength: fillerOutput.length + }) + } + const stalledOutput = 's'.repeat(700 * 1024) + dataListeners.get('pty-5')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + dataListeners.get('pty-6')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytesByStream = new Map() + for (const frame of initialOutputFrames) { + if (!frame) { + continue + } + initialBytesByStream.set( + frame.streamId, + (initialBytesByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(2 * 1024 * 1024) + expect(initialBytesByStream.get(21)).toBe(480 * 1024) + expect(initialBytesByStream.get(22)).toBe(480 * 1024) + expect(initialBytesByStream.get(23)).toBe(480 * 1024) + expect(initialBytesByStream.get(24)).toBe(480 * 1024) + expect(initialBytesByStream.get(25)).toBeGreaterThan(0) + expect(initialBytesByStream.get(26) ?? 0).toBe(0) + + handlers.get(26)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 26, + seq: 200, + payload: encodeTerminalStreamText('remote-still-interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-6', { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }) + ) + + const frameCountBeforeAck = binaryFrames.length + handlers.get(21)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + seq: 201, + payload: encodeTerminalStreamJson({ bytes: initialBytesByStream.get(21) ?? 0 }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const framesAfterAck = binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + const snapshotStartIndex = framesAfterAck.findIndex((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const outputFramesAfterAck = framesAfterAck.filter( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + const bytesAfterAckByStream = new Map() + for (const frame of outputFramesAfterAck) { + if (!frame) { + continue + } + bytesAfterAckByStream.set( + frame.streamId, + (bytesAfterAckByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + expect(snapshotStartIndex).toBeGreaterThanOrEqual(0) + expect( + framesAfterAck + .filter((frame) => frame?.streamId === 25 && frame.opcode === TerminalStreamOpcode.Output) + .every((frame) => framesAfterAck.indexOf(frame) > snapshotStartIndex) + ).toBe(true) + expect(bytesAfterAckByStream.get(25) ?? 0).toBeGreaterThan(0) + expect(bytesAfterAckByStream.get(21) ?? 0).toBe(0) + expect( + outputFramesAfterAck.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + ).toBeLessThanOrEqual(initialBytesByStream.get(21) ?? 0) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-shared-budget') + await dispatchPromise + }) + it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] From 6f5c03f4c6affa282efc1d1f2eaf551c0a2ffb8a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:40:02 -0700 Subject: [PATCH 14/62] Gate rich hidden snapshot restore --- .../terminal-pane/pty-connection.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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 6989a9de289..967da282a5d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4243,6 +4243,73 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('replays rich headless snapshots as the future hidden TUI view source', async () => { + 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 hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const richSnapshot = [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h' + ].join('') + const visibleTrigger = 'visible-trigger\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: richSnapshot, + cols: 96, + rows: 18, + seq: hidden.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?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleTrigger, { + seq: hidden.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(richSnapshot, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(deps.replayingPanesRef.current.size).toBe(0) + disposable.dispose() + }) + it('refreshes visible rows after replaying a hidden TUI snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') From c050e7532fbf93ec00467de91714b140bbbe6e82 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:04:28 -0700 Subject: [PATCH 15/62] Prototype rich hidden terminal model restore --- .../hidden-renderer-skip-eligibility.test.ts | 13 +++ .../hidden-renderer-skip-eligibility.ts | 10 +- .../terminal-pane/pty-connection.test.ts | 78 ++++++++++++++ .../terminal-pane/pty-connection.ts | 10 ++ ...terminal-hidden-tui-visual-restore.spec.ts | 102 ++++++++++++++++++ 5 files changed, 210 insertions(+), 3 deletions(-) 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 }) => { From 6709d71153ab7f10949cc1a412b7f43a4fb37831 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:26:04 -0700 Subject: [PATCH 16/62] Test rich hidden model restore under pressure --- .../terminal-pane/pty-connection.ts | 3 +- ...icial-opencode-hidden-pressure-scenario.ts | 12 +++ ...ificial-opencode-hidden-pressure-script.ts | 10 +- .../artificial-opencode-terminal-load.spec.ts | 96 +++++++++---------- ...otype-synchronized-hidden-model-restore.ts | 15 +++ ...terminal-hidden-tui-visual-restore.spec.ts | 21 ++-- 6 files changed, 88 insertions(+), 69 deletions(-) create mode 100644 tests/e2e/prototype-synchronized-hidden-model-restore.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index d825916a3d9..668f061ef5a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -425,7 +425,8 @@ function shouldAllowPrototypeSynchronizedHiddenModelRestore(): boolean { __ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__?: boolean } return ( - import.meta.env.DEV && target.__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__ === true + (import.meta.env.DEV || e2eConfig.exposeStore) && + target.__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__ === true ) } diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index f6e2447afc9..88b72163eff 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -7,6 +7,7 @@ import { type HiddenPressureOutputMode, writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' +import { setPrototypeSynchronizedHiddenModelRestore } from './prototype-synchronized-hidden-model-restore' import { ensureTerminalVisible, getActiveWorktreeId, @@ -94,6 +95,7 @@ export async function runHiddenRealPtyPressureScenario< pressureOutputChars, pressureOutputMode = 'tui', pressureStartDelayMs, + prototypeSynchronizedHiddenModelRestore = false, testInfo, testRepoPath, orcaPage @@ -104,6 +106,7 @@ export async function runHiddenRealPtyPressureScenario< pressureOutputChars: number pressureOutputMode?: HiddenPressureOutputMode pressureStartDelayMs: number + prototypeSynchronizedHiddenModelRestore?: boolean testInfo: TestInfo testRepoPath: string orcaPage: Page @@ -135,6 +138,10 @@ export async function runHiddenRealPtyPressureScenario< writePressureOutputScript(pressureScriptPath, runId, pressureOutputMode) await deps.resetTerminalPtyOutputDebug(orcaPage) + await setPrototypeSynchronizedHiddenModelRestore( + orcaPage, + prototypeSynchronizedHiddenModelRestore + ) await deps.holdTerminalAckGate( orcaPage, hiddenPanes.map((pane) => pane.ptyId) @@ -175,6 +182,7 @@ export async function runHiddenRealPtyPressureScenario< pressureOutputMode === 'plain' || pressureOutputMode === 'latin' || pressureOutputMode === 'title' || + pressureOutputMode === 'rich-model' || pressureOutputMode === 'tui' ) { expect(debug?.hiddenRendererSkipCount ?? 0).toBeGreaterThan(0) @@ -183,6 +191,9 @@ export async function runHiddenRealPtyPressureScenario< expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) } + if (pressureOutputMode === 'rich-model') { + expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(pressureOutputChars) + } expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( @@ -300,6 +311,7 @@ async function cleanupHiddenPressureScenario< await Promise.all( hiddenPanes.map((pane) => sendToTerminal(orcaPage, pane.ptyId, '\x03').catch(() => undefined)) ) + await setPrototypeSynchronizedHiddenModelRestore(orcaPage, false).catch(() => undefined) rmSync(typingScriptPath, { force: true }) rmSync(pressureScriptPath, { force: true }) } diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts index 05c01a3a6a4..1eb94b3de89 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-script.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -1,11 +1,11 @@ import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' -export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' +export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' | 'rich-model' export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { - const headerPrefix = mode === 'tui' ? '\\x1b[0m' : '' - const donePrefix = mode === 'tui' ? '\\x1b[0m' : '' + const headerPrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const donePrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' const chunkExpression = mode === 'plain' ? "'plain pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" @@ -13,7 +13,9 @@ export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMo ? "'latin pressure café déjà vu São Tomé Żubrówka pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" : mode === 'title' ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" - : "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'" + : mode === 'rich-model' + ? "'\\x1b[?2026h\\x1b[?1049h\\x1b[2J\\x1b[H\\x1b[?25l\\x1b[2;36m╭────────────────────────────────────────╮\\x1b[0m\\r\\n\\x1b[2;36m│ rich model pane=' + paneIndex + ' frame=' + frame + ' 😀 ███░ │\\x1b[0m\\r\\n\\x1b[2;36m│ ' + chunkBody + ' │\\x1b[0m\\r\\n\\x1b[2;36m╰────────────────────────────────────────╯\\x1b[0m\\x1b[6;4H\\x1b[?25h\\x1b[?2026l\\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') diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index b6df0bb1d5b..4b1e310b888 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -752,7 +752,8 @@ test.describe('Artificial OpenCode terminal load', () => { testInfo: TestInfo, hiddenPaneCount: number, annotationSuffix?: string, - pressureOutputMode?: HiddenPressureOutputMode + pressureOutputMode?: HiddenPressureOutputMode, + prototypeSynchronizedHiddenModelRestore?: boolean ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -762,60 +763,53 @@ test.describe('Artificial OpenCode terminal load', () => { pressureOutputChars: PRESSURE_OUTPUT_CHARS, pressureOutputMode, pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, + prototypeSynchronizedHiddenModelRestore, testInfo, deps: terminalLoadScenarioDeps }) } - test('keeps typing responsive while hidden real PTYs are ACK-backpressured', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await runConfiguredHiddenRealPtyPressureScenario( - orcaPage, - testRepoPath, - testInfo, - 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' - ) - }) - test('skips renderer writes for Latin hidden PTY output while preserving restore', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await runConfiguredHiddenRealPtyPressureScenario( - orcaPage, - testRepoPath, - testInfo, - HIDDEN_PRESSURE_PANES, - '-latin', - 'latin' - ) - }) - test('skips renderer writes for title-only hidden PTY output while preserving restore', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await runConfiguredHiddenRealPtyPressureScenario( - orcaPage, - testRepoPath, - testInfo, - HIDDEN_PRESSURE_PANES, - '-title', - 'title' - ) - }) + const hiddenPressureCases: { + title: string + suffix?: string + mode?: HiddenPressureOutputMode + prototypeModelRestore?: boolean + }[] = [ + { title: 'keeps typing responsive while hidden real PTYs are ACK-backpressured' }, + { + title: 'skips renderer writes for plain hidden PTY output while preserving restore', + suffix: '-plain', + mode: 'plain' + }, + { + title: 'skips renderer writes for Latin hidden PTY output while preserving restore', + suffix: '-latin', + mode: 'latin' + }, + { + title: 'skips renderer writes for title-only hidden PTY output while preserving restore', + suffix: '-title', + mode: 'title' + }, + { + title: 'prototypes rich hidden model restore under ACK-backpressured PTY output', + suffix: '-rich-model', + mode: 'rich-model', + prototypeModelRestore: true + } + ] + for (const hiddenPressureCase of hiddenPressureCases) { + test(hiddenPressureCase.title, async ({ orcaPage, testRepoPath }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + hiddenPressureCase.suffix, + hiddenPressureCase.mode, + hiddenPressureCase.prototypeModelRestore + ) + }) + } 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/prototype-synchronized-hidden-model-restore.ts b/tests/e2e/prototype-synchronized-hidden-model-restore.ts new file mode 100644 index 00000000000..d4931c92bb7 --- /dev/null +++ b/tests/e2e/prototype-synchronized-hidden-model-restore.ts @@ -0,0 +1,15 @@ +import type { Page } from '@stablyai/playwright-test' + +const PROTOTYPE_FLAG = '__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__' + +export async function setPrototypeSynchronizedHiddenModelRestore( + page: Page, + enabled: boolean +): Promise { + await page.evaluate( + ({ flag, enabled }) => { + ;(window as unknown as Record)[flag] = enabled + }, + { flag: PROTOTYPE_FLAG, enabled } + ) +} diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index a1fc9e86d3d..ac1d1660071 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -1,6 +1,6 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { @@ -17,9 +17,9 @@ import { waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './helpers/terminal' +import { setPrototypeSynchronizedHiddenModelRestore } from './prototype-synchronized-hidden-model-restore' type HiddenTuiWindow = Window & { - __ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__?: boolean __terminalPtyDataInjection?: { inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean } @@ -44,6 +44,8 @@ type TuiCursorState = { initialized: boolean | null } +const HIDDEN_FRAME_SCRIPT_DELAY_MS = 750 + function tuiFrame(runId: string, frame: number): string { const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` const rows = [ @@ -83,27 +85,20 @@ 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)) + mkdirSync(path.dirname(scriptPath), { recursive: true }) writeFileSync( scriptPath, - `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), 250)\n` + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` ) } function writeLowRiskFrameScript(scriptPath: string, frame: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) writeFileSync( scriptPath, - `setTimeout(() => process.stdout.write(${JSON.stringify(frame)}), 250)\n` + `setTimeout(() => process.stdout.write(${JSON.stringify(frame)}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` ) } From 71fa199b15c67b8fb1f72917046bfe721acb8bf2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:06:47 -0700 Subject: [PATCH 17/62] Enable rich hidden terminal model restore by default --- .../hidden-renderer-skip-eligibility.test.ts | 49 ++++++- .../hidden-renderer-skip-eligibility.ts | 65 ++++++++- .../terminal-pane/pty-connection.test.ts | 92 +++++++++++-- .../terminal-pane/pty-connection.ts | 124 ++++++++++++++---- ...icial-opencode-hidden-pressure-scenario.ts | 8 -- .../artificial-opencode-terminal-load.spec.ts | 29 ++-- ...otype-synchronized-hidden-model-restore.ts | 15 --- ...terminal-hidden-tui-visual-restore.spec.ts | 31 +++-- ...terminal-long-table-scroll-restore.spec.ts | 12 +- 9 files changed, 328 insertions(+), 97 deletions(-) delete mode 100644 tests/e2e/prototype-synchronized-hidden-model-restore.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 index 9d28a1a8b05..da233792582 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 @@ -73,7 +73,7 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(true) }) - it('keeps hidden synchronized redraw chunks live', () => { + it('keeps hidden synchronized redraw chunks live without the model restore gate', () => { expect( shouldSkipHiddenRendererOutput({ foreground: false, @@ -85,7 +85,7 @@ describe('shouldSkipHiddenRendererOutput', () => { ).toBe(false) }) - it('can opt synchronized chunks into model-backed restore for prototype coverage', () => { + it('skips model-restorable synchronized rich chunks when model restore is allowed', () => { expect( shouldSkipHiddenRendererOutput({ foreground: false, @@ -93,11 +93,44 @@ describe('shouldSkipHiddenRendererOutput', () => { startupRendererQueryWindowActive: false, synchronizedOutputActive: true, allowSynchronizedModelRestore: true, - data: '\x1b[?2026h\x1b[2J\x1b[H╭ rich 😀 ╮\r\n\x1b[?2026l' + data: '\x1b[?2026h\x1b[?1049h\x1b[2J\x1b[H╭ rich 😀 ╮\r\n\x1b[?25l\x1b[?2026l' }) ).toBe(true) }) + it('skips synchronized model output with PTY-mapped CRCRLF newlines', () => { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: true, + allowSynchronizedModelRestore: true, + data: '\x1b[?2026h\x1b[2J\x1b[H╭ rich 😀 ╮\r\r\n\x1b[?2026l' + }) + ).toBe(true) + }) + + it('keeps query and incomplete synchronized chunks live even with model restore allowed', () => { + for (const data of [ + '\x1b[?2026h\x1b[6n', + '\x1b[?2026h\x1b[c', + '\x1b[?2026h\x1b[?25', + '\x1b[?2026h\x9b6n' + ]) { + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: false, + synchronizedOutputActive: true, + allowSynchronizedModelRestore: true, + data + }) + ).toBe(false) + } + }) + it('keeps startup query windows live', () => { expect( shouldSkipHiddenRendererOutput({ @@ -117,6 +150,16 @@ describe('shouldSkipHiddenRendererOutput', () => { data: '\x1b]0;title\x07' }) ).toBe(false) + expect( + shouldSkipHiddenRendererOutput({ + foreground: false, + canRestoreHiddenOutput: true, + startupRendererQueryWindowActive: true, + synchronizedOutputActive: true, + allowSynchronizedModelRestore: true, + data: '\x1b[?2026h\x1b[2J\x1b[Hmodel-restorable\r\n\x1b[?2026l' + }) + ).toBe(false) expect( shouldSkipHiddenRendererOutput({ foreground: 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 index c4f81c10827..015096c63cc 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 @@ -44,7 +44,11 @@ function findTitleOscEnd(data: string, startIndex: number): number | null { return null } -function findSafeCsiEnd(data: string, startIndex: number): number | null { +function findSafeCsiEnd( + data: string, + startIndex: number, + mode: 'plain' | 'synchronized-model' = 'plain' +): number | null { if (data.charCodeAt(startIndex) !== 0x1b || data.charCodeAt(startIndex + 1) !== 0x5b) { return null } @@ -56,7 +60,7 @@ function findSafeCsiEnd(data: string, startIndex: number): number | null { } const body = data.slice(startIndex + 2, index) const final = data[index] - if (isSafeHiddenRedrawCsi(body, final)) { + if (isSafeHiddenRedrawCsi(body, final, mode)) { return index + 1 } return null @@ -64,12 +68,16 @@ function findSafeCsiEnd(data: string, startIndex: number): number | null { return null } -function isSafeHiddenRedrawCsi(body: string, final: string): boolean { +function isSafeHiddenRedrawCsi( + body: string, + final: string, + mode: 'plain' | 'synchronized-model' +): boolean { if (/[^0-9;?]/.test(body)) { return false } if (final === 'h' || final === 'l') { - return body === '?2026' || body === '?25' + return body === '?2026' || body === '?25' || (mode === 'synchronized-model' && body === '?1049') } return ( final === 'm' || @@ -112,6 +120,48 @@ function containsOnlyRestorableHiddenOutput(data: string): boolean { return true } +function containsOnlyModelRestorableSynchronizedOutput(data: string): boolean { + for (let index = 0; index < data.length; ) { + const code = data.charCodeAt(index) + if (code === 0x1b) { + const nextIndex = + findTitleOscEnd(data, index) ?? findSafeCsiEnd(data, index, 'synchronized-model') + if (nextIndex === null) { + return false + } + index = nextIndex + continue + } + if (code === 0x0d) { + let newlineIndex = index + 1 + // Why: real PTYs can map an app-written CRLF into CRCRLF. Treat only + // CR runs that immediately end in LF as newlines, not cursor rewrites. + while (data.charCodeAt(newlineIndex) === 0x0d) { + newlineIndex += 1 + } + if (data.charCodeAt(newlineIndex) !== 0x0a) { + return false + } + index = newlineIndex + 1 + continue + } + const codePoint = data.codePointAt(index) + if ( + typeof codePoint !== 'number' || + codePoint < 0x09 || + codePoint === 0x7f || + (codePoint >= 0x80 && codePoint <= 0x9f) + ) { + return false + } + if (codePoint < 0x20 && codePoint !== 0x09 && codePoint !== 0x0a) { + return false + } + index += codePoint > 0xffff ? 2 : 1 + } + return true +} + export function shouldSkipHiddenRendererOutput({ foreground, canRestoreHiddenOutput, @@ -129,9 +179,10 @@ export function shouldSkipHiddenRendererOutput({ 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 + if (!allowSynchronizedModelRestore) { + return false + } + return containsOnlyModelRestorableSynchronizedOutput(data) } 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 4d3dc9c2a25..ecbbac8ec15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -533,8 +533,6 @@ 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 () => { @@ -3035,7 +3033,7 @@ describe('connectPanePty', () => { } }) - it('keeps the safe tail of a rich hidden synchronized frame live', async () => { + it('skips every chunk of a rich hidden synchronized frame for model-backed restore', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -3066,15 +3064,15 @@ describe('connectPanePty', () => { capturedDataCallback.current?.(tailChunk) vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(startChunk)) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(richChunk)) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(tailChunk)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(startChunk)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(richChunk)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(tailChunk)) } finally { vi.useRealTimers() } }) - it('keeps split hidden synchronized output frames on the live xterm path', async () => { + it('skips split hidden synchronized output frames for model-backed restore', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -3126,17 +3124,85 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(50) expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(startChunk)) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(plainRowChunk)) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(endChunk)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(startChunk)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(plainRowChunk)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(endChunk)) } finally { vi.useRealTimers() } }) - it('can prototype hidden rich synchronized restore from the headless model', async () => { - ;(globalThis as Record).__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__ = - true + it('detects split hidden synchronized starts before skipping later payload', 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) + + 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) + + expect(capturedDataCallback.current).not.toBeNull() + const splitStartHead = '\x1b[?20' + const splitStartTail = '26h\x1b[2J\x1b[H' + const payload = 'split synchronized payload\r\n\x1b[?2026l' + + vi.useFakeTimers() + try { + capturedDataCallback.current?.(splitStartHead) + capturedDataCallback.current?.(splitStartTail) + capturedDataCallback.current?.(payload) + + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(splitStartHead)) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(splitStartTail)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(payload)) + } finally { + vi.useRealTimers() + } + }) + + it('keeps hidden synchronized terminal queries on the live xterm path', 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) + + 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) + + const queryChunk = '\x1b[?2026h\x1b[6n' + vi.useFakeTimers() + try { + capturedDataCallback.current?.(queryChunk) + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(queryChunk)) + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('restores default hidden rich synchronized output from the headless model', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 668f061ef5a..73baecde7c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -105,6 +105,7 @@ const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000 const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 +const SYNCHRONIZED_OUTPUT_SCAN_TAIL_CHARS = 16 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 @@ -116,6 +117,8 @@ const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150 const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024 const FOREGROUND_BUDGET_WINDOW_MS = 500 const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024 +const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' +const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' // Why: this is only shown if hidden renderer output was skipped and main-owned // terminal state is unavailable, so the user has an explicit loss signal. const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = @@ -150,6 +153,10 @@ type E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: number hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number + hiddenRendererLiveSynchronizedChars: number + hiddenRendererLiveNonSynchronizedChars: number + hiddenRendererStartupWindowChars: number + hiddenRendererSplitBoundaryChars: number } type E2eTerminalPtyOutputDebugApi = { @@ -164,13 +171,21 @@ type E2eTerminalPtyOutputDebugWindow = Window & { const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: 0, hiddenRendererSkippedChars: 0, - hiddenRendererMode2031ReplyCount: 0 + hiddenRendererMode2031ReplyCount: 0, + hiddenRendererLiveSynchronizedChars: 0, + hiddenRendererLiveNonSynchronizedChars: 0, + hiddenRendererStartupWindowChars: 0, + hiddenRendererSplitBoundaryChars: 0 } function resetE2eTerminalPtyOutputDebug(): void { e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0 e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0 e2eTerminalPtyOutputDebugState.hiddenRendererMode2031ReplyCount = 0 + e2eTerminalPtyOutputDebugState.hiddenRendererLiveSynchronizedChars = 0 + e2eTerminalPtyOutputDebugState.hiddenRendererLiveNonSynchronizedChars = 0 + e2eTerminalPtyOutputDebugState.hiddenRendererStartupWindowChars = 0 + e2eTerminalPtyOutputDebugState.hiddenRendererSplitBoundaryChars = 0 } function exposeE2eTerminalPtyOutputDebug(): void { @@ -193,6 +208,25 @@ function recordHiddenRendererSkip(chars: number): void { e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars } +function recordHiddenRendererLiveOutput( + chars: number, + reason: 'synchronized' | 'non-synchronized' | 'startup-window' | 'split-boundary' +): void { + if (!e2eConfig.exposeStore) { + return + } + exposeE2eTerminalPtyOutputDebug() + if (reason === 'synchronized') { + e2eTerminalPtyOutputDebugState.hiddenRendererLiveSynchronizedChars += chars + } else if (reason === 'non-synchronized') { + e2eTerminalPtyOutputDebugState.hiddenRendererLiveNonSynchronizedChars += chars + } else if (reason === 'startup-window') { + e2eTerminalPtyOutputDebugState.hiddenRendererStartupWindowChars += chars + } else { + e2eTerminalPtyOutputDebugState.hiddenRendererSplitBoundaryChars += chars + } +} + function recordHiddenMode2031Reply(): void { if (!e2eConfig.exposeStore) { return @@ -420,16 +454,6 @@ 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 || e2eConfig.exposeStore) && - 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 @@ -568,22 +592,39 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { } function containsSynchronizedOutputStart(data: string): boolean { - return data.includes('\x1b[?2026h') + return data.includes(SYNCHRONIZED_OUTPUT_START_SEQUENCE) } function containsSynchronizedOutputEnd(data: string): boolean { - return data.includes('\x1b[?2026l') + return data.includes(SYNCHRONIZED_OUTPUT_END_SEQUENCE) } function shouldSynchronizedOutputRemainActive(data: string, wasActive: boolean): boolean { - const lastStartIndex = data.lastIndexOf('\x1b[?2026h') - const lastEndIndex = data.lastIndexOf('\x1b[?2026l') + const lastStartIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE) + const lastEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE) if (lastStartIndex === -1 && lastEndIndex === -1) { return wasActive } return lastStartIndex > lastEndIndex } +function updateSynchronizedOutputScanTail(data: string): string { + return data.slice(-SYNCHRONIZED_OUTPUT_SCAN_TAIL_CHARS) +} + +function containsSequenceAcrossBoundary(tail: string, data: string, sequence: string): boolean { + const maxPrefixLength = Math.min(sequence.length - 1, tail.length, data.length) + for (let prefixLength = 1; prefixLength <= maxPrefixLength; prefixLength++) { + if ( + tail.endsWith(sequence.slice(0, prefixLength)) && + data.startsWith(sequence.slice(prefixLength)) + ) { + return true + } + } + return false +} + function containsCursorPositionSequence(data: string): boolean { let offset = data.indexOf('\x1b[') while (offset !== -1) { @@ -634,6 +675,7 @@ export function connectPanePty( let reattachIdleAgentCursorResetTimer: ReturnType | null = null let synchronizedForegroundOutputActive = false let synchronizedHiddenOutputActive = false + let synchronizedHiddenOutputScanTail = '' // 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. @@ -2608,23 +2650,48 @@ export function connectPanePty( const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) const restoreAppliesToCurrentPty = hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId - const synchronizedOutputStarted = containsSynchronizedOutputStart(data) + const hiddenSynchronizedScanData = synchronizedHiddenOutputScanTail + data + const synchronizedOutputStarted = containsSynchronizedOutputStart(hiddenSynchronizedScanData) + const synchronizedOutputEnded = containsSynchronizedOutputEnd(hiddenSynchronizedScanData) + // Why: if a DEC 2026 marker spans chunks, xterm may already hold the + // first bytes of that escape. Complete that boundary live, then skip. + const splitSynchronizedBoundary = + !foreground && + (containsSequenceAcrossBoundary( + synchronizedHiddenOutputScanTail, + data, + SYNCHRONIZED_OUTPUT_START_SEQUENCE + ) || + containsSequenceAcrossBoundary( + synchronizedHiddenOutputScanTail, + data, + SYNCHRONIZED_OUTPUT_END_SEQUENCE + )) const synchronizedHiddenOutput = !foreground && - (synchronizedHiddenOutputActive || - synchronizedOutputStarted || - containsSynchronizedOutputEnd(data)) + (synchronizedHiddenOutputActive || synchronizedOutputStarted || synchronizedOutputEnded) + const hiddenStartupRendererQueryWindowActive = isHiddenStartupRendererQueryWindowActive() const shouldSkipHiddenOutput = shouldSkipHiddenRendererOutput({ foreground, canRestoreHiddenOutput: canUseHiddenOutputSnapshot(transport.getPtyId()), - startupRendererQueryWindowActive: isHiddenStartupRendererQueryWindowActive(), + startupRendererQueryWindowActive: hiddenStartupRendererQueryWindowActive, synchronizedOutputActive: synchronizedHiddenOutput, - allowSynchronizedModelRestore: shouldAllowPrototypeSynchronizedHiddenModelRestore(), + allowSynchronizedModelRestore: true, data }) - if (shouldSkipHiddenOutput) { + if (shouldSkipHiddenOutput && !splitSynchronizedBoundary) { skipHiddenRendererOutput(data) } else if (synchronizedHiddenOutput) { + if (!foreground) { + recordHiddenRendererLiveOutput( + data.length, + splitSynchronizedBoundary + ? 'split-boundary' + : hiddenStartupRendererQueryWindowActive + ? 'startup-window' + : 'synchronized' + ) + } writePtyOutputToXterm(data, foreground) } else if ( (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && @@ -2638,13 +2705,24 @@ export function connectPanePty( hiddenOutputRestoreFreshSnapshotNeeded = true } } else { + if (!foreground) { + recordHiddenRendererLiveOutput( + data.length, + hiddenStartupRendererQueryWindowActive ? 'startup-window' : 'non-synchronized' + ) + } writePtyOutputToXterm(data, foreground) } if (!foreground) { synchronizedHiddenOutputActive = shouldSynchronizedOutputRemainActive( - data, + hiddenSynchronizedScanData, synchronizedHiddenOutputActive ) + synchronizedHiddenOutputScanTail = updateSynchronizedOutputScanTail( + hiddenSynchronizedScanData + ) + } else { + synchronizedHiddenOutputScanTail = '' } schedulePendingStartupCommandDelivery() diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 88b72163eff..b3d92e1fd6c 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -7,7 +7,6 @@ import { type HiddenPressureOutputMode, writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' -import { setPrototypeSynchronizedHiddenModelRestore } from './prototype-synchronized-hidden-model-restore' import { ensureTerminalVisible, getActiveWorktreeId, @@ -95,7 +94,6 @@ export async function runHiddenRealPtyPressureScenario< pressureOutputChars, pressureOutputMode = 'tui', pressureStartDelayMs, - prototypeSynchronizedHiddenModelRestore = false, testInfo, testRepoPath, orcaPage @@ -106,7 +104,6 @@ export async function runHiddenRealPtyPressureScenario< pressureOutputChars: number pressureOutputMode?: HiddenPressureOutputMode pressureStartDelayMs: number - prototypeSynchronizedHiddenModelRestore?: boolean testInfo: TestInfo testRepoPath: string orcaPage: Page @@ -138,10 +135,6 @@ export async function runHiddenRealPtyPressureScenario< writePressureOutputScript(pressureScriptPath, runId, pressureOutputMode) await deps.resetTerminalPtyOutputDebug(orcaPage) - await setPrototypeSynchronizedHiddenModelRestore( - orcaPage, - prototypeSynchronizedHiddenModelRestore - ) await deps.holdTerminalAckGate( orcaPage, hiddenPanes.map((pane) => pane.ptyId) @@ -311,7 +304,6 @@ async function cleanupHiddenPressureScenario< await Promise.all( hiddenPanes.map((pane) => sendToTerminal(orcaPage, pane.ptyId, '\x03').catch(() => undefined)) ) - await setPrototypeSynchronizedHiddenModelRestore(orcaPage, false).catch(() => undefined) rmSync(typingScriptPath, { force: true }) rmSync(pressureScriptPath, { force: true }) } diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 4b1e310b888..b6833bf9e95 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -62,6 +62,10 @@ type TerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: number hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number + hiddenRendererLiveSynchronizedChars: number + hiddenRendererLiveNonSynchronizedChars: number + hiddenRendererStartupWindowChars: number + hiddenRendererSplitBoundaryChars: number } type TerminalOutputSchedulerDebugSnapshot = { @@ -110,6 +114,7 @@ const DEFAULT_PRESSURE_BACKGROUND_PANES = 17 const DEFAULT_PRESSURE_OUTPUT_CHARS = 768 * 1024 const DEFAULT_HIDDEN_PRESSURE_PANES = 17 const HIDDEN_PRESSURE_START_DELAY_MS = 1200 +const RICH_MODEL_HIDDEN_PRESSURE_START_DELAY_MS = 11_000 const DEFAULT_FRAME_COUNT = 180 const DEFAULT_FRAME_INTERVAL_MS = 6 const TIMER_SAMPLE_MS = 16 @@ -417,7 +422,9 @@ function annotateTypingMeasurement( ackGate: TerminalPtyAckGateSnapshot | null = null ): void { const hiddenSkipSummary = debug - ? ` hiddenSkips=${debug.hiddenRendererSkipCount} hiddenSkippedChars=${debug.hiddenRendererSkippedChars} mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` + ? ` hiddenSkips=${debug.hiddenRendererSkipCount} hiddenSkippedChars=${debug.hiddenRendererSkippedChars} mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` + + ` hiddenLiveSyncChars=${debug.hiddenRendererLiveSynchronizedChars} hiddenLiveNonSyncChars=${debug.hiddenRendererLiveNonSynchronizedChars}` + + ` hiddenStartupWindowChars=${debug.hiddenRendererStartupWindowChars} hiddenSplitBoundaryChars=${debug.hiddenRendererSplitBoundaryChars}` : '' const schedulerSummary = scheduler ? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount} rendererQueuedTerminals=${scheduler.queuedTerminalCount} rendererQueuedChars=${scheduler.queuedChars} rendererPeakQueuedTerminals=${scheduler.peakQueuedTerminalCount} rendererPeakQueuedChars=${scheduler.peakQueuedChars} rendererPeakQueuedCharsByTerminal=${scheduler.peakQueuedCharsByTerminal} rendererDroppedBacklogs=${scheduler.droppedBacklogCount}` @@ -752,8 +759,7 @@ test.describe('Artificial OpenCode terminal load', () => { testInfo: TestInfo, hiddenPaneCount: number, annotationSuffix?: string, - pressureOutputMode?: HiddenPressureOutputMode, - prototypeSynchronizedHiddenModelRestore?: boolean + pressureOutputMode?: HiddenPressureOutputMode ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -762,8 +768,12 @@ test.describe('Artificial OpenCode terminal load', () => { hiddenPaneCount, pressureOutputChars: PRESSURE_OUTPUT_CHARS, pressureOutputMode, - pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, - prototypeSynchronizedHiddenModelRestore, + // Why: Codex/OpenCode startup queries intentionally stay live for 10s. + // This benchmark measures steady-state model restore after that guard. + pressureStartDelayMs: + pressureOutputMode === 'rich-model' + ? RICH_MODEL_HIDDEN_PRESSURE_START_DELAY_MS + : HIDDEN_PRESSURE_START_DELAY_MS, testInfo, deps: terminalLoadScenarioDeps }) @@ -772,7 +782,6 @@ test.describe('Artificial OpenCode terminal load', () => { title: string suffix?: string mode?: HiddenPressureOutputMode - prototypeModelRestore?: boolean }[] = [ { title: 'keeps typing responsive while hidden real PTYs are ACK-backpressured' }, { @@ -791,10 +800,9 @@ test.describe('Artificial OpenCode terminal load', () => { mode: 'title' }, { - title: 'prototypes rich hidden model restore under ACK-backpressured PTY output', + title: 'restores rich hidden model output under ACK-backpressured PTY output', suffix: '-rich-model', - mode: 'rich-model', - prototypeModelRestore: true + mode: 'rich-model' } ] for (const hiddenPressureCase of hiddenPressureCases) { @@ -805,8 +813,7 @@ test.describe('Artificial OpenCode terminal load', () => { testInfo, HIDDEN_PRESSURE_PANES, hiddenPressureCase.suffix, - hiddenPressureCase.mode, - hiddenPressureCase.prototypeModelRestore + hiddenPressureCase.mode ) }) } diff --git a/tests/e2e/prototype-synchronized-hidden-model-restore.ts b/tests/e2e/prototype-synchronized-hidden-model-restore.ts deleted file mode 100644 index d4931c92bb7..00000000000 --- a/tests/e2e/prototype-synchronized-hidden-model-restore.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Page } from '@stablyai/playwright-test' - -const PROTOTYPE_FLAG = '__ORCA_TEST_ALLOW_SYNCHRONIZED_HIDDEN_MODEL_RESTORE__' - -export async function setPrototypeSynchronizedHiddenModelRestore( - page: Page, - enabled: boolean -): Promise { - await page.evaluate( - ({ flag, enabled }) => { - ;(window as unknown as Record)[flag] = enabled - }, - { flag: PROTOTYPE_FLAG, enabled } - ) -} diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index ac1d1660071..e8e3ba82a7c 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -17,7 +17,6 @@ import { waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './helpers/terminal' -import { setPrototypeSynchronizedHiddenModelRestore } from './prototype-synchronized-hidden-model-restore' type HiddenTuiWindow = Window & { __terminalPtyDataInjection?: { @@ -249,15 +248,21 @@ test.describe('Hidden terminal TUI visual restore', () => { 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' + message: 'visually rich hidden TUI output should skip renderer writes' }) - .toBeLessThanOrEqual(1) + .toBeGreaterThan(0) 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' + message: 'visually rich hidden TUI output did not skip bulk renderer writes' }) - .toBeLessThan(512) + .toBeGreaterThan(1024) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'visually rich hidden TUI source did not come from headless model' + }) + .toBe('headless') await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -384,7 +389,7 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) - test('prototypes rich synchronized TUI restore from the headless model', async ({ + test('restores rich synchronized TUI output from the headless model', async ({ orcaPage, testRepoPath }, testInfo: TestInfo) => { @@ -404,13 +409,13 @@ test.describe('Hidden terminal TUI visual restore', () => { 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') + throw new Error('hidden rich model 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' + message: 'first worktree did not become active before hidden rich model restore' }) .toBe(firstWorktreeId) @@ -419,7 +424,6 @@ test.describe('Hidden terminal TUI visual restore', () => { 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) @@ -427,13 +431,13 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { timeout: 10_000, - message: 'prototype rich hidden TUI output should skip renderer writes' + message: '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' + message: 'rich hidden TUI source did not come from headless model' }) .toBe('headless') @@ -444,7 +448,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => getTerminalContent(orcaPage, 12_000), { timeout: 10_000, - message: 'prototype rich headless TUI frame did not restore when visible' + message: 'rich headless TUI frame did not restore when visible' }) .toContain(finalMarker) @@ -457,7 +461,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => readTuiCursorState(orcaPage), { timeout: 5_000, - message: 'prototype rich headless TUI cursor stayed hidden after restore' + message: 'rich headless TUI cursor stayed hidden after restore' }) .toMatchObject({ hidden: false, @@ -471,7 +475,6 @@ test.describe('Hidden terminal TUI visual restore', () => { contentType: 'image/png' }) } finally { - await setPrototypeSynchronizedHiddenModelRestore(orcaPage, false) rmSync(scriptPath, { force: true }) } }) diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts index 5b6587b3dec..3249d3f07ab 100644 --- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -620,7 +620,9 @@ test.describe('Terminal long table scroll restore repro', () => { const hiddenDebug = await orcaPage.evaluate(() => (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) + if ((hiddenDebug?.hiddenRendererSkipCount ?? 0) > 0) { + expect(hiddenDebug?.hiddenRendererSkippedChars).toBeGreaterThan(1024) + } const restoredPane = diagnostics.allPaneStates.find((paneState) => paneState.hasMarker) expect(restoredPane).toBeDefined() expect(diagnostics.cursorHidden).toBe(false) @@ -688,7 +690,9 @@ test.describe('Terminal long table scroll restore repro', () => { const hiddenDebug = await orcaPage.evaluate(() => (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) + if ((hiddenDebug?.hiddenRendererSkipCount ?? 0) > 0) { + expect(hiddenDebug?.hiddenRendererSkippedChars).toBeGreaterThan(1024) + } // Why: renderer cell metrics can land one column wider in headless runs; // the content and screenshot assertions below cover the actual regression. expect(diagnostics.cols).toBeLessThanOrEqual(112) @@ -772,7 +776,9 @@ test.describe('Terminal long table scroll restore repro', () => { const hiddenDebug = await orcaPage.evaluate(() => (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) + if ((hiddenDebug?.hiddenRendererSkipCount ?? 0) > 0) { + expect(hiddenDebug?.hiddenRendererSkippedChars).toBeGreaterThan(1024) + } expect(diagnostics.cols).toBeLessThan(100) expect(diagnostics.cursorHidden).toBe(false) testInfo.annotations.push({ From 5b27a23ae8e114da60949d0047ec8c885ee1988d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:30:23 -0700 Subject: [PATCH 18/62] Test remote terminal ACK pressure --- .../remote-runtime-terminal-multiplexer.ts | 110 +++++++++++++- .../runtime/runtime-terminal-stream.test.ts | 134 ++++++++++++++++++ tests/e2e/ssh-docker-relay-perf.spec.ts | 133 +++++++++++++++++ 3 files changed, 372 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 0d70a8906a2..1d78dbee3c5 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -9,6 +9,7 @@ import { encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' +import { e2eConfig } from '@/lib/e2e-config' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' type RuntimeEnvironmentSubscriptionHandle = { @@ -71,6 +72,7 @@ type RemoteRuntimeMultiplexedTerminalState = { terminal: string callbacks: RemoteRuntimeMultiplexedTerminalCallbacks acknowledgeOutput: boolean + heldAckBytes: number snapshotChunks: Uint8Array[] snapshotBytes: number snapshotOverflowed: boolean @@ -110,6 +112,73 @@ const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000 const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' +type E2eRemoteTerminalMultiplexAckGateSnapshot = { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number +} + +type E2eRemoteTerminalMultiplexAckGateApi = { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => E2eRemoteTerminalMultiplexAckGateSnapshot +} + +type E2eRemoteTerminalMultiplexAckGateWindow = Window & { + __remoteTerminalMultiplexAckGate?: E2eRemoteTerminalMultiplexAckGateApi +} + +const e2eHeldRemoteAckTerminals = new Set() +let e2eReleasedRemoteAckChars = 0 + +function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean { + return e2eConfig.exposeStore && e2eHeldRemoteAckTerminals.has(terminal) +} + +function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot { + let heldStreamCount = 0 + let heldAckChars = 0 + for (const multiplexer of multiplexers.values()) { + for (const stream of multiplexer.getStreamsForE2e()) { + if (stream.heldAckBytes > 0) { + heldStreamCount += 1 + heldAckChars += stream.heldAckBytes + } + } + } + return { + heldTerminalCount: e2eHeldRemoteAckTerminals.size, + heldStreamCount, + heldAckChars, + releasedAckChars: e2eReleasedRemoteAckChars + } +} + +function releaseE2eRemoteTerminalAcks(): void { + for (const multiplexer of multiplexers.values()) { + e2eReleasedRemoteAckChars += multiplexer.releaseHeldAcksForE2e() + } + e2eHeldRemoteAckTerminals.clear() +} + +function exposeE2eRemoteTerminalMultiplexAckGate(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eRemoteTerminalMultiplexAckGateWindow + target.__remoteTerminalMultiplexAckGate ??= { + hold: (terminals) => { + releaseE2eRemoteTerminalAcks() + for (const terminal of terminals) { + e2eHeldRemoteAckTerminals.add(terminal) + } + }, + release: releaseE2eRemoteTerminalAcks, + snapshot: getE2eRemoteAckSnapshot + } +} + class RemoteRuntimeTerminalMultiplexer { private readonly streams = new Map() private subscription: RuntimeEnvironmentSubscriptionHandle | null = null @@ -140,6 +209,7 @@ class RemoteRuntimeTerminalMultiplexer { terminal: args.terminal, callbacks: args.callbacks, acknowledgeOutput: args.client.type === 'desktop', + heldAckBytes: 0, snapshotChunks: [], snapshotBytes: 0, snapshotOverflowed: false, @@ -339,11 +409,11 @@ class RemoteRuntimeTerminalMultiplexer { }) } finally { if (stream.acknowledgeOutput) { - this.sendFrame( - stream.streamId, - TerminalStreamOpcode.Ack, - encodeTerminalStreamJson({ bytes: frame.payload.byteLength }) - ) + if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { + stream.heldAckBytes += frame.payload.byteLength + } else { + this.acknowledgeOutput(stream, frame.payload.byteLength) + } } } return @@ -471,6 +541,33 @@ class RemoteRuntimeTerminalMultiplexer { return id } + private acknowledgeOutput(stream: RemoteRuntimeMultiplexedTerminalState, bytes: number): boolean { + return this.sendFrame( + stream.streamId, + TerminalStreamOpcode.Ack, + encodeTerminalStreamJson({ bytes }) + ) + } + + getStreamsForE2e(): Iterable { + return this.streams.values() + } + + releaseHeldAcksForE2e(): number { + let released = 0 + for (const stream of this.streams.values()) { + if (stream.heldAckBytes <= 0) { + continue + } + const bytes = stream.heldAckBytes + stream.heldAckBytes = 0 + if (this.acknowledgeOutput(stream, bytes)) { + released += bytes + } + } + return released + } + private sendFrame( streamId: number, opcode: TerminalStreamOpcode, @@ -553,6 +650,7 @@ function releaseRemoteRuntimeTerminalMultiplexer( export function getRemoteRuntimeTerminalMultiplexer( environmentId: string ): RemoteRuntimeTerminalMultiplexer { + exposeE2eRemoteTerminalMultiplexAckGate() let multiplexer = multiplexers.get(environmentId) if (!multiplexer) { multiplexer = new RemoteRuntimeTerminalMultiplexer( @@ -570,6 +668,8 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() + e2eHeldRemoteAckTerminals.clear() + e2eReleasedRemoteAckChars = 0 } function concatBytes(chunks: Uint8Array[]): Uint8Array { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 820e59ea4a0..1772a6b5cab 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -199,3 +199,137 @@ describe('remote runtime terminal data subscriptions', () => { expect(unsubscribe).toHaveBeenCalledOnce() }) }) + +describe('remote runtime terminal multiplex ACK gate', () => { + const runtimeSubscribe = vi.fn() + const sendBinary = vi.fn() + const unsubscribe = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void + } | null = null + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + callbacks = null + runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => + callbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + ) + return { unsubscribe, sendBinary } + }) + vi.stubGlobal('window', { + api: { + e2e: { + getConfig: () => ({ exposeStore: true }) + }, + runtimeEnvironments: { + subscribe: runtimeSubscribe + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + }) + + it('holds and releases ACKs for selected remote terminal streams only', async () => { + const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-ack-gate') + const heldTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-held', + client: { id: 'desktop-held', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + const liveTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-live', + client: { id: 'desktop-live', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalledTimes(2)) + const heldStreamId = heldTerminal.streamId + const liveStreamId = liveTerminal.streamId + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number + } + } + } + ).__remoteTerminalMultiplexAckGate + expect(gate).toBeDefined() + gate?.hold(['terminal-held']) + sendBinary.mockClear() + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: heldStreamId, + seq: 1, + payload: encodeTerminalStreamText('held-output') + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: liveStreamId, + seq: 2, + payload: encodeTerminalStreamText('live-output') + }) + ) + + const immediateAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(immediateAckFrames).toHaveLength(1) + expect(immediateAckFrames[0]?.streamId).toBe(liveStreamId) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 1, + heldStreamCount: 1, + heldAckChars: 'held-output'.length + }) + + gate?.release() + const allAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + const releasedAck = allAckFrames.find((frame) => frame?.streamId === heldStreamId) + expect(releasedAck && decodeTerminalStreamJson(releasedAck.payload)).toEqual({ + bytes: 'held-output'.length + }) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 0, + heldStreamCount: 0, + heldAckChars: 0, + releasedAckChars: 'held-output'.length + }) + + heldTerminal.close() + liveTerminal.close() + }) +}) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index e3dd37395e8..8a63213b887 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -3,6 +3,8 @@ import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { execInTerminal, + focusLastTerminalPane, + splitActiveTerminalPane, waitForActivePanePtyId, waitForActiveTerminalManager, waitForTerminalOutput @@ -18,6 +20,7 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const KEY_LATENCY_SAMPLES = 'abcdefghij' const MAX_MEDIAN_KEY_LATENCY_MS = 500 const MAX_WORST_KEY_LATENCY_MS = 2_000 +const MIN_HELD_SSH_ACK_CHARS = 256 * 1024 type TypingMeasurement = { latencies: number[] @@ -25,6 +28,20 @@ type TypingMeasurement = { worstLatencyMs: number } +type SshPtyAckGateSnapshot = { + gatedPtyCount: number + heldAckCount: number + heldAckChars: number +} + +type SshPtyAckGateWindow = Window & { + __terminalPtyAckGate?: { + hold: (ptyIds: string[]) => void + release: () => void + snapshot: () => SshPtyAckGateSnapshot + } +} + function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } @@ -50,6 +67,22 @@ function remoteTypingLoadScript(runId: string): string { ].join(';') } +function remoteBackgroundFloodScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + `process.stdout.write('REMOTE_ACK_FLOOD_READY_${runId}\\n')`, + 'let frame = 0', + 'let timer = null', + "const chunk = 'R'.repeat(8192)", + 'function stop() { if (timer) clearInterval(timer); process.exit(0) }', + "function start() { if (timer) return; timer = setInterval(() => { frame += 1; process.stdout.write('REMOTE_ACK_FLOOD_' + frame + '_' + chunk + '\\n') }, 2) }", + "process.stdin.on('data', (chunk) => { if (chunk.includes(String.fromCharCode(3))) stop(); if (chunk.includes('g')) start() })", + "process.on('SIGINT', stop)" + ].join(';') +} + async function connectDockerRemote(page: Page, target: DockerSshRelayTarget): Promise { await page.evaluate( async ({ target, remotePath }) => { @@ -134,6 +167,28 @@ async function measureRemoteTyping( } } +async function holdSshPtyAckGate(page: Page, ptyIds: string[]): Promise { + await page.evaluate((heldPtyIds) => { + const gate = (window as SshPtyAckGateWindow).__terminalPtyAckGate + if (!gate) { + throw new Error('terminal PTY ACK gate is unavailable') + } + gate.hold(heldPtyIds) + }, ptyIds) +} + +async function releaseSshPtyAckGate(page: Page): Promise { + await page.evaluate(() => { + ;(window as SshPtyAckGateWindow).__terminalPtyAckGate?.release() + }) +} + +async function readSshPtyAckGate(page: Page): Promise { + return page.evaluate( + () => (window as SshPtyAckGateWindow).__terminalPtyAckGate?.snapshot() ?? null + ) +} + async function stopRemoteLoad(page: Page, ptyId: string): Promise { await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03'), ptyId) } @@ -177,4 +232,82 @@ test.describe('Docker SSH relay perf', () => { cleanupDockerSshRelayTarget(target) } }) + + test('keeps active remote typing responsive while a background SSH PTY stream is ACK-stalled', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + let backgroundPtyId: string | null = null + let activePtyId: string | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + backgroundPtyId = await waitForActivePanePtyId(orcaPage, 60_000) + + const runId = String(Date.now()) + await execInTerminal( + orcaPage, + backgroundPtyId, + `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) + await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) + await orcaPage.evaluate((ptyId) => window.api.pty.write(ptyId, 'g'), backgroundPtyId) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await focusLastTerminalPane(orcaPage) + activePtyId = await waitForActivePanePtyId(orcaPage, 60_000) + expect(activePtyId).not.toBe(backgroundPtyId) + + const activeRunId = `${runId}_active` + await execInTerminal( + orcaPage, + activePtyId, + `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) + await expect + .poll(async () => (await readSshPtyAckGate(orcaPage))?.heldAckChars ?? 0, { + timeout: 30_000, + message: 'remote background SSH PTY stream did not build held ACK pressure' + }) + .toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + + const measurement = await measureRemoteTyping(orcaPage, activePtyId, activeRunId) + const ackGate = await readSshPtyAckGate(orcaPage) + const summary = `median=${measurement.medianLatencyMs.toFixed( + 1 + )}ms worst=${measurement.worstLatencyMs.toFixed(1)}ms heldAckChars=${ + ackGate?.heldAckChars ?? 0 + } heldPtys=${ackGate?.heldAckCount ?? 0} samples=${measurement.latencies + .map((value) => value.toFixed(1)) + .join(',')}` + console.log(`[docker-ssh-relay-pty-ack-pressure] ${summary}`) + testInfo.annotations.push({ + type: 'docker-ssh-relay-pty-ack-pressure', + description: summary + }) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + + await releaseSshPtyAckGate(orcaPage) + const releasedAckGate = await readSshPtyAckGate(orcaPage) + expect(releasedAckGate?.heldAckChars ?? 0).toBe(0) + } finally { + await releaseSshPtyAckGate(orcaPage).catch(() => undefined) + if (activePtyId) { + await stopRemoteLoad(orcaPage, activePtyId).catch(() => undefined) + } + if (backgroundPtyId) { + await stopRemoteLoad(orcaPage, backgroundPtyId).catch(() => undefined) + } + cleanupDockerSshRelayTarget(target) + } + }) }) From 87b2027a374af0c235a8905efa0bb3cfc3f7b77e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:44:03 -0700 Subject: [PATCH 19/62] Harden slept terminal rich restore coverage --- tests/e2e/terminal-sleep-wake-restore.spec.ts | 131 +++++++++++------- 1 file changed, 82 insertions(+), 49 deletions(-) diff --git a/tests/e2e/terminal-sleep-wake-restore.spec.ts b/tests/e2e/terminal-sleep-wake-restore.spec.ts index 232665e9805..83a924a1099 100644 --- a/tests/e2e/terminal-sleep-wake-restore.spec.ts +++ b/tests/e2e/terminal-sleep-wake-restore.spec.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { @@ -102,25 +104,48 @@ async function mainSnapshotContains(page: Page, ptyId: string, text: string): Pr } function richSleepWakePayload(runId: string): string { + const shortId = runId.slice(0, 8) return [ '\x1b[?2026h', '\x1b[2J\x1b[H', - '╭────────────────────────────╮', - `│ sleep wake restore ${runId.slice(0, 8)} 😀 │`, - '╰────────────────────────────╯', + '╭────────────────────────────────────────────╮', + `│ sleep wake restore ${shortId} 😀 │`, + '├────────────┬───────────────┬───────────────┤', + '│ agent │ status │ output │', + '├────────────┼───────────────┼───────────────┤', + `│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`, + '│ opencode │ streaming │ unicode ✓ │', + '│ shell │ idle │ prompt ready │', + '╰────────────┴───────────────┴───────────────╯', `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, '\x1b[?2026l' ].join('\r\n') } -function nodeWriteCodePointPayloadCommand(payload: string): string { - const codePoints = [...payload].map((char) => char.codePointAt(0) ?? 0) - return `node -e "process.stdout.write(String.fromCodePoint(...${JSON.stringify(codePoints)}))"` +function sleepWakeExpectedMarkers(runId: string): string[] { + return [ + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + 'box/table ok', + 'unicode ✓', + 'prompt ready' + ] +} + +function writeSleepWakePayloadScript(scriptPath: string, payload: string): void { + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + writeFileSync( + scriptPath, + `process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`, + 'utf8' + ) } test.describe('Terminal sleep wake restore', () => { test('restores slept terminal output and accepts fresh input after wake', async ({ - orcaPage + orcaPage, + testRepoPath }) => { await waitForSessionReady(orcaPage) const firstWorktreeId = await waitForActiveWorktree(orcaPage) @@ -139,49 +164,57 @@ test.describe('Terminal sleep wake restore', () => { const runId = randomUUID() const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}` const freshMarker = `SLEEP_WAKE_FRESH_${runId}` - await sendToTerminal( - orcaPage, - ptyId, - `${nodeWriteCodePointPayloadCommand(richSleepWakePayload(runId))}\r` - ) - await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000) - const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) - expect(await mainSnapshotContains(orcaPage, ptyId, restoreMarker)).toBe(true) + const expectedMarkers = sleepWakeExpectedMarkers(runId) + const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`) + writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId)) + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000) + const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + for (const marker of expectedMarkers) { + expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true) + } - await switchToWorktree(orcaPage, firstWorktreeId) - await sleepWorktreeTerminals(orcaPage, secondWorktreeId) - const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) - await expect - .poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), { - timeout: 10_000, - message: 'sleep did not release live PTYs for the background worktree' - }) - .toBe(0) + await switchToWorktree(orcaPage, firstWorktreeId) + await sleepWorktreeTerminals(orcaPage, secondWorktreeId) + const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + await expect + .poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), { + timeout: 10_000, + message: 'sleep did not release live PTYs for the background worktree' + }) + .toBe(0) - await switchToWorktree(orcaPage, secondWorktreeId) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - const awakePtyId = await waitForActivePanePtyId(orcaPage) - const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) - const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000) - expect - .soft(awakeTerminalContent.includes(restoreMarker), { - message: JSON.stringify( - { - ptyId, - awakePtyId, - beforeSleepDebug, - afterSleepDebug, - afterWakeDebug, - terminalTail: awakeTerminalContent.slice(-2000) - }, - null, - 2 - ) - }) - .toBe(true) - await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000) - await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`) - await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000) + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const awakePtyId = await waitForActivePanePtyId(orcaPage) + const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000) + for (const marker of expectedMarkers) { + expect + .soft(awakeTerminalContent.includes(marker), { + message: JSON.stringify( + { + missingMarker: marker, + ptyId, + awakePtyId, + beforeSleepDebug, + afterSleepDebug, + afterWakeDebug, + terminalTail: awakeTerminalContent.slice(-2000) + }, + null, + 2 + ) + }) + .toBe(true) + } + await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000) + await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`) + await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000) + } finally { + rmSync(scriptPath, { force: true }) + } }) }) From 908f167365b63af900b08ebcb359156041a499db Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:55:16 -0700 Subject: [PATCH 20/62] Test runtime multiplex pressure over WebSocket --- src/main/runtime/runtime-rpc.test.ts | 209 +++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index b316ccb3ecb..5a2814cb6e8 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -13,6 +13,15 @@ import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' import { parsePairingCode } from '../../shared/pairing' +import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../shared/terminal-stream-protocol' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { DeviceRegistry } from './device-registry' @@ -2472,6 +2481,206 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const writes: { terminal: string; text: string }[] = [] + const runtime = new OrcaRuntimeService(makeStore() as never) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'multiplex-background-pty' }) + .mockResolvedValueOnce({ id: 'multiplex-active-pty' }) + runtime.setPtyController({ + spawn, + write: (ptyId, data) => { + writes.push({ terminal: ptyId, text: data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + const phoneOffer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'phone', + scope: 'mobile' + }) + expect(phoneOffer.available).toBe(true) + if (!phoneOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + const pairing = parsePairingCode(phoneOffer.pairingUrl) + expect(pairing).toBeTruthy() + if (!pairing) { + throw new Error('Pairing URL did not parse') + } + + const metadata = readRuntimeMetadata(userDataPath) + const laptopEndpoint = metadata!.transports[0]!.endpoint + const laptopAuthToken = metadata!.authToken + const worktree = 'id:repo-1::/tmp/worktree-a' + const backgroundLeafId = '11111111-1111-4111-8111-111111111111' + const activeLeafId = '22222222-2222-4222-8222-222222222222' + const backgroundCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_background', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'background', + tabId: 'multiplex-background-tab', + leafId: backgroundLeafId + } + }) + const activeCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_active', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'active', + tabId: 'multiplex-active-tab', + leafId: activeLeafId, + activate: true + } + }) + const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } }) + .terminal + const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } }) + .terminal + + const responses: Record[] = [] + const binaryFrames: Uint8Array[] = [] + const onError = vi.fn() + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'terminal.multiplex', + {}, + 15_000, + { + onResponse: (response) => responses.push(response as Record), + onBinary: (bytes) => binaryFrames.push(bytes), + onError + } + ) + + try { + await vi.waitFor(() => + expect( + responses.some( + (response) => (response.result as { type?: string } | undefined)?.type === 'ready' + ) + ).toBe(true) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 1, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 21, + terminal: backgroundTerminal.handle, + client: { id: 'desktop-background', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 2, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 22, + terminal: activeTerminal.handle, + client: { id: 'desktop-active', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + await vi.waitFor(() => { + const subscribedStreamIds = responses + .map((response) => response.result as { type?: string; streamId?: number } | undefined) + .filter((result) => result?.type === 'subscribed') + .map((result) => result?.streamId) + expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22])) + }) + binaryFrames.splice(0) + + const backgroundOutput = 'B'.repeat(700 * 1024) + runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1) + await vi.waitFor(() => { + const backgroundFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + const backgroundBytes = backgroundFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(backgroundBytes).toBeGreaterThan(0) + expect(backgroundBytes).toBeLessThan(backgroundOutput.length) + }) + + const frameCountBeforeActive = binaryFrames.length + runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2) + await vi.waitFor(() => { + const activeOutput = binaryFrames + .slice(frameCountBeforeActive) + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY') + }) + + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 3, + opcode: TerminalStreamOpcode.Input, + streamId: 22, + payload: encodeTerminalStreamText('still interactive\r') + }) + ) + await vi.waitFor(() => + expect(writes).toContainEqual({ + terminal: 'multiplex-active-pty', + text: 'still interactive\r' + }) + ) + + const backgroundBytesBeforeAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 4, + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck }) + }) + ) + await vi.waitFor(() => { + const backgroundBytesAfterAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck) + }) + expect(onError).not.toHaveBeenCalled() + } finally { + subscription.close() + await server.stop() + } + }) + it('serves worktree.ps from the runtime summary builder', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never) From 78a72ad2e821b8638dc6b196d4c315692565afe8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:11:10 -0700 Subject: [PATCH 21/62] Generate terminal perf HTML reports --- .../generate-terminal-perf-html-report.mjs | 454 ++++++++++++++++++ ...enerate-terminal-perf-html-report.test.mjs | 142 ++++++ .../run-terminal-scale-perf-report-gate.mjs | 16 +- ...n-terminal-scale-perf-report-gate.test.mjs | 31 +- package.json | 1 + 5 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 config/scripts/generate-terminal-perf-html-report.mjs create mode 100644 config/scripts/generate-terminal-perf-html-report.test.mjs diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs new file mode 100644 index 00000000000..2cea04324fb --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -0,0 +1,454 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html' + +const BUDGETS = { + medianMs: 75, + worstMs: 300, + maxTimerDriftMs: 150, + scrollMs: 150, + restoreMs: 1000, + rendererQueuedChars: 2 * 1024 * 1024, + rendererPeakQueuedChars: 2 * 1024 * 1024, + rendererDroppedBacklogs: 0 +} + +const SCENARIO_LABELS = [ + ['opencode-scale-same-workspace', 'Same workspace panes'], + ['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'], + ['opencode-scale-pressure', 'ACK-backpressured PTYs'], + ['opencode-scale-hidden-pressure', 'Hidden real PTYs'], + ['opencode-cross-workspace-typing', 'Cross-workspace typing'], + ['opencode-main-pressure', 'Main renderer pressure'], + ['opencode-hidden-pressure', 'Hidden pressure'], + ['opencode-revisit-pressure', 'Revisit under pressure'] +] + +export function parseHtmlReportArgs(argv, env = process.env) { + const args = [...argv] + if (args[0] === '--') { + args.shift() + } + + const inputPaths = [] + let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--output' || arg === '-o') { + const next = args[index + 1] + if (!next || next.startsWith('-')) { + throw new Error(`${arg} requires a path`) + } + outputPath = next + index += 1 + continue + } + if (arg.startsWith('--output=')) { + outputPath = arg.slice('--output='.length) + continue + } + inputPaths.push(arg) + } + + if (inputPaths.length === 0) { + throw new Error( + 'Usage: node config/scripts/generate-terminal-perf-html-report.mjs ... --output ' + ) + } + return { inputPaths, outputPath } +} + +function readJsonReport(path) { + const raw = readFileSync(path, 'utf8') + const start = raw.indexOf('{') + const end = raw.lastIndexOf('}') + if (start === -1 || end <= start) { + throw new Error(`${path}: no JSON object found`) + } + return JSON.parse(raw.slice(start, end + 1)) +} + +function parseAnnotationDescription(description) { + const values = {} + for (const part of description.split(/\s+/)) { + const index = part.indexOf('=') + if (index === -1) { + continue + } + values[part.slice(0, index)] = part.slice(index + 1) + } + return values +} + +function collectTerminalPerfRows(report, source) { + const rows = [] + const visitSuite = (suite) => { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const annotation of test.annotations ?? []) { + if (!annotation.type.startsWith('opencode-')) { + continue + } + rows.push( + normalizeRow({ + source, + scenario: annotation.type, + ...parseAnnotationDescription(annotation.description ?? '') + }) + ) + } + } + } + for (const child of suite.suites ?? []) { + visitSuite(child) + } + } + for (const suite of report.suites ?? []) { + visitSuite(suite) + } + return rows +} + +function parseMs(value) { + const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/) + return match ? Number(match[1]) : null +} + +function parseCount(value) { + if (value == null || value === '') { + return null + } + const count = Number(value) + return Number.isFinite(count) ? count : null +} + +function normalizeRow(row) { + const panes = parseCount(row.panes) + const frames = parseCount(row.frames) + const medianMs = parseMs(row.median) + const worstMs = parseMs(row.worst) + const maxTimerDriftMs = parseMs(row.maxTimerDrift) + const scrollMs = parseMs(row.scroll) + const restoreMs = parseMs(row.restore) + const rendererQueuedChars = parseCount(row.rendererQueuedChars) + const rendererPeakQueuedChars = parseCount(row.rendererPeakQueuedChars) + const rendererDroppedBacklogs = parseCount(row.rendererDroppedBacklogs) + return { + ...row, + group: scenarioGroup(row.scenario), + panes, + frames, + medianMs, + worstMs, + maxTimerDriftMs, + scrollMs, + restoreMs, + rendererQueuedChars, + rendererPeakQueuedChars, + rendererDroppedBacklogs, + mainPeakPendingChars: parseCount(row.mainPeakPendingChars), + mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), + heldAckChars: parseCount(row.heldAckChars), + hiddenSkippedChars: parseCount(row.hiddenSkippedChars) + } +} + +function scenarioGroup(scenario) { + for (const [prefix, label] of SCENARIO_LABELS) { + if (scenario.startsWith(prefix)) { + return label + } + } + return 'Other terminal scenarios' +} + +function budgetFailures(row) { + const failures = [] + for (const [key, budget] of Object.entries(BUDGETS)) { + const value = row[key] + if (value == null) { + continue + } + if (value > budget) { + failures.push( + `${labelForMetric(key)} ${formatMetricValue(key, value)} > ${formatMetricValue(key, budget)}` + ) + } + } + return failures +} + +function labelForMetric(key) { + return ( + { + medianMs: 'Median typing', + worstMs: 'Worst typing', + maxTimerDriftMs: 'Timer drift', + scrollMs: 'Scroll', + restoreMs: 'Restore', + rendererQueuedChars: 'Renderer queued', + rendererPeakQueuedChars: 'Renderer peak queued', + rendererDroppedBacklogs: 'Renderer dropped backlogs' + }[key] ?? key + ) +} + +function formatMetricValue(key, value) { + if (value == null) { + return '' + } + if (key.endsWith('Ms')) { + return `${value.toFixed(1)}ms` + } + return Number.isInteger(value) ? String(value) : value.toFixed(1) +} + +function formatCell(value, suffix = '') { + if (value == null || value === '') { + return '' + } + if (typeof value === 'number') { + return Number.isInteger(value) ? `${value}${suffix}` : `${value.toFixed(1)}${suffix}` + } + return String(value) +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function groupRows(rows) { + const groups = new Map() + for (const row of rows) { + const existing = groups.get(row.group) ?? [] + existing.push(row) + groups.set(row.group, existing) + } + return [...groups.entries()].map(([label, group]) => [ + label, + group.sort((a, b) => (a.panes ?? 0) - (b.panes ?? 0) || a.scenario.localeCompare(b.scenario)) + ]) +} + +function chartSvg(title, rows, metrics) { + const plotRows = rows.filter( + (row) => row.panes != null && metrics.some((metric) => row[metric.key] != null) + ) + if (plotRows.length === 0) { + return '' + } + const width = 720 + const height = 260 + const pad = { bottom: 42, left: 54, right: 20, top: 28 } + const minPane = Math.min(...plotRows.map((row) => row.panes)) + const maxPane = Math.max(...plotRows.map((row) => row.panes)) + const maxValue = Math.max( + 1, + ...plotRows.flatMap((row) => metrics.map((metric) => row[metric.key] ?? 0)) + ) + const x = (pane) => { + if (minPane === maxPane) { + return pad.left + (width - pad.left - pad.right) / 2 + } + return pad.left + ((pane - minPane) / (maxPane - minPane)) * (width - pad.left - pad.right) + } + const y = (value) => height - pad.bottom - (value / maxValue) * (height - pad.top - pad.bottom) + const axis = [ + ``, + `` + ].join('') + const series = metrics + .map((metric) => { + const points = plotRows + .filter((row) => row[metric.key] != null) + .map((row) => `${x(row.panes).toFixed(1)},${y(row[metric.key]).toFixed(1)}`) + .join(' ') + if (!points) { + return '' + } + return `${plotRows + .filter((row) => row[metric.key] != null) + .map( + (row) => + `${escapeHtml(row.scenario)} ${metric.label}: ${escapeHtml(formatCell(row[metric.key], metric.suffix ?? ''))}` + ) + .join('')}` + }) + .join('') + const xLabels = [...new Set(plotRows.map((row) => row.panes))] + .sort((a, b) => a - b) + .map( + (pane) => + `${pane}` + ) + .join('') + const yLabels = [0, maxValue / 2, maxValue] + .map( + (value) => + `${formatLargeValue(value)}` + ) + .join('') + const legend = metrics + .map((metric) => `${escapeHtml(metric.label)}`) + .join('') + return `
${escapeHtml(title)}
${axis}${series}${xLabels}${yLabels}Pane count
${legend}
` +} + +function formatLargeValue(value) { + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)}M` + } + if (value >= 1000) { + return `${(value / 1000).toFixed(0)}k` + } + return value.toFixed(value % 1 === 0 ? 0 : 1) +} + +function renderTable(rows) { + const columns = [ + ['Scenario', (row) => row.scenario], + ['Source', (row) => row.source], + ['Panes', (row) => row.panes], + ['Frames', (row) => row.frames], + ['Median', (row) => formatCell(row.medianMs, 'ms')], + ['Worst', (row) => formatCell(row.worstMs, 'ms')], + ['Scroll', (row) => formatCell(row.scrollMs, 'ms')], + ['Restore', (row) => formatCell(row.restoreMs, 'ms')], + ['Drift', (row) => formatCell(row.maxTimerDriftMs, 'ms')], + ['Renderer Peak', (row) => row.rendererPeakQueuedChars], + ['Main In-Flight', (row) => row.mainPeakInFlightChars], + ['Held ACK', (row) => row.heldAckChars], + ['Hidden Chars', (row) => row.hiddenSkippedChars], + ['Drops', (row) => row.rendererDroppedBacklogs], + [ + 'Budget', + (row) => { + const failures = budgetFailures(row) + return failures.length === 0 ? 'pass' : `fail: ${failures.join('; ')}` + } + ] + ] + return `${columns.map(([label]) => ``).join('')}${rows + .map((row) => { + const failed = budgetFailures(row).length > 0 + return `${columns + .map(([, getter]) => ``) + .join('')}` + }) + .join('')}
${escapeHtml(label)}
${escapeHtml(getter(row))}
` +} + +function renderHtml({ generatedAt, inputPaths, rows }) { + const failures = rows.flatMap((row) => budgetFailures(row).map((failure) => ({ failure, row }))) + const grouped = groupRows(rows) + const chartSections = grouped + .map(([label, group]) => + [ + chartSvg(`${label}: typing latency`, group, [ + { className: 'metric-a', key: 'medianMs', label: 'Median', suffix: 'ms' }, + { className: 'metric-b', key: 'worstMs', label: 'Worst', suffix: 'ms' } + ]), + chartSvg(`${label}: renderer/main pressure`, group, [ + { className: 'metric-c', key: 'rendererPeakQueuedChars', label: 'Renderer peak chars' }, + { className: 'metric-d', key: 'mainPeakInFlightChars', label: 'Main in-flight chars' }, + { className: 'metric-e', key: 'mainPeakPendingChars', label: 'Main pending chars' } + ]), + chartSvg(`${label}: restore and scroll`, group, [ + { className: 'metric-f', key: 'restoreMs', label: 'Restore', suffix: 'ms' }, + { className: 'metric-g', key: 'scrollMs', label: 'Scroll', suffix: 'ms' } + ]) + ].join('') + ) + .join('') + return ` + + + + + Terminal Performance Impact Report + + + +
+

Terminal Performance Impact Report

+

Generated ${escapeHtml(generatedAt)} from ${inputPaths.length} Playwright JSON report${inputPaths.length === 1 ? '' : 's'}.

+

${inputPaths.map((path) => escapeHtml(path)).join('
')}

+
+
Scenario rows${rows.length}
+
Budget status${failures.length === 0 ? 'Pass' : `${failures.length} failure${failures.length === 1 ? '' : 's'}`}
+
Max panes${Math.max(...rows.map((row) => row.panes ?? 0))}
+
Max renderer peak chars${formatLargeValue(Math.max(...rows.map((row) => row.rendererPeakQueuedChars ?? 0)))}
+
+

Impact Charts

+ ${chartSections || '

No chartable pane-count rows were found.

'} +

Scenario Metrics

+ ${renderTable(rows)} +

Correctness Gates To Pair With This Report

+

Pair this performance report with hidden TUI visual restore, terminal rendering golden, long-table restore, sleep/wake restore, SSH/remote ACK pressure, and WebSocket multiplex pressure evidence before declaring the terminal performance goal complete.

+
+ + +` +} + +export function generateTerminalPerfHtmlReport({ inputPaths, outputPath, now = new Date() }) { + const rows = inputPaths.flatMap((path) => + collectTerminalPerfRows(readJsonReport(path), basename(path)) + ) + if (rows.length === 0) { + throw new Error('No OpenCode terminal perf annotations found.') + } + mkdirSync(dirname(outputPath), { recursive: true }) + const html = renderHtml({ generatedAt: now.toISOString(), inputPaths, rows }) + writeFileSync(outputPath, html) + return { outputPath, rowCount: rows.length, failureCount: rows.flatMap(budgetFailures).length } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const result = generateTerminalPerfHtmlReport(parseHtmlReportArgs(process.argv.slice(2))) + console.log( + `Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} row${result.rowCount === 1 ? '' : 's'}, ${result.failureCount} budget failure${result.failureCount === 1 ? '' : 's'}).` + ) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs new file mode 100644 index 00000000000..14aeb26633d --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -0,0 +1,142 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + generateTerminalPerfHtmlReport, + parseHtmlReportArgs +} from './generate-terminal-perf-html-report.mjs' + +const tempDirs = [] + +function makeTempDir() { + const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-')) + tempDirs.push(dir) + return dir +} + +function writeReport(annotationDescription, annotationType = 'opencode-scale-same-workspace-25') { + const dir = makeTempDir() + const reportPath = join(dir, 'report.json') + writeFileSync( + reportPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: annotationType, + description: annotationDescription + }, + { + type: 'browser-unrelated', + description: 'median=999.0ms' + } + ] + } + ] + } + ] + } + ] + }) + ) + return reportPath +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('generate-terminal-perf-html-report', () => { + it('parses input paths and output flags', () => { + expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({ + inputPaths: ['a.json', 'b.json'], + outputPath: 'out.html' + }) + expect( + parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' }) + ).toEqual({ + inputPaths: ['a.json'], + outputPath: 'env.html' + }) + expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path') + expect(() => parseHtmlReportArgs([])).toThrow('Usage:') + }) + + it('writes an HTML report with charts, table rows, and escaped input', () => { + const reportPath = writeReport( + [ + 'panes=25', + 'frames=60', + 'median=12.4ms', + 'worst=44.8ms', + 'scroll=61.0ms', + 'restore=320.0ms', + 'maxTimerDrift=8.0ms', + 'rendererPeakQueuedChars=2048', + 'mainPeakInFlightChars=4096', + 'heldAckChars=1024', + 'hiddenSkippedChars=512', + 'rendererDroppedBacklogs=0' + ].join(' ') + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath, + now: new Date('2026-06-09T10:00:00.000Z') + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result).toEqual({ failureCount: 0, outputPath, rowCount: 1 }) + expect(html).toContain('') + expect(html).toContain('Terminal Performance Impact Report') + expect(html).toContain('2026-06-09T10:00:00.000Z') + expect(html).toContain('Same workspace panes: typing latency') + expect(html).toContain('opencode-scale-same-workspace-25') + expect(html).toContain('') + expect(html).toContain('Pass') + expect(html).not.toContain('browser-unrelated') + }) + + it('marks over-budget rows as failures', () => { + const reportPath = writeReport( + [ + 'panes=100', + 'median=80.0ms', + 'worst=301.0ms', + 'rendererPeakQueuedChars=2097153', + 'rendererDroppedBacklogs=1' + ].join(' '), + 'opencode-scale-cross-workspace-100' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.failureCount).toBe(4) + expect(html).toContain('4 failures') + expect(html).toContain('fail: Median typing 80.0ms > 75.0ms') + expect(html).toContain('Cross-workspace hidden panes') + }) + + it('fails when reports contain no terminal perf annotations', () => { + const reportPath = writeReport('median=12.0ms', 'browser-unrelated') + + expect(() => + generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath: join(makeTempDir(), 'report.html') + }) + ).toThrow('No OpenCode terminal perf annotations found.') + }) +}) diff --git a/config/scripts/run-terminal-scale-perf-report-gate.mjs b/config/scripts/run-terminal-scale-perf-report-gate.mjs index 801e810dbe3..a791e6b70e9 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.mjs @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json' +const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html' export function parseReportGateArgs(argv, env = process.env) { const forwardedArgs = [...argv] @@ -115,7 +116,20 @@ export function runTerminalScalePerfReportGate({ spawnSyncImpl, env ) - return exitCode(budgetResult) + const budgetExitCode = exitCode(budgetResult) + if (budgetExitCode !== 0) { + return budgetExitCode + } + + const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH + const htmlResult = runNodeScript( + 'config/scripts/generate-terminal-perf-html-report.mjs', + [reportPath, '--output', htmlReportPath], + 'inherit', + spawnSyncImpl, + env + ) + return exitCode(htmlResult) } if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs index 929e2d5b9ba..e4d7be8861e 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs @@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls.map((call) => call.args[0])).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', 'config/scripts/summarize-terminal-perf-report.mjs', - 'config/scripts/check-terminal-perf-report-budgets.mjs' + 'config/scripts/check-terminal-perf-report-budgets.mjs', + 'config/scripts/generate-terminal-perf-html-report.mjs' ]) expect(calls[0].args).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', @@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => { 'config/scripts/check-terminal-perf-report-budgets.mjs', reportPath ]) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'test-results/terminal-perf-impact-report.html' + ]) }) it('uses the report path from env when no flag is provided', () => { @@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath]) }) + it('uses the HTML report path from env when provided', () => { + const reportPath = tempReportPath() + const { calls, spawnSyncImpl } = makeSpawnSync() + + const status = runTerminalScalePerfReportGate({ + env: { + ...process.env, + ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html', + ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath + }, + spawnSyncImpl + }) + + expect(status).toBe(0) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'tmp/terminal-report.html' + ]) + }) + it('preserves the report when Playwright clears the target report directory', () => { const reportPath = tempReportPath() const { spawnSyncImpl } = makeSpawnSync({ diff --git a/package.json b/package.json index cf31766093a..40dc11aefb8 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs", "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", + "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts" From 803d719d6dfa1ae86abe36aab8a85b050ce8acb6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:20:53 -0700 Subject: [PATCH 22/62] Track terminal revisit latency in perf reports --- .../scripts/check-terminal-perf-report-budgets.mjs | 7 +++++++ .../check-terminal-perf-report-budgets.test.mjs | 14 ++++++++++++++ .../scripts/generate-terminal-perf-html-report.mjs | 8 +++++++- .../generate-terminal-perf-html-report.test.mjs | 7 +++++-- config/scripts/summarize-terminal-perf-report.mjs | 1 + 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index 6e579e02da1..cb00ce09256 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -18,6 +18,7 @@ if (reportPaths.length === 0) { const BUDGETS = { maxMedianKeyLatencyMs: 75, maxWorstKeyLatencyMs: 300, + maxRevisitLatencyMs: 300, maxTimerDriftMs: 150, maxScrollLatencyMs: 150, maxRestoreLatencyMs: 1000, @@ -129,6 +130,12 @@ function validateRow(row) { BUDGETS.maxWorstKeyLatencyMs, 'ms' ) + addBudgetCheck( + 'revisit latency', + parseMs(row.revisit, 'revisit', row, failures), + BUDGETS.maxRevisitLatencyMs, + 'ms' + ) addBudgetCheck( 'timer drift', parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures), diff --git a/config/scripts/check-terminal-perf-report-budgets.test.mjs b/config/scripts/check-terminal-perf-report-budgets.test.mjs index d1fd378b498..1a8232a9d26 100644 --- a/config/scripts/check-terminal-perf-report-budgets.test.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.test.mjs @@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=180', 'median=2.9ms', 'worst=5.9ms', + 'revisit=42.0ms', 'maxTimerDrift=12.1ms', 'scroll=149.9ms', 'restore=642.0ms', @@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=60', 'median=76.0ms', 'worst=301.0ms', + 'revisit=301.0ms', 'maxTimerDrift=151.0ms', 'scroll=151.0ms', 'restore=1001.0ms', @@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.status).toBe(1) expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms') expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms') + expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms') expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms') expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms') expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms') @@ -116,6 +119,17 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.stderr).toContain('no recognized budget metrics found') }) + it('accepts revisit-only marker rows as budgeted perf evidence', () => { + const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184') + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + it('fails OpenCode annotation rows that contain no budget metrics', () => { const reportPath = writeReport('panes=1 frames=60') diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs index 2cea04324fb..855fa9330e4 100644 --- a/config/scripts/generate-terminal-perf-html-report.mjs +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -7,6 +7,7 @@ const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html' const BUDGETS = { medianMs: 75, worstMs: 300, + revisitMs: 300, maxTimerDriftMs: 150, scrollMs: 150, restoreMs: 1000, @@ -129,6 +130,7 @@ function normalizeRow(row) { const frames = parseCount(row.frames) const medianMs = parseMs(row.median) const worstMs = parseMs(row.worst) + const revisitMs = parseMs(row.revisit) const maxTimerDriftMs = parseMs(row.maxTimerDrift) const scrollMs = parseMs(row.scroll) const restoreMs = parseMs(row.restore) @@ -142,6 +144,7 @@ function normalizeRow(row) { frames, medianMs, worstMs, + revisitMs, maxTimerDriftMs, scrollMs, restoreMs, @@ -185,6 +188,7 @@ function labelForMetric(key) { { medianMs: 'Median typing', worstMs: 'Worst typing', + revisitMs: 'Revisit', maxTimerDriftMs: 'Timer drift', scrollMs: 'Scroll', restoreMs: 'Restore', @@ -319,6 +323,7 @@ function renderTable(rows) { ['Frames', (row) => row.frames], ['Median', (row) => formatCell(row.medianMs, 'ms')], ['Worst', (row) => formatCell(row.worstMs, 'ms')], + ['Revisit', (row) => formatCell(row.revisitMs, 'ms')], ['Scroll', (row) => formatCell(row.scrollMs, 'ms')], ['Restore', (row) => formatCell(row.restoreMs, 'ms')], ['Drift', (row) => formatCell(row.maxTimerDriftMs, 'ms')], @@ -362,7 +367,8 @@ function renderHtml({ generatedAt, inputPaths, rows }) { ]), chartSvg(`${label}: restore and scroll`, group, [ { className: 'metric-f', key: 'restoreMs', label: 'Restore', suffix: 'ms' }, - { className: 'metric-g', key: 'scrollMs', label: 'Scroll', suffix: 'ms' } + { className: 'metric-g', key: 'scrollMs', label: 'Scroll', suffix: 'ms' }, + { className: 'metric-b', key: 'revisitMs', label: 'Revisit', suffix: 'ms' } ]) ].join('') ) diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs index 14aeb26633d..5c3602c9d00 100644 --- a/config/scripts/generate-terminal-perf-html-report.test.mjs +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -77,6 +77,7 @@ describe('generate-terminal-perf-html-report', () => { 'frames=60', 'median=12.4ms', 'worst=44.8ms', + 'revisit=28.6ms', 'scroll=61.0ms', 'restore=320.0ms', 'maxTimerDrift=8.0ms', @@ -102,6 +103,7 @@ describe('generate-terminal-perf-html-report', () => { expect(html).toContain('2026-06-09T10:00:00.000Z') expect(html).toContain('Same workspace panes: typing latency') expect(html).toContain('opencode-scale-same-workspace-25') + expect(html).toContain('28.6ms') expect(html).toContain('
') expect(html).toContain('Pass') expect(html).not.toContain('browser-unrelated') @@ -113,6 +115,7 @@ describe('generate-terminal-perf-html-report', () => { 'panes=100', 'median=80.0ms', 'worst=301.0ms', + 'revisit=301.0ms', 'rendererPeakQueuedChars=2097153', 'rendererDroppedBacklogs=1' ].join(' '), @@ -123,8 +126,8 @@ describe('generate-terminal-perf-html-report', () => { const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) const html = readFileSync(outputPath, 'utf8') - expect(result.failureCount).toBe(4) - expect(html).toContain('4 failures') + expect(result.failureCount).toBe(5) + expect(html).toContain('5 failures') expect(html).toContain('fail: Median typing 80.0ms > 75.0ms') expect(html).toContain('Cross-workspace hidden panes') }) diff --git a/config/scripts/summarize-terminal-perf-report.mjs b/config/scripts/summarize-terminal-perf-report.mjs index 04a80d81dc4..3303f97ef87 100644 --- a/config/scripts/summarize-terminal-perf-report.mjs +++ b/config/scripts/summarize-terminal-perf-report.mjs @@ -74,6 +74,7 @@ function printMarkdownTable(rows) { ['Frames', 'frames'], ['Median', 'median'], ['Worst', 'worst'], + ['Revisit', 'revisit'], ['Scroll', 'scroll'], ['Restore', 'restore'], ['Max Drift', 'maxTimerDrift'], From 32d3c916497a615226849be9992b6f2259033876 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:39:06 -0700 Subject: [PATCH 23/62] Stabilize raw emoji terminal golden width --- ...nal-raw-emoji-table-scroll-restore.spec.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index 365d81d5eec..6c541101cc2 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -54,6 +54,7 @@ const EMOJI_TABLE_FIXTURE = readFileSync( path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'), 'utf8' ) +const RAW_EMOJI_TABLE_MIN_COLS = 140 function rawEmojiFixtureBoxTableScript(table: string, runId: string): string { const marker = `RAW_EMOJI_FIXTURE_TABLE_RESTORE_${runId}` @@ -147,7 +148,7 @@ function rawEmojiFixtureCompletionMarker(runId: string): string { } async function setWideRenderedTableViewport(page: Page): Promise { - await page.setViewportSize({ width: 1480, height: 820 }) + await page.setViewportSize({ width: 1760, height: 820 }) await page.waitForTimeout(250) await page.evaluate(() => { const store = window.__store @@ -158,6 +159,21 @@ async function setWideRenderedTableViewport(page: Page): Promise { await page.waitForTimeout(250) } +async function waitForRawEmojiTableColumns(page: Page): Promise { + await expect + .poll( + () => + page.evaluate( + () => (window as RawTableDebugWindow).getActiveTestPane?.().terminal.cols ?? 0 + ), + { + message: 'raw emoji table golden needs a wide terminal viewport', + timeout: 10_000 + } + ) + .toBeGreaterThanOrEqual(RAW_EMOJI_TABLE_MIN_COLS) +} + async function readTerminalBoxTableWrapDiagnostics(page: Page): Promise<{ cols: number rows: number @@ -454,6 +470,7 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { await setWideRenderedTableViewport(orcaPage) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForRawEmojiTableColumns(orcaPage) const ptyId = await waitForActivePanePtyId(orcaPage) const runId = randomUUID() const scriptPath = path.join(testRepoPath, `.orca-raw-emoji-fixture-table-${runId}.mjs`) From 197594423b09c1970e53294f7cd23fdb957ebab3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:50:18 -0700 Subject: [PATCH 24/62] Use native Windows width for terminal golden --- .../terminal-raw-emoji-table-scroll-restore.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index 6c541101cc2..1676ed12a57 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -54,7 +54,7 @@ const EMOJI_TABLE_FIXTURE = readFileSync( path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'), 'utf8' ) -const RAW_EMOJI_TABLE_MIN_COLS = 140 +const RAW_EMOJI_TABLE_COLS = 137 function rawEmojiFixtureBoxTableScript(table: string, runId: string): string { const marker = `RAW_EMOJI_FIXTURE_TABLE_RESTORE_${runId}` @@ -148,7 +148,10 @@ function rawEmojiFixtureCompletionMarker(runId: string): string { } async function setWideRenderedTableViewport(page: Page): Promise { - await page.setViewportSize({ width: 1760, height: 820 }) + const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) + // Why: macOS hosted runners need extra room for font/column variance, while + // Windows Electron golden rendering is stable at the native-sized viewport. + await page.setViewportSize({ width: isWindows ? 1480 : 1760, height: 820 }) await page.waitForTimeout(250) await page.evaluate(() => { const store = window.__store @@ -160,6 +163,8 @@ async function setWideRenderedTableViewport(page: Page): Promise { } async function waitForRawEmojiTableColumns(page: Page): Promise { + const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) + const minCols = isWindows ? RAW_EMOJI_TABLE_COLS : RAW_EMOJI_TABLE_COLS + 3 await expect .poll( () => @@ -171,7 +176,7 @@ async function waitForRawEmojiTableColumns(page: Page): Promise { timeout: 10_000 } ) - .toBeGreaterThanOrEqual(RAW_EMOJI_TABLE_MIN_COLS) + .toBeGreaterThanOrEqual(minCols) } async function readTerminalBoxTableWrapDiagnostics(page: Page): Promise<{ From dba74ae1ab6e4766185d9189597b6533ba7c1db8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:06:17 -0700 Subject: [PATCH 25/62] Document terminal model view contract --- .../reference/terminal-model-view-contract.md | 154 ++++++++++++++++++ docs/terminal-main-owned-state.md | 4 + 2 files changed, 158 insertions(+) create mode 100644 docs/reference/terminal-model-view-contract.md diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md new file mode 100644 index 00000000000..07ab4a9a179 --- /dev/null +++ b/docs/reference/terminal-model-view-contract.md @@ -0,0 +1,154 @@ +# Terminal Model/View Contract + +## Goal + +Terminal output should have one authoritative model path and many disposable +views. A renderer xterm is the fast interactive view, but it must not be the +only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists. + +This contract defines the boundary future terminal performance work should move +toward without changing the query-response behavior that real shells and TUIs +depend on. + +## Terms + +- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY, + or remote runtime PTY. +- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this + is mostly the headless emulator plus retained read transcript state. +- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop + subscriber, or CLI read page consuming model state and live output. +- **Snapshot:** A bounded model serialization that can restore a view without + replaying an unbounded byte log. +- **Transcript:** The retained output contract for `orca terminal read`; it is + line/cursor oriented and distinct from a screen snapshot. + +## Non-Negotiable Invariants + +1. PTY reads do not stop to protect renderer performance. Backpressure may bound + delivery to views, but terminal state, notifications, titles, and agent + status keep advancing from the PTY stream. +2. Active visible terminal input/output stays on the lowest-latency path. Bulk + hidden or background output must not delay keystroke-sized foreground redraws. +3. Hidden views do not own unbounded output memory. When a hidden renderer view + cannot keep up, it becomes stale and restores from the model later. +4. Returning to a hidden or slept terminal must show model-correct output. A + stale or replaced view may be cleared and replayed from a snapshot, but it + must not show a warning fallback when model recovery is available. +5. Snapshots and live bytes have ordering metadata. A view restore must not + duplicate bytes already included in the snapshot or drop bytes that arrived + after it. +6. Terminal query authority stays with the visible renderer when needed. The + headless model tracks state but must not answer DA, DSR, OSC 11, or other + shell/TUI queries that would inject replies into the PTY. +7. The transcript contract stays separate from screen restore. `orca terminal + read` must preserve bounded previews, cursor pagination, partial-line rules, + truncation flags, and total counts even if view snapshots change shape. +8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy + the same model/view contract or explicitly report that model recovery is + unavailable. + +## Current Owners + +| Responsibility | Current owner | +| --- | --- | +| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` | +| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` | +| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` | +| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` | +| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` | +| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` | + +## Snapshot Contract + +A model snapshot must include: + +- terminal dimensions used to produce the snapshot; +- enough ANSI state to rehydrate xterm before snapshot content; +- bounded screen and scrollback content; +- title and cwd metadata when known; +- source metadata that distinguishes headless/model snapshots from renderer + fallback snapshots; +- monotonic ordering metadata for live-output reconciliation when available. + +A snapshot must not: + +- include unbounded transcript history; +- answer terminal queries while replaying into the model; +- overwrite newer live view output with older model output; +- hide that recovery was unavailable for a PTY surface. + +## View Contract + +A renderer or remote view may: + +- write active visible output immediately; +- budget visible inactive output; +- skip hidden renderer writes when the model can recover the state; +- request fresh snapshots for restore, mobile subscription, or explicit remote + snapshot recovery. + +A view must: + +- keep live-output buffers bounded while a snapshot is in flight; +- apply generation or sequence checks before replaying a snapshot; +- refresh/repaint after replay when xterm/WebGL needs an explicit paint; +- keep side effects such as title, bell, cwd, and agent status flowing from the + PTY/model path even while renderer writes are skipped. + +## Transcript Contract + +The retained read transcript is not a screen dump. It must preserve: + +- uncursored bounded latest preview behavior; +- cursor reads over completed retained lines; +- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`; +- partial-line duplication rules; +- `truncated`, `limited`, and total count metadata; +- bounded memory for long partial lines and large output bursts. + +Snapshot optimizations must be tested against this transcript contract instead +of assuming xterm scrollback serialization can replace it. + +## Required Contract Tests + +Before moving more runtime behavior behind the model/view boundary, add or +extend tests that prove: + +- headless snapshots rehydrate rich alternate-screen TUI state; +- headless tracking does not answer DA, DSR, OSC 11, or theme-sensitive queries; +- hidden renderer overflow restores from model state without duplicate live + output; +- sleep/wake and worktree revisit restore from model-correct state; +- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs; +- remote runtime multiplex output remains ACK bounded and can request recovery + snapshots; +- mobile subscribers receive bounded snapshots without unbounded pending live + output; +- retained terminal reads remain pageable and bounded after large output. + +Current coverage is spread across: + +- `src/main/daemon/headless-emulator.test.ts` +- `src/main/daemon/session.test.ts` +- `src/main/runtime/mobile-subscribe-integration.test.ts` +- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` +- `src/main/runtime/rpc/terminal-multiplex.test.ts` +- `src/main/runtime/orca-runtime.test.ts` +- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` +- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` +- `tests/e2e/terminal-sleep-wake-restore.spec.ts` +- `tests/e2e/terminal-output-scheduler.spec.ts` +- `tests/e2e/artificial-opencode-terminal-load.spec.ts` + +## Migration Shape + +1. Keep the current green ACK/backpressure and hidden-restore stack intact. +2. Add contract tests for one PTY surface at a time: local, SSH, remote runtime, + mobile, then CLI reads. +3. Move renderer-only restore authority behind model snapshots only where the + contract is already executable. +4. Remove renderer fallback paths only after the equivalent model path has + platform and TUI golden coverage. +5. Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a + local repaint quirk. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md index a9c3165e7cf..48b1dce27c5 100644 --- a/docs/terminal-main-owned-state.md +++ b/docs/terminal-main-owned-state.md @@ -1,5 +1,9 @@ # Terminal Main-Owned State +This document covers the hidden-output recovery slice. The broader terminal +model/view boundary is defined in +[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md). + ## Problem Hidden and background terminal panes cannot rely on renderer memory as the only From 53c99c03146215c3ef6ebea49a27d6a0b575bd65 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:11:07 -0700 Subject: [PATCH 26/62] Test daemon terminal query authority --- src/main/daemon/session.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index f1215d09bcd..bb99d1ffaba 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -167,9 +167,14 @@ describe('Session', () => { // it with default-xterm values (no theme, stale cursor). The renderer is // the authoritative responder; a daemon-side reply to any query is a bug. it.each([ + ['OSC 10 foreground-color', '\x1b]10;?\x07'], ['OSC 11 background-color', '\x1b]11;?\x07'], + ['OSC 12 cursor-color', '\x1b]12;?\x1b\\'], ['DA1 device-attributes', '\x1b[c'], - ['DSR cursor-position', '\x1b[6n'] + ['DA2 secondary device-attributes', '\x1b[>c'], + ['DSR terminal status', '\x1b[5n'], + ['DSR cursor-position', '\x1b[6n'], + ['DECRPM bracketed-paste mode', '\x1b[?2004$p'] ])('does not reply to %s query', async (_label, query) => { createSession({ shellReadySupported: false }) subprocess.simulateData(query) From 4d15d9c9d60e64a44db142bcf5e1501645971c6f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:26:21 -0700 Subject: [PATCH 27/62] Recover terminal subscribe overflow from snapshots --- src/main/runtime/rpc/methods/terminal.ts | 85 +++++++++++++++++-- .../rpc/terminal-subscribe-buffer.test.ts | 65 +++++++++++--- 2 files changed, 133 insertions(+), 17 deletions(-) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 5fa80053d16..de584279ab2 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -291,6 +291,38 @@ function trimPendingOutputToBudget( return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } } +function trimPendingOutputCoveredBySnapshot( + pendingOutput: TerminalOutputChunk[], + snapshotSeq: number | undefined +): { chunks: TerminalOutputChunk[]; bytes: number } { + if (typeof snapshotSeq !== 'number') { + return { + chunks: pendingOutput, + bytes: pendingOutput.reduce((sum, chunk) => sum + terminalStreamByteLength(chunk.data), 0) + } + } + const chunks: TerminalOutputChunk[] = [] + let bytes = 0 + for (const chunk of pendingOutput) { + const chunkSeq = chunk.meta?.seq + const rawLength = chunk.meta?.rawLength ?? chunk.data.length + if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { + chunks.push(chunk) + bytes += terminalStreamByteLength(chunk.data) + continue + } + const startSeq = chunkSeq - rawLength + if (snapshotSeq >= chunkSeq) { + continue + } + const data = + snapshotSeq > startSeq ? chunk.data.slice(Math.max(0, snapshotSeq - startSeq)) : chunk.data + chunks.push({ data, meta: data === chunk.data ? chunk.meta : undefined }) + bytes += terminalStreamByteLength(data) + } + return { chunks, bytes } +} + function terminalStreamByteLength(data: string): number { return terminalStreamTextEncoder.encode(data).byteLength } @@ -1566,8 +1598,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let cursor = 0 let closed = false let buffering = true - const pendingOutput: string[] = [] + let pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 + let pendingOutputOverflowed = false let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} @@ -1669,17 +1702,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed) { return } if (buffering) { - pendingOutput.push(data) + pendingOutput.push({ data, meta }) pendingOutputBytes += terminalStreamByteLength(data) - pendingOutputBytes = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes).bytes + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed return } - outputBatcher?.push(data) + outputBatcher?.push(data, meta) }) const read = await runtime.readTerminal(params.terminal) @@ -1723,9 +1758,47 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ scrollbackRows: serialized?.scrollbackRows, truncatedByByteBudget: serialized?.truncatedByByteBudget === true }) + let recoveryAttempts = 0 + // Why: if the bounded pre-subscribe tail overflowed, only a fresh + // model snapshot can cover the dropped middle without replay gaps. + while (pendingOutputOverflowed && recoveryAttempts < 2) { + pendingOutputOverflowed = false + recoveryAttempts += 1 + const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed) { + return + } + if (!recovery) { + break + } + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'scrollback', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'pending-output-overflow', + seq: recovery.seq, + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'pending-output-overflow', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) + pendingOutput = trimmed.chunks + pendingOutputBytes = trimmed.bytes + } buffering = false for (const item of pendingOutput.splice(0)) { - outputBatcher.push(item) + outputBatcher.push(item.data, item.meta) } pendingOutputBytes = 0 outputBatcher.flush() diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index c976090aa73..5e2b75e5f74 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -331,21 +331,35 @@ describe('terminal subscribe buffering', () => { await dispatchPromise }) - it('bounds legacy binary output queued while the initial snapshot is serializing', async () => { + it('recovers binary output overflow queued while the initial snapshot is serializing', async () => { vi.useFakeTimers() try { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] const cleanups = new Map void>() - const dataListenerRef: { current?: (data: string) => void } = {} - let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => void)[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => - new Promise<{ data: string; cols: number; rows: number }>((resolve) => { - resolveSnapshot = resolve + new Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) }) ), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -386,26 +400,55 @@ describe('terminal subscribe buffering', () => { await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) const shiftSpy = vi.spyOn(Array.prototype, 'shift') + let seq = 0 for (let index = 0; index < 400; index += 1) { - dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) } const shiftCallCount = shiftSpy.mock.calls.length shiftSpy.mockRestore() await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) - resolveSnapshot({ data: '', cols: 120, rows: 40 }) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + snapshotResolvers[1]?.({ + data: 'recovered after overflow\r\n', + cols: 120, + rows: 40, + seq, + source: 'headless' + }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) await vi.runOnlyPendingTimersAsync() - const output = binaryFrames + const decodedFrames = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) - .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ + expect.objectContaining({ kind: 'scrollback', seq: 1 }), + expect.objectContaining({ + reason: 'pending-output-overflow', + seq, + source: 'headless' + }) + ]) + const snapshotText = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame.payload)) .join('') expect(output.length).toBeLessThanOrEqual(256 * 1024) expect(output).not.toContain('000') - expect(output).toContain('399') + expect(output).not.toContain('399') + expect(snapshotText).toContain('recovered after overflow') expect(shiftCallCount).toBe(0) runtime.cleanupSubscription('terminal-1:desktop-1') From d4164fd3d89f42fac9c737e654e1f5f2d2a86deb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:41:30 -0700 Subject: [PATCH 28/62] Allow hidden skip metrics in scale typing test --- tests/e2e/artificial-opencode-terminal-load.spec.ts | 6 ++++-- tests/e2e/global-setup.ts | 12 ++++++------ tests/e2e/global-teardown.ts | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index b6833bf9e95..14d0e2b6e2b 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -502,8 +502,10 @@ async function measureCrossWorkspaceTypingDuringHiddenLoad({ scheduler, mainPressure ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) + if ((debug?.hiddenRendererSkipCount ?? 0) > 0) { + expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(0) + } + expect(scheduler?.rendererDroppedBacklogs ?? 0).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_TIMER_DRIFT_MS) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index ef029fa79aa..f7eb0d683f6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -26,12 +26,12 @@ export default function globalSetup(): void { // ── 1. Build the Electron app ────────────────────────────────────── if (process.env.SKIP_BUILD && existsSync(outMain)) { - console.log('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') + console.error('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') } else { // Why: --mode e2e loads .env.e2e which sets VITE_EXPOSE_STORE=true. This // makes window.__store available in the renderer build so tests can read // Zustand state directly instead of fragile DOM scraping. - console.log('[e2e] Building Electron app with electron-vite build --mode e2e...') + console.error('[e2e] Building Electron app with electron-vite build --mode e2e...') execSync('npx electron-vite build --mode e2e', { cwd: root, stdio: 'inherit', @@ -39,13 +39,13 @@ export default function globalSetup(): void { // when healthy; global setup should not fail before specs can run. timeout: ELECTRON_E2E_BUILD_TIMEOUT_MS }) - console.log('[e2e] Build complete.') + console.error('[e2e] Build complete.') } if (process.env.ORCA_E2E_SSH_LOCALHOST === '1' || process.env.ORCA_E2E_SSH_DOCKER === '1') { // Why: the SSH specs deploy Orca's relay from out/relay. The // normal Electron E2E build does not produce that bundle, so build it only // for explicit SSH runs. - console.log('[e2e] Building SSH relay bundle for SSH E2E...') + console.error('[e2e] Building SSH relay bundle for SSH E2E...') execSync('pnpm run build:relay', { cwd: root, stdio: 'inherit', @@ -87,9 +87,9 @@ export default function globalSetup(): void { cwd: testRepoDir, stdio: 'pipe' }) - console.log(`[e2e] Secondary worktree created at ${worktreeDir}`) + console.error(`[e2e] Secondary worktree created at ${worktreeDir}`) // Write the test repo path so the fixture can read it writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - console.log(`[e2e] Test repo created at ${testRepoDir}`) + console.error(`[e2e] Test repo created at ${testRepoDir}`) } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 619ad3d2e1b..a5a59562c5b 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -32,7 +32,7 @@ export default function globalTeardown(): void { } rmSync(testRepoDir, { recursive: true, force: true }) - console.log(`[e2e] Cleaned up test repo at ${testRepoDir}`) + console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) } rmSync(TEST_REPO_PATH_FILE, { force: true }) From 0318394628dc7dd6d7b88ae8f538c5d0b2757537 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:31:27 -0700 Subject: [PATCH 29/62] Reset hidden synchronized output state on visibility and PTY changes Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 82 +++++++++++++++++++ .../terminal-pane/pty-connection.ts | 16 ++++ 2 files changed, 98 insertions(+) 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 ecbbac8ec15..9526727bb82 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -3171,6 +3171,88 @@ describe('connectPanePty', () => { } }) + it('clears hidden synchronized state when the end marker arrives while visible', 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) + + const pane = createPane(1) + const manager = createManager(1) + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + const hiddenSyncQueryChunk = '\x1b[?2026h\x1b[6n' + const foregroundEndChunk = 'done\x1b[?2026l' + const hiddenWideGlyphChunk = '│ 漢字 ║ 🚀 │\r\n' + + vi.useFakeTimers() + try { + capturedDataCallback.current?.(hiddenSyncQueryChunk) + isVisibleRef.current = true + capturedDataCallback.current?.(foregroundEndChunk) + isVisibleRef.current = false + capturedDataCallback.current?.(hiddenWideGlyphChunk) + + vi.advanceTimersByTime(50) + // Why: the synchronized frame ended while visible, so the wide-glyph + // hidden output must be judged by the strict plain rules and stay live. + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining(hiddenWideGlyphChunk) + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not inherit hidden synchronized state across PTY restarts', 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) + + 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) + + expect(capturedDataCallback.current).not.toBeNull() + const hiddenSyncQueryChunk = '\x1b[?2026h\x1b[6n' + const hiddenWideGlyphChunk = '│ 漢字 ║ 🚀 │\r\n' + + vi.useFakeTimers() + try { + capturedDataCallback.current?.(hiddenSyncQueryChunk) + ;(transport.attach as unknown as (opts: { existingPtyId: string }) => void)({ + existingPtyId: 'pty-id-2' + }) + capturedDataCallback.current?.(hiddenWideGlyphChunk) + + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining(hiddenWideGlyphChunk) + ) + } finally { + vi.useRealTimers() + } + }) + it('keeps hidden synchronized terminal queries on the live xterm path', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 73baecde7c4..33f7d5175f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -676,6 +676,7 @@ export function connectPanePty( let synchronizedForegroundOutputActive = false let synchronizedHiddenOutputActive = false let synchronizedHiddenOutputScanTail = '' + let synchronizedHiddenOutputPtyId: string | null = null // 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. @@ -2650,6 +2651,14 @@ export function connectPanePty( const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) const restoreAppliesToCurrentPty = hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId + const dataPtyId = transport.getPtyId() + if (synchronizedHiddenOutputPtyId !== dataPtyId) { + // Why: DEC 2026 state is per PTY stream; a restarted/reattached PTY + // must not inherit synchronized classification from the old shell. + synchronizedHiddenOutputPtyId = dataPtyId + synchronizedHiddenOutputActive = false + synchronizedHiddenOutputScanTail = '' + } const hiddenSynchronizedScanData = synchronizedHiddenOutputScanTail + data const synchronizedOutputStarted = containsSynchronizedOutputStart(hiddenSynchronizedScanData) const synchronizedOutputEnded = containsSynchronizedOutputEnd(hiddenSynchronizedScanData) @@ -2722,6 +2731,13 @@ export function connectPanePty( hiddenSynchronizedScanData ) } else { + // Why: a DEC 2026 end consumed while visible must still clear hidden + // synchronized state, or later hidden plain output is misclassified + // under the permissive synchronized model grammar. + synchronizedHiddenOutputActive = shouldSynchronizedOutputRemainActive( + hiddenSynchronizedScanData, + synchronizedHiddenOutputActive + ) synchronizedHiddenOutputScanTail = '' } From 8cc550e19803fd8cb79314d9e0af993e9a93f9f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:37:11 -0700 Subject: [PATCH 30/62] Re-register slept terminal sessions on wake Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.test.ts | 47 ++++++++++++++++++++++ src/main/daemon/daemon-pty-adapter.ts | 8 ++++ src/main/daemon/history-manager.ts | 18 +++++++++ 3 files changed, 73 insertions(+) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 17693639b95..b680399bd0c 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -721,6 +721,53 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('cold restores the second sleep/wake cycle with post-wake output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-wake-cycles' + }) + + lastSubprocess._simulateData('first cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + // Why: production wake always happens after the slept session's exit + // event closed its history; wait for that close before re-spawning. + const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json') + await waitFor(() => JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt !== null) + + const firstWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(firstWake.coldRestore?.scrollback).toContain('first cycle content') + historyAdapter.ackColdRestore(id) + expect(historyAdapter.hasPty(id)).toBe(true) + + lastSubprocess._simulateData('second cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + await waitFor(() => JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt !== null) + + const secondWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(secondWake.coldRestore?.scrollback).toContain('second cycle content') + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + it('writes meta.json with endedAt on exit', async () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 7ccb01415d1..53322fd237c 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -157,6 +157,14 @@ export class DaemonPtyAdapter implements IPtyProvider { // but should still return the cached cold restore data. const cachedRestore = this.coldRestoreCache.get(sessionId) if (cachedRestore) { + // Why: wake after sleep also lands here, and the slept session's active + // tracking and history writer were dropped when sleep killed the PTY. + // Without re-registering both, checkpoints stop after wake and the + // second sleep/wake cycle restores a blank terminal. + this.activeSessionIds.add(sessionId) + if (this.historyManager) { + this.historyManager.reopenSession(sessionId) + } return { id: sessionId, pid, diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 4cc9a85f191..691bb4ffd0d 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -103,6 +103,24 @@ export class HistoryManager { }) } + // Why: wake after sleep re-spawns a session whose history was closed by the + // sleep-time kill. Re-register the writer without deleting checkpoint.json + // (still the only recovery data until the next tick) and clear endedAt so + // the next sleep can cold-restore this session again. + reopenSession(sessionId: string): void { + this.disabledSessions.delete(sessionId) + this.registerWriter(sessionId) + const writer = this.writers.get(sessionId) + if (!writer) { + return + } + try { + this.updateMeta(writer.dir, { endedAt: null, exitCode: null }) + } catch (err) { + this.handleWriteError(sessionId, err) + } + } + // Why: replaces the old appendData (which wrote every PTY chunk to disk). // Checkpoints happen every ~5 seconds from a timer, not on every data event, // so disk I/O drops from O(PTY throughput) to O(1 write per interval). From a4e300142c73661d0f939876800cd57b95f75d6a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:49:43 -0700 Subject: [PATCH 31/62] Deliver subscribe overflow recovery as resized snapshot Co-authored-by: Orca --- src/main/runtime/rpc/methods/terminal.ts | 15 ++- .../rpc/terminal-subscribe-buffer.test.ts | 124 +++++++++++++++++- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index de584279ab2..9fee769a584 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1771,13 +1771,24 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!recovery) { break } + // Why: without an output seq (renderer-source fallback) covered + // chunks cannot be trimmed exactly, and the renderer view may lag + // the queued chunks under backpressure. Keep the bounded replay + // instead of applying an unverifiable snapshot. + if (typeof recovery.seq !== 'number') { + break + } + // Why: shipped mobile clients drop a second scrollback snapshot for + // an initialized handle but apply a resized snapshot inline by + // re-initializing xterm with fresh scrollback. Omit seq on the wire + // so the client's layout-seq staleness filter is not polluted with + // output-byte sequences. const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'scrollback', + kind: 'resized', cols: recovery.cols, rows: recovery.rows, displayMode, reason: 'pending-output-overflow', - seq: recovery.seq, source: recovery.source, truncated: false, truncatedByByteBudget: recovery.truncatedByByteBudget, diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 5e2b75e5f74..789317d6554 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -429,14 +429,20 @@ describe('terminal subscribe buffering', () => { const snapshotStarts = decodedFrames.filter( (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart ) - expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ + const decodedStarts = snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload)) + // Why: shipped mobile clients only apply a mid-session snapshot when it + // arrives as a resized frame; a second scrollback frame is dropped. + expect(decodedStarts).toEqual([ expect.objectContaining({ kind: 'scrollback', seq: 1 }), expect.objectContaining({ + kind: 'resized', reason: 'pending-output-overflow', - seq, source: 'headless' }) ]) + // Why: output-byte sequences must not pollute the client layout-seq + // staleness filter. + expect(decodedStarts[1]).not.toHaveProperty('seq') const snapshotText = decodedFrames .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) .map((frame) => decodeTerminalStreamText(frame.payload)) @@ -457,4 +463,118 @@ describe('terminal subscribe buffering', () => { vi.useRealTimers() } }) + + it('keeps bounded replay when overflow recovery has no output seq', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => void)[] = [] + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered-no-seq', + sendBinary: (bytes) => binaryFrames.push(bytes) + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + let seq = 0 + for (let index = 0; index < 400; index += 1) { + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + // Why: renderer-source snapshots carry no output seq, so covered chunks + // cannot be trimmed and the recovery snapshot must not be applied. + snapshotResolvers[1]?.({ + data: 'renderer fallback snapshot\r\n', + cols: 120, + rows: 40, + source: 'renderer' + }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const decodedFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ + expect.objectContaining({ kind: 'scrollback', seq: 1 }) + ]) + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + expect(output.length).toBeLessThanOrEqual(256 * 1024) + expect(output).not.toContain('000') + expect(output).toContain('399') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) }) From c976c751c9e8754bd91a3c7dc130e9d39bf4fa56 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:56:18 -0700 Subject: [PATCH 32/62] Apply remote ACK recovery snapshots on desktop clients Co-authored-by: Orca --- src/main/runtime/rpc/methods/terminal.ts | 18 +- .../runtime/rpc/terminal-multiplex.test.ts | 154 ++++++++++++++++++ .../remote-runtime-terminal-multiplexer.ts | 10 +- .../runtime/runtime-terminal-stream.test.ts | 71 ++++++++ 4 files changed, 250 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 9fee769a584..14dafed8441 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1031,6 +1031,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const displayMode = runtime.getMobileDisplayMode(stream.ptyId) // Why: dropped ACK-pending output means live frames are no longer a // complete replay. Send a fresh model snapshot before resuming output. + // Why: truncated marks an unusable snapshot, and clients discard + // those. The recovery snapshot must be applied to cover dropped + // output, so it is only truncated when serialization failed. sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { kind: 'scrollback', cols: serialized?.cols ?? size?.cols ?? 80, @@ -1039,10 +1042,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ reason: 'ack-pending-overflow', seq: serialized?.seq, source: serialized?.source, - truncated: true, + truncated: !serialized, truncatedByByteBudget: serialized?.truncatedByByteBudget, data: serialized?.data ?? '' }) + if (serialized && typeof serialized.seq === 'number') { + // Why: retained chunks queued before the snapshot serialized are + // already contained in it; replaying them would duplicate output. + const snapshotSeq = serialized.seq + const retained = stream.ackPendingOutput.filter( + (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) + ) + stream.ackPendingOutput = retained + stream.ackPendingOutputBytes = retained.reduce( + (total, chunk) => total + chunk.bytes.byteLength, + 0 + ) + } stream.ackPendingOutputOverflowed = false } catch (error) { sendStreamError( diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 00fc8668b72..3234bf4e86f 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -867,6 +867,12 @@ describe('terminal multiplex RPC', () => { (frame) => frame?.opcode === TerminalStreamOpcode.Output ) expect(recoveryStartIndex).toBeGreaterThanOrEqual(0) + // Why: clients discard truncated snapshots; a usable recovery snapshot + // must not be marked truncated or the dropped output gap is permanent. + expect( + decodeTerminalStreamJson<{ truncated?: boolean }>(drainFrames[recoveryStartIndex]!.payload) + ?.truncated + ).toBe(false) expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex) expect( drainFrames @@ -884,6 +890,154 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('trims recovery-covered ACK pending output instead of replaying it', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const floodedChars = 3 * 1024 * 1024 + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + // Why: the recovery snapshot seq covers the entire flood, so every + // retained pending chunk is already contained in the snapshot. + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: floodedChars }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-trim', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 31, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(floodedChars) + dataListenerRef.current?.(output, { seq: floodedChars, rawLength: floodedChars }) + const initialBytes = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + binaryFrames.splice(0) + handlers.get(31)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 31, + seq: 2, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd) + ).toBe(true) + ) + + const framesAfterRecovery = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + expect( + framesAfterRecovery + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + // Why: every retained chunk is covered by the recovery snapshot seq; + // replaying any of them would duplicate snapshot content. + expect( + framesAfterRecovery.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + ).toEqual([]) + + binaryFrames.splice(0) + const fresh = 'fresh-after-recovery\r\n' + dataListenerRef.current?.(fresh, { + seq: floodedChars + fresh.length, + rawLength: fresh.length + }) + await vi.waitFor(() => { + const freshOutput = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(freshOutput).toBe(fresh) + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-trim') + await dispatchPromise + }) + it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 1d78dbee3c5..7b01a768164 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -76,7 +76,7 @@ type RemoteRuntimeMultiplexedTerminalState = { snapshotChunks: Uint8Array[] snapshotBytes: number snapshotOverflowed: boolean - snapshotTarget: 'initial' | 'request' + snapshotTarget: 'initial' | 'request' | 'recovery' snapshotInfo: RemoteRuntimeSnapshotInfo | null initialSnapshotReceived: boolean pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null @@ -426,7 +426,9 @@ class RemoteRuntimeTerminalMultiplexer { typeof requestId === 'number' || (stream.initialSnapshotReceived && stream.pendingSnapshotRequest) ? 'request' - : 'initial' + : stream.initialSnapshotReceived + ? 'recovery' + : 'initial' return } if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { @@ -469,6 +471,10 @@ class RemoteRuntimeTerminalMultiplexer { clearPendingSnapshotRequest(stream) } else if (target === 'initial') { stream.callbacks.onSnapshot(data ?? '') + } else if (target === 'recovery' && data) { + // Why: a server-pushed recovery snapshot replaces terminal state + // mid-session; clear the screen and scrollback before applying it. + stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data}`) } } else if (matchesPendingRequest) { pendingRequest.resolve(null) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 1772a6b5cab..2c953f87b61 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -4,6 +4,7 @@ import { decodeTerminalStreamFrame, decodeTerminalStreamJson, encodeTerminalStreamFrame, + encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { @@ -332,4 +333,74 @@ describe('remote runtime terminal multiplex ACK gate', () => { heldTerminal.close() liveTerminal.close() }) + + it('applies mid-session recovery snapshots without re-subscribing', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-recovery') + const onSnapshot = vi.fn() + const onSubscribed = vi.fn() + const stream = await multiplexer.subscribeTerminal({ + terminal: 'terminal-recovery', + client: { id: 'desktop-recovery', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot, + onSubscribed + } + }) + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const streamId = stream.streamId + + const injectSnapshot = (info: Record, text: string): void => { + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson(info) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(text) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array(0) + }) + ) + } + + injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state') + expect(onSnapshot).toHaveBeenCalledWith('initial state') + expect(onSubscribed).toHaveBeenCalledTimes(1) + + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + 'recovered state' + ) + // Why: an unsolicited recovery snapshot replaces terminal state, so it + // clears screen and scrollback first and must not replay the subscribe + // lifecycle. + expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + stream.close() + }) }) From 3e00c077e2f0d356a9c16874890e4373ba506abe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:30:37 -0700 Subject: [PATCH 33/62] Compare terminal perf reports in HTML --- .../generate-terminal-perf-html-report.mjs | 175 ++++++++++++++++++ ...enerate-terminal-perf-html-report.test.mjs | 45 ++++- 2 files changed, 218 insertions(+), 2 deletions(-) diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs index 855fa9330e4..ed872df3e2f 100644 --- a/config/scripts/generate-terminal-perf-html-report.mjs +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -27,6 +27,19 @@ const SCENARIO_LABELS = [ ['opencode-revisit-pressure', 'Revisit under pressure'] ] +const COMPARISON_METRICS = [ + { key: 'medianMs', label: 'Median typing', suffix: 'ms', lowerIsBetter: true }, + { key: 'worstMs', label: 'Worst typing', suffix: 'ms', lowerIsBetter: true }, + { key: 'maxTimerDriftMs', label: 'Timer drift', suffix: 'ms', lowerIsBetter: true }, + { key: 'scrollMs', label: 'Scroll', suffix: 'ms', lowerIsBetter: true }, + { key: 'restoreMs', label: 'Restore', suffix: 'ms', lowerIsBetter: true }, + { key: 'revisitMs', label: 'Revisit', suffix: 'ms', lowerIsBetter: true }, + { key: 'rendererPeakQueuedChars', label: 'Renderer peak chars', lowerIsBetter: true }, + { key: 'mainPeakInFlightChars', label: 'Main in-flight chars', lowerIsBetter: true }, + { key: 'mainPeakPendingChars', label: 'Main pending chars', lowerIsBetter: true }, + { key: 'rendererDroppedBacklogs', label: 'Renderer drops', lowerIsBetter: true } +] + export function parseHtmlReportArgs(argv, env = process.env) { const args = [...argv] if (args[0] === '--') { @@ -241,6 +254,160 @@ function groupRows(rows) { ]) } +function sourceOrder(rows) { + return [...new Set(rows.map((row) => row.source))] +} + +function comparisonKey(row) { + return [row.scenario, row.panes ?? '', row.frames ?? ''].join('|') +} + +function comparisonLabel(row) { + const details = [] + if (row.panes != null) { + details.push(`${row.panes} panes`) + } + if (row.frames != null) { + details.push(`${row.frames} frames`) + } + return `${row.scenario}${details.length > 0 ? ` (${details.join(', ')})` : ''}` +} + +function collectPairComparisons(rows, fromSource, toSource) { + const bySourceAndKey = new Map() + for (const row of rows) { + bySourceAndKey.set(`${row.source}\0${comparisonKey(row)}`, row) + } + const comparisons = [] + for (const row of rows) { + if (row.source !== toSource) { + continue + } + const before = bySourceAndKey.get(`${fromSource}\0${comparisonKey(row)}`) + if (!before) { + continue + } + for (const metric of COMPARISON_METRICS) { + const beforeValue = before[metric.key] + const afterValue = row[metric.key] + if (beforeValue == null || afterValue == null) { + continue + } + const delta = afterValue - beforeValue + const percent = beforeValue === 0 ? null : (delta / beforeValue) * 100 + const improved = metric.lowerIsBetter ? delta < 0 : delta > 0 + const regressed = metric.lowerIsBetter ? delta > 0 : delta < 0 + comparisons.push({ + before, + after: row, + metric, + beforeValue, + afterValue, + delta, + percent, + improved, + regressed + }) + } + } + return comparisons.sort( + (a, b) => + comparisonLabel(a.after).localeCompare(comparisonLabel(b.after)) || + a.metric.label.localeCompare(b.metric.label) + ) +} + +function renderDelta(value, metric) { + const sign = value > 0 ? '+' : '' + return `${sign}${formatMetricValue(metric.key, value)}` +} + +function renderPercent(value) { + if (value == null || !Number.isFinite(value)) { + return '' + } + const sign = value > 0 ? '+' : '' + return `${sign}${value.toFixed(1)}%` +} + +function renderComparisonSummary(rows) { + const sources = sourceOrder(rows) + if (sources.length < 2) { + return '' + } + const first = sources[0] + const last = sources.at(-1) + const finalComparisons = collectPairComparisons(rows, first, last) + const changed = finalComparisons.filter((comparison) => comparison.delta !== 0) + const improved = changed.filter((comparison) => comparison.improved).length + const regressed = changed.filter((comparison) => comparison.regressed).length + const unchanged = finalComparisons.length - changed.length + const worstRegressions = finalComparisons + .filter((comparison) => comparison.regressed) + .sort((a, b) => Math.abs(b.percent ?? b.delta) - Math.abs(a.percent ?? a.delta)) + .slice(0, 6) + return `
+

Baseline To Final Impact

+

Comparing ${escapeHtml(first)} to ${escapeHtml(last)} across matching scenario rows. Lower is better for all metrics in this section.

+
+
Compared metrics${finalComparisons.length}
+
Improved${improved}
+
Regressed${regressed}
+
Unchanged${unchanged}
+
+ ${ + worstRegressions.length === 0 + ? '

No regressions found among matching baseline/final metrics.

' + : `

Largest Regressions

${renderComparisonTable(worstRegressions)}` + } +

All Baseline To Final Deltas

+ ${renderComparisonTable(finalComparisons)} +
` +} + +function renderIncrementalComparisons(rows) { + const sources = sourceOrder(rows) + if (sources.length < 3) { + return '' + } + const sections = [] + for (let index = 1; index < sources.length; index += 1) { + const comparisons = collectPairComparisons(rows, sources[index - 1], sources[index]) + const changed = comparisons.filter((comparison) => comparison.delta !== 0) + const improved = changed.filter((comparison) => comparison.improved).length + const regressed = changed.filter((comparison) => comparison.regressed).length + sections.push(`
+ ${escapeHtml(sources[index - 1])} → ${escapeHtml(sources[index])}: ${improved} improved, ${regressed} regressed + ${renderComparisonTable(comparisons)} +
`) + } + return `
+

Incremental Stack Deltas

+

Adjacent report comparisons show how each measured slice changed from the previous report.

+ ${sections.join('')} +
` +} + +function renderComparisonTable(comparisons) { + if (comparisons.length === 0) { + return '

No matching comparable metrics found.

' + } + const rows = comparisons + .map((comparison) => { + const direction = comparison.improved ? 'improved' : comparison.regressed ? 'regressed' : '' + return `
+ + + + + + + ` + }) + .join('') + return `
${escapeHtml(comparisonLabel(comparison.after))}${escapeHtml(comparison.metric.label)}${escapeHtml(formatMetricValue(comparison.metric.key, comparison.beforeValue))}${escapeHtml(formatMetricValue(comparison.metric.key, comparison.afterValue))}${escapeHtml(renderDelta(comparison.delta, comparison.metric))}${escapeHtml(renderPercent(comparison.percent))}
${rows}
ScenarioMetricBeforeAfterDeltaPercent
` +} + function chartSvg(title, rows, metrics) { const plotRows = rows.filter( (row) => row.panes != null && metrics.some((metric) => row[metric.key] != null) @@ -386,6 +553,7 @@ function renderHtml({ generatedAt, inputPaths, rows }) { main { max-width: 1180px; margin: 0 auto; padding: 32px 20px 48px; } h1 { font-size: 28px; margin: 0 0 8px; } h2 { font-size: 20px; margin: 32px 0 12px; } + h3 { font-size: 15px; margin: 18px 0 8px; } .meta, .summary { color: var(--muted); } .cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin: 18px 0 24px; } .card, .chart { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 14px; } @@ -396,6 +564,11 @@ function renderHtml({ generatedAt, inputPaths, rows }) { th, td { border-bottom: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; } th { color: var(--muted); font-size: 12px; text-transform: uppercase; } tr.failed td:last-child { color: var(--bad); font-weight: 600; } + tr.improved td:nth-last-child(2), tr.improved td:last-child { color: var(--ok); font-weight: 600; } + tr.regressed td:nth-last-child(2), tr.regressed td:last-child { color: var(--bad); font-weight: 600; } + details { background: var(--card); border: 1px solid var(--line); border-radius: 8px; margin: 12px 0; padding: 10px 12px; } + summary { cursor: pointer; font-weight: 700; } + details table { margin-top: 12px; } .chart { margin: 14px 0; } .chart-title { font-weight: 700; margin-bottom: 8px; } svg { width: 100%; height: auto; overflow: visible; } @@ -422,6 +595,8 @@ function renderHtml({ generatedAt, inputPaths, rows }) {
Max panes${Math.max(...rows.map((row) => row.panes ?? 0))}
Max renderer peak chars${formatLargeValue(Math.max(...rows.map((row) => row.rendererPeakQueuedChars ?? 0)))}
+ ${renderComparisonSummary(rows)} + ${renderIncrementalComparisons(rows)}

Impact Charts

${chartSections || '

No chartable pane-count rows were found.

'}

Scenario Metrics

diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs index 5c3602c9d00..5a876d65b95 100644 --- a/config/scripts/generate-terminal-perf-html-report.test.mjs +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -15,9 +15,13 @@ function makeTempDir() { return dir } -function writeReport(annotationDescription, annotationType = 'opencode-scale-same-workspace-25') { +function writeReport( + annotationDescription, + annotationType = 'opencode-scale-same-workspace-25', + reportName = 'report.json' +) { const dir = makeTempDir() - const reportPath = join(dir, 'report.json') + const reportPath = join(dir, reportName) writeFileSync( reportPath, JSON.stringify({ @@ -132,6 +136,43 @@ describe('generate-terminal-perf-html-report', () => { expect(html).toContain('Cross-workspace hidden panes') }) + it('renders baseline, final, and incremental deltas for multiple reports', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const middleReport = writeReport( + 'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'pty-backpressure.json' + ) + const finalReport = writeReport( + 'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'top-stack.json' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputPaths: [mainReport, middleReport, finalReport], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.rowCount).toBe(3) + expect(html).toContain('Baseline To Final Impact') + expect(html).toContain('main.json') + expect(html).toContain('top-stack.json') + expect(html).toContain('Incremental Stack Deltas') + expect(html).toContain('main.json → pty-backpressure.json') + expect(html).toContain('pty-backpressure.json → top-stack.json') + expect(html).toContain('-30.0ms') + expect(html).toContain('-60.0%') + expect(html).toContain('+20.0ms') + expect(html).toContain('+16.7%') + }) + it('fails when reports contain no terminal perf annotations', () => { const reportPath = writeReport('median=12.0ms', 'browser-unrelated') From 750665bc44441c8b3e24e201c801f7da2ec118d1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:39:58 -0700 Subject: [PATCH 34/62] Apply empty remote recovery snapshots as clears Co-authored-by: Orca --- .../remote-runtime-terminal-multiplexer.ts | 6 ++++-- .../src/runtime/runtime-terminal-stream.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 7b01a768164..f447d4e2637 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -471,10 +471,12 @@ class RemoteRuntimeTerminalMultiplexer { clearPendingSnapshotRequest(stream) } else if (target === 'initial') { stream.callbacks.onSnapshot(data ?? '') - } else if (target === 'recovery' && data) { + } else if (target === 'recovery') { // Why: a server-pushed recovery snapshot replaces terminal state // mid-session; clear the screen and scrollback before applying it. - stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data}`) + // An empty snapshot is still applied so stale dropped output does + // not linger on a terminal the model says is blank. + stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`) } } else if (matchesPendingRequest) { pendingRequest.resolve(null) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 2c953f87b61..85b21b22821 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -401,6 +401,21 @@ describe('remote runtime terminal multiplex ACK gate', () => { expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`) expect(onSubscribed).toHaveBeenCalledTimes(1) + // Why: an empty recovery snapshot means the model terminal is blank, so + // the client must still clear stale dropped output. + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + '' + ) + expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H') + expect(onSubscribed).toHaveBeenCalledTimes(1) + stream.close() }) }) From d659404a446b36564d0c613e078c64e0c3d15b26 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:14:58 -0700 Subject: [PATCH 35/62] Rewrite terminal perf report around revision trends Co-authored-by: Orca --- .../generate-terminal-perf-html-report.mjs | 844 +++++++++--------- ...enerate-terminal-perf-html-report.test.mjs | 121 ++- 2 files changed, 528 insertions(+), 437 deletions(-) diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs index ed872df3e2f..5950a7d5387 100644 --- a/config/scripts/generate-terminal-perf-html-report.mjs +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -27,26 +27,42 @@ const SCENARIO_LABELS = [ ['opencode-revisit-pressure', 'Revisit under pressure'] ] -const COMPARISON_METRICS = [ - { key: 'medianMs', label: 'Median typing', suffix: 'ms', lowerIsBetter: true }, - { key: 'worstMs', label: 'Worst typing', suffix: 'ms', lowerIsBetter: true }, - { key: 'maxTimerDriftMs', label: 'Timer drift', suffix: 'ms', lowerIsBetter: true }, - { key: 'scrollMs', label: 'Scroll', suffix: 'ms', lowerIsBetter: true }, - { key: 'restoreMs', label: 'Restore', suffix: 'ms', lowerIsBetter: true }, - { key: 'revisitMs', label: 'Revisit', suffix: 'ms', lowerIsBetter: true }, - { key: 'rendererPeakQueuedChars', label: 'Renderer peak chars', lowerIsBetter: true }, - { key: 'mainPeakInFlightChars', label: 'Main in-flight chars', lowerIsBetter: true }, - { key: 'mainPeakPendingChars', label: 'Main pending chars', lowerIsBetter: true }, - { key: 'rendererDroppedBacklogs', label: 'Renderer drops', lowerIsBetter: true } +// Why: every tracked metric is lower-is-better, so delta coloring and the +// regression table share one direction rule. +const MS_METRICS = [ + { key: 'medianMs', label: 'Typing median', chart: true }, + { key: 'worstMs', label: 'Typing worst', chart: true }, + { key: 'scrollMs', label: 'Active scroll', chart: true }, + { key: 'restoreMs', label: 'Restore', chart: true }, + { key: 'revisitMs', label: 'Revisit marker', chart: true }, + { key: 'maxTimerDriftMs', label: 'Timer drift', chart: false } ] +const COUNT_METRICS = [ + { key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' }, + { key: 'mainPeakInFlightChars', label: 'Main in-flight chars' }, + { key: 'mainPeakPendingChars', label: 'Main pending chars' }, + { key: 'hiddenSkippedChars', label: 'Hidden skipped chars' }, + { key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' } +] + +const SERIES_COLORS = { + medianMs: '#2563eb', + worstMs: '#dc2626', + scrollMs: '#d97706', + restoreMs: '#7c3aed', + revisitMs: '#0d9488' +} + +const LABELED_INPUT_RE = /^([\w .#@+-]+)=(.+)$/ + export function parseHtmlReportArgs(argv, env = process.env) { const args = [...argv] if (args[0] === '--') { args.shift() } - const inputPaths = [] + const inputs = [] let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH for (let index = 0; index < args.length; index += 1) { const arg = args[index] @@ -63,15 +79,20 @@ export function parseHtmlReportArgs(argv, env = process.env) { outputPath = arg.slice('--output='.length) continue } - inputPaths.push(arg) + const labeled = arg.match(LABELED_INPUT_RE) + if (labeled) { + inputs.push({ label: labeled[1], path: labeled[2] }) + } else { + inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg }) + } } - if (inputPaths.length === 0) { + if (inputs.length === 0) { throw new Error( - 'Usage: node config/scripts/generate-terminal-perf-html-report.mjs ... --output ' + 'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]... --output ' ) } - return { inputPaths, outputPath } + return { inputs, outputPath } } function readJsonReport(path) { @@ -139,31 +160,20 @@ function parseCount(value) { } function normalizeRow(row) { - const panes = parseCount(row.panes) - const frames = parseCount(row.frames) - const medianMs = parseMs(row.median) - const worstMs = parseMs(row.worst) - const revisitMs = parseMs(row.revisit) - const maxTimerDriftMs = parseMs(row.maxTimerDrift) - const scrollMs = parseMs(row.scroll) - const restoreMs = parseMs(row.restore) - const rendererQueuedChars = parseCount(row.rendererQueuedChars) - const rendererPeakQueuedChars = parseCount(row.rendererPeakQueuedChars) - const rendererDroppedBacklogs = parseCount(row.rendererDroppedBacklogs) return { ...row, group: scenarioGroup(row.scenario), - panes, - frames, - medianMs, - worstMs, - revisitMs, - maxTimerDriftMs, - scrollMs, - restoreMs, - rendererQueuedChars, - rendererPeakQueuedChars, - rendererDroppedBacklogs, + panes: parseCount(row.panes), + frames: parseCount(row.frames), + medianMs: parseMs(row.median), + worstMs: parseMs(row.worst), + revisitMs: parseMs(row.revisit), + maxTimerDriftMs: parseMs(row.maxTimerDrift), + scrollMs: parseMs(row.scroll), + restoreMs: parseMs(row.restore), + rendererQueuedChars: parseCount(row.rendererQueuedChars), + rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars), + rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs), mainPeakPendingChars: parseCount(row.mainPeakPendingChars), mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), heldAckChars: parseCount(row.heldAckChars), @@ -180,6 +190,36 @@ function scenarioGroup(scenario) { return 'Other terminal scenarios' } +function scenarioSortKey(scenario) { + const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix)) + const paneMatch = scenario.match(/-(\d+)$/) + return [ + prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex, + paneMatch ? Number(paneMatch[1]) : 0, + scenario + ] +} + +function compareScenarios(a, b) { + const ka = scenarioSortKey(a) + const kb = scenarioSortKey(b) + if (ka[0] !== kb[0]) { + return ka[0] - kb[0] + } + if (ka[1] !== kb[1]) { + return ka[1] - kb[1] + } + return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0 +} + +function scenarioTitle(scenario, row) { + const group = scenarioGroup(scenario) + if (row?.panes != null) { + return `${group} — ${row.panes} panes` + } + return group +} + function budgetFailures(row) { const failures = [] for (const [key, budget] of Object.entries(BUDGETS)) { @@ -188,445 +228,429 @@ function budgetFailures(row) { continue } if (value > budget) { - failures.push( - `${labelForMetric(key)} ${formatMetricValue(key, value)} > ${formatMetricValue(key, budget)}` - ) + failures.push(`${key} ${value} > ${budget}`) } } return failures } -function labelForMetric(key) { - return ( - { - medianMs: 'Median typing', - worstMs: 'Worst typing', - revisitMs: 'Revisit', - maxTimerDriftMs: 'Timer drift', - scrollMs: 'Scroll', - restoreMs: 'Restore', - rendererQueuedChars: 'Renderer queued', - rendererPeakQueuedChars: 'Renderer peak queued', - rendererDroppedBacklogs: 'Renderer dropped backlogs' - }[key] ?? key - ) -} - -function formatMetricValue(key, value) { +function formatMs(value) { if (value == null) { - return '' + return '—' } - if (key.endsWith('Ms')) { - return `${value.toFixed(1)}ms` - } - return Number.isInteger(value) ? String(value) : value.toFixed(1) + return `${value.toFixed(1)}ms` } -function formatCell(value, suffix = '') { - if (value == null || value === '') { - return '' +function formatLargeValue(value) { + if (value == null) { + return '—' } - if (typeof value === 'number') { - return Number.isInteger(value) ? `${value}${suffix}` : `${value.toFixed(1)}${suffix}` + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(2)}M` + } + if (value >= 1024) { + return `${Math.round(value / 1024)}k` } return String(value) } function escapeHtml(value) { - return String(value ?? '') + return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') - .replaceAll("'", ''') } -function groupRows(rows) { - const groups = new Map() - for (const row of rows) { - const existing = groups.get(row.group) ?? [] - existing.push(row) - groups.set(row.group, existing) - } - return [...groups.entries()].map(([label, group]) => [ - label, - group.sort((a, b) => (a.panes ?? 0) - (b.panes ?? 0) || a.scenario.localeCompare(b.scenario)) - ]) -} +// ── Trend data ──────────────────────────────────────────────────────────── -function sourceOrder(rows) { - return [...new Set(rows.map((row) => row.source))] -} - -function comparisonKey(row) { - return [row.scenario, row.panes ?? '', row.frames ?? ''].join('|') -} - -function comparisonLabel(row) { - const details = [] - if (row.panes != null) { - details.push(`${row.panes} panes`) - } - if (row.frames != null) { - details.push(`${row.frames} frames`) - } - return `${row.scenario}${details.length > 0 ? ` (${details.join(', ')})` : ''}` -} - -function collectPairComparisons(rows, fromSource, toSource) { - const bySourceAndKey = new Map() - for (const row of rows) { - bySourceAndKey.set(`${row.source}\0${comparisonKey(row)}`, row) - } - const comparisons = [] - for (const row of rows) { - if (row.source !== toSource) { - continue - } - const before = bySourceAndKey.get(`${fromSource}\0${comparisonKey(row)}`) - if (!before) { - continue - } - for (const metric of COMPARISON_METRICS) { - const beforeValue = before[metric.key] - const afterValue = row[metric.key] - if (beforeValue == null || afterValue == null) { - continue +function buildMatrix(revisions) { + const scenarios = new Map() + for (const revision of revisions) { + for (const row of revision.rows) { + if (!scenarios.has(row.scenario)) { + scenarios.set(row.scenario, new Map()) } - const delta = afterValue - beforeValue - const percent = beforeValue === 0 ? null : (delta / beforeValue) * 100 - const improved = metric.lowerIsBetter ? delta < 0 : delta > 0 - const regressed = metric.lowerIsBetter ? delta > 0 : delta < 0 - comparisons.push({ - before, - after: row, - metric, - beforeValue, - afterValue, - delta, - percent, - improved, - regressed - }) + scenarios.get(row.scenario).set(revision.label, row) } } - return comparisons.sort( - (a, b) => - comparisonLabel(a.after).localeCompare(comparisonLabel(b.after)) || - a.metric.label.localeCompare(b.metric.label) - ) + const orderedScenarios = [...scenarios.keys()].sort(compareScenarios) + return { scenarios, orderedScenarios } } -function renderDelta(value, metric) { - const sign = value > 0 ? '+' : '' - return `${sign}${formatMetricValue(metric.key, value)}` -} - -function renderPercent(value) { - if (value == null || !Number.isFinite(value)) { - return '' +function niceCeil(value) { + if (value <= 0) { + return 1 } - const sign = value > 0 ? '+' : '' - return `${sign}${value.toFixed(1)}%` -} - -function renderComparisonSummary(rows) { - const sources = sourceOrder(rows) - if (sources.length < 2) { - return '' - } - const first = sources[0] - const last = sources.at(-1) - const finalComparisons = collectPairComparisons(rows, first, last) - const changed = finalComparisons.filter((comparison) => comparison.delta !== 0) - const improved = changed.filter((comparison) => comparison.improved).length - const regressed = changed.filter((comparison) => comparison.regressed).length - const unchanged = finalComparisons.length - changed.length - const worstRegressions = finalComparisons - .filter((comparison) => comparison.regressed) - .sort((a, b) => Math.abs(b.percent ?? b.delta) - Math.abs(a.percent ?? a.delta)) - .slice(0, 6) - return `
-

Baseline To Final Impact

-

Comparing ${escapeHtml(first)} to ${escapeHtml(last)} across matching scenario rows. Lower is better for all metrics in this section.

-
-
Compared metrics${finalComparisons.length}
-
Improved${improved}
-
Regressed${regressed}
-
Unchanged${unchanged}
-
- ${ - worstRegressions.length === 0 - ? '

No regressions found among matching baseline/final metrics.

' - : `

Largest Regressions

${renderComparisonTable(worstRegressions)}` + const magnitude = 10 ** Math.floor(Math.log10(value)) + for (const step of [1, 2, 2.5, 5, 10]) { + if (value <= step * magnitude) { + return step * magnitude } -

All Baseline To Final Deltas

- ${renderComparisonTable(finalComparisons)} -
` + } + return 10 * magnitude } -function renderIncrementalComparisons(rows) { - const sources = sourceOrder(rows) - if (sources.length < 3) { - return '' - } - const sections = [] - for (let index = 1; index < sources.length; index += 1) { - const comparisons = collectPairComparisons(rows, sources[index - 1], sources[index]) - const changed = comparisons.filter((comparison) => comparison.delta !== 0) - const improved = changed.filter((comparison) => comparison.improved).length - const regressed = changed.filter((comparison) => comparison.regressed).length - sections.push(`
- ${escapeHtml(sources[index - 1])} → ${escapeHtml(sources[index])}: ${improved} improved, ${regressed} regressed - ${renderComparisonTable(comparisons)} -
`) - } - return `
-

Incremental Stack Deltas

-

Adjacent report comparisons show how each measured slice changed from the previous report.

- ${sections.join('')} -
` -} +// ── Rendering ───────────────────────────────────────────────────────────── -function renderComparisonTable(comparisons) { - if (comparisons.length === 0) { - return '

No matching comparable metrics found.

' - } - const rows = comparisons - .map((comparison) => { - const direction = comparison.improved ? 'improved' : comparison.regressed ? 'regressed' : '' - return ` - ${escapeHtml(comparisonLabel(comparison.after))} - ${escapeHtml(comparison.metric.label)} - ${escapeHtml(formatMetricValue(comparison.metric.key, comparison.beforeValue))} - ${escapeHtml(formatMetricValue(comparison.metric.key, comparison.afterValue))} - ${escapeHtml(renderDelta(comparison.delta, comparison.metric))} - ${escapeHtml(renderPercent(comparison.percent))} - ` - }) - .join('') - return `${rows}
ScenarioMetricBeforeAfterDeltaPercent
` -} - -function chartSvg(title, rows, metrics) { - const plotRows = rows.filter( - (row) => row.panes != null && metrics.some((metric) => row[metric.key] != null) +function renderTrendChart({ scenario, byRevision, revisions, title }) { + const metrics = MS_METRICS.filter( + (metric) => + metric.chart && + revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null) ) - if (plotRows.length === 0) { + if (metrics.length === 0) { return '' } - const width = 720 - const height = 260 - const pad = { bottom: 42, left: 54, right: 20, top: 28 } - const minPane = Math.min(...plotRows.map((row) => row.panes)) - const maxPane = Math.max(...plotRows.map((row) => row.panes)) + const width = 560 + const height = 230 + const pad = { left: 52, right: 14, top: 30, bottom: 38 } + const plotW = width - pad.left - pad.right + const plotH = height - pad.top - pad.bottom const maxValue = Math.max( 1, - ...plotRows.flatMap((row) => metrics.map((metric) => row[metric.key] ?? 0)) + ...metrics.flatMap((metric) => + revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0) + ) ) - const x = (pane) => { - if (minPane === maxPane) { - return pad.left + (width - pad.left - pad.right) / 2 - } - return pad.left + ((pane - minPane) / (maxPane - minPane)) * (width - pad.left - pad.right) + const yMax = niceCeil(maxValue * 1.15) + const xFor = (index) => + pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1)) + const yFor = (value) => pad.top + plotH - (plotH * value) / yMax + + const parts = [] + parts.push( + `` + ) + parts.push(`${escapeHtml(title)}`) + // Horizontal gridlines + y labels + const ticks = 4 + for (let tick = 0; tick <= ticks; tick += 1) { + const value = (yMax * tick) / ticks + const y = yFor(value) + parts.push( + `` + ) + parts.push( + `${value % 1 === 0 ? value : value.toFixed(1)}` + ) } - const y = (value) => height - pad.bottom - (value / maxValue) * (height - pad.top - pad.bottom) - const axis = [ - ``, - `` - ].join('') - const series = metrics + // X labels + revisions.forEach((revision, index) => { + parts.push( + `${escapeHtml(revision.label)}` + ) + }) + // Series + for (const metric of metrics) { + const color = SERIES_COLORS[metric.key] ?? '#475569' + const points = revisions + .map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] })) + .filter((point) => point.value != null) + if (points.length === 0) { + continue + } + const path = points + .map( + (point, order) => + `${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}` + ) + .join(' ') + parts.push(``) + for (const point of points) { + const x = xFor(point.index) + const y = yFor(point.value) + parts.push(``) + parts.push( + `${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}` + ) + } + } + parts.push('') + + const legend = metrics .map((metric) => { - const points = plotRows - .filter((row) => row[metric.key] != null) - .map((row) => `${x(row.panes).toFixed(1)},${y(row[metric.key]).toFixed(1)}`) - .join(' ') - if (!points) { - return '' - } - return `${plotRows - .filter((row) => row[metric.key] != null) + const color = SERIES_COLORS[metric.key] ?? '#475569' + return `${escapeHtml(metric.label)}` + }) + .join('') + return `
${parts.join('')}
${legend} ms — lower is better
` +} + +function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) { + if (baseline == null || latest == null) { + return '—' + } + const diff = latest - baseline + const pct = baseline === 0 ? null : (diff / baseline) * 100 + let cls = 'neutral' + if (zeroBudget) { + cls = latest > 0 ? 'worse' : 'better' + } else if (pct != null && Math.abs(pct) >= 5) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } else if (baseline === 0 && diff !== 0) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } + const pctLabel = + pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%` + const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}` + return `${escapeHtml(pctLabel)} (${escapeHtml(diffLabel)})` +} + +function renderScenarioTable({ scenario, byRevision, revisions, title }) { + const metricRows = [] + const allMetrics = [...MS_METRICS, ...COUNT_METRICS] + for (const metric of allMetrics) { + const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key]) + if (values.every((value) => value == null)) { + continue + } + const isMs = MS_METRICS.includes(metric) + const format = isMs ? formatMs : formatLargeValue + const cells = values + .map((value) => `${value == null ? '—' : escapeHtml(format(value))}`) + .join('') + const baseline = values.find((value) => value != null) + const latest = [...values].reverse().find((value) => value != null) + metricRows.push( + `${escapeHtml(metric.label)}${cells}${deltaCell(baseline, latest, { + zeroBudget: metric.key === 'rendererDroppedBacklogs' + })}` + ) + } + if (metricRows.length === 0) { + return '' + } + const headers = revisions.map((revision) => `${escapeHtml(revision.label)}`).join('') + return `
+

${escapeHtml(title)} ${escapeHtml(scenario)}

+ +${headers} +${metricRows.join('')} +
MetricΔ first → last
+
` +} + +function renderHeadline(revisions, matrix) { + if (revisions.length < 2) { + return '' + } + const first = revisions[0] + const last = revisions.at(-1) + const cards = [] + for (const scenario of matrix.orderedScenarios) { + const byRevision = matrix.scenarios.get(scenario) + const baseRow = byRevision.get(first.label) + const lastRow = byRevision.get(last.label) + if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) { + continue + } + const diff = lastRow.medianMs - baseRow.medianMs + const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100 + const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse' + cards.push(`
+
${escapeHtml(scenarioTitle(scenario, lastRow))}
+
${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}
+
typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)
+
`) + } + if (cards.length === 0) { + return '' + } + return `

Baseline vs latest

${cards.join('')}
` +} + +function renderBudgets(latestRevision) { + const failures = [] + for (const row of latestRevision.rows) { + for (const failure of budgetFailures(row)) { + failures.push(`${row.scenario}: ${failure}`) + } + } + const status = + failures.length === 0 ? 'Pass' : 'Fail' + const failureList = + failures.length === 0 + ? '' + : `
    ${failures.map((failure) => `
  • ${escapeHtml(failure)}
  • `).join('')}
` + return `

Budget status — ${escapeHtml(latestRevision.label)}

+

${latestRevision.rows.length} scenario rows checked: ${status}

${failureList}
` +} + +function renderInputsMeta(revisions) { + const items = revisions + .map((revision) => { + const stats = revision.stats + const statsLabel = stats + ? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky` + : '' + const failNote = + stats && stats.unexpected > 0 + ? ' (failed assertions at this revision; metrics still recorded)' + : '' + return `
  • ${escapeHtml(revision.label)} — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}
  • ` + }) + .join('') + return `
      ${items}
    ` +} + +function renderRawDetails(revisions) { + return revisions + .map((revision) => { + const rows = revision.rows .map( (row) => - `${escapeHtml(row.scenario)} ${metric.label}: ${escapeHtml(formatCell(row[metric.key], metric.suffix ?? ''))}` + `${escapeHtml(row.scenario)}${row.panes ?? '—'}${escapeHtml(formatMs(row.medianMs))}${escapeHtml(formatMs(row.worstMs))}${escapeHtml(formatMs(row.scrollMs))}${escapeHtml(formatMs(row.restoreMs))}${escapeHtml(formatMs(row.revisitMs))}${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}${row.rendererDroppedBacklogs ?? '—'}` ) - .join('')}
    ` + .join('') + return `
    Raw rows — ${escapeHtml(revision.label)} + + +${rows}
    ScenarioPanesMedianWorstScrollRestoreRevisitRenderer peakHidden skippedDrops
    ` }) .join('') - const xLabels = [...new Set(plotRows.map((row) => row.panes))] - .sort((a, b) => a - b) - .map( - (pane) => - `${pane}` - ) - .join('') - const yLabels = [0, maxValue / 2, maxValue] - .map( - (value) => - `${formatLargeValue(value)}` - ) - .join('') - const legend = metrics - .map((metric) => `${escapeHtml(metric.label)}`) - .join('') - return `
    ${escapeHtml(title)}
    ${axis}${series}${xLabels}${yLabels}Pane count
    ${legend}
    ` } -function formatLargeValue(value) { - if (value >= 1024 * 1024) { - return `${(value / (1024 * 1024)).toFixed(1)}M` - } - if (value >= 1000) { - return `${(value / 1000).toFixed(0)}k` - } - return value.toFixed(value % 1 === 0 ? 0 : 1) -} +const PAGE_CSS = ` +:root { color-scheme: light; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; } +h1 { font-size: 24px; margin-bottom: 4px; } +h2 { font-size: 18px; margin: 28px 0 10px; } +h3 { font-size: 15px; margin: 18px 0 6px; } +.meta { color: #64748b; font-size: 13px; } +.inputs { font-size: 13px; color: #334155; padding-left: 20px; } +.meta-warn { color: #b45309; } +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; } +.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; } +.card.better { border-left-color: #16a34a; } +.card.worse { border-left-color: #dc2626; } +.card.neutral { border-left-color: #94a3b8; } +.card-title { font-size: 12px; color: #64748b; } +.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; } +.card-sub { font-size: 11px; color: #94a3b8; } +.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; } +.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; } +.trend-chart { width: 100%; height: auto; } +.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; } +.gridline { stroke: #e2e8f0; stroke-width: 1; } +.axis-label { font-size: 10px; fill: #64748b; } +.point-label { font-size: 10px; font-weight: 600; } +.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.legend-item { display: inline-flex; align-items: center; gap: 4px; } +.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } +.legend-unit { color: #94a3b8; margin-left: auto; } +.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; } +.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; } +table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; } +table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; } +table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; } +table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; } +td.delta.better { color: #15803d; font-weight: 600; } +td.delta.worse { color: #b91c1c; font-weight: 600; } +td.delta.neutral { color: #64748b; } +.delta-abs { font-weight: 400; color: #94a3b8; } +.pass { color: #15803d; font-weight: 700; } +.fail { color: #b91c1c; font-weight: 700; } +details { margin: 8px 0; } +summary { cursor: pointer; font-size: 13px; color: #334155; } +` -function renderTable(rows) { - const columns = [ - ['Scenario', (row) => row.scenario], - ['Source', (row) => row.source], - ['Panes', (row) => row.panes], - ['Frames', (row) => row.frames], - ['Median', (row) => formatCell(row.medianMs, 'ms')], - ['Worst', (row) => formatCell(row.worstMs, 'ms')], - ['Revisit', (row) => formatCell(row.revisitMs, 'ms')], - ['Scroll', (row) => formatCell(row.scrollMs, 'ms')], - ['Restore', (row) => formatCell(row.restoreMs, 'ms')], - ['Drift', (row) => formatCell(row.maxTimerDriftMs, 'ms')], - ['Renderer Peak', (row) => row.rendererPeakQueuedChars], - ['Main In-Flight', (row) => row.mainPeakInFlightChars], - ['Held ACK', (row) => row.heldAckChars], - ['Hidden Chars', (row) => row.hiddenSkippedChars], - ['Drops', (row) => row.rendererDroppedBacklogs], - [ - 'Budget', - (row) => { - const failures = budgetFailures(row) - return failures.length === 0 ? 'pass' : `fail: ${failures.join('; ')}` - } - ] - ] - return `${columns.map(([label]) => ``).join('')}${rows - .map((row) => { - const failed = budgetFailures(row).length > 0 - return `${columns - .map(([, getter]) => ``) - .join('')}` +function renderHtml({ generatedAt, revisions }) { + const matrix = buildMatrix(revisions) + const charts = + revisions.length >= 2 + ? matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderTrendChart({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) + }) + .filter(Boolean) + .join('') + : '' + const tables = matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderScenarioTable({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) }) - .join('')}
    ${escapeHtml(label)}
    ${escapeHtml(getter(row))}
    ` -} - -function renderHtml({ generatedAt, inputPaths, rows }) { - const failures = rows.flatMap((row) => budgetFailures(row).map((failure) => ({ failure, row }))) - const grouped = groupRows(rows) - const chartSections = grouped - .map(([label, group]) => - [ - chartSvg(`${label}: typing latency`, group, [ - { className: 'metric-a', key: 'medianMs', label: 'Median', suffix: 'ms' }, - { className: 'metric-b', key: 'worstMs', label: 'Worst', suffix: 'ms' } - ]), - chartSvg(`${label}: renderer/main pressure`, group, [ - { className: 'metric-c', key: 'rendererPeakQueuedChars', label: 'Renderer peak chars' }, - { className: 'metric-d', key: 'mainPeakInFlightChars', label: 'Main in-flight chars' }, - { className: 'metric-e', key: 'mainPeakPendingChars', label: 'Main pending chars' } - ]), - chartSvg(`${label}: restore and scroll`, group, [ - { className: 'metric-f', key: 'restoreMs', label: 'Restore', suffix: 'ms' }, - { className: 'metric-g', key: 'scrollMs', label: 'Scroll', suffix: 'ms' }, - { className: 'metric-b', key: 'revisitMs', label: 'Revisit', suffix: 'ms' } - ]) - ].join('') - ) + .filter(Boolean) .join('') + return ` - - - Terminal Performance Impact Report - + + +Terminal Performance Over Time + -
    -

    Terminal Performance Impact Report

    -

    Generated ${escapeHtml(generatedAt)} from ${inputPaths.length} Playwright JSON report${inputPaths.length === 1 ? '' : 's'}.

    -

    ${inputPaths.map((path) => escapeHtml(path)).join('
    ')}

    -
    -
    Scenario rows${rows.length}
    -
    Budget status${failures.length === 0 ? 'Pass' : `${failures.length} failure${failures.length === 1 ? '' : 's'}`}
    -
    Max panes${Math.max(...rows.map((row) => row.panes ?? 0))}
    -
    Max renderer peak chars${formatLargeValue(Math.max(...rows.map((row) => row.rendererPeakQueuedChars ?? 0)))}
    -
    - ${renderComparisonSummary(rows)} - ${renderIncrementalComparisons(rows)} -

    Impact Charts

    - ${chartSections || '

    No chartable pane-count rows were found.

    '} -

    Scenario Metrics

    - ${renderTable(rows)} -

    Correctness Gates To Pair With This Report

    -

    Pair this performance report with hidden TUI visual restore, terminal rendering golden, long-table restore, sleep/wake restore, SSH/remote ACK pressure, and WebSocket multiplex pressure evidence before declaring the terminal performance goal complete.

    -
    +

    Terminal Performance Over Time

    +

    Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.

    +${renderInputsMeta(revisions)} +${renderHeadline(revisions, matrix)} +${charts ? `

    Trends across revisions

    ${charts}
    ` : ''} +

    Metric detail by scenario

    ${tables}
    +${renderBudgets(revisions.at(-1))} +

    Raw data

    ${renderRawDetails(revisions)}
    ` } -export function generateTerminalPerfHtmlReport({ inputPaths, outputPath, now = new Date() }) { - const rows = inputPaths.flatMap((path) => - collectTerminalPerfRows(readJsonReport(path), basename(path)) - ) - if (rows.length === 0) { - throw new Error('No OpenCode terminal perf annotations found.') +export function generateTerminalPerfHtmlReport({ + inputs, + inputPaths, + outputPath, + now = new Date() +}) { + // Why: older callers (the scale report gate) pass bare inputPaths. + const resolvedInputs = + inputs ?? + (inputPaths ?? []).map((path) => ({ + label: basename(path).replace(/\.json$/i, ''), + path + })) + const revisions = resolvedInputs.map(({ label, path }) => { + const report = readJsonReport(path) + return { + label, + path, + stats: report.stats ?? null, + rows: collectTerminalPerfRows(report, label) + } + }) + const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0) + if (totalRows === 0) { + throw new Error('No opencode terminal perf annotations found in the provided reports') } + const html = renderHtml({ generatedAt: now.toISOString(), revisions }) mkdirSync(dirname(outputPath), { recursive: true }) - const html = renderHtml({ generatedAt: now.toISOString(), inputPaths, rows }) writeFileSync(outputPath, html) - return { outputPath, rowCount: rows.length, failureCount: rows.flatMap(budgetFailures).length } + const latestFailures = revisions + .at(-1) + .rows.reduce((sum, row) => sum + budgetFailures(row).length, 0) + return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures } } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] +if (isMain) { try { - const result = generateTerminalPerfHtmlReport(parseHtmlReportArgs(process.argv.slice(2))) + const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2)) + const result = generateTerminalPerfHtmlReport({ inputs, outputPath }) console.log( - `Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} row${result.rowCount === 1 ? '' : 's'}, ${result.failureCount} budget failure${result.failureCount === 1 ? '' : 's'}).` + `Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).` ) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs index 5a876d65b95..bd6d95a62e4 100644 --- a/config/scripts/generate-terminal-perf-html-report.test.mjs +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -59,22 +59,34 @@ afterEach(() => { }) describe('generate-terminal-perf-html-report', () => { - it('parses input paths and output flags', () => { + it('parses labeled and bare input paths plus output flags', () => { expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({ - inputPaths: ['a.json', 'b.json'], + inputs: [ + { label: 'a', path: 'a.json' }, + { label: 'b', path: 'b.json' } + ], outputPath: 'out.html' }) + expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual( + { + inputs: [ + { label: 'main', path: 'runs/0-main.json' }, + { label: '#5038 final', path: 'runs/4-final.json' } + ], + outputPath: 'test-results/terminal-perf-impact-report.html' + } + ) expect( parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' }) ).toEqual({ - inputPaths: ['a.json'], + inputs: [{ label: 'a', path: 'a.json' }], outputPath: 'env.html' }) expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path') expect(() => parseHtmlReportArgs([])).toThrow('Usage:') }) - it('writes an HTML report with charts, table rows, and escaped input', () => { + it('writes a single-run report with scenario tables and budget status', () => { const reportPath = writeReport( [ 'panes=25', @@ -101,19 +113,20 @@ describe('generate-terminal-perf-html-report', () => { }) const html = readFileSync(outputPath, 'utf8') - expect(result).toEqual({ failureCount: 0, outputPath, rowCount: 1 }) + expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 }) expect(html).toContain('') - expect(html).toContain('Terminal Performance Impact Report') + expect(html).toContain('Terminal Performance Over Time') expect(html).toContain('2026-06-09T10:00:00.000Z') - expect(html).toContain('Same workspace panes: typing latency') + expect(html).toContain('Same workspace panes — 25 panes') expect(html).toContain('opencode-scale-same-workspace-25') expect(html).toContain('28.6ms') - expect(html).toContain('') expect(html).toContain('Pass') + // Why: one run has no over-time story; the trend section must not render. + expect(html).not.toContain('Trends across revisions') expect(html).not.toContain('browser-unrelated') }) - it('marks over-budget rows as failures', () => { + it('marks over-budget rows as failures for the latest run', () => { const reportPath = writeReport( [ 'panes=100', @@ -130,13 +143,13 @@ describe('generate-terminal-perf-html-report', () => { const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) const html = readFileSync(outputPath, 'utf8') - expect(result.failureCount).toBe(5) - expect(html).toContain('5 failures') - expect(html).toContain('fail: Median typing 80.0ms > 75.0ms') + expect(result.budgetFailureCount).toBe(5) + expect(html).toContain('Fail') + expect(html).toContain('medianMs 80 > 75') expect(html).toContain('Cross-workspace hidden panes') }) - it('renders baseline, final, and incremental deltas for multiple reports', () => { + it('renders ordered revisions with trend charts and baseline deltas', () => { const mainReport = writeReport( 'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0', 'opencode-scale-same-workspace-25', @@ -145,32 +158,86 @@ describe('generate-terminal-perf-html-report', () => { const middleReport = writeReport( 'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0', 'opencode-scale-same-workspace-25', - 'pty-backpressure.json' + 'backpressure.json' ) const finalReport = writeReport( 'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0', 'opencode-scale-same-workspace-25', - 'top-stack.json' + 'final.json' ) const outputPath = join(makeTempDir(), 'report.html') const result = generateTerminalPerfHtmlReport({ - inputPaths: [mainReport, middleReport, finalReport], + inputs: [ + { label: 'main', path: mainReport }, + { label: 'backpressure', path: middleReport }, + { label: 'final', path: finalReport } + ], outputPath }) const html = readFileSync(outputPath, 'utf8') expect(result.rowCount).toBe(3) - expect(html).toContain('Baseline To Final Impact') - expect(html).toContain('main.json') - expect(html).toContain('top-stack.json') - expect(html).toContain('Incremental Stack Deltas') - expect(html).toContain('main.json → pty-backpressure.json') - expect(html).toContain('pty-backpressure.json → top-stack.json') - expect(html).toContain('-30.0ms') - expect(html).toContain('-60.0%') - expect(html).toContain('+20.0ms') - expect(html).toContain('+16.7%') + expect(html).toContain('Baseline vs latest') + expect(html).toContain('Trends across revisions') + expect(html).toContain('trend-chart') + expect(html).toContain('>main<') + expect(html).toContain('>backpressure<') + expect(html).toContain('>final<') + // Why: median 50 -> 20 is a 60% improvement and must read as better. + expect(html).toContain('delta better') + expect(html).toContain('-60%') + expect(html).toContain('50.0ms → 20.0ms') + }) + + it('renders missing scenarios at older revisions as gaps, not zeros', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const finalReport = makeTempDir() + const finalPath = join(finalReport, 'final.json') + writeFileSync( + finalPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale-same-workspace-25', + description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0' + }, + { + type: 'opencode-revisit-pressure', + description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0' + } + ] + } + ] + } + ] + } + ] + }) + ) + const outputPath = join(makeTempDir(), 'report.html') + + generateTerminalPerfHtmlReport({ + inputs: [ + { label: 'main', path: mainReport }, + { label: 'final', path: finalPath } + ], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(html).toContain('Revisit under pressure') + expect(html).toContain('') }) it('fails when reports contain no terminal perf annotations', () => { @@ -181,6 +248,6 @@ describe('generate-terminal-perf-html-report', () => { inputPaths: [reportPath], outputPath: join(makeTempDir(), 'report.html') }) - ).toThrow('No OpenCode terminal perf annotations found.') + ).toThrow('No opencode terminal perf annotations found') }) }) From e7129949875136b04af635cc3f9ff25137514c46 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:34:37 -0700 Subject: [PATCH 36/62] Extract terminal perf report row parsing module Co-authored-by: Orca --- .../generate-terminal-perf-html-report.mjs | 204 ++---------------- config/scripts/terminal-perf-report-rows.mjs | 191 ++++++++++++++++ 2 files changed, 203 insertions(+), 192 deletions(-) create mode 100644 config/scripts/terminal-perf-report-rows.mjs diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs index 5950a7d5387..0f670083935 100644 --- a/config/scripts/generate-terminal-perf-html-report.mjs +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -1,32 +1,19 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, writeFileSync } from 'node:fs' +import { + budgetFailures, + collectTerminalPerfRows, + compareScenarios, + escapeHtml, + formatLargeValue, + formatMs, + readJsonReport, + scenarioTitle +} from './terminal-perf-report-rows.mjs' import { basename, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html' -const BUDGETS = { - medianMs: 75, - worstMs: 300, - revisitMs: 300, - maxTimerDriftMs: 150, - scrollMs: 150, - restoreMs: 1000, - rendererQueuedChars: 2 * 1024 * 1024, - rendererPeakQueuedChars: 2 * 1024 * 1024, - rendererDroppedBacklogs: 0 -} - -const SCENARIO_LABELS = [ - ['opencode-scale-same-workspace', 'Same workspace panes'], - ['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'], - ['opencode-scale-pressure', 'ACK-backpressured PTYs'], - ['opencode-scale-hidden-pressure', 'Hidden real PTYs'], - ['opencode-cross-workspace-typing', 'Cross-workspace typing'], - ['opencode-main-pressure', 'Main renderer pressure'], - ['opencode-hidden-pressure', 'Hidden pressure'], - ['opencode-revisit-pressure', 'Revisit under pressure'] -] - // Why: every tracked metric is lower-is-better, so delta coloring and the // regression table share one direction rule. const MS_METRICS = [ @@ -54,7 +41,7 @@ const SERIES_COLORS = { revisitMs: '#0d9488' } -const LABELED_INPUT_RE = /^([\w .#@+-]+)=(.+)$/ +const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/ export function parseHtmlReportArgs(argv, env = process.env) { const args = [...argv] @@ -95,173 +82,6 @@ export function parseHtmlReportArgs(argv, env = process.env) { return { inputs, outputPath } } -function readJsonReport(path) { - const raw = readFileSync(path, 'utf8') - const start = raw.indexOf('{') - const end = raw.lastIndexOf('}') - if (start === -1 || end <= start) { - throw new Error(`${path}: no JSON object found`) - } - return JSON.parse(raw.slice(start, end + 1)) -} - -function parseAnnotationDescription(description) { - const values = {} - for (const part of description.split(/\s+/)) { - const index = part.indexOf('=') - if (index === -1) { - continue - } - values[part.slice(0, index)] = part.slice(index + 1) - } - return values -} - -function collectTerminalPerfRows(report, source) { - const rows = [] - const visitSuite = (suite) => { - for (const spec of suite.specs ?? []) { - for (const test of spec.tests ?? []) { - for (const annotation of test.annotations ?? []) { - if (!annotation.type.startsWith('opencode-')) { - continue - } - rows.push( - normalizeRow({ - source, - scenario: annotation.type, - ...parseAnnotationDescription(annotation.description ?? '') - }) - ) - } - } - } - for (const child of suite.suites ?? []) { - visitSuite(child) - } - } - for (const suite of report.suites ?? []) { - visitSuite(suite) - } - return rows -} - -function parseMs(value) { - const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/) - return match ? Number(match[1]) : null -} - -function parseCount(value) { - if (value == null || value === '') { - return null - } - const count = Number(value) - return Number.isFinite(count) ? count : null -} - -function normalizeRow(row) { - return { - ...row, - group: scenarioGroup(row.scenario), - panes: parseCount(row.panes), - frames: parseCount(row.frames), - medianMs: parseMs(row.median), - worstMs: parseMs(row.worst), - revisitMs: parseMs(row.revisit), - maxTimerDriftMs: parseMs(row.maxTimerDrift), - scrollMs: parseMs(row.scroll), - restoreMs: parseMs(row.restore), - rendererQueuedChars: parseCount(row.rendererQueuedChars), - rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars), - rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs), - mainPeakPendingChars: parseCount(row.mainPeakPendingChars), - mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), - heldAckChars: parseCount(row.heldAckChars), - hiddenSkippedChars: parseCount(row.hiddenSkippedChars) - } -} - -function scenarioGroup(scenario) { - for (const [prefix, label] of SCENARIO_LABELS) { - if (scenario.startsWith(prefix)) { - return label - } - } - return 'Other terminal scenarios' -} - -function scenarioSortKey(scenario) { - const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix)) - const paneMatch = scenario.match(/-(\d+)$/) - return [ - prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex, - paneMatch ? Number(paneMatch[1]) : 0, - scenario - ] -} - -function compareScenarios(a, b) { - const ka = scenarioSortKey(a) - const kb = scenarioSortKey(b) - if (ka[0] !== kb[0]) { - return ka[0] - kb[0] - } - if (ka[1] !== kb[1]) { - return ka[1] - kb[1] - } - return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0 -} - -function scenarioTitle(scenario, row) { - const group = scenarioGroup(scenario) - if (row?.panes != null) { - return `${group} — ${row.panes} panes` - } - return group -} - -function budgetFailures(row) { - const failures = [] - for (const [key, budget] of Object.entries(BUDGETS)) { - const value = row[key] - if (value == null) { - continue - } - if (value > budget) { - failures.push(`${key} ${value} > ${budget}`) - } - } - return failures -} - -function formatMs(value) { - if (value == null) { - return '—' - } - return `${value.toFixed(1)}ms` -} - -function formatLargeValue(value) { - if (value == null) { - return '—' - } - if (value >= 1024 * 1024) { - return `${(value / (1024 * 1024)).toFixed(2)}M` - } - if (value >= 1024) { - return `${Math.round(value / 1024)}k` - } - return String(value) -} - -function escapeHtml(value) { - return String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} - // ── Trend data ──────────────────────────────────────────────────────────── function buildMatrix(revisions) { diff --git a/config/scripts/terminal-perf-report-rows.mjs b/config/scripts/terminal-perf-report-rows.mjs new file mode 100644 index 00000000000..c61ac208af8 --- /dev/null +++ b/config/scripts/terminal-perf-report-rows.mjs @@ -0,0 +1,191 @@ +import { readFileSync } from 'node:fs' + +const BUDGETS = { + medianMs: 75, + worstMs: 300, + revisitMs: 300, + maxTimerDriftMs: 150, + scrollMs: 150, + restoreMs: 1000, + rendererQueuedChars: 2 * 1024 * 1024, + rendererPeakQueuedChars: 2 * 1024 * 1024, + rendererDroppedBacklogs: 0 +} + +const SCENARIO_LABELS = [ + ['opencode-scale-same-workspace', 'Same workspace panes'], + ['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'], + ['opencode-scale-pressure', 'ACK-backpressured PTYs'], + ['opencode-scale-hidden-pressure', 'Hidden real PTYs'], + ['opencode-cross-workspace-typing', 'Cross-workspace typing'], + ['opencode-main-pressure', 'Main renderer pressure'], + ['opencode-hidden-pressure', 'Hidden pressure'], + ['opencode-revisit-pressure', 'Revisit under pressure'] +] + +export function readJsonReport(path) { + const raw = readFileSync(path, 'utf8') + const start = raw.indexOf('{') + const end = raw.lastIndexOf('}') + if (start === -1 || end <= start) { + throw new Error(`${path}: no JSON object found`) + } + return JSON.parse(raw.slice(start, end + 1)) +} + +function parseAnnotationDescription(description) { + const values = {} + for (const part of description.split(/\s+/)) { + const index = part.indexOf('=') + if (index === -1) { + continue + } + values[part.slice(0, index)] = part.slice(index + 1) + } + return values +} + +export function collectTerminalPerfRows(report, source) { + const rows = [] + const visitSuite = (suite) => { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const annotation of test.annotations ?? []) { + if (!annotation.type.startsWith('opencode-')) { + continue + } + rows.push( + normalizeRow({ + source, + scenario: annotation.type, + ...parseAnnotationDescription(annotation.description ?? '') + }) + ) + } + } + } + for (const child of suite.suites ?? []) { + visitSuite(child) + } + } + for (const suite of report.suites ?? []) { + visitSuite(suite) + } + return rows +} + +function parseMs(value) { + const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/) + return match ? Number(match[1]) : null +} + +function parseCount(value) { + if (value == null || value === '') { + return null + } + const count = Number(value) + return Number.isFinite(count) ? count : null +} + +function normalizeRow(row) { + return { + ...row, + group: scenarioGroup(row.scenario), + panes: parseCount(row.panes), + frames: parseCount(row.frames), + medianMs: parseMs(row.median), + worstMs: parseMs(row.worst), + revisitMs: parseMs(row.revisit), + maxTimerDriftMs: parseMs(row.maxTimerDrift), + scrollMs: parseMs(row.scroll), + restoreMs: parseMs(row.restore), + rendererQueuedChars: parseCount(row.rendererQueuedChars), + rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars), + rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs), + mainPeakPendingChars: parseCount(row.mainPeakPendingChars), + mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), + heldAckChars: parseCount(row.heldAckChars), + hiddenSkippedChars: parseCount(row.hiddenSkippedChars) + } +} + +export function scenarioGroup(scenario) { + for (const [prefix, label] of SCENARIO_LABELS) { + if (scenario.startsWith(prefix)) { + return label + } + } + return 'Other terminal scenarios' +} + +function scenarioSortKey(scenario) { + const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix)) + const paneMatch = scenario.match(/-(\d+)$/) + return [ + prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex, + paneMatch ? Number(paneMatch[1]) : 0, + scenario + ] +} + +export function compareScenarios(a, b) { + const ka = scenarioSortKey(a) + const kb = scenarioSortKey(b) + if (ka[0] !== kb[0]) { + return ka[0] - kb[0] + } + if (ka[1] !== kb[1]) { + return ka[1] - kb[1] + } + return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0 +} + +export function scenarioTitle(scenario, row) { + const group = scenarioGroup(scenario) + if (row?.panes != null) { + return `${group} — ${row.panes} panes` + } + return group +} + +export function budgetFailures(row) { + const failures = [] + for (const [key, budget] of Object.entries(BUDGETS)) { + const value = row[key] + if (value == null) { + continue + } + if (value > budget) { + failures.push(`${key} ${value} > ${budget}`) + } + } + return failures +} + +export function formatMs(value) { + if (value == null) { + return '—' + } + return `${value.toFixed(1)}ms` +} + +export function formatLargeValue(value) { + if (value == null) { + return '—' + } + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(2)}M` + } + if (value >= 1024) { + return `${Math.round(value / 1024)}k` + } + return String(value) +} + +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} From 72a03564ee4651b5bf5a02127be930208c88c20f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:21:54 -0700 Subject: [PATCH 37/62] Park hidden terminal views behind a byte watcher Co-authored-by: Orca --- .../reference/terminal-hidden-view-parking.md | 100 ++++ src/preload/e2e-config.ts | 5 +- src/renderer/src/components/Terminal.tsx | 289 +++++++++- .../TerminalPaneOverlayLayer.tsx | 28 + .../parked-terminal-byte-watcher.test.ts | 460 +++++++++++++++ .../parked-terminal-byte-watcher.ts | 265 +++++++++ .../components/terminal-pane/pty-transport.ts | 19 +- .../terminal-hidden-view-parking.test.ts | 529 ++++++++++++++++++ .../terminal-hidden-view-parking.ts | 285 ++++++++++ .../terminal-parked-tab-watchers.test.ts | 483 ++++++++++++++++ .../terminal-parked-tab-watchers.ts | 243 ++++++++ .../terminal-parked-watcher-registry.ts | 107 ++++ .../terminal-parking-e2e-overrides.test.ts | 82 +++ .../terminal-parking-e2e-overrides.ts | 34 ++ .../use-terminal-pane-lifecycle.ts | 14 + .../use-terminal-tab-cold-parking.ts | 243 ++++++++ src/renderer/src/env.d.ts | 4 + src/renderer/src/store/slices/terminals.ts | 9 + src/shared/agent-detection.ts | 10 +- src/shared/constants.ts | 1 + src/shared/e2e-config.ts | 13 +- src/shared/types.ts | 5 + tests/e2e/helpers/orca-app.ts | 10 +- .../e2e/terminal-hidden-view-parking.spec.ts | 409 ++++++++++++++ 24 files changed, 3621 insertions(+), 26 deletions(-) create mode 100644 docs/reference/terminal-hidden-view-parking.md create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts create mode 100644 tests/e2e/terminal-hidden-view-parking.spec.ts diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md new file mode 100644 index 00000000000..61b1f832f3c --- /dev/null +++ b/docs/reference/terminal-hidden-view-parking.md @@ -0,0 +1,100 @@ +# Terminal Hidden View Parking + +Status: Phase 1 of the terminal model/view architecture. See +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the +invariants this design extends. + +## Problem + +Hidden terminal panes keep a full renderer xterm instance alive (buffer, +scrollback, DOM, addons). At many-worktree scale this is the dominant renderer +memory cost, and it forces every hidden byte through renderer-side write/skip +decisions. The main-process model (daemon + runtime headless emulators) already +ingests every byte and can serve restorable snapshots, so the renderer view for +a long-hidden pane is redundant state. + +A previous attempt shipped and was reverted the same day. The post-mortem +finding: parking unmounted the pane component, which also tore down the +renderer's PTY byte parsers — and those parsers are the only source of bell +notifications, title-transition agent-complete notifications, and tab titles. +A parked worktree whose agent finished would never notify. This design keeps +those side effects alive while parked. + +## Design + +### Park policy (renderer) + +A pure policy module decides which hidden terminal tabs may park: + +- Cold-park hysteresis: a tab must be hidden for 30s before parking. +- Hot-retain working set: recently visible worktrees/tabs are retained + (5 minutes, bounded count) so quick tab switches never pay a re-hydrate. +- Eligibility excludes: visible panes, hidden-measuring startup probes, + activity-portal panes, tabs with pending startup commands or pending + activation spawns, floating-panel tabs, and any tab whose PTY is not + snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded in + this phase). +- Kill switch: `settings.terminalHiddenViewParking === false` disables parking + entirely. + +### Park mechanics + +Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer +renders null for parked tabs). This is the same teardown that tab-group moves +already exercise: transports detach but the PTY session, daemon model, and tab +state all survive. The xterm instance, its buffers, DOM, and WebGL/addon +resources are released. + +### Parked byte watcher (the piece the reverted attempt lacked) + +While a tab is parked, a pane-less watcher subscribes to its PTYs through the +dispatcher sidecar mechanism (the same mechanism background agent launches +use). The watcher runs the transport-level byte parsers with no xterm: + +- OSC 0/1/2 titles → tab/pane title store updates (all-titles ordering, same + normalization as the live transport path). +- Title-transition agent tracker → agent-became-idle completion notification + and prompt-cache timer, agent-became-working cancellation. +- BEL detection (OSC-aware stateful detector) → worktree/tab unread plus the + delayed terminal-bell OS notification. +- DECSET 2031 subscribe scan → out-of-band color-scheme reply via + `transport.sendInput`, so TUIs that subscribe while parked still learn the + theme. +- GitHub PR link scan → worktree linked-PR detection keeps working for agents + that print PR URLs while parked. + +Main's synthetic agent-title/permission frames ride the same `pty:data` +channel, so they flow through the watcher unchanged. + +Out of scope while parked (documented behavior, unchanged from the hidden +skip-latch status quo): terminal query auto-replies other than mode 2031, +OSC 52 clipboard writes, Command Code output scraping. + +### Reveal + +Revealing a parked tab remounts the pane subtree and rides the existing +reattach path: fresh xterm via `openTerminal` (unicode provider activation +before any write), daemon model snapshot > relay replay > cold restore +precedence, replay-guarded so snapshot-embedded queries never answer, then +`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is +disposed before the pane handlers re-register. + +## Invariants + +1. PTY reads never stop; parking only changes renderer-side view lifetime. +2. Bell, agent-completion, title, and PR-link side effects keep working while + parked (watcher parity tests). +3. Reveal shows model-correct output (visual gates: hidden TUI restore, long + table, rendering golden) and accepts input immediately. +4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers + or strand parked state. +5. Memory: parked tabs hold no xterm buffers; renderer memory scales with + visible panes. + +## Cut-offs + +This phase is independently mergeable. Later phases (side-effect authority to +main, gating hidden delivery in main, model query authority) replace the +watcher's byte parsing for local/SSH PTYs and stop hidden delivery entirely; +the watcher remains the parser for remote-runtime PTYs, which never transit +local main. diff --git a/src/preload/e2e-config.ts b/src/preload/e2e-config.ts index 29b6f09c404..379e83bc585 100644 --- a/src/preload/e2e-config.ts +++ b/src/preload/e2e-config.ts @@ -12,5 +12,8 @@ const preloadEnv = ( export const preloadE2EConfig = createE2EConfig({ headless: process.env.ORCA_E2E_HEADLESS === '1', exposeStore: preloadEnv?.VITE_EXPOSE_STORE, - userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null + userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null, + // Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so + // only a real positive override reaches the renderer parking policy. + terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null }) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 2cb006b52b0..bcdf33f2e19 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -60,6 +60,18 @@ import { anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { + getTerminalWorktreeColdParkRecheckDelayMs, + selectColdParkedTerminalWorktrees, + type TerminalWorktreeColdParkCandidate +} from './terminal-pane/terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-pane/terminal-parked-tab-watchers' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import CodexRestartChip from './CodexRestartChip' import { @@ -108,6 +120,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set(['editor', 'diff', 'con type TerminalStoreSnapshot = ReturnType +function haveSameWorktreeIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + function findUnifiedTabByVisibleId( state: TerminalStoreSnapshot, worktreeId: string, @@ -186,11 +210,15 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { function Terminal(): React.JSX.Element | null { const mountedWorktreeIdsRef = useRef(new Set()) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const terminalWorktreeHiddenSinceRef = useRef(new Map()) + const terminalWorktreeParkingTimersRef = useRef(new Map()) const allWorktrees = useAllWorktrees() const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const renderedActiveWorktreeId = activeWorktreeId const activeView = useAppStore((s) => s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const activeTabId = useAppStore((s) => s.activeTabId) const createTab = useAppStore((s) => s.createTab) const closeTab = useAppStore((s) => s.closeTab) @@ -546,7 +574,12 @@ function Terminal(): React.JSX.Element | null { releaseCloseDialogGuardAfterDebounce() return } - toast.error(translate("auto.components.Terminal.a2a279b32a", "Save timed out or failed. Fix errors before closing.")) + toast.error( + translate( + 'auto.components.Terminal.a2a279b32a', + 'Save timed out or failed. Fix errors before closing.' + ) + ) setSaveDialogFileId(fileId) // Why: a genuine timeout leaves the user back on the same dialog, so // release the guard immediately — a new click here is a deliberate @@ -658,7 +691,11 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) - const [, setBackgroundMountRevision] = useState(0) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) + const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( + () => new Set() + ) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current @@ -708,6 +745,122 @@ function Terminal(): React.JSX.Element | null { closeDialogDebounceTimers.clear() } }, []) + + useEffect(() => { + const timers = terminalWorktreeParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const parkingTimers = terminalWorktreeParkingTimersRef.current + for (const timer of parkingTimers.values()) { + window.clearTimeout(timer) + } + parkingTimers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId)) + const currentWorktreeIds = new Set(allWorktrees.map((worktree) => worktree.id)) + for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) { + if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } + } + + const retentionCandidates: TerminalWorktreeColdParkCandidate[] = [] + for (const worktree of allWorktrees) { + const worktreeId = worktree.id + if (!mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + continue + } + const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId) + const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId) + if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs) + } + + retentionCandidates.push({ + worktreeId, + terminalTabs: tabsByWorktree[worktreeId] ?? [], + isVisible, + shouldMeasureHiddenWorktree, + hasActivityTerminalPortal, + hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null + }) + } + + const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({ + worktrees: retentionCandidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a worktree with any tab the byte watchers cannot cover (no + // capture, no layout snapshot, legacy leaf ids) must never park — it + // would go silent for bells/titles/completions, the failure that sank + // the first parking attempt. + for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { + const tabs = tabsByWorktree[worktreeId] ?? [] + if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) { + nextParkedTerminalWorktreeIds.delete(worktreeId) + } + } + setParkedTerminalWorktreeIds((current) => + haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds) + ? current + : nextParkedTerminalWorktreeIds + ) + + for (const candidate of retentionCandidates) { + if ( + candidate.isVisible || + candidate.shouldMeasureHiddenWorktree || + candidate.hasActivityTerminalPortal || + nextParkedTerminalWorktreeIds.has(candidate.worktreeId) + ) { + continue + } + const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const worktreeId = candidate.worktreeId + const timer = window.setTimeout(() => { + parkingTimers.delete(worktreeId) + setTerminalParkingRevision((revision) => revision + 1) + }, delayMs) + parkingTimers.set(worktreeId, timer) + } + } + }, [ + activeView, + activityTerminalPortals, + allWorktrees, + backgroundMountRevision, + pendingStartupByTabId, + renderedActiveWorktreeId, + tabsByWorktree, + terminalParkingEnabled, + terminalParkingRevision + ]) // Why: gated on workspaceSessionReady to prevent TerminalPane from mounting // before reconnectPersistedTerminals() has finished eagerly spawning PTYs. // Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId @@ -730,6 +883,59 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) + // Why: parked byte-watcher reconciliation for the legacy (non-split) + // terminal host, which renders TerminalPanes directly. In split mode each + // TerminalPaneOverlayLayer owns its worktree's watchers, so here we only + // dispose worktrees that render no overlay layer (no layout / unmounted) + // and prune watchers for deleted worktrees. + useEffect(() => { + pruneParkedTerminalWatchers(new Set(allWorktrees.map((worktree) => worktree.id))) + for (const worktree of allWorktrees) { + if ( + anyMountedWorktreeHasLayout && + mountedWorktreeIdsRef.current.has(worktree.id) && + getEffectiveLayoutForWorktree(worktree.id) + ) { + continue + } + const tabs = tabsByWorktree[worktree.id] ?? [] + const parkedTabIds = new Set() + if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(worktree.id)) { + const isVisible = activeView === 'terminal' && worktree.id === renderedActiveWorktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) + const parked = + !isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(worktree.id) + if (parked) { + for (const tab of tabs) { + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId: worktree.id, + tabId: tab.id + }) + if (!activityTerminalPortal) { + parkedTabIds.add(tab.id) + } + } + } + } + syncParkedTerminalTabWatchers({ worktreeId: worktree.id, tabs, parkedTabIds }) + } + }, [ + activeView, + activityTerminalPortals, + allWorktrees, + anyMountedWorktreeHasLayout, + backgroundMountRevision, + getEffectiveLayoutForWorktree, + parkedTerminalWorktreeIds, + renderedActiveWorktreeId, + tabsByWorktree + ]) + // Why: symmetric with useTerminalTabColdParking's unmount cleanup — when + // the terminal host unmounts, no reconciliation effect will run again, so + // dispose every remaining parked watcher here (overlay-layer children have + // already disposed theirs by the time this parent cleanup runs). + useEffect(() => () => pruneParkedTerminalWatchers(new Set()), []) // Auto-create first tab when worktree activates useEffect(() => { if (!workspaceSessionReady) { @@ -861,7 +1067,7 @@ function Terminal(): React.JSX.Element | null { return } createBrowserTab(activeWorktreeId, defaultUrl, { - title: translate("auto.components.Terminal.37da0d736f", "New Browser Tab"), + title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), focusAddressBar: true }) }, [ @@ -988,6 +1194,12 @@ function Terminal(): React.JSX.Element | null { if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } handleCloseTab(tabId) }, [consumeSuppressedPtyExit, handleCloseTab] @@ -1292,7 +1504,12 @@ function Terminal(): React.JSX.Element | null { if (floatingWorkspaceFocused) { void createFloatingWorkspaceMarkdownTab(useAppStore.getState()).catch((err) => { toast.error( - err instanceof Error ? err.message : translate("auto.components.Terminal.f0600556b3", "Failed to create untitled markdown file.") + err instanceof Error + ? err.message + : translate( + 'auto.components.Terminal.f0600556b3', + 'Failed to create untitled markdown file.' + ) ) }) return @@ -1647,6 +1864,10 @@ function Terminal(): React.JSX.Element | null { activeView === 'terminal' && worktree.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) + const shouldColdParkTerminalPanes = + !isVisible && + !shouldMeasureHiddenWorktree && + parkedTerminalWorktreeIds.has(worktree.id) return ( ) @@ -1707,6 +1929,10 @@ function Terminal(): React.JSX.Element | null { activeView === 'terminal' && worktree.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) + const shouldColdParkTerminalPanes = + !isVisible && + !shouldMeasureHiddenWorktree && + parkedTerminalWorktreeIds.has(worktree.id) return (
    - {renderedActiveWorktreeId && activeTabType === "editor" && worktreeFiles.length > 0 && ( + {renderedActiveWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && ( - {translate("auto.components.Terminal.5c1d2a32bb", "Loading editor...")}
    + {translate('auto.components.Terminal.5c1d2a32bb', 'Loading editor...')} + } > @@ -1830,20 +2063,32 @@ function Terminal(): React.JSX.Element | null { > - {translate("auto.components.Terminal.21295c6b8c", "Unsaved Changes")} + + {translate('auto.components.Terminal.21295c6b8c', 'Unsaved Changes')} + {saveDialogFile - ? translate("auto.components.Terminal.61ed600d29", "\"{{value0}}\" has unsaved changes. Do you want to save before closing?", { value0: basename(saveDialogFile.relativePath) }) - : translate("auto.components.Terminal.46e08bc5c8", "This file has unsaved changes.")} + ? translate( + 'auto.components.Terminal.61ed600d29', + '"{{value0}}" has unsaved changes. Do you want to save before closing?', + { value0: basename(saveDialogFile.relativePath) } + ) + : translate( + 'auto.components.Terminal.46e08bc5c8', + 'This file has unsaved changes.' + )} + {translate('auto.components.Terminal.f82e9f02df', 'Cancel')} + + {translate('auto.components.Terminal.0037b21794', "Don't Save")} + + {translate('auto.components.Terminal.cd51e28d8b', 'Save')} + @@ -1859,9 +2104,15 @@ function Terminal(): React.JSX.Element | null { > - {translate("auto.components.Terminal.2fa9c69ff3", "Close Window?")} + + {translate('auto.components.Terminal.2fa9c69ff3', 'Close Window?')} + - {translate("auto.components.Terminal.7958465754", "There are local terminals with running processes. Close the window anyway?")} + {translate( + 'auto.components.Terminal.7958465754', + 'There are local terminals with running processes. Close the window anyway?' + )} + + {translate('auto.components.Terminal.f82e9f02df', 'Cancel')} + + {translate('auto.components.Terminal.73768427cf', 'Close')} + @@ -1909,6 +2162,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId, isVisible, shouldMeasureHiddenWorktree, + shouldColdParkTerminalPanes, activityTerminalPortals }: { worktreeId: string @@ -1917,6 +2171,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId?: string isVisible: boolean shouldMeasureHiddenWorktree: boolean + shouldColdParkTerminalPanes: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] }): React.JSX.Element { const browserPageIds = useAppStore( @@ -1954,6 +2209,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreeId={worktreeId} worktreePath={worktreePath} isWorktreeActive={isVisible} + coldParkTerminalPanes={shouldColdParkTerminalPanes} + shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} activityTerminalPortals={activityTerminalPortals} /> diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 5bf9a8d7b7a..8426b93a000 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -10,6 +10,8 @@ import { type ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' import TerminalPane from './TerminalPane' +import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers' +import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' type TerminalOverlayAssignment = { groupId: string @@ -206,6 +208,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) { + return + } closeTab(terminalTabId) leaveWorktreeIfEmpty() }} @@ -241,11 +249,15 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, + coldParkTerminalPanes = false, + shouldMeasureHiddenWorktree = false, activityTerminalPortals = EMPTY_ACTIVITY_PORTALS }: { worktreeId: string worktreePath: string isWorktreeActive: boolean + coldParkTerminalPanes?: boolean + shouldMeasureHiddenWorktree?: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] }): React.JSX.Element | null { const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore( @@ -306,6 +318,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ return entries }, [groupActiveTabById, unifiedTabs]) + const parkedTerminalTabIds = useTerminalTabColdParking({ + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + }) + if (!worktreePath) { return null } @@ -320,6 +342,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, tabId: terminalTab.id }) + // Why: parking is exactly the unmount path tab-group moves use — + // transports detach, the PTY and tab model survive, and the parked + // byte watcher takes over side effects until reveal remounts here. + if (parkedTerminalTabIds.has(terminalTab.id)) { + return null + } return ( + clearRuntimePaneTitle: ReturnType + updateTabTitle: ReturnType + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType + setCacheTimerStartedAt: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType +} + +const dispatchTerminalNotification = vi.fn() +let mockStoreState: MockStoreState + +vi.mock('./use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/lib/terminal-theme', () => ({ + getSystemPrefersDark: () => true +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState + } +})) + +function createMockStoreState(): MockStoreState { + return { + settings: { + theme: 'system', + promptCacheTimerEnabled: true, + experimentalTerminalAttention: false, + notifications: { enabled: true, agentTaskComplete: true } + }, + setRuntimePaneTitle: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + updateTabTitle: vi.fn(), + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + observeTerminalGitHubPullRequestLink: vi.fn() + } +} + +describe('startParkedTerminalByteWatcher', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onData: ((payload: { id: string; data: string }) => void) | null = null + + function emit(data: string): void { + onData?.({ id: PTY_ID, data }) + } + + // The output processor defers title/bell side effects onto a 0ms drain timer. + function flushSideEffects(): void { + vi.advanceTimersByTime(0) + } + + async function startWatcher( + overrides: Partial = {} + ): Promise<{ dispose: () => void; sendInput: ReturnType }> { + const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') + const sendInput = vi.fn() + const dispose = startParkedTerminalByteWatcher({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: PANE_ID, + sendInput, + ...overrides + }) + return { dispose, sendInput } + } + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalNotification.mockClear() + onData = null + mockStoreState = createMockStoreState() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + pty: { + onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { + onData = callback + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('forwards every OSC title in order to the pane and tab title store actions', async () => { + const { dispose } = await startWatcher() + + emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([ + [TAB_ID, PANE_ID, '⠋ Build feature'], + [TAB_ID, PANE_ID, IDLE_TITLE] + ]) + expect(mockStoreState.updateTabTitle.mock.calls).toEqual([ + [TAB_ID, '⠋ Build feature'], + [TAB_ID, IDLE_TITLE] + ]) + dispose() + }) + + it('drops the bare cursor-agent native title before it reaches the store', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;Cursor Agent\x07') + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('does not drive the tab title when drivesTabTitle is false', async () => { + const { dispose } = await startWatcher({ drivesTabTitle: false }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('marks the exact pane unread when experimental terminal attention is enabled', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true + } + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + + expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY) + dispose() + }) + + it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;par') + emit('tial title\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('suppresses the completion OS notification when only terminal attention is on', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true, + notifications: { enabled: true, agentTaskComplete: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY, + suppressOsNotification: true + }) + dispose() + }) + + it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: false, + notifications: { enabled: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + dispose() + }) + + it('lets a same-burst completion supersede the pending bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + emit(`${IDLE_TITLE_OSC}\x07`) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8) + + // The bell still marks unread immediately; only the OS notification yields. + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ source: 'agent-task-complete' }) + ) + dispose() + }) + + it('cancels the pending completion and clears the cache timer when working resumes', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + emit(WORKING_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + dispose() + }) + + it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => { + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + + emit('31h') + expect(sendInput).toHaveBeenCalledTimes(1) + // theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol. + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + emit('\x1b[?2031l') + expect(sendInput).toHaveBeenCalledTimes(1) + dispose() + }) + + it('observes GitHub PR links across chunk boundaries', async () => { + const { dispose } = await startWatcher() + + emit('PR: https://github.com/orca-dev/orca/pull/42') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + + emit('1\r\ndone') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ + url: 'https://github.com/orca-dev/orca/pull/421', + number: 421, + slug: { owner: 'orca-dev', repo: 'orca' } + }) + ) + dispose() + }) + + it('fires completion when seeded with a working title and the agent goes idle while parked', async () => { + // Why: the pane was working at park time; the watcher's fresh tracker + // must be seeded or this working→idle transition can never fire. + const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('does not fire completion for an idle title without a seed or observed transition', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('clears the watcher-written runtime title slot on dispose', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + }) + + it('leaves the runtime title slot alone on dispose when it never wrote one', async () => { + const { dispose } = await startWatcher() + + emit('plain output with no titles\r\n') + flushSideEffects() + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled() + }) + + it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + + // Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds. + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + // The teardown flush that main emits after pty.kill must be a no-op. + emit('final teardown flush\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + it('dispose unregisters the sidecar and cancels the pending bell notification', async () => { + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + // Idempotent: a second dispose must not throw or clobber another watcher. + dispose() + }) + + it('disposes the previous watcher when a new one starts for the same PTY', async () => { + await startWatcher({ paneId: 1 }) + const second = await startWatcher({ paneId: 2 }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE) + second.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts new file mode 100644 index 00000000000..ff78c010bd7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -0,0 +1,265 @@ +/** + * Parked terminal byte watcher. + * + * Why: parking unmounts the TerminalPane subtree, which tears down the + * transport byte parsers — the renderer's only source of bell, title, + * agent-completion, mode-2031, and PR-link side effects. (Losing them is the + * gap that sank the first parking attempt.) This watcher rides the dispatcher + * sidecar channel — the same mechanism background agent launches use — so it + * never disturbs pane handler registration or eager buffering, and keeps the + * PTY side effects alive with no xterm while the tab is parked. + * See docs/reference/terminal-hidden-view-parking.md. + */ +import { isClaudeAgent } from '../../../../shared/agent-detection' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode, + scanMode2031Sequences +} from '../../../../shared/terminal-color-scheme-protocol' +import { useAppStore } from '@/store' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' +import { subscribeToPtyData } from './pty-dispatcher' +import { createPtyOutputProcessor } from './pty-transport' +import { dispatchTerminalNotification } from './use-notification-dispatch' + +// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts. +// The parked path must keep the live path's BEL-vs-completion race window so +// notification behavior is identical whether a tab is parked or mounted. +const PARKED_NOTIFICATION_GRACE_MS = 250 + +type StoreState = ReturnType + +// Why: these settings predicates are duplicated from pty-connection.ts instead +// of imported — that module drags the whole pane/xterm dependency graph into a +// watcher that exists precisely to run without a pane. +function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean { + const notifications = state.settings?.notifications + return notifications?.enabled !== false && notifications?.agentTaskComplete !== false +} + +function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean { + return ( + isAgentTaskCompleteOsNotificationEnabled(state) || + state.settings?.experimentalTerminalAttention === true + ) +} + +export type ParkedTerminalByteWatcherOptions = { + ptyId: string + tabId: string + worktreeId: string + /** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey + * used for cache-timer, unread, and notification attribution. */ + leafId: string + /** PaneManager pane id the unmounted pane used. Runtime pane titles are + * keyed by it, so the watcher must write the slot the live path wrote — + * a different id would leave a stale (possibly "working") title behind. */ + paneId: number + /** Whether this PTY's pane was the tab's active split pane. Mirrors the + * live path, where only the focused split drives the tab title. */ + drivesTabTitle?: boolean + /** The pane's last known runtime title at park time. Seeds the agent + * tracker so an agent that was working when the pane unmounted still + * fires its completion when it goes idle while parked. */ + initialTitle?: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +const parkedWatcherDisposersByPtyId = new Map void>() + +export function startParkedTerminalByteWatcher( + options: ParkedTerminalByteWatcherOptions +): () => void { + const { ptyId, tabId, worktreeId, paneId, sendInput } = options + const drivesTabTitle = options.drivesTabTitle ?? true + const paneKey = makePaneKey(tabId, options.leafId) + + // Why: one watcher per PTY. A stale watcher from a previous park cycle would + // double-fire bell/completion side effects for the same bytes. + parkedWatcherDisposersByPtyId.get(ptyId)?.() + + let disposed = false + let pendingBellNotification = false + // Why: a watcher-written runtime title (especially into a negative fallback + // slot) has no live pane to overwrite it after reveal; a stale 'working' + // entry would pin worktree status forever. Track writes so dispose can + // clear exactly the slot this watcher touched. + let wroteRuntimeTitleSlot = false + let bellNotificationTimer: ReturnType | null = null + let agentTaskCompleteTimer: ReturnType | null = null + let mode2031ScanTail = '' + const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() + + const clearBellNotificationTimer = (): void => { + if (bellNotificationTimer !== null) { + clearTimeout(bellNotificationTimer) + bellNotificationTimer = null + } + } + + const clearAgentTaskCompleteTimer = (): void => { + if (agentTaskCompleteTimer !== null) { + clearTimeout(agentTaskCompleteTimer) + agentTaskCompleteTimer = null + } + } + + // Why: like the live path, a BEL OS notification only yields when the + // pending completion would itself produce an OS notification. + const hasPendingAgentTaskCompleteNotification = (): boolean => + agentTaskCompleteTimer !== null && + isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + + const scheduleTerminalBellNotification = (): void => { + if (bellNotificationTimer !== null) { + return + } + bellNotificationTimer = setTimeout(() => { + bellNotificationTimer = null + if (disposed) { + pendingBellNotification = false + return + } + if (hasPendingAgentTaskCompleteNotification()) { + return + } + pendingBellNotification = false + dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey }) + }, PARKED_NOTIFICATION_GRACE_MS) + } + + // Why: reuse the transport's output processor so the parked path keeps the + // exact live-path parsing semantics — all-titles ordering, title + // normalization, the cursor-agent native-title drop, the OSC-aware stateful + // bell detector, and the working/idle agent tracker. + const processor = createPtyOutputProcessor({ + // Why: an agent that was already working at park time must still produce + // a working→idle transition; the fresh tracker would otherwise start cold + // and never fire the completion entry point while parked. + ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), + onTitleChange: (title) => { + const state = useAppStore.getState() + wroteRuntimeTitleSlot = true + state.setRuntimePaneTitle(tabId, paneId, title) + if (drivesTabTitle) { + state.updateTabTitle(tabId, title) + } + }, + onBell: () => { + const state = useAppStore.getState() + state.markWorktreeUnread(worktreeId) + state.markTerminalTabUnread(tabId) + if (state.settings?.experimentalTerminalAttention === true) { + state.markTerminalPaneUnread(paneKey) + } + // Why: agent CLIs often emit BEL in the same completion burst as their + // working→idle title change. Delay only the OS notification so the + // richer agent-task-complete notification can win (live-path parity). + pendingBellNotification = true + if (!hasPendingAgentTaskCompleteNotification()) { + scheduleTerminalBellNotification() + } + }, + onAgentBecameIdle: (title) => { + const state = useAppStore.getState() + // Why: mirrors pty-connection — null settings means "not hydrated yet"; + // a spurious timestamp is harmless while a dropped one loses the timer. + if ( + isClaudeAgent(title) && + (state.settings === null || state.settings.promptCacheTimerEnabled) + ) { + state.setCacheTimerStartedAt(paneKey, Date.now()) + } + if (!isAgentTaskCompleteTrackingEnabled(state)) { + return + } + clearAgentTaskCompleteTimer() + agentTaskCompleteTimer = setTimeout(() => { + agentTaskCompleteTimer = null + if (disposed) { + return + } + // Why: the completion supersedes a concurrent BEL so each completion + // burst yields exactly one OS notification, same as the live path. + pendingBellNotification = false + clearBellNotificationTimer() + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey, + ...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + ? {} + : { suppressOsNotification: true }) + }) + }, PARKED_NOTIFICATION_GRACE_MS) + }, + onAgentBecameWorking: () => { + // Why: a new API call refreshes the prompt-cache TTL, so clear any + // running countdown; it restarts when the agent next becomes idle. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + clearAgentTaskCompleteTimer() + if (pendingBellNotification) { + scheduleTerminalBellNotification() + } + }, + onAgentExited: () => { + // Why: title reverting to a plain shell means the agent session ended; + // a stale countdown must not survive in the sidebar while parked. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + } + }) + + const respondToMode2031Subscribe = (data: string): void => { + const scan = scanMode2031Sequences(mode2031ScanTail, data) + mode2031ScanTail = scan.tail + if (!scan.subscribe) { + return + } + // Why: no xterm exists while parked, so nothing answers the DECSET 2031 + // subscription. Reply out-of-band so TUIs that subscribe while parked + // still learn the theme before the pane is ever revealed. + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + } + + const unsubscribe = subscribeToPtyData(ptyId, (data) => { + // Why: empty pane callbacks — the watcher wants only the parser side + // effects; there is no xterm to deliver bytes to. + processor.processData(data, {}) + respondToMode2031Subscribe(data) + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + } + }) + + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + unsubscribe() + // Why: cancels the deferred side-effect drain, stale-title timer, and + // tracker/bell-detector state so the watcher cannot fire after the + // revealed pane's live parsers take over. + processor.clearAccumulatedState() + clearBellNotificationTimer() + clearAgentTaskCompleteTimer() + pendingBellNotification = false + // Why: the store merge never deletes title slots, so a watcher-written + // entry would strand after reveal (the revealing pane re-registers under + // its own pane id) and could pin worktree status 'working'. The revealed + // pane repopulates its slot via its own title flow. + if (wroteRuntimeTitleSlot) { + wroteRuntimeTitleSlot = false + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) { + parkedWatcherDisposersByPtyId.delete(ptyId) + } + } + parkedWatcherDisposersByPtyId.set(ptyId, dispose) + return dispose +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 93e28bed670..7edf7a05bda 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -64,7 +64,12 @@ type PtyOutputProcessorOptions = Pick< | 'onAgentBecameWorking' | 'onAgentExited' | 'onAgentStatus' -> +> & { + /** Seed for processors that start mid-session (parked-tab byte watchers): + * the pane's last known title, so a working agent that finishes while the + * processor owns the stream still yields a working→idle transition. */ + initialAgentTitle?: string +} type ProcessPtyOutputOptions = { replayingBufferedData?: boolean @@ -85,7 +90,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle, onAgentBecameWorking, onAgentExited, - onAgentStatus + onAgentStatus, + initialAgentTitle }: PtyOutputProcessorOptions): { processData: ( data: string, @@ -100,7 +106,11 @@ export function createPtyOutputProcessor({ } { const bellDetector = createBellDetector() const processAgentStatusChunk = createAgentStatusOscProcessor() - let lastEmittedTitle: string | null = null + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session processor behaves as if it had observed the + // pane's last live title — full parity with the live path it replaces. + let lastEmittedTitle: string | null = + initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null let staleTitleTimer: ReturnType | null = null let sideEffectDrainTimer: ReturnType | null = null let pendingSideEffects: PendingPtySideEffect[] = [] @@ -113,7 +123,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle?.(title) }, onAgentBecameWorking, - onAgentExited + onAgentExited, + initialAgentTitle ) : null diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts new file mode 100644 index 00000000000..40ecab2f5a5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_TAB_HOT_RETAIN_MS, + TERMINAL_WORKTREE_HOT_RETAIN_MS, + TERMINAL_WORKTREE_PARK_DELAY_MS, + canParkTerminalTabRenderer, + canParkTerminalWorktreeRenderers, + getTerminalTabColdParkRecheckDelayMs, + getTerminalWorktreeColdParkRecheckDelayMs, + isSnapshotBackedTerminalPty, + selectColdParkedTerminalTabs, + selectColdParkedTerminalWorktrees +} from './terminal-hidden-view-parking' + +describe('isSnapshotBackedTerminalPty', () => { + it('allows local daemon sessions owned by the worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true) + expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true) + }) + + // Why: separator-less ids ('1', '2', 'pty-local-detached') come from the + // daemon-fail-open LocalPtyProvider and have no daemon session model — + // revealing a parked pane would silently respawn a fresh shell, so they + // must not count as snapshot-backed (changed from the ported prior art). + it('rejects separator-less local PTY ids that lack a daemon session model', () => { + expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false) + }) + + it('rejects tabs that do not have a PTY yet', () => { + expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false) + }) + + it('rejects daemon sessions owned by another worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false) + }) + + it('rejects SSH and remote runtime PTY handles', () => { + expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false) + }) +}) + +describe('canParkTerminalWorktreeRenderers', () => { + const hiddenSinceMs = 1_000 + const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + } + + it('parks hidden local terminal renderers after the idle delay', () => { + expect(canParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + it('never parks when the settings kill switch disables parking', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + parkingEnabled: false, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10 + }) + ).toBe(false) + }) + + it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false) + expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe( + false + ) + expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1 + }) + ).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + const shortDelayArgs = { ...base, coldParkDelayMs: 100 } + expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) + + it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }, + { id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' } + ] + }) + ).toBe(false) + }) + + it('keeps renderers mounted while a tab has startup or activation work pending', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } } + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true } + ] + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 } + ] + }) + ).toBe(false) + }) +}) + +describe('canParkTerminalTabRenderer', () => { + const hiddenSinceMs = 1_000 + const base = { + worktreeId: 'wt-1', + terminalTab: { + id: 'tab-1', + ptyId: 'wt-1@@session-1', + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + }, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + } + + it('parks an idle hidden local tab and honors the kill switch', () => { + expect(canParkTerminalTabRenderer(base)).toBe(true) + expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 }) + ).toBe(false) + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) +}) + +describe('selectColdParkedTerminalWorktrees', () => { + const nowMs = 500_000 + + function localCandidate(worktreeId: string, hiddenSinceMs: number) { + return { + worktreeId, + terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }], + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps recent hidden local worktrees hot up to the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['wt-3'])) + }) + + it('cold-parks aged local worktrees even when under the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 4 + }) + + expect(selected).toEqual(new Set(['wt-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park terminals without local snapshot recovery', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + { + ...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }] + }, + { + ...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }] + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['wt-local'])) + }) + + it('keeps visible, measuring, portaled, and pending terminals mounted', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + { + ...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + isVisible: true + }, + { + ...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + shouldMeasureHiddenWorktree: true + }, + { + ...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + { + ...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [ + { + id: 'tab-activation', + ptyId: 'wt-activation@@session-1', + pendingActivationSpawn: true + } + ] + }, + localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS) + ], + pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('selectColdParkedTerminalTabs', () => { + const nowMs = 500_000 + + function localTab(id: string, hiddenSinceMs: number) { + return { + id, + ptyId: `wt-1@@session-${id}`, + pendingActivationSpawn: false, + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps visible and recent inactive terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true }, + localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest inactive local tabs beyond the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['tab-3'])) + }) + + it('cold-parks aged inactive local tabs even when under the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 12 + }) + + expect(selected).toEqual(new Set(['tab-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park inactive terminal tabs without local snapshot recovery', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'ssh:ssh-1@@pty-1' + }, + { + ...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'remote:env-1@@terminal-1' + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['tab-local'])) + }) + + it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { + ...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + pendingActivationSpawn: true + } + ], + pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('getTerminalWorktreeColdParkRecheckDelayMs', () => { + it('returns the next cold-park policy deadline', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) + +describe('getTerminalTabColdParkRecheckDelayMs', () => { + it('returns the next terminal-tab cold-park policy deadline', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts new file mode 100644 index 00000000000..1421e8ac53d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -0,0 +1,285 @@ +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' + +// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab +// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible +// working set warm for 5 minutes beyond that. +export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4 +export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS +export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12 + +// Why: tests override these per call (instead of process.env reads inside the +// module) to shrink the 30s hysteresis to test-friendly durations. +export type TerminalColdParkPolicyOverrides = { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number +} + +export type ColdParkableTerminalTab = Pick + +export type TerminalWorktreeColdParkCandidate = { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & { + isVisible: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +function getPendingActivationSpawnCount(value: boolean | number | undefined): number { + if (value === true) { + return 1 + } + return typeof value === 'number' && value > 0 ? value : 0 +} + +// Why: parking relies on the daemon model snapshot to re-hydrate. Remote +// runtime and SSH PTYs have no local snapshot in this phase, and a session id +// minted for another worktree reattaches through a path parking cannot replay. +export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean { + if (!ptyId) { + return false + } + if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { + return false + } + // Why: separator-less ids come from the daemon-fail-open LocalPtyProvider; + // they have no daemon session model, so revealing a parked pane would + // silently respawn a fresh shell instead of restoring the snapshot. + const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR) + return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId +} + +export function canParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + // Why: callers pass settings.terminalHiddenViewParking !== false — the + // design-doc kill switch that disables parking entirely. + parkingEnabled: boolean + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number +}): boolean { + if ( + !args.parkingEnabled || + args.isVisible || + args.shouldMeasureHiddenWorktree || + args.hasActivityTerminalPortal || + args.hiddenSinceMs === null + ) { + return false + } + if ( + args.nowMs - args.hiddenSinceMs < + (args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS) + ) { + return false + } + return args.terminalTabs.every((tab) => { + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) + }) +} + +export function canParkTerminalTabRenderer(args: { + worktreeId: string + terminalTab: TerminalTabColdParkCandidate + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + coldParkDelayMs?: number +}): boolean { + const tab = args.terminalTab + if ( + !args.parkingEnabled || + tab.isVisible || + tab.hasActivityTerminalPortal || + tab.hiddenSinceMs === null + ) { + return false + } + if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) { + return false + } + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) +} + +type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number } + +// Why: hot-retain keeps the most recently hidden ids warm up to the limit; +// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id +// so the selection is deterministic. +function selectIdsBeyondHotRetain( + candidates: ColdParkRetainCandidate[], + args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number } +): Set { + const coldParkedIds = new Set() + const retainedCandidates: ColdParkRetainCandidate[] = [] + for (const candidate of candidates) { + if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) { + coldParkedIds.add(candidate.id) + } else { + retainedCandidates.push(candidate) + } + } + retainedCandidates.sort((a, b) => { + const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs + return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta + }) + for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) { + coldParkedIds.add(candidate.id) + } + return coldParkedIds +} + +export function selectColdParkedTerminalWorktrees( + args: { + worktrees: readonly TerminalWorktreeColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const worktree of args.worktrees) { + if ( + worktree.hiddenSinceMs === null || + !canParkTerminalWorktreeRenderers({ + ...worktree, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT + }) +} + +export function selectColdParkedTerminalTabs( + args: { + worktreeId: string + terminalTabs: readonly TerminalTabColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const tab of args.terminalTabs) { + if ( + tab.hiddenSinceMs === null || + !canParkTerminalTabRenderer({ + worktreeId: args.worktreeId, + terminalTab: tab, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT + }) +} + +// Why: parking decisions change only at the cold-park and hot-retain +// deadlines, so callers schedule one recheck at the next deadline instead of +// polling. +function nextColdParkDeadlineDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs: number + hotRetainMs: number +}): number | null { + if (!args.parkingEnabled || args.hiddenSinceMs === null) { + return null + } + const pendingDeadlines = [ + args.hiddenSinceMs + args.coldParkDelayMs, + args.hiddenSinceMs + args.hotRetainMs + ].filter((deadlineMs) => deadlineMs > args.nowMs) + return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs +} + +export function getTerminalWorktreeColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS + }) +} + +export function getTerminalTabColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts new file mode 100644 index 00000000000..21cac2cd0c3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -0,0 +1,483 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const WORKTREE_ID = 'repo::/worktree' +const OTHER_WORKTREE_ID = 'repo::/other-worktree' +const TAB_ID = 'tab-1' +const PTY_ID = `${WORKTREE_ID}@@session-1` +const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +type StartedWatcher = { + options: ParkedTerminalByteWatcherOptions + dispose: ReturnType +} + +const startedWatchers: StartedWatcher[] = [] +const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => { + const dispose = vi.fn() + startedWatchers.push({ options, dispose }) + return dispose +}) + +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => + startParkedTerminalByteWatcher(options) +})) + +type ExitSubscription = { + ptyId: string + callback: (code: number) => void + unsubscribe: ReturnType +} + +const exitSubscriptions: ExitSubscription[] = [] +const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => { + const unsubscribe = vi.fn() + exitSubscriptions.push({ ptyId, callback, unsubscribe }) + return unsubscribe +}) + +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) => + subscribeToPtyExit(ptyId, callback) +})) + +type MockStoreState = { + terminalLayoutsByTabId: Record< + string, + { + root: unknown + activeLeafId: string | null + expandedLeafId: string | null + ptyIdsByLeafId?: Record + } + > + runtimePaneTitlesByTabId: Record> + clearRuntimePaneTitle: ReturnType +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +import { + canWatcherCoverParkedTerminalTab, + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + fallbackParkedPaneCandidates, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +const ptyWrite = vi.fn() +const originalWindow = (globalThis as { window?: unknown }).window + +function capturePanes( + panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[], + args?: { tabId?: string; worktreeId?: string } +): void { + captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes) +} + +function syncParked(args?: { + worktreeId?: string + tabs?: { id: string; ptyId: string | null }[] + parkedTabIds?: Iterable +}): void { + syncParkedTerminalTabWatchers({ + worktreeId: args?.worktreeId ?? WORKTREE_ID, + tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }], + parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID]) + }) +} + +describe('terminal-parked-tab-watchers', () => { + beforeEach(() => { + mockStoreState = { + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + clearRuntimePaneTitle: vi.fn() + } + ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } + }) + + afterEach(() => { + // Module-level registries persist across tests; clear them through the + // public prune path so each test starts from an empty parked state. + pruneParkedTerminalWatchers(new Set()) + startedWatchers.length = 0 + exitSubscriptions.length = 0 + vi.clearAllMocks() + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[0].options).toMatchObject({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: 1, + drivesTabTitle: true + }) + expect(startedWatchers[1].options).toMatchObject({ + ptyId: SECOND_PTY_ID, + paneId: 2, + drivesTabTitle: false + }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + startedWatchers[0].options.sendInput('\x1b[?2031;1$y') + expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y') + }) + + it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID }) + }) + + it('never starts watchers for remote-runtime or SSH PTYs', () => { + capturePanes([ + { ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] }) + + expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled() + // Why: the tab is still tracked as parked so debug introspection + // (window.__terminalParkingDebug) reflects every parked tab. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('keeps existing watchers across repeated syncs of the same parked state', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes the watcher and exit subscription when the tab unparks', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ parkedTabIds: [] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes the watcher when the tab closes while parked', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ tabs: [], parkedTabIds: [TAB_ID] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes a PTY watcher when that PTY exits while parked', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID) + exited?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // The tab itself is still parked, only the exited PTY's watcher is gone. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('seeds each watcher with the pane slot last known runtime title', () => { + mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature') + expect(startedWatchers[1].options.initialTitle).toBeUndefined() + }) + + it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + disposeParkedTerminalWatchersForPtyIds([PTY_ID]) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // Why: the entry survives so a sleeping parked tab cannot restart a + // watcher against its stale PTY ids before wake re-mints them. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + syncParked() + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + }) + + it('restarts watchers from store layout when the tab PTY was re-minted', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + const remintedPtyId = `${WORKTREE_ID}@@session-after-wake` + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId } + } + syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID }) + }) + + it('scopes sync disposal to the given worktree', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9` + capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], { + tabId: 'tab-other', + worktreeId: OTHER_WORKTREE_ID + }) + syncParked({ + worktreeId: OTHER_WORKTREE_ID, + tabs: [{ id: 'tab-other', ptyId: otherPtyId }], + parkedTabIds: ['tab-other'] + }) + + // Unparking everything in the other worktree must not touch this one. + syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + disposeParkedTerminalWatchersForWorktree(WORKTREE_ID) + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID])) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + describe('shouldDeferParkedPtyExitTabClose', () => { + const closeTab = vi.fn() + + // Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab. + function hostOnPtyExit(tabId: string, ptyId: string): void { + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } + closeTab(tabId) + } + + it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + // The dead leaf's runtime-title slot cannot pin worktree status. + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1) + }) + + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('keeps exit→closeTab parity when the tab is not parked', () => { + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('closes the tab when the last surviving leaf of a parked split exits', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + // First leaf dies: deferred, then its exit sidecar drops the watcher. + hostOnPtyExit(TAB_ID, PTY_ID) + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + expect(closeTab).not.toHaveBeenCalled() + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + }) + + describe('canWatcherCoverParkedTerminalTab', () => { + it('rejects a tab with no unmount capture and no layout snapshot', () => { + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts a current capture whose panes are all snapshot-backed', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects a capture containing a legacy non-UUID leaf id', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('rejects a capture containing a PTY without snapshot backing', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts layout-derived candidates when the capture is stale', () => { + capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects layout-derived candidates missing a leaf PTY binding', () => { + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + }) +}) + +describe('fallbackParkedPaneCandidates', () => { + it('returns nothing without a layout snapshot', () => { + expect( + fallbackParkedPaneCandidates( + { id: TAB_ID, ptyId: PTY_ID }, + { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } + ) + ).toEqual([]) + }) + + it('reuses the single runtime-title slot for a single-pane tab', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } + } as never) + ).toEqual([ + { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, + { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts new file mode 100644 index 00000000000..4f9bf0f77cc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -0,0 +1,243 @@ +/** + * Parked terminal tab watcher lifecycle. + * + * Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer + * byte parsers. This module owns the pane-less replacement: it remembers the + * unmounted panes' identities (pane id / leaf id), starts one + * parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on + * reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps + * live in terminal-parked-watcher-registry so the terminals store slice can + * dispose watchers without importing this store-coupled module. + * See docs/reference/terminal-hidden-view-parking.md. + */ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids' +import { subscribeToPtyExit } from './pty-dispatcher' +import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' +import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' +import { + capturedPanesByTabId, + disposeParkedTabWatchers, + parkedWatchersByTabId, + type ParkedTerminalPaneCapture +} from './terminal-parked-watcher-registry' + +// Why: re-exported so park wiring keeps one import surface; the registry +// split exists only to break the store-slice import cycle. +export { + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers +} from './terminal-parked-watcher-registry' +export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' + +export type ParkableTerminalTabModel = Pick + +type ParkedPaneFallbackState = { + terminalLayoutsByTabId: ReturnType['terminalLayoutsByTabId'] + runtimePaneTitlesByTabId: ReturnType['runtimePaneTitlesByTabId'] +} + +// Why: if no unmount capture exists (or it predates a PTY respawn), derive +// pane identities from the persisted layout snapshot. Numeric pane ids are +// unknown here: reuse the single existing runtime-title slot when unambiguous +// so a stale "working" title still gets overwritten, otherwise use negative +// slots that can never collide with real PaneManager ids. +export function fallbackParkedPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const layout = state.terminalLayoutsByTabId[tab.id] + const leafIds = collectLeafIdsInOrder(layout?.root) + if (leafIds.length === 0) { + return [] + } + const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {} + const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}) + const reusableSlot = + leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null + return leafIds.map((leafId, index) => ({ + ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null), + paneId: reusableSlot ?? -(index + 1), + leafId, + drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0 + })) +} + +// Why: unmount captures and layout fallbacks must resolve identically for the +// watcher start path and the park-eligibility coverage check, or a tab could +// pass the check and then start with different (uncoverable) candidates. +function resolveParkedTerminalPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const captured = capturedPanesByTabId.get(tab.id) + // Why: a capture that no longer mentions the tab's current PTY is stale + // (the PTY was re-minted since the unmount); fall back to the layout. + const capturedIsCurrent = + captured !== undefined && + captured.panes.length > 0 && + (tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId)) + return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state) +} + +/** + * Whether the parked byte watchers can fully cover this tab's PTYs (some + * candidate exists and every candidate has a snapshot-backed PTY bound to a + * valid leaf). Hosts must refuse to park a tab that fails this check — + * parking it would silently drop bell/title/completion side effects, the + * exact failure that sank the first parking attempt. + */ +export function canWatcherCoverParkedTerminalTab( + worktreeId: string, + tab: ParkableTerminalTabModel +): boolean { + const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()) + return ( + panes.length > 0 && + panes.every( + (pane) => + pane.ptyId !== null && + isTerminalLeafId(pane.leafId) && + isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) + ) + ) +} + +function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void { + const state = useAppStore.getState() + const panes = resolveParkedTerminalPaneCandidates(tab, state) + const disposersByPtyId = new Map void>() + const paneIdByPtyId = new Map() + for (const pane of panes) { + const ptyId = pane.ptyId + // Why: the park policy already excludes non-snapshot-backed PTYs, but the + // tab model can change between the park decision and this effect — guard + // again so remote-runtime/SSH PTYs never get a local watcher. Legacy + // non-UUID leaf ids are skipped because makePaneKey throws on them. + if ( + !ptyId || + disposersByPtyId.has(ptyId) || + !isTerminalLeafId(pane.leafId) || + !isSnapshotBackedTerminalPty(ptyId, worktreeId) + ) { + continue + } + const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId] + const disposeWatcher = startParkedTerminalByteWatcher({ + ptyId, + tabId: tab.id, + worktreeId, + leafId: pane.leafId, + paneId: pane.paneId, + drivesTabTitle: pane.drivesTabTitle, + // Why: seed the watcher's agent tracker with the pane's last known + // title so an agent already working at park time still notifies when + // it finishes while parked. + ...(initialTitle !== undefined ? { initialTitle } : {}), + // Why: no pane transport exists while parked; write straight to the + // PTY, the same channel background agent launches use. + sendInput: (data) => window.api.pty.write(ptyId, data) + }) + // Why: a PTY that exits while parked has no pane to run exit cleanup; at + // minimum its watcher must not outlive it. + const unsubscribeExit = subscribeToPtyExit(ptyId, () => { + disposersByPtyId.get(ptyId)?.() + disposersByPtyId.delete(ptyId) + // Why: with the last watcher gone there is nothing left to watch or + // dispose; dropping the entry keeps the registry bounded to parked + // tabs that still hold live PTYs. + const entry = parkedWatchersByTabId.get(tab.id) + if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) { + parkedWatchersByTabId.delete(tab.id) + } + }) + paneIdByPtyId.set(ptyId, pane.paneId) + disposersByPtyId.set(ptyId, () => { + unsubscribeExit() + disposeWatcher() + }) + } + // Why: tracked even with zero watchers so parked-state introspection + // (window.__terminalParkingDebug) reflects every parked tab. + parkedWatchersByTabId.set(tab.id, { + worktreeId, + tabPtyId: tab.ptyId, + paneIdByPtyId, + disposersByPtyId + }) +} + +/** + * Hosts call this from their onPtyExit handlers before closing the tab. + * Returns true when the close must be deferred: a parked tab has no + * PaneManager to promote split siblings, so the live exit path degenerates to + * "close the whole tab" — which would kill the surviving sibling panes. The + * reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs + * return false so exit→closeTab parity is preserved. Also clears the dead + * leaf's runtime-title slot so a stale title cannot pin worktree status. + */ +export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return false + } + const paneId = entry.paneIdByPtyId.get(ptyId) + if (paneId !== undefined) { + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + const remaining = entry.disposersByPtyId.size + if (remaining === 0) { + return false + } + // Why: this runs from the PTY exit handler, before the exit sidecar above + // removes the dead PTY's watcher — so the watcher count still includes the + // exiting PTY. More than one watcher (or an exit for an unwatched PTY) + // means live sibling leaves remain. + return remaining > 1 || !entry.disposersByPtyId.has(ptyId) +} + +/** + * Reconciles watchers for one worktree against its rendered parked set. + * Callers run this from an effect keyed on the committed render state, so + * disposal lands in the same effect flush as a reveal remount (before any + * PTY data IPC can be delivered) and start lands after the park unmount. + */ +export function syncParkedTerminalTabWatchers(args: { + worktreeId: string + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet +}): void { + const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId !== args.worktreeId) { + continue + } + if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) { + disposeParkedTabWatchers(tabId) + } + } + // Why: captures for closed tabs have no future park/reveal; drop them so + // the registry stays bounded by live tabs. + for (const [tabId, capture] of capturedPanesByTabId) { + if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { + capturedPanesByTabId.delete(tabId) + } + } + for (const tab of args.tabs) { + if (!args.parkedTabIds.has(tab.id)) { + continue + } + const entry = parkedWatchersByTabId.get(tab.id) + if (entry && entry.tabPtyId !== tab.ptyId) { + disposeParkedTabWatchers(tab.id) + } + if (!parkedWatchersByTabId.has(tab.id)) { + startParkedTabWatchers(args.worktreeId, tab) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts new file mode 100644 index 00000000000..d6dd76e817a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -0,0 +1,107 @@ +/** + * Parked terminal watcher registry (store-free bookkeeping). + * + * Why a separate module: shutdownWorktreeTerminals (a store slice) must + * synchronously dispose parked watchers, but the watcher lifecycle module + * imports the store — a slice importing it would re-enter store creation + * mid-evaluation. Keeping the maps and pure disposal here lets the slice + * import cycle-free, mirroring how pty-dispatcher exports its handler maps. + */ + +export type ParkedTerminalPaneCapture = { + ptyId: string | null + /** PaneManager numeric pane id the live pane used for runtime titles. */ + paneId: number + /** Stable terminal-layout leaf UUID (paneKey attribution). */ + leafId: string + drivesTabTitle: boolean +} + +export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] } + +export const capturedPanesByTabId = new Map() + +// Why: PaneManager pane ids die with the unmounted pane, but the watcher must +// keep writing the exact runtime-title slots the live pane used — a different +// slot would strand a stale "working" title that pins worktree status. +// TerminalPane unmount records the identities here for the park wiring. +export function captureParkedTerminalPaneCandidates( + tabId: string, + worktreeId: string, + panes: ParkedTerminalPaneCapture[] +): void { + capturedPanesByTabId.set(tabId, { worktreeId, panes }) +} + +export type ParkedTabWatcherEntry = { + worktreeId: string + /** Tab-level ptyId at watcher start; a change means the PTY was re-minted + * (e.g. wake respawn) and the watchers must restart against fresh ids. */ + tabPtyId: string | null + /** Runtime-title slot each watcher writes, so parked PTY-exit handling can + * clear the dead leaf's slot (no live pane will ever overwrite it). */ + paneIdByPtyId: Map + disposersByPtyId: Map void> +} + +export const parkedWatchersByTabId = new Map() + +export function getParkedTerminalWatcherTabIds(): string[] { + return Array.from(parkedWatchersByTabId.keys()) +} + +export function disposeParkedTabWatchers(tabId: string): void { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return + } + parkedWatchersByTabId.delete(tabId) + for (const dispose of entry.disposersByPtyId.values()) { + dispose() + } + entry.disposersByPtyId.clear() +} + +/** + * Synchronously disposes any parked watcher subscribed to these PTYs. + * shutdownWorktreeTerminals silences the live transports' final teardown + * flush via unregisterPtyDataHandlers, but parked watchers ride the + * dispatcher SIDECAR channel that call does not touch — without this, the + * flush still marks unread and arms notification timers for a worktree that + * is already sleeping or deleted. The tab entries are kept so a sleeping + * parked tab does not restart watchers against its stale PTY ids; wake + * re-mints the ids and the sync path restarts watchers then. + */ +export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void { + for (const entry of parkedWatchersByTabId.values()) { + for (const ptyId of ptyIds) { + const dispose = entry.disposersByPtyId.get(ptyId) + if (dispose) { + entry.disposersByPtyId.delete(ptyId) + dispose() + } + } + } +} + +export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId === worktreeId) { + disposeParkedTabWatchers(tabId) + } + } +} + +/** Drops watchers and captures for worktrees that no longer exist. */ +export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (!liveWorktreeIds.has(entry.worktreeId)) { + disposeParkedTabWatchers(tabId) + } + } + for (const [tabId, capture] of capturedPanesByTabId) { + if (!liveWorktreeIds.has(capture.worktreeId)) { + capturedPanesByTabId.delete(tabId) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts new file mode 100644 index 00000000000..40e226cf5df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null } + +let mockE2EConfig: MockE2EConfig + +vi.mock('@/lib/e2e-config', () => ({ + get e2eConfig() { + return mockE2EConfig + } +})) + +vi.mock('./terminal-parked-tab-watchers', () => ({ + getParkedTerminalWatcherTabIds: () => ['tab-parked'] +})) + +const originalWindow = (globalThis as { window?: unknown }).window + +type TerminalParkingE2EOverridesModule = { + getTerminalParkingPolicyOverrides: () => { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number + } + registerTerminalParkingDebugHandle: () => void +} + +async function importOverridesModule(): Promise { + vi.resetModules() + return import('./terminal-parking-e2e-overrides') +} + +describe('getTerminalParkingPolicyOverrides', () => { + beforeEach(() => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('ignores the delay override outside e2e (exposeStore off)', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({ + coldParkDelayMs: 500, + hotRetainMs: 500 + }) + }) + + it('returns no overrides when no delay is configured', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('registers window.__terminalParkingDebug on import under exposeStore', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const testWindow: { + __terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] } + } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500) + expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked']) + }) + + it('does not register the debug handle outside e2e', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + const testWindow: { __terminalParkingDebug?: unknown } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts new file mode 100644 index 00000000000..1717b170bb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -0,0 +1,34 @@ +import { e2eConfig } from '@/lib/e2e-config' +import { + TERMINAL_TAB_COLD_PARK_DELAY_MS, + type TerminalColdParkPolicyOverrides +} from './terminal-hidden-view-parking' +import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers' + +// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park +// hysteresis and the hot-retain window — recently hidden tabs otherwise sit +// in the hot-retain working set for 5 minutes and never park within a test +// run. Gated on exposeStore so packaged builds ignore stray env vars. +export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides { + const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null + return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 + ? { coldParkDelayMs: delayMs, hotRetainMs: delayMs } + : {} +} + +export function registerTerminalParkingDebugHandle(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + window.__terminalParkingDebug = { + parkDelayMs: + getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + parkedTabIds: () => getParkedTerminalWatcherTabIds() + } +} + +// Why: the parking e2e spec gates on window.__terminalParkingDebug existing +// shortly after launch. This module is statically imported by the park +// wiring, so registering at module load makes the handle visible before any +// tab parks. +registerTerminalParkingDebugHandle() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 7f721fc0d9f..3cc6fcecac3 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -62,6 +62,7 @@ import { getConnectionId } from '@/lib/connection-context' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -1302,6 +1303,19 @@ export function useTerminalPaneLifecycle({ disposable.dispose() } mouseHideDisposables.clear() + // Why: hidden-view parking starts pane-less byte watchers right after + // this unmount; record pane identities before transports detach so the + // watchers write the same runtime-title slots the live panes used. + captureParkedTerminalPaneCandidates( + tabId, + worktreeId, + manager.getPanes().map((capturedPane) => ({ + ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null, + paneId: capturedPane.id, + leafId: capturedPane.leafId, + drivesTabTitle: manager.getActivePane()?.id === capturedPane.id + })) + ) for (const transport of paneTransports.values()) { const ptyId = transport.getPtyId() if ( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts new file mode 100644 index 00000000000..190a5fd5a27 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -0,0 +1,243 @@ +/** + * Per-tab hidden-view parking for TerminalPaneOverlayLayer. + * + * Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck + * timers, parked-set selection) and the parked byte-watcher reconciliation so + * the overlay layer only consumes the final parked tab set when deciding to + * render a slot as null. See docs/reference/terminal-hidden-view-parking.md. + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + findActivityTerminalPortal, + type ActivityTerminalPortalTarget +} from '../activity/activity-terminal-portal' +import { + getTerminalTabColdParkRecheckDelayMs, + selectColdParkedTerminalTabs, + type TerminalTabColdParkCandidate +} from './terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + disposeParkedTerminalWatchersForWorktree, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +type TerminalOverlayTabAssignment = { + groupId: string + isActiveInGroup: boolean +} + +function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + +export function useTerminalTabColdParking(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + isWorktreeActive: boolean + /** Worktree-level park verdict from Terminal.tsx. */ + coldParkTerminalPanes: boolean + /** Hidden-measuring startup probe from Terminal.tsx — the panes must stay + * mounted for their first xterm fit, mirroring the worktree-level guard. */ + shouldMeasureHiddenWorktree: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] +}): ReadonlySet { + const { + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + } = args + const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore( + (state) => state.settings?.terminalHiddenViewParking !== false + ) + const terminalTabHiddenSinceRef = useRef(new Map()) + const terminalTabParkingTimersRef = useRef(new Map()) + const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) + const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( + () => new Set() + ) + + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id)) + const portalTabIds = new Set( + activityTerminalPortals + .filter((portal) => portal.worktreeId === worktreeId) + .map((portal) => portal.tabId) + ) + for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) { + if (!currentTerminalTabIds.has(tabId)) { + terminalTabHiddenSinceRef.current.delete(tabId) + } + } + + const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id) + // Why: hidden-measuring counts as visibility — the startup probe needs + // mounted panes, so the hidden clock must not run during it. + if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) { + terminalTabHiddenSinceRef.current.delete(terminalTab.id) + } else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) { + terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs) + } + return { + id: terminalTab.id, + ptyId: terminalTab.ptyId, + pendingActivationSpawn: terminalTab.pendingActivationSpawn, + isVisible, + hasActivityTerminalPortal, + hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null + } + }) + + const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({ + worktreeId, + terminalTabs: candidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a tab the byte watchers cannot cover (no capture, no layout + // snapshot, legacy leaf ids) must never park — it would go silent for + // bells/titles/completions, the failure that sank the first attempt. + for (const terminalTab of terminalTabs) { + if ( + nextColdParkedTerminalTabIds.has(terminalTab.id) && + !canWatcherCoverParkedTerminalTab(worktreeId, terminalTab) + ) { + nextColdParkedTerminalTabIds.delete(terminalTab.id) + } + } + setColdParkedTerminalTabIds((current) => + haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds) + ? current + : nextColdParkedTerminalTabIds + ) + + for (const candidate of candidates) { + if ( + candidate.isVisible || + candidate.hasActivityTerminalPortal || + nextColdParkedTerminalTabIds.has(candidate.id) + ) { + continue + } + const delayMs = getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const tabId = candidate.id + const timer = window.setTimeout(() => { + timers.delete(tabId) + setTerminalTabParkingRevision((revision) => revision + 1) + }, delayMs) + timers.set(tabId, timer) + } + } + }, [ + activityTerminalPortals, + assignments, + isWorktreeActive, + pendingStartupByTabId, + shouldMeasureHiddenWorktree, + terminalParkingEnabled, + terminalTabParkingRevision, + terminalTabs, + worktreeId + ]) + + // Why: the rendered park verdict — worktree-level park (prop from + // Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and + // the watcher-sync effect must share this exact set so watcher lifecycle + // tracks the committed unmounts. + const parkedTerminalTabIds = useMemo(() => { + const parked = new Set() + for (const terminalTab of terminalTabs) { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = + findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: terminalTab.id + }) !== null + if ( + (coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) && + !hasActivityTerminalPortal && + // Why: the hidden-measuring startup probe needs mounted panes; gate + // here too so the reveal lands in the same render that starts it. + !shouldMeasureHiddenWorktree + ) { + parked.add(terminalTab.id) + } + } + return parked + }, [ + activityTerminalPortals, + assignments, + coldParkTerminalPanes, + coldParkedTerminalTabIds, + isWorktreeActive, + shouldMeasureHiddenWorktree, + terminalTabs, + worktreeId + ]) + + // Why: runs in the same effect flush as the commit that parked/revealed the + // panes — watcher disposal therefore lands before any PTY data IPC can + // reach a freshly remounted pane, and watcher start lands after the parked + // pane's unmount capture. + useEffect(() => { + syncParkedTerminalTabWatchers({ + worktreeId, + tabs: terminalTabs, + parkedTabIds: parkedTerminalTabIds + }) + }, [parkedTerminalTabIds, terminalTabs, worktreeId]) + + useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) + + return parkedTerminalTabIds +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index dc81e50d3ae..9cc3de97fb3 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -19,6 +19,10 @@ declare global { interface Window { __paneManagers?: Map __onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps + __terminalParkingDebug?: { + parkDelayMs: number + parkedTabIds: () => string[] + } } } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c549ca7d167..20cecb2b112 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -33,6 +33,10 @@ import { ensurePtyDispatcher, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' +// Why: import the store-free registry, not terminal-parked-tab-watchers — +// that module imports @/store, and a slice importing it would re-enter store +// creation before this slice finishes evaluating. +import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -1528,6 +1532,11 @@ export const createTerminalSlice: StateCreator // the "phantom alerts" users see after shutting down worktrees. // Removing the data handlers first ensures the final flush is a no-op. unregisterPtyDataHandlers(ptyIds) + // Why: parked-tab byte watchers observe the same flush through dispatcher + // sidecars, which the call above does not touch — dispose them now or a + // just-slept/deleted worktree still gets unread marks and delayed + // bell/completion OS notifications from its teardown bytes. + disposeParkedTerminalWatchersForPtyIds(ptyIds) // Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every // pane's serializer buffer into terminalLayoutsByTabId[tab].buffersByLeafId diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index d6c7ba8176a..57ea84d9d90 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -216,14 +216,20 @@ export function clearWorkingIndicators(title: string): string { export function createAgentStatusTracker( onBecameIdle: (title: string) => void, onBecameWorking?: () => void, - onAgentExited?: () => void + onAgentExited?: () => void, + initialTitle?: string ): { handleTitle: (title: string) => void /** Clear accumulated status so a stale working→idle transition cannot fire * after the owning transport is torn down. */ reset: () => void } { - let lastStatus: AgentStatus | null = null + // Why: trackers that start mid-session (parked-tab byte watchers) must seed + // the last known status, or an agent that was working when its pane + // unmounted never produces a working→idle transition. Seeding sets state + // only — no callbacks fire. + let lastStatus: AgentStatus | null = + initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null return { handleTitle(title: string): void { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0c1d9ef8e0a..bb5ee473647 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -273,6 +273,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, + terminalHiddenViewParking: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts index 54da72a45f0..e8f34d6bf51 100644 --- a/src/shared/e2e-config.ts +++ b/src/shared/e2e-config.ts @@ -3,23 +3,34 @@ export type E2EConfig = { headless: boolean exposeStore: boolean userDataDir: string | null + /** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the + * terminal hidden-view parking delays. null means use production timing. */ + terminalParkingDelayMs: number | null } type E2EConfigInput = { headless?: boolean exposeStore?: boolean userDataDir?: string | null + terminalParkingDelayMs?: number | null } export function createE2EConfig(input: E2EConfigInput): E2EConfig { const userDataDir = input.userDataDir?.trim() || null const headless = Boolean(input.headless) const exposeStore = Boolean(input.exposeStore) + const terminalParkingDelayMs = + typeof input.terminalParkingDelayMs === 'number' && + Number.isFinite(input.terminalParkingDelayMs) && + input.terminalParkingDelayMs > 0 + ? input.terminalParkingDelayMs + : null return { enabled: headless || exposeStore || userDataDir !== null, headless, exposeStore, - userDataDir + userDataDir, + terminalParkingDelayMs } } diff --git a/src/shared/types.ts b/src/shared/types.ts index a3cf0c113a0..cf9311a7ef6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2191,6 +2191,11 @@ export type GlobalSettings = { * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */ terminalScopeHistoryByWorktree: boolean + /** Kill switch for hidden terminal view parking — unmounting long-hidden + * terminal panes while a pane-less watcher keeps PTY side effects alive. + * Defaults to true; `false` disables parking entirely. + * See docs/reference/terminal-hidden-view-parking.md. */ + terminalHiddenViewParking?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index b68fc00d82d..00a99355852 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -43,6 +43,10 @@ type OrcaTestFixtures = { // Why: most E2E specs need a ready project before assertions start. Golden // first-run specs opt out so they can prove the zero-project onboarding path. seedTestRepo: boolean + // Why: spec-scoped launch env. Mutating process.env at spec module scope + // leaks into other specs when a worker reloads files without replaying the + // first spec's afterAll; per-test launch env cannot leak. + orcaAppExtraEnv: Record } type OrcaWorkerFixtures = { @@ -188,7 +192,7 @@ export const test = base.extend({ ], // Test-scoped: one Electron app per test - electronApp: async ({ dismissOnboarding }, provideFixture, testInfo) => { + electronApp: async ({ dismissOnboarding, orcaAppExtraEnv }, provideFixture, testInfo) => { const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) @@ -249,7 +253,8 @@ export const test = base.extend({ !cleanEnv.ORCA_RELAY_PATH ? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') } : {}), - ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) + ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }), + ...orcaAppExtraEnv } }) forwardElectronProcessLogs(app, testInfo) @@ -264,6 +269,7 @@ export const test = base.extend({ // Default: dismiss the onboarding overlay so it doesn't intercept clicks. dismissOnboarding: [true, { option: true }], seedTestRepo: [true, { option: true }], + orcaAppExtraEnv: [{}, { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/terminal-hidden-view-parking.spec.ts b/tests/e2e/terminal-hidden-view-parking.spec.ts new file mode 100644 index 00000000000..aa97ba4bf25 --- /dev/null +++ b/tests/e2e/terminal-hidden-view-parking.spec.ts @@ -0,0 +1,409 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getWorktreeTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: the parking wiring registers this handle (dev/exposeStore builds only) +// so tests can detect that hidden-view parking is compiled in and which delay +// override the app actually applied. +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { + parkDelayMs?: number + } +} + +// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain +// window. The fast-park override must be scoped to THIS spec's app launches — +// mutating process.env at module scope leaked into later specs when a worker +// reloaded files without replaying this file's afterAll. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +const PARKED_FRAME_SCRIPT_DELAY_MS = 750 +const PARKED_FRAME_COUNT = 25 + +function parkedTuiFrame(runId: string, frame: number): string { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const rows = [ + '╭────────────────────────────────────────────────────────────────────╮', + `│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`, + '├──────────────┬──────────────────────┬──────────────────────────────┤', + `│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`, + `│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`, + `│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`, + '╰──────────────┴──────────────────────┴──────────────────────────────╯', + `PARKED_RESTORE_FINAL_${runId}_${frame}` + ] + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[10;18H\x1b[?25h', + '\x1b[?2026l' + ].join('') +} + +function writeParkedFrameScript(scriptPath: string, runId: string): void { + const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) => + parkedTuiFrame(runId, frame) + ) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +async function readParkingWiring( + page: Page +): Promise<{ present: boolean; parkDelayMs: number | null }> { + return page.evaluate(() => { + const debug = (window as ParkingDebugWindow).__terminalParkingDebug + return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null } + }) +} + +// Why: the spec lands ahead of the feature wiring. Skip (rather than fail) +// when the app under test does not expose the parking debug handle so this +// file is safe to merge in any order with the wiring branch. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let wiring = await readParkingWiring(page) + while (!wiring.present && Date.now() < deadline) { + await page.waitForTimeout(250) + wiring = await readParkingWiring(page) + } + test.skip( + !wiring.present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a +// missing manager is the observable signal that the tab's xterm was parked. +async function waitForTabParked(page: Page, tabId: string): Promise { + const parkWaitStartedAt = Date.now() + await expect + .poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) + return Date.now() - parkWaitStartedAt +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function getUnreadTerminalTabIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.keys(store.getState().unreadTerminalTabs) + }) +} + +async function isWorktreeUnread(page: Page, worktreeId: string): Promise { + return page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + return false + } + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((candidate) => candidate.id === worktreeId) + return worktree?.isUnread === true + }, worktreeId) +} + +async function getTerminalTabTitle( + page: Page, + worktreeId: string, + tabId: string +): Promise { + const tabs = await getWorktreeTabs(page, worktreeId) + return tabs.find((tab) => tab.id === tabId)?.title ?? null +} + +async function hasPendingStartupCommand(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + return false + } + return store.getState().pendingStartupByTabId[tabId] !== undefined + }, tabId) +} + +type ParkableTabSetup = { + worktreeId: string + tabAId: string + tabAPtyId: string +} + +// Why: every scenario starts from the same shape — tab A live in the active +// worktree; callers then create more tabs on top so tab A goes hidden. +async function setUpParkableTabA(page: Page): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1) + const tabAPtyId = tabASnapshot.panes[0]?.ptyId + if (!tabAPtyId) { + throw new Error('parking spec tab A did not bind a PTY') + } + return { + worktreeId, + tabAId: tabASnapshot.tabId, + tabAPtyId + } +} + +test.describe('Terminal hidden view parking', () => { + test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}` + const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`) + writeParkedFrameScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'rich TUI final frame did not render while tab A was visible' + }) + .toContain(finalMarker) + + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId) + const wiring = await readParkingWiring(orcaPage) + testInfo.annotations.push({ + type: 'terminal-parking', + description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}` + }) + + // Why: parking must be scoped to the hidden tab — the visible tab keeps + // a live pane manager and xterm. + const tabBState = await readTerminalTabViewState(orcaPage, tabBId) + expect(tabBState.hasManager).toBe(true) + expect(tabBState.paneCount).toBeGreaterThan(0) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealedSnapshot.tabId).toBe(tabAId) + // Why: parking only tears down the renderer view; the PTY session must + // survive so reveal reattaches to the same shell. + expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked rich TUI frame did not restore when the tab was revealed' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + + // Why: the typed marker only appears joined in command *output*, so this + // proves the revealed terminal accepts input end-to-end, not just echo. + const typedMarker = `PARKED_TYPED_OK_${runId}` + const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'revealed terminal did not execute and display typed input' + }) + .toContain(typedMarker) + + const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('parked-tab-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps bell and title side effects live while parked', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabAId) + + const runId = randomUUID() + const parkedTitle = `Parked side effects ${runId}` + const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}` + // Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL + // must not count as a bell), then a content marker for the reveal check. + // The 30s keep-alive stops the shell prompt from overwriting the title + // before the store assertion lands. + const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n` + const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`) + + await expect + .poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), { + timeout: 10_000, + message: 'parked OSC 0 title did not update the tab title in the store' + }) + .toBe(parkedTitle) + await expect + .poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), { + timeout: 10_000, + message: 'parked BEL did not mark the terminal tab unread' + }) + .toBe(true) + await expect + .poll(() => isWorktreeUnread(orcaPage, worktreeId), { + timeout: 10_000, + message: 'parked BEL did not mark the worktree unread' + }) + .toBe(true) + + // Why: side effects must come from the pane-less watcher — the burst must + // not have woken the parked view back up. + expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked side-effect marker did not restore when the tab was revealed' + }) + .toContain(marker) + }) + + test('does not park excluded tabs', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId } = setup + + // Tab C: parking-excluded because it has a pending startup command. Queue + // it after the pane mounted so the mount-time consume cannot drain it. + const tabCId = await createActiveTerminalTab(orcaPage, worktreeId) + await orcaPage.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('parking exclusion spec: window.__store is unavailable') + } + store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' }) + }, tabCId) + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + + // Tab B on top hides both A and C. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + await expect + .poll(() => getActiveTabId(orcaPage), { + timeout: 5_000, + message: 'tab B did not stay active while waiting on the parking window' + }) + .toBe(tabBId) + + // Why: tab A parking proves the machinery ran past the delay in this app + // instance, so the tab C assertion below is not vacuously green. + await waitForTabParked(orcaPage, tabAId) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + + // Premise guard: nothing consumed the pending startup while hidden. + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + const tabCState = await readTerminalTabViewState(orcaPage, tabCId) + expect(tabCState.hasManager).toBe(true) + expect(tabCState.paneCount).toBeGreaterThan(0) + }) +}) From 8aaf1daf546273b3117c78b02ae5848fc7b196ed Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:42:47 -0700 Subject: [PATCH 38/62] Benchmark parked hidden terminal memory Co-authored-by: Orca --- .../check-terminal-perf-report-budgets.mjs | 8 + ...heck-terminal-perf-report-budgets.test.mjs | 14 + .../generate-terminal-perf-html-report.mjs | 7 +- ...enerate-terminal-perf-html-report.test.mjs | 19 + config/scripts/terminal-perf-report-rows.mjs | 12 +- tests/e2e/helpers/orca-app.ts | 13 +- tests/e2e/terminal-parked-memory.spec.ts | 358 ++++++++++++++++++ 7 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/terminal-parked-memory.spec.ts diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index cb00ce09256..660620fe527 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -169,6 +169,14 @@ function validateRow(row) { parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures), BUDGETS.maxRendererDroppedBacklogs ) + // Why: parked-memory rows carry heap/view-count metrics with no latency + // budget; recognize them so memory-only scenarios pass the gate instead of + // tripping the "no recognized budget metrics" guard. + for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) { + if (parseCount(row[fieldName], fieldName, row, failures) != null) { + checkedMetricCount += 1 + } + } if (checkedMetricCount === 0) { failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`) } diff --git a/config/scripts/check-terminal-perf-report-budgets.test.mjs b/config/scripts/check-terminal-perf-report-budgets.test.mjs index 1a8232a9d26..e6129975151 100644 --- a/config/scripts/check-terminal-perf-report-budgets.test.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.test.mjs @@ -130,6 +130,20 @@ describe('check-terminal-perf-report-budgets', () => { expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') }) + it('accepts parked-memory rows that carry only heap and view-count metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + it('fails OpenCode annotation rows that contain no budget metrics', () => { const reportPath = writeReport('panes=1 frames=60') diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs index 0f670083935..984b33bb485 100644 --- a/config/scripts/generate-terminal-perf-html-report.mjs +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -30,7 +30,12 @@ const COUNT_METRICS = [ { key: 'mainPeakInFlightChars', label: 'Main in-flight chars' }, { key: 'mainPeakPendingChars', label: 'Main pending chars' }, { key: 'hiddenSkippedChars', label: 'Hidden skipped chars' }, - { key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' } + { key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' }, + // Why: parked-memory scenarios are table-only — heap/view counts have no + // ms trend story, so they stay out of the charts. + { key: 'heapUsedMB', label: 'Renderer JS heap (MB)' }, + { key: 'liveTerminals', label: 'Live xterm instances' }, + { key: 'livePaneManagers', label: 'Live pane managers' } ] const SERIES_COLORS = { diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs index bd6d95a62e4..a804bb6639d 100644 --- a/config/scripts/generate-terminal-perf-html-report.test.mjs +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -126,6 +126,25 @@ describe('generate-terminal-perf-html-report', () => { expect(html).not.toContain('browser-unrelated') }) + it('renders parked-memory heap and live view counts as table metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + // Why: heapUsedMB has no budget — a memory row alone must not fail gates. + expect(result.budgetFailureCount).toBe(0) + expect(html).toContain('Parked hidden terminal memory — 8 panes') + expect(html).toContain('Renderer JS heap (MB)') + expect(html).toContain('142.5') + expect(html).toContain('Live xterm instances') + expect(html).toContain('Live pane managers') + }) + it('marks over-budget rows as failures for the latest run', () => { const reportPath = writeReport( [ diff --git a/config/scripts/terminal-perf-report-rows.mjs b/config/scripts/terminal-perf-report-rows.mjs index c61ac208af8..c1f562f3b9e 100644 --- a/config/scripts/terminal-perf-report-rows.mjs +++ b/config/scripts/terminal-perf-report-rows.mjs @@ -20,7 +20,10 @@ const SCENARIO_LABELS = [ ['opencode-cross-workspace-typing', 'Cross-workspace typing'], ['opencode-main-pressure', 'Main renderer pressure'], ['opencode-hidden-pressure', 'Hidden pressure'], - ['opencode-revisit-pressure', 'Revisit under pressure'] + ['opencode-revisit-pressure', 'Revisit under pressure'], + // Why: the prefix also matches opencode-parked-memory-disabled, so both + // parked-memory scenarios group under one label. + ['opencode-parked-memory', 'Parked hidden terminal memory'] ] export function readJsonReport(path) { @@ -105,7 +108,12 @@ function normalizeRow(row) { mainPeakPendingChars: parseCount(row.mainPeakPendingChars), mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), heldAckChars: parseCount(row.heldAckChars), - hiddenSkippedChars: parseCount(row.hiddenSkippedChars) + hiddenSkippedChars: parseCount(row.hiddenSkippedChars), + // Why: parked-memory annotations report a fractional MB heap figure plus + // live renderer view counts; Number() keeps the MB float intact. + heapUsedMB: parseCount(row.heapUsedMB), + liveTerminals: parseCount(row.liveTerminals), + livePaneManagers: parseCount(row.livePaneManagers) } } diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index 00a99355852..132e6d24d2d 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -47,6 +47,10 @@ type OrcaTestFixtures = { // leaks into other specs when a worker reloads files without replaying the // first spec's afterAll; per-test launch env cannot leak. orcaAppExtraEnv: Record + // Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for + // memory benchmarks). Prepended before the main entry so Electron forwards + // them to Chromium without affecting other specs' launches. + orcaAppExtraArgs: string[] } type OrcaWorkerFixtures = { @@ -192,7 +196,11 @@ export const test = base.extend({ ], // Test-scoped: one Electron app per test - electronApp: async ({ dismissOnboarding, orcaAppExtraEnv }, provideFixture, testInfo) => { + electronApp: async ( + { dismissOnboarding, orcaAppExtraEnv, orcaAppExtraArgs }, + provideFixture, + testInfo + ) => { const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) @@ -229,7 +237,7 @@ export const test = base.extend({ mkdirSync(recordVideoDir, { recursive: true }) } const app = await electron.launch({ - args: getOrcaElectronLaunchArgs(mainPath, headful), + args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)], ...(slowMo > 0 ? { slowMo } : {}), ...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}), // Why: keep NODE_ENV=development so window.__store is exposed and @@ -270,6 +278,7 @@ export const test = base.extend({ dismissOnboarding: [true, { option: true }], seedTestRepo: [true, { option: true }], orcaAppExtraEnv: [{}, { option: true }], + orcaAppExtraArgs: [[], { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts new file mode 100644 index 00000000000..efe2628316a --- /dev/null +++ b/tests/e2e/terminal-parked-memory.spec.ts @@ -0,0 +1,358 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: production cold-park hysteresis is 30s. The fast-park env override is +// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as +// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }, + // Why: without this switch Chromium quantizes performance.memory and only + // refreshes it every ~20 minutes, so both scenarios report the same stale + // launch-time bucket instead of a comparable heap figure. + orcaAppExtraArgs: ['--enable-precise-memory-info'] +}) + +// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit +// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS +// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the +// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden +// past hotRetainMs before the retain-count limit is even consulted. So all 8 +// park without needing 14 tabs or extra policy knobs. +const SCROLLBACK_TAB_COUNT = 8 +const SCROLLBACK_LINE_COUNT = 3000 +const PARK_SETTLE_MS = 2_000 +const HEAP_SAMPLE_COUNT = 5 +const HEAP_SAMPLE_INTERVAL_MS = 250 +// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of +// scrollback each, then waits out the parking window — well past the default +// 120s per-test budget. +const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000 + +// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes +// each xterm hold realistic narrow+wide buffer rows, so released parked-tab +// memory reflects real agent output rather than uniform filler. +function writeScrollbackFillScript(scriptPath: string, runId: string): void { + const script = [ + `const tabIndex = process.argv[2] ?? '0'`, + `const wide = '統合端末記憶計測'`, + `const emoji = ['🟢', '🟡', '🔵', '🟣']`, + `const lines = []`, + `for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`, + ` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`, + ` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`, + ` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`, + `}`, + `process.stdout.write(lines.join('\\n') + '\\n')`, + `process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')` + ].join('\n') + writeFileSync(scriptPath, `${script}\n`) +} + +// Why: the spec lands ahead of the feature wiring in some merge orders. Skip +// (rather than fail) when the app under test does not expose the parking +// debug handle, mirroring terminal-hidden-view-parking.spec.ts. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + } + test.skip( + !present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so +// a missing manager is the observable signal that the tab's xterm was parked. +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise { + return page.evaluate( + (tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length, + tabIds + ) +} + +async function waitForTabsParked(page: Page, tabIds: string[]): Promise { + await expect + .poll(() => countMountedPaneManagers(page, tabIds), { + timeout: Math.max(30_000, PARKING_DELAY_MS * 10), + message: 'hidden scrollback tabs did not all park (pane managers still mounted)' + }) + .toBe(0) +} + +type ScrollbackTab = { + tabId: string + ptyId: string +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + const snapshot = await waitForPaneIdentitySnapshot(page, 1) + const ptyId = snapshot.panes[0]?.ptyId + if (snapshot.tabId !== tabId || !ptyId) { + throw new Error('createActiveTerminalTab: new tab did not bind a PTY') + } + return { tabId, ptyId } +} + +async function fillActiveTerminalWithScrollback( + page: Page, + ptyId: string, + scriptPath: string, + tabIndex: number, + runId: string +): Promise { + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`) + await expect + .poll(() => getTerminalContent(page, 4_000), { + timeout: 30_000, + message: `scrollback fill marker for tab ${tabIndex} did not render` + }) + .toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`) +} + +type ScrollbackTabSetup = { + worktreeId: string + scrollbackTabs: ScrollbackTab[] +} + +// Why: each tab generates its scrollback while visible, so every xterm holds +// the full buffer before going hidden — the hidden skip latch never gets a +// chance to drop the output the memory comparison depends on. +async function setUpScrollbackTabs( + page: Page, + scriptPath: string, + runId: string +): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1) + const baselinePtyId = baselineSnapshot.panes[0]?.ptyId + if (!baselinePtyId) { + throw new Error('parked memory spec: baseline terminal tab did not bind a PTY') + } + + const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }] + await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId) + for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) { + const tab = await createActiveTerminalTab(page, worktreeId) + scrollbackTabs.push(tab) + await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId) + } + return { worktreeId, scrollbackTabs } +} + +type ParkedMemoryMetrics = { + heapUsedMB: number + liveTerminals: number + livePaneManagers: number +} + +// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best +// effort) and take the min of several settled samples — the min reflects +// retained heap instead of allocation noise between collections. Note xterm +// buffer rows are typed-array backing stores outside the V8 heap, so the +// liveTerminals/livePaneManagers counts are the strong release signal and the +// heap figure tracks only the on-heap share. +async function sampleParkedMemoryMetrics(page: Page): Promise { + await page.waitForTimeout(PARK_SETTLE_MS) + try { + const session = await page.context().newCDPSession(page) + await session.send('HeapProfiler.collectGarbage') + await session.detach() + } catch { + // GC over CDP is a measurement-fidelity improvement, not a gate. + } + + let minHeapBytes: number | null = null + for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) { + const heapBytes = await page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory + return memory?.usedJSHeapSize ?? null + }) + if (heapBytes !== null) { + minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes) + } + await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS) + } + if (minHeapBytes === null) { + throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable') + } + + const liveCounts = await page.evaluate(() => ({ + liveTerminals: document.querySelectorAll('.xterm').length, + livePaneManagers: window.__paneManagers?.size ?? 0 + })) + return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts } +} + +function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string { + return [ + `panes=${SCROLLBACK_TAB_COUNT}`, + `parkedTabs=${parkedTabs}`, + `heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`, + `liveTerminals=${metrics.liveTerminals}`, + `livePaneManagers=${metrics.livePaneManagers}` + ].join(' ') +} + +test.describe('Terminal parked memory', () => { + test('releases renderer terminal memory when hidden tabs park', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + + // A fresh 9th tab hides all 8 scrollback tabs. + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabsParked( + orcaPage, + scrollbackTabs.map((tab) => tab.tabId) + ) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory', + description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length) + }) + + // Structural assertions: all 8 parked (managers gone), and the only + // live xterm/pane manager belongs to the visible tab. + for (const tab of scrollbackTabs) { + expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false) + } + const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId) + expect(visibleState.hasManager).toBe(true) + expect(visibleState.paneCount).toBeGreaterThan(0) + // Why: design invariant 5 — renderer terminal views scale with visible + // panes, so parked tabs must leave no xterm DOM behind. + expect(metrics.liveTerminals).toBe(visibleState.paneCount) + expect(metrics.livePaneManagers).toBe(1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('retains terminal views when parking is disabled', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + // Why: settings.terminalHiddenViewParking === false is the design-doc + // kill switch. updateSettings persists it through window.api.settings.set + // and updates the store slice the cold-park hook subscribes to — the same + // mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env + // wiring is needed. + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('parked memory spec: window.__store is unavailable') + } + await store.getState().updateSettings({ terminalHiddenViewParking: false }) + }) + await expect + .poll( + () => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking), + { timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' } + ) + .toBe(false) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId) + + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + // Why: with parking enabled these tabs park within ~1x the collapsed + // delay (the first test proves the machinery in this app build), so + // surviving 3x the delay shows the kill switch held. + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory-disabled', + description: formatParkedMemoryAnnotation(metrics, 0) + }) + + // Structural assertions: every hidden tab keeps its pane manager and + // xterm; nothing parked even after the settle + sampling window. + for (const tab of scrollbackTabs) { + const state = await readTerminalTabViewState(orcaPage, tab.tabId) + expect(state.hasManager).toBe(true) + expect(state.paneCount).toBeGreaterThan(0) + } + expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true) + expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1) + expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) From 21f6605a4e4d9feb72666d440f0bab76039635a9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:54:53 -0700 Subject: [PATCH 39/62] Document terminal side-effect authority design Co-authored-by: Orca --- .../terminal-side-effect-authority.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/reference/terminal-side-effect-authority.md diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md new file mode 100644 index 00000000000..9af5cb3d6e7 --- /dev/null +++ b/docs/reference/terminal-side-effect-authority.md @@ -0,0 +1,214 @@ +# Terminal Side-Effect Authority + +Status: Phase 3 of the terminal model/view architecture. Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and +[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1). + +## Problem + +Main already parses every local/daemon/SSH PTY byte before renderer delivery +(`OrcaRuntimeService.onPtyData`, `src/main/runtime/orca-runtime.ts:3256`: +OSC 9999 agent status, last-OSC-title, headless emulator, tails, URL watchers; +SSH feeds the same path at `src/main/ssh/ssh-relay-session.ts:915`). Yet the +side effects users see — bell unread/notifications, title transitions, +agent-complete notifications, command lifecycle, PR links — are derived a +second time by renderer byte parsers (`pty-transport.ts`'s +`createPtyOutputProcessor`, `pty-connection.ts`, the parked byte watcher). +That duplication forces Phase 1's watcher to exist, forces main to fabricate +synthetic OSC title frames over `pty:data` (`src/main/index.ts:975-990, +1033-1112`) just so renderer parsers can see them, and blocks Phase 4 from +ever stopping hidden byte delivery. Phase 3 makes main the side-effect parser +for every PTY whose bytes transit local main. + +## Authority Matrix + +"Main" means parsed once in `onPtyData` and delivered as derived facts. +Remote-runtime PTYs (`remote:`) never transit local main; the renderer +(`remote-runtime-pty-transport.ts:74`) stays their parser permanently. + +| Side effect | local-daemon | SSH | remote-runtime | +| --- | --- | --- | --- | +| OSC 9999 agent status | main (shipped: `orca-runtime.ts:3259` → `agentStatus:set`) | main (shipped) | renderer (`pty-connection.ts:1490-1541`) | +| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer | +| BEL attention (OSC-aware stateful detector) | main | main | renderer | +| OSC 133;D command-finished exit code | main | main | renderer | +| GitHub PR-link scan | main | main | renderer | +| Command Code output scrape | main (last slice) | main | renderer | +| DECSET 2031 color-scheme reply | renderer view/watcher — query authority stays with the view (contract invariant 6) | same | renderer | +| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | + +## Main-Side Tracker + +- Lift the side-effect core of `createPtyOutputProcessor` + (`pty-transport.ts:87-428`) into a shared module + (`src/shared/terminal-output-side-effects.ts`): all-titles ordering via + `extractAllOscTitles` (coalesced working→idle transitions are why last-title + is insufficient — issue #1083), `normalizeTerminalTitle`, the literal + `cursor agent` title drop (`pty-transport.ts:145-166`), the + `createAgentStatusTracker` transitions, the stale-working-title 3s timer + (`pty-transport.ts:51,363-379`), and the stateful BEL detector + (`bell-detector.ts`). +- One tracker per PTY on `OrcaRuntimeService`, lazily created like + `agentStatusOscProcessorsByPtyId` (`orca-runtime.ts:3420-3427`); disposed in + `onPtyExit` (cancels the stale-title timer). +- It replaces `extractLastOscTitle` at `orca-runtime.ts:3286`: titles feed in + byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, and + pending-message delivery see intermediate transitions instead of only the + chunk's last title. PTY/leaf records keep the **raw** last title (worktree + `ps` and mobile tab titles at `orca-runtime.ts:13209` expect raw); emitted + facts carry `(normalizedTitle, rawTitle)` like `onTitleChange` today. +- No deferred drain in main — the renderer's setTimeout(0) batching + (`pty-transport.ts:175-182,319-339`) protects xterm paint, which does not + exist in main. Apply synchronously, batch the IPC per flush. +- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own + last-title scan, untouched: synthetic titles must never reach it. + +## Event Transport: `pty:sideEffect` + +One new batched main→renderer channel (preload pattern of `agentStatus:set`, +`src/preload/index.ts:3586`), routed by the existing singleton dispatcher like +`pty:data`/`pty:exit` (`pty-dispatcher.ts:92-145`). Events are **facts, not +decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), +`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries +`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf +records, same resolution as `emitTerminalAgentStatusEvents`, +`orca-runtime.ts:3429-3460`), and the PTY `outputSequence`. + +Ordering rules: + +1. Per-PTY in-order; facts from one chunk are emitted in byte order (status + payloads, then titles in sequence, then bell — the renderer drain's order). +2. Deliberately **not** synchronized with `pty:data`: side effects must keep + advancing while renderer delivery is ACK-gated (contract invariant 1). A + completion title may reach the store before the visible xterm paints the + final output; that is acceptable — attention/title state is out-of-band UI + state, and today's renderer drain already decouples by many batches under + timer throttling. +3. No attention replay: facts emitted while no renderer is subscribed are + dropped. On transport attach/park-handoff the renderer requests (or main + re-emits) a `title`+status snapshot marked `replay: true` — this reproduces + the eager-buffer behavior where replay restores titles but is barred from + bells/completions (`pty-transport.ts:656-714` `suppressAttentionEvents`). + The store handler ignores a replay title older (by `outputSequence`) than + the last live title fact it applied. + +## Renderer Store Handler (policy stays in the renderer) + +Verified current notification semantics, all preserved: + +- BEL marks worktree+tab unread unconditionally — including the focused pane + (`pty-connection.ts:1232-1250`); pane unread only behind + `experimentalTerminalAttention`; keydown clears unread + (`pty-connection.ts:959-999`). +- BEL's OS notification is delayed 250 ms and yields to a pending + agent-task-complete (`pty-connection.ts:1259-1275`). +- working→idle starts the Claude cache timer (null settings = not hydrated, + treat enabled, `pty-connection.ts:1409-1430`) and schedules completion with + 250 ms grace + 1500 ms max wait + detail-wait store subscription + (`pty-connection.ts:1328-1390`). +- Completion unread is suppressed only for the exact visible foreground pane + (`use-notification-dispatch.ts:280-298`); BEL unread has no such check. +- Dispatch-time liveness/staleness guards (`use-notification-dispatch.ts: + 229-277`) and main's 5 s per-worktree cooldown (`src/main/ipc/ + notifications.ts:286-296`) remain the final gates. + +These need live renderer store state (PTY/layout maps, pane visibility, +settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer: +a pane-independent per-paneKey handler module consumes `pty:sideEffect` and +subsumes both `pty-connection.ts`'s callbacks and the parked watcher's +callback block (`parked-terminal-byte-watcher.ts:96-213`) — one policy path +whether the tab is mounted, hidden, or parked. Main holds **no** notification +timers; only the stale-title timer (parser state) moves to main. + +## Synthetic Frame Reroute + +`driveSyntheticTitleFromHook` and the spinner tick (`src/main/index.ts: +1033-1112`) currently fabricate OSC title/BEL frames onto `pty:data` +(`sendSyntheticTitle`, `index.ts:975-990`) solely for renderer parsers. +Replace with `runtime.ingestSyntheticTitleFrame(ptyId, label, { bell })` +feeding the per-PTY tracker directly — **not** `onPtyData`, so emulator +state, tails, transcripts, and stats stay clean (today they never see these +frames either). Keep the decorative-frame visibility gating +(`shouldSendSyntheticTitleFrame`). Verified renderer dependencies on those +bytes: the visible xterm renders nothing from titles, but +`pane.terminal.onTitleChange` feeds `registerPtyTitleSource` +(`pty-connection.ts:1797-1799`) → renderer serialize-snapshot `lastTitle` +(mobile parity). After the reroute main must prefer its own tracker title +over renderer snapshot `lastTitle`. Side benefit: synthetic frames stop +producing phantom ACKs for bytes main never metered +(`pty-dispatcher.ts:124-129`). + +## Migration Switch and Double-Fire Prevention + +Authority is structural per PTY kind — the predicate is "bytes transit local +main", exactly the shipped `shouldOwnAgentStatusInRenderer` split +(`pty-connection.ts:1484-1545`). One renderer-consulted kill switch +(`settings.terminalMainSideEffectAuthority`, default on, mirroring +`terminalHiddenViewParking`): when on, IPC transports and the parked watcher +do not register byte parsers for local/SSH and the store handler consumes +`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect` +events are ignored. Main always parses and emits (its internal consumers need +the tracker regardless); main consults the same setting only to keep the +legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly +one consumer per fact at any time — decided at transport/watcher creation, so +no per-chunk race. + +## Sidecar Consumers and Phase 4 + +Keep renderer byte access (input pacing / raw-output consumers, not side +effects): `agent-paste-draft.ts` (DECSET 2004 readiness), +`launch-agent-background-session.ts` (startup-injection pacing, onData +passthrough), `automation-session-observer.ts` (onData passthrough). Their +duplicated local OSC 9999 store writes drop once main authority covers them. +Phase 4's hidden-delivery gate must exempt PTYs with an active +`subscribeToPtyData` sidecar: that registration becomes an explicit +delivery-interest signal surfaced to main. With main authoritative, the +parked watcher's local/SSH parsing is dead code; since parking eligibility +excludes `remote:` and SSH PTYs, the watcher is deleted outright — it only +returns if remote-runtime tabs ever become parkable. + +## Invariants + +1. Every byte is side-effect-parsed exactly once, by exactly one authority, + chosen structurally per PTY kind. +2. Attention facts never replay: snapshot/eager/attach replays restore title + state only. +3. Notification policy (grace timers, yielding, suppression, dispatch guards) + lives with the renderer store; main emits facts with ordering metadata. +4. Side-effect facts keep flowing while renderer byte delivery is + backpressured, parked, or (Phase 4) stopped. +5. Synthetic agent frames feed the model tracker, never the emulator, tails, + transcripts, or stats. + +## Test Strategy + +- Parity harness: shared byte fixtures (agent title cycles incl. coalesced + chunks, BEL inside/spanning OSC, CAN/SUB cancellation, cursor-agent literal, + stale-title timeout under fake timers, OSC 133;D, split PR URLs) run through + the renderer `createPtyOutputProcessor` and the main tracker; assert + identical ordered fact sequences. +- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle + parity, tui-idle waiter transitions, synthetic ingestion); store-handler + tests reusing `parked-terminal-byte-watcher.test.ts` scenarios. +- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring, + `parked-terminal-byte-watcher.test.ts` (retires with the watcher); + `pty-transport*.test.ts` stay (processor remains for remote + kill switch). +- E2E gates that must stay green throughout: `terminal-attention.spec.ts`, + `droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`, + `terminal-parked-memory.spec.ts`; add main-authority bell/completion cases + (parked tab, focused-pane suppression, kill switch off). SSH parity is + exercised manually per the SSH test procedure before each slice ships. + +## Cut-Offs (stacked, independently mergeable) + +1. **Shared tracker in main.** Extract the processor core to shared, run the + per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity + tests. Main-internal consumers only; no IPC or renderer change. +2. **Authority flip.** `pty:sideEffect` channel, renderer store handler, + titles/bell/tracker authority to main for local+SSH behind the kill + switch; parked watcher stops byte parsing for those kinds. +3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`; + OSC 133;D and PR-link facts; mobile `lastTitle` source preference. +4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked + watcher deletion, Phase 4 delivery-interest registration documented in the + gate design. From 657a39bb6e3a226921d9a54ef497d1ac1a3f1beb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:17:24 -0700 Subject: [PATCH 40/62] Track terminal titles in main with all-titles ordering Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 139 +++++++++++++ src/main/runtime/orca-runtime.ts | 185 +++++++++++++----- .../terminal-title-tracker-parity.test.ts | 150 ++++++++++++++ src/shared/agent-detection.ts | 10 + src/shared/terminal-output-side-effects.ts | 129 ++++++++++++ 5 files changed, 561 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts create mode 100644 src/shared/terminal-output-side-effects.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6a08ce06039..345c67f67e7 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3502,6 +3502,145 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle when a completion title is coalesced with the next working title', async () => { + // Why: node-pty + the main batch window can coalesce "task done" and the + // next task's working title into one chunk. A last-title reader never + // sees the intermediate idle and the waiter hangs (issue #1083 class). + const runtime = createRuntime() + syncSinglePty(runtime) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const [terminal] = (await runtime.listTerminals()).terminals + const wait = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 1_000 + }) + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07\x1b]0;Codex working\x07', 101) + + await expect(wait).resolves.toMatchObject({ + handle: terminal.handle, + condition: 'tui-idle', + status: 'running' + }) + }) + + it('ignores the bare cursor-agent native title so synthesized spinner state survives', async () => { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + // cursor-agent re-emits its bare native title on internal redraws while + // still working; it must not stomp the synthesized working title. + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 101) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: '⠋ Cursor Agent' + }) + }) + + it('clears a stale working title after 3s of title-less output', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + + await vi.advanceTimersByTimeAsync(3_000) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex' + }) + } finally { + vi.useRealTimers() + } + }) + + it('cancels the stale-title timer when the PTY exits', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + runtime.onPtyExit(ptyId, 0) + + await vi.advanceTimersByTimeAsync(4_000) + + // The dead session keeps its factual last title — the disposed tracker's + // stale-title rewrite must not fire into the retained record. + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale-title timers isolated per PTY', async () => { + vi.useFakeTimers() + try { + const ptyA = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a` + const ptyB = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-b` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: ptyA, cwd: '/tmp/worktree-a', title: 'shell' }, + { id: ptyB, cwd: '/tmp/worktree-a', title: 'shell' } + ] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyA, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyB, '\x1b]0;Aider working\x07', 100) + // Only A receives title-less output, so only A's stale timer arms. + runtime.onPtyData(ptyA, 'output without a title\r\n', 101) + + await vi.advanceTimersByTimeAsync(3_000) + + const { terminals } = await runtime.listTerminals() + expect(terminals.find((t) => t.tabId === `pty:${ptyA}`)).toMatchObject({ title: 'Codex' }) + expect(terminals.find((t) => t.tabId === `pty:${ptyB}`)).toMatchObject({ + title: 'Aider working' + }) + } finally { + vi.useRealTimers() + } + }) + it('returns OSC titles from headless main terminal snapshots', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fce1fdb2720..5e77a0b17b1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,12 +1,12 @@ /* eslint-disable max-lines -- Why: OrcaRuntimeService still owns the mutable live graph, PTY handles, waiters, mobile floor/layout state, and managed-worktree reconciliation. Stateless browser and file command adapters live beside it; the remaining split points need state-owner extraction before enforcing max-lines. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ -import { - extractLastOscTitle, - detectAgentStatusFromTitle, - isShellProcess -} from '../../shared/agent-detection' +import { detectAgentStatusFromTitle, isShellProcess } from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' +import { + createTerminalTitleTracker, + type TerminalTitleTracker +} from '../../shared/terminal-output-side-effects' import { AGENT_STATUS_STALE_AFTER_MS, type ParsedAgentStatusPayload, @@ -714,6 +714,15 @@ export type RuntimeTerminalAgentStatusEvent = { payload: ParsedAgentStatusPayload } +type RuntimePtyTitleTrackerEntry = { + tracker: TerminalTitleTracker + // Why: onPtyData batches the mobile session-tab touch to once per chunk; + // the stale-working-title timer fires between chunks and must touch + // immediately. These flags route the tracker callback to the right mode. + applyingChunk: boolean + chunkTouchedSessionTabs: boolean +} + type RuntimeHeadlessTerminal = { emulator: HeadlessEmulator // Why: serialize can race with newer writes appended to writeChain; return @@ -1378,6 +1387,11 @@ export class OrcaRuntimeService { string, ReturnType >() + // Why: per-PTY shared title trackers (all-titles ordering + stale-working + // timer) replace last-title-per-chunk scanning so main observes the same + // intra-chunk working→idle transitions the renderer does (issue #1083). + // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. + private ptyTitleTrackersByPtyId = new Map() // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again @@ -3279,20 +3293,12 @@ export class OrcaRuntimeService { this.maybeHydrateHeadlessFromRenderer(ptyId) this.trackHeadlessTerminalData(ptyId, data, outputSequence) - // Why: extract OSC title from raw PTY data before tail-buffer processing - // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) - // announce status via OSC 0/1/2 title sequences — this is the same - // detection path the renderer uses for notifications and sidebar badges. - const oscTitle = extractLastOscTitle(data) - const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null - let normalizedData: string | null = null const getNormalizedData = (): string => { normalizedData ??= normalizeTerminalChunk(data) return normalizedData } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - let shouldTouchPtyBackedSessionTabs = false const ptyTailBefore = pty ? { lines: pty.tailBuffer, @@ -3317,17 +3323,6 @@ export class OrcaRuntimeService { pty.tailTruncated = pty.tailTruncated || nextTail.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) - if (oscTitle !== null) { - const prevStatus = pty.lastAgentStatus - const prevTitle = pty.lastOscTitle - pty.lastOscTitle = oscTitle - pty.lastAgentStatus = agentStatus - shouldTouchPtyBackedSessionTabs = - prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolvePtyTuiIdleWaiters(pty, ptyId) - } - } } for (const leaf of this.getLeavesForPty(ptyId)) { @@ -3372,36 +3367,19 @@ export class OrcaRuntimeService { leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) } - - if (oscTitle !== null) { - // Why: keep the latest OSC title on the leaf so worktree.ps can - // recompute status from the live title each call. Without this, - // daemon-hosted terminals (no renderer pushing pane titles) had no - // way to clear a stale 'working' status after the agent exited and - // the shell took over the title — the stuck-spinner bug in #1437. - leaf.lastOscTitle = oscTitle - const prevStatus = leaf.lastAgentStatus - // Why: when a new OSC title doesn't classify as an agent state (e.g. - // bare shell title after the agent exits), clear lastAgentStatus so - // it is no longer sticky. Tui-idle waiters that needed the previous - // 'idle' transition were already resolved at the moment of the - // transition below; only fresh waiters registered after the agent - // exits would observe the cleared value, and they correctly fall - // back to title-based detection / polling. - leaf.lastAgentStatus = agentStatus - // Why: resolve tui-idle on any transition TO idle (not just working→idle). - // Claude Code may skip "working" entirely on fast tasks, going null→idle, - // and the coordinator's tui-idle waiter would hang forever waiting for a - // working→idle transition that never comes. Permission→idle is excluded: - // it means the agent was blocked on user approval and the user said no, - // which isn't a task-completion signal. - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolveTuiIdleWaiters(leaf) - this.deliverPendingMessages(leaf) - } - } } + // Why: feed the chunk's OSC titles through the shared per-PTY tracker in + // byte order — the same ordering the renderer transport uses — so + // coalesced working→idle transitions reach tui-idle waiters and + // pending-message delivery instead of being masked by the chunk's last + // title (issue #1083). Uses the OSC 9999-stripped cleanData like the + // renderer, so pure status chunks don't perturb the stale-title probe. + const shouldTouchPtyBackedSessionTabs = this.ingestPtyTitlesForChunk( + ptyId, + agentStatusChunk.cleanData + ) + this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) if (shouldTouchPtyBackedSessionTabs) { this.touchMobileSessionSnapshotsForPty(ptyId) @@ -3426,6 +3404,107 @@ export class OrcaRuntimeService { return processor(data) } + /** Feed one raw PTY chunk through the per-PTY title tracker. Returns true + * when a title application changed the PTY record's title/status, so + * onPtyData can touch mobile session snapshots once per chunk. */ + private ingestPtyTitlesForChunk(ptyId: string, data: string): boolean { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.applyingChunk = true + entry.chunkTouchedSessionTabs = false + try { + entry.tracker.handleChunk(data) + } finally { + entry.applyingChunk = false + } + return entry.chunkTouchedSessionTabs + } + + private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { + const existing = this.ptyTitleTrackersByPtyId.get(ptyId) + if (existing) { + return existing + } + const tracker = createTerminalTitleTracker({ + onTitle: (_normalizedTitle, rawTitle) => { + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle) + if (!changed) { + return + } + const live = this.ptyTitleTrackersByPtyId.get(ptyId) + if (live?.applyingChunk) { + live.chunkTouchedSessionTabs = true + } else { + // Stale-working-title timer path — fires between chunks, so the + // per-chunk batching in onPtyData cannot pick it up. + this.touchMobileSessionSnapshotsForPty(ptyId) + } + } + }) + const entry: RuntimePtyTitleTrackerEntry = { + tracker, + applyingChunk: false, + chunkTouchedSessionTabs: false + } + this.ptyTitleTrackersByPtyId.set(ptyId, entry) + return entry + } + + /** Apply one observed OSC title (raw form) to the PTY and leaf records. + * Returns true when the PTY record's title or status changed. */ + private applyTrackedPtyTitle(ptyId: string, rawTitle: string): boolean { + const agentStatus = detectAgentStatusFromTitle(rawTitle) + let ptyRecordChanged = false + const pty = this.ptysById.get(ptyId) + if (pty) { + const prevStatus = pty.lastAgentStatus + const prevTitle = pty.lastOscTitle + // Why: records keep the RAW title — worktree `ps` and mobile tab titles + // expect it; normalized titles ride along on the tracker for later + // emitted facts (terminal-side-effect-authority.md). + pty.lastOscTitle = rawTitle + pty.lastAgentStatus = agentStatus + ptyRecordChanged = prevTitle !== rawTitle || prevStatus !== agentStatus + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolvePtyTuiIdleWaiters(pty, ptyId) + } + } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + leaf.lastOscTitle = rawTitle + const prevStatus = leaf.lastAgentStatus + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. + leaf.lastAgentStatus = agentStatus + // Why: resolve tui-idle on any transition TO idle (not just working→idle). + // Claude Code may skip "working" entirely on fast tasks, going null→idle, + // and the coordinator's tui-idle waiter would hang forever waiting for a + // working→idle transition that never comes. Permission→idle is excluded: + // it means the agent was blocked on user approval and the user said no, + // which isn't a task-completion signal. + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolveTuiIdleWaiters(leaf) + this.deliverPendingMessages(leaf) + } + } + return ptyRecordChanged + } + + /** Cancel the per-PTY title tracker (stale-title timer included) on PTY + * teardown so it cannot fire into pruned records. */ + private disposePtyTitleTracker(ptyId: string): void { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() + this.ptyTitleTrackersByPtyId.delete(ptyId) + } + private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { if (!this.onTerminalAgentStatus || chunk.payloads.length === 0) { return @@ -4630,6 +4709,7 @@ export class OrcaRuntimeService { this.recentPtyOutputById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) // Layout state machine: clear `layouts` and `layoutQueues`. Any // already-queued applyLayout work for this ptyId will run, but every // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and @@ -12739,6 +12819,7 @@ export class OrcaRuntimeService { this.recentPtyOutputById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { this.handleByPtyId.delete(ptyId) diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts new file mode 100644 index 00000000000..72d27837e54 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -0,0 +1,150 @@ +// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY +// title tracker in main alongside the renderer transport's byte parser. Both +// must derive IDENTICAL ordered title/status facts from the same bytes, or +// main-side consumers (tui-idle waiters, worktree ps, mobile titles) drift +// from what the renderer shows. This harness feeds identical byte fixtures +// through the renderer `createPtyOutputProcessor` and through main's +// consumption shape (OSC 9999 strip → shared title tracker) and asserts the +// event sequences match. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' +import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects' +import { createPtyOutputProcessor } from './pty-transport' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type TitleFactEvent = + | { kind: 'title'; normalized: string; raw: string } + | { kind: 'became-working' } + | { kind: 'became-idle'; title: string } + | { kind: 'agent-exited' } + +type TitleFactPath = { + events: TitleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }) + }) + const callbacks = { onData: () => {} } + return { + events, + feed(chunk: string): void { + processor.processData(chunk, callbacks) + // Why: the renderer defers side effects behind a setTimeout(0) drain to + // protect xterm paint. Flush synchronously so both paths observe each + // chunk at the same fake-timer instant. + processor.flushPendingSideEffects() + } + } +} + +function createMainPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + // Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999 + // processor strips status payloads before the title tracker sees the chunk. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +function feedBoth(paths: { renderer: TitleFactPath; main: TitleFactPath }, chunk: string): void { + paths.renderer.feed(chunk) + paths.main.feed(chunk) +} + +describe('main title tracker parity with the renderer transport processor', () => { + let paths: { renderer: TitleFactPath; main: TitleFactPath } + + beforeEach(() => { + vi.useFakeTimers() + paths = { renderer: createRendererPath(), main: createMainPath() } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => { + // One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's + // trailing idle title. A last-title reader sees only the idle title and + // never observes the working state. + const chunk = + `${ESC}]0;⠋ π - cwd${BEL}response text\r\n` + + `${ESC}]0;⠙ π - cwd${BEL}more text\r\n` + + `${ESC}]0;π - cwd${BEL}` + feedBoth(paths, chunk) + + expect(paths.main.events).toEqual(paths.renderer.events) + const kinds = paths.main.events.map((event) => event.kind) + expect(kinds).toContain('became-working') + expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle')) + }) + + it('derives identical facts from BEL- and ST-terminated titles', () => { + feedBoth(paths, `${ESC}]2;Codex working${ST}body bytes`) + feedBoth(paths, `${ESC}]0;Codex done${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' }) + }) + + it('drops the bare cursor-agent native title in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + const titles = paths.main.events.filter((event) => event.kind === 'title') + expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }]) + }) + + it('clears a stale working title after the 3s timeout in both paths', () => { + feedBoth(paths, `${ESC}]0;. Claude working${BEL}`) + feedBoth(paths, 'output with no title\r\n') + + vi.advanceTimersByTime(3_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Claude' }) + }) + + it('keeps the stale-title timer unperturbed by pure OSC 9999 status chunks', () => { + feedBoth(paths, `${ESC}]0;Codex working${BEL}`) + feedBoth(paths, 'plain output arms the timer\r\n') + + vi.advanceTimersByTime(2_000) + // Why: a chunk that is ONLY an Orca status payload strips to empty + // cleanData; neither path may restart (or newly arm) the stale probe. + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + vi.advanceTimersByTime(1_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Codex' }) + }) + + it('ignores a title split across chunk boundaries in both paths', () => { + feedBoth(paths, `${ESC}]0;split-ti`) + feedBoth(paths, `tle${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 57ea84d9d90..554b37aa14f 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -411,6 +411,16 @@ export function getAgentLabel(title: string): string | null { // stomp the synthesized state back to idle. const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' +/** + * True for cursor-agent's bare native title ("Cursor Agent", trimmed, + * case-insensitive). Title trackers drop it before it reaches stored state so + * cursor's per-turn re-emissions cannot stomp Orca's synthesized spinner + * titles. Anything with additional tokens ("⠋ Cursor Agent") passes through. + */ +export function isCursorNativeAgentTitle(title: string): boolean { + return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER +} + export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title) { return null diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts new file mode 100644 index 00000000000..eb62817e369 --- /dev/null +++ b/src/shared/terminal-output-side-effects.ts @@ -0,0 +1,129 @@ +/** + * Shared per-PTY terminal title side-effect tracking — the parser core behind + * both the renderer transport (`createPtyOutputProcessor`) and main's + * per-PTY tracker in `OrcaRuntimeService.onPtyData`. + * + * Why shared: docs/reference/terminal-side-effect-authority.md makes main the + * side-effect parser for every PTY whose bytes transit local main. Title + * semantics (all-titles ordering, cursor-agent literal drop, normalization, + * stale-working-title clearing) must not drift between the two paths. + */ + +import { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + extractAllOscTitles, + isCursorNativeAgentTitle, + normalizeTerminalTitle +} from './agent-detection' + +/** Ms of title-less output after a working title before it is cleared. */ +export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 + +export type TerminalTitleTrackerCallbacks = { + /** + * Fired once per observed OSC title, in byte order — including the + * synthesized cleared title when the stale-working timer fires. + */ + onTitle?: (normalizedTitle: string, rawTitle: string) => void + onAgentBecameIdle?: (title: string) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void +} + +export type TerminalTitleTracker = { + /** Feed one raw PTY chunk; titles are applied synchronously in byte order. */ + handleChunk: (data: string) => void + /** Last title surfaced through onTitle, after normalization. */ + getLastNormalizedTitle: () => string | null + /** Cancel the stale-title timer and clear accumulated tracker state. */ + dispose: () => void +} + +export function createTerminalTitleTracker( + callbacks: TerminalTitleTrackerCallbacks, + options: { initialTitle?: string } = {} +): TerminalTitleTracker { + const { onTitle, onAgentBecameIdle, onAgentBecameWorking, onAgentExited } = callbacks + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session tracker behaves as if it had observed the pane's + // last live title — parity with the renderer processor's seeding. + let lastEmittedTitle: string | null = + options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null + let staleTitleTimer: ReturnType | null = null + const agentTracker = + onAgentBecameIdle || onAgentBecameWorking || onAgentExited + ? createAgentStatusTracker( + (title) => { + onAgentBecameIdle?.(title) + }, + onAgentBecameWorking, + onAgentExited, + options.initialTitle + ) + : null + + function clearStaleTitleTimer(): void { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + } + + function applyObservedTitle(rawTitle: string): void { + // Why: cursor-agent re-emits its bare native title many times per turn + // while still working; letting it through would stomp Orca's synthesized + // "⠋ Cursor Agent" spinner state back to agentless within a second. + if (isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + onTitle?.(lastEmittedTitle, rawTitle) + agentTracker?.handleTitle(rawTitle) + } + + function handleChunk(data: string): void { + // Why: feed EVERY OSC title in the chunk in byte order, never just the + // last one. node-pty plus the main-process batch window commonly coalesce + // multiple title updates into a single payload; a last-title reader drops + // intra-chunk working→idle transitions (issue #1083). + const titles = data.includes('\x1b]') ? extractAllOscTitles(data) : [] + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + return + } + // Why: agents that exit without resetting their title leave a stale + // working spinner behind. Any title-less output while the last title + // classifies as working restarts a 3s timer that rewrites the title to + // its cleared form — the renderer transport's stale-title semantics. + if ( + data.length > 0 && + lastEmittedTitle !== null && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + onTitle?.(cleared, cleared) + agentTracker?.handleTitle(cleared) + } + }, STALE_WORKING_TITLE_TIMEOUT_MS) + } + } + + return { + handleChunk, + getLastNormalizedTitle: () => lastEmittedTitle, + dispose(): void { + clearStaleTitleTimer() + agentTracker?.reset() + } + } +} From 9ee937b0964af496e53e20dad07c7b4816117c23 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:20:44 -0700 Subject: [PATCH 41/62] Move terminal side-effect authority to a main facts channel Co-authored-by: Orca --- .../terminal-side-effect-authority.md | 7 +- src/main/index.ts | 23 +- src/main/ipc/pty.ts | 12 + src/main/ipc/settings.test.ts | 21 +- src/main/ipc/settings.ts | 9 + src/main/runtime/orca-runtime.test.ts | 320 +++++++++++++++ src/main/runtime/orca-runtime.ts | 284 ++++++++++++-- src/preload/api-types.ts | 10 + src/preload/index.ts | 20 + .../agent-task-complete-policy.ts | 66 ++++ .../parked-terminal-byte-watcher.test.ts | 271 +++++++++++++ .../parked-terminal-byte-watcher.ts | 91 +++-- .../terminal-pane/pty-connection.test.ts | 255 +++++++++++- .../terminal-pane/pty-connection.ts | 148 ++++--- .../components/terminal-pane/pty-transport.ts | 2 +- ...terminal-side-effect-facts-handler.test.ts | 365 ++++++++++++++++++ .../terminal-side-effect-facts-handler.ts | 214 ++++++++++ .../terminal-title-tracker-parity.test.ts | 43 ++- src/renderer/src/web/web-preload-api.ts | 7 + src/shared/agent-detection.ts | 14 +- src/shared/constants.ts | 1 + .../terminal-bell-detector.test.ts} | 2 +- .../terminal-bell-detector.ts} | 13 +- src/shared/terminal-output-side-effects.ts | 131 ++++++- src/shared/terminal-side-effect-facts.ts | 37 ++ src/shared/types.ts | 6 + 26 files changed, 2228 insertions(+), 144 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts rename src/{renderer/src/components/terminal-pane/bell-detector.test.ts => shared/terminal-bell-detector.test.ts} (93%) rename src/{renderer/src/components/terminal-pane/bell-detector.ts => shared/terminal-bell-detector.ts} (82%) create mode 100644 src/shared/terminal-side-effect-facts.ts diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md index 9af5cb3d6e7..039ad0243cf 100644 --- a/docs/reference/terminal-side-effect-authority.md +++ b/docs/reference/terminal-side-effect-authority.md @@ -66,8 +66,11 @@ Remote-runtime PTYs (`remote:`) never transit local main; the renderer ## Event Transport: `pty:sideEffect` One new batched main→renderer channel (preload pattern of `agentStatus:set`, -`src/preload/index.ts:3586`), routed by the existing singleton dispatcher like -`pty:data`/`pty:exit` (`pty-dispatcher.ts:92-145`). Events are **facts, not +`src/preload/index.ts:3586`). It is **not** routed through the pty dispatcher: +the renderer fact-consumer registry +(`terminal-side-effect-facts-handler.ts`) subscribes directly via +`window.api.pty.onSideEffect` — one channel subscription per renderer, with +exactly one registered fact consumer per PTY. Events are **facts, not decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), `agent-exited`, `command-finished` (exit code), `pr-link`. Each carries `ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf diff --git a/src/main/index.ts b/src/main/index.ts index 678ac7ad14b..0310c11286f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -136,6 +136,7 @@ import { type SyntheticAgentTitleProfile } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' @@ -986,6 +987,12 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool ) { return } + // Why: feed the per-PTY tracker directly (never onPtyData — emulator state, + // tails, transcripts, and stats must not see fabricated bytes) so synthetic + // titles/BELs reach pty:sideEffect consumers when main holds side-effect + // authority. The legacy pty:data copy below stays until slice 3 so renderer + // byte parsers keep working while the kill switch is off. + runtime?.ingestSyntheticTitleFrame(ptyId, data) mainWindow.webContents.send('pty:data', { id: ptyId, data }) } @@ -1224,7 +1231,21 @@ app.whenReady().then(async () => { onPtyStopped: clearProviderPtyState, onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) - } + }, + // Why: derived title/bell/agent facts ride one batched main→renderer + // channel (terminal-side-effect-authority.md). The renderer's authority + // kill switch decides whether to consume. Headless serve never creates a + // window, so the dep is omitted entirely — the runtime then skips fact + // batch construction and the per-chunk bell walk. + ...(isServeMode + ? {} + : { + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + } + }) }) runtime = runtimeService automations = new AutomationService(store, { claudeUsage, codexUsage }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index f810aef5654..9837704b5b8 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1031,6 +1031,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') ipcMain.removeHandler('pty:getMainBufferSnapshot') + ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') ipcMain.removeHandler('pty:resetRendererDeliveryDebug') ipcMain.removeHandler('pty:writeAccepted') @@ -1985,6 +1986,17 @@ export function registerPtyHandlers( } ) + // Why: with main holding side-effect authority the renderer no longer + // derives titles from replayed bytes on (re)attach. This title-only replay + // snapshot restores title state — never historical bells/completions (the + // no-attention-replay rule, terminal-side-effect-authority.md). + ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + return runtime.getTerminalSideEffectSnapshot(args.id) + }) + ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { return getPtyRendererDeliveryDebugSnapshot() }) diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 278bf41bec1..93254aac039 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -5,6 +5,7 @@ const { applyElectronProxySettingsMock, browserWindowGetAllWindowsMock, handleMock, + onMock, previewGhosttyImportMock, rebuildAppMenuMock } = vi.hoisted(() => ({ @@ -12,13 +13,14 @@ const { applyElectronProxySettingsMock: vi.fn(), browserWindowGetAllWindowsMock: vi.fn(), handleMock: vi.fn(), + onMock: vi.fn(), previewGhosttyImportMock: vi.fn(), rebuildAppMenuMock: vi.fn() })) vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock }, - ipcMain: { handle: handleMock }, + ipcMain: { handle: handleMock, on: onMock }, nativeTheme: { themeSource: 'system' } })) @@ -58,6 +60,7 @@ const store = { describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() + onMock.mockClear() applyAppIconMock.mockClear() applyElectronProxySettingsMock.mockClear() applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' }) @@ -75,6 +78,22 @@ describe('registerSettingsHandlers', () => { expect(channels).toContain('settings:previewGhosttyImport') }) + it('answers the synchronous settings read with the persisted settings', () => { + // Why: panes can bind PTYs before async hydration; the side-effect + // authority kill switch needs the persisted value synchronously. + store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false }) + registerSettingsHandlers(store as never) + + const listener = onMock.mock.calls.find( + (call) => call[0] === 'settings:get-sync' + )?.[1] as (event: { returnValue: unknown }) => void + expect(listener).toBeTypeOf('function') + + const event = { returnValue: undefined as unknown } + listener(event) + expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false }) + }) + it('settings:previewGhosttyImport returns preview result', async () => { const expected = { found: false, diff: {}, unsupportedKeys: [] } previewGhosttyImportMock.mockResolvedValue(expected) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 5272fbf2902..e4a73db11f0 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -50,6 +50,15 @@ export function registerSettingsHandlers( return store.getSettings() }) + // Why: terminal panes can bind PTYs before async settings hydration + // completes. The side-effect authority kill switch is consulted once at + // transport creation, so the renderer needs the persisted value + // synchronously or pre-hydration bindings would always pick main authority + // (terminal-side-effect-authority.md, migration switch). + ipcMain.on('settings:get-sync', (event) => { + event.returnValue = store.getSettings() + }) + ipcMain.handle('settings:set', async (event, args: Partial) => { const sanitizedArgs = { ...args } // Why: Floating Workspace grants are trusted only when written by the diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 345c67f67e7..113a351e680 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -40,6 +40,7 @@ import { type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider @@ -3641,6 +3642,325 @@ describe('OrcaRuntimeService', () => { } }) + // ─── pty:sideEffect channel (terminal-side-effect-authority.md, slice 2) ── + describe('terminal side-effect fact channel', () => { + function createSideEffectRuntime(): { + runtime: OrcaRuntimeService + batches: TerminalSideEffectBatch[] + } { + const batches: TerminalSideEffectBatch[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalSideEffects: (batch) => batches.push(batch) + }) + return { runtime, batches } + } + + it('emits one batched event per chunk with facts in byte order and attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + const chunk = '\x1b]0;Codex working\x07response\x1b]0;Codex done\x07\x07' + runtime.onPtyData('pty-1', chunk, 100) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + seq: chunk.length, + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + paneKey: 'tab-1:1' + }) + expect(batches[0].replay).toBeUndefined() + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }, + { kind: 'agent-idle', title: 'Codex done' }, + { kind: 'bell' } + ]) + }) + + it('keeps per-PTY ordering across chunks and accumulates seq', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches.map((batch) => batch.facts[0]?.kind)).toEqual(['title', 'title']) + expect(batches[0].seq).toBeLessThan(batches[1].seq) + }) + + it('emits nothing for chunks without derived facts', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Plain output, a BEL-terminated OSC title split across chunks, and an + // Orca status payload: none of these is a title/bell/agent fact. + runtime.onPtyData('pty-1', 'plain output\r\n', 100) + runtime.onPtyData('pty-1', '\x1b]0;par', 101) + runtime.onPtyData('pty-1', 'tial\x07', 102) + runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103) + + expect(batches).toEqual([]) + }) + + it('emits the stale-working-title rewrite as between-chunk fact batches', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', 'output without a title\r\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + // Timer facts fire outside a chunk, so each emits immediately — + // still strictly ordered per PTY. They carry staleWorkingTitleClear: + // the renderer must clear state without scheduling a task-complete + // notification main's unthrottled timer did not earn. + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('ingests synthetic title frames without touching the byte pipeline', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + expect(batches).toHaveLength(1) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + // The synthesized spinner classifies as working — agent facts derive + // from synthetic frames the same as from real bytes. + { kind: 'agent-working' } + ]) + // Synthetic frames are fabricated by main: they must not advance the + // metered output sequence the renderer ACK budget is based on. + expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) + }) + + it('carries the synthetic permission BEL as a bell fact', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor needs your input\x07\x07') + + expect(batches[0].facts.at(0)).toMatchObject({ kind: 'title' }) + expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' }) + }) + + it('returns a title-only replay snapshot and never historical attention', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x07', 100) + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({ + ptyId: 'pty-1', + replay: true, + facts: [{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }] + }) + expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull() + }) + + it('drops the cursor-agent literal from record-fallback snapshots', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Simulate a record title restored by a path that bypassed the tracker + // (the tracker itself refuses to store the bare native title). + const records = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById + records.get('pty-1')!.lastOscTitle = 'Cursor Agent' + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull() + }) + + it('emits the chunk agentStatus events before its side-effect batch', () => { + // Cross-channel contract order per chunk: status → titles → bell. + const order: string[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalAgentStatus: () => order.push('agentStatus:set'), + onTerminalSideEffects: () => order.push('pty:sideEffect') + }) + syncSinglePty(runtime) + + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex working\x07\x07', + 100 + ) + + expect(order).toEqual(['agentStatus:set', 'pty:sideEffect']) + }) + + it('still emits a throwing chunk’s facts under its own seq, not the next chunk’s', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + vi.spyOn( + runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean }, + 'applyTrackedPtyTitle' + ).mockImplementationOnce(() => { + throw new Error('tracker boom') + }) + + const first = '\x1b]0;Codex working\x07' + expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom') + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches).toHaveLength(2) + expect(batches[0].seq).toBe(first.length) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' } + ]) + // The next chunk's batch carries only its own facts (the throw aborted + // the first chunk's agent-tracker pass, so no working state was kept). + expect(batches[1].seq).toBeGreaterThan(batches[0].seq) + expect(batches[1].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' } + ]) + }) + + it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;split ti', 100) + // An 80ms spinner tick lands between the two halves of the real OSC. + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + // Continuation: this BEL terminates the real OSC — it is NOT a bell. + runtime.onPtyData('pty-1', 'tle\x07', 101) + // A later standalone BEL is a real bell and must not be swallowed. + runtime.onPtyData('pty-1', 'ready\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + { kind: 'agent-working' }, + { kind: 'bell' } + ]) + }) + + it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + const touchSpy = vi.spyOn( + runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void }, + 'touchMobileSessionSnapshotsForPty' + ) + + for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) { + runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`) + } + // Five ticks with the same de-spinnered title: one snapshot fan-out. + expect(touchSpy).toHaveBeenCalledTimes(1) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07') + expect(touchSpy).toHaveBeenCalledTimes(2) + // Raw record titles still track every frame for worktree ps/mobile tabs. + expect( + ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1')?.lastOscTitle + ).toBe('Cursor ready') + }) + + it('seeds the lazily created tracker from the daemon-snapshot title', async () => { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + // First live chunk creates the tracker cold and kicks off hydration; + // the snapshot seed must land in the already-created tracker. + runtime.onPtyData('pty-1', 'plain output without a title\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + // Without the seed the tracker never saw 'working', so this idle title + // could not produce a completion fact. + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-idle', + title: 'Codex done' + }) + }) + + it('arms the stale-title timer for a seeded working title', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Settle the async daemon-snapshot hydration that seeds the tracker. + await vi.advanceTimersByTimeAsync(0) + runtime.onPtyData('pty-1', 'still no title\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + }) + it('returns OSC titles from headless main terminal snapshots', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5e77a0b17b1..f3b29600f44 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,12 +1,22 @@ /* eslint-disable max-lines -- Why: OrcaRuntimeService still owns the mutable live graph, PTY handles, waiters, mobile floor/layout state, and managed-worktree reconciliation. Stateless browser and file command adapters live beside it; the remaining split points need state-owner extraction before enforcing max-lines. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ -import { detectAgentStatusFromTitle, isShellProcess } from '../../shared/agent-detection' +import { + detectAgentStatusFromTitle, + isCursorNativeAgentTitle, + isShellProcess, + normalizeTerminalTitle +} from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' import { createTerminalTitleTracker, + stripBrailleSpinnerGlyphs, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../shared/terminal-side-effect-facts' import { AGENT_STATUS_STALE_AFTER_MS, type ParsedAgentStatusPayload, @@ -720,7 +730,16 @@ type RuntimePtyTitleTrackerEntry = { // the stale-working-title timer fires between chunks and must touch // immediately. These flags route the tracker callback to the right mode. applyingChunk: boolean + // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the + // synthetic path gates mobile snapshot fan-out on a non-decorative title + // change (spinner glyph + status comparison key kept below). + applyingSyntheticFrame: boolean + lastMobileTitleGateKey: string | null chunkTouchedSessionTabs: boolean + // Why: facts observed while applying a chunk are batched into one + // pty:sideEffect emission per chunk, preserving byte order (titles in + // sequence, then bell). Timer-fired facts emit immediately between chunks. + pendingFacts: TerminalSideEffectFact[] } type RuntimeHeadlessTerminal = { @@ -1595,6 +1614,7 @@ export class OrcaRuntimeService { private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null + private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -1617,6 +1637,7 @@ export class OrcaRuntimeService { getLocalProvider?: () => IPtyProvider onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void + onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void } ) { this.store = store @@ -1633,6 +1654,7 @@ export class OrcaRuntimeService { this.getLocalProviderFn = deps?.getLocalProvider ?? null this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null + this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null } getLocalProvider(): IPtyProvider | null { @@ -3375,13 +3397,25 @@ export class OrcaRuntimeService { // pending-message delivery instead of being masked by the chunk's last // title (issue #1083). Uses the OSC 9999-stripped cleanData like the // renderer, so pure status chunks don't perturb the stale-title probe. - const shouldTouchPtyBackedSessionTabs = this.ingestPtyTitlesForChunk( - ptyId, - agentStatusChunk.cleanData - ) - - this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) - if (shouldTouchPtyBackedSessionTabs) { + const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + titleTrackerEntry.applyingChunk = true + titleTrackerEntry.chunkTouchedSessionTabs = false + try { + titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData) + } finally { + titleTrackerEntry.applyingChunk = false + try { + // Why: per-chunk cross-channel contract order is status → titles → + // bell — the chunk's agentStatus:set events must reach the renderer + // before its pty:sideEffect batch. + this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + } finally { + // Why: flushed in the finally so a throwing tracker callback cannot + // strand this chunk's facts to be emitted under the next chunk's seq. + this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) + } + } + if (titleTrackerEntry.chunkTouchedSessionTabs) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -3404,19 +3438,146 @@ export class OrcaRuntimeService { return processor(data) } - /** Feed one raw PTY chunk through the per-PTY title tracker. Returns true - * when a title application changed the PTY record's title/status, so - * onPtyData can touch mobile session snapshots once per chunk. */ - private ingestPtyTitlesForChunk(ptyId: string, data: string): boolean { + /** Emit the facts batched while applying one chunk/frame as a single + * pty:sideEffect batch, preserving byte order. */ + private flushPendingTerminalSideEffectFacts( + ptyId: string, + entry: RuntimePtyTitleTrackerEntry + ): void { + if (entry.pendingFacts.length === 0) { + return + } + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) + } + + /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through + * the per-PTY tracker — NOT onPtyData, so emulator state, tails, + * transcripts, and stats never see synthetic bytes. Parsed via the + * tracker's stateless synthetic path: the shared chunk bell detector must + * never observe fabricated bytes, or a tick interleaved with a split real + * OSC corrupts its escape state (phantom/swallowed bells). While the + * side-effect kill switch is off the legacy pty:data copy still drives + * renderer parsers; this ingest keeps main's facts and records + * authoritative. */ + ingestSyntheticTitleFrame(ptyId: string, data: string): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.applyingChunk = true + entry.applyingSyntheticFrame = true entry.chunkTouchedSessionTabs = false try { - entry.tracker.handleChunk(data) + entry.tracker.applySyntheticTitleFrame(data) } finally { entry.applyingChunk = false + entry.applyingSyntheticFrame = false + this.flushPendingTerminalSideEffectFacts(ptyId, entry) } - return entry.chunkTouchedSessionTabs + if (entry.chunkTouchedSessionTabs) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + } + + /** Record one derived side-effect fact: batched per chunk while applying + * bytes, emitted immediately for between-chunk facts (stale-title timer). */ + private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { + if (!this.onTerminalSideEffects) { + return + } + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (entry?.applyingChunk) { + entry.pendingFacts.push(fact) + return + } + this.emitTerminalSideEffectBatch(ptyId, [fact]) + } + + private emitTerminalSideEffectBatch( + ptyId: string, + facts: TerminalSideEffectFact[], + options: { replay?: boolean } = {} + ): void { + if (!this.onTerminalSideEffects || facts.length === 0) { + return + } + const batch: TerminalSideEffectBatch = { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + facts, + ...(options.replay ? { replay: true } : {}), + ...this.resolveTerminalSideEffectAttribution(ptyId) + } + try { + this.onTerminalSideEffects(batch) + } catch (err) { + console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) + } + } + + /** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the + * first mounted leaf, fall back to the spawn-time PTY record binding. */ + private resolveTerminalSideEffectAttribution(ptyId: string): { + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null + } { + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId ?? null + for (const leaf of this.getLeavesForPty(ptyId)) { + return { + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + paneKey: this.makeRuntimePaneKey(leaf), + connectionId + } + } + if (pty?.paneKey) { + return { + worktreeId: pty.worktreeId, + ...(pty.tabId ? { tabId: pty.tabId } : {}), + paneKey: pty.paneKey, + connectionId + } + } + return {} + } + + /** Title-only replay batch for renderer (re)attach — the no-attention-replay + * rule: snapshots restore title state, never historical bells/completions. */ + getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { + const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + // Why: the cursor-agent literal drop applies to every title surface; a + // record-fallback snapshot must not replay the bare native title the + // tracker would have refused to emit live. + const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null + const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null + if (normalizedTitle === null && !rawTitle) { + return null + } + return { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + replay: true, + facts: [ + { + kind: 'title', + normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!), + rawTitle: rawTitle ?? normalizedTitle! + } + ], + ...this.resolveTerminalSideEffectAttribution(ptyId) + } + } + + /** Decorative comparison key: spinner frame glyphs stripped, derived agent + * status kept so a working→idle flip with an otherwise-equal label still + * counts as a change. */ + private makeMobileTitleGateKey(rawTitle: string, normalizedTitle: string): string { + return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( + normalizedTitle + )}` } private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { @@ -3424,26 +3585,86 @@ export class OrcaRuntimeService { if (existing) { return existing } - const tracker = createTerminalTitleTracker({ - onTitle: (_normalizedTitle, rawTitle) => { - const changed = this.applyTrackedPtyTitle(ptyId, rawTitle) - if (!changed) { - return - } - const live = this.ptyTitleTrackersByPtyId.get(ptyId) - if (live?.applyingChunk) { - live.chunkTouchedSessionTabs = true - } else { - // Stale-working-title timer path — fires between chunks, so the - // per-chunk batching in onPtyData cannot pick it up. - this.touchMobileSessionSnapshotsForPty(ptyId) + // Why: trackers are created lazily on the first observed chunk. After an + // app relaunch the PTY/leaf records can already hold a persisted title; a + // cold tracker would miss the parked working→idle completion and never + // arm the stale-title timer for a persisted 'working' title. + let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null + if (initialTitle === null) { + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + initialTitle = leaf.lastOscTitle + break } } - }) + } + const tracker = createTerminalTitleTracker( + { + onTitle: (normalizedTitle, rawTitle, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle) + if (!changed) { + return + } + const live = this.ptyTitleTrackersByPtyId.get(ptyId) + const gateKey = this.makeMobileTitleGateKey(rawTitle, normalizedTitle) + const decorativeOnly = live?.lastMobileTitleGateKey === gateKey + if (live) { + live.lastMobileTitleGateKey = gateKey + } + if (live?.applyingChunk) { + // Why: synthetic spinner ticks change only the braille glyph + // ~12.5x/sec; fanning out full mobile session snapshots per frame + // is pure churn. Raw lastOscTitle updates above stay cheap. + if (!(live.applyingSyntheticFrame && decorativeOnly)) { + live.chunkTouchedSessionTabs = true + } + } else { + // Stale-working-title timer path — fires between chunks, so the + // per-chunk batching in onPtyData cannot pick it up. + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }, + // Why: agent transitions and bells become pty:sideEffect facts — + // main is the single byte parser for local/SSH PTYs; the renderer + // store handler decides what the facts mean (notification policy). + onAgentBecameWorking: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' }) + }, + onAgentBecameIdle: (title, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-idle', + title, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + }, + onAgentExited: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + }, + // Why: bell facts exist only for the pty:sideEffect channel. Headless + // serve has no consumer, so skip the per-chunk bell walk entirely. + ...(this.onTerminalSideEffects + ? { + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + } + } + : {}) + }, + initialTitle !== null ? { initialTitle } : {} + ) const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, - chunkTouchedSessionTabs: false + applyingSyntheticFrame: false, + lastMobileTitleGateKey: null, + chunkTouchedSessionTabs: false, + pendingFacts: [] } this.ptyTitleTrackersByPtyId.set(ptyId, entry) return entry @@ -3804,6 +4025,11 @@ export class OrcaRuntimeService { if (!title) { return } + // Why: a relaunched main starts its per-PTY title tracker cold — without + // this seed it misses the parked working→idle completion and never arms + // the stale-title timer for a persisted 'working' title. Seeding no-ops + // once a live title was observed, so live state always wins. + this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) const status = detectAgentStatusFromTitle(title) for (const leaf of this.getLeavesForPty(ptyId)) { // Why: seed lastOscTitle even when the seeded title doesn't classify diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e5f6ced121b..fef21cbfaef 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -209,6 +209,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -970,6 +971,11 @@ export type PreloadApi = { callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void + /** Batched derived side-effect facts for PTYs whose bytes transit local + * main; see docs/reference/terminal-side-effect-authority.md. */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void + /** Title-only replay snapshot for (re)attach; attention facts never replay. */ + getSideEffectSnapshot: (id: string) => Promise onExit: (callback: (data: { id: string; code: number }) => void) => () => void onSerializeBufferRequest: ( callback: (data: { @@ -1613,6 +1619,10 @@ export type PreloadApi = { telemetryAcknowledgeBanner: () => Promise settings: { get: () => Promise + /** Synchronous persisted-settings read for startup decisions that cannot + * wait for async hydration (terminal side-effect authority). Blocking + * IPC — call sparingly. */ + getSync: () => GlobalSettings | null set: (args: Partial) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index d0fd0930552..cc170aeac25 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -108,6 +108,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -772,6 +773,21 @@ const api = { return () => ipcRenderer.removeListener('pty:replay', listener) }, + /** Batched derived side-effect facts (title/bell/agent transitions) for + * PTYs whose bytes transit local main. Per-PTY in-order; deliberately not + * synchronized with pty:data (terminal-side-effect-authority.md). */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => + callback(batch) + ipcRenderer.on('pty:sideEffect', listener) + return () => ipcRenderer.removeListener('pty:sideEffect', listener) + }, + + /** Title-only replay snapshot applied on (re)attach — attention facts + * (bells/completions) never replay. */ + getSideEffectSnapshot: (id: string): Promise => + ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) => callback(data) @@ -1494,6 +1510,10 @@ const api = { settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), + // Why: blocking read for the few startup decisions (terminal side-effect + // authority) that cannot wait for async hydration. Call sparingly. + getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + set: (args: Record): Promise => ipcRenderer.invoke('settings:set', args), diff --git a/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts new file mode 100644 index 00000000000..7b08ca0c5c1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts @@ -0,0 +1,66 @@ +/** + * Agent-task-complete notification policy predicates and timing constants. + * + * Why extracted from pty-connection.ts: the parked byte watcher and the + * pty:sideEffect facts handler apply the exact live-path semantics without a + * pane, and policy must not drift between the three consumers + * (docs/reference/terminal-side-effect-authority.md). This module is + * deliberately dependency-light — no pane/xterm imports — so pane-less + * consumers can use it. + */ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { GlobalSettings } from '../../../../shared/types' + +/** Delay before BEL/completion OS notifications so the richer + * agent-task-complete notification can win a same-burst BEL race. */ +export const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 +/** Hard cap on waiting for hook detail before dispatching a completion. */ +export const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 +export const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 + +type NotificationSettingsState = { + settings: Pick | null +} + +export function isAgentTaskCompleteOsNotificationEnabledFromState( + state: NotificationSettingsState +): boolean { + const notifications = state.settings?.notifications + return notifications?.enabled !== false && notifications?.agentTaskComplete !== false +} + +export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean { + return state.settings?.experimentalTerminalAttention === true +} + +/** Completion tracking runs when either consumer (OS notification or the + * experimental terminal-attention marker) is enabled. */ +export function isAgentTaskCompleteTrackingEnabledFromState( + state: NotificationSettingsState +): boolean { + return ( + isAgentTaskCompleteOsNotificationEnabledFromState(state) || + isTerminalAttentionEnabledFromState(state) + ) +} + +export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { + return Boolean( + entry && + Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && + (entry.lastAssistantMessage || entry.toolName || entry.toolInput) + ) +} + +export function canDispatchAgentNotificationAfterGrace( + entry: AgentStatusEntry | undefined, + options: { allowDoneDetailAfterGrace?: boolean } = {} +): boolean { + // Why: hook-backed goal/mission loops can report `done` between milestones. + // User-input states may notify as soon as detail arrives, but `done` waits + // for the max quiet window so resumed work can cancel the pending banner. + return ( + hasAgentNotificationDetail(entry) && + (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) + ) +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index 471d71a218e..6f222088adb 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts' import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' const PTY_ID = 'pty-parked-1' @@ -21,6 +22,7 @@ type MockStoreState = { theme?: 'system' | 'dark' | 'light' promptCacheTimerEnabled?: boolean experimentalTerminalAttention?: boolean + terminalMainSideEffectAuthority?: boolean notifications?: { enabled?: boolean; agentTaskComplete?: boolean } } | null setRuntimePaneTitle: ReturnType @@ -52,10 +54,14 @@ vi.mock('@/store', () => ({ function createMockStoreState(): MockStoreState { return { + // Why: terminalMainSideEffectAuthority false pins the legacy byte-parser + // mode this suite was written for; the authority-on fact-consumer mode is + // covered by the dedicated describe block below. settings: { theme: 'system', promptCacheTimerEnabled: true, experimentalTerminalAttention: false, + terminalMainSideEffectAuthority: false, notifications: { enabled: true, agentTaskComplete: true } }, setRuntimePaneTitle: vi.fn(), @@ -457,4 +463,269 @@ describe('startParkedTerminalByteWatcher', () => { expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE) second.dispose() }) + + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on, the watcher must not register byte parsers — + // main is the single byte parser and the watcher's policy block consumes + // pty:sideEffect facts instead. The byte sidecar stays only for the 2031 + // reply and PR-link scan (they move to main in a later slice). + describe('with main side-effect authority on', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + async function dispatchFacts( + facts: TerminalSideEffectFact[], + options: { seq?: number; replay?: boolean } = {} + ): Promise { + const handler = await import('./terminal-side-effect-facts-handler') + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: PTY_ID, + seq: options.seq ?? 0, + ...(options.replay ? { replay: true } : {}), + facts + }) + } + + /** Feed chunks the way OrcaRuntimeService.onPtyData does: OSC 9999 strip, + * shared title tracker, one fact batch per chunk — the main half of the + * migration-safety parity check. */ + async function emitViaMainTrackerFacts(chunks: string[]): Promise { + const { createAgentStatusOscProcessor } = await import('../../../../shared/agent-status-osc') + const { createTerminalTitleTracker } = + await import('../../../../shared/terminal-output-side-effects') + const handler = await import('./terminal-side-effect-facts-handler') + const processStatusChunk = createAgentStatusOscProcessor() + let pending: TerminalSideEffectFact[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalizedTitle, rawTitle) => + pending.push({ kind: 'title', normalizedTitle, rawTitle }), + onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }), + onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }), + onAgentExited: () => pending.push({ kind: 'agent-exited' }), + onBell: () => pending.push({ kind: 'bell' }) + }) + let seq = 0 + for (const chunk of chunks) { + seq += chunk.length + tracker.handleChunk(processStatusChunk(chunk).cleanData) + if (pending.length > 0) { + handler._dispatchTerminalSideEffectBatchForTest({ ptyId: PTY_ID, seq, facts: pending }) + pending = [] + } + } + tracker.dispose() + } + + type RecordedCall = [string, ...unknown[]] + + /** Wrap the policy-visible store actions so byte mode and fact mode can be + * compared as one ordered outcome sequence. Timestamps are masked. */ + function recordPolicyOutcomes(): RecordedCall[] { + const calls: RecordedCall[] = [] + mockStoreState.setRuntimePaneTitle.mockImplementation((...args: unknown[]) => { + calls.push(['setRuntimePaneTitle', ...args]) + }) + mockStoreState.updateTabTitle.mockImplementation((...args: unknown[]) => { + calls.push(['updateTabTitle', ...args]) + }) + mockStoreState.markWorktreeUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markWorktreeUnread', ...args]) + }) + mockStoreState.markTerminalTabUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalTabUnread', ...args]) + }) + mockStoreState.markTerminalPaneUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalPaneUnread', ...args]) + }) + mockStoreState.setCacheTimerStartedAt.mockImplementation((key: unknown, at: unknown) => { + calls.push(['setCacheTimerStartedAt', key, typeof at === 'number' ? '' : at]) + }) + dispatchTerminalNotification.mockImplementation((...args: unknown[]) => { + calls.push(['dispatchTerminalNotification', ...args]) + }) + return calls + } + + it('does not consume bytes: a byte BEL produces no unread or notification', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'bell' }]) + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('fires the cache timer and completion from working→idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + await dispatchFacts([ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'agent-idle', title: IDLE_TITLE } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('clears state without completion attention for stale-derived idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + // Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a + // merely-paused agent must not earn a task-complete notification. + await dispatchFacts([ + { + kind: 'title', + normalizedTitle: 'Build feature', + rawTitle: 'Build feature', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true } + ]) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith( + TAB_ID, + PANE_ID, + 'Build feature' + ) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + dispose() + }) + + it('replay batches restore the title only — attention facts never replay', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts( + [ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'bell' }, + { kind: 'agent-idle', title: IDLE_TITLE } + ], + { replay: true } + ) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('still answers DECSET 2031 and observes PR links from the byte sidecar', async () => { + enableMainAuthority() + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?2031h') + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + emit('PR: https://github.com/orca-dev/orca/pull/42\r\n') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + dispose() + }) + + it('dispose unregisters the fact consumer and clears a written title slot', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }]) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + + await dispatchFacts([{ kind: 'bell' }]) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + // The key migration-safety check: the same bytes produce the identical + // ordered store outcome whether the watcher parses them directly (kill + // switch off) or consumes main-derived facts over the channel. + it('produces identical store outcomes via the channel as the byte parser did', async () => { + const fixtureChunks = [WORKING_TITLE_OSC, 'agent response body\r\n', `${IDLE_TITLE_OSC}\x07`] + + // Pass 1: legacy byte-parser mode. + const byteModeCalls = recordPolicyOutcomes() + { + const { dispose } = await startWatcher() + for (const chunk of fixtureChunks) { + emit(chunk) + flushSideEffects() + } + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + // Pass 2: fresh modules/store, authority on, facts derived by the + // shared main tracker from the same bytes. + vi.resetModules() + mockStoreState = createMockStoreState() + dispatchTerminalNotification.mockReset() + const factModeCalls = recordPolicyOutcomes() + { + enableMainAuthority() + const { dispose } = await startWatcher() + await emitViaMainTrackerFacts(fixtureChunks) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + expect(byteModeCalls.length).toBeGreaterThan(0) + expect(factModeCalls).toEqual(byteModeCalls) + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index ff78c010bd7..7fbf1f8d2c3 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -20,30 +20,32 @@ import { import { useAppStore } from '@/store' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' import { subscribeToPtyData } from './pty-dispatcher' import { createPtyOutputProcessor } from './pty-transport' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' import { dispatchTerminalNotification } from './use-notification-dispatch' // Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts. // The parked path must keep the live path's BEL-vs-completion race window so // notification behavior is identical whether a tab is parked or mounted. -const PARKED_NOTIFICATION_GRACE_MS = 250 +const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS type StoreState = ReturnType -// Why: these settings predicates are duplicated from pty-connection.ts instead -// of imported — that module drags the whole pane/xterm dependency graph into a -// watcher that exists precisely to run without a pane. function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean { - const notifications = state.settings?.notifications - return notifications?.enabled !== false && notifications?.agentTaskComplete !== false + return isAgentTaskCompleteOsNotificationEnabledFromState(state) } function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean { - return ( - isAgentTaskCompleteOsNotificationEnabled(state) || - state.settings?.experimentalTerminalAttention === true - ) + return isAgentTaskCompleteTrackingEnabledFromState(state) } export type ParkedTerminalByteWatcherOptions = { @@ -131,16 +133,11 @@ export function startParkedTerminalByteWatcher( }, PARKED_NOTIFICATION_GRACE_MS) } - // Why: reuse the transport's output processor so the parked path keeps the - // exact live-path parsing semantics — all-titles ordering, title - // normalization, the cursor-agent native-title drop, the OSC-aware stateful - // bell detector, and the working/idle agent tracker. - const processor = createPtyOutputProcessor({ - // Why: an agent that was already working at park time must still produce - // a working→idle transition; the fresh tracker would otherwise start cold - // and never fire the completion entry point while parked. - ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), - onTitleChange: (title) => { + // Why: one policy block for both consumption modes — byte parsing (kill + // switch off) and pty:sideEffect facts (main authority on). The semantics + // must be identical or flipping the switch changes notification behavior. + const sideEffectCallbacks = { + onTitleChange: (title: string): void => { const state = useAppStore.getState() wroteRuntimeTitleSlot = true state.setRuntimePaneTitle(tabId, paneId, title) @@ -148,7 +145,7 @@ export function startParkedTerminalByteWatcher( state.updateTabTitle(tabId, title) } }, - onBell: () => { + onBell: (): void => { const state = useAppStore.getState() state.markWorktreeUnread(worktreeId) state.markTerminalTabUnread(tabId) @@ -163,7 +160,14 @@ export function startParkedTerminalByteWatcher( scheduleTerminalBellNotification() } }, - onAgentBecameIdle: (title) => { + onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: stale-derived idles come from main's unthrottled 3s timer, not + // observed bytes — clear session state, never schedule the completion + // notification a merely-paused agent did not earn (live-path parity). + if (meta?.staleWorkingTitleClear) { + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + return + } const state = useAppStore.getState() // Why: mirrors pty-connection — null settings means "not hydrated yet"; // a spurious timestamp is harmless while a dropped one loses the timer. @@ -196,7 +200,7 @@ export function startParkedTerminalByteWatcher( }) }, PARKED_NOTIFICATION_GRACE_MS) }, - onAgentBecameWorking: () => { + onAgentBecameWorking: (): void => { // Why: a new API call refreshes the prompt-cache TTL, so clear any // running countdown; it restarts when the agent next becomes idle. useAppStore.getState().setCacheTimerStartedAt(paneKey, null) @@ -205,13 +209,44 @@ export function startParkedTerminalByteWatcher( scheduleTerminalBellNotification() } }, - onAgentExited: () => { + onAgentExited: (): void => { // Why: title reverting to a plain shell means the agent session ended; // a stale countdown must not survive in the sidebar while parked. useAppStore.getState().setCacheTimerStartedAt(paneKey, null) } + } + + // Why: parking eligibility excludes remote-runtime and SSH PTYs, so every + // watched PTY's bytes transit local main — when the authority switch is on, + // the watcher must NOT register byte parsers (the fact consumer below is + // the single policy consumer; double registration would double-fire bells). + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null }) + // Why (byte-parser mode only): reuse the transport's output processor so + // the parked path keeps the exact live-path parsing semantics — all-titles + // ordering, normalization, the cursor-agent native-title drop, the + // OSC-aware stateful bell detector, and the working/idle agent tracker. + // initialAgentTitle: an agent already working at park time must still + // produce a working→idle transition; main's continuous tracker covers this + // in fact-consumer mode. + const processor = mainSideEffectAuthority + ? null + : createPtyOutputProcessor({ + ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), + ...sideEffectCallbacks + }) + const unregisterFactConsumer = mainSideEffectAuthority + ? registerTerminalSideEffectFactConsumer({ + ptyId, + // Why: no title snapshot on park — the pane's runtime title slot is + // already current at park time, exactly like the byte-parser mode. + callbacks: sideEffectCallbacks + }) + : null + const respondToMode2031Subscribe = (data: string): void => { const scan = scanMode2031Sequences(mode2031ScanTail, data) mode2031ScanTail = scan.tail @@ -225,10 +260,13 @@ export function startParkedTerminalByteWatcher( sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) } + // Why: the byte sidecar stays in BOTH modes for the 2031 reply and PR-link + // scan — those move to main in a later slice. Only the title/bell/agent + // parsing is gated: processor is null when main holds authority. const unsubscribe = subscribeToPtyData(ptyId, (data) => { // Why: empty pane callbacks — the watcher wants only the parser side // effects; there is no xterm to deliver bytes to. - processor.processData(data, {}) + processor?.processData(data, {}) respondToMode2031Subscribe(data) for (const link of observeTerminalGitHubPRLink(data)) { useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) @@ -241,10 +279,11 @@ export function startParkedTerminalByteWatcher( } disposed = true unsubscribe() + unregisterFactConsumer?.() // Why: cancels the deferred side-effect drain, stale-title timer, and // tracker/bell-detector state so the watcher cannot fire after the // revealed pane's live parsers take over. - processor.clearAccumulatedState() + processor?.clearAccumulatedState() clearBellNotificationTimer() clearAgentTaskCompleteTimer() pendingBellNotification = false 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 9526727bb82..260ac4983d8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -58,6 +58,7 @@ type StoreState = { promptCacheTimerEnabled?: boolean activeRuntimeEnvironmentId?: string | null experimentalTerminalAttention?: boolean + terminalMainSideEffectAuthority?: boolean notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -448,7 +449,14 @@ describe('connectPanePty', () => { repos: [{ id: 'repo1', connectionId: null, displayName: 'orca' }], sshConnectionStates: new Map(), cacheTimerByKey: {}, - settings: { promptCacheTimerEnabled: true, experimentalTerminalAttention: true }, + // Why: terminalMainSideEffectAuthority false pins the legacy renderer + // byte-parser wiring this suite asserts on (onTitleChange/onBell on the + // transport). The authority-on fact-consumer mode has its own tests. + settings: { + promptCacheTimerEnabled: true, + experimentalTerminalAttention: true, + terminalMainSideEffectAuthority: false + }, codexRestartNoticeByPtyId: {}, deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, @@ -5068,6 +5076,251 @@ describe('connectPanePty', () => { expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() }) + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on (the default), local/SSH transports must not + // register title/bell/agent byte parsers; the pane's policy callbacks are + // registered as the PTY's single pty:sideEffect fact consumer instead. + describe('with main side-effect authority on', () => { + const SIDE_EFFECT_PARSER_CALLBACKS = [ + 'onTitleChange', + 'onBell', + 'onAgentBecameIdle', + 'onAgentBecameWorking', + 'onAgentExited' + ] as const + + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + it('omits byte-parser callbacks from the local transport options', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createdTransportOptions[0]).toBeDefined() + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeUndefined() + } + // The lifecycle callbacks stay on the transport — only side-effect + // parsing moves to the fact consumer. + expect(createdTransportOptions[0]?.onPtySpawn).toBeTypeOf('function') + expect(createdTransportOptions[0]?.onPtyExit).toBeTypeOf('function') + }) + + it('keeps byte-parser callbacks on remote-runtime transports', async () => { + enableMainAuthority() + enableActiveRuntimeEnvironment() + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + }) + + it('consumes pty:sideEffect facts with the live-path policy after spawn', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + connectPanePty(pane as never, manager as never, deps as never) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-1') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-1', + seq: 10, + facts: [ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'bell' } + ] + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex working') + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + expect(deps.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.dispatchNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'terminal-bell', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('stops consuming facts after the pane binding is disposed', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-2') + + binding.dispose() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-2', + seq: 1, + facts: [{ kind: 'bell' }] + }) + + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalTabUnread).not.toHaveBeenCalled() + }) + + it('schedules the completion notification for genuine working→idle facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-genuine') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 2, + facts: [ + { kind: 'title', normalizedTitle: '* Codex done', rawTitle: '* Codex done' }, + { kind: 'agent-idle', title: '* Codex done' } + ] + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears state without completion attention for stale-derived facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-stale') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + // Main's unthrottled 3s stale-title rewrite for a merely-paused agent. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 2, + facts: [ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ] + }) + + // The cleared title still lands; the cache timer is cleared. + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'Codex') + expect(deps.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + makePaneKey('tab-1', LEAF_1), + null + ) + // But no task-complete notification or unread attention is scheduled. + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { + // Pre-hydration: the store has no settings yet, but the user persisted + // the kill switch off. The pane must register byte parsers, not a fact + // consumer — and hydration must not produce a second consumer. + mockStoreState.settings = null + ;(window.api as unknown as Record).settings = { + getSync: vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + } + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-prehydration') + + // No fact consumer registered: channel batches are dropped. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-prehydration', + seq: 1, + facts: [{ kind: 'bell' }] + }) + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + + // Hydration lands with the switch still off: byte parsing stays the + // single consumer — one BEL marks unread exactly once. + mockStoreState.settings = { terminalMainSideEffectAuthority: false } + notifyStoreSubscribers() + const onBell = createdTransportOptions[0]?.onBell as () => void + onBell() + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + }) + }) + it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 33f7d5175f4..49c7e4e467e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -89,14 +89,22 @@ import { } from '../../../../shared/agent-session-resume' import { isWslUncPath } from '../../../../shared/wsl-paths' import { shouldSkipHiddenRendererOutput } from './hidden-renderer-skip-eligibility' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, + canDispatchAgentNotificationAfterGrace, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const REMOTE_PTY_ID_PREFIX = 'remote:' const PTY_CONNECT_DIAG_LIMIT = 200 -const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 -const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 -const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 @@ -346,37 +354,11 @@ type PanePtyBinding = IDisposable & { } function isAgentTaskCompleteNotificationEnabled(): boolean { - return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState()) -} - -function isAgentTaskCompleteNotificationEnabledFromState( - state: ReturnType -): boolean { - const notifications = state.settings?.notifications - return notifications?.enabled !== false && notifications?.agentTaskComplete !== false -} - -function isTerminalAttentionEnabledFromState( - state: ReturnType -): boolean { - return state.settings?.experimentalTerminalAttention === true + return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) } function isAgentTaskCompleteTrackingEnabled(): boolean { - const state = useAppStore.getState() - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) -} - -function isAgentTaskCompleteTrackingEnabledFromState( - state: ReturnType -): boolean { - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) + return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState()) } const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>() @@ -386,7 +368,7 @@ let agentTaskCompleteTrackingSettingsSnapshot: string | null = null function getAgentTaskCompleteTrackingSettingsSnapshot( state: ReturnType ): string { - return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteNotificationEnabledFromState(state)}` + return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}` } function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void { @@ -420,27 +402,6 @@ function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => } } -function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { - return Boolean( - entry && - Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && - (entry.lastAssistantMessage || entry.toolName || entry.toolInput) - ) -} - -function canDispatchAgentNotificationAfterGrace( - entry: AgentStatusEntry | undefined, - options: { allowDoneDetailAfterGrace?: boolean } = {} -): boolean { - // Why: hook-backed goal/mission loops can report `done` between milestones. - // User-input states may notify as soon as detail arrives, but `done` waits - // for the max quiet window so resumed work can cancel the pending banner. - return ( - hasAgentNotificationDetail(entry) && - (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) - ) -} - function recordPtyConnectDiagnostic(message: string): void { if (!e2eConfig.exposeStore) { return @@ -1013,6 +974,34 @@ export function connectPanePty( bindPanePtyId(pane.id, ptyId, deps.tabId) pane.container.dataset.ptyId = ptyId } + + // Why: with main side-effect authority on, the pane's title/bell/agent + // policy callbacks consume pty:sideEffect facts instead of transport byte + // parsers (which stay unregistered) — same policy code, single consumer. + // restoreTitleOnRegister replaces the eager-replay title restore: main's + // title-only snapshot carries the no-attention-replay rule. + let unregisterSideEffectFactConsumer: (() => void) | null = null + const registerSideEffectFactConsumerForPty = (ptyId: string): void => { + if (!mainSideEffectAuthority || disposed) { + return + } + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ + ptyId, + callbacks: { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited + }, + restoreTitleOnRegister: true + }) + } + const dropSideEffectFactConsumer = (): void => { + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = null + } const clearPanePtyFitBinding = (): void => { // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. @@ -1045,6 +1034,7 @@ export function connectPanePty( const onExit = (ptyId: string): void => { agentCompletionCoordinator.dispose() + dropSideEffectFactConsumer() clearPanePtyFitBinding() // Why: sleep and intentional pane-close/restart paths already record the // desired lifecycle state before kill. Do not erase wake hints here. @@ -1084,10 +1074,18 @@ export function connectPanePty( let hasConsideredInitialCacheTimerSeed = false let allowInitialIdleCacheSeed = false - const onTitleChange = (title: string, rawTitle: string): void => { + const onTitleChange = ( + title: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ): void => { manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle)) deps.setRuntimePaneTitle(deps.tabId, pane.id, title) - if (syncAgentTaskCompleteTrackingEnabled()) { + // Why: a stale-derived cleared title comes from main's unthrottled 3s + // timer, not agent output. It must update the visible title but never + // feed completion tracking — observeTitle would classify the cleared + // title as idle and mint a task-complete for a merely-paused agent. + if (!meta?.staleWorkingTitleClear && syncAgentTaskCompleteTrackingEnabled()) { agentCompletionCoordinator.observeTitle(rawTitle) } // Why: only the focused pane should drive the tab title — otherwise two @@ -1206,6 +1204,7 @@ export function connectPanePty( const onPtySpawn = (ptyId: string): void => { setPanePtyFitBinding(ptyId) + registerSideEffectFactConsumerForPty(ptyId) deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) // Why: Command Code has no prompt-start hook. Seed the visible working row @@ -1406,7 +1405,16 @@ export function connectPanePty( // findable after the OS banner is gone. Double-firing with a concurrent BEL // is handled by delaying the BEL OS notification below; main still keeps a // 5 s per-worktree dedupe as the final guard. - const onAgentBecameIdle = (title: string): void => { + const onAgentBecameIdle = (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not + // observed bytes — a merely-paused agent (>3s silent mid-task, window + // minimized) would otherwise mint a false task-complete OS notification + // that renderer timer throttling previously damped. Clear session-tied + // state only; never schedule completion attention from it. + if (meta?.staleWorkingTitleClear) { + deps.setCacheTimerStartedAt(cacheKey, null) + return + } // Why: only start the prompt-cache countdown for Claude agents — other // agents have different (or no) prompt-caching semantics and showing a // timer for them would be misleading. @@ -1488,6 +1496,14 @@ export function connectPanePty( const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || null const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? activeRuntimeEnvironmentId const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null + // Why: when main holds side-effect authority for this PTY's bytes, the + // transport must NOT register title/bell/agent byte parsers — the + // pty:sideEffect fact consumer below is the single policy consumer. + // Decided once at transport creation so a fact never has two consumers. + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: state.settings, + runtimeEnvironmentId + }) const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' let lastTerminalInputAt = Number.NEGATIVE_INFINITY const markTerminalInputSent = (): void => { @@ -1509,12 +1525,16 @@ export function connectPanePty( ...(shellOverride ? { shellOverride } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, - onTitleChange, onPtySpawn, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited, + ...(mainSideEffectAuthority + ? {} + : { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited + }), // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService // parses OSC 9999 before renderer delivery and forwards through the hook // server with local/SSH identity. Remote-runtime streams do not pass through @@ -2792,6 +2812,7 @@ export function connectPanePty( return } setPanePtyFitBinding(ptyId) + registerSideEffectFactConsumerForPty(ptyId) deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) agentCompletionCoordinator.startProcessTracking() @@ -3274,6 +3295,7 @@ export function connectPanePty( onError: reportError } }) + registerSideEffectFactConsumerForPty(attachPtyId) deps.syncPanePtyLayoutBinding(pane.id, attachPtyId) deps.updateTabPtyId(deps.tabId, attachPtyId) agentCompletionCoordinator.startProcessTracking() @@ -3324,6 +3346,7 @@ export function connectPanePty( onError: reportError } }) + registerSideEffectFactConsumerForPty(spawnedPtyId) // Why: attach sets the transport's PTY id; starting process // tracking before this point no-ops because getPtyId() is empty. agentCompletionCoordinator.startProcessTracking() @@ -3373,6 +3396,9 @@ export function connectPanePty( unregisterBacklogRecovery = null unregisterDocumentVisibilityRecovery?.() unregisterDocumentVisibilityRecovery = null + // Why: a parked-tab watcher may take over this PTY's facts in the same + // effect flush; the pane's consumer must be gone before that handoff. + dropSideEffectFactConsumer() clearPanePtyFitBinding() discardTerminalOutput(pane.terminal) unregisterE2ePtyDataInjection() diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 7edf7a05bda..af3b4c67a76 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -23,7 +23,7 @@ import type { PtyConnectResult, PtyDataMeta } from './pty-dispatcher' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from '../../../../shared/terminal-bell-detector' import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts new file mode 100644 index 00000000000..395eb87d732 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -0,0 +1,365 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts' +import { + _dispatchTerminalSideEffectBatchForTest, + _resetTerminalSideEffectFactConsumersForTest, + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer, + type TerminalSideEffectFactConsumerCallbacks +} from './terminal-side-effect-facts-handler' + +const PTY_ID = 'wt-1#1' + +function createCallbackRecorder(): { + callbacks: TerminalSideEffectFactConsumerCallbacks + events: unknown[][] +} { + const events: unknown[][] = [] + return { + events, + callbacks: { + onTitleChange: (normalizedTitle, rawTitle) => + events.push(['title', normalizedTitle, rawTitle]), + onBell: () => events.push(['bell']), + onAgentBecameIdle: (title) => events.push(['idle', title]), + onAgentBecameWorking: () => events.push(['working']), + onAgentExited: () => events.push(['exited']) + } + } +} + +function batch( + facts: TerminalSideEffectBatch['facts'], + options: Partial = {} +): TerminalSideEffectBatch { + return { ptyId: PTY_ID, seq: 0, facts, ...options } +} + +describe('isMainTerminalSideEffectAuthorityForPty', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + function setPersistedSettingsSync(settings: unknown): void { + ;(globalThis as { window: unknown }).window = { + api: { settings: { getSync: () => settings } } + } + } + + it('is on by default for PTYs whose bytes transit local main', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null }) + ).toBe(true) + // Why: settings hydrate asynchronously; the default-on switch must not + // flip authority off during the null-settings startup window. + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('is off for remote-runtime PTYs regardless of the setting', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: 'env-1' + }) + ).toBe(false) + }) + + it('is off when the kill switch is disabled', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: false }, + runtimeEnvironmentId: null + }) + ).toBe(false) + }) + + it('honors the persisted kill switch before settings hydrate', () => { + // Why: the authority decision is made once at transport creation; a pane + // bound during startup must not pick main authority when the user + // persisted the switch off. + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(false) + }) + + it('stays on pre-hydration when the persisted switch is on or unset', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: true }) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + + _resetTerminalSideEffectFactConsumersForTest() + setPersistedSettingsSync({}) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('prefers hydrated settings over the persisted sync read', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: null + }) + ).toBe(true) + }) + + it('caches the sync read so panes do not re-block per bind', () => { + const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + ;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } } + + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + + expect(getSync).toHaveBeenCalledTimes(1) + }) +}) + +describe('registerTerminalSideEffectFactConsumer', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('routes live facts to the registered consumer in batch order', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'agent-idle', title: '✳ Claude' }, + { kind: 'bell' } + ]) + ) + + expect(events).toEqual([ + ['title', '⠋ Claude', '⠋ Claude'], + ['working'], + ['title', '✳ Claude', '✳ Claude'], + ['idle', '✳ Claude'], + ['bell'] + ]) + }) + + it('passes stale-clear provenance through to the title and idle callbacks', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle, _rawTitle, meta) => + events.push(['title', normalizedTitle, meta]), + onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'agent-idle', title: 'Codex done' }, + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + ) + + expect(events).toEqual([ + ['idle', 'Codex done', undefined], + ['title', 'Codex', { staleWorkingTitleClear: true }], + ['idle', 'Codex', { staleWorkingTitleClear: true }] + ]) + }) + + it('drops batches for PTYs without a registered consumer', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' })) + + expect(events).toEqual([]) + }) + + it('applies only title facts from replay batches — no attention replay', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'bell' }, + { kind: 'agent-idle', title: '✳ Claude' } + ], + { replay: true, seq: 10 } + ) + ) + + expect(events).toEqual([['title', '✳ Claude', '✳ Claude']]) + }) + + it('drops a replay title not newer than the last applied live title', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], { + replay: true, + seq: 20 + }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], { + replay: true, + seq: 21 + }) + ) + + expect(events).toEqual([ + ['title', 'live', 'live'], + ['title', 'newer', 'newer'] + ]) + }) + + it('keeps exactly one consumer per PTY: a new registration replaces the old', () => { + const first = createCallbackRecorder() + const second = createCallbackRecorder() + const disposeFirst = registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks + }) + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + // A stale registration's dispose must not evict the live consumer. + disposeFirst() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(first.events).toEqual([]) + expect(second.events).toEqual([['bell'], ['bell']]) + }) + + it('stops routing after the consumer unregisters', () => { + const { callbacks, events } = createCallbackRecorder() + const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + dispose() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(events).toEqual([]) + }) + + it('subscribes to the channel once and routes IPC batches', () => { + let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null + const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => { + channelCallback = callback + return () => { + channelCallback = null + } + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { onSideEffect } } + } + const first = createCallbackRecorder() + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks }) + registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks }) + + expect(onSideEffect).toHaveBeenCalledTimes(1) + channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' })) + expect(second.events).toEqual([['bell']]) + }) + + it('applies the title snapshot on register unless the registration was replaced', async () => { + let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {} + const getSideEffectSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot } } + } + + const first = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks, + restoreTitleOnRegister: true + }) + expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID) + + // Replace before the snapshot resolves: the slow snapshot must not fire + // into the superseded registration. + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + resolveSnapshot( + batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ) + await Promise.resolve() + + expect(first.events).toEqual([]) + expect(second.events).toEqual([]) + }) + + it('restores the snapshot title for a live registration', async () => { + const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } } + } + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks, + restoreTitleOnRegister: true + }) + + await Promise.resolve() + await Promise.resolve() + + expect(events).toEqual([['title', 'restored', 'restored']]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts new file mode 100644 index 00000000000..7c9789b69bf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -0,0 +1,214 @@ +/** + * Renderer consumer registry for the `pty:sideEffect` channel. + * + * Why: with main as the side-effect parser for local-daemon/SSH PTYs + * (docs/reference/terminal-side-effect-authority.md), the renderer no longer + * derives title/bell/agent facts from bytes for those PTYs. This module is + * the single channel subscriber; mounted panes and parked-tab watchers + * register exactly one fact consumer per PTY (their existing policy + * callbacks), so every fact has exactly one policy consumer regardless of + * whether the tab is mounted, hidden, or parked. Facts for PTYs without a + * registered consumer are dropped — mirroring today's eager-buffer behavior + * where pre-mount output produces no attention side effects. + */ +import type { GlobalSettings } from '../../../../shared/types' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../../../shared/terminal-side-effect-facts' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedAuthorityFlagCache: boolean | null | undefined + +function readPersistedSideEffectAuthorityFlagSync(): boolean | null { + if (persistedAuthorityFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedAuthorityFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null + } catch { + persistedAuthorityFlagCache = null + } + } + return persistedAuthorityFlagCache +} + +/** + * Structural authority predicate: main owns side effects for a PTY when its + * bytes transit local main (everything except remote-runtime PTYs) and the + * kill switch is on. Decided at transport/watcher creation — never per chunk — + * so each fact has one consumer with no race. + */ +export function isMainTerminalSideEffectAuthorityForPty(args: { + settings: Pick | null + /** Remote-runtime owner environment; null means bytes transit local main. */ + runtimeEnvironmentId: string | null +}): boolean { + if (args.runtimeEnvironmentId !== null) { + return false + } + if (args.settings !== null) { + return args.settings.terminalMainSideEffectAuthority !== false + } + // Why: settings hydrate asynchronously, and the authority decision made + // here at transport/watcher creation is never revisited. A pane bound + // before hydration must honor the persisted kill switch — otherwise a user + // who turned main authority off gets startup panes with no byte parsers + // and a fact consumer they disabled. Surfaces without the sync read (web + // remote clients, tests) keep the default-on behavior. + return readPersistedSideEffectAuthorityFlagSync() !== false +} + +export type TerminalSideEffectFactConsumerCallbacks = { + /** `meta.staleWorkingTitleClear` marks facts derived from main's 3s + * stale-title timer — policy must clear title/cache state without + * scheduling task-complete notifications or unread attention. */ + onTitleChange?: ( + normalizedTitle: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ) => void + onBell?: () => void + onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void +} + +type ConsumerEntry = { + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Output sequence of the last live title fact applied. Replay snapshots at + * or before this point are stale and must not regress the title state. */ + lastLiveTitleSeq: number | null +} + +const consumersByPtyId = new Map() +let channelUnsubscribe: (() => void) | null = null + +function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { + switch (fact.kind) { + case 'title': + entry.lastLiveTitleSeq = seq + entry.callbacks.onTitleChange?.( + fact.normalizedTitle, + fact.rawTitle, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'bell': + entry.callbacks.onBell?.() + return + case 'agent-working': + entry.callbacks.onAgentBecameWorking?.() + return + case 'agent-idle': + entry.callbacks.onAgentBecameIdle?.( + fact.title, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'agent-exited': + entry.callbacks.onAgentExited?.() + } +} + +function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void { + if (batch.replay) { + // Why: the no-attention-replay rule — (re)attach snapshots restore title + // state only; historical bells/completions must never fire again. A replay + // older (by output sequence) than the last live title fact is stale. + if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) { + return + } + for (const fact of batch.facts) { + if (fact.kind === 'title') { + entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle) + } + } + return + } + for (const fact of batch.facts) { + applyLiveFact(entry, fact, batch.seq) + } +} + +function handleSideEffectBatch(batch: TerminalSideEffectBatch): void { + const entry = consumersByPtyId.get(batch.ptyId) + if (!entry) { + return + } + applyBatchToConsumer(entry, batch) +} + +function ensureSideEffectChannelSubscription(): void { + if (channelUnsubscribe !== null) { + return + } + // Why: optional-chained from globalThis so unit tests (and any non-preload + // surface) without window.api degrade to "no channel" instead of throwing. + const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect + if (typeof onSideEffect !== 'function') { + return + } + channelUnsubscribe = onSideEffect(handleSideEffectBatch) +} + +export type TerminalSideEffectFactConsumerOptions = { + ptyId: string + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Pull main's title-only replay snapshot on registration. Pane transports + * use this in place of deriving titles from eager-buffer byte replay; + * parked watchers skip it because the pane's runtime title slot is already + * current at park time. */ + restoreTitleOnRegister?: boolean +} + +/** + * Register the single fact consumer for a PTY. A new registration replaces a + * stale one for the same PTY (same semantics as the parked watcher registry): + * two consumers would double-fire bell/completion policy for the same bytes. + */ +export function registerTerminalSideEffectFactConsumer( + options: TerminalSideEffectFactConsumerOptions +): () => void { + ensureSideEffectChannelSubscription() + const entry: ConsumerEntry = { + callbacks: options.callbacks, + lastLiveTitleSeq: null + } + consumersByPtyId.set(options.ptyId, entry) + + if (options.restoreTitleOnRegister) { + const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot + if (typeof getSnapshot === 'function') { + void getSnapshot(options.ptyId) + .then((batch) => { + // Why: apply only while this registration is still the live + // consumer; a slow snapshot must not fire into a replaced one. + if (batch && consumersByPtyId.get(options.ptyId) === entry) { + applyBatchToConsumer(entry, { ...batch, replay: true }) + } + }) + .catch(() => {}) + } + } + + return () => { + if (consumersByPtyId.get(options.ptyId) === entry) { + consumersByPtyId.delete(options.ptyId) + } + } +} + +/** Test seam: deliver a batch as if it arrived on the channel. */ +export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void { + handleSideEffectBatch(batch) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalSideEffectFactConsumersForTest(): void { + consumersByPtyId.clear() + channelUnsubscribe?.() + channelUnsubscribe = null + persistedAuthorityFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index 72d27837e54..6c4f2f10fbc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -20,6 +20,7 @@ type TitleFactEvent = | { kind: 'became-working' } | { kind: 'became-idle'; title: string } | { kind: 'agent-exited' } + | { kind: 'bell' } type TitleFactPath = { events: TitleFactEvent[] @@ -32,7 +33,8 @@ function createRendererPath(): TitleFactPath { onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), onAgentBecameWorking: () => events.push({ kind: 'became-working' }), onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), - onAgentExited: () => events.push({ kind: 'agent-exited' }) + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) }) const callbacks = { onData: () => {} } return { @@ -56,7 +58,8 @@ function createMainPath(): TitleFactPath { onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), onAgentBecameWorking: () => events.push({ kind: 'became-working' }), onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), - onAgentExited: () => events.push({ kind: 'agent-exited' }) + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) }) return { events, @@ -147,4 +150,40 @@ describe('main title tracker parity with the renderer transport processor', () = expect(paths.main.events).toEqual(paths.renderer.events) expect(paths.main.events).toEqual([]) }) + + it('orders a real BEL after the same chunk titles in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Claude working${BEL}done text${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.map((event) => event.kind)).toEqual([ + 'title', + 'became-working', + 'bell' + ]) + }) + + it('never reports an OSC-terminator BEL as a bell, even spanning chunks', () => { + feedBoth(paths, `${ESC}]0;par`) + feedBoth(paths, `tial title${BEL}`) + feedBoth(paths, `${ESC}]2;st-terminated${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.filter((event) => event.kind === 'bell')).toEqual([]) + }) + + it('treats a BEL after a CAN-cancelled OSC as a real bell in both paths', () => { + // ECMA-48 CAN aborts the in-progress OSC; the next BEL is a real bell. + feedBoth(paths, `${ESC}]0;truncated`) + feedBoth(paths, `\x18${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([{ kind: 'bell' }]) + }) + + it('keeps bells suppressed inside OSC 9999 status payloads in both paths', () => { + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) }) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 16d72501469..06a0f77d99f 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -436,6 +436,9 @@ function createWebPreloadApi(): Partial { }, settings: { get: async () => getStoredSettings(), + // Why: localStorage-backed settings are synchronous in the web client, + // so the pre-hydration kill-switch read works the same as desktop. + getSync: () => getStoredSettings(), set: async (updates) => { if (updates.activeRuntimeEnvironmentId === null) { disconnectActiveRuntimeEnvironment() @@ -2258,6 +2261,10 @@ function createPtyApi(): NonNullable['pty']> { getCwd: () => Promise.resolve('~'), listSessions: () => Promise.resolve([]), getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs never transit local main, so the web client has + // no side-effect facts source; renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ pendingPtyCount: 0, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 554b37aa14f..fbb8e055a2f 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -89,6 +89,11 @@ const PI_IDLE_PREFIX = '\u03c0 - ' // π - (Pi titlebar extension idle format) // eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g +// Braille spinner frame glyphs (U+2800–U+28FF) — the decorative animation +// class agents rotate through while working. +// eslint-disable-next-line no-control-regex -- intentional unicode range +const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g + /** * Extract the last OSC title-set sequence from raw PTY data. * Agent CLIs (Claude Code, Gemini, etc.) set OSC titles to announce their @@ -188,8 +193,7 @@ export function clearWorkingIndicators(title: string): string { cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') // Braille spinner characters (U+2800–U+28FF) - // eslint-disable-next-line no-control-regex -- intentional unicode range - cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') + cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '') // Claude Code ". " working prefix if (cleaned.startsWith('. ')) { @@ -220,6 +224,9 @@ export function createAgentStatusTracker( initialTitle?: string ): { handleTitle: (title: string) => void + /** Seed the last-known status after creation without firing callbacks — + * for trackers restored mid-session (app relaunch with persisted titles). */ + seedTitle: (title: string) => void /** Clear accumulated status so a stale working→idle transition cannot fire * after the owning transport is torn down. */ reset: () => void @@ -254,6 +261,9 @@ export function createAgentStatusTracker( lastStatus = newStatus } }, + seedTitle(title: string): void { + lastStatus = detectAgentStatusFromTitle(title) + }, reset(): void { lastStatus = null } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index bb5ee473647..e60913ef4ab 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -274,6 +274,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, terminalHiddenViewParking: true, + terminalMainSideEffectAuthority: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/renderer/src/components/terminal-pane/bell-detector.test.ts b/src/shared/terminal-bell-detector.test.ts similarity index 93% rename from src/renderer/src/components/terminal-pane/bell-detector.test.ts rename to src/shared/terminal-bell-detector.test.ts index bd48ba98381..8f1ce7b0bfc 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.test.ts +++ b/src/shared/terminal-bell-detector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from './terminal-bell-detector' describe('createBellDetector', () => { it('skips ANSI chunks without losing later real bells', () => { diff --git a/src/renderer/src/components/terminal-pane/bell-detector.ts b/src/shared/terminal-bell-detector.ts similarity index 82% rename from src/renderer/src/components/terminal-pane/bell-detector.ts rename to src/shared/terminal-bell-detector.ts index 1afdb51e28c..cce08a7dbd0 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.ts +++ b/src/shared/terminal-bell-detector.ts @@ -2,6 +2,10 @@ * Stateful BEL detector that correctly ignores BEL (0x07) bytes * occurring inside OSC escape sequences. * + * Shared between the renderer transport processor and main's per-PTY + * side-effect tracker (docs/reference/terminal-side-effect-authority.md): + * bell semantics must not drift between the two parsing authorities. + * * Why stateful: PTY data arrives in arbitrary chunks, so an OSC sequence * may span multiple calls. The detector tracks in-progress escape state * across invocations so a BEL used as an OSC terminator is never @@ -17,7 +21,10 @@ * that ended mid-escape does not leak into the next stream. */ export type BellDetector = { - chunkContainsBell(data: string): boolean + /** `hints.containsOscIntroducer` lets a caller that already scanned for + * `\x1b]` (the title-extraction gate) share the result instead of paying + * a second includes() pass per chunk on the hot path. */ + chunkContainsBell(data: string, hints?: { containsOscIntroducer?: boolean }): boolean reset(): void } @@ -27,11 +34,11 @@ export function createBellDetector(): BellDetector { let pendingOscEscape = false return { - chunkContainsBell(data: string): boolean { + chunkContainsBell(data: string, hints: { containsOscIntroducer?: boolean } = {}): boolean { if (!inOsc && !pendingEscape && !data.includes('\x07')) { // Why: CSI/plain chunks with no BEL and no OSC start cannot affect // bell state; avoid walking every byte of normal terminal output. - if (!data.includes('\x1b]')) { + if (!(hints.containsOscIntroducer ?? data.includes('\x1b]'))) { pendingEscape = data.endsWith('\x1b') return false } diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index eb62817e369..a1a0ed56a14 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -17,24 +17,66 @@ import { isCursorNativeAgentTitle, normalizeTerminalTitle } from './agent-detection' +import { createBellDetector } from './terminal-bell-detector' /** Ms of title-less output after a working title before it is cleared. */ export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 +// Braille spinner frame glyphs (U+2800–U+28FF) — the decorative animation +// class agents rotate through while working. Mirrors the range +// clearWorkingIndicators strips in agent-detection.ts. +// eslint-disable-next-line no-control-regex -- intentional unicode range +const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g + +/** + * Strip decorative braille spinner frame glyphs for change comparisons. + * Two working titles that differ only by the animation frame (e.g. + * "⠋ Cursor Agent" vs "⠙ Cursor Agent") compare equal after stripping — + * the gate consumers use to avoid fan-out churn on spinner ticks. + */ +export function stripBrailleSpinnerGlyphs(title: string): string { + return title.replace(BRAILLE_SPINNER_RE, '').trim() +} + +/** Provenance for title/idle facts. `staleWorkingTitleClear` marks facts + * synthesized by the 3s stale-working-title timer rather than observed + * bytes — consumers must not treat them as genuine task completions. */ +export type TerminalTitleFactMeta = { + staleWorkingTitleClear?: boolean +} + export type TerminalTitleTrackerCallbacks = { /** * Fired once per observed OSC title, in byte order — including the * synthesized cleared title when the stale-working timer fires. */ - onTitle?: (normalizedTitle: string, rawTitle: string) => void - onAgentBecameIdle?: (title: string) => void + onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void onAgentBecameWorking?: () => void onAgentExited?: () => void + /** + * Fired once per chunk containing a real BEL (OSC-aware, escape state kept + * across chunks), after the chunk's title facts — the renderer drain order. + */ + onBell?: () => void } export type TerminalTitleTracker = { /** Feed one raw PTY chunk; titles are applied synchronously in byte order. */ handleChunk: (data: string) => void + /** + * Apply a main-fabricated OSC title/BEL frame (agent hook spinner frames). + * Parsed statelessly — never through the chunk bell detector — so a + * synthetic tick landing between two real chunks that split an OSC cannot + * corrupt the cross-chunk escape state into phantom or swallowed bells. + */ + applySyntheticTitleFrame: (frame: string) => void + /** + * Seed the last-known title for a tracker created mid-session (app relaunch + * with persisted/snapshot titles). No-ops once any title has been observed + * or seeded — live state always wins. Fires no callbacks. + */ + seedInitialTitle: (rawTitle: string) => void /** Last title surfaced through onTitle, after normalization. */ getLastNormalizedTitle: () => string | null /** Cancel the stale-title timer and clear accumulated tracker state. */ @@ -45,18 +87,26 @@ export function createTerminalTitleTracker( callbacks: TerminalTitleTrackerCallbacks, options: { initialTitle?: string } = {} ): TerminalTitleTracker { - const { onTitle, onAgentBecameIdle, onAgentBecameWorking, onAgentExited } = callbacks + const { onTitle, onAgentBecameIdle, onAgentBecameWorking, onAgentExited, onBell } = callbacks + const bellDetector = onBell ? createBellDetector() : null // Why: seed both the emitted-title memory (stale-title probe) and the agent // tracker so a mid-session tracker behaves as if it had observed the pane's // last live title — parity with the renderer processor's seeding. let lastEmittedTitle: string | null = options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null let staleTitleTimer: ReturnType | null = null + // Why: set while the stale timer's cleared title flows through the agent + // tracker so the resulting idle callback carries timer provenance — the + // renderer must not turn a stale clear into a task-complete notification. + let applyingStaleWorkingTitleClear = false const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( (title) => { - onAgentBecameIdle?.(title) + onAgentBecameIdle?.( + title, + applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) }, onAgentBecameWorking, onAgentExited, @@ -84,23 +134,31 @@ export function createTerminalTitleTracker( } function handleChunk(data: string): void { + // Why: this is main's per-chunk hot path — scan for the OSC introducer + // once and share the result with the bell detector's fast-path gate. + const containsOscIntroducer = data.includes('\x1b]') + // Why: the bell detector must consume EVERY chunk so OSC sequences that + // span chunk boundaries keep their escape state, even when the chunk has + // no title. The fact itself is surfaced after the chunk's titles, the + // renderer drain's order (payloads → titles → bell). + const containsBell = bellDetector + ? bellDetector.chunkContainsBell(data, { containsOscIntroducer }) + : false // Why: feed EVERY OSC title in the chunk in byte order, never just the // last one. node-pty plus the main-process batch window commonly coalesce // multiple title updates into a single payload; a last-title reader drops // intra-chunk working→idle transitions (issue #1083). - const titles = data.includes('\x1b]') ? extractAllOscTitles(data) : [] + const titles = containsOscIntroducer ? extractAllOscTitles(data) : [] if (titles.length > 0) { clearStaleTitleTimer() for (const title of titles) { applyObservedTitle(title) } - return - } - // Why: agents that exit without resetting their title leave a stale - // working spinner behind. Any title-less output while the last title - // classifies as working restarts a 3s timer that rewrites the title to - // its cleared form — the renderer transport's stale-title semantics. - if ( + } else if ( + // Why: agents that exit without resetting their title leave a stale + // working spinner behind. Any title-less output while the last title + // classifies as working restarts a 3s timer that rewrites the title to + // its cleared form — the renderer transport's stale-title semantics. data.length > 0 && lastEmittedTitle !== null && detectAgentStatusFromTitle(lastEmittedTitle) === 'working' @@ -111,19 +169,64 @@ export function createTerminalTitleTracker( if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { const cleared = clearWorkingIndicators(lastEmittedTitle) lastEmittedTitle = cleared - onTitle?.(cleared, cleared) - agentTracker?.handleTitle(cleared) + // Why: tag timer-synthesized facts. Main's timer is unthrottled + // (unlike the renderer timers that previously damped this path in + // hidden windows), so a merely-paused agent must be distinguishable + // from a genuine working→idle completion downstream. + applyingStaleWorkingTitleClear = true + try { + onTitle?.(cleared, cleared, { staleWorkingTitleClear: true }) + agentTracker?.handleTitle(cleared) + } finally { + applyingStaleWorkingTitleClear = false + } } }, STALE_WORKING_TITLE_TIMEOUT_MS) } + if (containsBell) { + onBell?.() + } + } + + function applySyntheticTitleFrame(frame: string): void { + // Why: synthetic frames have an exact main-fabricated shape, so they are + // parsed statelessly here. Feeding them through handleChunk would run the + // stateful bell detector: a tick landing while a REAL OSC is split across + // two chunks would consume the pending escape state, minting a phantom + // bell from the continuation chunk or swallowing a real one. + const titles = extractAllOscTitles(frame) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } + // The deliberate permission BEL rides outside the OSC title sequence. A + // FRESH detector instance keeps the OSC-terminator-vs-bell semantics + // while guaranteeing zero interaction with the chunk detector's state. + if (onBell && createBellDetector().chunkContainsBell(frame)) { + onBell() + } } return { handleChunk, + applySyntheticTitleFrame, + seedInitialTitle(rawTitle: string): void { + // Why: the cursor-agent literal drop applies to seeds too — restoring + // the bare native title would stomp synthesized spinner state exactly + // like emitting it live would. + if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + agentTracker?.seedTitle(rawTitle) + }, getLastNormalizedTitle: () => lastEmittedTitle, dispose(): void { clearStaleTitleTimer() agentTracker?.reset() + bellDetector?.reset() } } } diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts new file mode 100644 index 00000000000..961ecf74cfb --- /dev/null +++ b/src/shared/terminal-side-effect-facts.ts @@ -0,0 +1,37 @@ +/** + * Derived terminal side-effect facts carried on the `pty:sideEffect` channel + * (main → renderer). Events are facts, not decisions: main parses every + * local-daemon/SSH PTY byte exactly once and emits what it observed; the + * renderer store handler owns notification/unread policy. + * See docs/reference/terminal-side-effect-authority.md. + */ + +/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not + * observed bytes. Renderer policy clears title/cache state from them but + * must not schedule task-complete notifications or unread attention — a + * merely-paused agent (>3s silent mid-task) is not a completion. */ +export type TerminalSideEffectFact = + | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } + | { kind: 'bell' } + | { kind: 'agent-working' } + | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } + | { kind: 'agent-exited' } + +export type TerminalSideEffectBatch = { + ptyId: string + /** PTY output byte sequence at emission. Replay batches carry the sequence + * their title state was current at, so the handler can drop a replay title + * older than the last live title fact it applied. */ + seq: number + /** Facts from one chunk, in byte order: titles in sequence, then bell. */ + facts: TerminalSideEffectFact[] + /** True for (re)attach snapshots. Replay batches restore title state only — + * attention facts (bell, agent transitions) never replay. */ + replay?: boolean + /** Main-known attribution from runtime leaf/PTY records (same resolution as + * agent-status events). Absent when main has no binding for the PTY yet. */ + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null +} diff --git a/src/shared/types.ts b/src/shared/types.ts index cf9311a7ef6..6652edf8dcb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2196,6 +2196,12 @@ export type GlobalSettings = { * Defaults to true; `false` disables parking entirely. * See docs/reference/terminal-hidden-view-parking.md. */ terminalHiddenViewParking?: boolean + /** Kill switch for main-process terminal side-effect authority: when true + * (default), local-daemon/SSH PTY title/bell/agent facts are consumed from + * the `pty:sideEffect` channel and renderer byte parsers stay unregistered + * for those PTYs; `false` restores renderer byte parsing. + * See docs/reference/terminal-side-effect-authority.md. */ + terminalMainSideEffectAuthority?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) From ecdd29793f64e5652367937177bd7a023ac4cede Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:55:07 -0700 Subject: [PATCH 42/62] Complete terminal side-effect facts coverage Co-authored-by: Orca --- src/main/index.ts | 11 +- src/main/runtime/orca-runtime.test.ts | 91 +++++++++++ src/main/runtime/orca-runtime.ts | 52 +++++-- .../synthetic-title-frame-routing.test.ts | 24 +++ src/main/synthetic-title-frame-routing.ts | 14 ++ .../parked-terminal-byte-watcher.test.ts | 27 +++- .../parked-terminal-byte-watcher.ts | 28 +++- .../terminal-pane/pty-connection.test.ts | 94 ++++++++++++ .../terminal-pane/pty-connection.ts | 76 ++++++---- .../terminal-command-lifecycle.ts | 85 ++--------- ...terminal-side-effect-facts-handler.test.ts | 66 ++++++++ .../terminal-side-effect-facts-handler.ts | 11 ++ .../terminal-title-tracker-parity.test.ts | 98 +++++++++++- src/renderer/src/lib/github-links.ts | 126 +--------------- .../src/store/slices/worktree-helpers.ts | 2 +- src/shared/github-links.ts | 122 +++++++++++++++ .../terminal-github-pr-link-detector.test.ts | 0 .../terminal-github-pr-link-detector.ts | 9 ++ .../terminal-osc133-command-finished.ts | 102 +++++++++++++ .../terminal-output-side-effects.test.ts | 141 ++++++++++++++++++ src/shared/terminal-output-side-effects.ts | 42 +++++- src/shared/terminal-side-effect-facts.ts | 7 + 22 files changed, 974 insertions(+), 254 deletions(-) create mode 100644 src/main/synthetic-title-frame-routing.test.ts create mode 100644 src/main/synthetic-title-frame-routing.ts create mode 100644 src/shared/github-links.ts rename src/{renderer/src/lib => shared}/terminal-github-pr-link-detector.test.ts (100%) rename src/{renderer/src/lib => shared}/terminal-github-pr-link-detector.ts (88%) create mode 100644 src/shared/terminal-osc133-command-finished.ts create mode 100644 src/shared/terminal-output-side-effects.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 0310c11286f..9f722f14663 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,6 +129,7 @@ import { type SyntheticTitleSpinnerEntry } from './synthetic-title-spinner' import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' import { isCrashReportReason } from '../shared/crash-reporting' import { getSyntheticAgentTitleProfile, @@ -990,10 +991,14 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool // Why: feed the per-PTY tracker directly (never onPtyData — emulator state, // tails, transcripts, and stats must not see fabricated bytes) so synthetic // titles/BELs reach pty:sideEffect consumers when main holds side-effect - // authority. The legacy pty:data copy below stays until slice 3 so renderer - // byte parsers keep working while the kill switch is off. + // authority. runtime?.ingestSyntheticTitleFrame(ptyId, data) - mainWindow.webContents.send('pty:data', { id: ptyId, data }) + // Why: only the kill-switch-off renderer still byte-parses synthetic frames; + // under main authority the copy would just mint phantom ACKs for unmetered + // bytes (see synthetic-title-frame-routing.ts). + if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { + mainWindow.webContents.send('pty:data', { id: ptyId, data }) + } } function isSyntheticTitleWindowVisible(): boolean { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 113a351e680..e970a0606d7 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3763,6 +3763,97 @@ describe('OrcaRuntimeService', () => { expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' }) }) + it('emits command-finished facts with best-effort exit codes across chunk splits', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'output\x1b]133;D;13', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '0\x07prompt $ ', 101) + runtime.onPtyData('pty-1', '\x1b]133;D\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'command-finished', exitCode: 130 }, + { kind: 'command-finished', exitCode: null } + ]) + }) + + it('emits pr-link facts once per URL with batch attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'PR https://github.com/acme/orca/pull/4', 100) + runtime.onPtyData('pty-1', '2\r\nand https://github.com/acme/orca/pull/43 done\r\n', 101) + // Repeated URL: deduped per PTY, like the renderer byte detector. + runtime.onPtyData('pty-1', 'again https://github.com/acme/orca/pull/42\r\n', 102) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches[0].facts).toEqual([ + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/43', + slug: { owner: 'acme', repo: 'orca' }, + number: 43 + } + } + ]) + }) + + it('prefers the tracked title over the renderer snapshot lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'visible content', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic frame (it no longer + // rides pty:data), so its serializer reports a stale title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('renderer') + expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent') + }) + + it('prefers the tracked title over the headless emulator lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07real output\r\n', 100) + // The hook-driven idle frame lands only in main's tracker — the + // emulator never sees fabricated bytes (invariant 5). + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('headless') + expect(snapshot?.lastTitle).toBe('Codex ready') + }) + it('returns a title-only replay snapshot and never historical attention', () => { const { runtime } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f3b29600f44..d735764e008 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -17,6 +17,7 @@ import type { TerminalSideEffectBatch, TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { AGENT_STATUS_STALE_AFTER_MS, type ParsedAgentStatusPayload, @@ -3571,6 +3572,33 @@ export class OrcaRuntimeService { } } + /** Raw last title from main's tracked PTY/leaf records — the title surface + * the tracker (live bytes + synthetic frames) keeps current. */ + private getTrackedRawTitleForPty(ptyId: string): string | null { + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + if (recordTitle) { + return recordTitle + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + return leaf.lastOscTitle + } + } + return null + } + + /** Why: synthetic agent title frames no longer ride pty:data, so neither + * renderer xterm nor the headless emulator observes them. Mobile-parity + * snapshot titles must prefer main's tracker over snapshot lastTitle, or + * hook-driven spinner/idle titles vanish from mobile tabs. */ + private preferTrackedLastTitle(ptyId: string, snapshot: T): T { + const tracked = this.getTrackedRawTitleForPty(ptyId) + if (!tracked) { + return snapshot + } + return { ...snapshot, lastTitle: tracked } + } + /** Decorative comparison key: spinner frame glyphs stripped, derived agent * status kept so a working→idle flip with an otherwise-equal label still * counts as a change. */ @@ -3646,12 +3674,19 @@ export class OrcaRuntimeService { onAgentExited: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) }, - // Why: bell facts exist only for the pty:sideEffect channel. Headless - // serve has no consumer, so skip the per-chunk bell walk entirely. + // Why: bell/command-finished/pr-link facts exist only for the + // pty:sideEffect channel. Headless serve has no consumer, so skip the + // per-chunk bell walk and 133/URL scans entirely. ...(this.onTerminalSideEffects ? { onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onCommandFinished: (exitCode: number | null) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) } } : {}) @@ -4124,10 +4159,9 @@ export class OrcaRuntimeService { // If renderer serialization races reload/unmount, the runtime snapshot // below can still preserve colored terminal state. } - if (rendererSnapshot && rendererSnapshot.data.length > 0) { - return { ...rendererSnapshot, source: 'renderer' } - } - return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null + return rendererSnapshot + ? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, source: 'renderer' as const }) + : null } private async withVisibleSnapshotFallback( @@ -4212,15 +4246,15 @@ export class OrcaRuntimeService { const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true - ? { + ? this.preferTrackedLastTitle(ptyId, { data, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd, lastTitle: snapshot.lastTitle, seq: state.outputSequence, - source: 'headless' - } + source: 'headless' as const + }) : null } diff --git a/src/main/synthetic-title-frame-routing.test.ts b/src/main/synthetic-title-frame-routing.test.ts new file mode 100644 index 00000000000..b4bbf35ecd2 --- /dev/null +++ b/src/main/synthetic-title-frame-routing.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' + +describe('shouldCopySyntheticTitleFrameToPtyData', () => { + it('keeps the legacy pty:data copy only while the kill switch is off', () => { + // Authority off: renderer byte parsers are the sole synthetic-frame + // consumer, so the legacy copy must keep flowing. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: false })).toBe( + true + ) + }) + + it('skips the copy under main authority — tracker ingest is the only consumer', () => { + // Why: under authority the copy would only mint phantom renderer ACKs + // for fabricated bytes main never metered. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: true })).toBe( + false + ) + // Default-on: an unset switch means main authority. + expect(shouldCopySyntheticTitleFrameToPtyData({})).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(null)).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(undefined)).toBe(false) + }) +}) diff --git a/src/main/synthetic-title-frame-routing.ts b/src/main/synthetic-title-frame-routing.ts new file mode 100644 index 00000000000..3bb10a030fa --- /dev/null +++ b/src/main/synthetic-title-frame-routing.ts @@ -0,0 +1,14 @@ +import type { GlobalSettings } from '../shared/types' + +/** + * Why: with the side-effect kill switch off, renderer byte parsers are the + * ONLY consumer of main-fabricated OSC title frames, so they must still ride + * `pty:data`. With main authority on (the default), the tracker ingest is the + * sole consumer and the legacy copy would only mint phantom renderer ACKs for + * bytes main never metered. See terminal-side-effect-authority.md (slice 3). + */ +export function shouldCopySyntheticTitleFrameToPtyData( + settings: Pick | null | undefined +): boolean { + return settings?.terminalMainSideEffectAuthority === false +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index 6f222088adb..1cb9b0018cc 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -468,8 +468,8 @@ describe('startParkedTerminalByteWatcher', () => { // // With the kill switch on, the watcher must not register byte parsers — // main is the single byte parser and the watcher's policy block consumes - // pty:sideEffect facts instead. The byte sidecar stays only for the 2031 - // reply and PR-link scan (they move to main in a later slice). + // pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031 + // reply (query authority never moves to main); PR links arrive as facts. describe('with main side-effect authority on', () => { function enableMainAuthority(): void { mockStoreState.settings = { @@ -664,15 +664,36 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) - it('still answers DECSET 2031 and observes PR links from the byte sidecar', async () => { + it('keeps the byte sidecar only for the DECSET 2031 reply — no PR byte scan', async () => { enableMainAuthority() const { dispose, sendInput } = await startWatcher() emit('\x1b[?2031h') expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + // Why: pr-link facts arrive on the channel; byte-scanning here too + // would observe every link twice. emit('PR: https://github.com/orca-dev/orca/pull/42\r\n') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + dispose() + }) + + it('observes PR links from pr-link facts with worktree attribution', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + const link = { + url: 'https://github.com/orca-dev/orca/pull/421', + slug: { owner: 'orca-dev', repo: 'orca' }, + number: 421 + } + await dispatchFacts([{ kind: 'pr-link', link }]) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + link + ) dispose() }) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index 7fbf1f8d2c3..ece2b160c13 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -19,7 +19,7 @@ import { } from '../../../../shared/terminal-color-scheme-protocol' import { useAppStore } from '@/store' import { getSystemPrefersDark } from '@/lib/terminal-theme' -import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, isAgentTaskCompleteOsNotificationEnabledFromState, @@ -93,7 +93,6 @@ export function startParkedTerminalByteWatcher( let bellNotificationTimer: ReturnType | null = null let agentTaskCompleteTimer: ReturnType | null = null let mode2031ScanTail = '' - const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() const clearBellNotificationTimer = (): void => { if (bellNotificationTimer !== null) { @@ -238,12 +237,21 @@ export function startParkedTerminalByteWatcher( ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), ...sideEffectCallbacks }) + // Why (byte-parser mode only): with main authority, pr-link facts arrive on + // the channel below; byte-scanning too would observe every link twice. + const observeTerminalGitHubPRLink = mainSideEffectAuthority + ? null + : createTerminalGitHubPRLinkDetector() const unregisterFactConsumer = mainSideEffectAuthority ? registerTerminalSideEffectFactConsumer({ ptyId, // Why: no title snapshot on park — the pane's runtime title slot is // already current at park time, exactly like the byte-parser mode. - callbacks: sideEffectCallbacks + callbacks: { + ...sideEffectCallbacks, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + } }) : null @@ -260,16 +268,20 @@ export function startParkedTerminalByteWatcher( sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) } - // Why: the byte sidecar stays in BOTH modes for the 2031 reply and PR-link - // scan — those move to main in a later slice. Only the title/bell/agent - // parsing is gated: processor is null when main holds authority. + // Why: under main authority the byte sidecar stays ONLY for the DECSET 2031 + // reply — query authority belongs to the view/watcher (model/view contract + // invariant 6), so it can never move to main. Title/bell/agent parsing and + // the PR-link scan are byte-parser-mode only (null when main holds + // authority; their facts arrive on pty:sideEffect instead). const unsubscribe = subscribeToPtyData(ptyId, (data) => { // Why: empty pane callbacks — the watcher wants only the parser side // effects; there is no xterm to deliver bytes to. processor?.processData(data, {}) respondToMode2031Subscribe(data) - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + if (observeTerminalGitHubPRLink) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + } } }) 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 260ac4983d8..ee39fd85238 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5281,6 +5281,100 @@ describe('connectPanePty', () => { expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() }) + it('drops the agent status from a command-finished fact like the byte path did', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-133') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-133', + seq: 1, + facts: [{ kind: 'command-finished', exitCode: 130 }] + }) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + + it('routes pr-link facts to the worktree PR observer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-pr') + + const link = { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-pr', + seq: 1, + facts: [{ kind: 'pr-link', link }] + }) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith('wt-1', link) + }) + + it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => { + enableMainAuthority() + 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-authority-bytes' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { // Pre-hydration: the store has no settings yet, but the user persisted // the kill switch off. The pane must register byte parsers, not a fact diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 49c7e4e467e..733c1878486 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -75,7 +75,7 @@ import { } from './terminal-bracketed-paste' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' -import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { installConptyDeviceAttributesHandler } from './terminal-conpty-device-attributes' import { cancelScheduledHiddenOutputRestore, @@ -889,33 +889,40 @@ export function connectPanePty( } return pendingWrite.then(() => interruptInference.flushPending()) } - const commandLifecycle = createTerminalCommandLifecycle({ - onCommandFinished: () => { - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const inferenceResult = flushPendingInterruptInference() - if (inferenceResult === true) { - // Why: OSC 133 D means the foreground shell command exited. If an - // interrupt was inferred first, drop only when the current interrupted - // row is still the same turn; otherwise a killed OpenCode CLI leaves a - // stale "interrupted" row even though the process is gone. - dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) - return - } - if (inferenceResult instanceof Promise) { - void inferenceResult.then((applied) => { - dropCommandFinishedStatusIfSameTurn(entry, { - allowInferredInterrupt: applied === true - }) - }) - return - } - // Why: OSC 133 D marks the foreground shell command exiting. Remove the - // row without retaining a done snapshot; this section represents a live - // agent process, and the shell prompt means that process is gone. - dropCommandFinishedStatusIfSameTurn(entry) + // Why: one command-finished policy whether the signal arrives as bytes + // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — + // routing both through this handler keeps the drop/interrupt semantics + // identical across authority modes. + const handleCommandFinished = (_bestEffortExitCode: number | null): void => { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const inferenceResult = flushPendingInterruptInference() + if (inferenceResult === true) { + // Why: OSC 133 D means the foreground shell command exited. If an + // interrupt was inferred first, drop only when the current interrupted + // row is still the same turn; otherwise a killed OpenCode CLI leaves a + // stale "interrupted" row even though the process is gone. + dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) + return } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + dropCommandFinishedStatusIfSameTurn(entry, { + allowInferredInterrupt: applied === true + }) + }) + return + } + // Why: OSC 133 D marks the foreground shell command exiting. Remove the + // row without retaining a done snapshot; this section represents a live + // agent process, and the shell prompt means that process is gone. + dropCommandFinishedStatusIfSameTurn(entry) + } + const commandLifecycle = createTerminalCommandLifecycle({ + onCommandFinished: handleCommandFinished }) + // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — + // it stays attached in every authority mode. commandLifecycle.attachXtermConsumer(pane.terminal) const onTerminalKeyDown = (event: KeyboardEvent): void => { if (isPlainEscapeKeyEvent(event)) { @@ -993,7 +1000,10 @@ export function connectPanePty( onBell, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited + onAgentExited, + onCommandFinished: handleCommandFinished, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) }, restoreTitleOnRegister: true }) @@ -2660,11 +2670,17 @@ export function connectPanePty( const dataCallback = (data: string, meta?: PtyDataMeta): void => { resetHiddenOutputRestoreIfPtyChanged() observeTerminalBracketedPasteModeOutput(pane.terminal, data) - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + // Why: with main side-effect authority, command-finished and pr-link + // arrive as pty:sideEffect facts — byte-scanning here too would + // double-fire the same policy. Remote-runtime PTYs (and the kill + // switch off) keep this byte path as their only parser. + if (!mainSideEffectAuthority) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + } + commandLifecycle.handlePtyData(data) } commandCodeOutputStatusDetector.observe(data) - commandLifecycle.handlePtyData(data) // Why: split-pane layouts have multiple visible-but-inactive panes whose // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts index 3fb103efeac..69b6d923423 100644 --- a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts @@ -1,99 +1,32 @@ import type { Terminal, IDisposable } from '@xterm/xterm' +import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished' type TerminalCommandLifecycleOptions = { onCommandFinished: (bestEffortExitCode: number | null) => void } -type OscTerminator = { - index: number - length: number -} - -const OSC_133_PREFIX = '\x1b]133;' -const MAX_OSC_CARRY_LENGTH = 4096 - -function findOscTerminator(data: string, startIndex: number): OscTerminator | null { - const bel = data.indexOf('\x07', startIndex) - const st = data.indexOf('\x1b\\', startIndex) - - if (bel === -1 && st === -1) { - return null - } - if (bel !== -1 && (st === -1 || bel < st)) { - return { index: bel, length: 1 } - } - return { index: st, length: 2 } -} - -function parseBestEffortExitCode(value: string | undefined): number | null { - if (!value) { - return null - } - const parsed = Number.parseInt(value, 10) - return Number.isNaN(parsed) ? null : parsed -} - -function findPrefixCarry(data: string): string { - const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) - for (let length = maxCarryLength; length > 0; length -= 1) { - const suffix = data.slice(data.length - length) - if (OSC_133_PREFIX.startsWith(suffix)) { - return suffix - } - } - return '' -} - export function createTerminalCommandLifecycle(options: TerminalCommandLifecycleOptions): { handlePtyData: (data: string) => void attachXtermConsumer: (terminal: Terminal) => IDisposable dispose: () => void } { - let carry = '' + // Why: the byte parsing lives in shared so main's side-effect tracker emits + // identical command-finished facts for local/SSH PTYs; this renderer wrapper + // remains the byte path for remote-runtime PTYs and the kill-switch-off mode. + const scanner = createOsc133CommandFinishedScanner(options.onCommandFinished) const disposables: IDisposable[] = [] - const handleOsc133 = (payload: string): void => { - const [sequence, exitCode] = payload.split(';') - if (sequence === 'D') { - options.onCommandFinished(parseBestEffortExitCode(exitCode)) - } - } - - const handlePtyData = (data: string): void => { - let combined = carry + data - carry = '' - - while (combined.length > 0) { - const start = combined.indexOf(OSC_133_PREFIX) - if (start === -1) { - carry = findPrefixCarry(combined) - return - } - - const payloadStart = start + OSC_133_PREFIX.length - const terminator = findOscTerminator(combined, payloadStart) - if (!terminator) { - carry = combined.slice(start) - if (carry.length > MAX_OSC_CARRY_LENGTH) { - carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH) - } - return - } - - handleOsc133(combined.slice(payloadStart, terminator.index)) - combined = combined.slice(terminator.index + terminator.length) - } - } - return { - handlePtyData, + handlePtyData: scanner.scan, attachXtermConsumer(terminal) { + // Why: swallow OSC 133 so shell-integration markers never paint — + // rendering hygiene that applies regardless of side-effect authority. const disposable = terminal.parser.registerOscHandler(133, () => true) disposables.push(disposable) return disposable }, dispose() { - carry = '' + scanner.reset() for (const disposable of disposables.splice(0)) { disposable.dispose() } diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index 395eb87d732..074fd7cad98 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -173,6 +173,72 @@ describe('registerTerminalSideEffectFactConsumer', () => { ]) }) + it('routes command-finished and pr-link facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-finished', exitCode: 130 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { kind: 'command-finished', exitCode: null } + ]) + ) + + expect(events).toEqual([ + ['finished', 130], + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['finished', null] + ]) + }) + + it('never replays command-finished or pr-link facts', () => { + // Why: like bells and agent transitions, command/PR facts are attention + // signals — replay snapshots restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-finished', exitCode: 0 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + it('passes stale-clear provenance through to the title and idle callbacks', () => { const events: unknown[][] = [] registerTerminalSideEffectFactConsumer({ diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 7c9789b69bf..6774bd9a8bf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -12,6 +12,7 @@ * where pre-mount output produces no attention side effects. */ import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { TerminalSideEffectBatch, TerminalSideEffectFact @@ -73,6 +74,10 @@ export type TerminalSideEffectFactConsumerCallbacks = { onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void onAgentBecameWorking?: () => void onAgentExited?: () => void + /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove + * (stale agent-status row drop + interrupt-inference coordination). */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + onPrLink?: (link: TerminalGitHubPRLink) => void } type ConsumerEntry = { @@ -109,6 +114,12 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: return case 'agent-exited': entry.callbacks.onAgentExited?.() + return + case 'command-finished': + entry.callbacks.onCommandFinished?.(fact.exitCode) + return + case 'pr-link': + entry.callbacks.onPrLink?.(fact.link) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index 6c4f2f10fbc..b505486278a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -8,8 +8,10 @@ // event sequences match. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects' import { createPtyOutputProcessor } from './pty-transport' +import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' const ESC = '\x1b' const BEL = '\x07' @@ -69,7 +71,9 @@ function createMainPath(): TitleFactPath { } } -function feedBoth(paths: { renderer: TitleFactPath; main: TitleFactPath }, chunk: string): void { +type ChunkFeed = { feed: (chunk: string) => void } + +function feedBoth(paths: { renderer: ChunkFeed; main: ChunkFeed }, chunk: string): void { paths.renderer.feed(chunk) paths.main.feed(chunk) } @@ -187,3 +191,95 @@ describe('main title tracker parity with the renderer transport processor', () = expect(paths.main.events).toEqual([]) }) }) + +// Why: slice 3 moves the renderer's OSC 133;D and PR-link byte parsing into +// main's tracker for local/SSH PTYs. Both must derive identical fact +// sequences from the same chunk boundaries, or flipping the kill switch +// changes which commands/links are observed. +type LifecycleFactEvent = ['command-finished', number | null] | ['pr-link', string, number] + +type LifecycleFactPath = { + events: LifecycleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + // Why: mirrors pty-connection's dataCallback wiring — the transport + // processor strips OSC 9999 before the lifecycle/PR-link byte scans run. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const lifecycle = createTerminalCommandLifecycle({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]) + }) + const detectPRLinks = createTerminalGitHubPRLinkDetector() + return { + events, + feed(chunk: string): void { + const clean = processAgentStatusChunk(chunk).cleanData + lifecycle.handlePtyData(clean) + for (const link of detectPRLinks(clean)) { + events.push(['pr-link', link.url, link.number]) + } + } + } +} + +function createMainLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]), + onPrLink: (link) => events.push(['pr-link', link.url, link.number]) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +describe('main tracker parity with renderer 133;D and PR-link byte parsers', () => { + let paths: { renderer: LifecycleFactPath; main: LifecycleFactPath } + + beforeEach(() => { + paths = { renderer: createRendererLifecyclePath(), main: createMainLifecyclePath() } + }) + + it('derives identical command-finished facts from split OSC 133;D chunks', () => { + feedBoth(paths, `output${ESC}]133`) + feedBoth(paths, ';D;13') + feedBoth(paths, `0${BEL}prompt $ `) + feedBoth(paths, `${ESC}]133;D;0${BEL}`) + feedBoth(paths, `${ESC}]133;D${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['command-finished', 130], + ['command-finished', 0], + ['command-finished', null] + ]) + }) + + it('derives identical pr-link facts from split and repeated URLs', () => { + feedBoth(paths, 'Created https://github.com/acme/orca/pull/4') + feedBoth(paths, '2\r\nAlso https://github.com/acme/orca/pull/43 merged\r\n') + feedBoth(paths, 'again https://github.com/acme/orca/pull/42\r\n') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['pr-link', 'https://github.com/acme/orca/pull/42', 42], + ['pr-link', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('ignores 133;D and PR URLs inside stripped OSC 9999 payloads in both paths', () => { + feedBoth( + paths, + `${ESC}]9999;{"state":"done","prompt":"https://github.com/acme/orca/pull/9"}${BEL}\r\n` + ) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index cc379cc99de..87844dc9d04 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,122 +1,4 @@ -const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i - -export type RepoSlug = { - owner: string - repo: string -} - -export type GitHubLinkQuery = { - query: string - directNumber: number | null -} - -export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { - if (!slug?.owner || !slug.repo) { - return null - } - return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` -} - -function matchGitHubItemPath(url: URL): RegExpExecArray | null { - return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) -} - -/** - * Parses a GitHub issue/PR reference from plain input. - * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. - */ -export function parseGitHubIssueOrPRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return Number.parseInt(numeric, 10) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - - return Number.parseInt(match[4], 10) -} - -/** - * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns - * null for anything that isn't a recognizable github.com issue or pull URL. - */ -export function parseGitHubIssueOrPRLink(input: string): { - slug: RepoSlug - number: number - type: 'issue' | 'pr' -} | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - - return { - slug: { owner: match[1], repo: match[2] }, - type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', - number: Number.parseInt(match[4], 10) - } -} - -/** - * Normalizes link-picker input so both raw issue/PR numbers and full GitHub - * URLs resolve to a usable query + direct-number lookup. - */ -export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { - const trimmed = raw.trim() - if (!trimmed) { - return { query: '', directNumber: null } - } - - const direct = parseGitHubIssueOrPRNumber(trimmed) - if (direct !== null && !trimmed.startsWith('http')) { - return { query: trimmed, directNumber: direct } - } - - const link = parseGitHubIssueOrPRLink(trimmed) - if (!link) { - return { query: trimmed, directNumber: null } - } - - // Why: any github.com issue/pull URL is accepted by number regardless of - // slug, since fork checkouts can legitimately target upstream issues whose - // slug differs from the origin remote. - return { - query: trimmed, - directNumber: link.number - } -} +// Why: the parsing core moved to shared so main's terminal side-effect +// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3). +// Re-exported here so renderer consumers keep their '@/lib' import path. +export * from '../../../shared/github-links' diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 4fabef17d84..1ad0ea27c29 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -17,7 +17,7 @@ import type { WorktreeRemoteBranchConflictEvent, WorktreeMeta } from '../../../../shared/types' -import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { PendingWorktreeCreation, WorktreeCreationPhase diff --git a/src/shared/github-links.ts b/src/shared/github-links.ts new file mode 100644 index 00000000000..cc379cc99de --- /dev/null +++ b/src/shared/github-links.ts @@ -0,0 +1,122 @@ +const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i + +export type RepoSlug = { + owner: string + repo: string +} + +export type GitHubLinkQuery = { + query: string + directNumber: number | null +} + +export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { + if (!slug?.owner || !slug.repo) { + return null + } + return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` +} + +function matchGitHubItemPath(url: URL): RegExpExecArray | null { + return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) +} + +/** + * Parses a GitHub issue/PR reference from plain input. + * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. + */ +export function parseGitHubIssueOrPRNumber(input: string): number | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(numeric)) { + return Number.parseInt(numeric, 10) + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + + return Number.parseInt(match[4], 10) +} + +/** + * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns + * null for anything that isn't a recognizable github.com issue or pull URL. + */ +export function parseGitHubIssueOrPRLink(input: string): { + slug: RepoSlug + number: number + type: 'issue' | 'pr' +} | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + + return { + slug: { owner: match[1], repo: match[2] }, + type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', + number: Number.parseInt(match[4], 10) + } +} + +/** + * Normalizes link-picker input so both raw issue/PR numbers and full GitHub + * URLs resolve to a usable query + direct-number lookup. + */ +export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { + const trimmed = raw.trim() + if (!trimmed) { + return { query: '', directNumber: null } + } + + const direct = parseGitHubIssueOrPRNumber(trimmed) + if (direct !== null && !trimmed.startsWith('http')) { + return { query: trimmed, directNumber: direct } + } + + const link = parseGitHubIssueOrPRLink(trimmed) + if (!link) { + return { query: trimmed, directNumber: null } + } + + // Why: any github.com issue/pull URL is accepted by number regardless of + // slug, since fork checkouts can legitimately target upstream issues whose + // slug differs from the origin remote. + return { + query: trimmed, + directNumber: link.number + } +} diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts b/src/shared/terminal-github-pr-link-detector.test.ts similarity index 100% rename from src/renderer/src/lib/terminal-github-pr-link-detector.test.ts rename to src/shared/terminal-github-pr-link-detector.test.ts diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts similarity index 88% rename from src/renderer/src/lib/terminal-github-pr-link-detector.ts rename to src/shared/terminal-github-pr-link-detector.ts index c0b22629951..7c70b60d91e 100644 --- a/src/renderer/src/lib/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -1,3 +1,12 @@ +/** + * Chunk-boundary-safe GitHub PR URL scan over PTY output. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the + * renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off + * path. Both paths must share the carry/dedupe semantics or links split across + * chunks would resolve differently per authority mode. + */ import type { RepoSlug } from './github-links' import { parseGitHubIssueOrPRLink } from './github-links' diff --git a/src/shared/terminal-osc133-command-finished.ts b/src/shared/terminal-osc133-command-finished.ts new file mode 100644 index 00000000000..fc6c9ae7694 --- /dev/null +++ b/src/shared/terminal-osc133-command-finished.ts @@ -0,0 +1,102 @@ +/** + * Chunk-boundary-safe OSC 133;D (command finished) scanner. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `command-finished` facts from its per-PTY tracker for local/SSH PTYs, while + * the renderer keeps byte-parsing for remote-runtime PTYs and the + * kill-switch-off path. The carry semantics (split prefixes, BEL/ST + * terminators, best-effort exit codes) must be identical in both. + */ + +type OscTerminator = { + index: number + length: number +} + +const OSC_133_PREFIX = '\x1b]133;' +const MAX_OSC_CARRY_LENGTH = 4096 + +function findOscTerminator(data: string, startIndex: number): OscTerminator | null { + const bel = data.indexOf('\x07', startIndex) + const st = data.indexOf('\x1b\\', startIndex) + + if (bel === -1 && st === -1) { + return null + } + if (bel !== -1 && (st === -1 || bel < st)) { + return { index: bel, length: 1 } + } + return { index: st, length: 2 } +} + +function parseBestEffortExitCode(value: string | undefined): number | null { + if (!value) { + return null + } + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +function findPrefixCarry(data: string): string { + const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) + for (let length = maxCarryLength; length > 0; length -= 1) { + const suffix = data.slice(data.length - length) + if (OSC_133_PREFIX.startsWith(suffix)) { + return suffix + } + } + return '' +} + +export type Osc133CommandFinishedScanner = { + /** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */ + scan: (data: string) => void + /** Drop the cross-chunk carry (transport teardown / parser reset). */ + reset: () => void +} + +export function createOsc133CommandFinishedScanner( + onCommandFinished: (bestEffortExitCode: number | null) => void +): Osc133CommandFinishedScanner { + let carry = '' + + const handleOsc133 = (payload: string): void => { + const [sequence, exitCode] = payload.split(';') + if (sequence === 'D') { + onCommandFinished(parseBestEffortExitCode(exitCode)) + } + } + + const scan = (data: string): void => { + let combined = carry + data + carry = '' + + while (combined.length > 0) { + const start = combined.indexOf(OSC_133_PREFIX) + if (start === -1) { + carry = findPrefixCarry(combined) + return + } + + const payloadStart = start + OSC_133_PREFIX.length + const terminator = findOscTerminator(combined, payloadStart) + if (!terminator) { + carry = combined.slice(start) + if (carry.length > MAX_OSC_CARRY_LENGTH) { + carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH) + } + return + } + + handleOsc133(combined.slice(payloadStart, terminator.index)) + combined = combined.slice(terminator.index + terminator.length) + } + } + + return { + scan, + reset() { + carry = '' + } + } +} diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts new file mode 100644 index 00000000000..6e7c8b5bf19 --- /dev/null +++ b/src/shared/terminal-output-side-effects.test.ts @@ -0,0 +1,141 @@ +// Why: slice 3 of terminal-side-effect-authority.md adds OSC 133;D +// command-finished and GitHub pr-link scanning to the shared tracker so main +// emits those facts for local/SSH PTYs. These tests pin the chunk-boundary +// carry, exit-code best-effort, dedupe, and synthetic-frame isolation rules. +import { describe, expect, it } from 'vitest' +import { + createTerminalTitleTracker, + type TerminalTitleTrackerCallbacks +} from './terminal-output-side-effects' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type RecordedEvent = + | ['title', string] + | ['bell'] + | ['finished', number | null] + | ['pr', string, number] + +function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): { + events: RecordedEvent[] + tracker: ReturnType +} { + const events: RecordedEvent[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => events.push(['title', normalized]), + onBell: () => events.push(['bell']), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]), + ...overrides + }) + return { events, tracker } +} + +describe('createTerminalTitleTracker command-finished facts', () => { + it('emits command-finished with best-effort exit codes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`before${ESC}]133;A${BEL}prompt${ESC}]133;B${BEL}`) + tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}`) + tracker.handleChunk(`${ESC}]133;D;130${BEL}`) + tracker.handleChunk(`${ESC}]133;D;not-a-number${BEL}`) + tracker.handleChunk(`${ESC}]133;D${BEL}`) + + expect(events).toEqual([ + ['finished', 0], + ['finished', 130], + ['finished', null], + ['finished', null] + ]) + }) + + it('detects OSC 133;D split across chunk boundaries (BEL and ST terminated)', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`chunk${ESC}]133`) + tracker.handleChunk(';D;1') + expect(events).toEqual([]) + tracker.handleChunk(`30${BEL}rest`) + tracker.handleChunk(`${ESC}]133;D;7${ST}`) + + expect(events).toEqual([ + ['finished', 130], + ['finished', 7] + ]) + }) + + it('orders chunk facts titles → command-finished → bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}done${BEL}`) + + expect(events).toEqual([['title', 'zsh'], ['finished', 0], ['bell']]) + }) +}) + +describe('createTerminalTitleTracker pr-link facts', () => { + it('emits one fact per PR URL including multiple links in one chunk', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk( + 'see https://github.com/acme/orca/pull/42 and https://github.com/acme/orca/pull/43 \r\n' + ) + + expect(events).toEqual([ + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['pr', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('waits for a boundary when a URL splits across chunks and dedupes repeats', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk('PR: https://github.com/acme/orca/pull/4') + expect(events).toEqual([]) + tracker.handleChunk('2\r\n') + tracker.handleChunk('again https://github.com/acme/orca/pull/42\r\n') + + expect(events).toEqual([['pr', 'https://github.com/acme/orca/pull/42', 42]]) + }) + + it('skips the 133/URL scans entirely when no consumer is registered', () => { + // Mirrors headless serve: no pty:sideEffect consumer means no callbacks, + // so the scanners must not be created (no carry state, no scan cost). + const titles: string[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => titles.push(normalized) + }) + + tracker.handleChunk(`${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`) + + expect(titles).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker synthetic-frame isolation', () => { + it('never feeds synthetic frames to the 133/PR scanners', () => { + const { events, tracker } = createRecordingTracker() + + tracker.applySyntheticTitleFrame( + `${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n` + ) + + expect(events).toEqual([['title', '⠋ Cursor Agent']]) + }) + + it('keeps a split 133 carry intact across an interleaved synthetic frame', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`out${ESC}]133;D;`) + // An 80ms spinner tick lands between the two halves of the real OSC. + tracker.applySyntheticTitleFrame(`${ESC}]0;⠋ Cursor Agent${BEL}`) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([ + ['title', '⠋ Cursor Agent'], + ['finished', 130] + ]) + }) +}) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index a1a0ed56a14..5dfe149fcac 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -18,6 +18,11 @@ import { normalizeTerminalTitle } from './agent-detection' import { createBellDetector } from './terminal-bell-detector' +import { + createTerminalGitHubPRLinkDetector, + type TerminalGitHubPRLink +} from './terminal-github-pr-link-detector' +import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished' /** Ms of title-less output after a working title before it is cleared. */ export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 @@ -59,6 +64,15 @@ export type TerminalTitleTrackerCallbacks = { * across chunks), after the chunk's title facts — the renderer drain order. */ onBell?: () => void + /** + * Fired per complete OSC 133;D (chunk-boundary-safe) with the sequence's + * best-effort exit code — mirrors the renderer terminal-command-lifecycle + * semantics so the fact path drops stale agent rows exactly like byte mode. + */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, + * deduplicated per tracker like the renderer detector). */ + onPrLink?: (link: TerminalGitHubPRLink) => void } export type TerminalTitleTracker = { @@ -87,8 +101,22 @@ export function createTerminalTitleTracker( callbacks: TerminalTitleTrackerCallbacks, options: { initialTitle?: string } = {} ): TerminalTitleTracker { - const { onTitle, onAgentBecameIdle, onAgentBecameWorking, onAgentExited, onBell } = callbacks + const { + onTitle, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onBell, + onCommandFinished, + onPrLink + } = callbacks const bellDetector = onBell ? createBellDetector() : null + // Why: created only when a consumer exists (like the bell detector) so + // headless serve never pays the per-chunk 133/URL scans. + const commandFinishedScanner = onCommandFinished + ? createOsc133CommandFinishedScanner(onCommandFinished) + : null + const prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null // Why: seed both the emitted-title memory (stale-title probe) and the agent // tracker so a mid-session tracker behaves as if it had observed the pane's // last live title — parity with the renderer processor's seeding. @@ -183,6 +211,15 @@ export function createTerminalTitleTracker( } }, STALE_WORKING_TITLE_TIMEOUT_MS) } + // Per-chunk fact order: titles → command-finished → pr-link → bell. The + // bell stays last (the renderer drain's order); the byte scanners keep + // their own cross-chunk carry so split sequences/URLs still resolve. + commandFinishedScanner?.scan(data) + if (prLinkDetector) { + for (const link of prLinkDetector(data)) { + onPrLink?.(link) + } + } if (containsBell) { onBell?.() } @@ -204,6 +241,8 @@ export function createTerminalTitleTracker( // The deliberate permission BEL rides outside the OSC title sequence. A // FRESH detector instance keeps the OSC-terminator-vs-bell semantics // while guaranteeing zero interaction with the chunk detector's state. + // Synthetic frames never reach the 133/PR-link scanners: fabricated bytes + // contain neither and must not perturb their cross-chunk carry state. if (onBell && createBellDetector().chunkContainsBell(frame)) { onBell() } @@ -227,6 +266,7 @@ export function createTerminalTitleTracker( clearStaleTitleTimer() agentTracker?.reset() bellDetector?.reset() + commandFinishedScanner?.reset() } } } diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 961ecf74cfb..b1c3733d071 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -6,6 +6,8 @@ * See docs/reference/terminal-side-effect-authority.md. */ +import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' + /** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not * observed bytes. Renderer policy clears title/cache state from them but * must not schedule task-complete notifications or unread attention — a @@ -16,6 +18,11 @@ export type TerminalSideEffectFact = | { kind: 'agent-working' } | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } | { kind: 'agent-exited' } + /** OSC 133;D — foreground shell command exited (exit code best-effort). */ + | { kind: 'command-finished'; exitCode: number | null } + /** Carries the parsed link so the renderer store consumer never re-parses + * the URL (parse drift would break the per-PTY dedupe contract). */ + | { kind: 'pr-link'; link: TerminalGitHubPRLink } export type TerminalSideEffectBatch = { ptyId: string From 659f8736f9f69c19422162ba594127e183db8213 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:06:24 -0700 Subject: [PATCH 43/62] Finish terminal side-effect authority migration Co-authored-by: Orca --- .../terminal-side-effect-authority.md | 45 +++++-- src/main/ipc/pty.test.ts | 21 +++ src/main/ipc/pty.ts | 11 ++ src/main/runtime/orca-runtime.test.ts | 85 ++++++++++++ src/main/runtime/orca-runtime.ts | 54 +++++++- .../parked-terminal-byte-watcher.test.ts | 22 +++ .../parked-terminal-byte-watcher.ts | 80 +++++------ .../parked-terminal-mode2031-responder.ts | 45 +++++++ .../terminal-pane/pty-connection.test.ts | 107 +++++++++++++++ .../terminal-pane/pty-connection.ts | 35 +++-- ...terminal-side-effect-facts-handler.test.ts | 50 +++++++ .../terminal-side-effect-facts-handler.ts | 10 ++ .../terminal-title-tracker-parity.test.ts | 93 +++++++++++++ .../lib/automation-session-observer.test.ts | 126 ++++++++++++++++++ .../src/lib/automation-session-observer.ts | 16 ++- .../launch-agent-background-session.test.ts | 46 ++++++- .../lib/launch-agent-background-session.ts | 22 ++- .../command-code-output-status.test.ts | 0 .../command-code-output-status.ts | 8 ++ src/shared/terminal-side-effect-facts.ts | 9 +- 20 files changed, 807 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts create mode 100644 src/renderer/src/lib/automation-session-observer.test.ts rename src/{renderer/src/components/terminal-pane => shared}/command-code-output-status.test.ts (100%) rename src/{renderer/src/components/terminal-pane => shared}/command-code-output-status.ts (95%) diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md index 039ad0243cf..ccbf719d36b 100644 --- a/docs/reference/terminal-side-effect-authority.md +++ b/docs/reference/terminal-side-effect-authority.md @@ -33,7 +33,7 @@ Remote-runtime PTYs (`remote:`) never transit local main; the renderer | BEL attention (OSC-aware stateful detector) | main | main | renderer | | OSC 133;D command-finished exit code | main | main | renderer | | GitHub PR-link scan | main | main | renderer | -| Command Code output scrape | main (last slice) | main | renderer | +| Command Code output scrape | main (shipped: per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main (shipped) | renderer | | DECSET 2031 color-scheme reply | renderer view/watcher — query authority stays with the view (contract invariant 6) | same | renderer | | DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | @@ -161,14 +161,18 @@ no per-chunk race. Keep renderer byte access (input pacing / raw-output consumers, not side effects): `agent-paste-draft.ts` (DECSET 2004 readiness), `launch-agent-background-session.ts` (startup-injection pacing, onData -passthrough), `automation-session-observer.ts` (onData passthrough). Their -duplicated local OSC 9999 store writes drop once main authority covers them. -Phase 4's hidden-delivery gate must exempt PTYs with an active -`subscribeToPtyData` sidecar: that registration becomes an explicit -delivery-interest signal surfaced to main. With main authoritative, the -parked watcher's local/SSH parsing is dead code; since parking eligibility -excludes `remote:` and SSH PTYs, the watcher is deleted outright — it only -returns if remote-runtime tabs ever become parkable. +passthrough), `automation-session-observer.ts` (onData passthrough), and +`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies while +parked). Their duplicated local OSC 9999 store writes are gated off under +main authority (shipped — the `onAgentStatus` automation callbacks still +fire; only the racing `setAgentStatus` store writes drop). Phase 4's +hidden-delivery gate must exempt PTYs with an active `subscribeToPtyData` +sidecar: that registration becomes an explicit delivery-interest signal +surfaced to main. With main authoritative, the parked watcher is purely +fact-driven: byte parsing exists only in kill-switch-off mode, and the 2031 +reply lives in the dedicated responder sidecar. The watcher file is deleted +outright only when the kill switch retires — it returns as a byte parser +only if remote-runtime tabs ever become parkable. ## Invariants @@ -215,3 +219,26 @@ returns if remote-runtime tabs ever become parkable. 4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked watcher deletion, Phase 4 delivery-interest registration documented in the gate design. + +## Open Items (carried into Phase 4) + +- **Delivery-interest registration.** Every remaining `subscribeToPtyData` + sidecar (`parked-terminal-mode2031-responder.ts`, `agent-paste-draft.ts`, + `launch-agent-background-session.ts`, `automation-session-observer.ts`) + must surface its registration to main as an explicit delivery-interest + signal before the hidden-delivery gate can stop byte delivery. +- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic + checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon + `Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is + derived from real PTY bytes only — synthetic hook title frames never reach + the daemon process, so that field cannot carry hook-driven titles. Today no + restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots + surface only the ANSI payload), so there is nothing to fix. Main-side + consumers of the renderer serializer's `lastTitle` (mobile snapshot reads + and the headless hydration seed) prefer main's tracked title. If a future + consumer starts reading checkpoint `lastTitle`, it must route through the + same tracked-title preference. +- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is + removed, the parked watcher's byte-parser mode, the renderer transport + parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all + become dead code and the watcher byte path can be deleted outright. diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 66ca1397931..e1dd27d117a 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1315,6 +1315,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -1351,6 +1352,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -2391,6 +2393,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn(() => 13), @@ -2450,6 +2453,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -2856,6 +2860,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn() } @@ -2899,6 +2904,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_wrong'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -2950,6 +2956,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -2988,6 +2995,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3070,6 +3078,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -3141,6 +3150,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3227,6 +3237,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3324,6 +3335,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -3432,6 +3444,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -3525,6 +3538,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -3626,6 +3640,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3684,6 +3699,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3731,6 +3747,7 @@ describe('registerPtyHandlers', () => { it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -3758,6 +3775,7 @@ describe('registerPtyHandlers', () => { }) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_wsl'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5089,6 +5107,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -5353,6 +5372,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -5393,6 +5413,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9837704b5b8..4db944cb22b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1807,6 +1807,10 @@ export function registerPtyHandlers( if (args.worktreeId) { runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand(result.id, args.command ?? null) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -2474,6 +2478,13 @@ export function registerPtyHandlers( ) { runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand( + result.id, + typeof args.command === 'string' ? args.command : null + ) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e970a0606d7..9a740fc4e8a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -4050,6 +4050,91 @@ describe('OrcaRuntimeService', () => { vi.useRealTimers() } }) + + it('emits command-code-working facts only after the banner arms the scrape', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Generic status words without the Command Code banner must not arm. + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\nThinking...', 100) + expect(batches.flatMap((batch) => batch.facts)).toEqual([]) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 101) + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m', 102) + + expect(batches.at(-1)).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches.at(-1)?.facts).toEqual([ + { kind: 'command-code-working', prompt: 'Fix the spinner' } + ]) + }) + + it('emits a command-code-done fact when the idle composer returns', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 100) + runtime.onPtyData('pty-1', '❯ say hi\r\n✻ Thinking...', 101) + runtime.onPtyData( + 'pty-1', + '\r\n✻ Thought for 1 second\r\n:: Hi!\r\n❯ Ask your question...', + 102 + ) + + expect(batches.at(-1)?.facts).toEqual([{ kind: 'command-code-done', prompt: 'say hi' }]) + }) + + it('arms the Command Code scrape from the noted spawn command', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Mirrors the renderer detector's startupCommand fast-arm: no banner + // needed when main saw the launch command at spawn time. + runtime.noteTerminalSpawnCommand('pty-1', 'command-code --trust') + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n✻ Thinking...', 100) + + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'command-code-working', + prompt: 'Fix the spinner' + }) + }) + + it('prefers the tracked title over a stale renderer lastTitle in the hydration seed', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'renderer scrollback\n', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic hook frame (it no longer + // rides pty:data), so its serializer reports the pre-agent title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude working\x07') + // First live chunk kicks off renderer hydration; awaiting the snapshot + // below settles the seed write chain. + runtime.onPtyData('pty-1', 'plain output\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + + const leaves = ( + runtime as unknown as { leaves: Map } + ).leaves + // The seed must not stomp the leaf record (worktree ps status source) + // back to the renderer's stale title. + expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working') + }) }) it('returns OSC titles from headless main terminal snapshots', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d735764e008..9afde60f485 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -13,6 +13,7 @@ import { stripBrailleSpinnerGlyphs, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' import type { TerminalSideEffectBatch, TerminalSideEffectFact @@ -741,6 +742,10 @@ type RuntimePtyTitleTrackerEntry = { // pty:sideEffect emission per chunk, preserving byte order (titles in // sequence, then bell). Timer-fired facts emit immediately between chunks. pendingFacts: TerminalSideEffectFact[] + // Why: Command Code lacks hooks, so its working/done state is scraped from + // TUI output. Null when no side-effect consumer exists (headless serve) — + // the scrape produces facts only. + commandCodeDetector: { observe: (data: string) => boolean } | null } type RuntimeHeadlessTerminal = { @@ -1412,6 +1417,10 @@ export class OrcaRuntimeService { // intra-chunk working→idle transitions the renderer does (issue #1083). // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. private ptyTitleTrackersByPtyId = new Map() + // Why: the Command Code output detector arms early from the launch command + // when known (banner detection covers user-typed launches), mirroring the + // renderer detector's startupCommand seed. + private terminalSpawnCommandsByPtyId = new Map() // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again @@ -3290,6 +3299,16 @@ export class OrcaRuntimeService { this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId }) } + /** Record the spawn launch command so the per-PTY Command Code detector can + * arm from it (renderer startupCommand parity). Best-effort: a chunk that + * beats this call falls back to the detector's banner arming. */ + noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void { + const trimmed = typeof command === 'string' ? command.trim() : '' + if (trimmed.length > 0) { + this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed) + } + } + onPtyData(ptyId: string, data: string, at: number): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length this.ptyOutputSequenceById.set(ptyId, outputSequence) @@ -3403,6 +3422,11 @@ export class OrcaRuntimeService { titleTrackerEntry.chunkTouchedSessionTabs = false try { titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData) + // Why: the Command Code scrape rides the same per-chunk batch (its facts + // trail the tracker's). cleanData keeps OSC 9999 payloads out of the + // detector's bounded recent-text window; the detector strips remaining + // control sequences itself, exactly like the renderer byte path. + titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData) } finally { titleTrackerEntry.applyingChunk = false try { @@ -3699,7 +3723,22 @@ export class OrcaRuntimeService { applyingSyntheticFrame: false, lastMobileTitleGateKey: null, chunkTouchedSessionTabs: false, - pendingFacts: [] + pendingFacts: [], + // Why: command-code facts exist only for the pty:sideEffect channel — + // headless serve skips the per-chunk scrape entirely. The detector + // self-arms on the Command Code banner; the spawn command (when main + // saw one) mirrors the renderer detector's startupCommand fast-arm. + commandCodeDetector: this.onTerminalSideEffects + ? createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + : null } this.ptyTitleTrackersByPtyId.set(ptyId, entry) return entry @@ -4037,9 +4076,14 @@ export class OrcaRuntimeService { if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { state.emulator.resize(ptyDims.cols, ptyDims.rows) } - if (rendered.lastTitle) { - state.emulator.setLastTitle(rendered.lastTitle) - this.applySeededAgentStatus(ptyId, rendered.lastTitle) + // Why: the renderer xterm no longer sees synthetic hook title frames + // (they feed main's tracker only), so its serializer lastTitle can be + // stale here. Prefer main's tracked title; the renderer's is only the + // seed when main has observed none (fresh relaunch, cold tracker). + const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle + if (seedTitle) { + state.emulator.setLastTitle(seedTitle) + this.applySeededAgentStatus(ptyId, seedTitle) } } catch { // Hydration is best-effort. Live writes continue via the same @@ -4969,6 +5013,7 @@ export class OrcaRuntimeService { this.recentPtyOutputById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) // Layout state machine: clear `layouts` and `layoutQueues`. Any // already-queued applyLayout work for this ptyId will run, but every @@ -13079,6 +13124,7 @@ export class OrcaRuntimeService { this.recentPtyOutputById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index 1cb9b0018cc..b028008c68f 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -343,6 +343,15 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) + it('stops answering DECSET 2031 after dispose', async () => { + const { dispose, sendInput } = await startWatcher() + + dispose() + emit('\x1b[?2031h') + + expect(sendInput).not.toHaveBeenCalled() + }) + it('observes GitHub PR links across chunk boundaries', async () => { const { dispose } = await startWatcher() @@ -678,6 +687,19 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) + it('answers a DECSET 2031 subscribe split across chunks via the responder sidecar', async () => { + enableMainAuthority() + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + emit('31h') + + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + dispose() + }) + it('observes PR links from pr-link facts with worktree attribution', async () => { enableMainAuthority() const { dispose } = await startWatcher() diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index ece2b160c13..b375831becb 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -1,30 +1,27 @@ /** - * Parked terminal byte watcher. + * Parked terminal side-effect watcher. * - * Why: parking unmounts the TerminalPane subtree, which tears down the - * transport byte parsers — the renderer's only source of bell, title, - * agent-completion, mode-2031, and PR-link side effects. (Losing them is the - * gap that sank the first parking attempt.) This watcher rides the dispatcher - * sidecar channel — the same mechanism background agent launches use — so it - * never disturbs pane handler registration or eager buffering, and keeps the - * PTY side effects alive with no xterm while the tab is parked. - * See docs/reference/terminal-hidden-view-parking.md. + * Why: parking unmounts the TerminalPane subtree, which tears down the pane's + * side-effect consumers — the parked tab's only source of bell, title, + * agent-completion, and PR-link policy. (Losing them is the gap that sank the + * first parking attempt.) Under main side-effect authority the watcher is + * purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the + * kill switch off it registers the legacy byte parsers on the dispatcher + * sidecar channel. The DECSET 2031 reply lives in its own byte sidecar + * (parked-terminal-mode2031-responder.ts) in BOTH modes — query authority + * never moves to main. See docs/reference/terminal-hidden-view-parking.md and + * docs/reference/terminal-side-effect-authority.md. */ import { isClaudeAgent } from '../../../../shared/agent-detection' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { - mode2031SequenceFor, - resolveTerminalColorSchemeMode, - scanMode2031Sequences -} from '../../../../shared/terminal-color-scheme-protocol' import { useAppStore } from '@/store' -import { getSystemPrefersDark } from '@/lib/terminal-theme' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, isAgentTaskCompleteOsNotificationEnabledFromState, isAgentTaskCompleteTrackingEnabledFromState } from './agent-task-complete-policy' +import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder' import { subscribeToPtyData } from './pty-dispatcher' import { createPtyOutputProcessor } from './pty-transport' import { @@ -92,7 +89,6 @@ export function startParkedTerminalByteWatcher( let wroteRuntimeTitleSlot = false let bellNotificationTimer: ReturnType | null = null let agentTaskCompleteTimer: ReturnType | null = null - let mode2031ScanTail = '' const clearBellNotificationTimer = (): void => { if (bellNotificationTimer !== null) { @@ -255,42 +251,36 @@ export function startParkedTerminalByteWatcher( }) : null - const respondToMode2031Subscribe = (data: string): void => { - const scan = scanMode2031Sequences(mode2031ScanTail, data) - mode2031ScanTail = scan.tail - if (!scan.subscribe) { - return - } - // Why: no xterm exists while parked, so nothing answers the DECSET 2031 - // subscription. Reply out-of-band so TUIs that subscribe while parked - // still learn the theme before the pane is ever revealed. - const settings = useAppStore.getState().settings - sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) - } + // Why: no xterm exists while parked, so nothing answers a DECSET 2031 + // subscription. The responder is the parked path's only byte consumer under + // main authority — query authority belongs to the view/watcher (model/view + // contract invariant 6), so it can never move to main. + const stopMode2031Responder = startParkedTerminalMode2031Responder({ ptyId, sendInput }) - // Why: under main authority the byte sidecar stays ONLY for the DECSET 2031 - // reply — query authority belongs to the view/watcher (model/view contract - // invariant 6), so it can never move to main. Title/bell/agent parsing and - // the PR-link scan are byte-parser-mode only (null when main holds - // authority; their facts arrive on pty:sideEffect instead). - const unsubscribe = subscribeToPtyData(ptyId, (data) => { - // Why: empty pane callbacks — the watcher wants only the parser side - // effects; there is no xterm to deliver bytes to. - processor?.processData(data, {}) - respondToMode2031Subscribe(data) - if (observeTerminalGitHubPRLink) { - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) - } - } - }) + // Why (byte-parser mode only): with main authority the watcher consumes + // pty:sideEffect facts exclusively and registers NO byte parsers here — + // title/bell/agent parsing and the PR-link scan would double-fire policy. + const unsubscribeByteParsers = + processor === null + ? null + : subscribeToPtyData(ptyId, (data) => { + // Why: empty pane callbacks — the watcher wants only the parser + // side effects; there is no xterm to deliver bytes to. + processor.processData(data, {}) + if (observeTerminalGitHubPRLink) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + } + } + }) const dispose = (): void => { if (disposed) { return } disposed = true - unsubscribe() + stopMode2031Responder() + unsubscribeByteParsers?.() unregisterFactConsumer?.() // Why: cancels the deferred side-effect drain, stale-title timer, and // tracker/bell-detector state so the watcher cannot fire after the diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts new file mode 100644 index 00000000000..7ae97932379 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -0,0 +1,45 @@ +/** + * DECSET 2031 color-scheme responder for parked terminals. + * + * Why a dedicated byte sidecar: no xterm exists while a tab is parked, so + * nothing answers a TUI's mode-2031 theme subscription. Query authority stays + * with the view/watcher (model/view contract invariant 6), so this reply can + * never move to main — it is the parked path's ONLY byte consumer when main + * holds side-effect authority. Phase 4: this subscribeToPtyData registration + * doubles as the delivery-interest signal that keeps hidden byte delivery + * alive for parked PTYs. + */ +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode, + scanMode2031Sequences +} from '../../../../shared/terminal-color-scheme-protocol' +import { useAppStore } from '@/store' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { subscribeToPtyData } from './pty-dispatcher' + +export type ParkedTerminalMode2031ResponderOptions = { + ptyId: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +export function startParkedTerminalMode2031Responder( + options: ParkedTerminalMode2031ResponderOptions +): () => void { + const { ptyId, sendInput } = options + // Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan + // carries a bounded tail between chunks so split sequences still match. + let scanTail = '' + return subscribeToPtyData(ptyId, (data) => { + const scan = scanMode2031Sequences(scanTail, data) + scanTail = scan.tail + if (!scan.subscribe) { + return + } + // Why: reply with the resolved theme so TUIs that subscribe while parked + // still learn it before the pane is ever revealed. + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + }) +} 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 ee39fd85238..ecb662a8d23 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5375,6 +5375,113 @@ describe('connectPanePty', () => { expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() }) + it('seeds and settles Command Code status from command-code facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'say hi' }] + }) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + // Why: the done fact is a hint — the settle timer stays in the pane + // policy because it must consult the live status row before completing. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'say hi' }] + }) + vi.advanceTimersByTime(1499) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) + vi.advanceTimersByTime(1) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + prompt: 'say hi', + agentType: 'command-code' + }) + }) + + it('keeps Command Code working when a working fact lands before the done settles', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc-repaint') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(1000) + // An active repaint within the settle window cancels the pending done. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 3, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'Run a slow command', + agentType: 'command-code' + }) + }) + + it('does not byte-scan Command Code output — facts are the only consumer', async () => { + enableMainAuthority() + 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-authority-cc-bytes' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'command-code --trust' } }) as never + ) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { // Pre-hydration: the store has no settings yet, but the user persisted // the kill switch off. The pane must register byte parsers, not a fact diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 733c1878486..5e078494959 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -73,7 +73,7 @@ import { observeTerminalBracketedPasteModeOutput, pasteTerminalText } from './terminal-bracketed-paste' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { installConptyDeviceAttributesHandler } from './terminal-conpty-device-attributes' @@ -1003,7 +1003,12 @@ export function connectPanePty( onAgentExited, onCommandFinished: handleCommandFinished, onPrLink: (link) => - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link), + // Why: the Command Code settle policy stays here — the done settle + // timer must consult the live store row (which hook events and + // renderer seeds also write), so main only emits scrape facts. + onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, + onCommandCodeDone: scheduleCommandCodeOutputDoneStatus }, restoreTitleOnRegister: true }) @@ -1205,11 +1210,6 @@ export function connectPanePty( }, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS) } - const commandCodeOutputStatusDetector = createCommandCodeOutputStatusDetector({ - startupCommand: paneStartup?.command, - onWorking: seedCommandCodeOutputWorkingStatus, - onDone: scheduleCommandCodeOutputDoneStatus - }) const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() const onPtySpawn = (ptyId: string): void => { @@ -1514,6 +1514,16 @@ export function connectPanePty( settings: state.settings, runtimeEnvironmentId }) + // Why (byte-parser mode only): with main authority the Command Code scrape + // runs in main's per-PTY tracker and arrives as command-code facts; running + // the byte detector too would double-drive the seed/settle policy above. + const commandCodeOutputStatusDetector = mainSideEffectAuthority + ? null + : createCommandCodeOutputStatusDetector({ + startupCommand: paneStartup?.command, + onWorking: seedCommandCodeOutputWorkingStatus, + onDone: scheduleCommandCodeOutputDoneStatus + }) const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' let lastTerminalInputAt = Number.NEGATIVE_INFINITY const markTerminalInputSent = (): void => { @@ -2670,17 +2680,18 @@ export function connectPanePty( const dataCallback = (data: string, meta?: PtyDataMeta): void => { resetHiddenOutputRestoreIfPtyChanged() observeTerminalBracketedPasteModeOutput(pane.terminal, data) - // Why: with main side-effect authority, command-finished and pr-link - // arrive as pty:sideEffect facts — byte-scanning here too would - // double-fire the same policy. Remote-runtime PTYs (and the kill - // switch off) keep this byte path as their only parser. + // Why: with main side-effect authority, command-finished, pr-link, and + // the Command Code scrape arrive as pty:sideEffect facts — + // byte-scanning here too would double-fire the same policy. + // Remote-runtime PTYs (and the kill switch off) keep this byte path as + // their only parser. if (!mainSideEffectAuthority) { for (const link of observeTerminalGitHubPRLink(data)) { useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) } commandLifecycle.handlePtyData(data) } - commandCodeOutputStatusDetector.observe(data) + commandCodeOutputStatusDetector?.observe(data) // Why: split-pane layouts have multiple visible-but-inactive panes whose // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index 074fd7cad98..fe4193e9c68 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -205,6 +205,56 @@ describe('registerTerminalSideEffectFactConsumer', () => { ]) }) + it('routes command-code scrape facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ]) + ) + + expect(events).toEqual([ + ['cc-working', 'Fix the spinner'], + ['cc-done', 'Fix the spinner'] + ]) + }) + + it('never replays command-code scrape facts', () => { + // Why: a replayed working/done seed would resurrect a finished turn's + // status row — replay batches restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + it('never replays command-finished or pr-link facts', () => { // Why: like bells and agent transitions, command/PR facts are attention // signals — replay snapshots restore title state only. diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 6774bd9a8bf..103965e7380 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -78,6 +78,10 @@ export type TerminalSideEffectFactConsumerCallbacks = { * (stale agent-status row drop + interrupt-inference coordination). */ onCommandFinished?: (bestEffortExitCode: number | null) => void onPrLink?: (link: TerminalGitHubPRLink) => void + /** Command Code output scrape (no hooks): working seeds the status row; + * done is settle-checked by the pane policy before completing the turn. */ + onCommandCodeWorking?: (prompt: string) => void + onCommandCodeDone?: (prompt: string) => void } type ConsumerEntry = { @@ -120,6 +124,12 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: return case 'pr-link': entry.callbacks.onPrLink?.(fact.link) + return + case 'command-code-working': + entry.callbacks.onCommandCodeWorking?.(fact.prompt) + return + case 'command-code-done': + entry.callbacks.onCommandCodeDone?.(fact.prompt) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index b505486278a..ae5fa99b63f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -8,6 +8,7 @@ // event sequences match. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects' import { createPtyOutputProcessor } from './pty-transport' @@ -283,3 +284,95 @@ describe('main tracker parity with renderer 133;D and PR-link byte parsers', () expect(paths.main.events).toEqual([]) }) }) + +// Why: slice 4 moves the Command Code output scrape into main for local/SSH +// PTYs. The renderer byte path observes raw transport data; main observes the +// OSC 9999-stripped cleanData. Both must derive identical working/done +// sequences from the same chunk boundaries, or flipping the kill switch +// changes Command Code status rows. +type CommandCodeFactEvent = ['working' | 'done', string] + +type CommandCodeFactPath = { + events: CommandCodeFactEvent[] + feed: (chunk: string) => void +} + +function createCommandCodePath(options: { stripStatusPayloads: boolean }): CommandCodeFactPath { + const events: CommandCodeFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking: (prompt) => events.push(['working', prompt]), + onDone: (prompt) => events.push(['done', prompt]) + }) + return { + events, + feed(chunk: string): void { + detector.observe( + options.stripStatusPayloads ? processAgentStatusChunk(chunk).cleanData : chunk + ) + } + } +} + +describe('main Command Code scrape parity with the renderer byte detector', () => { + let paths: { renderer: CommandCodeFactPath; main: CommandCodeFactPath } + + beforeEach(() => { + paths = { + renderer: createCommandCodePath({ stripStatusPayloads: false }), + main: createCommandCodePath({ stripStatusPayloads: true }) + } + }) + + it('derives identical working facts after the banner arms across chunks', () => { + feedBoth(paths, '# Command') + feedBoth(paths, ' Code v0.27.3\r\n') + feedBoth(paths, '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([['working', 'Fix the spinner']]) + }) + + it('derives identical done facts for a no-tool turn in both paths', () => { + feedBoth(paths, '# Command Code v0.27.3\r\n') + feedBoth(paths, '❯ say hi\r\n✻ Thinking...') + feedBoth(paths, '\r\n:: Hi!\r\n❯ Ask your question...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('recovers prompt capture from interleaved OSC 9999 payloads (main improvement)', () => { + // Deliberate divergence, not drift: the renderer's raw byte path lets an + // OSC 9999 payload leak partial text into the scrape window (its ANSI + // strip consumes only the ESC] introducer), which breaks the prompt-echo + // line match. Main feeds the OSC 9999-stripped cleanData, so the prompt + // (and therefore the done settle hint) survives an adjacent payload. + const payloadThenPrompt = [ + '# Command Code v0.27.3\r\n', + `${ESC}]9999;{"state":"working","agentType":"command-code"}${BEL}`, + '❯ say hi\r\n✻ Thinking...', + '\r\n:: Hi!\r\n❯ Ask your question...' + ] + for (const chunk of payloadThenPrompt) { + feedBoth(paths, chunk) + } + + expect(paths.renderer.events).toEqual([['working', '']]) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('stays silent without the Command Code banner in both paths', () => { + feedBoth(paths, '❯ Fix the spinner\r\nThinking about unrelated shell output...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/automation-session-observer.test.ts b/src/renderer/src/lib/automation-session-observer.test.ts new file mode 100644 index 00000000000..76cd9c1edf3 --- /dev/null +++ b/src/renderer/src/lib/automation-session-observer.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSubscribeToPtyData = vi.fn() +const mockSubscribeToPtyExit = vi.fn() +const mockSubscribeTerminal = vi.fn() +const mockCallRuntimeRpc = vi.fn() + +const state = { + settings: { + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, + setAgentStatus: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state + } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + subscribeToPtyData: mockSubscribeToPtyData, + subscribeToPtyExit: mockSubscribeToPtyExit +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mockCallRuntimeRpc, + getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })) +})) + +vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({ + getRemoteRuntimeTerminalMultiplexer: () => ({ subscribeTerminal: mockSubscribeTerminal }) +})) + +const DONE_STATUS_OSC = '\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07' + +describe('observeExistingAutomationSession', () => { + beforeEach(() => { + vi.clearAllMocks() + state.settings = { + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } + mockSubscribeToPtyData.mockReturnValue(vi.fn()) + mockSubscribeToPtyExit.mockReturnValue(vi.fn()) + mockCallRuntimeRpc.mockReturnValue(new Promise(() => {})) + mockSubscribeTerminal.mockResolvedValue({ close: vi.fn() }) + }) + + it('skips the duplicate OSC store write for local PTYs under main authority', async () => { + // Why: main already parses OSC 9999 for local/SSH PTYs and routes it to + // the store via agentStatus:set; writing here too would race that path. + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + + it('keeps the legacy OSC store write when the kill switch is off', async () => { + state.settings.terminalMainSideEffectAuthority = false + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) + + it('keeps the OSC store write for remote-runtime PTYs (bytes never transit local main)', async () => { + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'remote:env-1@@terminal-9', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + expect(mockSubscribeTerminal).toHaveBeenCalledTimes(1) + const callbacks = mockSubscribeTerminal.mock.calls[0]?.[0]?.callbacks as { + onData: (data: string) => void + } + callbacks.onData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index aa9bb108098..b355edfa5ca 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -9,6 +9,7 @@ import { import { useAppStore } from '@/store' import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' export async function observeExistingAutomationSession(args: { ptyId: string @@ -19,12 +20,25 @@ export async function observeExistingAutomationSession(args: { onExit: (code: number) => void }): Promise<() => void> { const { ptyId, paneKey, runId, onData, onExit } = args + // Why: for local/SSH PTYs main already parses OSC 9999 and routes it + // through the hook server (agentStatus:set → store); writing here too + // would race/duplicate that path. Remote-runtime bytes never transit local + // main, and the kill switch restores the legacy write. The onAgentStatus + // callback always fires — automation completion tracking stays here. + const mainOwnsAgentStatusWrites = + !isRemoteRuntimePtyId(ptyId) && + isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { onData(data) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + } args.onAgentStatus(payload) } } diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 9aaf6453895..b1ef85d5afc 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -32,7 +32,11 @@ function expectStablePaneSpawn(): string { } const state = { - settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, + settings: { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, repos: [{ id: 'repo-1', connectionId: null as string | null }], allWorktrees: vi.fn(() => [ { id: 'wt-1', repoId: 'repo-1', path: '/repo/worktree', displayName: 'main' } @@ -75,7 +79,11 @@ describe('launchAgentBackgroundSession', () => { (args) => createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) ) - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } state.repos = [{ id: 'repo-1', connectionId: null }] mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' }) mockSpawn.mockResolvedValue({ id: 'pty-1' }) @@ -173,7 +181,10 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalled() }) - it('parses agent status from hidden PTY output', async () => { + it('parses agent status from hidden PTY output when the kill switch is off', async () => { + // Why: with main side-effect authority disabled, this sidecar is the only + // OSC 9999 → store path for hidden local sessions. + state.settings.terminalMainSideEffectAuthority = false const onAgentStatus = vi.fn() const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -198,6 +209,29 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('skips the duplicate OSC store write under main side-effect authority', async () => { + // Why: main already routes OSC 9999 through the hook server to the store + // (agentStatus:set); a second write here would race the authoritative + // path. The automation onAgentStatus callback must still fire. + const onAgentStatus = vi.fn() + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + onAgentStatus + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07') + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + it('seeds a working status for Command Code prompt launches', async () => { const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -297,7 +331,11 @@ describe('launchAgentBackgroundSession', () => { }) it('creates background sessions on the active runtime environment', async () => { - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const result = await launchAgentBackgroundSession({ diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 11fcadd8bf5..1e2c0068650 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -24,6 +24,7 @@ import { import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { translate } from '@/i18n/i18n' export type LaunchAgentBackgroundSessionArgs = { @@ -212,13 +213,25 @@ export async function launchAgentBackgroundSession( useAppStore.getState().clearTabPtyId(tab.id, ptyId) onExit?.(ptyId, code) } + // Why: for local/SSH PTYs main already parses OSC 9999 and routes it through + // the hook server (agentStatus:set → store), so a second store write here + // would race/duplicate the authoritative path. Remote-runtime bytes never + // transit local main; the kill switch restores the legacy write. The + // onAgentStatus callback always fires — automation completion tracking is + // this sidecar's own responsibility, not a store side effect. + const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({ + settings: store.settings, + runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { onData?.(data) scheduleSshStartupInjection(ptyId) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + } onAgentStatus?.(payload) } } @@ -257,7 +270,12 @@ export async function launchAgentBackgroundSession( agent, submit: true, onTimeout: () => { - toast.message(translate("auto.lib.launch.agent.background.session.4ca0651d56", "Your automation prompt wasn't sent — open the workspace and paste it.")) + toast.message( + translate( + 'auto.lib.launch.agent.background.session.4ca0651d56', + "Your automation prompt wasn't sent — open the workspace and paste it." + ) + ) track('agent_error', { error_class: 'paste_readiness_timeout', agent_kind: tuiAgentToAgentKind(agent) diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-output-status.test.ts rename to src/shared/command-code-output-status.test.ts diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.ts b/src/shared/command-code-output-status.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/command-code-output-status.ts rename to src/shared/command-code-output-status.ts index dc3b7db2cad..bc743c54a05 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -1,3 +1,11 @@ +/** + * Command Code TUI output scrape — that CLI lacks hooks, so working/done + * agent-status rows are seeded from its rendered status words and idle + * composer. Shared because main runs this per-PTY under side-effect authority + * (emitting command-code facts) while the renderer keeps the byte path for + * remote-runtime PTYs and the kill switch + * (docs/reference/terminal-side-effect-authority.md). + */ type CommandCodeOutputStatusDetector = { observe: (data: string) => boolean } diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index b1c3733d071..b99ce6679b0 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -23,6 +23,11 @@ export type TerminalSideEffectFact = /** Carries the parsed link so the renderer store consumer never re-parses * the URL (parse drift would break the per-PTY dedupe contract). */ | { kind: 'pr-link'; link: TerminalGitHubPRLink } + /** Command Code output scrape (that CLI lacks hooks). Working seeds the + * agent-status row immediately; done is a hint the renderer settle-checks + * against its live status row before completing the turn. */ + | { kind: 'command-code-working'; prompt: string } + | { kind: 'command-code-done'; prompt: string } export type TerminalSideEffectBatch = { ptyId: string @@ -30,7 +35,9 @@ export type TerminalSideEffectBatch = { * their title state was current at, so the handler can drop a replay title * older than the last live title fact it applied. */ seq: number - /** Facts from one chunk, in byte order: titles in sequence, then bell. */ + /** Facts from one chunk, in byte order: titles in sequence, then bell. + * Command Code scrape facts trail the chunk's parser facts — their policy + * (status-row seeding) never interacts with title/bell ordering. */ facts: TerminalSideEffectFact[] /** True for (re)attach snapshots. Replay batches restore title state only — * attention facts (bell, agent transitions) never replay. */ From 62b6d012ca3586d8ed1da28b9760cf301ee0f86d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:10:09 -0700 Subject: [PATCH 44/62] Gate PTY delivery to hidden terminal views Co-authored-by: Orca --- docs/reference/terminal-query-authority.md | 291 ++++++++ src/main/ipc/pty-hidden-delivery-gate.test.ts | 118 ++++ src/main/ipc/pty-hidden-delivery-gate.ts | 148 ++++ src/main/ipc/pty.test.ts | 651 +++++++++++++++++- src/main/ipc/pty.ts | 228 +++++- src/main/runtime/orca-runtime.test.ts | 13 + src/main/runtime/orca-runtime.ts | 10 +- src/main/ssh/ssh-relay-session.test.ts | 95 +++ src/main/ssh/ssh-relay-session.ts | 33 +- src/preload/api-types.ts | 20 + src/preload/index.ts | 30 + .../parked-terminal-byte-watcher.test.ts | 45 +- .../parked-terminal-byte-watcher.ts | 60 +- .../parked-terminal-mode2031-responder.ts | 10 +- .../terminal-pane/pty-connection-types.ts | 6 + .../terminal-pane/pty-connection.test.ts | 564 +++++++++++++++ .../terminal-pane/pty-connection.ts | 347 +++++++++- .../terminal-pane/pty-delivery-interest.ts | 42 ++ .../pty-dispatcher-delivery-interest.test.ts | 111 +++ .../terminal-pane/pty-dispatcher.ts | 23 + .../pty-model-restore-channel.test.ts | 86 +++ .../pty-model-restore-channel.ts | 55 ++ .../terminal-pane/pty-transport.test.ts | 17 + .../components/terminal-pane/pty-transport.ts | 18 +- .../terminal-hidden-delivery-gate.ts | 45 ++ ...terminal-side-effect-facts-handler.test.ts | 28 + .../terminal-side-effect-facts-handler.ts | 7 + .../use-terminal-pane-lifecycle.ts | 21 +- src/renderer/src/web/web-preload-api.ts | 10 +- src/shared/constants.ts | 1 + src/shared/pty-model-restore-marker.ts | 19 + .../terminal-output-side-effects.test.ts | 37 + src/shared/terminal-output-side-effects.ts | 28 +- src/shared/terminal-side-effect-facts.ts | 5 + src/shared/types.ts | 5 + ...icial-opencode-hidden-pressure-scenario.ts | 80 ++- .../artificial-opencode-terminal-load.spec.ts | 8 +- ...terminal-hidden-tui-visual-restore.spec.ts | 47 +- 38 files changed, 3256 insertions(+), 106 deletions(-) create mode 100644 docs/reference/terminal-query-authority.md create mode 100644 src/main/ipc/pty-hidden-delivery-gate.test.ts create mode 100644 src/main/ipc/pty-hidden-delivery-gate.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-delivery-interest.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts create mode 100644 src/shared/pty-model-restore-marker.ts diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md new file mode 100644 index 00000000000..3ce68597d1b --- /dev/null +++ b/docs/reference/terminal-query-authority.md @@ -0,0 +1,291 @@ +# Terminal Query Authority + +Status: Phase 5 of the terminal model/view architecture. Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this +phase **amends invariant 6**), +[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md) +(Phase 3), and the Phase-4 hidden-delivery gate +(`src/main/ipc/pty-hidden-delivery-gate.ts`). + +## Problem + +Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion +(`src/main/ipc/pty.ts:1417,1506`, `src/main/ssh/ssh-relay-session.ts:931`). +Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks +waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang +TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4 +hidden skip latch had the same hole (only mode 2031 and the 10s codex startup +window answered), so this is not a regression — it is the long-standing gap +this phase closes. + +Contract invariant 6 ("the model must never answer queries") was written +against a real bug: the daemon emulator replying ahead of the renderer with +default-xterm values (the OSC-11 default-black-background race, +`headless-emulator.ts:82-93`, pinned by `session.test.ts:163-187`). The danger +was never "the model answers" — it was **two answerers for the same bytes**, +one of them with wrong values. Phase 5 keeps the singularity and fixes the +values. + +## Decision: the delivery decision is the reply decision + +Main answers a query **iff main dropped the chunk that carried it**. The same +per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that +decides renderer delivery decides reply ownership, evaluated once, +synchronously, at ingestion: + +- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via + `Terminal.onData` → `transport.sendInput`, unchanged. +- Hidden-marked, no delivery interest → chunk dropped → main answers from the + runtime headless emulator, via the provider input path (`provider.write`, + same path as `pty:write`; daemon shell-ready write gating and the SSH relay + write apply unchanged). +- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both + sides). + +This is structurally exactly-one-responder: a chunk is delivered or dropped, +never both, and each side only answers bytes it actually parsed live. The +mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist +from Phase 4 and are reused, not duplicated. + +Rejected alternatives: + +- **Fact-based renderer replies per query class** (the mode-2031 pattern + generalized): needs a main-side detection grammar per query, a fact round + trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the + emulator is the only state for a hidden pane. The 2031 fact stays because it + is subscription registration, not a state query. +- **Emulator always answers**: re-creates the OSC-11 double-reply race for + visible panes. Never. + +## Mechanism: forwarded emulator onData, not a new grammar + +`HeadlessEmulator` gains `onData` wiring behind a per-write capture flag. +For static and model-state queries, xterm core **is** the query grammar: the +runtime emulator runs the same xterm version with equivalent options as the +renderer pane, so main's reply set equals the visible renderer's by +construction — verified empirically against the bundled headless build: +DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including +DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS +(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching +visible behavior today. The headless build has **no theme service**: OSC +4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option +set, so the view-attribute class is answered by responder-registered parser +handlers instead (below) — never by core defaults. + +Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and +attached to the emulator `writeChain` link (the mark can flip between +ingestion and an async write; the decision must not be re-read at reply time): + +1. gate enabled (`terminalMainSideEffectAuthority` and + `terminalHiddenDeliveryGate` both on) AND new kill switch + `terminalModelQueryAuthority !== false`; +2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData` + — same module state, same tick as the drop sites); +3. the write is live PTY data — never `seedHeadlessTerminal`, + `maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay + (main-side replay guard, mirror of the renderer's `replay-guard.ts`); +4. no remote view subscriber is attached to the PTY (runtime terminal-RPC + subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop + xterm receiving the multiplexed stream answers with view authority, exactly + like a visible local pane. Read-only consumers (CLI reads, automation + observers) do not suppress — they also do not answer; that bounded + no-reply case matches today's behavior. + +Everything the emulator emits outside a forwarding window is discarded, which +also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes +triggered by option mutations). + +## Reply classes + +| Class | Queries | Answer source | +| --- | --- | --- | +| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | +| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | +| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | +| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | +| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | +| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`pty-connection.ts:1627`, parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | + +### View-attribute bridge + +New renderer→main push, `pty:terminalViewAttributes` — one global snapshot, +not per-PTY: the composed terminal `ITheme` (from +`applyTerminalAppearanceToPanes`, `terminal-appearance.ts:211-232`), +`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme +mode (`resolveTerminalColorSchemeMode` — the same source as the existing +hidden 2031 reply). Pushed on renderer startup and on every theme/settings +apply. + +Main consumes it two ways: + +- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options + inside the replay guard; xterm core then answers DECRQSS DECSCUSR and + DECRQM 12 with renderer-true values (verified working headless). +- Palette and color-scheme replies come from responder-registered parser + handlers on the emulator (`registerOscHandler` 4/10/11/12, + `registerCsiHandler` for DSR ?996n), because the headless core cannot + answer them. The OSC handlers see SET payloads too, so runtime OSC + 4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are + tracked per PTY and layered over the pushed base palette — matching what + the renderer's theme service reports for a visible pane. + +Staleness rules: replies use the last push; a theme flip is stale for at most +one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push). +**Before the first push main answers no view-attribute query** — a fabricated +default would resurrect the default-black OSC-11 bug; silence is the +documented hidden status quo. + +### Kitty keyboard flags + +Enable `vtExtensions.kittyKeyboard: true` in `HeadlessEmulator`, matching +`buildDefaultTerminalOptions` (`pane-terminal-options.ts:49`). Risk is low: +for the write-only daemon use, keyboard state never alters serialization; the +change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring +them, and lets the responder answer `CSI ? u` with the flags the hidden app +actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes` +for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty +flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty +reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`) +stays authoritative. A re-seeded emulator that lost flags answers `?0u`; +protocol-conformant programs re-push. + +### ConPTY DA1 variant + +The provider kind is known main-side: mirror `isLocalNativeWindowsPty` +(`windows-pty-compatibility.ts:48`) from the spawn record (local/daemon +provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the +emulator parser (the main-side twin of +`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by +the forwarding predicate. ConPTY blocking on a missing DA1 is a spawn-time +hazard; spawn-time ownership is deterministic (see races below). + +## Suppression: when main never replies + +- Visible or unmarked PTY (chunk was delivered). +- Renderer delivery interest registered (chunk was delivered to a sidecar). +- Codex startup window active — the renderer never marks the PTY hidden while + the window runs (`pty-connection.ts:2259-2261`), so the gate predicate is + structurally false; the live xterm answers startup probes. +- Remote-runtime (`remote:`) PTYs — never markable + (`isHiddenDeliveryGateManagedPty`), bytes never transit local main. +- Remote view subscriber attached (mobile/web/remote desktop owns replies). +- Seed/hydration/snapshot writes into the emulator, and option pushes. +- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an + independent off switch for the responder alone. +- The **daemon** emulator: never, under any setting. The responder lives in + main's runtime only; `session.test.ts:163-187` stays pinned verbatim. + +## Transition races + +Worst cases, per direction: + +- **visible→hidden**: chunks delivered between the visibility flip and the + mark landing in main are hidden-skipped by the renderer write path without + query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden + skip behavior, bounded by one renderer→main IPC hop. After the mark lands, + main answers everything it drops. +- **hidden→visible**: unmark consumes the drop latch and emits the restore + marker; the snapshot replay is replay-guarded, so queries main already + answered are never re-answered from the snapshot; post-unmark live chunks + are answered by xterm once (restore-queued live chunks reply late, not + twice). +- **Split queries across the drop/deliver boundary**: neither parser saw the + whole sequence → no reply; the restore marker resets renderer cross-chunk + state and replay hygiene resets the parser. At-most-once holds. + +Safe-side rule per class: duplicates are structurally impossible (one decision +point per chunk); where the race costs anything it costs a missing reply. +That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or +tolerate silence, as they did for every hidden pane before this phase). The +one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn, where +ownership is deterministic: visible pane, startup window (renderer), or +marked-at-spawn (main). It cannot land in the flip gap. + +## Invariants + +1. Exactly one party may answer any query, chosen by the chunk's delivery + decision: delivered → the consuming live view's xterm; dropped → main's + model responder; replayed/seeded → no one. The decision is captured once, + synchronously, at ingestion. +2. Main answers only from live PTY bytes parsed by the runtime emulator — + never from snapshot, seed, hydration, or option-push writes. +3. View-attribute answers are renderer-true or absent: no reply is ever + fabricated from emulator defaults (the OSC-11 lesson). +4. The daemon emulator stays write-only; daemon subprocess query writes stay + zero (`session.test.ts` pins are permanent). +5. Reply parity is structural for static and model-state classes: same xterm + core, equivalent options, no hand-rolled grammar — the only overrides are + the documented ConPTY DA1 variant and the view-attribute parser handlers + the headless core cannot serve. +6. Remote views keep view authority; main yields whenever a remote view + subscriber is attached. + +**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is +replaced by: + +> 6. Terminal query authority is singular and structural: the party that +> writes a chunk into a live terminal answers its queries. Visible renderer +> and remote views keep xterm authority. Chunks dropped by the +> hidden-delivery gate are answered exactly once by the main model +> responder, from runtime-emulator state plus renderer-pushed view +> attributes. Replayed, seeded, or snapshot bytes are answered by no one. +> The daemon emulator never answers. + +The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11, +or theme-sensitive queries" splits into: daemon emulator never answers +(unchanged pins) / runtime responder answers only hidden-dropped chunks. The +side-effect authority matrix row "DECSET 2031 reply — query authority stays +with the view (contract invariant 6)" gains a pointer here; its reply path is +otherwise untouched in this phase. + +## Test strategy + +- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs + interest-suppressed; each reply class; seed/hydrate silence; remote- + subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip + between ingestion and async emulator write (captured decision wins). +- Parity harness: shared query byte fixtures through a renderer-configured + xterm (onData capture) and through the responder; assert byte-identical + replies for static + model-state classes, and for view-attribute classes + after an attribute push. +- `session.test.ts:163-187`: assertions stay; the comment is updated to name + the main responder (not "the renderer") as the hidden answerer. +- E2E: hidden `claude /theme` reports the configured theme; hidden TUI + blocked on CPR/DA unblocks while gated; reveal shows no stray reply + fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and + `terminal-hidden-view-parking.spec.ts` stay green. + +## Cut-offs (stacked, independently mergeable) + +1. **Responder core.** Emulator onData wiring + per-write capture + main + replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`); + static + model-state classes; ConPTY DA1 override; remote-subscriber + suppression; `terminalModelQueryAuthority` switch; unit + parity tests. + Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock. +2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor + option application under the guard, responder OSC/DSR parser handlers with + per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e. +3. **Contract alignment.** Invariant-6 amendment in the contract doc, test + bullet split, `session.test.ts` comment, side-effect matrix pointer, and + the Phase 6 prerequisites below recorded as accepted. + +## What Phase 6 (delete skip grammar + startup window) requires from this design + +- **Mark-before-first-byte**: panes spawned without a visible view must be + hidden-marked at spawn (spawn-record flag, not a renderer round trip) so + startup queries — including ConPTY's blocking DA1 and codex startup probes — + are main-owned from byte zero once the 10s window is gone. +- **Attributes before spawn**: the renderer must push view attributes at app + start, before any hidden spawn, or spawn-time view-attribute queries fall + into the silent-until-push rule. +- **Daemon shell-ready write gating** queues responder replies until the + ready marker; spawn-time replies on Windows daemon PTYs need explicit + validation before the window is removed. +- With the skip grammar deleted, every chunk is either written to a live + xterm or dropped — the delivered-but-skipped no-reply gap disappears and + the only remaining loss window is the mark IPC race. +- **2031 consolidation** (optional follow-up): move the subscription registry + into the responder (the headless core cannot serve 997 pushes any more than + it can ?996n) and push 997 flips from the attribute cache, retiring the + `2031-subscribe` fact reply, the parked responder, and the parked-tab + theme-flip gap. diff --git a/src/main/ipc/pty-hidden-delivery-gate.test.ts b/src/main/ipc/pty-hidden-delivery-gate.test.ts new file mode 100644 index 00000000000..c85c4e2c8e0 --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + isHiddenPtyDeliveryGateEnabled, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' + +const PTY_ID = 'pty-1' + +describe('pty hidden delivery gate', () => { + beforeEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('only operates when both kill switches are on (default on)', () => { + expect(isHiddenPtyDeliveryGateEnabled(undefined)).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({})).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({ terminalHiddenDeliveryGate: false })).toBe(false) + expect(isHiddenPtyDeliveryGateEnabled({ terminalMainSideEffectAuthority: false })).toBe(false) + }) + + it('drops only hidden PTYs without registered delivery interest', () => { + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + + markHiddenRendererPty(PTY_ID) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, { terminalHiddenDeliveryGate: false })).toBe( + false + ) + + setRendererPtyDeliveryInterest(PTY_ID, true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + setRendererPtyDeliveryInterest(PTY_ID, false) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + }) + + it('requests the restore marker exactly once per drop episode, re-armed by unmark', () => { + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false) + + // Why: unmark consumes the latch (and re-emits via its own return value); + // the next hidden period's first drop reports again. + unmarkHiddenRendererPty(PTY_ID) + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + }) + + it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => { + // Why: a hidden remount or renderer reload re-marks without an unhide in + // between — clearing the latch there would make reveal skip the restore. + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('reports drops on unhide so reveal can heal a replaced renderer view', () => { + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(false) + + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) + + it('clears renderer-scoped state on reload while preserving drop memory', () => { + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + setRendererPtyDeliveryInterest('pty-2', true) + markHiddenRendererPty('pty-2') + + resetRendererScopedHiddenPtyDeliveryState() + + // Hidden marks and interest holds died with the old renderer process. + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + // pty-2's leaked interest is gone: re-marking gates it again. + markHiddenRendererPty('pty-2') + expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true) + // Drop memory survives so the new renderer's first unhide still restores. + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('clears all per-PTY state on teardown and tracks debug counters', () => { + markHiddenRendererPty(PTY_ID) + setRendererPtyDeliveryInterest('pty-2', true) + recordHiddenRendererPtyDataDrop(PTY_ID, 7) + recordHiddenRendererPtyDataDrop(PTY_ID, 5) + + expect(getHiddenRendererPtyDeliveryDebug()).toEqual({ + hiddenDeliveryGatedPtyCount: 1, + deliveryInterestPtyCount: 1, + hiddenDeliveryDroppedChars: 12, + hiddenDeliveryDroppedChunks: 2 + }) + + clearHiddenRendererPtyDeliveryState(PTY_ID) + clearHiddenRendererPtyDeliveryState('pty-2') + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) +}) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts new file mode 100644 index 00000000000..b0269e600d2 --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -0,0 +1,148 @@ +/** + * Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of + * the terminal model/view architecture). + * + * The renderer marks a PTY hidden when no visible view consumes its bytes; + * main then drops renderer-bound delivery AFTER model ingestion — the runtime + * already parsed the chunk, and reveal restores from the model snapshot via + * the existing seq-guarded machinery. Any renderer party that still needs raw + * bytes (dispatcher sidecars, eager pre-mount buffers) registers delivery + * interest, which suppresses the gate for that PTY. + * See docs/reference/terminal-side-effect-authority.md (Open Items). + */ +import type { GlobalSettings } from '../../shared/types' + +export type HiddenPtyDeliveryGateSettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' +> + +const hiddenRendererPtys = new Set() +// Why: sidecar consumers (paste-draft pacing, background agent launches, +// automation observers, the kill-switch-off parked 2031 responder) and eager +// pre-mount buffers need live bytes even while no visible view exists. Any +// registered interest suppresses the gate for that PTY. +const deliveryInterestRendererPtys = new Set() +// Why: reveal must restore from the model only when bytes were actually +// dropped. Doubles as the one-shot marker latch: the first gated drop emits a +// restore marker, and the latch is consumed only by unmark (which re-emits) +// or full PTY teardown — never by re-marking hidden, so drop memory survives +// hidden remounts and renderer reloads. +const droppedSinceHiddenPtys = new Set() + +let droppedHiddenDeliveryChars = 0 +let droppedHiddenDeliveryChunks = 0 + +/** Gate kill switches, both read main-side: the gate only operates under main + * side-effect authority AND the gate-specific setting (both default on). */ +export function isHiddenPtyDeliveryGateEnabled( + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + settings?.terminalMainSideEffectAuthority !== false && + settings?.terminalHiddenDeliveryGate !== false + ) +} + +/** Renderer-reported "no visible view needs bytes" bit. Never clears drop + * memory: a hidden remount or renderer reload re-marks an already-dropped + * PTY, and erasing the latch there would make the eventual reveal skip the + * restore. Unmark is the only consumer of the latch. */ +export function markHiddenRendererPty(id: string): void { + hiddenRendererPtys.add(id) +} + +/** Clears the hidden bit. Returns whether bytes were dropped while hidden so + * the caller can emit a restore marker to the now-visible renderer. */ +export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } { + hiddenRendererPtys.delete(id) + const droppedWhileHidden = droppedSinceHiddenPtys.delete(id) + return { droppedWhileHidden } +} + +export function isHiddenRendererPty(id: string): boolean { + return hiddenRendererPtys.has(id) +} + +/** Renderer-side ref-counted interest, surfaced as boolean transitions. */ +export function setRendererPtyDeliveryInterest(id: string, interested: boolean): void { + if (interested) { + deliveryInterestRendererPtys.add(id) + } else { + deliveryInterestRendererPtys.delete(id) + } +} + +export function shouldDropHiddenRendererPtyData( + id: string, + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + isHiddenPtyDeliveryGateEnabled(settings) && + hiddenRendererPtys.has(id) && + !deliveryInterestRendererPtys.has(id) + ) +} + +/** Record one gated drop. Returns whether the caller should emit the one-shot + * empty restore-marker chunk (first drop since this PTY went hidden). */ +export function recordHiddenRendererPtyDataDrop( + id: string, + chars: number +): { shouldEmitRestoreMarker: boolean } { + droppedHiddenDeliveryChars += chars + droppedHiddenDeliveryChunks += 1 + if (droppedSinceHiddenPtys.has(id)) { + return { shouldEmitRestoreMarker: false } + } + droppedSinceHiddenPtys.add(id) + return { shouldEmitRestoreMarker: true } +} + +/** Renderer process replaced (reload / crash): its ref-counted interest + * holds and hidden marks died with it, so keeping them would gate (or + * force-feed) PTYs no live renderer party asked about. Drop memory is + * preserved — surviving daemon/SSH PTYs may have dropped bytes the old + * renderer never restored; the new renderer's first hidden/visible sync + * re-marks or unmarks and the unmark path re-emits the restore marker. */ +export function resetRendererScopedHiddenPtyDeliveryState(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() +} + +/** Full per-PTY teardown — wired into clearProviderPtyState so every exit + * path (local, daemon, SSH, connection teardown) releases gate state. */ +export function clearHiddenRendererPtyDeliveryState(id: string): void { + hiddenRendererPtys.delete(id) + deliveryInterestRendererPtys.delete(id) + droppedSinceHiddenPtys.delete(id) +} + +export type HiddenRendererPtyDeliveryDebug = { + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number +} + +export function getHiddenRendererPtyDeliveryDebug(): HiddenRendererPtyDeliveryDebug { + return { + hiddenDeliveryGatedPtyCount: hiddenRendererPtys.size, + deliveryInterestPtyCount: deliveryInterestRendererPtys.size, + hiddenDeliveryDroppedChars: droppedHiddenDeliveryChars, + hiddenDeliveryDroppedChunks: droppedHiddenDeliveryChunks + } +} + +export function resetHiddenRendererPtyDeliveryDebugCounters(): void { + droppedHiddenDeliveryChars = 0 + droppedHiddenDeliveryChunks = 0 +} + +/** Test seam: reset all module state between tests. */ +export function _resetHiddenRendererPtyDeliveryGateForTest(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() + droppedSinceHiddenPtys.clear() + resetHiddenRendererPtyDeliveryDebugCounters() +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e1dd27d117a..dc14b3514e1 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -160,6 +160,7 @@ import { rebindLocalProviderListeners, unregisterSshPtyProvider } from './pty' +import { _resetHiddenRendererPtyDeliveryGateForTest } from './pty-hidden-delivery-gate' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { encodePowerShellCommand, @@ -258,6 +259,9 @@ describe('registerPtyHandlers', () => { clearPaneKeyAliasesForPtyMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() + // Why: hidden-delivery gate state is module-level by design (PTY-keyed, + // not window-keyed); tests must not leak hidden bits across cases. + _resetHiddenRendererPtyDeliveryGateForTest() handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { handlers.set(channel, handler) @@ -488,6 +492,32 @@ describe('registerPtyHandlers', () => { return activeCall[1] as (event: unknown, args: { id: string; active: boolean }) => void } + function getPtySetHiddenRendererPtyListener(): ( + event: unknown, + args: { id: string; hidden: boolean } + ) => void { + const hiddenCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setHiddenRendererPty' + ) + if (!hiddenCall) { + throw new Error('missing pty:setHiddenRendererPty listener') + } + return hiddenCall[1] as (event: unknown, args: { id: string; hidden: boolean }) => void + } + + function getPtySetDeliveryInterestListener(): ( + event: unknown, + args: { id: string; interested: boolean } + ) => void { + const interestCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setPtyDeliveryInterest' + ) + if (!interestCall) { + throw new Error('missing pty:setPtyDeliveryInterest listener') + } + return interestCall[1] as (event: unknown, args: { id: string; interested: boolean }) => void + } + /** Helper: trigger pty:spawn and return the env passed to node-pty. */ async function spawnAndGetEnv( argsEnv?: Record, @@ -4954,6 +4984,532 @@ describe('registerPtyHandlers', () => { } }) + describe('hidden renderer delivery gate', () => { + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'hidden output') + vi.advanceTimersByTime(50) + + // Model ingestion still ran — only renderer delivery was dropped. + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'hidden output', + expect.any(Number) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data chunk is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + + // Subsequent gated chunks drop silently — the marker is one-shot. + daemon.emitData(result.id, 'more hidden output') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryDroppedChars: 'hidden output'.length + 'more hidden output'.length, + hiddenDeliveryDroppedChunks: 2, + pendingPtyCount: 0, + rendererInFlightChars: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the interactive bypass gated for hidden PTYs', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + const setHidden = getPtySetHiddenRendererPtyListener() + + writeListener(null, { id: spawnResult.id, data: 'a' }) + setHidden(null, { id: spawnResult.id, hidden: true }) + mainWindow.webContents.send.mockClear() + + // A keystroke-sized redraw would take the immediate path when visible. + mockProc.emitData('\x1b[20;2Hredraw') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses the gate while renderer delivery interest is registered', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setInterest(null, { id: spawnResult.id, interested: true }) + mockProc.emitData('sidecar bytes') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'sidecar bytes' + }) + + setInterest(null, { id: spawnResult.id, interested: false }) + mainWindow.webContents.send.mockClear() + mockProc.emitData('gated bytes') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])('keeps delivery when the %s kill switch is off', async (_name, settings) => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('still delivered') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'still delivered' + }) + } finally { + vi.useRealTimers() + } + }) + + it('drops queued pending data when a PTY is marked hidden', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('queued before hidden') + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + setHidden(null, { id: spawnResult.id, hidden: true }) + + // The queued bytes are model-owned; only the restore marker goes out. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('re-emits the restore marker on unhide and resumes delivery', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a renderer reload can replace the view that latched + // restore-needed; unhide repeats the marker so the live view heals. + setHidden(null, { id: spawnResult.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + + mockProc.emitData('visible again') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible again' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not emit an unhide marker when nothing was dropped', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('clears gate state on PTY exit', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + + setHidden(null, { id: spawnResult.id, hidden: true }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1 + }) + + mockProc.emitExit(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a hidden remount so reveal still restores', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a hidden remount (tab move, parking handoff) re-marks the PTY + // without an unhide in between. The fresh view never saw the first + // marker, so re-marking must NOT erase the drop memory. + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a renderer reload while clearing hidden/interest state', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + // Why daemon provider: it survives renderer reloads (the scenario + // under test) and keeps the LocalPtyProvider orphan-kill handler off + // this webContents, so 'did-finish-load' maps to the gate reset only. + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'dropped while hidden') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Renderer reload: hidden marks die with the old renderer, but the + // dropped bytes were never restored — memory must survive. + reloadHandlers[0]() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0 + }) + + // The reloaded pane's first sync re-marks hidden, then reveals. + setHidden(null, { id: result.id, hidden: true }) + setHidden(null, { id: result.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'unhide', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears leaked delivery interest on renderer reload so the gate re-engages', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + // A sidecar holds interest, so hidden bytes still flow. + setInterest(null, { id: result.id, interested: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'sidecar bytes') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith( + 'pty:data', + expect.objectContaining({ id: result.id, data: 'sidecar bytes' }) + ) + + // Why: the renderer reload killed the sidecar's ref count without a + // release IPC — the leaked hold must not force-feed the PTY forever. + reloadHandlers[0]() + mainWindow.webContents.send.mockClear() + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'gated after reload') + vi.advanceTimersByTime(50) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + }) + + it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + // 1 MB of old output followed by 2 MB of fresh output in one starved + // pending entry: the cap must drop the OLDEST 1 MB and keep the tail. + mockProc.emitData('x'.repeat(1024 * 1024) + 'y'.repeat(2 * 1024 * 1024)) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'pending-cap' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingChars: 2 * 1024 * 1024, + pendingDroppedChars: 1024 * 1024 + }) + + // A second overflow before the entry drains must not re-mark. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingChars: 2 * 1024 * 1024, + pendingDroppedChars: 1024 * 1024 + 64 * 1024 + }) + + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: 'y'.repeat(16 * 1024) + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])('keeps pending delivery unbounded when the %s kill switch is off', async (_name, settings) => { + // Why: with the gate off, renderer byte parsers need byte-identical + // delivery — the cap's oldest-drop would silently lose bytes they parse. + // The unbounded-growth hazard only exists alongside the gate rollout. + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + mockProc.emitData('x'.repeat(3 * 1024 * 1024)) + + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingChars: 3 * 1024 * 1024, + pendingDroppedChars: 0 + }) + + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: 'x'.repeat(16 * 1024) + }) + } finally { + vi.useRealTimers() + } + }) + it('batches stale PTY output after the interactive window expires', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -5422,16 +5978,23 @@ describe('registerPtyHandlers', () => { spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never, runtime as never) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // Why both: a reload fires the hidden-delivery gate reset AND the orphan + // cleanup; invoke every registered listener like a real did-finish-load. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => { + for (const handler of didFinishLoadHandlers) { + handler() + } + } await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) // The first load after spawn only advances generation. The second one sees // this PTY as belonging to a prior page load and kills it as orphaned. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( killSpy.mock.invocationCallOrder[0] @@ -5460,10 +6023,12 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(firstWindow as never) - const didFinishLoad = firstWindow.webContents.on.mock.calls.find( + // Two listeners on the first (LocalPtyProvider) window: the renderer-gate + // reset and the orphan cleanup. + const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + ) + expect(firstWindowLoadHandlers).toHaveLength(2) setLocalPtyProvider({ spawn: vi.fn(), @@ -5478,13 +6043,20 @@ describe('registerPtyHandlers', () => { } as never) registerPtyHandlers(secondWindow as never) - expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( - 'did-finish-load', - didFinishLoad - ) + // Every first-window load listener was detached from its webContents. + for (const [, handler] of firstWindowLoadHandlers) { + expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( + 'did-finish-load', + handler + ) + } + // The non-Local provider keeps orphan cleanup off the second window — + // only the renderer-gate reset listener remains. expect( - secondWindow.webContents.on.mock.calls.some(([eventName]) => eventName === 'did-finish-load') - ).toBe(false) + secondWindow.webContents.on.mock.calls.filter( + ([eventName]) => eventName === 'did-finish-load' + ) + ).toHaveLength(1) }) it('clears PTY state even when kill reports the process is already gone', async () => { @@ -5760,14 +6332,63 @@ describe('registerPtyHandlers', () => { expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 50_000 }) + // Why pendingDeliveryStartSeq === seq: the pending renderer-delivery + // queue is empty, so the renderer's post-restore duplicate window is + // empty too — low-seq live chunks (fresh seq domain) must not be + // dropped against the snapshot baseline. expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, cwd: '/projects/restored', seq: 42, + pendingDeliveryStartSeq: 42, source: 'headless' }) }) + + it('reports where the undelivered pending backlog starts alongside the snapshot', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => null), + getPtyOutputSequence: vi.fn(() => 2_472), + serializeMainTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot\r\n', + cols: 100, + rows: 30, + seq: 2_472, + source: 'headless' + }) + } + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + + // Starved pending entry: bytes ingested up to seq 2_472 but not yet + // flushed to the renderer — they can still arrive after the snapshot. + mockProc.emitData('frame-bytes') + + const result = (await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: spawnResult.id + })) as { pendingDeliveryStartSeq?: number } + + expect(result.pendingDeliveryStartSeq).toBe(2_472 - 'frame-bytes'.length) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4db944cb22b..2392fdc34cc 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -61,6 +61,19 @@ import { import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + isHiddenPtyDeliveryGateEnabled, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetHiddenRendererPtyDeliveryDebugCounters, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' +import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' @@ -841,6 +854,10 @@ export function clearProviderPtyState(id: string): void { lastInputAtByPty.delete(id) interactiveOutputCharsByPty.delete(id) activeRendererPtys.delete(id) + // Why: every PTY teardown path funnels through here (local exit, daemon + // shutdown, SSH exit/connection teardown) — hidden/interest gate bits must + // not outlive the PTY or a reused map entry could silently gate a new one. + clearHiddenRendererPtyDeliveryState(id) const paneKey = ptyPaneKey.get(id) const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep @@ -918,6 +935,14 @@ let localDataUnsub: (() => void) | null = null let localExitUnsub: (() => void) | null = null let didFinishLoadHandler: (() => void) | null = null let didFinishLoadWebContents: WebContents | null = null +// Why: the hidden-delivery gate's interest/hidden registries mirror renderer +// state (ref-counted holds, per-pane hidden marks). A reload or renderer +// crash destroys the owners without unregistering, so the registries are +// reset whenever the renderer process is replaced +// (resetRendererScopedHiddenPtyDeliveryState preserves drop memory). +let rendererGateResetLoadHandler: (() => void) | null = null +let rendererGateResetGoneHandler: (() => void) | null = null +let rendererGateResetWebContents: WebContents | null = null // Why: the "Restart daemon" path needs to re-bind provider→renderer listeners // against the freshly-created adapter after replaceDaemonProvider swaps the @@ -945,6 +970,11 @@ export type PtyRendererDeliveryDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number } const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { @@ -960,7 +990,12 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh peakMaxPendingCharsByPty: 0, peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0 + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0 } let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ @@ -984,6 +1019,23 @@ function clearDidFinishLoadHandler(): void { didFinishLoadWebContents = null } +function clearRendererGateResetHandlers(): void { + if (rendererGateResetWebContents) { + if (rendererGateResetLoadHandler) { + rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) + } + if (rendererGateResetGoneHandler) { + rendererGateResetWebContents.removeListener( + 'render-process-gone', + rendererGateResetGoneHandler + ) + } + } + rendererGateResetLoadHandler = null + rendererGateResetGoneHandler = null + rendererGateResetWebContents = null +} + // Why: the "Restart daemon" flow needs to detach listeners from the current // adapter *after* synthetic pty:exit events fan out (so the renderer receives // them) but *before* replaceDaemonProvider swaps in the new adapter (so the @@ -1114,10 +1166,14 @@ export function registerPtyHandlers( } const pendingData = new Map() + // Why: one restore marker per overflow episode — cleared when the entry + // fully drains so a later overflow re-marks the renderer exactly once. + const pendingOverflowMarkedPtys = new Set() const rendererInFlightCharsByPty = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null let rendererInFlightTotalChars = 0 + let pendingDroppedChars = 0 const PTY_BATCH_INTERVAL_MS = 8 const PTY_BATCH_DRAIN_CONTINUE_MS = 1 const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 @@ -1130,6 +1186,11 @@ export function registerPtyHandlers( // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 + // Why: Phase-0 finding — pendingData string concat is unbounded under ACK + // starvation. Cap per PTY with oldest-drop (mirroring the remote subscribe + // buffer trim); a restore marker tells the renderer to recover the dropped + // middle from the model snapshot. + const PTY_PENDING_DELIVERY_MAX_CHARS = 2 * 1024 * 1024 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -1158,6 +1219,7 @@ export function registerPtyHandlers( pendingChars += chars maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) } + const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() return { pendingPtyCount: pendingData.size, pendingChars, @@ -1171,7 +1233,9 @@ export function registerPtyHandlers( peakMaxPendingCharsByPty, peakRendererInFlightChars, peakMaxRendererInFlightCharsByPty, - ackGatedFlushSkipCount + ackGatedFlushSkipCount, + ...hiddenDeliveryDebug, + pendingDroppedChars } } @@ -1193,6 +1257,8 @@ export function registerPtyHandlers( peakRendererInFlightChars = 0 peakMaxRendererInFlightCharsByPty = 0 ackGatedFlushSkipCount = 0 + pendingDroppedChars = 0 + resetHiddenRendererPtyDeliveryDebugCounters() recordPtyRendererDeliveryPressure() } @@ -1272,6 +1338,26 @@ export function registerPtyHandlers( mainWindow.webContents.send('pty:data', payload) } + // Why: when main drops renderer delivery (hidden gate / pending cap), an + // explicit out-of-band pty:modelRestoreNeeded signal tells the renderer to + // latch model-restore-needed. It must NOT ride pty:data: an in-band empty + // chunk is indistinguishable from a chunk fully consumed by renderer-side + // OSC-9999 stripping, which spuriously restored visible panes. + function sendModelRestoreNeededMarker( + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined + ): void { + if (mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } + function getPendingPtyFlushEntries(): [string, PendingPtyData][] { const entries = Array.from(pendingData.entries()) const active: [string, PendingPtyData][] = [] @@ -1314,16 +1400,29 @@ export function registerPtyHandlers( flushTimer = null if (mainWindow.isDestroyed()) { pendingData.clear() + pendingOverflowMarkedPtys.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 recordPtyRendererDeliveryPressure() return } + const settings = getSettings?.() let writes = 0 for (const [id, pending] of getPendingPtyFlushEntries()) { if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) { break } + // Why: hidden-gated bytes are dropped, never re-queued — the model + // already ingested them; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + pendingData.delete(id) + pendingOverflowMarkedPtys.delete(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) + } + continue + } if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { continue } @@ -1337,6 +1436,8 @@ export function registerPtyHandlers( nextPending.startSeq = pending.startSeq + chunk.length } pendingData.set(id, nextPending) + } else { + pendingOverflowMarkedPtys.delete(id) } sendPtyDataToRenderer(id, makePtyDataPayload(id, chunk, pending.startSeq)) writes++ @@ -1390,13 +1491,50 @@ export function registerPtyHandlers( flushTimer = null } pendingData.clear() + pendingOverflowMarkedPtys.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 recordPtyRendererDeliveryPressure() return } + const settings = getSettings?.() + // Why: hidden-delivery gate — runtime ingestion above already consumed + // the chunk; gated renderer delivery is DROPPED (never queued) and the + // reveal path restores from the model snapshot via the seq guard. The + // drop sits before the interactive bypass so gated PTYs take neither + // the immediate nor the batched renderer path. + if (shouldDropHiddenRendererPtyData(payload.id, settings)) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) + } + return + } const existing = pendingData.get(payload.id) - const pending = appendPendingPtyData(existing, payload.data, startSeq) + let pending = appendPendingPtyData(existing, payload.data, startSeq) + // Why the cap shares the gate kill switches: trimming is only loss-free + // because the renderer recovers the dropped middle through the gate's + // model-restore machinery. With the switches off, renderer byte parsers + // need every byte (byte-identical delivery), and the unbounded-growth + // hazard the cap addresses only exists alongside the gate rollout. + if ( + isHiddenPtyDeliveryGateEnabled(settings) && + pending.data.length > PTY_PENDING_DELIVERY_MAX_CHARS + ) { + // Why: oldest-drop under ACK starvation — keep the freshest tail and + // tell the renderer once to restore the dropped middle from the model. + const excess = pending.data.length - PTY_PENDING_DELIVERY_MAX_CHARS + pendingDroppedChars += excess + const trimmed: PendingPtyData = { data: pending.data.slice(excess) } + if (typeof pending.startSeq === 'number') { + trimmed.startSeq = pending.startSeq + excess + } + pending = trimmed + if (!pendingOverflowMarkedPtys.has(payload.id)) { + pendingOverflowMarkedPtys.add(payload.id) + sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) + } + } const nextData = pending.data const isInteractiveOutput = shouldSendInteractiveOutputNow( payload.id, @@ -1413,6 +1551,7 @@ export function registerPtyHandlers( return } pendingData.delete(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) clearFlushTimerIfIdle() // Why: agent TUIs redraw small prompt regions after every keystroke. // Waiting for the throughput batch timer adds visible input latency. @@ -1450,6 +1589,7 @@ export function registerPtyHandlers( ) pendingData.delete(payload.id) } + pendingOverflowMarkedPtys.delete(payload.id) lastInputAtByPty.delete(payload.id) interactiveOutputCharsByPty.delete(payload.id) rendererInFlightTotalChars = Math.max( @@ -1552,6 +1692,19 @@ export function registerPtyHandlers( }) } + // Why: a reload (did-finish-load) or renderer crash replaces the process + // that owned every delivery-interest hold and hidden mark; surviving + // daemon/SSH PTYs would otherwise stay force-fed (leaked interest defeats + // the gate) or stay gated against a renderer that never marked them. Drop + // memory is preserved — each pane's first sync re-marks/unmarks and the + // unmark path re-emits the restore marker for unrestored drops. + clearRendererGateResetHandlers() + rendererGateResetLoadHandler = () => resetRendererScopedHiddenPtyDeliveryState() + rendererGateResetGoneHandler = () => resetRendererScopedHiddenPtyDeliveryState() + rendererGateResetWebContents = mainWindow.webContents + mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler) + mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler) + // Kill orphaned PTY processes from previous page loads when the renderer reloads. // Why: only applies to LocalPtyProvider where PTYs live in the Electron main // process and can become orphaned on page reload. Daemon-backed sessions @@ -1976,6 +2129,7 @@ export function registerPtyHandlers( cwd?: string | null lastTitle?: string seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { @@ -1983,7 +2137,24 @@ export function registerPtyHandlers( } const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) try { - return await runtime.serializeMainTerminalBuffer(args.id, { scrollbackRows }) + const snapshot = await runtime.serializeMainTerminalBuffer(args.id, { scrollbackRows }) + if (!snapshot || typeof snapshot.seq !== 'number') { + return snapshot + } + // Why: sampled after serialize — every byte at or below snapshot.seq + // that can still reach the renderer sits in this pending queue. The + // renderer's post-restore dedupe bounds its duplicate window with it; + // without the bound a stale baseline silently swallows genuinely-new + // chunks whose seq domain sits below the snapshot counter. + const pending = pendingData.get(args.id) + if (pending && typeof pending.startSeq !== 'number') { + // Why: a seq-less backlog cannot be bounded — stay conservative. + return snapshot + } + return { + ...snapshot, + pendingDeliveryStartSeq: Math.min(pending?.startSeq ?? snapshot.seq, snapshot.seq) + } } catch { return null } @@ -2734,6 +2905,55 @@ export function registerPtyHandlers( } }) + ipcMain.removeAllListeners('pty:setHiddenRendererPty') + ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + if (args.hidden === true) { + markHiddenRendererPty(args.id) + // Why: bytes already queued for a newly hidden PTY are model-owned + // state; drop them now instead of holding them under ACK starvation. + // Reveal restores from the snapshot. + const pending = pendingData.get(args.id) + if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) { + pendingData.delete(args.id) + pendingOverflowMarkedPtys.delete(args.id) + const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + args.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(args.id) + ) + } + recordPtyRendererDeliveryPressure() + } + return + } + const { droppedWhileHidden } = unmarkHiddenRendererPty(args.id) + // Why: a renderer reload or remount can replace the view that latched + // restore-needed from the first-drop marker. Re-emit on unhide so the + // (possibly fresh) visible view still pulls the model snapshot covering + // the dropped bytes. If the original view is still alive this can trigger + // a redundant second restore — accepted: a snapshot replay is cheap and + // idempotent, while a missed restore leaves a corrupt pane. + if (droppedWhileHidden) { + sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) + } + }) + + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') + ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: explicit delivery-interest signal from renderer sidecars / eager + // pre-mount buffers — any interest suppresses the hidden-delivery gate so + // raw-byte consumers keep receiving while the view is hidden or parked. + setRendererPtyDeliveryInterest(args.id, args.interested === true) + }) + ipcMain.removeAllListeners('pty:signal') ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { tryGetProviderForPty(args.id) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9a740fc4e8a..3783290b25b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3813,6 +3813,19 @@ describe('OrcaRuntimeService', () => { ]) }) + it('emits 2031-subscribe facts across chunk splits', () => { + // Why: hidden-delivery-gated views never receive the bytes — this fact + // is their only signal to send the DECSET 2031 color-scheme reply. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b[?20', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '31h', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) + }) + it('prefers the tracked title over the renderer snapshot lastTitle', async () => { const { runtime } = createSideEffectRuntime() const serializeBuffer = vi.fn().mockResolvedValue({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9afde60f485..0e438c96899 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3698,9 +3698,9 @@ export class OrcaRuntimeService { onAgentExited: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) }, - // Why: bell/command-finished/pr-link facts exist only for the + // Why: bell/command-finished/pr-link/2031 facts exist only for the // pty:sideEffect channel. Headless serve has no consumer, so skip the - // per-chunk bell walk and 133/URL scans entirely. + // per-chunk bell walk and 133/URL/2031 scans entirely. ...(this.onTerminalSideEffects ? { onBell: () => { @@ -3711,6 +3711,12 @@ export class OrcaRuntimeService { }, onPrLink: (link: TerminalGitHubPRLink) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see the bytes, so main + // surfaces DECSET 2031 subscribes as facts; the theme reply is + // still sent by the renderer (query authority stays with the view). + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) } } : {}) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 66c8650221e..94ff2ebbacf 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -88,6 +88,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') +// Why: the hidden-delivery gate module is intentionally real (pure state, no +// electron deps) so the SSH parity tests exercise the same gate main uses. +const { markHiddenRendererPty, setRendererPtyDeliveryInterest } = + await import('../ipc/pty-hidden-delivery-gate') +const { _resetHiddenRendererPtyDeliveryGateForTest } = + await import('../ipc/pty-hidden-delivery-gate') const { execCommand } = await import('./ssh-relay-deploy-helpers') const { getRemoteHostPlatform } = await import('./ssh-remote-platform') const { @@ -144,6 +150,95 @@ describe('SshRelaySession', () => { installRemoteManagedAgentHooksMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { + onPtyData: vi.fn(() => 99), + onPtyExit: vi.fn() + } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'hidden ssh output' }) + + // Runtime ingestion still ran; renderer delivery shrank to one marker. + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'ssh-pty-1', + 'hidden ssh output', + expect.any(Number) + ) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data sentinel is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'ssh-pty-1', + reason: 'hidden-drop', + markerSeq: 99 + }) + + onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Delivery interest (renderer sidecars) suppresses the gate — parity with + // the local path in ipc/pty.ts. + setRendererPtyDeliveryInterest('ssh-pty-1', true) + onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'sidecar ssh bytes', + seq: 99, + rawLength: 'sidecar ssh bytes'.length + }) + + // Non-hidden PTYs are unaffected. + onData({ id: 'ssh-pty-2', data: 'visible ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-2', + data: 'visible ssh output', + seq: 99, + rawLength: 'visible ssh output'.length + }) + }) + + it('keeps hidden SSH delivery when the gate kill switch is off', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + ;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({ + terminalHiddenDeliveryGate: false + })) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'still delivered' }) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'still delivered' + }) }) it('starts in idle state', () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index deeb8270067..d4c1f802af4 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -39,6 +39,11 @@ import { deletePtyOwnership, setPtyOwnership } from '../ipc/pty' +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, @@ -914,12 +919,30 @@ export class SshRelaySession { ptyProvider.onData((payload) => { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:data', { - ...payload, - ...(typeof seq === 'number' ? { seq, rawLength: payload.data.length } : {}) - }) + if (!win || win.isDestroyed()) { + return } + // Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion + // above already consumed the chunk; gated renderer delivery is dropped + // and one out-of-band pty:modelRestoreNeeded signal latches + // model-restore-needed for reveal. Never an in-band pty:data sentinel: + // OSC-9999-only chunks legitimately strip to empty in the renderer. + const store = this.store as { getSettings?: Store['getSettings'] } + if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + win.webContents.send('pty:modelRestoreNeeded', { + id: payload.id, + reason: 'hidden-drop', + ...(typeof seq === 'number' ? { markerSeq: seq } : {}) + } satisfies PtyModelRestoreNeededEvent) + } + return + } + win.webContents.send('pty:data', { + ...payload, + ...(typeof seq === 'number' ? { seq, rawLength: payload.data.length } : {}) + }) }) ptyProvider.onReplay((payload) => { const win = this.getMainWindow() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fef21cbfaef..d1b21503efa 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -145,6 +145,7 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -936,6 +937,12 @@ export type PreloadApi = { ackColdRestore: (id: string) => void ackData: (id: string, charCount: number) => void setActiveRendererPty: (id: string, active: boolean) => void + /** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer + * byte delivery after model ingestion; reveal restores from snapshots. */ + setHiddenRendererPty: (id: string, hidden: boolean) => void + /** Ref-counted-on-the-renderer delivery-interest signal that suppresses + * the hidden-delivery gate while any raw-byte consumer is registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise @@ -949,6 +956,10 @@ export type PreloadApi = { rows: number cwd?: string | null seq?: number + /** Start of main's pending renderer-delivery queue at snapshot time + * (equals `seq` when empty) — bounds the renderer's post-restore + * duplicate window. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ @@ -965,12 +976,21 @@ export type PreloadApi = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number }> resetRendererDeliveryDebug: () => Promise onData: ( callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void + /** Out-of-band main→renderer signal that renderer-bound bytes were + * dropped (hidden-delivery gate / pending cap); the pane restores from + * the model snapshot. Never delivered in-band on pty:data. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void /** Batched derived side-effect facts for PTYs whose bytes transit local * main; see docs/reference/terminal-side-effect-authority.md. */ onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index cc170aeac25..79d59435315 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,6 +44,7 @@ import type { WorktreeDefaultTabsLaunch, WorktreeRemoteBranchConflictEvent } from '../shared/types' +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' @@ -704,6 +705,18 @@ const api = { setActiveRendererPty: (id: string, active: boolean): void => { ipcRenderer.send('pty:setActiveRendererPty', { id, active }) }, + /** Hidden-delivery gate (Phase 4): hidden=true lets main DROP renderer + * byte delivery after model ingestion; reveal restores from the model + * snapshot. Fire-and-forget like setActiveRendererPty. */ + setHiddenRendererPty: (id: string, hidden: boolean): void => { + ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) + }, + /** Delivery-interest signal: any renderer party that needs raw bytes + * (dispatcher sidecars, eager pre-mount buffers) suppresses the + * hidden-delivery gate for that PTY while registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean): void => { + ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) + }, kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), @@ -720,6 +733,7 @@ const api = { rows: number cwd?: string | null seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), @@ -737,6 +751,11 @@ const api = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), resetRendererDeliveryDebug: (): Promise => @@ -773,6 +792,17 @@ const api = { return () => ipcRenderer.removeListener('pty:replay', listener) }, + /** Out-of-band signal that main dropped renderer-bound bytes for a PTY + * (hidden-delivery gate / pending cap) — the pane must restore from the + * model snapshot. Deliberately NOT on pty:data: an in-band marker is + * ambiguous with chunks fully stripped by OSC-9999 cleaning. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => + callback(event) + ipcRenderer.on('pty:modelRestoreNeeded', listener) + return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) + }, + /** Batched derived side-effect facts (title/bell/agent transitions) for * PTYs whose bytes transit local main. Per-PTY in-order; deliberately not * synchronized with pty:data (terminal-side-effect-authority.md). */ diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index b028008c68f..bd492d5194c 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -23,6 +23,7 @@ type MockStoreState = { promptCacheTimerEnabled?: boolean experimentalTerminalAttention?: boolean terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean notifications?: { enabled?: boolean; agentTaskComplete?: boolean } } | null setRuntimePaneTitle: ReturnType @@ -673,11 +674,18 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) - it('keeps the byte sidecar only for the DECSET 2031 reply — no PR byte scan', async () => { + it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => { + // Why: with the hidden-delivery gate on (default), parked PTY bytes are + // dropped in main — the fact is the only 2031 signal, and the byte + // sidecar must NOT exist (its registration would re-enable delivery). enableMainAuthority() const { dispose, sendInput } = await startWatcher() emit('\x1b[?2031h') + expect(sendInput).not.toHaveBeenCalled() + + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') // Why: pr-link facts arrive on the channel; byte-scanning here too @@ -687,16 +695,47 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) - it('answers a DECSET 2031 subscribe split across chunks via the responder sidecar', async () => { + it('marks the PTY hidden for delivery on start and clears it on dispose', async () => { enableMainAuthority() + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { dispose } = await startWatcher() + + expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true) + + dispose() + // Why: the unhide must land before reveal re-registers pane handlers — + // the watcher registry disposes watchers before the remount effect runs. + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + }) + + it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as MockStoreState['settings'] + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty const { dispose, sendInput } = await startWatcher() + // Gate off — bytes keep flowing, so the split-chunk byte scan answers. emit('\x1b[?20') expect(sendInput).not.toHaveBeenCalled() emit('31h') - expect(sendInput).toHaveBeenCalledTimes(1) expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a 2031-subscribe fact must not double-fire the reply in byte + // mode — exactly one responder owns the answer at any time. + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) + + expect(setHiddenRendererPty).not.toHaveBeenCalled() dispose() }) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index b375831becb..90f55f7902b 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -7,15 +7,22 @@ * first parking attempt.) Under main side-effect authority the watcher is * purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the * kill switch off it registers the legacy byte parsers on the dispatcher - * sidecar channel. The DECSET 2031 reply lives in its own byte sidecar - * (parked-terminal-mode2031-responder.ts) in BOTH modes — query authority - * never moves to main. See docs/reference/terminal-hidden-view-parking.md and + * sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate: + * gate ON answers from main's '2031-subscribe' fact (no parked bytes exist), + * gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts). + * Either way the reply is sent from the renderer — query authority never + * moves to main. See docs/reference/terminal-hidden-view-parking.md and * docs/reference/terminal-side-effect-authority.md. */ import { isClaudeAgent } from '../../../../shared/agent-detection' import { makePaneKey } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode +} from '../../../../shared/terminal-color-scheme-protocol' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { getSystemPrefersDark } from '@/lib/terminal-theme' import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, isAgentTaskCompleteOsNotificationEnabledFromState, @@ -24,6 +31,7 @@ import { import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder' import { subscribeToPtyData } from './pty-dispatcher' import { createPtyOutputProcessor } from './pty-transport' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' import { isMainTerminalSideEffectAuthorityForPty, registerTerminalSideEffectFactConsumer @@ -219,6 +227,18 @@ export function startParkedTerminalByteWatcher( settings: useAppStore.getState().settings, runtimeEnvironmentId: null }) + // Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all — + // facts carry side effects and the reveal remount restores from the model + // snapshot. Decided once at watcher start: it picks which 2031 responder + // (byte sidecar vs fact reply) exists, so it must never flip per chunk. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && + isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings) + + const sendMode2031Reply = (): void => { + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + } // Why (byte-parser mode only): reuse the transport's output processor so // the parked path keeps the exact live-path parsing semantics — all-titles @@ -246,16 +266,32 @@ export function startParkedTerminalByteWatcher( callbacks: { ...sideEffectCallbacks, onPrLink: (link) => - useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), + // Why (gate mode only): bytes never arrive while gated, so the 2031 + // subscribe arrives as a main-tracker fact instead of a byte scan. + // The reply is still sent from here — query authority stays with + // the view/watcher (model/view contract invariant 6). + ...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {}) } }) : null // Why: no xterm exists while parked, so nothing answers a DECSET 2031 - // subscription. The responder is the parked path's only byte consumer under - // main authority — query authority belongs to the view/watcher (model/view - // contract invariant 6), so it can never move to main. - const stopMode2031Responder = startParkedTerminalMode2031Responder({ ptyId, sendInput }) + // subscription. With the hidden-delivery gate OFF the byte responder is the + // parked path's only byte consumer under main authority. With the gate ON it + // must NOT register: its subscribeToPtyData sidecar doubles as a + // delivery-interest signal that would force-feed bytes to the gated PTY — + // the fact callback above replaces the byte scan. + const stopMode2031Responder = hiddenDeliveryGateActive + ? null + : startParkedTerminalMode2031Responder({ ptyId, sendInput }) + + // Why: parked tabs are the canonical hidden view — mark the PTY gated so + // main stops renderer byte delivery; dispose clears the bit before the + // reveal remount re-registers pane handlers (existing dispose ordering). + if (hiddenDeliveryGateActive) { + ;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, true) + } // Why (byte-parser mode only): with main authority the watcher consumes // pty:sideEffect facts exclusively and registers NO byte parsers here — @@ -279,7 +315,13 @@ export function startParkedTerminalByteWatcher( return } disposed = true - stopMode2031Responder() + // Why: unhide BEFORE the reveal remount registers pane handlers — main + // resumes delivery and (if bytes were dropped) emits the restore marker + // the remounted pane's restore machinery consumes. + if (hiddenDeliveryGateActive) { + ;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, false) + } + stopMode2031Responder?.() unsubscribeByteParsers?.() unregisterFactConsumer?.() // Why: cancels the deferred side-effect drain, stale-title timer, and diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts index 7ae97932379..8305c5c1edd 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -1,13 +1,13 @@ /** - * DECSET 2031 color-scheme responder for parked terminals. + * DECSET 2031 color-scheme responder for parked terminals (byte-scan mode). * * Why a dedicated byte sidecar: no xterm exists while a tab is parked, so * nothing answers a TUI's mode-2031 theme subscription. Query authority stays * with the view/watcher (model/view contract invariant 6), so this reply can - * never move to main — it is the parked path's ONLY byte consumer when main - * holds side-effect authority. Phase 4: this subscribeToPtyData registration - * doubles as the delivery-interest signal that keeps hidden byte delivery - * alive for parked PTYs. + * never move to main. Phase 4: this subscribeToPtyData registration doubles + * as a delivery-interest signal, so it is only used while the hidden-delivery + * gate is OFF — gated parked PTYs answer from the main tracker's + * '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts). */ import { mode2031SequenceFor, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index d532ae9f0f4..118d23f824b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -53,4 +53,10 @@ export type PtyConnectionDeps = { }) => void setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void + /** Records a DECSET 2031 subscription answered from main's + * '2031-subscribe' fact, mirroring the xterm CSI handler's registry write + * (paneMode2031 + last replied theme) so later theme flips push CSI 997. + * The reply itself is sent by the fact handler — query authority stays + * with the view (model/view contract invariant 6). */ + recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void } 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 ecb662a8d23..9601407d02e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -59,6 +59,7 @@ type StoreState = { activeRuntimeEnvironmentId?: string | null experimentalTerminalAttention?: boolean terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -494,6 +495,8 @@ describe('connectPanePty', () => { getMainBufferSnapshot: vi.fn().mockResolvedValue(null), getForegroundProcess: vi.fn().mockResolvedValue(null), hasChildProcesses: vi.fn().mockResolvedValue(false), + setHiddenRendererPty: vi.fn(), + setPtyDeliveryInterest: vi.fn(), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), onSerializeBufferRequest: vi.fn(() => vi.fn()), @@ -3080,6 +3083,567 @@ describe('connectPanePty', () => { } }) + describe('hidden-delivery gate', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + } + + function getSetHiddenRendererPtyMock(): ReturnType { + return window.api.pty.setHiddenRendererPty as unknown as ReturnType + } + + async function connectHiddenPane(deps: ReturnType): Promise<{ + transport: MockTransport + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + binding: { syncProcessTracking: () => void; dispose: () => void } + }> { + 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 pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + return { transport, pane, dataCallback: capturedDataCallback.current!, binding } + } + + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'model snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // Reveal rides the visible-resume backlog recovery hook. + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + // The unhide IPC must precede the snapshot request (seq-guard contract). + const unhideOrder = setHiddenRendererPty.mock.invocationCallOrder.at(-1)! + const snapshotOrder = getMainBufferSnapshot.mock.invocationCallOrder[0]! + expect(unhideOrder).toBeLessThan(snapshotOrder) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('model snapshot'), + expect.any(Function) + ) + }) + + it('clears the hidden bit on visibility flips through syncProcessTracking', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + + // Hiding again re-marks through the same lifecycle hook. + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + }) + + it('never marks hidden while the codex startup renderer-query window is active', async () => { + enableMainAuthority() + // Why: only fake the clock — the default fake set would also replace + // the suite's synchronous requestAnimationFrame mock and the deferred + // connect frame would never run. + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + dataCallback('startup probe output\r\n') + expect(setHiddenRendererPty).not.toHaveBeenCalledWith('pty-id', true) + + // Why: the fact is the sole 2031 responder for gate-managed PTYs — + // even during the startup window (the xterm-side CSI reply is + // suppressed by the lifecycle for these panes), so a fact racing the + // hidden mark can never produce zero or two replies. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 8, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: after the 10s window lapses, hidden output gates normally. + vi.advanceTimersByTime(10_001) + dataCallback('post window output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + + // Gated now — a new subscribe fact still gets exactly one reply. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 16, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n') + } finally { + vi.useRealTimers() + } + }) + + it('latches model restore from the out-of-band marker and restores on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'dropped bytes snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + // Why: the marker subscription is keyed by the live PTY id — the byte + // path latches it on the first hidden chunk, like the hidden mark. + dataCallback('pre-drop output\r\n', { seq: 16, rawLength: 17 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + // Main dropped gated bytes and signalled it out-of-band. + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('dropped bytes snapshot'), + expect.any(Function) + ) + }) + + it('answers each 2031-subscribe fact exactly once, before any hidden mark exists', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Simulate the transport's spawn completion so the pane registers its + // side-effect fact consumer (the mock transport never calls onPtySpawn). + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: no pty:data has flowed, so no hidden mark was sent — the fact + // can outrun the mark (codex post-startup-window race) and must still + // reply: ownership is structural, never mark-dependent. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a visible gated pane still answers via the fact — the lifecycle + // suppresses the xterm CSI reply for gate-managed panes, so this stays + // the only reply for the new subscribe. + ;(deps.isVisibleRef as { current: boolean }).current = true + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n') + }) + + it('registers the fact-answered 2031 subscription for later theme flips', async () => { + enableMainAuthority() + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const { transport } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + // Why: without the registry write, applyTerminalAppearance's + // maybePushMode2031Flip never pushes CSI 997 after a theme change and + // the revealed TUI keeps a stale theme. + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') + }) + + it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { binding } = await connectHiddenPane(deps) + const bindingWithPredicate = binding as typeof binding & { + isHiddenDeliveryGateManagedPty: () => boolean + } + expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) + }) + + it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport, dataCallback, binding } = await connectHiddenPane(deps) + // Why: the lifecycle's xterm CSI observer consults this predicate — + // kill switch off must keep the legacy xterm reply path. + expect( + ( + binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean } + ).isHiddenDeliveryGateManagedPty() + ).toBe(false) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).not.toHaveBeenCalled() + + // Why: gate off keeps the byte-scan responder authoritative — the fact + // must not produce a second reply for the same subscribe. + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('clears a marked-hidden PTY on dispose so a remount is never gated', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + + binding.dispose() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + }) + + it('never treats a live chunk that strips to empty as a restore marker', async () => { + // Why: a chunk that is purely OSC 9999 reaches the data callback as '' + // (transport stripping) — only the out-of-band pty:modelRestoreNeeded + // channel may trigger a snapshot restore, or visible panes would be + // spuriously cleared and repainted mid-session. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + dataCallback('', { seq: 32, rawLength: 24 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + }) + + it('fetches a fresh snapshot when a marker lands while a restore is in flight', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'fresh snapshot\r\n', cols: 100, rows: 30, seq: 96 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 64 }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Second drop while the first snapshot is still being serialized — the + // in-flight snapshot may predate it, so a fresh one must follow. + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 80 }) + firstSnapshot.resolve({ data: 'stale snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + }) + + describe('post-restore backlog reconciliation', () => { + async function restoreVisiblePaneToBaseline(): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'restored snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('restored snapshot'), + expect.any(Function) + ) + pane.terminal.write.mockClear() + return { pane, dataCallback, getMainBufferSnapshot } + } + + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } + + it('drops backlog chunks the restored snapshot already covers', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // Whole chunk at or before the baseline seq: duplicate, never written. + dataCallback('OLD-DUPLICATE', { seq: 60, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('OLD-DUPLICATE') + + // Contiguous post-baseline chunk flows through normally. + dataCallback('NEW', { seq: 67, rawLength: 3 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('NEW') + }) + + it('slices a partial overlap when raw and clean lengths match', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // start seq 61 < baseline 64 < end seq 67 — only the last 3 chars are new. + dataCallback('ABCDEF', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(8) + + const written = writtenData(pane) + expect(written).toContain('DEF') + expect(written).not.toContain('ABC') + }) + + it('forces a fresh snapshot for an overlap whose offsets cannot be mapped', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'second snapshot\r\n', + cols: 100, + rows: 30, + seq: 80 + }) + + // rawLength (6) !== data.length (4): renderer-side OSC stripping makes + // the slice offset unmappable — restore from a fresh snapshot instead. + dataCallback('ABCD', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('ABCD') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('second snapshot') + }) + + it('detects a seq gap after restore and forces another restore', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'gap-heal snapshot\r\n', + cols: 100, + rows: 30, + seq: 120 + }) + + // Why: a chunk starting past the continuity point (start seq 87 > + // expected 64) means main trimmed bytes after the one-shot overflow + // marker was consumed — only the model snapshot can heal the gap. + dataCallback('AFTER-GAP', { seq: 96, rawLength: 9 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('AFTER-GAP') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('gap-heal snapshot') + }) + + it('writes genuinely-new live output whose seq sits below an empty-backlog baseline', async () => { + // E2E twin (terminal-hidden-tui-visual-restore "keeps newer live + // output correct"): main's snapshot seq is a cumulative PTY counter + // (shell init + prompt echo + hidden frame), while a synthetic live + // chunk meters only its own frames — far below the baseline. With an + // empty pending queue main can never re-deliver seqs at or below the + // snapshot, so the chunk must write, never silently drop. + enableMainAuthority() + const isVisibleRef = { current: true } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + // Visible prompt echo metered in main's cumulative seq domain. + dataCallback('$ node frame-script.mjs\r\n', { seq: 2_315, rawLength: 25 }) + // Pane hides mid-stream; main drops the hidden frame and marks restore. + isVisibleRef.current = false + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 2_472 + }) + // Reveal: the snapshot covers everything ingested; pending queue empty + // (pendingDeliveryStartSeq === seq). + getMainBufferSnapshot.mockResolvedValue({ + data: 'LOW_RISK_RESTORE_FRAME_40\r\n', + cols: 100, + rows: 30, + seq: 2_472, + pendingDeliveryStartSeq: 2_472 + }) + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_40') + pane.terminal.write.mockClear() + + // Newer live frame injected with a seq domain unrelated to main's + // counter (e2e __terminalPtyDataInjection twin). + dataCallback('LOW_RISK_RESTORE_FRAME_41\r\n', { seq: 315, rawLength: 27 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_41') + + // The retired baseline keeps subsequent low-seq live chunks flowing. + dataCallback('progress=041\r\n', { seq: 329, rawLength: 14 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('progress=041') + }) + + it('keeps suppressing backlog duplicates inside the reported pending window', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'windowed snapshot\r\n', + cols: 100, + rows: 30, + seq: 96, + pendingDeliveryStartSeq: 80 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 96 + }) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('windowed snapshot') + pane.terminal.write.mockClear() + + // Inside the pending window (80, 96]: a draining backlog duplicate. + dataCallback('IN-WINDOW-DUP-16', { seq: 96, rawLength: 16 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('IN-WINDOW-DUP-16') + + // Past the baseline: genuinely-new live output still flows. + dataCallback('PAST-BASELINE', { seq: 109, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PAST-BASELINE') + + // Below the pending window (≤ 80): main can never re-send these seqs, + // so this is a foreign seq domain — written, never silently dropped. + dataCallback('BELOW-WINDOW', { seq: 60, rawLength: 12 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BELOW-WINDOW') + }) + }) + }) + it('skips split hidden synchronized output frames for model-backed restore', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5e078494959..10c52a8e2a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -74,6 +74,7 @@ import { pasteTerminalText } from './terminal-bracketed-paste' import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' +import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel' import type { PtyDataMeta } from './pty-dispatcher' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { installConptyDeviceAttributesHandler } from './terminal-conpty-device-attributes' @@ -100,6 +101,7 @@ import { isMainTerminalSideEffectAuthorityForPty, registerTerminalSideEffectFactConsumer } from './terminal-side-effect-facts-handler' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -351,6 +353,11 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void + /** True when the hidden-delivery gate structurally manages the pane's + * current PTY. The lifecycle's xterm CSI ?2031h observer consults this to + * stay silent — main's '2031-subscribe' fact is the sole responder for + * gate-managed PTYs. */ + isHiddenDeliveryGateManagedPty: () => boolean } function isAgentTaskCompleteNotificationEnabled(): boolean { @@ -638,6 +645,11 @@ export function connectPanePty( let synchronizedHiddenOutputActive = false let synchronizedHiddenOutputScanTail = '' let synchronizedHiddenOutputPtyId: string | null = null + // Why: hidden-delivery gate sync is wired up alongside the deferred PTY + // output plumbing inside the connect frame; lifecycle hooks (visibility + // flips, exit, dispose) run before/after it exists, so start with no-ops. + let syncHiddenRendererPtyDelivery: () => void = () => {} + let releaseHiddenRendererPtyDelivery: () => void = () => {} // 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. @@ -1008,7 +1020,12 @@ export function connectPanePty( // timer must consult the live store row (which hook events and // renderer seeds also write), so main only emits scrape facts. onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, - onCommandCodeDone: scheduleCommandCodeOutputDoneStatus + onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, + // Why: gated hidden panes never see the subscribe bytes; the fact + // replaces the byte scan (and the old post-latch subscribe drop). + ...(hiddenDeliveryGateActive + ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact } + : {}) }, restoreTitleOnRegister: true }) @@ -1050,6 +1067,9 @@ export function connectPanePty( const onExit = (ptyId: string): void => { agentCompletionCoordinator.dispose() dropSideEffectFactConsumer() + // Why: main clears gate state on PTY exit too; this only resets the + // pane-local marker so a reused pane cannot skip re-marking a new PTY. + releaseHiddenRendererPtyDelivery() clearPanePtyFitBinding() // Why: sleep and intentional pane-close/restart paths already record the // desired lifecycle state before kill. Do not erase wake hints here. @@ -1215,6 +1235,7 @@ export function connectPanePty( const onPtySpawn = (ptyId: string): void => { setPanePtyFitBinding(ptyId) registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) // Why: Command Code has no prompt-start hook. Seed the visible working row @@ -1514,6 +1535,19 @@ export function connectPanePty( settings: state.settings, runtimeEnvironmentId }) + // Why: Phase-4 hidden-delivery gate — only meaningful under main authority + // (renderer byte parsers need bytes otherwise). Decided once at pane + // creation: it picks the mode-2031 answer path (fact reply vs byte scan), + // which must have exactly one owner. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings) + // Why: structural per-PTY gate predicate (authority on + gate on + bytes + // transit local main, which implies snapshot-backed). Shared by the hidden + // mark sync and mode-2031 reply ownership so reply ownership can never + // disagree with what main may drop — and never depends on the racy hidden + // mark (a fact can outrun the pty:data task that sets it). + const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string => + hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) // Why (byte-parser mode only): with main authority the Command Code scrape // runs in main's per-PTY tracker and arrives as command-code facts; running // the byte detector too would double-drive the seed/settle policy above. @@ -1583,6 +1617,28 @@ export function connectPanePty( const transport = runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + // Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031 + // responder — visible, hidden, marked or not. Conditioning the reply on the + // hidden mark double-fired (mark set + bytes delivered live via interest → + // fact AND xterm both replied) or dropped the reply entirely (fact outran + // the pty:data task that set the mark). The xterm-side CSI reply and the + // skipped-byte scan are disabled for these panes (same structural + // predicate), so exactly one reply goes out. + const handleHiddenMode2031SubscribeFact = (): void => { + if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + return + } + const mode = resolveTerminalColorSchemeMode( + useAppStore.getState().settings, + getSystemPrefersDark() + ) + transport.sendInput(mode2031SequenceFor(mode)) + // Why: register the subscription exactly like the xterm CSI handler + // would — without the registry entry, later theme flips never push the + // CSI 997 update and the TUI keeps a stale theme after reveal. + deps.recordPaneMode2031Subscription?.(pane.id, mode) + recordHiddenMode2031Reply() + } const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0 deps.paneTransportsRef.current.set(pane.id, transport) const conptyDeviceAttributesDisposable = isNativeWindowsConpty @@ -2056,6 +2112,54 @@ export function connectPanePty( // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null let hiddenOutputRestoreGeneration = 0 + // Why: after a snapshot restore, main can still drain ACK-backlog chunks + // whose bytes the snapshot already covers — writing them unguarded + // duplicates visible output. Track the restored baseline seq (per PTY) + // and the expected next chunk start so dataCallback can drop/slice + // overlaps and detect seq gaps from main-side pending-cap trims whose + // one-shot marker was already consumed. + let restoredSnapshotBaselineSeq: number | null = null + let restoredSnapshotBaselinePtyId: string | null = null + let restoredSnapshotExpectedStartSeq: number | null = null + // Why: main samples its pending renderer-delivery queue with the snapshot. + // Chunks at or below this seq can never be backlog duplicates (delivery is + // once-and-in-order), so the dedupe window is (windowStart, baseline]. + let restoredSnapshotDeliveryWindowStartSeq: number | null = null + + function setRestoredSnapshotBaseline( + ptyId: string, + snapshot: { seq?: number; pendingDeliveryStartSeq?: number } + ): void { + if (typeof snapshot.seq !== 'number') { + clearRestoredSnapshotBaseline() + return + } + const windowStartSeq = + typeof snapshot.pendingDeliveryStartSeq === 'number' + ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) + : null + if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) { + // Why: main reported an empty undelivered backlog — no chunk at or + // below the snapshot seq can ever arrive again (delivery is once and + // in order) and a future pending-cap trim re-arms the out-of-band + // marker. Arming a baseline anyway would misread live chunks from a + // foreign seq domain (restarted counter / synthetic injection) as + // duplicates or trim gaps and silently drop genuinely-new output. + clearRestoredSnapshotBaseline() + return + } + restoredSnapshotBaselineSeq = snapshot.seq + restoredSnapshotBaselinePtyId = ptyId + restoredSnapshotExpectedStartSeq = snapshot.seq + restoredSnapshotDeliveryWindowStartSeq = windowStartSeq + } + + function clearRestoredSnapshotBaseline(): void { + restoredSnapshotBaselineSeq = null + restoredSnapshotBaselinePtyId = null + restoredSnapshotExpectedStartSeq = null + restoredSnapshotDeliveryWindowStartSeq = null + } let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 let hiddenMode2031ScanTail = '' @@ -2102,7 +2206,115 @@ export function connectPanePty( ) } + // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── + // Why: marks this pane's PTY hidden in main while no visible view needs + // its bytes; main then drops delivery after model ingestion and reveal + // restores from the snapshot. The marked id is tracked locally so PTY + // changes (reattach/restart) can never leave a stale id gated. + let hiddenDeliverySyncedPtyId: string | null = null + let hiddenDeliveryMarkedHidden = false + let modelRestoreSubscribedPtyId: string | null = null + let unregisterModelRestoreNeeded: (() => void) | null = null + + function sendHiddenRendererPtyDelivery(ptyId: string, hidden: boolean): void { + window.api.pty.setHiddenRendererPty?.(ptyId, hidden) + } + + // Why: main reports dropped renderer-bound bytes (hidden gate / pending + // cap) out-of-band — routed per PTY by pty-model-restore-channel.ts. + function handleModelRestoreNeededMarker(): void { + if (disposed) { + return + } + // Why: dropped bytes invalidate every cross-chunk carry — a DEC 2026 + // classification or partial OSC-9999 prefix spanning the gap would + // corrupt the next live chunk. + synchronizedHiddenOutputActive = false + synchronizedHiddenOutputScanTail = '' + hiddenMode2031ScanTail = '' + transport.resetCrossChunkParserState?.() + // Why: parity with the hidden skip path — a marker landing while a + // restore is in flight means the in-flight snapshot may predate the + // drop, so a fresh snapshot must follow. Captured BEFORE the mark: on a + // visible pane the mark starts a restore synchronously, which must not + // count as "already in flight". + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + } + + function syncModelRestoreNeededSubscription(ptyId: string | null): void { + if (modelRestoreSubscribedPtyId === ptyId) { + return + } + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = ptyId + // Why: markers exist only for PTYs whose bytes transit local main; + // remote-runtime transports are structurally unaffected. + if (!ptyId || isRemoteRuntimePtyId(ptyId)) { + return + } + unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler( + ptyId, + handleModelRestoreNeededMarker + ) + } + + syncHiddenRendererPtyDelivery = (): void => { + const ptyId = transport.getPtyId() + syncModelRestoreNeededSubscription(ptyId) + if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { + if (hiddenDeliveryMarkedHidden) { + sendHiddenRendererPtyDelivery(hiddenDeliverySyncedPtyId, false) + } + hiddenDeliverySyncedPtyId = null + hiddenDeliveryMarkedHidden = false + } + if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) { + return + } + const shouldHide = + !disposed && + !shouldWritePtyOutputForeground(deps.isVisibleRef.current) && + // Why: codex startup probes need the live xterm to answer renderer + // queries for 10s — never gate delivery while the window is active. + !isHiddenStartupRendererQueryWindowActive() + const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId + hiddenDeliverySyncedPtyId = ptyId + if (shouldHide) { + if (!hiddenDeliveryMarkedHidden) { + hiddenDeliveryMarkedHidden = true + sendHiddenRendererPtyDelivery(ptyId, true) + } + } else if (hiddenDeliveryMarkedHidden || isFirstSyncForPty) { + // Why: clear unconditionally on the first sync for a PTY — a stale + // main-side hidden bit can survive a renderer reload for + // daemon-backed PTYs that keep their session id. + hiddenDeliveryMarkedHidden = false + sendHiddenRendererPtyDelivery(ptyId, false) + } + } + releaseHiddenRendererPtyDelivery = (): void => { + if (hiddenDeliverySyncedPtyId !== null && hiddenDeliveryMarkedHidden) { + sendHiddenRendererPtyDelivery(hiddenDeliverySyncedPtyId, false) + } + hiddenDeliverySyncedPtyId = null + hiddenDeliveryMarkedHidden = false + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = null + } + function respondToSkippedMode2031Subscribe(data: string): void { + // Why: gate-managed PTYs answer 2031 from main's '2031-subscribe' fact + // (sole responder); scanning skipped chunks here too would answer the + // same subscribe twice. + if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + return + } const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) hiddenMode2031ScanTail = scan.tail if (!scan.subscribe) { @@ -2348,6 +2560,75 @@ export function connectPanePty( return chunk.data.slice(offset) } + type RestoredSnapshotReconciliation = + | { action: 'write'; data: string; meta: PtyDataMeta | undefined } + | { action: 'drop-duplicate' } + | { action: 'force-fresh-restore' } + + // Why: same slicing rules as getChunkDataAfterSnapshot, applied to LIVE + // chunks after a restore completed — main's ACK backlog keeps draining + // chunks at or before the snapshot seq, and pending-cap trims can drop + // seq ranges silently once the one-shot overflow marker was consumed. + function reconcileChunkAgainstRestoredSnapshot( + data: string, + meta: PtyDataMeta | undefined + ): RestoredSnapshotReconciliation { + if (restoredSnapshotBaselineSeq === null) { + return { action: 'write', data, meta } + } + if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) { + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + if (typeof meta?.seq !== 'number') { + // Why: seq-less chunks (no runtime metering) cannot be reconciled; + // mirror getChunkDataAfterSnapshot and pass them through. + return { action: 'write', data, meta } + } + if ( + restoredSnapshotDeliveryWindowStartSeq !== null && + meta.seq <= restoredSnapshotDeliveryWindowStartSeq + ) { + // Why: every byte main could still deliver at snapshot time started + // AFTER this seq, and delivery is once-and-in-order — so this chunk + // cannot be a backlog duplicate. It is a new seq domain (restarted + // counter / synthetic source); retire the stale baseline and write + // instead of silently dropping genuinely-new live output. + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + const rawLength = meta.rawLength ?? data.length + const startSeq = meta.seq - rawLength + const expectedStartSeq = restoredSnapshotExpectedStartSeq + restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq) + if (expectedStartSeq !== null && startSeq > expectedStartSeq) { + // Why: the chunk starts past the continuity point — bytes between + // were dropped (pending-cap trim after the marker fired). Only the + // model snapshot can heal the gap. + return { action: 'force-fresh-restore' } + } + if (meta.seq <= restoredSnapshotBaselineSeq) { + return { action: 'drop-duplicate' } + } + if (startSeq >= restoredSnapshotBaselineSeq) { + return { action: 'write', data, meta } + } + if (rawLength !== data.length) { + // Why: renderer-only OSC stripping makes raw sequence offsets + // impossible to map onto cleaned text — fetch a fresh snapshot + // instead of risking duplicate visible output. + return { action: 'force-fresh-restore' } + } + const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq) + return { + action: 'write', + data: sliced, + // Why: keep seq metadata consistent with the sliced payload so a + // later restore queue drain slices against accurate offsets. + meta: { ...meta, rawLength: sliced.length } + } + } + function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false @@ -2369,6 +2650,12 @@ export function connectPanePty( hiddenOutputRestorePendingChars = 0 return false } + // Why: drained chunks advance the post-restore continuity point so + // the live-chunk reconciliation neither re-drops them as duplicates + // nor misreads the next live chunk as a gap. + if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { + restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) + } if (data) { writePtyOutputToXterm(data, true) } @@ -2445,6 +2732,7 @@ export function connectPanePty( // Why: renderer backlog is tied to the old PTY stream; after reattach, // queued hidden bytes must not delay or replay before the new PTY. clearHiddenOutputRestoreState() + clearRestoredSnapshotBaseline() discardTerminalOutput(pane.terminal) } } @@ -2626,6 +2914,10 @@ export function connectPanePty( } hiddenOutputRestoreDeferredRetryAttempts = 0 applyMainBufferSnapshot(snapshot) + // Why: everything at or before snapshot.seq is now painted; chunks + // still draining from main's ACK backlog below that point are + // duplicates the dataCallback reconciliation must suppress. + setRestoredSnapshotBaseline(currentPtyId, snapshot) const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded hiddenOutputRestoreFreshSnapshotNeeded = false if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) { @@ -2658,16 +2950,22 @@ export function connectPanePty( return true } - unregisterBacklogRecovery = registerTerminalBacklogRecovery( - pane.terminal, - requestHiddenOutputRestoreIfNeeded - ) + unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => { + // Why: clear the hidden-delivery bit BEFORE the restore snapshot + // request — bytes arriving between the unhide IPC and the snapshot + // are reconciled by the existing seq guard. + syncHiddenRendererPtyDelivery() + return requestHiddenOutputRestoreIfNeeded() + }) if ( typeof document !== 'undefined' && typeof document.addEventListener === 'function' && typeof document.removeEventListener === 'function' ) { const onDocumentVisibilityChange = (): void => { + // Why: document hide/show flips the foreground predicate without any + // pane lifecycle event — re-sync the hidden-delivery gate both ways. + syncHiddenRendererPtyDelivery() if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { requestHiddenOutputRestoreIfNeeded() } @@ -2696,6 +2994,33 @@ export function connectPanePty( // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) + // Why: latch the hidden-delivery gate from the byte path too — covers + // the startup-query-window expiring without a visibility event and a + // PTY id arriving after the initial sync. No-op when state is current. + if (!foreground) { + syncHiddenRendererPtyDelivery() + } + // Why: post-restore reconciliation — drop/slice backlog chunks the + // restored snapshot already covers, and force a fresh restore for seq + // gaps or overlaps whose offsets cannot be mapped. Runs after the byte + // observers above (those bytes were never delivered before; their side + // effects are still real) but before any xterm write decision. + const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta) + if (reconciliation.action === 'drop-duplicate') { + return + } + if (reconciliation.action === 'force-fresh-restore') { + // Why: in-flight captured BEFORE the mark — on a visible pane the + // mark starts the restore synchronously and must not flag itself. + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + return + } + data = reconciliation.data + meta = reconciliation.meta const restoreAppliesToCurrentPty = hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId const dataPtyId = transport.getPtyId() @@ -2840,6 +3165,7 @@ export function connectPanePty( } setPanePtyFitBinding(ptyId) registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) agentCompletionCoordinator.startProcessTracking() @@ -3323,6 +3649,7 @@ export function connectPanePty( } }) registerSideEffectFactConsumerForPty(attachPtyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, attachPtyId) deps.updateTabPtyId(deps.tabId, attachPtyId) agentCompletionCoordinator.startProcessTracking() @@ -3374,6 +3701,7 @@ export function connectPanePty( } }) registerSideEffectFactConsumerForPty(spawnedPtyId) + syncHiddenRendererPtyDelivery() // Why: attach sets the transport's PTY id; starting process // tracking before this point no-ops because getPtyId() is empty. agentCompletionCoordinator.startProcessTracking() @@ -3392,9 +3720,18 @@ export function connectPanePty( return { syncProcessTracking() { agentCompletionCoordinator.startProcessTracking() + // Why: the lifecycle hook calls this on every pane visibility flip — + // the hidden-delivery gate must follow the same transitions. + syncHiddenRendererPtyDelivery() + }, + isHiddenDeliveryGateManagedPty() { + return isHiddenDeliveryGateManagedPty(transport.getPtyId()) }, dispose() { disposed = true + // Why: a pane unmount (tab move, parking teardown) must never leave its + // PTY gated — the parked watcher or the remounted pane re-decides. + releaseHiddenRendererPtyDelivery() if (terminalKeyTargetSupportsEvents) { terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) } diff --git a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts new file mode 100644 index 00000000000..a695b77d82f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts @@ -0,0 +1,42 @@ +/** + * Renderer-side delivery-interest registry for the Phase-4 hidden-delivery + * gate (docs/reference/terminal-side-effect-authority.md, Open Items). + * + * Why: main only drops hidden PTY byte delivery while NO renderer party needs + * raw bytes. Dispatcher sidecars and eager pre-mount buffers register + * interest here; ref-counted so main sees only the 0↔1 transitions. + */ +const ptyDeliveryInterestRefCounts = new Map() + +function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void { + ;(globalThis as { window?: Window }).window?.api?.pty?.setPtyDeliveryInterest?.(ptyId, interested) +} + +/** Acquire a delivery-interest hold for a PTY. Returns a release fn that is + * safe to call more than once (only the first call decrements). */ +export function acquirePtyDeliveryInterest(ptyId: string): () => void { + const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1 + ptyDeliveryInterestRefCounts.set(ptyId, next) + if (next === 1) { + sendPtyDeliveryInterest(ptyId, true) + } + let released = false + return () => { + if (released) { + return + } + released = true + const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0 + if (current <= 1) { + ptyDeliveryInterestRefCounts.delete(ptyId) + sendPtyDeliveryInterest(ptyId, false) + } else { + ptyDeliveryInterestRefCounts.set(ptyId, current - 1) + } + } +} + +/** Test seam: drop ref counts between tests (no IPC is sent). */ +export function _resetPtyDeliveryInterestForTest(): void { + ptyDeliveryInterestRefCounts.clear() +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts new file mode 100644 index 00000000000..2d860a08b5b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts @@ -0,0 +1,111 @@ +// Why: the Phase-4 hidden-delivery gate only drops bytes while NO renderer +// party needs them. These tests pin the dispatcher-side interest signal: every +// subscribeToPtyData sidecar and every eager pre-mount buffer must surface a +// ref-counted delivery-interest hold to main, and release it exactly once. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery interest', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let setPtyDeliveryInterest: ReturnType + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + + beforeEach(() => { + vi.resetModules() + exitCallback = null + setPtyDeliveryInterest = vi.fn() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + setPtyDeliveryInterest, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('registers interest on the first sidecar and releases on the last unsubscribe', async () => { + const { subscribeToPtyData } = await import('./pty-dispatcher') + + const unsubscribeFirst = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-1', true) + + // Why: ref-counted — main only sees the 0↔1 transitions. + const unsubscribeSecond = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribeFirst() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + unsubscribeSecond() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('releases sidecar interest only once for repeated unsubscribes', async () => { + const { subscribeToPtyData } = await import('./pty-dispatcher') + + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + unsubscribe() + unsubscribe() + + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('holds interest for an eager pre-mount buffer until the pane attach disposes it', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + const handle = registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-eager', true) + + handle.dispose() + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-eager', false) + + // Why: dispose + a later exit event must not double-release the hold a + // concurrent sidecar may still own. + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + }) + + it('releases eager-buffer interest when the PTY exits before any pane mounts', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-eager', true) + + exitCallback?.({ id: 'pty-eager', code: 0 }) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-eager', false) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + }) + + it('keeps interest held while a sidecar and an eager buffer overlap', async () => { + const { registerEagerPtyBuffer, subscribeToPtyData } = await import('./pty-dispatcher') + + const handle = registerEagerPtyBuffer('pty-1', vi.fn()) + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + handle.dispose() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribe() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 656657aab2e..20d90d39307 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -7,6 +7,7 @@ */ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { EventProps } from '../../../../shared/telemetry-events' +import { acquirePtyDeliveryInterest } from './pty-delivery-interest' import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' // ── Singleton PTY event dispatcher ─────────────────────────────────── @@ -24,6 +25,11 @@ export type PtyBufferSnapshot = { cols: number rows: number seq?: number + /** Lowest seq main could still deliver when the snapshot was taken (start + * of its pending renderer-delivery queue; equals `seq` when empty). Bytes + * are delivered once and in order, so a post-restore chunk at or below + * this seq can never be a duplicate the snapshot already covers. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' } @@ -41,6 +47,10 @@ export const ptyDataSidecars = new Map void>>() * is called automatically so the underlying IPC stream is wired up. */ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { ensurePtyDispatcher() + // Why: a sidecar is, by definition, a raw-byte consumer — its registration + // doubles as the delivery-interest signal that suppresses main's + // hidden-delivery gate (terminal-side-effect-authority.md, Open Items). + const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId) let set = ptyDataSidecars.get(ptyId) if (!set) { set = new Set() @@ -48,6 +58,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi } set.add(watcher) return () => { + releaseDeliveryInterest() const current = ptyDataSidecars.get(ptyId) if (!current) { return @@ -206,6 +217,10 @@ export function registerEagerPtyBuffer( onExit: (ptyId: string, code: number) => void ): EagerPtyHandle { ensurePtyDispatcher() + // Why: an eager buffer means a pane mount is (potentially) pending — the + // hidden-delivery gate must keep bytes flowing until the pane attaches and + // takes over, so the buffer holds delivery interest for its lifetime. + const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId) // Why: a head index instead of Array.shift() — shift() is O(n), making // pre-attach buffering quadratic under many small chunks. Compaction is deferred. @@ -234,6 +249,7 @@ export function registerEagerPtyBuffer( const exitHandler = (code: number): void => { // Shell died before TerminalPane attached — clean up and notify the store // so the tab's ptyId is cleared and connectPanePty falls through to connect(). + releaseDeliveryInterest() ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) ptyExitHandlers.delete(ptyId) @@ -256,6 +272,9 @@ export function registerEagerPtyBuffer( return data }, dispose() { + // Why: dispose runs at pane attach (mount completed) — the pane's own + // visibility sync now owns the hidden-delivery decision for this PTY. + releaseDeliveryInterest() // Only remove if the current handler is still the temp one (compare by // reference). After attach() replaces the handler this becomes a no-op. if (ptyDataHandlers.get(ptyId) === dataHandler) { @@ -340,6 +359,10 @@ export type PtyTransport = { ) => boolean isConnected: () => boolean getPtyId: () => string | null + /** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a + * model-restore marker reports dropped bytes — a carry spanning the gap + * would corrupt the next live chunk. IPC transports only. */ + resetCrossChunkParserState?: () => void serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void /** Unregister PTY handlers without killing the process, so a remounted diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts new file mode 100644 index 00000000000..289192d13d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts @@ -0,0 +1,86 @@ +// Why: the out-of-band pty:modelRestoreNeeded channel replaces the in-band +// empty-chunk sentinel (ambiguous with chunks fully consumed by OSC-9999 +// stripping). These tests pin the channel routing: one channel subscription, +// handlers keyed by PTY id, replace-on-reregister semantics. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty model-restore channel routing', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onModelRestoreNeeded: ReturnType + let channelCallback: ((event: { id: string; reason: string; markerSeq?: number }) => void) | null + + beforeEach(() => { + vi.resetModules() + channelCallback = null + onModelRestoreNeeded = vi.fn( + (callback: (event: { id: string; reason: string; markerSeq?: number }) => void) => { + channelCallback ??= callback + return () => {} + } + ) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + onModelRestoreNeeded + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('attaches the channel once and routes markers to the registered PTY handler', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handlerA = vi.fn() + const handlerB = vi.fn() + + registerPtyModelRestoreNeededHandler('pty-a', handlerA) + registerPtyModelRestoreNeededHandler('pty-b', handlerB) + expect(onModelRestoreNeeded).toHaveBeenCalledTimes(1) + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerA).toHaveBeenCalledWith({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerB).not.toHaveBeenCalled() + + // Markers for PTYs without a registered handler are dropped silently. + channelCallback?.({ id: 'pty-unknown', reason: 'pending-cap' }) + expect(handlerA).toHaveBeenCalledTimes(1) + expect(handlerB).not.toHaveBeenCalled() + }) + + it('lets a new registration replace a stale one without the stale unregister clobbering it', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const staleHandler = vi.fn() + const liveHandler = vi.fn() + + const unregisterStale = registerPtyModelRestoreNeededHandler('pty-a', staleHandler) + registerPtyModelRestoreNeededHandler('pty-a', liveHandler) + // Why: a reattaching pane can re-register before the old connection's + // teardown runs — the stale unregister must not remove the live handler. + unregisterStale() + + channelCallback?.({ id: 'pty-a', reason: 'unhide' }) + expect(staleHandler).not.toHaveBeenCalled() + expect(liveHandler).toHaveBeenCalledTimes(1) + }) + + it('stops routing after the live handler unregisters', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handler = vi.fn() + + const unregister = registerPtyModelRestoreNeededHandler('pty-a', handler) + unregister() + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop' }) + expect(handler).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts new file mode 100644 index 00000000000..5f3f95cc1a3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts @@ -0,0 +1,55 @@ +/** + * Singleton router for the out-of-band `pty:modelRestoreNeeded` channel + * (sibling of the pty-dispatcher's data/exit routing — split out to keep the + * dispatcher under the line limit). + * + * Why a dedicated channel + registry: the marker means "main dropped + * renderer-bound bytes (hidden gate / pending cap); restore from the model + * snapshot". It must NOT ride the transport data path — an in-band empty + * chunk is ambiguous with chunks fully consumed by OSC-9999 stripping, and + * remote-runtime transports (which never see main's gate) must stay + * structurally unaffected. + */ +import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker' + +const ptyModelRestoreNeededHandlers = new Map void>() +let modelRestoreNeededChannelAttached = false + +function dispatchPtyModelRestoreNeeded(event: PtyModelRestoreNeededEvent): void { + ptyModelRestoreNeededHandlers.get(event.id)?.(event) +} + +function ensureModelRestoreNeededChannel(): void { + if (modelRestoreNeededChannelAttached) { + return + } + // Why optional-chained: unit tests and the web remote client expose a + // partial pty API; missing channel means "no markers", never a throw. + const onModelRestoreNeeded = (globalThis as { window?: Window }).window?.api?.pty + ?.onModelRestoreNeeded + if (typeof onModelRestoreNeeded !== 'function') { + return + } + modelRestoreNeededChannelAttached = true + onModelRestoreNeeded(dispatchPtyModelRestoreNeeded) +} + +/** Register the single model-restore-needed handler for a PTY (the pane + * connection that owns its view). A new registration replaces a stale one. */ +export function registerPtyModelRestoreNeededHandler( + ptyId: string, + handler: (event: PtyModelRestoreNeededEvent) => void +): () => void { + ensureModelRestoreNeededChannel() + ptyModelRestoreNeededHandlers.set(ptyId, handler) + return () => { + if (ptyModelRestoreNeededHandlers.get(ptyId) === handler) { + ptyModelRestoreNeededHandlers.delete(ptyId) + } + } +} + +/** Test seam: deliver a marker as if it arrived on the channel. */ +export function _dispatchPtyModelRestoreNeededForTest(event: PtyModelRestoreNeededEvent): void { + dispatchPtyModelRestoreNeeded(event) +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 7ff205f25aa..42adbc528f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -112,6 +112,23 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('drops the OSC-9999 cross-chunk carry on resetAgentStatusCarry', async () => { + // Why: a model-restore marker means bytes were dropped between chunks — + // a partial OSC-9999 prefix carried across that gap would swallow the + // next live chunk's head as bogus status payload. + const { createPtyOutputProcessor } = await import('./pty-transport') + const processor = createPtyOutputProcessor({}) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]9999;', callbacks) + expect(callbacks.onData).toHaveBeenLastCalledWith('') + + processor.resetAgentStatusCarry() + processor.processData('plain output after the gap', callbacks) + + expect(callbacks.onData).toHaveBeenLastCalledWith('plain output after the gap') + }) + it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index af3b4c67a76..4b870e3a115 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -103,9 +103,13 @@ export function createPtyOutputProcessor({ clearStaleTitleTimer: () => void flushPendingSideEffects: () => void resetBellDetector: () => void + resetAgentStatusCarry: () => void } { const bellDetector = createBellDetector() - const processAgentStatusChunk = createAgentStatusOscProcessor() + // Why `let`: a model-restore marker means bytes were dropped between + // chunks; a partial OSC-9999 prefix carried across that gap would swallow + // the next live chunk's head as bogus payload. Reset recreates the parser. + let processAgentStatusChunk = createAgentStatusOscProcessor() // Why: seed both the emitted-title memory (stale-title probe) and the agent // tracker so a mid-session processor behaves as if it had observed the // pane's last live title — full parity with the live path it replaces. @@ -423,7 +427,10 @@ export function createPtyOutputProcessor({ clearAccumulatedState, clearStaleTitleTimer, flushPendingSideEffects, - resetBellDetector: () => bellDetector.reset() + resetBellDetector: () => bellDetector.reset(), + resetAgentStatusCarry: () => { + processAgentStatusChunk = createAgentStatusOscProcessor() + } } } @@ -783,6 +790,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return ptyId }, + resetCrossChunkParserState() { + // Why: only the OSC-9999 carry spans the dropped-byte gap a + // model-restore marker reports; title/bell trackers re-sync from the + // snapshot's side-effect replay and must not be reset here. + outputProcessor.resetAgentStatusCarry() + }, + destroy() { destroyed = true this.disconnect() diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts new file mode 100644 index 00000000000..8123b83a9e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts @@ -0,0 +1,45 @@ +/** + * Renderer-side predicate for main's Phase-4 hidden PTY delivery gate. + * + * The gate only operates when main holds side-effect authority for the PTY + * (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill + * switch is on. Callers decide once at pane/watcher creation — the decision + * picks which mode-2031 responder is registered (byte sidecar vs fact reply), + * so it must never flip per chunk. + */ +import type { GlobalSettings } from '../../../../shared/types' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedGateFlagCache: boolean | null | undefined + +function readPersistedHiddenDeliveryGateFlagSync(): boolean | null { + if (persistedGateFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedGateFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null + } catch { + persistedGateFlagCache = null + } + } + return persistedGateFlagCache +} + +export function isRendererHiddenPtyDeliveryGateEnabled( + settings: Pick | null +): boolean { + if (settings !== null) { + return settings.terminalHiddenDeliveryGate !== false + } + // Why: settings hydrate asynchronously; a pane/watcher bound before + // hydration must honor the persisted kill switch — the responder-mode + // decision made here is never revisited (same rationale as the + // side-effect-authority sync read). + return readPersistedHiddenDeliveryGateFlagSync() !== false +} + +/** Test seam: reset the persisted-flag cache between tests. */ +export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void { + persistedGateFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index fe4193e9c68..84a91c2e7c4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -255,6 +255,34 @@ describe('registerTerminalSideEffectFactConsumer', () => { expect(events).toEqual([['title', 'restored']]) }) + it('routes 2031-subscribe facts to the registered consumer but never replays them', () => { + // Why: the fact lets hidden-delivery-gated views answer the color-scheme + // query without byte access; a replayed subscribe would re-answer a query + // the snapshot already satisfied. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onMode2031Subscribe: () => events.push(['2031-subscribe']) + } + }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }])) + expect(events).toEqual([['2031-subscribe']]) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: '2031-subscribe' } + ], + { replay: true, seq: 5 } + ) + ) + expect(events).toEqual([['2031-subscribe'], ['title', 'restored']]) + }) + it('never replays command-finished or pr-link facts', () => { // Why: like bells and agent transitions, command/PR facts are attention // signals — replay snapshots restore title state only. diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 103965e7380..6fffdbea1a4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -82,6 +82,10 @@ export type TerminalSideEffectFactConsumerCallbacks = { * done is settle-checked by the pane policy before completing the turn. */ onCommandCodeWorking?: (prompt: string) => void onCommandCodeDone?: (prompt: string) => void + /** DECSET 2031 subscribe observed by main's tracker. Registered only by + * hidden-delivery-gated consumers (their bytes never arrive); the theme + * reply is sent renderer-side — query authority stays with the view. */ + onMode2031Subscribe?: () => void } type ConsumerEntry = { @@ -130,6 +134,9 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: return case 'command-code-done': entry.callbacks.onCommandCodeDone?.(fact.prompt) + return + case '2031-subscribe': + entry.callbacks.onMode2031Subscribe?.() } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 3cc6fcecac3..9b48fd77c9b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -539,6 +539,13 @@ export function useTerminalPaneLifecycle({ dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding, + // Why: a DECSET 2031 subscribe answered from main's fact channel must + // land in the same registries the xterm CSI handler writes — otherwise + // theme flips never push CSI 997 and the TUI keeps a stale theme. + recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => { + paneMode2031Ref.current.set(paneId, true) + paneLastThemeModeRef.current.set(paneId, repliedMode) + }, restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {} } @@ -566,7 +573,19 @@ export function useTerminalPaneLifecycle({ const mode2031Disposables = installMode2031Handlers({ paneId: pane.id, parser: pane.terminal.parser, - onSubscribe: () => pushMode2031ForPane(pane.id), + onSubscribe: () => { + // Why: for hidden-delivery-gate-managed PTYs main's + // '2031-subscribe' fact is the sole responder — bytes reaching + // xterm live (foreground, sidecar interest) must not produce a + // second reply. The CSI handler still records the subscription. + const binding = panePtyBindings.get(pane.id) as + | (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean }) + | undefined + if (binding?.isHiddenDeliveryGateManagedPty?.()) { + return + } + pushMode2031ForPane(pane.id) + }, isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), paneMode2031: paneMode2031Ref.current, paneLastThemeMode: paneLastThemeModeRef.current diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 06a0f77d99f..65e0bdf6ee9 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2256,6 +2256,8 @@ function createPtyApi(): NonNullable['pty']> { ackColdRestore: () => {}, ackData: () => {}, setActiveRendererPty: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), @@ -2279,11 +2281,17 @@ function createPtyApi(): NonNullable['pty']> { peakMaxPendingCharsByPty: 0, peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0 + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0 }), resetRendererDeliveryDebug: () => Promise.resolve(), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, onExit: () => noopUnsubscribe, onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e60913ef4ab..7edbed12fa3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -275,6 +275,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalScopeHistoryByWorktree: true, terminalHiddenViewParking: true, terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/shared/pty-model-restore-marker.ts b/src/shared/pty-model-restore-marker.ts new file mode 100644 index 00000000000..6328ed6abc2 --- /dev/null +++ b/src/shared/pty-model-restore-marker.ts @@ -0,0 +1,19 @@ +/** + * Out-of-band `pty:modelRestoreNeeded` (main → renderer) payload. + * + * Why a dedicated channel instead of an in-band sentinel chunk: an empty + * `pty:data` chunk is indistinguishable from a real chunk whose bytes were + * entirely stripped by renderer-side OSC-9999 cleaning, so an in-band marker + * could spuriously trigger full snapshot restores on visible panes. The + * marker is delivery machinery, not PTY data — remote-runtime transports + * never see it. + */ +export type PtyModelRestoreReason = 'hidden-drop' | 'unhide' | 'pending-cap' + +export type PtyModelRestoreNeededEvent = { + id: string + reason: PtyModelRestoreReason + /** Main's PTY output sequence at emit time — everything at or before this + * point is only recoverable from the model snapshot. */ + markerSeq?: number +} diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts index 6e7c8b5bf19..821d2ca5e42 100644 --- a/src/shared/terminal-output-side-effects.test.ts +++ b/src/shared/terminal-output-side-effects.test.ts @@ -17,6 +17,7 @@ type RecordedEvent = | ['bell'] | ['finished', number | null] | ['pr', string, number] + | ['2031-subscribe'] function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): { events: RecordedEvent[] @@ -28,6 +29,7 @@ function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): onBell: () => events.push(['bell']), onCommandFinished: (exitCode) => events.push(['finished', exitCode]), onPrLink: (link) => events.push(['pr', link.url, link.number]), + onMode2031Subscribe: () => events.push(['2031-subscribe']), ...overrides }) return { events, tracker } @@ -114,6 +116,41 @@ describe('createTerminalTitleTracker pr-link facts', () => { }) }) +describe('createTerminalTitleTracker 2031-subscribe facts', () => { + it('emits a fact per chunk containing a DECSET 2031 subscribe, before the bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031hready${BEL}`) + + expect(events).toEqual([['2031-subscribe'], ['bell']]) + }) + + it('detects a subscribe split across chunk boundaries', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?20`) + tracker.handleChunk('31h') + + expect(events).toEqual([['2031-subscribe']]) + }) + + it('ignores DECSET 2031 unsubscribes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031l`) + + expect(events).toEqual([]) + }) + + it('skips the 2031 scan entirely when no consumer is registered', () => { + const { events, tracker } = createRecordingTracker({ onMode2031Subscribe: undefined }) + + tracker.handleChunk(`${ESC}[?2031h`) + + expect(events).toEqual([]) + }) +}) + describe('createTerminalTitleTracker synthetic-frame isolation', () => { it('never feeds synthetic frames to the 133/PR scanners', () => { const { events, tracker } = createRecordingTracker() diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 5dfe149fcac..0054ae44e90 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -18,6 +18,7 @@ import { normalizeTerminalTitle } from './agent-detection' import { createBellDetector } from './terminal-bell-detector' +import { scanMode2031Sequences } from './terminal-color-scheme-protocol' import { createTerminalGitHubPRLinkDetector, type TerminalGitHubPRLink @@ -73,6 +74,12 @@ export type TerminalTitleTrackerCallbacks = { /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, * deduplicated per tracker like the renderer detector). */ onPrLink?: (link: TerminalGitHubPRLink) => void + /** + * Fired per chunk containing a DECSET 2031 subscribe (chunk-boundary-safe). + * Lets hidden-delivery-gated renderer views answer the color-scheme query + * without byte access; the reply itself stays with the view. + */ + onMode2031Subscribe?: () => void } export type TerminalTitleTracker = { @@ -108,7 +115,8 @@ export function createTerminalTitleTracker( onAgentExited, onBell, onCommandFinished, - onPrLink + onPrLink, + onMode2031Subscribe } = callbacks const bellDetector = onBell ? createBellDetector() : null // Why: created only when a consumer exists (like the bell detector) so @@ -117,6 +125,9 @@ export function createTerminalTitleTracker( ? createOsc133CommandFinishedScanner(onCommandFinished) : null const prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + // Why: a DECSET 2031 subscribe can be split across PTY chunks; carry a + // bounded tail between chunks so split sequences still match. + let mode2031ScanTail = '' // Why: seed both the emitted-title memory (stale-title probe) and the agent // tracker so a mid-session tracker behaves as if it had observed the pane's // last live title — parity with the renderer processor's seeding. @@ -211,15 +222,23 @@ export function createTerminalTitleTracker( } }, STALE_WORKING_TITLE_TIMEOUT_MS) } - // Per-chunk fact order: titles → command-finished → pr-link → bell. The - // bell stays last (the renderer drain's order); the byte scanners keep - // their own cross-chunk carry so split sequences/URLs still resolve. + // Per-chunk fact order: titles → command-finished → pr-link → + // 2031-subscribe → bell. The bell stays last (the renderer drain's + // order); the byte scanners keep their own cross-chunk carry so split + // sequences/URLs still resolve. commandFinishedScanner?.scan(data) if (prLinkDetector) { for (const link of prLinkDetector(data)) { onPrLink?.(link) } } + if (onMode2031Subscribe) { + const mode2031Scan = scanMode2031Sequences(mode2031ScanTail, data) + mode2031ScanTail = mode2031Scan.tail + if (mode2031Scan.subscribe) { + onMode2031Subscribe() + } + } if (containsBell) { onBell?.() } @@ -267,6 +286,7 @@ export function createTerminalTitleTracker( agentTracker?.reset() bellDetector?.reset() commandFinishedScanner?.reset() + mode2031ScanTail = '' } } } diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index b99ce6679b0..1a546324006 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -28,6 +28,11 @@ export type TerminalSideEffectFact = * against its live status row before completing the turn. */ | { kind: 'command-code-working'; prompt: string } | { kind: 'command-code-done'; prompt: string } + /** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted + * so hidden-delivery-gated views (whose bytes never arrive) can still send + * the theme reply — the reply stays renderer-side because query authority + * belongs to the view (model/view contract invariant 6). */ + | { kind: '2031-subscribe' } export type TerminalSideEffectBatch = { ptyId: string diff --git a/src/shared/types.ts b/src/shared/types.ts index 6652edf8dcb..5f96bf48a7d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2202,6 +2202,11 @@ export type GlobalSettings = { * for those PTYs; `false` restores renderer byte parsing. * See docs/reference/terminal-side-effect-authority.md. */ terminalMainSideEffectAuthority?: boolean + /** Kill switch for main's hidden-delivery gate (Phase 4): when true + * (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte + * delivery to hidden renderer views after model ingestion; reveal restores + * from the model snapshot. `false` restores hidden byte delivery. */ + terminalHiddenDeliveryGate?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index b3d92e1fd6c..4764795d65f 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -51,7 +51,6 @@ type HiddenPressureDeps Promise releaseTerminalAckGate: (page: Page) => Promise resetTerminalPtyOutputDebug: (page: Page) => Promise - waitForMainPtyPressureBacklog: (page: Page) => Promise writeInteractivePromptScript: (scriptPath: string, runId: string) => void } @@ -70,6 +69,13 @@ type HiddenPressureMainSnapshot = { peakPendingChars: number peakRendererInFlightChars: number ackGatedFlushSkipCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryGatedPtyCount: number +} + +type HiddenPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number } type HiddenPressureAckGate = { @@ -79,6 +85,9 @@ type HiddenPressureAckGate = { // Why: restore still has to finish promptly, but parallel Electron workers on // Linux CI can overshoot the 1s product target without a responsiveness regression. const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped in +// main after model ingestion, so renderer-delivery pressure must stay FAR +// below the old 2 MB ACK-backpressure target instead of reaching it. const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 export async function runHiddenRealPtyPressureScenario< @@ -86,7 +95,7 @@ export async function runHiddenRealPtyPressureScenario< TDebug extends HiddenPressureDebug, TMainPressure extends HiddenPressureMainSnapshot, TAckGate extends HiddenPressureAckGate, - TScheduler + TScheduler extends HiddenPressureSchedulerSnapshot >({ deps, annotationSuffix, @@ -150,7 +159,11 @@ export async function runHiddenRealPtyPressureScenario< await switchToTypingWorkspace(orcaPage, firstWorktreeId) const typingPtyId = await waitForActivePanePtyId(orcaPage) - const pressureBeforeTyping = await deps.waitForMainPtyPressureBacklog(orcaPage) + // Why: under the Phase-4 hidden-delivery gate the hidden panes' bytes are + // dropped in main after model ingestion, so renderer-delivery pressure + // never builds. Wait for the gate to drop at least one pane's worth of + // output instead of the old 2 MB ACK-backpressure target. + await waitForMainHiddenDeliveryDrops(orcaPage, deps, pressureOutputChars) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -158,6 +171,7 @@ export async function runHiddenRealPtyPressureScenario< runId ) const debug = await deps.readTerminalPtyOutputDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) const mainPressure = await deps.readMainPtyPressureDebug(orcaPage) const ackGate = await deps.readTerminalAckGateDebug(orcaPage) deps.annotateTypingMeasurement( @@ -166,33 +180,26 @@ export async function runHiddenRealPtyPressureScenario< hiddenPanes.length + 1, measurement, debug, - await deps.readTerminalOutputSchedulerDebug(orcaPage), + scheduler, mainPressure, ackGate ) - if ( - pressureOutputMode === 'plain' || - pressureOutputMode === 'latin' || - pressureOutputMode === 'title' || - pressureOutputMode === 'rich-model' || - pressureOutputMode === 'tui' - ) { - 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) - } - if (pressureOutputMode === 'rich-model') { - expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(pressureOutputChars) - } - expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) - expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) - expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( + // New hidden-delivery contract (all pressure modes): bytes never reach the + // renderer, so hidden skips may legitimately be zero — at most a pre-latch + // trickle below one pane's output — and main's renderer-delivery pressure + // must stay clearly below the old 2 MB backpressure target. + expect(debug?.hiddenRendererSkippedChars ?? 0).toBeLessThan(pressureOutputChars) + expect(mainPressure?.hiddenDeliveryDroppedChars ?? 0).toBeGreaterThanOrEqual( + pressureOutputChars + ) + expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeLessThan( MAIN_RENDERER_PRESSURE_TARGET_CHARS ) - expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) + // Why: the renderer scheduler queue must stay ~empty (no hidden bytes to + // queue) and must never drop a backlog — strict, per the gate contract. + expect(scheduler?.peakQueuedChars ?? 0).toBeLessThan(pressureOutputChars) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(75) expect(measurement.worstLatencyMs).toBeLessThan(300) expect(measurement.maxTimerDriftMs).toBeLessThan(150) @@ -207,9 +214,11 @@ export async function runHiddenRealPtyPressureScenario< type: `opencode-hidden-real-pty-restore${annotationSuffix ?? ''}`, description: `panes=${hiddenPanes.length + 1} restore=${restoreLatencyMs.toFixed( 1 - )}ms hiddenSkippedChars=${debug?.hiddenRendererSkippedChars ?? 0} mainPeakInFlightChars=${ - mainPressure?.peakRendererInFlightChars ?? 0 - } heldAckChars=${ackGate?.heldAckChars ?? 0}` + )}ms hiddenSkippedChars=${debug?.hiddenRendererSkippedChars ?? 0} hiddenDeliveryDroppedChars=${ + mainPressure?.hiddenDeliveryDroppedChars ?? 0 + } mainPeakInFlightChars=${mainPressure?.peakRendererInFlightChars ?? 0} heldAckChars=${ + ackGate?.heldAckChars ?? 0 + }` }) expect(restoreLatencyMs).toBeLessThan(MAX_HIDDEN_RESTORE_LATENCY_MS) } finally { @@ -225,6 +234,23 @@ export async function runHiddenRealPtyPressureScenario< } } +// Why: replaces the old waitForMainPtyPressureBacklog premise — the Phase-4 +// gate drops hidden bytes in main, so renderer-delivery pressure never builds; +// readiness is the gate reporting one pane's worth of dropped output. The 30s +// timeout covers the rich-model 11s startup-window delay. +async function waitForMainHiddenDeliveryDrops( + orcaPage: Page, + deps: { readMainPtyPressureDebug: (page: Page) => Promise }, + pressureOutputChars: number +): Promise { + await expect + .poll( + async () => (await deps.readMainPtyPressureDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'Main hidden-delivery gate did not drop hidden PTY output' } + ) + .toBeGreaterThanOrEqual(pressureOutputChars) +} + async function measureHiddenOutputRestoreLatency( orcaPage: Page, worktreeId: string, diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 14d0e2b6e2b..ae4337eb30f 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -105,6 +105,12 @@ type MainPtyPressureDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + // Phase-4 hidden-delivery gate: bytes dropped in main after model ingestion. + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number } const KEY_LATENCY_SAMPLES = 'abcdefghijklmnop' @@ -430,7 +436,7 @@ function annotateTypingMeasurement( ? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount} rendererQueuedTerminals=${scheduler.queuedTerminalCount} rendererQueuedChars=${scheduler.queuedChars} rendererPeakQueuedTerminals=${scheduler.peakQueuedTerminalCount} rendererPeakQueuedChars=${scheduler.peakQueuedChars} rendererPeakQueuedCharsByTerminal=${scheduler.peakQueuedCharsByTerminal} rendererDroppedBacklogs=${scheduler.droppedBacklogCount}` : '' const mainPressureSummary = mainPressure - ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount}` + ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount} mainHiddenGatedPtys=${mainPressure.hiddenDeliveryGatedPtyCount} mainHiddenDroppedChars=${mainPressure.hiddenDeliveryDroppedChars} mainPendingDroppedChars=${mainPressure.pendingDroppedChars}` : '' const ackGateSummary = ackGate ? ` heldAckPtys=${ackGate.heldAckCount} heldAckChars=${ackGate.heldAckChars} gatedAckPtys=${ackGate.gatedPtyCount}` diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index e8e3ba82a7c..ba76da77596 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -32,12 +32,6 @@ type HiddenTuiWindow = Window & { } } -type HiddenTuiDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number -} - type TuiCursorState = { hidden: boolean | null initialized: boolean | null @@ -79,8 +73,11 @@ function lowRiskRestoreFrame(runId: string, frame: number): string { } async function resetHiddenDebug(page: Page): Promise { - await page.evaluate(() => { + await page.evaluate(async () => { ;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset() + // Why: under the Phase-4 hidden-delivery gate the withheld-output signal + // lives in main's delivery debug counters, not the renderer skip path. + await window.api.pty.resetRendererDeliveryDebug() }) } @@ -105,9 +102,14 @@ async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) } -async function readHiddenDebug(page: Page): Promise { - return page.evaluate(() => { - return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped +// in main after model ingestion and never reach the renderer, so "hidden +// output was withheld" is observed via main's dropped-chars counter instead +// of the old renderer hidden-skip counters. +async function readMainHiddenDeliveryDroppedChars(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return snapshot.hiddenDeliveryDroppedChars }) } @@ -245,16 +247,13 @@ test.describe('Hidden terminal TUI visual restore', () => { await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) await resetHiddenDebug(orcaPage) + // Why: hidden-delivery gate contract — the bulk TUI frames must be + // withheld in main (dropped after model ingestion), not delivered and + // skipped renderer-side. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'visually rich hidden TUI output should skip renderer writes' - }) - .toBeGreaterThan(0) - await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkippedChars ?? 0, { - timeout: 10_000, - message: 'visually rich hidden TUI output did not skip bulk renderer writes' + message: 'visually rich hidden TUI output was not withheld from the renderer' }) .toBeGreaterThan(1024) await expect @@ -342,10 +341,12 @@ test.describe('Hidden terminal TUI visual restore', () => { await sendToTerminal(orcaPage, hiddenPane.ptyId, `node ${JSON.stringify(scriptPath)}\r`) await resetHiddenDebug(orcaPage) + // Why: hidden-delivery gate contract — even plain hidden output is + // dropped in main, so the withheld signal is main's dropped counter. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'plain hidden injected output should skip renderer writes' + message: 'plain hidden injected output was not withheld from the renderer' }) .toBeGreaterThan(0) @@ -428,10 +429,12 @@ test.describe('Hidden terminal TUI visual restore', () => { await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) await resetHiddenDebug(orcaPage) + // Why: hidden-delivery gate contract — synchronized rich frames are + // withheld in main; the headless model snapshot is the restore source. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'rich hidden TUI output should skip renderer writes' + message: 'rich hidden TUI output was not withheld from the renderer' }) .toBeGreaterThan(0) await expect From c864530ca7eca4ffb98520cf1c62b4d9b12a6b70 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:41:23 -0700 Subject: [PATCH 45/62] Answer hidden terminal queries from the model Co-authored-by: Orca --- docs/reference/terminal-query-authority.md | 28 +- src/main/daemon/headless-emulator.test.ts | 43 ++ src/main/daemon/headless-emulator.ts | 238 ++++++---- src/main/daemon/terminal-mouse-mode-mirror.ts | 104 ++++ src/main/daemon/types.ts | 7 + src/main/ipc/pty.ts | 31 ++ src/main/runtime/orca-runtime.ts | 149 +++++- src/main/runtime/rpc/methods/terminal.ts | 50 +- src/main/runtime/rpc/streaming.test.ts | 3 + .../runtime/rpc/terminal-multiplex.test.ts | 223 +++++++++ .../rpc/terminal-output-batching.test.ts | 3 + .../rpc/terminal-subscribe-buffer.test.ts | 3 + .../terminal-model-query-authority.test.ts | 140 ++++++ .../runtime/terminal-model-query-authority.ts | 111 +++++ .../runtime/terminal-query-responder.test.ts | 447 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 10 +- .../windows-pty-compatibility.test.ts | 36 +- .../pane-manager/windows-pty-compatibility.ts | 11 + src/shared/constants.ts | 1 + src/shared/types.ts | 6 + 20 files changed, 1510 insertions(+), 134 deletions(-) create mode 100644 src/main/daemon/terminal-mouse-mode-mirror.ts create mode 100644 src/main/runtime/terminal-model-query-authority.test.ts create mode 100644 src/main/runtime/terminal-model-query-authority.ts create mode 100644 src/main/runtime/terminal-query-responder.test.ts diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index 3ce68597d1b..9472535818a 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -87,9 +87,13 @@ ingestion and an async write; the decision must not be re-read at reply time): 4. no remote view subscriber is attached to the PTY (runtime terminal-RPC subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop xterm receiving the multiplexed stream answers with view authority, exactly - like a visible local pane. Read-only consumers (CLI reads, automation - observers) do not suppress — they also do not answer; that bounded - no-reply case matches today's behavior. + like a visible local pane. Legacy JSON `terminal.subscribe` streams **do** + register as view subscribers and suppress, even when the consumer is a + read-only watcher — deliberately conservative, because the stream may feed + an older live xterm view and a withheld reply (the pre-Phase-5 status quo) + is strictly safer than a double reply. Consumers that never register a + stream (CLI `terminal.read`, automation observers) do not suppress — they + also do not answer; that bounded no-reply case matches today's behavior. Everything the emulator emits outside a forwarding window is discarded, which also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes @@ -156,8 +160,11 @@ The provider kind is known main-side: mirror `isLocalNativeWindowsPty` provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the emulator parser (the main-side twin of `installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by -the forwarding predicate. ConPTY blocking on a missing DA1 is a spawn-time -hazard; spawn-time ownership is deterministic (see races below). +the forwarding predicate. The override is installed at emulator creation and +retrofitted when the spawn mark lands (daemon stream data can create the +emulator before the awaited spawn response marks the PTY). ConPTY blocking on +a missing DA1 is a spawn-time hazard; see the races section for the +hidden-at-spawn loss window that remains until Phase 6. ## Suppression: when main never replies @@ -197,9 +204,14 @@ Safe-side rule per class: duplicates are structurally impossible (one decision point per chunk); where the race costs anything it costs a missing reply. That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or tolerate silence, as they did for every hidden pane before this phase). The -one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn, where -ownership is deterministic: visible pane, startup window (renderer), or -marked-at-spawn (main). It cannot land in the flip gap. +one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible +pane or an active codex startup window answers it from the renderer xterm. +But a PTY spawned hidden **without** the startup window has no answerer until +the renderer's hidden mark lands in main (one IPC hop after spawn): a DA1 +arriving in that pre-mark window is lost. That loss window is the pre-Phase-4 +hidden status quo and persists until Phase 6 marks hidden panes at spawn +(spawn-record flag, below) — spawn-time ownership is not deterministic before +then. ## Invariants diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 35203e71819..8ac734ac9ca 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -290,6 +290,49 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false) }) + it('tracks kitty keyboard flags for emulator re-seed parity', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=5;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('round-trips a pushed CSI > 1 u flag through the core-internals read path', async () => { + // Why: getKittyKeyboardFlags reads _core.coreService.kittyKeyboard.flags, + // a private xterm surface. If an xterm upgrade breaks that path this + // must fail loudly instead of the responder silently answering ?0u. + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[>1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(1) + }) + + it('snapshots the active-buffer kitty flags (alt screen keeps its own set)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + // Kitty flags are per screen buffer: entering the alt screen swaps to + // its own (empty) flag set, exactly what a CSI ? u reply would report. + await emulator.write('\x1b[=5;1u\x1b[?1049h') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=3;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(3) + + await emulator.write('\x1b[?1049l') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('never pushes kitty flags into rehydrateSequences', async () => { + // Why: POST_REPLAY_REATTACH_RESET's deliberate kitty reset must stay + // authoritative for renderer replays (terminal-query-authority.md). + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[=5;1u') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(5) + expect(snapshot.rehydrateSequences).not.toContain('u') + }) + it('tracks split SGR mouse reporting sequences', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index fa9231fac1a..133e6a700f4 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -2,12 +2,28 @@ import './xterm-env-polyfill' import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { extractLastOscTitle } from '../../shared/agent-detection' +import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' import type { TerminalSnapshot, TerminalModes } from './types' export type HeadlessEmulatorOptions = { cols: number rows: number scrollback?: number + /** Phase-5 model query responder sink (terminal-query-authority.md). + * When set, xterm-core auto-replies generated while parsing a write + * flagged `forwardQueryReplies` are forwarded here; all other emissions + * (seeds, hydration, snapshot replay, unsolicited core pushes) are + * discarded. The daemon Session must NEVER pass this — its emulator + * stays write-only forever (contract invariant: the daemon never + * answers). */ + onQueryReply?: (reply: string) => void +} + +export type HeadlessEmulatorWriteOptions = { + /** Reply ownership captured at ingestion for this exact chunk. Default + * false is the main-side replay guard (twin of the renderer's + * replay-guard.ts): seed/hydration/snapshot writes never forward. */ + forwardQueryReplies?: boolean } export type HeadlessSnapshotOptions = { @@ -17,15 +33,19 @@ export type HeadlessSnapshotOptions = { type TerminalWithSynchronousWrite = Terminal & { _core?: { writeSync?: (data: string) => void + // Why: kitty keyboard flags are not on the public IModes; read the core + // service state the CSI =/>/< u handlers mutate. + coreService?: { + kittyKeyboard?: { flags?: number } + } } } const DEFAULT_SCROLLBACK = 5000 +// Keep in sync with the renderer twin in terminal-conpty-device-attributes.ts +// (main must not import renderer modules). +const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' const OSC_SCAN_TAIL_LIMIT = 4096 -// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. -// Keep parser state far beyond normal mode lists while still bounding memory. -const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 -type MouseTrackingMode = NonNullable function parseFileUriPath(uri: string): string | null { try { @@ -61,11 +81,15 @@ export class HeadlessEmulator { private cwd: string | null = null private lastTitle: string | null = null private oscScanTail = '' - private privateModeScanTail = '' - private mouseTrackingMode: MouseTrackingMode = 'none' - private sgrMouseMode = false - private sgrMousePixelsMode = false + private mouseModes = new TerminalMouseModeMirror() private disposed = false + private onQueryReply: ((reply: string) => void) | null + private conptyDa1OverrideInstalled = false + // Why: replies must be scoped to the exact write that carried the query. + // The window opens around the parse of a forward-flagged chunk and closes + // with it, so seeds/snapshots and unsolicited core emissions (e.g. native + // 997 pushes from option mutations) can never leak to the PTY. + private queryReplyForwardingDepth = 0 constructor(opts: HeadlessEmulatorOptions) { this.terminal = new Terminal({ @@ -73,26 +97,72 @@ export class HeadlessEmulator { rows: opts.rows, scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK, allowProposedApi: true, - logLevel: 'off' + logLevel: 'off', + // Why: parity with the renderer's buildDefaultTerminalOptions — parse + // CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app + // actually pushed. Write-only daemon use is unaffected: keyboard state + // never alters serialization (terminal-query-authority.md §kitty). + vtExtensions: { kittyKeyboard: true } }) this.serializer = new SerializeAddon() this.terminal.loadAddon(this.serializer) - // Why no onData wiring: this emulator exists purely for state tracking - // (snapshots, cwd, mode flags). It MUST NOT respond to terminal query - // sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses - // data in-process synchronously before `handleSubprocessData` forwards - // it to the renderer over IPC, so any reply it emits would land on the - // shell's stdin ahead of the renderer's xterm reply and win the race. - // The renderer is the authoritative responder (it has the real theme, - // cursor position, and paste mode); a daemon-side reply would be a - // double-reply with wrong values. OSC 11 was the visible casualty: - // Claude Code's /theme auto always saw the emulator's default-black - // background regardless of Orca's configured terminal theme. + // Why onData is gated behind onQueryReply: by default this emulator is + // pure state tracking and MUST NOT respond to terminal query sequences + // (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data + // in-process synchronously before `handleSubprocessData` forwards it to + // the renderer over IPC, so any reply it emitted would land on the + // shell's stdin ahead of the renderer's xterm reply and win the race — + // a double-reply with default-xterm values (OSC 11 default-black was + // the visible casualty). Only main's runtime per-PTY emulators pass a + // sink, and even then replies flow only for chunks the hidden-delivery + // gate DROPPED, where the renderer never sees the bytes and main is the + // single answerer. See docs/reference/terminal-query-authority.md. + this.onQueryReply = opts.onQueryReply ?? null + if (this.onQueryReply) { + this.terminal.onData((reply) => this.emitQueryReply(reply)) + } } - write(data: string): Promise { + /** Main-side twin of the renderer's terminal-conpty-device-attributes.ts: + * ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override + * variant (`CSI ?61;4c`) must win. Returning true consumes the query so + * xterm core's default `?1;2c` cannot double-reply (custom CSI handlers + * run before core's; false falls through). The reply still routes through + * the forwarding window, so replayed/seeded bytes never answer. */ + installConptyPrimaryDeviceAttributesOverride(): void { + // Why idempotent: the spawn mark can land after daemon stream data + // already created the emulator, so the override is installed both at + // creation and retrofitted at mark time — never stacked. + if (this.conptyDa1OverrideInstalled) { + return + } + this.conptyDa1OverrideInstalled = true + this.terminal.parser.registerCsiHandler({ final: 'c' }, (params) => { + const isPrimaryQuery = params.length === 0 || (params.length === 1 && params[0] === 0) + if (!isPrimaryQuery) { + return false + } + this.emitQueryReply(CONPTY_DA1_RESPONSE) + return true + }) + } + + private emitQueryReply(reply: string): void { + if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { + this.onQueryReply(reply) + } + } + + /** Severs the reply sink at PTY teardown. Queued writeChain links may + * still parse after dispose is requested, and daemon respawns reuse + * session ids — a late reply must never reach a successor PTY. */ + disableQueryReplyForwarding(): void { + this.onQueryReply = null + } + + write(data: string, opts: HeadlessEmulatorWriteOptions = {}): Promise { if (this.disposed) { return Promise.resolve() } @@ -104,19 +174,43 @@ export class HeadlessEmulator { if (lastTitle !== null) { this.lastTitle = lastTitle } + const forwardQueryReplies = opts.forwardQueryReplies === true const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync if (typeof writeSync === 'function') { - // Why: hidden renderer restore snapshots are requested immediately after - // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. - writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) - this.scanPrivateModes(data) + if (forwardQueryReplies) { + this.queryReplyForwardingDepth += 1 + } + try { + // Why: hidden renderer restore snapshots are requested immediately after + // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. + writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) + } finally { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } + } + this.mouseModes.scan(data) return Promise.resolve() } + // Why the sentinel: xterm parses queued writes asynchronously, so opening + // the window at enqueue time would leak it over earlier queued unflagged + // chunks (seed/hydration bytes parsing while depth > 0). Write callbacks + // fire in FIFO parse order, so a zero-byte write whose callback opens the + // window brackets the parse of exactly this chunk; the data callback + // closes it. + if (forwardQueryReplies) { + this.terminal.write('', () => { + this.queryReplyForwardingDepth += 1 + }) + } return new Promise((resolve) => { this.terminal.write(data, () => { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. - this.scanPrivateModes(data) + this.mouseModes.scan(data) resolve() }) }) @@ -207,78 +301,6 @@ export class HeadlessEmulator { return suffix.slice(-OSC_SCAN_TAIL_LIMIT) } - private scanPrivateModes(data: string): void { - const input = this.privateModeScanTail + data - this.privateModeScanTail = this.extractPrivateModeScanTail(input) - // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars - const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g - let match: RegExpExecArray | null - while ((match = privateModeRe.exec(input)) !== null) { - if (match[0] === '\x1bc') { - this.mouseTrackingMode = 'none' - this.sgrMouseMode = false - this.sgrMousePixelsMode = false - continue - } - const params = match[1] ?? match[3] - const enabled = (match[2] ?? match[4]) === 'h' - for (const rawParam of params.split(';')) { - if (rawParam === '') { - continue - } - const param = Number(rawParam) - if (!Number.isInteger(param)) { - continue - } - if (param === 9) { - this.mouseTrackingMode = enabled ? 'x10' : 'none' - } - if (param === 1000) { - this.mouseTrackingMode = enabled ? 'vt200' : 'none' - } - if (param === 1002) { - this.mouseTrackingMode = enabled ? 'drag' : 'none' - } - if (param === 1003) { - this.mouseTrackingMode = enabled ? 'any' : 'none' - } - if (param === 1006) { - this.sgrMouseMode = enabled - this.sgrMousePixelsMode = false - } - if (param === 1016) { - this.sgrMouseMode = false - this.sgrMousePixelsMode = enabled - } - } - } - } - - private extractPrivateModeScanTail(input: string): string { - const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) - if (start === -1) { - return '' - } - const tail = input.slice(start) - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { - return '' - } - if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { - return tail - } - if (tail.startsWith('\x1b[?')) { - return this.isIncompletePrivateModeParams(tail.slice(3)) ? tail : '' - } - if (tail.startsWith('\x9b?')) { - return this.isIncompletePrivateModeParams(tail.slice(2)) ? tail : '' - } - return '' - } - - private isIncompletePrivateModeParams(params: string): boolean { - return /^[0-9;]*$/.test(params) - } - private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { if (!modes.alternateScreen) { return snapshotAnsi @@ -303,20 +325,32 @@ export class HeadlessEmulator { private getModes(): TerminalModes { const buffer = this.terminal.buffer.active - const mouseTrackingMode = this.mouseTrackingMode + const mouseTrackingMode = this.mouseModes.mouseTrackingMode return { bracketedPaste: this.terminal.modes.bracketedPasteMode, mouseTracking: mouseTrackingMode !== 'none', mouseTrackingMode, - sgrMouseMode: this.sgrMouseMode, - sgrMousePixelsMode: this.sgrMousePixelsMode, + sgrMouseMode: this.mouseModes.sgrMouseMode, + sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, - alternateScreen: buffer.type === 'alternate' + alternateScreen: buffer.type === 'alternate', + kittyKeyboardFlags: this.getKittyKeyboardFlags() } } + private getKittyKeyboardFlags(): number { + const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard + ?.flags + return typeof flags === 'number' ? flags : 0 + } + private buildRehydrateSequences(modes: TerminalModes): string { + // Why no kitty flags here: rehydrateSequences feeds renderer xterms, and + // POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C + // hazard) must stay authoritative. modes.kittyKeyboardFlags exists for + // emulator re-seed parity only; a re-seeded emulator answers ?0u and + // protocol-conformant programs re-push. const seqs: string[] = [] if (modes.alternateScreen) { seqs.push('\x1b[?1049h') diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts new file mode 100644 index 00000000000..b54e12344ba --- /dev/null +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -0,0 +1,104 @@ +import type { TerminalModes } from './types' + +type MouseTrackingMode = NonNullable + +// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. +// Keep parser state far beyond normal mode lists while still bounding memory. +const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 + +/** + * Mirrors DECSET mouse-protocol/encoding state from the raw byte stream. + * xterm's public modes API does not expose which mouse protocol is active, + * so snapshots track it independently of the headless terminal; callers + * must feed `scan()` the same bytes the terminal parsed, in order. + */ +export class TerminalMouseModeMirror { + private scanTail = '' + private trackingModeState: MouseTrackingMode = 'none' + private sgrMouseModeState = false + private sgrMousePixelsModeState = false + + get mouseTrackingMode(): MouseTrackingMode { + return this.trackingModeState + } + + get sgrMouseMode(): boolean { + return this.sgrMouseModeState + } + + get sgrMousePixelsMode(): boolean { + return this.sgrMousePixelsModeState + } + + scan(data: string): void { + const input = this.scanTail + data + this.scanTail = this.extractScanTail(input) + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g + let match: RegExpExecArray | null + while ((match = privateModeRe.exec(input)) !== null) { + if (match[0] === '\x1bc') { + this.trackingModeState = 'none' + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = false + continue + } + const params = match[1] ?? match[3] + const enabled = (match[2] ?? match[4]) === 'h' + for (const rawParam of params.split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (param === 9) { + this.trackingModeState = enabled ? 'x10' : 'none' + } + if (param === 1000) { + this.trackingModeState = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + this.trackingModeState = enabled ? 'drag' : 'none' + } + if (param === 1003) { + this.trackingModeState = enabled ? 'any' : 'none' + } + if (param === 1006) { + this.sgrMouseModeState = enabled + this.sgrMousePixelsModeState = false + } + if (param === 1016) { + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = enabled + } + } + } + } + + private extractScanTail(input: string): string { + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = input.slice(start) + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { + return tail + } + if (tail.startsWith('\x1b[?')) { + return this.isIncompleteParams(tail.slice(3)) ? tail : '' + } + if (tail.startsWith('\x9b?')) { + return this.isIncompleteParams(tail.slice(2)) ? tail : '' + } + return '' + } + + private isIncompleteParams(params: string): boolean { + return /^[0-9;]*$/.test(params) + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index f0eeb9d0af8..610f17add0f 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -34,6 +34,13 @@ export type TerminalModes = { sgrMousePixelsMode?: boolean applicationCursor: boolean alternateScreen: boolean + /** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed + * parity ONLY. Produced but not yet consumed — the re-seed consumer is + * slice-3 work; do not mistake this field for live snapshot parity. + * rehydrateSequences must never push these into a renderer xterm — + * POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative + * (terminal-query-authority.md §kitty). */ + kittyKeyboardFlags?: number } // ─── NDJSON Protocol Messages ─────────────────────────────────────── diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2392fdc34cc..92bceabc5ab 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -73,6 +73,11 @@ import { shouldDropHiddenRendererPtyData, unmarkHiddenRendererPty } from './pty-hidden-delivery-gate' +import { + clearNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + markNativeWindowsConptyPty +} from '../runtime/terminal-model-query-authority' import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -858,6 +863,8 @@ export function clearProviderPtyState(id: string): void { // shutdown, SSH exit/connection teardown) — hidden/interest gate bits must // not outlive the PTY or a reused map entry could silently gate a new one. clearHiddenRendererPtyDeliveryState(id) + // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. + clearNativeWindowsConptyPty(id) const paneKey = ptyPaneKey.get(id) const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep @@ -1905,6 +1912,18 @@ export function registerPtyHandlers( } } ptyOwnership.set(result.id, args.connectionId ?? null) + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before any byte reaches the + // runtime emulator, so its DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } const relayResultId = getRelayPtyId(args.connectionId, result.id) const persistSshLease = (): void => { if (!store || !args.connectionId) { @@ -2540,6 +2559,18 @@ export function registerPtyHandlers( } } ptyOwnership.set(result.id, args.connectionId ?? null) + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before the headless seed below, + // so the runtime emulator's DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: effectiveShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } const relayResultId = getRelayPtyId(args.connectionId, result.id) if (store && args.connectionId) { // Why: remote PTYs live in the SSH relay grace window after Orca diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0e438c96899..8d0f36bba8d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -479,6 +479,11 @@ import { import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + isNativeWindowsConptyPty, + registerConptyDa1OverrideInstaller, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' @@ -584,6 +589,11 @@ type RuntimeStore = { mobileEmulatorDefaultDeviceUdid?: string | null voice?: VoiceSettings claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode'] + // Why: Phase-5 query responder kill switches — read per chunk in + // onPtyData to capture reply ownership at ingestion. + terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority'] + terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate'] + terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value @@ -1477,6 +1487,14 @@ export class OrcaRuntimeService { > >() + // Why: Phase-5 query-responder suppression — a terminal-RPC subscribe + // stream feeds a remote xterm view (mobile/web/remote desktop) that answers + // queries with view authority, so main must yield while one is attached + // (terminal-query-authority.md). Ref-counted per PTY because multiple + // streams can attach concurrently; mobileSubscribers is consulted too so + // grace-window mobile records keep suppressing. + private remoteTerminalViewSubscriberCounts = new Map() + // Why: per-PTY driver state. The "driver" is whoever currently owns the // input/resize floor. While `kind === 'mobile'` the desktop renderer drops // xterm.onData/onResize and shows the lock banner; `terminal.send` / @@ -1665,6 +1683,10 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null + // Why: the ConPTY spawn mark can land after daemon stream data already + // created this PTY's emulator; the mark retrofits the DA1 override here + // (terminal-query-authority.md §ConPTY DA1). + registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) } getLocalProvider(): IPtyProvider | null { @@ -3325,6 +3347,12 @@ export class OrcaRuntimeService { // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) serveSimStateWatcher.ingestPtyOutput(ptyId, data) + // Why: reply ownership is captured per chunk, here at ingestion — the + // same module state and tick as the hidden-gate drop sites — and rides + // the writeChain link. A mark/setting/subscriber flip before the queued + // emulator write runs must not change who answers (terminal-query- + // authority.md invariant 1). + const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId) // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer // MUST run before trackHeadlessTerminalData so the eager-state pattern // (set headlessTerminals + writeChain head = seedPromise) is in place @@ -3333,7 +3361,7 @@ export class OrcaRuntimeService { // that the later seed-resolve would overwrite, dropping the live byte. // See docs/mobile-prefer-renderer-scrollback.md. this.maybeHydrateHeadlessFromRenderer(ptyId) - this.trackHeadlessTerminalData(ptyId, data, outputSequence) + this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies) let normalizedData: string | null = null const getNormalizedData = (): string => { @@ -3884,6 +3912,37 @@ export class OrcaRuntimeService { } } + /** Registered by terminal-RPC subscribe/multiplex streams: while a remote + * view subscriber is attached its xterm answers queries with view + * authority and the model responder must stay silent. Returns an + * idempotent release. */ + registerRemoteTerminalViewSubscriber(ptyId: string): () => void { + this.remoteTerminalViewSubscriberCounts.set( + ptyId, + (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.remoteTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.remoteTerminalViewSubscriberCounts.set(ptyId, next) + } + } + } + + hasRemoteTerminalViewSubscriber(ptyId: string): boolean { + if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { + return true + } + return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 + } + subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void @@ -4004,14 +4063,12 @@ export class OrcaRuntimeService { return } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) state.writeChain = state.writeChain .then(async () => { + // Why: seed writes never set forwardQueryReplies — the main-side + // replay guard. A snapshot containing old queries must answer no one. await state.emulator.write(data) if (metadata.cwd !== undefined) { state.emulator.setCwd(metadata.cwd) @@ -4050,11 +4107,9 @@ export class OrcaRuntimeService { this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + // Why: hydration writes below never set forwardQueryReplies (main-side + // replay guard) — renderer-buffer snapshots can embed stale queries. + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) // Why: append the seed work to writeChain so live writes queued by @@ -4127,11 +4182,28 @@ export class OrcaRuntimeService { } } - private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void { + /** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at + * ingestion only — never re-read at reply time. */ + private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean { + return shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: this.store?.getSettings(), + hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId) + }) + } + + private trackHeadlessTerminalData( + ptyId: string, + data: string, + outputSequence: number, + forwardQueryReplies = false + ): void { const state = this.getOrCreateHeadlessTerminal(ptyId) state.writeChain = state.writeChain .then(async () => { - await state.emulator.write(data) + // Why: the ingestion-time ownership decision is closed over this + // chain link; async scheduling cannot retroactively change it. + await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) .catch(() => { @@ -4140,17 +4212,53 @@ export class OrcaRuntimeService { }) } + /** Shared factory for the per-PTY runtime emulators (seed, hydration, and + * lazy live-byte creation): wires the Phase-5 query-reply sink and the + * ConPTY DA1 override. The daemon emulator never goes through here. */ + private createPtyHeadlessTerminalState( + ptyId: string, + dims: { cols: number; rows: number } + ): RuntimeHeadlessTerminal { + let state: RuntimeHeadlessTerminal | null = null + const emulator = new HeadlessEmulator({ + cols: dims.cols, + rows: dims.rows, + // Why: replies take the provider input path (same entry as pty:write — + // daemon shell-ready gating and the SSH relay write apply unchanged), + // NOT writePtyInput, so renderer interactive-output metering never + // counts responder traffic as user-input echo. + onQueryReply: (reply) => { + // Why the identity check: queued writeChain links can parse after + // disposeHeadlessTerminal, and daemon respawns reuse session ids — a + // stale link's reply must never reach a successor PTY under this id. + if (state !== null && this.headlessTerminals.get(ptyId) === state) { + this.ptyController?.write(ptyId, reply) + } + } + }) + if (isNativeWindowsConptyPty(ptyId)) { + emulator.installConptyPrimaryDeviceAttributesOverride() + } + state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + return state + } + + /** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via + * markNativeWindowsConptyPty when the spawn mark lands after daemon stream + * data already created this PTY's emulator. Idempotent emulator-side. */ + private ensureNativeWindowsConptyDa1Override(ptyId: string): void { + if (isNativeWindowsConptyPty(ptyId)) { + this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride() + } + } + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { const existing = this.headlessTerminals.get(ptyId) if (existing) { return existing } const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, size) this.headlessTerminals.set(ptyId, state) return state } @@ -4315,6 +4423,10 @@ export class OrcaRuntimeService { return } this.headlessTerminals.delete(ptyId) + // Why: queued chain links still parse below before the emulator disposes; + // sever the reply sink now so they cannot write to a respawned PTY that + // reused this id (belt to the sink's state-identity check). + state.emulator.disableQueryReplyForwarding() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } @@ -5013,6 +5125,7 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) + this.remoteTerminalViewSubscriberCounts.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 14dafed8441..70417b11996 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1319,6 +1319,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ emit({ type: 'end', streamId: request.streamId }) return } + if (closed) { + return + } + // Why: a competing subscribe for the same streamId can fully register + // while this one awaited the PTY id above. Overwriting it in + // `streams` would orphan its data/view-subscriber registrations — a + // leaked view subscriber permanently silences the model query + // responder (terminal-query-authority.md). Detach it so every + // registration stays release-balanced. + detachStream(request.streamId, false) const ptyId = leaf.ptyId const stream: TerminalMultiplexStream = { @@ -1354,7 +1364,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) try { - stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed || streams.get(request.streamId) !== stream) { return } @@ -1364,6 +1374,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.push(data, meta) }) + // Why: a multiplexed stream feeds a remote xterm view that answers + // terminal queries with view authority; the main model responder + // yields while it is attached (terminal-query-authority.md). + // Wrapped into unsubscribeData so every detach path releases it. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + stream.unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } if (isMobile && request.client?.id) { await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) @@ -1479,6 +1498,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } }) } catch (error) { + // Why the ownership check: a newer subscribe may own this streamId + // now (it detached and released this stream on arrival). Detaching + // or erroring the slot here would tear down the successor's live + // registrations instead of this stream's. + if (streams.get(request.streamId) !== stream) { + return + } detachStream(request.streamId, false) sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) emit({ type: 'end', streamId: request.streamId }) @@ -1578,9 +1604,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const outputBatcher = createTerminalOutputBatcher((chunk) => { emit({ type: 'data', chunk }) }) - const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { outputBatcher.push(data) }) + // Why: this legacy JSON stream can feed a live xterm view too + // (older web/desktop subscribers), so it conservatively registers + // as a remote view subscriber. For read-only watchers the cost is + // a withheld model reply — the pre-Phase-5 status quo — which is + // strictly safer than a double reply under a view consumer. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + const unsubscribeData = (): void => { + releaseViewSubscriber() + unsubscribeStreamData() + } const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { outputBatcher.flush() emit({ @@ -1718,7 +1754,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed) { return } @@ -1732,6 +1768,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } outputBatcher?.push(data, meta) }) + // Why: binary subscribe streams feed remote xterm views (mobile and + // binary-capable desktop clients) that answer queries with view + // authority; the main model responder yields while attached. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } const read = await runtime.readTerminal(params.terminal) const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index abc9ab20372..9cdc88a021b 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 3234bf4e86f..c8a9c6de867 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -18,6 +18,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: every multiplex stream registers as a remote view subscriber for + // Phase-5 query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } @@ -1673,6 +1676,226 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + // Why: a leaked registration permanently suppresses the model query + // responder (terminal-query-authority.md) — the count must return to 0. + let viewSubscriberCount = 0 + let leafResolved = false + let resolveFirstWait: (ptyId: string) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn(() => (leafResolved ? { ptyId: 'pty-1' } : { ptyId: null })), + waitForLeafPtyId: vi.fn( + () => + new Promise((resolve) => { + resolveFirstWait = resolve + }) + ), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-overwrite', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // Subscribe A blocks in waitForLeafPtyId; subscribe B (same streamId) + // then resolves the leaf directly and fully registers. + sendSubscribe() + await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled()) + leafResolved = true + sendSubscribe() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + + // A resumes and takes the slot; B's registration must be released, not + // orphaned by the overwrite. + resolveFirstWait('pty-1') + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 2 + ) + ) + + handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 7, + seq: 2, + payload: new Uint8Array() + }) + )! + ) + expect(viewSubscriberCount).toBe(0) + + cleanups.get('terminal-multiplex:conn-overwrite')?.() + await dispatchPromise + }) + + it('keeps an evicted subscribe error from detaching the successor stream', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + let viewSubscriberCount = 0 + const mobileSubscribeWaiters: { + resolve: () => void + reject: (error: Error) => void + }[] = [] + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve, reject) => { + mobileSubscribeWaiters.push({ resolve: () => resolve(true), reject }) + }) + ), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-evicted-error', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // A registers, then blocks in handleMobileSubscribe. B (same streamId) + // evicts A on arrival and completes its own registration. + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(1)) + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(2)) + mobileSubscribeWaiters[1]!.resolve() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + expect(viewSubscriberCount).toBe(1) + + // A's pending await now rejects. The evicted stream must not detach the + // successor that owns the slot. + mobileSubscribeWaiters[0]!.reject(new Error('mobile_subscribe_failed')) + await Promise.resolve() + await Promise.resolve() + expect(viewSubscriberCount).toBe(1) + + cleanups.get('terminal-multiplex:conn-evicted-error')?.() + await dispatchPromise + expect(viewSubscriberCount).toBe(0) + }) + it('bounds live output queued while a multiplex snapshot is loading', async () => { vi.useFakeTimers() try { diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index 11e2b7476c8..c5c135d1c6b 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -15,6 +15,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 789317d6554..9cd0deec065 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -15,6 +15,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/terminal-model-query-authority.test.ts b/src/main/runtime/terminal-model-query-authority.test.ts new file mode 100644 index 00000000000..3217a98022e --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetTerminalModelQueryAuthorityForTest, + clearNativeWindowsConptyPty, + isNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + isTerminalModelQueryAuthorityEnabled, + markNativeWindowsConptyPty, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest +} from '../ipc/pty-hidden-delivery-gate' + +const ALL_ON = { + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true +} + +afterEach(() => { + _resetTerminalModelQueryAuthorityForTest() + _resetHiddenRendererPtyDeliveryGateForTest() +}) + +describe('isTerminalModelQueryAuthorityEnabled', () => { + it('defaults on, including for absent settings', () => { + expect(isTerminalModelQueryAuthorityEnabled(ALL_ON)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled({})).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(null)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(undefined)).toBe(true) + }) + + it('is an independent off switch for the responder alone', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalModelQueryAuthority: false }) + ).toBe(false) + }) + + it('requires both Phase-4 gate switches — no marks exist without them', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalHiddenDeliveryGate: false }) + ).toBe(false) + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalMainSideEffectAuthority: false }) + ).toBe(false) + }) +}) + +describe('shouldModelAnswerHiddenPtyQueries', () => { + const answer = (ptyId: string, overrides: Record = {}): boolean => + shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: { ...ALL_ON, ...overrides }, + hasRemoteViewSubscriber: false + }) + + it('answers only for hidden-marked PTYs (the delivery decision is the reply decision)', () => { + expect(answer('pty-1')).toBe(false) + markHiddenRendererPty('pty-1') + expect(answer('pty-1')).toBe(true) + expect(answer('pty-other')).toBe(false) + }) + + it('yields to registered renderer delivery interest (chunk is delivered to a sidecar)', () => { + markHiddenRendererPty('pty-1') + setRendererPtyDeliveryInterest('pty-1', true) + expect(answer('pty-1')).toBe(false) + setRendererPtyDeliveryInterest('pty-1', false) + expect(answer('pty-1')).toBe(true) + }) + + it('yields while a remote view subscriber is attached', () => { + markHiddenRendererPty('pty-1') + expect( + shouldModelAnswerHiddenPtyQueries({ + ptyId: 'pty-1', + settings: ALL_ON, + hasRemoteViewSubscriber: true + }) + ).toBe(false) + }) + + it('stays silent under any kill switch', () => { + markHiddenRendererPty('pty-1') + expect(answer('pty-1', { terminalModelQueryAuthority: false })).toBe(false) + expect(answer('pty-1', { terminalHiddenDeliveryGate: false })).toBe(false) + expect(answer('pty-1', { terminalMainSideEffectAuthority: false })).toBe(false) + }) +}) + +describe('isNativeWindowsLocalPtySpawn (main-side mirror of isLocalNativeWindowsPty)', () => { + const base = { + connectionId: null, + cwd: 'C:\\repo', + shellOverride: undefined, + platform: 'win32' as NodeJS.Platform + } + + it('matches local native Windows spawns', () => { + expect(isNativeWindowsLocalPtySpawn(base)).toBe(true) + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: undefined })).toBe(true) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Tools\\powershell.exe' }) + ).toBe(true) + }) + + it('rejects non-Windows hosts', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'darwin' })).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'linux' })).toBe(false) + }) + + it('rejects SSH-backed spawns', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: 'ssh-1' })).toBe(false) + }) + + it('rejects WSL cwds and WSL shell overrides', () => { + expect( + isNativeWindowsLocalPtySpawn({ ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl.exe' })).toBe(false) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Windows\\System32\\wsl.exe' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl' })).toBe(false) + }) +}) + +describe('native-Windows ConPTY spawn record', () => { + it('marks, reads, and clears per PTY', () => { + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + markNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(true) + expect(isNativeWindowsConptyPty('pty-2')).toBe(false) + clearNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts new file mode 100644 index 00000000000..e1e0f9c28ef --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -0,0 +1,111 @@ +/** + * Phase 5 of the terminal model/view architecture: main-side terminal query + * authority (docs/reference/terminal-query-authority.md). + * + * The delivery decision is the reply decision: main answers a query iff the + * hidden-delivery gate dropped the chunk that carried it. This module owns + * the responder kill-switch predicate and the main-side mirror of the + * renderer's native-Windows-ConPTY determination, recorded per PTY at spawn + * so the runtime emulator can register the DA1 override before byte zero. + */ +import type { GlobalSettings } from '../../shared/types' +import { isWslUncPath } from '../../shared/wsl-paths' +import { + isHiddenPtyDeliveryGateEnabled, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' + +export type TerminalModelQueryAuthoritySettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' | 'terminalModelQueryAuthority' +> + +/** Responder kill switch: requires BOTH Phase-4 gate switches (no marks/drops + * exist without them) plus the Phase-5-specific independent off switch. */ +export function isTerminalModelQueryAuthorityEnabled( + settings: TerminalModelQueryAuthoritySettings | null | undefined +): boolean { + return isHiddenPtyDeliveryGateEnabled(settings) && settings?.terminalModelQueryAuthority !== false +} + +/** Per-chunk reply-ownership predicate, evaluated once at ingestion in + * OrcaRuntimeService.onPtyData — the same module state and tick as the + * hidden-gate drop sites, so "chunk dropped" and "main answers" cannot + * diverge for live chunks. Remote view subscribers (mobile/web/remote + * desktop xterms on the multiplexed stream) keep view authority, so main + * yields while one is attached. */ +export function shouldModelAnswerHiddenPtyQueries(opts: { + ptyId: string + settings: TerminalModelQueryAuthoritySettings | null | undefined + hasRemoteViewSubscriber: boolean +}): boolean { + return ( + isTerminalModelQueryAuthorityEnabled(opts.settings) && + !opts.hasRemoteViewSubscriber && + shouldDropHiddenRendererPtyData(opts.ptyId, opts.settings) + ) +} + +/** Main-side mirror of the renderer's isLocalNativeWindowsPty + * (windows-pty-compatibility.ts), computed from spawn-time facts: local or + * daemon provider (no SSH connection), win32 host, and not a WSL shell. */ +export function isNativeWindowsLocalPtySpawn(opts: { + connectionId: string | null | undefined + cwd: string | null | undefined + shellOverride: string | null | undefined + platform?: NodeJS.Platform +}): boolean { + if ((opts.platform ?? process.platform) !== 'win32') { + return false + } + if (opts.connectionId) { + return false + } + if (isWslUncPath(opts.cwd ?? '')) { + return false + } + if (/(?:^|[/\\])wsl(?:\.exe)?$/i.test(opts.shellOverride ?? '')) { + return false + } + return true +} + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts records +// the determination at spawn, the runtime consults it at emulator creation. +// Daemon-adopted PTYs from a previous app run carry no mark — acceptable: +// ConPTY's blocking DA1 only fires at spawn, which happened in a prior life. +const nativeWindowsConptyPtys = new Set() + +// Why installers: the mark lands after the awaited spawn response, but daemon +// stream data (warm-reattach flush) can lazy-create the runtime emulator +// first. The runtime registers an installer so marking retrofits the DA1 +// override onto an existing emulator; installation is idempotent emulator-side. +type ConptyDa1OverrideInstaller = (ptyId: string) => void +const conptyDa1OverrideInstallers = new Set() + +export function registerConptyDa1OverrideInstaller(installer: ConptyDa1OverrideInstaller): void { + conptyDa1OverrideInstallers.add(installer) +} + +export function markNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.add(id) + for (const installer of conptyDa1OverrideInstallers) { + installer(id) + } +} + +export function isNativeWindowsConptyPty(id: string): boolean { + return nativeWindowsConptyPtys.has(id) +} + +/** Wired into clearProviderPtyState so every PTY teardown path releases the + * spawn record. */ +export function clearNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.delete(id) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalModelQueryAuthorityForTest(): void { + nativeWindowsConptyPtys.clear() + conptyDa1OverrideInstallers.clear() +} diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts new file mode 100644 index 00000000000..a2ab1fc96a2 --- /dev/null +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -0,0 +1,447 @@ +/** + * Phase 5 model query responder (docs/reference/terminal-query-authority.md): + * reply parity through the runtime emulator, the per-chunk ownership matrix, + * the main-side replay guard, and the ingestion-time capture race. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest, + unmarkHiddenRendererPty +} from '../ipc/pty-hidden-delivery-gate' +import { + _resetTerminalModelQueryAuthorityForTest, + markNativeWindowsConptyPty +} from './terminal-model-query-authority' + +const settingsState = { + terminalMainSideEffectAuthority: true as boolean, + terminalHiddenDeliveryGate: true as boolean, + terminalModelQueryAuthority: true as boolean +} + +const store = { + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: settingsState.terminalMainSideEffectAuthority, + terminalHiddenDeliveryGate: settingsState.terminalHiddenDeliveryGate, + terminalModelQueryAuthority: settingsState.terminalModelQueryAuthority + }) +} + +type RendererBufferStub = { data: string; cols: number; rows: number } + +function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { + const runtime = new OrcaRuntimeService(store) + const replies: { ptyId: string; data: string }[] = [] + runtime.setPtyController({ + write: (ptyId, data) => { + replies.push({ ptyId, data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + resize: () => true, + ...(opts.rendererBuffer + ? { + hasRendererSerializer: () => true, + serializeBuffer: async () => opts.rendererBuffer ?? null + } + : {}) + }) + return { runtime, replies } +} + +/** Awaits the per-PTY emulator writeChain so queued chunk links (and the + * replies they forward) have settled. */ +async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise { + await runtime.serializeMainTerminalBuffer(ptyId) +} + +afterEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + _resetTerminalModelQueryAuthorityForTest() + settingsState.terminalMainSideEffectAuthority = true + settingsState.terminalHiddenDeliveryGate = true + settingsState.terminalModelQueryAuthority = true +}) + +describe('reply parity for hidden-dropped chunks', () => { + // Expected replies pinned from the design doc and verified against the + // bundled @xterm/headless build — the same core the renderer runs, so + // parity is structural for static and model-state classes. + it.each([ + ['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']], + ['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']], + ['DA2', '\x1b[>c', ['\x1b[>0;276;0c']], + ['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']], + ['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']], + ['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']], + ['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']], + ['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']], + ['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']], + ['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']], + ['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']], + ['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']], + ['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']], + ['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']], + ['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']], + ['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']], + ['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']], + ['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']], + ['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']], + ['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']], + ['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']], + ['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']], + ['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']], + ['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']], + ['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']], + ['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']], + ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], + ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + }) + + it.each([ + ['XTWINOPS', '\x1b[14t'], + ['XTGETTCAP', '\x1bP+q544e\x1b\\'], + ['DSR ?15n printer status', '\x1b[?15n'], + ['DSR ?25n UDK status', '\x1b[?25n'], + ['DSR ?26n keyboard status', '\x1b[?26n'], + ['DSR ?53n locator status', '\x1b[?53n'], + // View-attribute class: silent until the slice-2 renderer attribute push + // — a fabricated default would resurrect the default-black OSC-11 bug. + ['OSC 10 foreground query', '\x1b]10;?\x07'], + ['OSC 11 background query', '\x1b]11;?\x07'], + ['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'], + ['OSC 4 palette query', '\x1b]4;1;?\x07'], + ['DSR ?996n color-scheme query', '\x1b[?996n'] + ])('stays silent for %s', async (_label, chunk) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies).toEqual([]) + }) +}) + +describe('reply ownership matrix', () => { + const DA1 = '\x1b[c' + + it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-v', DA1, Date.now()) + await settle(runtime, 'pty-v') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-i') + setRendererPtyDeliveryInterest('pty-i', true) + + runtime.onPtyData('pty-i', DA1, Date.now()) + await settle(runtime, 'pty-i') + + expect(replies).toEqual([]) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-k') + + runtime.onPtyData('pty-k', DA1, Date.now()) + await settle(runtime, 'pty-k') + + expect(replies).toEqual([]) + }) + + it('yields while a remote view subscriber is attached and resumes on release', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-r') + const release = runtime.registerRemoteTerminalViewSubscriber('pty-r') + + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies).toEqual([]) + + release() + // Releases are idempotent: a double release must not unbalance the count. + release() + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('counts overlapping remote view subscribers', () => { + const { runtime } = createResponderRuntime() + const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-m') + const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-m') + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseA() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseB() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false) + }) + + it('treats mobile subscriber records as remote view subscribers', async () => { + const { runtime } = createResponderRuntime() + await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 }) + expect(runtime.hasRemoteTerminalViewSubscriber('pty-mob')).toBe(true) + }) + + it('answers a dropped-chunk query exactly once', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-once') + + runtime.onPtyData('pty-once', DA1, Date.now()) + await settle(runtime, 'pty-once') + + expect(replies).toHaveLength(1) + }) +}) + +describe('main-side replay guard', () => { + const DA1 = '\x1b[c' + + it('never answers queries embedded in a seeded snapshot, then answers live bytes', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seed') + + runtime.seedHeadlessTerminal('pty-seed', `restored prompt${DA1}`) + await settle(runtime, 'pty-seed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seed', DA1, Date.now()) + await settle(runtime, 'pty-seed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('never answers queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: `restored screen${DA1}`, cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-hyd') + + // First live byte triggers maybeHydrateHeadlessFromRenderer; the hydration + // seed parses the embedded DA1 but must not forward its reply. + runtime.onPtyData('pty-hyd', 'live output', Date.now()) + await settle(runtime, 'pty-hyd') + + expect(replies).toEqual([]) + }) +}) + +describe('ingestion-time ownership capture', () => { + const DA1 = '\x1b[c' + + it('still answers when the hidden mark flips off between ingestion and the async write', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-race') + + runtime.onPtyData('pty-race', DA1, Date.now()) + // Flip before the queued writeChain link runs: the captured decision wins. + unmarkHiddenRendererPty('pty-race') + await settle(runtime, 'pty-race') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('stays silent when the hidden mark lands after ingestion', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-race2', DA1, Date.now()) + markHiddenRendererPty('pty-race2') + await settle(runtime, 'pty-race2') + + expect(replies).toEqual([]) + }) +}) + +describe('stale writeChain links after dispose', () => { + const DA1 = '\x1b[c' + + it('never forwards a queued reply once the PTY state is disposed', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-stale') + + // Queue a forward-flagged chain link, then dispose before it runs. + runtime.onPtyData('pty-stale', DA1, Date.now()) + runtime.onPtyExit('pty-stale', 0) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(replies).toEqual([]) + }) + + it('never injects a stale reply into a successor PTY reusing the session id', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-reuse') + + // Daemon respawns reuse session ids: dispose with the flagged link still + // queued, then re-create the same id before the link runs. + runtime.onPtyData('pty-reuse', DA1, Date.now()) + runtime.onPtyExit('pty-reuse', 0) + runtime.onPtyData('pty-reuse', 'fresh shell banner', Date.now()) + await settle(runtime, 'pty-reuse') + + expect(replies).toEqual([]) + }) +}) + +describe('ConPTY DA1 override', () => { + it('retrofits the override when the spawn mark lands after data created the emulator', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-win-late') + + // Daemon warm-reattach flush: stream data creates the emulator before + // the awaited spawn response marks the PTY native-Windows. + runtime.onPtyData('pty-win-late', 'warm reattach flush', Date.now()) + markNativeWindowsConptyPty('pty-win-late') + + runtime.onPtyData('pty-win-late', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-late') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('keeps the override single-reply when installed at creation and marked again', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win-idem') + markHiddenRendererPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', 'boot output', Date.now()) + // A duplicate mark (e.g. respawn against a live emulator) must not stack + // a second handler that double-replies. + markNativeWindowsConptyPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('answers CSI ?61;4c for marked native-Windows PTYs, suppressing the core ?1;2c', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win') + markHiddenRendererPty('pty-win') + + runtime.onPtyData('pty-win', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('lets non-primary device-attribute queries fall through to the core', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win2') + markHiddenRendererPty('pty-win2') + + runtime.onPtyData('pty-win2', '\x1b[>c', Date.now()) + await settle(runtime, 'pty-win2') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c']) + }) + + it('keeps the override silent for delivered chunks', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win3') + + runtime.onPtyData('pty-win3', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win3') + + expect(replies).toEqual([]) + }) +}) + +describe('HeadlessEmulator forwarding window', () => { + it('forwards replies only for writes flagged forwardQueryReplies', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + try { + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c') + } finally { + emulator.dispose() + } + }) + + it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + // Force the async write path (xterm deprecates writeSync; the fallback + // must stay structurally safe without writeChain serialization). + const internals = emulator as unknown as { terminal: { _core: { writeSync?: unknown } } } + internals.terminal._core.writeSync = undefined + try { + // Enqueue an unflagged seed carrying a query, then a flagged live + // chunk, WITHOUT awaiting between them: both sit in xterm's write + // queue together. The seed parse must not see an open window. + const seed = emulator.write('seeded\x1b[c') + const live = emulator.write('\x1b[5n', { forwardQueryReplies: true }) + await Promise.all([seed, live]) + + expect(onQueryReply.mock.calls.map((call) => call[0])).toEqual(['\x1b[0n']) + } finally { + emulator.dispose() + } + }) + + it('keeps the ConPTY override inside the forwarding window', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + emulator.installConptyPrimaryDeviceAttributesOverride() + try { + // Unflagged (replayed/seeded) DA1 must answer no one even with the + // override installed. + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?61;4c') + } finally { + emulator.dispose() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 10c52a8e2a4..6f87a319a98 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -47,7 +47,10 @@ import { waitForTerminalOutputParsed, writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { isLocalNativeWindowsPty } from '@/lib/pane-manager/windows-pty-compatibility' +import { + isLocalNativeWindowsPty, + resolveWindowsShellOverride +} from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' import { makePaneKey } from '../../../../shared/stable-pane-id' @@ -1511,7 +1514,10 @@ export function connectPanePty( userAgent: navigator.userAgent, connectionId, cwd: deps.cwd, - shellOverride + // Why: main folds the global Windows shell into its spawn classification + // (pty.ts effectiveShellOverride); fold it here too so both sides treat + // a global-WSL default identically (terminal-query-authority.md ConPTY). + shellOverride: resolveWindowsShellOverride(shellOverride, state.settings?.terminalWindowsShell) }) const shouldApplyNativeWindowsRewriteRefresh = isNativeWindowsConpty const shouldProtectNativeWindowsSynchronizedOutput = isNativeWindowsConpty diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index 74eda75c833..0a6653711aa 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { buildWindowsPtyCompatibilityOptions, - isLocalNativeWindowsPty + isLocalNativeWindowsPty, + resolveWindowsShellOverride } from './windows-pty-compatibility' describe('buildWindowsPtyCompatibilityOptions', () => { @@ -88,6 +89,39 @@ describe('buildWindowsPtyCompatibilityOptions', () => { ).toEqual({}) }) + it('classifies a global-WSL default shell as non-native, matching main', () => { + // Why: main folds the global terminalWindowsShell into its spawn + // classification (isNativeWindowsLocalPtySpawn). Without the fold the + // renderer would call a tab with no override native-ConPTY while main + // never marks it. + const windowsUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride(undefined, 'wsl.exe') + }) + ).toBe(false) + // A tab-level override beats the global setting, both directions. + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('powershell.exe', 'wsl.exe') + }) + ).toBe(true) + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('wsl.exe', 'powershell.exe') + }) + ).toBe(false) + }) + it('exposes the same local native Windows predicate for related renderer workarounds', () => { expect( isLocalNativeWindowsPty({ diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 399b60ba42e..6e8f88a9ada 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -45,6 +45,17 @@ export function buildWindowsPtyCompatibilityOptions( } } +/** Mirror of main's effectiveShellOverride fold (pty.ts spawn handlers): a + * tab-level shell override wins, else the global Windows shell setting + * applies — so renderer and main classify a global-WSL default identically + * (the main-side twin is isNativeWindowsLocalPtySpawn). */ +export function resolveWindowsShellOverride( + tabShellOverride: string | null | undefined, + globalWindowsShell: string | null | undefined +): string | undefined { + return tabShellOverride ?? globalWindowsShell ?? undefined +} + export function isLocalNativeWindowsPty(context: WindowsPtyCompatibilityContext): boolean { if (!isWindowsUserAgent(context.userAgent)) { return false diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7edbed12fa3..e4001056ab6 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -276,6 +276,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalHiddenViewParking: true, terminalMainSideEffectAuthority: true, terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/shared/types.ts b/src/shared/types.ts index 5f96bf48a7d..83a07ee5a3d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2207,6 +2207,12 @@ export type GlobalSettings = { * delivery to hidden renderer views after model ingestion; reveal restores * from the model snapshot. `false` restores hidden byte delivery. */ terminalHiddenDeliveryGate?: boolean + /** Kill switch for the main model query responder (Phase 5): when true + * (default) AND both Phase-4 gate switches are on, main answers terminal + * queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the + * runtime emulator. `false` silences the responder without changing drops. + * See docs/reference/terminal-query-authority.md. */ + terminalModelQueryAuthority?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) From ee540f32de2d88c8deb4862bf3cac6a7a80a4ca8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:57:14 -0700 Subject: [PATCH 46/62] Bridge renderer view attributes to the model responder Co-authored-by: Orca --- src/main/daemon/headless-emulator.ts | 124 +++---- .../daemon/terminal-osc-cwd-title-scanner.ts | 78 ++++ .../terminal-view-attribute-responder.ts | 191 ++++++++++ src/main/ipc/pty.ts | 13 + src/main/runtime/orca-runtime.ts | 20 + .../runtime/terminal-query-responder.test.ts | 349 ++++++++++++++++++ .../runtime/terminal-view-attribute-store.ts | 57 +++ src/preload/api-types.ts | 4 + src/preload/index.ts | 7 + .../terminal-pane/terminal-appearance.test.ts | 66 +++- .../terminal-pane/terminal-appearance.ts | 41 +- ...terminal-view-attributes-publisher.test.ts | 249 +++++++++++++ .../terminal-view-attributes-publisher.ts | 247 +++++++++++++ src/renderer/src/web/web-preload-api.ts | 3 + src/shared/terminal-view-attributes.test.ts | 119 ++++++ src/shared/terminal-view-attributes.ts | 188 ++++++++++ 16 files changed, 1677 insertions(+), 79 deletions(-) create mode 100644 src/main/daemon/terminal-osc-cwd-title-scanner.ts create mode 100644 src/main/daemon/terminal-view-attribute-responder.ts create mode 100644 src/main/runtime/terminal-view-attribute-store.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts create mode 100644 src/shared/terminal-view-attributes.test.ts create mode 100644 src/shared/terminal-view-attributes.ts diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 133e6a700f4..984939cd499 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -1,8 +1,13 @@ import './xterm-env-polyfill' import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' -import { extractLastOscTitle } from '../../shared/agent-detection' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' +import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { + installTerminalViewAttributeResponder, + type TerminalViewAttributeResponder +} from './terminal-view-attribute-responder' import type { TerminalSnapshot, TerminalModes } from './types' export type HeadlessEmulatorOptions = { @@ -45,46 +50,16 @@ const DEFAULT_SCROLLBACK = 5000 // Keep in sync with the renderer twin in terminal-conpty-device-attributes.ts // (main must not import renderer modules). const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' -const OSC_SCAN_TAIL_LIMIT = 4096 - -function parseFileUriPath(uri: string): string | null { - try { - const url = new URL(uri) - if (url.protocol !== 'file:') { - return null - } - - const decodedPath = decodeURIComponent(url.pathname) - if (process.platform !== 'win32') { - return decodedPath - } - - // Why: Windows OSC-7 cwd updates can describe both drive-letter paths - // (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the - // hostname when present so live cwd tracking, snapshots, and restore all - // round-trip to a native Windows path instead of dropping the server name. - if (url.hostname) { - return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` - } - if (/^\/[A-Za-z]:/.test(decodedPath)) { - return decodedPath.slice(1) - } - return decodedPath.replace(/\//g, '\\') - } catch { - return null - } -} export class HeadlessEmulator { private terminal: Terminal private serializer: SerializeAddon - private cwd: string | null = null - private lastTitle: string | null = null - private oscScanTail = '' + private oscText = new TerminalOscCwdTitleScanner() private mouseModes = new TerminalMouseModeMirror() private disposed = false private onQueryReply: ((reply: string) => void) | null private conptyDa1OverrideInstalled = false + private viewAttributeResponder: TerminalViewAttributeResponder | null = null // Why: replies must be scoped to the exact write that carried the query. // The window opens around the parse of a forward-flagged chunk and closes // with it, so seeds/snapshots and unsolicited core emissions (e.g. native @@ -149,6 +124,39 @@ export class HeadlessEmulator { }) } + /** Phase-5 slice-2 view-attribute bridge: the headless core has no theme + * service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the + * renderer's pushed attributes via these parser handlers — never from + * emulator defaults. Runtime-only, like onQueryReply: the daemon Session + * must NEVER call this (its emulator stays write-only forever). */ + installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void { + if (this.viewAttributeResponder) { + return + } + this.viewAttributeResponder = installTerminalViewAttributeResponder({ + parser: this.terminal.parser, + getBaseAttributes, + // emitQueryReply keeps replies inside the per-chunk forwarding window, + // so seeded/replayed view-attribute queries answer no one. + emitReply: (reply) => this.emitQueryReply(reply) + }) + } + + /** Applies a renderer view-attribute push: cursor options make xterm core + * answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC + * color overrides are dropped because a theme apply overwrites mutated + * colors on visible panes too (ThemeService._setTheme parity). Option + * writes happen outside any forwarding window, so any core emission they + * trigger is discarded (main-side replay guard). */ + applyPushedViewAttributes(attributes: TerminalViewAttributes): void { + if (this.disposed) { + return + } + this.terminal.options.cursorStyle = attributes.cursorStyle + this.terminal.options.cursorBlink = attributes.cursorBlink + this.viewAttributeResponder?.clearColorOverrides() + } + private emitQueryReply(reply: string): void { if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { this.onQueryReply(reply) @@ -167,13 +175,7 @@ export class HeadlessEmulator { return Promise.resolve() } - const oscInput = this.oscScanTail + data - this.oscScanTail = this.extractOscScanTail(oscInput) - this.scanOsc7(oscInput) - const lastTitle = extractLastOscTitle(oscInput) - if (lastTitle !== null) { - this.lastTitle = lastTitle - } + this.oscText.scan(data) const forwardQueryReplies = opts.forwardQueryReplies === true const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync if (typeof writeSync === 'function') { @@ -233,12 +235,12 @@ export class HeadlessEmulator { snapshotAnsi, scrollbackAnsi: '', rehydrateSequences: this.buildRehydrateSequences(modes), - cwd: this.cwd, + cwd: this.oscText.cwd, modes, cols: this.terminal.cols, rows: this.terminal.rows, scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows, - lastTitle: this.lastTitle ?? undefined + lastTitle: this.oscText.lastTitle ?? undefined } } @@ -256,15 +258,15 @@ export class HeadlessEmulator { } getCwd(): string | null { - return this.cwd + return this.oscText.cwd } setCwd(cwd: string | null): void { - this.cwd = cwd + this.oscText.cwd = cwd } setLastTitle(title: string): void { - this.lastTitle = title + this.oscText.lastTitle = title } clearScrollback(): void { @@ -276,31 +278,6 @@ export class HeadlessEmulator { this.terminal.dispose() } - private scanOsc7(data: string): void { - // OSC-7 format: ESC ] 7 ; BEL or ESC ] 7 ; ST - // BEL = \x07, ST = ESC \ - // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars - const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g - let match: RegExpExecArray | null - while ((match = osc7Re.exec(data)) !== null) { - this.parseOsc7Uri(match[1]) - } - } - - private extractOscScanTail(input: string): string { - const lastOsc = input.lastIndexOf('\x1b]') - const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 - const start = Math.max(lastOsc, lastEscape) - if (start === -1) { - return '' - } - const suffix = input.slice(start) - if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { - return '' - } - return suffix.slice(-OSC_SCAN_TAIL_LIMIT) - } - private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { if (!modes.alternateScreen) { return snapshotAnsi @@ -316,13 +293,6 @@ export class HeadlessEmulator { return snapshotAnsi.slice(start + alternateScreenMarker.length) } - private parseOsc7Uri(uri: string): void { - const parsed = parseFileUriPath(uri) - if (parsed) { - this.cwd = parsed - } - } - private getModes(): TerminalModes { const buffer = this.terminal.buffer.active const mouseTrackingMode = this.mouseModes.mouseTrackingMode diff --git a/src/main/daemon/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts new file mode 100644 index 00000000000..fb8800c60cc --- /dev/null +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -0,0 +1,78 @@ +import { extractLastOscTitle } from '../../shared/agent-detection' + +const OSC_SCAN_TAIL_LIMIT = 4096 + +function parseFileUriPath(uri: string): string | null { + try { + const url = new URL(uri) + if (url.protocol !== 'file:') { + return null + } + + const decodedPath = decodeURIComponent(url.pathname) + if (process.platform !== 'win32') { + return decodedPath + } + + // Why: Windows OSC-7 cwd updates can describe both drive-letter paths + // (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the + // hostname when present so live cwd tracking, snapshots, and restore all + // round-trip to a native Windows path instead of dropping the server name. + if (url.hostname) { + return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` + } + if (/^\/[A-Za-z]:/.test(decodedPath)) { + return decodedPath.slice(1) + } + return decodedPath.replace(/\//g, '\\') + } catch { + return null + } +} + +function extractOscScanTail(input: string): string { + const lastOsc = input.lastIndexOf('\x1b]') + const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 + const start = Math.max(lastOsc, lastEscape) + if (start === -1) { + return '' + } + const suffix = input.slice(start) + if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { + return '' + } + return suffix.slice(-OSC_SCAN_TAIL_LIMIT) +} + +/** Regex-side mirror of the OSC sequences the emulator tracks outside xterm: + * OSC 7 cwd updates and OSC 0/2 titles. Keeps an unterminated-sequence tail + * so sequences split across PTY chunks still parse. */ +export class TerminalOscCwdTitleScanner { + private scanTail = '' + cwd: string | null = null + lastTitle: string | null = null + + scan(data: string): void { + const input = this.scanTail + data + this.scanTail = extractOscScanTail(input) + this.scanOsc7(input) + const lastTitle = extractLastOscTitle(input) + if (lastTitle !== null) { + this.lastTitle = lastTitle + } + } + + private scanOsc7(data: string): void { + // OSC-7 format: ESC ] 7 ; BEL or ESC ] 7 ; ST + // BEL = \x07, ST = ESC \ + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g + let match: RegExpExecArray | null + while ((match = osc7Re.exec(data)) !== null) { + const parsed = parseFileUriPath(match[1]) + if (parsed) { + this.cwd = parsed + } + } + } +} diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts new file mode 100644 index 00000000000..551ffddbe21 --- /dev/null +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -0,0 +1,191 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime + * headless emulator. The headless xterm core has no theme service, so these + * handlers compute replies from the renderer's pushed attribute snapshot, + * with per-PTY OSC SET mutations layered on top — mirroring exactly what the + * renderer's ThemeService reports for a visible pane. Replies route through + * the caller's emit sink, which the slice-1 forwarding window already gates, + * so seeded/replayed bytes and delivered chunks never produce a reply. + */ +import type { Terminal } from '@xterm/headless' +import { + formatXColorRgbSpec, + parseXColorSpec, + TERMINAL_VIEW_ANSI_COLOR_COUNT, + type TerminalViewAttributes, + type TerminalViewRgb +} from '../../shared/terminal-view-attributes' + +type ViewAttributeParser = Pick + +export type TerminalViewAttributeResponderDeps = { + parser: ViewAttributeParser + /** Last renderer push, or null before the first push. Null means SILENCE + * for every view-attribute query — a fabricated default would resurrect + * the default-black OSC-11 bug (design invariant 3). */ + getBaseAttributes: () => TerminalViewAttributes | null + /** Must already be replay/forwarding-window gated by the caller. */ + emitReply: (reply: string) => void +} + +export type TerminalViewAttributeResponder = { + /** A changed renderer attribute push replaces the whole palette, exactly + * like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors + * on a visible pane's theme apply. Identical re-pushes (fresh renderer + * process) are filtered in main's store and never reach this. */ + clearColorOverrides: () => void +} + +type SpecialColorSlot = 'foreground' | 'background' | 'cursor' + +// OSC 10/11/12 stack extra params onto consecutive slots (xterm's +// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background. +const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor'] +const SPECIAL_COLOR_IDENTS: Record = { + foreground: '10', + background: '11', + cursor: '12' +} + +function isValidColorIndex(value: number): boolean { + return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT +} + +// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) — +// the math CoreBrowserTerminal._reportColorScheme answers ?996n with. +function relativeLuminance([r, g, b]: TerminalViewRgb): number { + const linear = (channel: number): number => { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + } + return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722 +} + +export function installTerminalViewAttributeResponder( + deps: TerminalViewAttributeResponderDeps +): TerminalViewAttributeResponder { + // Why per-instance maps: SET mutations are per PTY (one emulator per PTY); + // they die with the emulator at teardown, like every other model state. + // They deliberately survive a reveal→re-hide cycle even though the revealed + // xterm restores without palette mutations (SerializeAddon emits no OSC + // color SETs): the TUI never reset its SET, so holding it is + // protocol-correct — the visible-side loss is the pre-existing restore + // limitation, not this model's. + const ansiOverrides = new Map() + const specialOverrides = new Map() + + const reportColor = (ident: string, rgb: TerminalViewRgb): void => { + // Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the + // renderer xterm's reply (CoreBrowserTerminal._handleColorEvent). + deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`) + } + + const handleSpecialColor = (data: string, offset: number): boolean => { + const slots = data.split(';') + for (let i = 0; i < slots.length; ++i, ++offset) { + if (offset >= SPECIAL_COLOR_SLOTS.length) { + break + } + const slot = SPECIAL_COLOR_SLOTS[offset] + if (slots[i] === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot]) + } + } else { + const rgb = parseXColorSpec(slots[i]) + if (rgb) { + specialOverrides.set(slot, rgb) + } + } + } + // True consumes the sequence; the headless core's own OSC 10/11/12 + // handler only fires an onColor event nothing consumes. + return true + } + + deps.parser.registerOscHandler(4, (data) => { + const slots = data.split(';') + while (slots.length > 1) { + const idx = slots.shift() as string + const spec = slots.shift() as string + if (!/^\d+$/.exec(idx)) { + continue + } + const index = parseInt(idx, 10) + if (!isValidColorIndex(index)) { + continue + } + if (spec === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index]) + } + } else { + const rgb = parseXColorSpec(spec) + if (rgb) { + ansiOverrides.set(index, rgb) + } + } + } + return true + }) + deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0)) + deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1)) + deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2)) + + // OSC 104/110/111/112 restore the themed color — dropping the override + // falls back to the pushed base, the model twin of ThemeService.restoreColor. + deps.parser.registerOscHandler(104, (data) => { + if (!data) { + ansiOverrides.clear() + return true + } + for (const slot of data.split(';')) { + if (/^\d+$/.exec(slot)) { + ansiOverrides.delete(parseInt(slot, 10)) + } + } + return true + }) + deps.parser.registerOscHandler(110, () => { + specialOverrides.delete('foreground') + return true + }) + deps.parser.registerOscHandler(111, () => { + specialOverrides.delete('background') + return true + }) + deps.parser.registerOscHandler(112, () => { + specialOverrides.delete('cursor') + return true + }) + + deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { + if (params[0] !== 996) { + // Fall through to the core for every other private DSR (?6n CPR etc.). + return false + } + const base = deps.getBaseAttributes() + if (base) { + // Why luminance and not base.colorSchemeMode: a visible xterm answers + // ?996n from the relative luminance of the CURRENT (OSC-SET-mutated) + // background vs foreground (CoreBrowserTerminal._reportColorScheme), + // so a dark terminal theme in a light app mode still answers dark. + // colorSchemeMode is the app mode and feeds the 2031/997 path only. + const background = specialOverrides.get('background') ?? base.background + const foreground = specialOverrides.get('foreground') ?? base.foreground + const dark = relativeLuminance(background) < relativeLuminance(foreground) + deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`) + } + return true + }) + + return { + clearColorOverrides: () => { + ansiOverrides.clear() + specialOverrides.clear() + } + } +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 92bceabc5ab..c146039c5ab 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -78,6 +78,8 @@ import { isNativeWindowsLocalPtySpawn, markNativeWindowsConptyPty } from '../runtime/terminal-model-query-authority' +import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -2974,6 +2976,17 @@ export function registerPtyHandlers( } }) + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: the responder must never store a malformed + // palette — a wrong color reply breaks TUI theme detection worse than + // the documented silent-until-first-push behavior. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { if (typeof args.id !== 'string' || !args.id) { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8d0f36bba8d..fbdcd8bb31e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -484,6 +484,10 @@ import { registerConptyDa1OverrideInstaller, shouldModelAnswerHiddenPtyQueries } from './terminal-model-query-authority' +import { + getTerminalViewAttributes, + registerTerminalViewAttributesApplier +} from './terminal-view-attribute-store' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' @@ -1687,6 +1691,15 @@ export class OrcaRuntimeService { // created this PTY's emulator; the mark retrofits the DA1 override here // (terminal-query-authority.md §ConPTY DA1). registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) + // Why: a renderer attribute push must reach already-live emulators too — + // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color + // override reset a theme apply implies (terminal-query-authority.md + // §View-attribute bridge). + registerTerminalViewAttributesApplier((attributes) => { + for (const state of this.headlessTerminals.values()) { + state.emulator.applyPushedViewAttributes(attributes) + } + }) } getLocalProvider(): IPtyProvider | null { @@ -4239,6 +4252,13 @@ export class OrcaRuntimeService { if (isNativeWindowsConptyPty(ptyId)) { emulator.installConptyPrimaryDeviceAttributesOverride() } + // Why the lazy getter: replies must use the freshest renderer push at + // parse time, and stay silent (never default) before the first push. + emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) + const viewAttributes = getTerminalViewAttributes() + if (viewAttributes) { + emulator.applyPushedViewAttributes(viewAttributes) + } state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } return state } diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index a2ab1fc96a2..093e1a8efbf 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -16,6 +16,11 @@ import { _resetTerminalModelQueryAuthorityForTest, markNativeWindowsConptyPty } from './terminal-model-query-authority' +import { + _resetTerminalViewAttributesForTest, + setTerminalViewAttributes +} from './terminal-view-attribute-store' +import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' const settingsState = { terminalMainSideEffectAuthority: true as boolean, @@ -75,9 +80,30 @@ async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise await runtime.serializeMainTerminalBuffer(ptyId) } +/** Renderer-pushed attribute snapshot with distinct, pinned slot values so + * reply fixtures cannot pass by coincidence. */ +function viewAttributes(overrides: Partial = {}): TerminalViewAttributes { + const ansi = Array.from( + { length: 256 }, + (_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb + ) + ansi[1] = [0xcc, 0x00, 0x00] + return { + foreground: [0xd0, 0xd0, 0xd0], + background: [0x1e, 0x1e, 0x2e], + cursor: [0xff, 0x99, 0x00], + ansi, + colorSchemeMode: 'dark', + cursorStyle: 'bar', + cursorBlink: true, + ...overrides + } +} + afterEach(() => { _resetHiddenRendererPtyDeliveryGateForTest() _resetTerminalModelQueryAuthorityForTest() + _resetTerminalViewAttributesForTest() settingsState.terminalMainSideEffectAuthority = true settingsState.terminalHiddenDeliveryGate = true settingsState.terminalModelQueryAuthority = true @@ -445,3 +471,326 @@ describe('HeadlessEmulator forwarding window', () => { } }) }) + +describe('view-attribute bridge replies (after renderer push)', () => { + // Reply bytes pinned to the renderer xterm's format: OSC replies use the + // queried ident, 16-bit doubled-byte channels, and ST termination + // (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with + // the contour 997 report, same bytes as mode2031SequenceFor. + it.each([ + ['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']], + ['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']], + ['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']], + ['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']], + ['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']], + [ + 'OSC 4 multiple slots in one sequence', + '\x1b]4;1;?;196;?\x07', + ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'] + ], + [ + 'OSC 10 stacked params report foreground then background', + '\x1b]10;?;?\x07', + ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'] + ], + ['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']], + ['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']], + ['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-view') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-view', chunk, Date.now()) + await settle(runtime, 'pty-view') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + }) + + it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-dark') + // Supported divergence: light app mode with terminalUseSeparateLightTheme + // off renders a dark terminal theme. A visible xterm answers ?996n from + // bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so + // the hidden reply must say dark here too. + setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' })) + + runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-dark') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n']) + }) + + it('answers ?996n light for a light palette regardless of the pushed app mode', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-light') + setTerminalViewAttributes( + viewAttributes({ + foreground: [0x33, 0x33, 0x33], + background: [0xfa, 0xfa, 0xfa], + colorSchemeMode: 'dark' + }) + ) + + runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-light') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => { + // _reportColorScheme reads the CURRENT theme-service colors, which include + // OSC 10/11 SET mutations — the per-PTY overlays layer the same way. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-set') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-set') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('stays silent before the first push, then answers the same query after it', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-first') + + runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-first') + // No fabricated defaults: silence is the documented hidden status quo. + expect(replies).toEqual([]) + + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-first') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('retrofits cursor options onto already-live emulators when the push lands late', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-late') + + // Emulator exists before any push: core default DECSCUSR is steady block. + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\']) + + setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false })) + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\') + }) +}) + +describe('per-PTY OSC color SET layering', () => { + it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-a') + markHiddenRendererPty('pty-b') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now()) + runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-a') + await settle(runtime, 'pty-b') + + expect(replies).toEqual([ + { ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' }, + { ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' } + ]) + }) + + it('restores a single indexed color via OSC 104;', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-104') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']) + }) + + it('restores the whole indexed table via bare OSC 104', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104all') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-104all', + '\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07', + Date.now() + ) + await settle(runtime, 'pty-104all') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]4;1;rgb:cccc/0000/0000\x1b\\', + '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\' + ]) + }) + + it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-special') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-special', + '\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' + + '\x1b]110\x07\x1b]111\x07\x1b]112\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07', + Date.now() + ) + await settle(runtime, 'pty-special') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]10;rgb:0101/0202/0303\x1b\\', + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\', + '\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\', + '\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', + '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', + '\x1b]12;rgb:ffff/9999/0000\x1b\\' + ]) + }) + + it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => { + // Cold-restore scrollback replayed into a visible renderer xterm re-applies + // OSC SETs to its theme service; the model mirrors that state — but the + // replay guard still keeps the seed from ANSWERING anything. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seedset') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07') + await settle(runtime, 'pty-seedset') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-seedset') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\']) + }) + + it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-idem') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-idem') + + // A second window / renderer reload / macOS re-activation re-pushes + // byte-identical attributes (its publisher dedupe is per-process). That is + // not a theme apply, so the OSC SET overlay must survive. + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\']) + }) + + it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-clear') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-clear') + + // A theme apply overwrites OSC-SET-mutated colors on visible panes too + // (ThemeService._setTheme), so the model mirrors that on every CHANGED + // push (identical re-pushes are filtered — see the test above). + setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] })) + runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-clear') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\']) + }) +}) + +describe('view-attribute replay guard and suppression', () => { + it('never answers view-attribute queries embedded in a seeded snapshot', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vseed') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n') + await settle(runtime, 'pty-vseed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vseed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-vhyd') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vhyd', 'live output', Date.now()) + await settle(runtime, 'pty-vhyd') + + expect(replies).toEqual([]) + }) + + it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => { + const { runtime, replies } = createResponderRuntime() + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vvis') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vint') + setRendererPtyDeliveryInterest('pty-vint', true) + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vint') + + expect(replies).toEqual([]) + }) + + it('yields view-attribute replies while a remote view subscriber is attached', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vrem') + setTerminalViewAttributes(viewAttributes()) + const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem') + + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies).toEqual([]) + + release() + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vkill') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vkill') + + expect(replies).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts new file mode 100644 index 00000000000..ee55581bfe9 --- /dev/null +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -0,0 +1,57 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): main-side cache of the renderer's `pty:terminalViewAttributes` + * push. One app-global snapshot, not per-PTY — per-pane font zoom never + * affects these attributes and the color/cursor settings are global. + * + * Null until the first push, and the responder answers NO view-attribute + * query while null (silent-until-first-push): a fabricated default would + * resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC + * hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip. + */ +import { + terminalViewAttributesEqual, + type TerminalViewAttributes +} from '../../shared/terminal-view-attributes' + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives +// the push, the runtime emulators consult it at reply time via the getter. +let currentAttributes: TerminalViewAttributes | null = null + +// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push +// must also reach already-live emulators — cursor options under the replay +// guard, plus the per-PTY override reset a theme apply implies. +type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void +const pushAppliers = new Set() + +export function registerTerminalViewAttributesApplier( + applier: TerminalViewAttributesApplier +): void { + pushAppliers.add(applier) +} + +/** Called from the pty:terminalViewAttributes IPC handler with a validated + * payload. Last push wins (replies always use the freshest snapshot). */ +export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void { + // Why idempotent: the renderer publisher's dedupe is per-process, so a + // fresh renderer (second window, reload, macOS re-activation) re-pushes + // identical attributes. That is not a theme apply — fanning out would wipe + // every PTY's OSC SET overlay while visible panes keep theirs. + if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) { + return + } + currentAttributes = attributes + for (const applier of pushAppliers) { + applier(attributes) + } +} + +export function getTerminalViewAttributes(): TerminalViewAttributes | null { + return currentAttributes +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalViewAttributesForTest(): void { + currentAttributes = null + pushAppliers.clear() +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d1b21503efa..88e1c6382ce 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -146,6 +146,7 @@ import type { WorkspaceSessionState } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -943,6 +944,9 @@ export type PreloadApi = { /** Ref-counted-on-the-renderer delivery-interest signal that suppresses * the hidden-delivery gate while any raw-byte consumer is registered. */ setPtyDeliveryInterest: (id: string, interested: boolean) => void + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push backing main's hidden-PTY OSC/DSR color replies. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 79d59435315..2edcbe8acdd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -45,6 +45,7 @@ import type { WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' @@ -717,6 +718,12 @@ const api = { setPtyDeliveryInterest: (id: string, interested: boolean): void => { ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) }, + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push that lets main's model responder answer OSC 4/10/11/12 + * and DSR ?996n for hidden-gated PTYs with renderer-true values. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 863d4cc7725..61b745a606c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' -import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' import { + applyTerminalAppearance, hexToRgba, installMode2031Handlers, maybePushMode2031Flip, @@ -373,6 +375,68 @@ describe('installMode2031Handlers', () => { }) }) +describe('applyTerminalAppearance theme assignment', () => { + // xterm's OptionsService fires the theme change on object IDENTITY, and + // ThemeService._setTheme then rebuilds the palette, discarding OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size, padding, + // zoom) compose a fresh-but-value-identical theme; assigning it anyway + // wipes TUI color mutations on visible panes while the deduped publisher + // keeps hidden overlays — so the assignment must be value-gated. + function makePane(id: number): ManagedPane { + return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function apply(pane: ManagedPane, settings: ReturnType): void { + applyTerminalAppearance( + makeManager([pane]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + } + + it('keeps options.theme identity across attribute-neutral applies (font size tweak)', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + expect(firstTheme).toBeDefined() + + apply(pane, { ...settings, terminalFontSize: settings.terminalFontSize + 2 }) + + // Identity-stable theme means xterm never re-runs _setTheme, so a TUI's + // modifyColors mutation survives the font tweak. + expect(pane.terminal.options.theme).toBe(firstTheme) + expect(pane.terminal.options.fontSize).toBe(settings.terminalFontSize + 2) + }) + + it('still assigns a fresh theme when composed values actually change', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + + apply(pane, { ...settings, terminalColorOverrides: { background: '#102030' } }) + + expect(pane.terminal.options.theme).not.toBe(firstTheme) + expect(pane.terminal.options.theme?.background).toBe('#102030') + }) +}) + describe('hexToRgba', () => { it('converts 6-char hex to rgba', () => { expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)') diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 9f7dc5f23d2..f2c4ac1c731 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -16,6 +16,7 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' export { mode2031SequenceFor } @@ -195,6 +196,32 @@ export function composeActiveTerminalTheme( return theme } +// Value equality over composed ITheme objects (flat string slots plus the +// extendedAnsi string array), used to gate the per-pane options.theme write. +function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean { + if (!a) { + return false + } + if (a === b) { + return true + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) { + if (key === 'extendedAnsi') { + continue + } + if (a[key as keyof ITheme] !== b[key as keyof ITheme]) { + return false + } + } + const extA = a.extendedAnsi + const extB = b.extendedAnsi + if (!extA || !extB) { + return extA === extB + } + return extA.length === extB.length && extA.every((value, i) => value === extB[i]) +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -209,6 +236,11 @@ export function applyTerminalAppearance( const paneStyles = resolvePaneStyleOptions(settings) const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) const theme = composeActiveTerminalTheme(baseTheme, settings) + // View-attribute bridge (Phase 5 slice 2): this is the single point where + // the composed app-global terminal appearance exists, so publish it to + // main's hidden-PTY query responder here. Deduped inside the publisher — + // per-pane re-applies and attribute-neutral tweaks do not re-push. + publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight) @@ -218,7 +250,14 @@ export function applyTerminalAppearance( ) for (const pane of manager.getPanes()) { - if (theme) { + // Why value-gated: xterm's OptionsService fires on object identity, and + // ThemeService._setTheme rebuilds the palette, discarding TUI OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size/family, + // line height, padding, per-pane zoom) compose a fresh-but-identical + // theme; skipping the write keeps visible-pane mutations alive (a + // pre-existing loss this also fixes) and matches the hidden responder's + // deduped overlay behavior, so hidden and visible no longer drift. + if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { pane.terminal.options.theme = theme } // Why: xterm's allowTransparency has measurable rendering cost, so clear diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts new file mode 100644 index 00000000000..ea7613ea354 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts @@ -0,0 +1,249 @@ +/** + * View-attribute bridge publication (terminal-query-authority.md §View- + * attribute bridge): the composed snapshot must mirror xterm ThemeService + * resolution (defaults, cursor blend, 256-entry palette), and pushes must + * happen once per actual change — not per pane, not per font tweak. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { applyTerminalAppearance } from './terminal-appearance' +import { + _resetTerminalViewAttributesPublisherForTest, + composeTerminalViewAttributes, + publishTerminalViewAttributes +} from './terminal-view-attributes-publisher' + +const cursorSettings = { + terminalCursorStyle: 'block' as const, + terminalCursorBlink: true +} + +beforeEach(() => { + _resetTerminalViewAttributesPublisherForTest() + vi.unstubAllGlobals() +}) + +describe('composeTerminalViewAttributes', () => { + it('resolves a null theme to the xterm ThemeService defaults', () => { + const attrs = composeTerminalViewAttributes(null, 'dark', cursorSettings) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + expect(attrs.background).toEqual([0x00, 0x00, 0x00]) + expect(attrs.cursor).toEqual([0xff, 0xff, 0xff]) + expect(attrs.ansi).toHaveLength(256) + // DEFAULT_ANSI_COLORS parity: named 16, color cube, greys. + expect(attrs.ansi[0]).toEqual([0x2e, 0x34, 0x36]) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.ansi[15]).toEqual([0xee, 0xee, 0xec]) + expect(attrs.ansi[16]).toEqual([0x00, 0x00, 0x00]) + expect(attrs.ansi[196]).toEqual([0xff, 0x00, 0x00]) + expect(attrs.ansi[232]).toEqual([8, 8, 8]) + expect(attrs.ansi[255]).toEqual([238, 238, 238]) + expect(attrs.colorSchemeMode).toBe('dark') + expect(attrs.cursorStyle).toBe('block') + expect(attrs.cursorBlink).toBe(true) + }) + + it('parses composed theme colors including rgba() opacity forms', () => { + const attrs = composeTerminalViewAttributes( + { + // composeActiveTerminalTheme emits rgba() when terminalBackgroundOpacity + // or terminalCursorOpacity apply; the reply drops alpha like xterm's + // toColorRGB, except the cursor which blends over the background. + background: 'rgba(30, 30, 46, 0.9)', + foreground: '#d0d0d0', + cursor: 'rgba(255, 0, 0, 0.5)', + red: '#ff8800' + }, + 'light', + { terminalCursorStyle: 'underline', terminalCursorBlink: false } + ) + expect(attrs.background).toEqual([30, 30, 46]) + expect(attrs.foreground).toEqual([0xd0, 0xd0, 0xd0]) + // color.blend parity: a = round(0.5*255)/255; ch = bg + round((fg-bg)*a). + expect(attrs.cursor).toEqual([143, 15, 23]) + expect(attrs.ansi[1]).toEqual([0xff, 0x88, 0x00]) + expect(attrs.colorSchemeMode).toBe('light') + expect(attrs.cursorStyle).toBe('underline') + expect(attrs.cursorBlink).toBe(false) + }) + + it('keeps an opaque cursor un-blended and blends short-hex alpha', () => { + const attrs = composeTerminalViewAttributes( + { background: '#000000', cursor: '#ff0000' }, + 'dark', + cursorSettings + ) + expect(attrs.cursor).toEqual([255, 0, 0]) + + const blended = composeTerminalViewAttributes( + { background: '#000000', cursor: '#f00a' }, + 'dark', + cursorSettings + ) + // #f00a → alpha 0xaa: 0 + round(255 * (0xaa/0xff)) = 170. + expect(blended.cursor).toEqual([170, 0, 0]) + }) + + it('overlays extendedAnsi onto the default 256 palette tail', () => { + const attrs = composeTerminalViewAttributes( + { extendedAnsi: ['#102030'] }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[16]).toEqual([0x10, 0x20, 0x30]) + // Untouched tail entries stay on the generated cube. + expect(attrs.ansi[17]).toEqual([0x00, 0x00, 0x5f]) + }) + + it('falls back to slot defaults for named colors (hand-edited settings divergence)', () => { + // A visible pane resolves named CSS via canvas; the composer cannot, so + // hand-edited values fall back — the documented divergence boundary. + const attrs = composeTerminalViewAttributes( + { red: 'darkred', foreground: 'hotpink' }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + }) +}) + +describe('publishTerminalViewAttributes dedupe', () => { + it('publishes once per snapshot change, not per call', () => { + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(false) + expect(send).toHaveBeenCalledTimes(1) + + // A real attribute change (theme flip) publishes again. + expect(publishTerminalViewAttributes(null, 'light', cursorSettings, send)).toBe(true) + expect(send).toHaveBeenCalledTimes(2) + }) + + it('does not record a failed send, so the next call retries', () => { + const failingSend = vi.fn(() => false) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, failingSend)).toBe(false) + + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + }) + + it('skips silently when the preload bridge is unavailable (web client, tests)', () => { + // No window stub: default send must be a safe no-op. + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings)).toBe(false) + }) +}) + +describe('applyTerminalAppearance publication', () => { + function makePane(id: number): ManagedPane { + return { + id, + terminal: { options: {}, cols: 80, rows: 24 } + } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function stubPublishBridge(): ReturnType { + const publish = vi.fn<(attributes: TerminalViewAttributes) => void>() + vi.stubGlobal('window', { api: { pty: { publishTerminalViewAttributes: publish } } }) + return publish + } + + it('pushes the app-global snapshot once per change, not per pane or per manager', () => { + const publish = stubPublishBridge() + const settings = getDefaultSettings('/tmp') + + // Two panes in one manager plus a second manager (another tab): the + // attributes are app-global, so identical applies publish exactly once. + applyTerminalAppearance( + makeManager([makePane(1), makePane(2)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(3)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + const attributes = publish.mock.calls[0][0] as TerminalViewAttributes + expect(attributes.ansi).toHaveLength(256) + expect(attributes.cursorStyle).toBe(settings.terminalCursorStyle) + + // Attribute-neutral tweak (font size) must not re-push… + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalFontSize: settings.terminalFontSize + 2 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + + // …while a cursor-style change is a real attribute change. + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalCursorStyle: 'underline' }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(2) + }) + + it('publishes the resolved color-scheme mode flip (system dark toggle)', () => { + const publish = stubPublishBridge() + const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const } + + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + false, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + const modes = publish.mock.calls.map( + (call) => (call[0] as TerminalViewAttributes).colorSchemeMode + ) + expect(modes).toEqual(['dark', 'light']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts new file mode 100644 index 00000000000..05970b77725 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -0,0 +1,247 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): renderer→main `pty:terminalViewAttributes` publication. Composes + * the reply-relevant slots of the active terminal theme exactly the way + * xterm's browser ThemeService resolves an ITheme (defaults, cursor blend, + * 256-entry palette), so main's hidden-PTY responder replies byte-identically + * to a visible pane's xterm. Deduped module-globally: applyTerminalAppearance + * runs per pane manager and on every font/opacity tweak, but the attributes + * are app-global, so identical snapshots publish once. + */ +import type { ITheme } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { + TerminalViewAttributes, + TerminalViewRgb +} from '../../../../shared/terminal-view-attributes' + +type ParsedCssColor = { + rgb: TerminalViewRgb + /** 0-255, the precision xterm stores (rgba byte) — blend parity needs it. */ + alpha: number +} + +// ThemeService defaults for the reply-relevant slots (browser/services/ +// ThemeService.ts): fg #ffffff, bg #000000, cursor #ffffff. +const DEFAULT_FOREGROUND: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } +const DEFAULT_BACKGROUND: ParsedCssColor = { rgb: [0x00, 0x00, 0x00], alpha: 0xff } +const DEFAULT_CURSOR: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } + +// xterm's DEFAULT_ANSI_COLORS first 16 entries (browser/Types.ts). +const DEFAULT_ANSI_16: readonly string[] = [ + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +] + +const THEME_ANSI_KEYS: readonly (keyof ITheme)[] = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] + +function buildDefaultAnsiPalette(): TerminalViewRgb[] { + const palette = DEFAULT_ANSI_16.map((hex) => parseThemeColor(hex, DEFAULT_BACKGROUND).rgb) + // 16-231: the 6x6x6 color cube, 232-255: greys — same generator as xterm's + // DEFAULT_ANSI_COLORS IIFE so untouched extended slots reply identically. + const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + for (let i = 0; i < 216; i++) { + palette.push([v[((i / 36) % 6) | 0], v[((i / 6) % 6) | 0], v[i % 6]]) + } + for (let i = 0; i < 24; i++) { + const c = 8 + i * 10 + palette.push([c, c, c]) + } + return palette +} + +const DEFAULT_ANSI_PALETTE: readonly TerminalViewRgb[] = buildDefaultAnsiPalette() + +/** Mirror of xterm's css.toColor fast paths (#rgb[a], #rrggbb[aa], rgb(), + * rgba()) — every format first-party inputs produce (builtin themes and the + * ghostty import are hex-validated; composeActiveTerminalTheme only adds the + * rgba() form this regex accepts). Known divergence boundary: the renderer's + * css.toColor also resolves named/modern CSS via a canvas litmus, so a + * hand-edited settings value like `background: 'darkslategray'` renders on + * a visible pane but falls back to the slot default in the hidden reply. */ +export function parseCssColor(css: string): ParsedCssColor | null { + if (/^#[\da-f]{3,8}$/i.test(css)) { + switch (css.length) { + case 4: + return { + rgb: [ + parseInt(css.slice(1, 2).repeat(2), 16), + parseInt(css.slice(2, 3).repeat(2), 16), + parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: 0xff + } + case 5: + return { + rgb: [ + parseInt(css.slice(1, 2).repeat(2), 16), + parseInt(css.slice(2, 3).repeat(2), 16), + parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: parseInt(css.slice(4, 5).repeat(2), 16) + } + case 7: + return { + rgb: [ + parseInt(css.slice(1, 3), 16), + parseInt(css.slice(3, 5), 16), + parseInt(css.slice(5, 7), 16) + ], + alpha: 0xff + } + case 9: + return { + rgb: [ + parseInt(css.slice(1, 3), 16), + parseInt(css.slice(3, 5), 16), + parseInt(css.slice(5, 7), 16) + ], + alpha: parseInt(css.slice(7, 9), 16) + } + default: + return null + } + } + const rgbaMatch = css.match( + /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/ + ) + if (rgbaMatch) { + return { + rgb: [parseInt(rgbaMatch[1], 10), parseInt(rgbaMatch[2], 10), parseInt(rgbaMatch[3], 10)], + alpha: Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xff) + } + } + return null +} + +function parseThemeColor(css: string | undefined, fallback: ParsedCssColor): ParsedCssColor { + if (css !== undefined) { + const parsed = parseCssColor(css) + if (parsed) { + return parsed + } + } + return fallback +} + +// Mirror of xterm's color.blend: ThemeService blends the cursor color's +// alpha over the background at theme-set time (terminalCursorOpacity), and +// the OSC 12 reply reports the blended value. +function blendOverBackground(background: TerminalViewRgb, color: ParsedCssColor): TerminalViewRgb { + if (color.alpha === 0xff) { + return color.rgb + } + const a = color.alpha / 0xff + return [ + background[0] + Math.round((color.rgb[0] - background[0]) * a), + background[1] + Math.round((color.rgb[1] - background[1]) * a), + background[2] + Math.round((color.rgb[2] - background[2]) * a) + ] +} + +export function composeTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick +): TerminalViewAttributes { + const foreground = parseThemeColor(theme?.foreground, DEFAULT_FOREGROUND) + const background = parseThemeColor(theme?.background, DEFAULT_BACKGROUND) + const cursor = parseThemeColor(theme?.cursor, DEFAULT_CURSOR) + const ansi: TerminalViewRgb[] = THEME_ANSI_KEYS.map((key, i) => { + const value = theme?.[key] + return parseThemeColor(typeof value === 'string' ? value : undefined, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + }) + for (let i = 16; i < DEFAULT_ANSI_PALETTE.length; i++) { + const extended = theme?.extendedAnsi?.[i - 16] + ansi.push( + parseThemeColor(extended, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + ) + } + return { + foreground: foreground.rgb, + background: background.rgb, + cursor: blendOverBackground(background.rgb, cursor), + ansi, + colorSchemeMode: mode, + // Same resolution as the per-pane option writes in applyTerminalAppearance. + cursorStyle: settings.terminalCursorStyle ?? 'block', + cursorBlink: settings.terminalCursorBlink === true + } +} + +let lastPublishedSnapshot: string | null = null + +function sendViaPreload(attributes: TerminalViewAttributes): boolean { + // Guarded: unit tests and the web client run without the preload bridge + // (remote-runtime PTYs are never hidden-gate markable anyway). + if (typeof window === 'undefined' || !window.api?.pty?.publishTerminalViewAttributes) { + return false + } + window.api.pty.publishTerminalViewAttributes(attributes) + return true +} + +/** Publishes the composed app-global attributes, once per actual change: + * repeat calls from per-pane appearance applies (and attribute-neutral + * tweaks like font size) are deduped against the last published snapshot. */ +export function publishTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick, + send: (attributes: TerminalViewAttributes) => boolean = sendViaPreload +): boolean { + const attributes = composeTerminalViewAttributes(theme, mode, settings) + const serialized = JSON.stringify(attributes) + if (serialized === lastPublishedSnapshot) { + return false + } + if (!send(attributes)) { + // Not recorded: a later call with a working bridge must still publish. + return false + } + lastPublishedSnapshot = serialized + return true +} + +/** Test seam: reset the dedupe state between tests. */ +export function _resetTerminalViewAttributesPublisherForTest(): void { + lastPublishedSnapshot = null +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 65e0bdf6ee9..38457954ac8 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2258,6 +2258,9 @@ function createPtyApi(): NonNullable['pty']> { setActiveRendererPty: () => {}, setHiddenRendererPty: () => {}, setPtyDeliveryInterest: () => {}, + // Why no-op: remote-runtime PTYs are never hidden-gate markable, so the + // web client has no main-side responder to feed. + publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), diff --git a/src/shared/terminal-view-attributes.test.ts b/src/shared/terminal-view-attributes.test.ts new file mode 100644 index 00000000000..17c77c08d09 --- /dev/null +++ b/src/shared/terminal-view-attributes.test.ts @@ -0,0 +1,119 @@ +/** + * View-attribute bridge (terminal-query-authority.md §View-attribute bridge): + * the XParseColor mirrors must match the bundled xterm grammar exactly — + * main's replies for hidden PTYs must be byte-identical to a visible + * renderer xterm's. + */ +import { describe, expect, it } from 'vitest' +import { + formatXColorRgbSpec, + parseXColorSpec, + terminalViewAttributesEqual, + validateTerminalViewAttributes, + type TerminalViewAttributes, + type TerminalViewRgb +} from './terminal-view-attributes' + +describe('parseXColorSpec', () => { + // Scaling fixtures mirror XParseColor.parseColor: h|hh|hhh|hhhh channels + // scale from their base (15/255/4095/65535) to 8 bit. + it.each([ + ['rgb:f/f/f', [255, 255, 255]], + ['rgb:0/8/f', [0, 136, 255]], + ['rgb:ff/00/80', [255, 0, 128]], + ['rgb:fff/000/888', [255, 0, 136]], + ['rgb:ffff/0000/8888', [255, 0, 136]], + ['RGB:FF/00/80', [255, 0, 128]], + ['#abc', [0xa0, 0xb0, 0xc0]], + ['#aabbcc', [0xaa, 0xbb, 0xcc]], + ['#aaabbbccc', [0xaa, 0xbb, 0xcc]], + ['#aaaabbbbcccc', [0xaa, 0xbb, 0xcc]] + ])('parses %s like xterm', (spec, expected) => { + expect(parseXColorSpec(spec)).toEqual(expected) + }) + + it.each([ + ['', 'empty'], + ['red', 'named colors (xterm rejects them too)'], + ['rgb:ff/ff', 'missing channel'], + ['rgb:ggg/000/000', 'non-hex'], + ['#abcd', 'hash length 4 is not a valid xparsecolor width'], + ['rgbi:1/1/1', 'rgbi is unsupported'] + ])('rejects %s — %s', (spec) => { + expect(parseXColorSpec(spec)).toBeNull() + }) +}) + +describe('formatXColorRgbSpec', () => { + it('reports 16-bit channels by doubling the 8-bit byte (toRgbString parity)', () => { + expect(formatXColorRgbSpec([0x1e, 0x1e, 0x2e])).toBe('rgb:1e1e/1e1e/2e2e') + expect(formatXColorRgbSpec([0, 8, 255])).toBe('rgb:0000/0808/ffff') + }) +}) + +describe('validateTerminalViewAttributes', () => { + const valid = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('accepts and normalizes a well-formed payload', () => { + const attrs = validateTerminalViewAttributes(valid()) + expect(attrs).not.toBeNull() + expect(attrs?.ansi).toHaveLength(256) + expect(attrs?.colorSchemeMode).toBe('dark') + }) + + it.each([ + ['null payload', null], + ['missing foreground', { ...valid(), foreground: undefined }], + ['short triple', { ...valid(), background: [1, 2] }], + ['out-of-range channel', { ...valid(), cursor: [0, 0, 300] }], + ['non-integer channel', { ...valid(), cursor: [0, 0, 1.5] }], + ['short palette', { ...valid(), ansi: valid().ansi.slice(0, 16) }], + ['bad palette entry', { ...valid(), ansi: [...valid().ansi.slice(0, 255), 'red'] }], + ['bad mode', { ...valid(), colorSchemeMode: 'auto' }], + ['bad cursor style', { ...valid(), cursorStyle: 'beam' }], + ['non-boolean blink', { ...valid(), cursorBlink: 1 }] + ])('rejects %s', (_label, payload) => { + expect(validateTerminalViewAttributes(payload)).toBeNull() + }) +}) + +describe('terminalViewAttributesEqual', () => { + // The store's idempotence gate: a deep-equal snapshot from a fresh renderer + // process must compare equal so the re-push never fans out as a theme apply. + const snapshot = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('treats two independently built identical snapshots as equal', () => { + expect(terminalViewAttributesEqual(snapshot(), snapshot())).toBe(true) + }) + + it.each([ + ['foreground', { ...snapshot(), foreground: [1, 2, 4] as TerminalViewRgb }], + ['background', { ...snapshot(), background: [0, 0, 0] as TerminalViewRgb }], + ['cursor', { ...snapshot(), cursor: [7, 8, 10] as TerminalViewRgb }], + [ + 'an ansi entry', + { ...snapshot(), ansi: snapshot().ansi.map((rgb, i) => (i === 200 ? [9, 9, 9] : rgb)) } + ], + ['colorSchemeMode', { ...snapshot(), colorSchemeMode: 'light' as const }], + ['cursorStyle', { ...snapshot(), cursorStyle: 'bar' as const }], + ['cursorBlink', { ...snapshot(), cursorBlink: false }] + ])('detects a change in %s', (_label, changed) => { + expect(terminalViewAttributesEqual(snapshot(), changed as TerminalViewAttributes)).toBe(false) + }) +}) diff --git a/src/shared/terminal-view-attributes.ts b/src/shared/terminal-view-attributes.ts new file mode 100644 index 00000000000..736003845dd --- /dev/null +++ b/src/shared/terminal-view-attributes.ts @@ -0,0 +1,188 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): payload contract for the renderer→main `pty:terminalViewAttributes` + * push, plus main/renderer mirrors of xterm's XParseColor color-spec grammar + * so main's responder replies byte-identically to a visible renderer xterm. + */ + +/** 8-bit-per-channel RGB triple — the same resolution xterm's theme service + * stores internally (`color.toColorRGB`). */ +export type TerminalViewRgb = [number, number, number] + +export const TERMINAL_VIEW_ANSI_COLOR_COUNT = 256 + +export type TerminalViewCursorStyle = 'bar' | 'block' | 'underline' + +/** One app-global snapshot of the renderer's composed terminal appearance — + * per-pane font zoom never affects these, and terminalColorOverrides / + * cursor settings are global, so one push covers all PTYs. */ +export type TerminalViewAttributes = { + foreground: TerminalViewRgb + background: TerminalViewRgb + /** Already blended over the background (xterm ThemeService blends the + * cursor color's alpha at theme-set time, e.g. terminalCursorOpacity). */ + cursor: TerminalViewRgb + /** Full 256-entry palette: theme's 16 named colors + extendedAnsi/default + * tail, exactly as the renderer ThemeService resolves them. */ + ansi: TerminalViewRgb[] + /** Resolved APP color-scheme mode (the 2031/997 flip source). NOT the DSR + * ?996n answer: that is computed from background/foreground relative + * luminance like a visible xterm (_reportColorScheme), and the two can + * disagree (e.g. dark terminal theme in light app mode). */ + colorSchemeMode: 'dark' | 'light' + cursorStyle: TerminalViewCursorStyle + cursorBlink: boolean +} + +// Mirror of @xterm XParseColor RGB_REX: r/g/b channels in 1-4 hex digits. +const X_RGB_SPEC_RE = + /^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/ +const X_HASH_SPEC_RE = /^[\da-f]+$/ + +/** Mirror of xterm's XParseColor `parseColor` (the grammar the renderer + * accepts for OSC 4/10/11/12 SET payloads): `rgb:h/h/h`..`rgb:hhhh/hhhh/hhhh` + * and `#RGB|#RRGGBB|#RRRGGGBBB|#RRRRGGGGBBBB`. Anything else (named colors, + * rgbi:) is rejected exactly like the renderer rejects it. */ +export function parseXColorSpec(spec: string): TerminalViewRgb | null { + if (!spec) { + return null + } + let low = spec.toLowerCase() + if (low.startsWith('rgb:')) { + low = low.slice(4) + const m = X_RGB_SPEC_RE.exec(low) + if (m) { + const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535 + return [ + Math.round((parseInt(m[1] || m[4] || m[7] || m[10], 16) / base) * 255), + Math.round((parseInt(m[2] || m[5] || m[8] || m[11], 16) / base) * 255), + Math.round((parseInt(m[3] || m[6] || m[9] || m[12], 16) / base) * 255) + ] + } + return null + } + if (low.startsWith('#')) { + low = low.slice(1) + if (X_HASH_SPEC_RE.exec(low) && [3, 6, 9, 12].includes(low.length)) { + const adv = low.length / 3 + const result: TerminalViewRgb = [0, 0, 0] + for (let i = 0; i < 3; ++i) { + const c = parseInt(low.slice(adv * i, adv * i + adv), 16) + result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8 + } + return result + } + } + return null +} + +function padChannelTo16Bit(value: number): string { + const hex = value.toString(16) + const byte = hex.length < 2 ? `0${hex}` : hex + // Why doubled: xterm reports 16-bit channels by repeating the 8-bit byte + // (XParseColor.toRgbString with bits=16) — pinned reply-format parity. + return byte + byte +} + +/** Mirror of xterm's `toRgbString(color, 16)` — the exact channel format a + * visible renderer xterm uses in OSC 4/10/11/12 query replies. */ +export function formatXColorRgbSpec(rgb: TerminalViewRgb): string { + return `rgb:${padChannelTo16Bit(rgb[0])}/${padChannelTo16Bit(rgb[1])}/${padChannelTo16Bit(rgb[2])}` +} + +function rgbEqual(a: TerminalViewRgb, b: TerminalViewRgb): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +/** Value equality over the whole snapshot. Lets main's store treat a + * re-push of identical attributes (fresh renderer process: second window, + * reload, macOS re-activation) as a no-op instead of a theme apply. */ +export function terminalViewAttributesEqual( + a: TerminalViewAttributes, + b: TerminalViewAttributes +): boolean { + if (a === b) { + return true + } + if ( + !rgbEqual(a.foreground, b.foreground) || + !rgbEqual(a.background, b.background) || + !rgbEqual(a.cursor, b.cursor) || + a.colorSchemeMode !== b.colorSchemeMode || + a.cursorStyle !== b.cursorStyle || + a.cursorBlink !== b.cursorBlink || + a.ansi.length !== b.ansi.length + ) { + return false + } + for (let i = 0; i < a.ansi.length; i++) { + if (!rgbEqual(a.ansi[i], b.ansi[i])) { + return false + } + } + return true +} + +function isRgbChannel(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255 +} + +function validateRgbTriple(value: unknown): TerminalViewRgb | null { + if (!Array.isArray(value) || value.length !== 3) { + return null + } + const [r, g, b] = value + if (!isRgbChannel(r) || !isRgbChannel(g) || !isRgbChannel(b)) { + return null + } + return [r, g, b] +} + +/** IPC-boundary validation for the `pty:terminalViewAttributes` push. Returns + * a normalized copy or null — main must never store a malformed palette (a + * wrong color reply is worse than silence, the OSC-11 lesson). */ +export function validateTerminalViewAttributes(payload: unknown): TerminalViewAttributes | null { + if (typeof payload !== 'object' || payload === null) { + return null + } + const candidate = payload as Record + const foreground = validateRgbTriple(candidate.foreground) + const background = validateRgbTriple(candidate.background) + const cursor = validateRgbTriple(candidate.cursor) + if (!foreground || !background || !cursor) { + return null + } + if (!Array.isArray(candidate.ansi) || candidate.ansi.length !== TERMINAL_VIEW_ANSI_COLOR_COUNT) { + return null + } + const ansi: TerminalViewRgb[] = [] + for (const entry of candidate.ansi) { + const triple = validateRgbTriple(entry) + if (!triple) { + return null + } + ansi.push(triple) + } + if (candidate.colorSchemeMode !== 'dark' && candidate.colorSchemeMode !== 'light') { + return null + } + if ( + candidate.cursorStyle !== 'bar' && + candidate.cursorStyle !== 'block' && + candidate.cursorStyle !== 'underline' + ) { + return null + } + if (typeof candidate.cursorBlink !== 'boolean') { + return null + } + return { + foreground, + background, + cursor, + ansi, + colorSchemeMode: candidate.colorSchemeMode, + cursorStyle: candidate.cursorStyle, + cursorBlink: candidate.cursorBlink + } +} From 0bcda38d63bb9ce742b539e41ef37030d44ce29d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:32:14 -0700 Subject: [PATCH 47/62] Align query authority contract and spawn-time ownership Co-authored-by: Orca --- .../reference/terminal-model-view-contract.md | 18 +- docs/reference/terminal-query-authority.md | 60 ++++-- .../terminal-side-effect-authority.md | 2 +- src/main/daemon/daemon-pty-adapter.ts | 8 + src/main/daemon/headless-emulator.ts | 14 ++ src/main/daemon/session.test.ts | 11 +- src/main/daemon/types.ts | 6 +- src/main/ipc/pty.test.ts | 190 +++++++++++++++++- src/main/ipc/pty.ts | 50 ++++- src/main/providers/types.ts | 5 + src/main/runtime/orca-runtime.ts | 16 ++ .../runtime/terminal-query-responder.test.ts | 31 +++ src/preload/api-types.ts | 4 + src/preload/index.ts | 4 + src/renderer/src/App.tsx | 9 + .../terminal-pane/pty-connection.test.ts | 31 +++ .../terminal-pane/pty-connection.ts | 21 ++ .../terminal-pane/pty-dispatcher.ts | 6 + .../components/terminal-pane/pty-transport.ts | 4 + .../terminal-pane/terminal-appearance.test.ts | 70 ++++++- .../terminal-pane/terminal-appearance.ts | 23 +++ 21 files changed, 548 insertions(+), 35 deletions(-) diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md index 07ab4a9a179..d303f43648e 100644 --- a/docs/reference/terminal-model-view-contract.md +++ b/docs/reference/terminal-model-view-contract.md @@ -38,9 +38,14 @@ depend on. 5. Snapshots and live bytes have ordering metadata. A view restore must not duplicate bytes already included in the snapshot or drop bytes that arrived after it. -6. Terminal query authority stays with the visible renderer when needed. The - headless model tracks state but must not answer DA, DSR, OSC 11, or other - shell/TUI queries that would inject replies into the PTY. +6. Terminal query authority is singular and structural: the party that + writes a chunk into a live terminal answers its queries. Visible renderer + and remote views keep xterm authority. Chunks dropped by the + hidden-delivery gate are answered exactly once by the main model + responder, from runtime-emulator state plus renderer-pushed view + attributes. Replayed, seeded, or snapshot bytes are answered by no one. + The daemon emulator never answers. (Amended by Phase 5 — see + [`terminal-query-authority.md`](./terminal-query-authority.md).) 7. The transcript contract stays separate from screen restore. `orca terminal read` must preserve bounded previews, cursor pagination, partial-line rules, truncation flags, and total counts even if view snapshots change shape. @@ -116,7 +121,11 @@ Before moving more runtime behavior behind the model/view boundary, add or extend tests that prove: - headless snapshots rehydrate rich alternate-screen TUI state; -- headless tracking does not answer DA, DSR, OSC 11, or theme-sensitive queries; +- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive + queries (the `session.test.ts` pins are permanent); +- the main runtime responder answers queries only from live chunks the + hidden-delivery gate dropped — never delivered, replayed, seeded, or + remote-subscribed chunks; - hidden renderer overflow restores from model state without duplicate live output; - sleep/wake and worktree revisit restore from model-correct state; @@ -135,6 +144,7 @@ Current coverage is spread across: - `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` - `src/main/runtime/rpc/terminal-multiplex.test.ts` - `src/main/runtime/orca-runtime.test.ts` +- `src/main/runtime/terminal-query-responder.test.ts` - `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` - `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` - `tests/e2e/terminal-sleep-wake-restore.spec.ts` diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index 9472535818a..5fb163d26d0 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -150,8 +150,13 @@ actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes` for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`) -stays authoritative. A re-seeded emulator that lost flags answers `?0u`; -protocol-conformant programs re-push. +stays authoritative. Slice 3 wires the re-seed consumer: the daemon +warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn +result into `seedHeadlessTerminal`, which applies them to the fresh runtime +emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding +window), so hidden `CSI ? u` reports the flags the hidden app actually +pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer +`?0u`; protocol-conformant programs re-push. ### ConPTY DA1 variant @@ -163,8 +168,8 @@ emulator parser (the main-side twin of the forwarding predicate. The override is installed at emulator creation and retrofitted when the spawn mark lands (daemon stream data can create the emulator before the awaited spawn response marks the PTY). ConPTY blocking on -a missing DA1 is a spawn-time hazard; see the races section for the -hidden-at-spawn loss window that remains until Phase 6. +a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is +closed by the slice-3 `initiallyHidden` spawn flag (races section). ## Suppression: when main never replies @@ -206,12 +211,17 @@ That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or tolerate silence, as they did for every hidden pane before this phase). The one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible pane or an active codex startup window answers it from the renderer xterm. -But a PTY spawned hidden **without** the startup window has no answerer until -the renderer's hidden mark lands in main (one IPC hop after spawn): a DA1 -arriving in that pre-mark window is lost. That loss window is the pre-Phase-4 -hidden status quo and persists until Phase 6 marks hidden panes at spawn -(spawn-record flag, below) — spawn-time ownership is not deterministic before -then. +A PTY spawned hidden **without** the startup window previously had no +answerer until the renderer's hidden mark landed in main (one IPC hop after +spawn). Slice 3 closes that window with the `initiallyHidden` spawn-record +flag: the renderer declares hidden-at-spawn on `pty:spawn` (never while the +codex startup window would run, and never for remote-runtime transports), +and main marks the PTY hidden before the first byte — pre-spawn for +daemon-host sessions whose id is minted up front, immediately after +`provider.spawn` resolves otherwise — so the gate and responder own queries +from byte one. The pane's first visibility sync then re-marks or unmarks +through the existing Phase-4 machinery (unmark emits the restore marker for +any spawn-window drops). ## Invariants @@ -283,16 +293,26 @@ otherwise untouched in this phase. ## What Phase 6 (delete skip grammar + startup window) requires from this design -- **Mark-before-first-byte**: panes spawned without a visible view must be - hidden-marked at spawn (spawn-record flag, not a renderer round trip) so - startup queries — including ConPTY's blocking DA1 and codex startup probes — - are main-owned from byte zero once the 10s window is gone. -- **Attributes before spawn**: the renderer must push view attributes at app - start, before any hidden spawn, or spawn-time view-attribute queries fall - into the silent-until-push rule. -- **Daemon shell-ready write gating** queues responder replies until the - ready marker; spawn-time replies on Windows daemon PTYs need explicit - validation before the window is removed. +Accepted and shipped in slice 3 (except where noted): + +- **Mark-before-first-byte** (shipped): panes spawned without a visible view + are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn` + (spawn-record flag, not a renderer round trip) so startup queries — + including ConPTY's blocking DA1 — are main-owned from byte zero. Codex + startup probes stay renderer-answered while the 10s window exists: the + renderer never sets the flag for codex startups; once Phase 6 deletes the + window, dropping that exclusion makes codex spawns main-owned too. +- **Attributes before spawn** (shipped): the renderer pushes composed view + attributes once at app start (right after settings load, before terminal + reconnect/spawn), so spawn-time view-attribute queries no longer fall into + the silent-until-push rule. Per-pane appearance applies keep re-publishing + through the same deduped publisher. +- **Daemon shell-ready write gating** (verified): responder replies through + `ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never + dropped, and the queue flushes at the shell-ready marker or the 15s + `SHELL_READY_TIMEOUT_MS` bound (`session.ts`). Spawn-time replies on + Windows daemon PTYs still need explicit e2e validation before the codex + window is removed. - With the skip grammar deleted, every chunk is either written to a live xterm or dropped — the delivered-but-skipped no-reply gap disappears and the only remaining loss window is the mark IPC race. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md index ccbf719d36b..5f22edf9be5 100644 --- a/docs/reference/terminal-side-effect-authority.md +++ b/docs/reference/terminal-side-effect-authority.md @@ -34,7 +34,7 @@ Remote-runtime PTYs (`remote:`) never transit local main; the renderer | OSC 133;D command-finished exit code | main | main | renderer | | GitHub PR-link scan | main | main | renderer | | Command Code output scrape | main (shipped: per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main (shipped) | renderer | -| DECSET 2031 color-scheme reply | renderer view/watcher — query authority stays with the view (contract invariant 6) | same | renderer | +| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer | | DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | ## Main-Side Tracker diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 53322fd237c..151ded8f70f 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -216,12 +216,20 @@ export class DaemonPtyAdapter implements IPtyProvider { const isAltScreen = result.snapshot.modes.alternateScreen const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi + // Why kitty flags ride beside the payload, not inside it: the snapshot + // string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's + // deliberate kitty reset must win. Only the runtime emulator re-seed + // consumes the flags (terminal-query-authority.md §kitty). + const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { id: sessionId, pid, snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 + ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } + : {}), isReattach: true, isAlternateScreen: isAltScreen } diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 984939cd499..5d4fe4e4550 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -157,6 +157,20 @@ export class HeadlessEmulator { this.viewAttributeResponder?.clearColorOverrides() } + /** Re-seed parity for snapshot `modes.kittyKeyboardFlags` + * (terminal-query-authority.md §kitty): replays the persisted flags + * through the same `CSI = flags ; 1 u` parse a live push uses, so hidden + * `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write — + * outside any forwarding window, it can never answer anything — and never + * into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty + * reset stays authoritative). */ + applyKittyKeyboardFlags(flags: number): Promise { + if (!Number.isInteger(flags) || flags <= 0) { + return Promise.resolve() + } + return this.write(`\x1b[=${flags};1u`) + } + private emitQueryReply(reply: string): void { if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { this.onQueryReply(reply) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index bb99d1ffaba..efd598521e0 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -162,10 +162,13 @@ describe('Session', () => { describe('emulator does not reply to terminal queries', () => { // Why: daemon emulator parses in-process synchronously — before - // handleSubprocessData forwards bytes to the renderer over IPC — so any - // auto-reply it emits races ahead of the renderer's xterm and clobbers - // it with default-xterm values (no theme, stale cursor). The renderer is - // the authoritative responder; a daemon-side reply to any query is a bug. + // handleSubprocessData forwards bytes onward — so any auto-reply it + // emits races ahead of the live answerer and clobbers it with + // default-xterm values (no theme, stale cursor). Query authority is + // structural (terminal-query-authority.md): a delivered chunk is + // answered by the consuming view's xterm, a hidden-dropped chunk by + // MAIN's runtime model responder. The daemon emulator is neither — it + // stays write-only forever, and these pins are permanent. it.each([ ['OSC 10 foreground-color', '\x1b]10;?\x07'], ['OSC 11 background-color', '\x1b]11;?\x07'], diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 610f17add0f..0f9458fabdc 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -35,8 +35,10 @@ export type TerminalModes = { applicationCursor: boolean alternateScreen: boolean /** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed - * parity ONLY. Produced but not yet consumed — the re-seed consumer is - * slice-3 work; do not mistake this field for live snapshot parity. + * parity ONLY. Consumed by the daemon warm-reattach path: the spawn + * result threads them into seedHeadlessTerminal, which re-applies them to + * the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags) + * so hidden `CSI ? u` answers the real flags instead of ?0u. * rehydrateSequences must never push these into a renderer xterm — * POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative * (terminal-query-authority.md §kitty). */ diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index dc14b3514e1..f51ea3a6eb2 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -160,7 +160,11 @@ import { rebindLocalProviderListeners, unregisterSshPtyProvider } from './pty' -import { _resetHiddenRendererPtyDeliveryGateForTest } from './pty-hidden-delivery-gate' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + isHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { encodePowerShellCommand, @@ -430,11 +434,12 @@ describe('registerPtyHandlers', () => { const spawn = vi.fn(async (options: { sessionId?: string }) => ({ id: options.sessionId ?? 'daemon-pty' })) + const write = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null setLocalPtyProvider({ spawn, - write: vi.fn(), + write, resize: vi.fn(), kill: vi.fn(), shutdown: vi.fn(), @@ -463,6 +468,7 @@ describe('registerPtyHandlers', () => { } as never) return { spawn, + write, emitData: (id: string, data: string) => dataHandler?.({ id, data }), emitExit: (id: string, code = 0) => exitHandler?.({ id, code }) } @@ -5425,6 +5431,186 @@ describe('registerPtyHandlers', () => { }) }) + describe('hidden-at-spawn mark (initiallyHidden)', () => { + // terminal-query-authority.md §races: the renderer declares hidden-at- + // spawn so main marks the PTY before its first byte — the spawn-time + // query window where neither side replied (the non-codex DA1 loss) is + // closed by the gate + responder owning queries from byte one. + function createRuntimeMock() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + } + + it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => { + vi.useFakeTimers() + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) as Promise<{ id: string }> + // Let the handler run up to the awaited provider.spawn. + await Promise.resolve() + mainWindow.webContents.send.mockClear() + + // Daemon PTYs can emit prompt bytes before spawn() resolves — the + // pre-spawn mark must already gate them. + expect(isHiddenRendererPty('daemon-session')).toBe(true) + daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c') + vi.advanceTimersByTime(50) + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'daemon-session', + 'pre-spawn prompt\x1b[c', + expect.any(Number) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'daemon-session', + reason: 'hidden-drop', + markerSeq: 42 + }) + + spawnGate.resolve() + const result = await spawnPromise + expect(isHiddenRendererPty(result.id)).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('clears the pre-spawn hidden mark when the spawn fails', async () => { + const daemon = installObservableDaemonTestProvider() + daemon.spawn.mockRejectedValue(new Error('spawn exploded')) + registerPtyHandlers(mainWindow as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) + ).rejects.toThrow('spawn exploded') + + // A later visible attach reusing this session id must not start gated. + expect(isHiddenRendererPty('daemon-session')).toBe(false) + }) + + it('marks local PTYs hidden after spawn, before their first data task', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + initiallyHidden: true + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(true) + mockProc.emitData('first chunk') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(false) + mockProc.emitData('visible output') + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => { + // End-to-end through a REAL runtime: spawn-marked → first chunk dropped + // → runtime emulator parses the query → reply written to the provider + // input path (the renderer never saw the bytes; main is the answerer). + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true + }) + } as never) + + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + })) as { id: string } + + daemon.emitData(result.id, '\x1b[c') + // Settle the per-PTY emulator writeChain (and the reply it forwards). + await runtime.serializeMainTerminalBuffer(result.id) + + expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c') + }) + }) + it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c146039c5ab..6e71dfa591f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2215,6 +2215,12 @@ export function registerPtyHandlers( worktreeId?: string sessionId?: string shellOverride?: string + // Why: hidden-at-spawn declaration (terminal-query-authority.md + // §races) — the renderer knows at spawn time that no visible view + // will consume this PTY's bytes, so main marks it hidden BEFORE the + // first byte and the gate + model responder own spawn-time queries. + // The renderer never sets this while the codex startup window runs. + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -2497,6 +2503,20 @@ export function registerPtyHandlers( ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined } + const initiallyHidden = args.initiallyHidden === true + // Why pre-spawn for daemon-host sessions (id minted up front): daemon + // PTYs can emit prompt bytes before spawn() resolves, and the hidden + // mark must beat the first byte so the gate + model responder own + // spawn-time queries (terminal-query-authority.md §races). Other + // providers cannot emit until spawn resolves; the post-spawn mark + // below is byte-zero-safe for them. + const preSpawnHiddenMarkId = + initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined + ? effectiveSessionAppId + : null + if (preSpawnHiddenMarkId !== null) { + markHiddenRendererPty(preSpawnHiddenMarkId) + } let result: PtySpawnResult try { if (preAllocatedHandle) { @@ -2504,6 +2524,11 @@ export function registerPtyHandlers( } result = await provider.spawn(spawnOptions) } catch (err) { + // Why: a failed spawn must not leave a stale hidden mark on a session + // id a later visible attach may reuse. + if (preSpawnHiddenMarkId !== null) { + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) if (effectiveSessionAppId !== undefined) { @@ -2561,6 +2586,18 @@ export function registerPtyHandlers( } } ptyOwnership.set(result.id, args.connectionId ?? null) + if (initiallyHidden) { + // Why marked synchronously before any await below: local/SSH provider + // data events dispatch on later tasks, so this is still ahead of the + // first byte's delivery decision. Idempotent for daemon hosts already + // marked pre-spawn; the renderer's first visibility sync re-marks or + // unmarks (emitting the restore marker) through the Phase-4 path. + markHiddenRendererPty(result.id) + if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { + // Defense: never strand a mark on an id the provider renamed. + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } + } // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY // determination from the spawn record before the headless seed below, // so the runtime emulator's DA1 override exists from byte zero. @@ -2664,7 +2701,18 @@ export function registerPtyHandlers( ? { cols: result.snapshotCols, rows: result.snapshotRows } : undefined if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { - runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize) + // Why kitty flags ride seed metadata: the snapshot string omits + // them by design (renderer kitty reset stays authoritative), but + // the re-seeded emulator must answer hidden `CSI ? u` with the + // flags the still-running app pushed (terminal-query-authority.md). + runtime.seedHeadlessTerminal( + result.id, + result.snapshot, + seedSize, + typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {} + ) } else if ( result.coldRestore && typeof result.coldRestore.scrollback === 'string' && diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index d699600350c..26580f0ca02 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -70,6 +70,11 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the + * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags + * (terminal-query-authority.md §kitty). Never replayed into a renderer + * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ + snapshotKittyKeyboardFlags?: number /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** True when the reattached session uses the alternate screen buffer diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fbdcd8bb31e..337634d489b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -772,6 +772,10 @@ type RuntimeHeadlessTerminal = { type HeadlessSeedMetadata = { cwd?: string | null + /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh + * emulator so hidden `CSI ? u` answers the real flags instead of ?0u + * (terminal-query-authority.md §kitty). */ + kittyKeyboardFlags?: number } type RuntimePtyController = { @@ -4083,6 +4087,13 @@ export class OrcaRuntimeService { // Why: seed writes never set forwardQueryReplies — the main-side // replay guard. A snapshot containing old queries must answer no one. await state.emulator.write(data) + // Why AFTER the seed write: the snapshot payload cannot carry kitty + // pushes (rehydrateSequences deliberately omits them), but ordering + // behind it keeps the parse deterministic. Unflagged like the seed — + // re-applying flags must answer no one. + if (typeof metadata.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags) + } if (metadata.cwd !== undefined) { state.emulator.setCwd(metadata.cwd) } @@ -4245,6 +4256,11 @@ export class OrcaRuntimeService { // disposeHeadlessTerminal, and daemon respawns reuse session ids — a // stale link's reply must never reach a successor PTY under this id. if (state !== null && this.headlessTerminals.get(ptyId) === state) { + // Why this write is safe pre-shell-ready: daemon Session.write + // QUEUES (never drops) input while the POSIX shell-ready gate is + // pending and flushes at the ready marker or the 15s + // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query + // reply is delayed at most that bound, not lost. this.ptyController?.write(ptyId, reply) } } diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 093e1a8efbf..b6988e06731 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -295,6 +295,37 @@ describe('main-side replay guard', () => { }) }) +describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => { + it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty') + + // Daemon warm-reattach threads modes.kittyKeyboardFlags through the + // spawn result into the seed; applying them is a seed-side write and + // must answer no one (main-side replay guard). + runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, { + kittyKeyboardFlags: 5 + }) + await settle(runtime, 'pty-kitty') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u']) + }) + + it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty0') + + runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt') + runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty0') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u']) + }) +}) + describe('ingestion-time ownership capture', () => { const DA1 = '\x1b[c' diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 88e1c6382ce..382aea2916d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -908,6 +908,10 @@ export type PreloadApi = { // Preserved from the deleted index.d.ts PtyApi duplicate during the // single-source-of-truth collapse (see docs/preload-typecheck-hole.md §1). shellOverride?: string + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md — main // sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before // pty:spawn returns. Only the renderer's daemon-host path threads these. diff --git a/src/preload/index.ts b/src/preload/index.ts index 2edcbe8acdd..abfbfe453d7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -650,6 +650,10 @@ const api = { worktreeId?: string sessionId?: string shellOverride?: string + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6246d80c576..304179f1d9c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -93,6 +93,7 @@ import { import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' import { applyDocumentTheme } from './lib/document-theme' import { getSystemPrefersDark } from './lib/terminal-theme' +import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance' import { isEditableTarget } from './lib/editable-target' import { getSelectedTextForFileSearch } from './lib/file-search-selection' import { useShortcutLabel } from './hooks/useShortcutLabel' @@ -721,6 +722,14 @@ function App(): React.JSX.Element { // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. await actions.fetchSettings() + // Why here: hidden-at-launch PTYs (background terminal reconnects, + // agent sessions) can query OSC 10/11 before any terminal pane mounts + // and main's responder is silent-until-first-push. Publish composed + // view attributes as soon as settings exist, before any spawn below. + publishTerminalViewAttributesAtAppStart( + useAppStore.getState().settings, + getSystemPrefersDark() + ) await actions.fetchRepos() await actions.fetchProjectGroups() await actions.fetchAllWorktrees() 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 9601407d02e..8afcbadb240 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -3338,6 +3338,37 @@ describe('connectPanePty', () => { expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) }) + it('declares hidden-at-spawn on connect for hidden non-codex panes', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Why: waiting for the first dataCallback sync left a spawn-time query + // window where neither side replied (the non-codex DA1 loss). The flag + // lets main mark the PTY hidden before its first byte. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('keeps visible spawns undeclared (visible spawn unchanged)', async () => { + enableMainAuthority() + const deps = createDeps() + const { transport } = await connectHiddenPane(deps) + expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + }) + + it('never declares hidden-at-spawn while the codex startup window is active', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport } = await connectHiddenPane(deps) + // Codex startup probes need live renderer delivery for the 10s window; + // a spawn-time hidden mark would gate them (codex spawns keep delivery). + expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + }) + it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { enableMainAuthority() mockStoreState.settings = { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 6f87a319a98..1f151949f3f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1993,6 +1993,7 @@ export function connectPanePty( url: '', cols, rows, + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -2212,6 +2213,24 @@ export function connectPanePty( ) } + // Why: hidden/parked panes used to mark hidden only at the first + // dataCallback sync, leaving a spawn-time window where neither side + // answered queries (the non-codex DA1 loss). Declaring hidden on the + // spawn IPC lets main mark the PTY before its first byte. Codex startups + // are excluded — their startup window needs live renderer delivery, and + // the window predicate is checked at connect time (same tick the flag is + // sent), so the two decisions cannot disagree. Remote-runtime PTYs are + // never gate-markable (no local main transit). + function shouldDeclareHiddenAtSpawn(): boolean { + return ( + hiddenDeliveryGateActive && + !runtimeEnvironmentId && + !disposed && + !shouldWritePtyOutputForeground(deps.isVisibleRef.current) && + !isHiddenStartupRendererQueryWindowActive() + ) + } + // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── // Why: marks this pane's PTY hidden in main while no visible view needs // its bytes; main then drops delivery after model ingestion and reveal @@ -3428,6 +3447,7 @@ export function connectPanePty( cols, rows, sessionId: pendingSessionId, + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -3565,6 +3585,7 @@ export function connectPanePty( cols, rows, sessionId: deferredReattachSessionId, + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 20d90d39307..4c5164d0933 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -314,6 +314,12 @@ export type PtyTransport = { /** Daemon session ID for reattach. When provided, the daemon reconnects * to an existing session instead of creating a new one. */ sessionId?: string + /** Hidden-at-spawn declaration (terminal-query-authority.md): no visible + * view will consume this PTY's bytes, so main marks it hidden BEFORE the + * first byte and the gate + model responder own spawn-time queries. + * Never set while the codex startup window would run, and ignored by + * remote-runtime transports (their PTYs are not gate-markable). */ + initiallyHidden?: boolean callbacks: { onConnect?: () => void onDisconnect?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 4b870e3a115..55e75104a3b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -562,6 +562,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra command, ...(connectionId ? { connectionId } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), + // Why: hidden-at-spawn mark must land in main before the PTY's + // first byte, so it rides the spawn IPC instead of the pane's + // first visibility sync (terminal-query-authority.md). + ...(options.initiallyHidden ? { initiallyHidden: true } : {}), worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 61b745a606c..b7e088ff4aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -7,9 +7,12 @@ import { hexToRgba, installMode2031Handlers, maybePushMode2031Flip, - mode2031SequenceFor + mode2031SequenceFor, + publishTerminalViewAttributesAtAppStart } from './terminal-appearance' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): { isConnected: () => boolean @@ -437,6 +440,71 @@ describe('applyTerminalAppearance theme assignment', () => { }) }) +describe('publishTerminalViewAttributesAtAppStart', () => { + // Phase 6 prerequisite (terminal-query-authority.md): hidden-at-launch + // PTYs can query OSC 10/11 before any terminal pane mounts; the app-start + // publication must go out with no pane manager involved at all. + it('publishes composed attributes without any pane mount and dedupes repeats', () => { + _resetTerminalViewAttributesPublisherForTest() + const sent: TerminalViewAttributes[] = [] + const send = (attributes: TerminalViewAttributes): boolean => { + sent.push(attributes) + return true + } + const settings = getDefaultSettings('/tmp') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true) + expect(sent).toHaveLength(1) + expect(sent[0]!.ansi).toHaveLength(256) + expect(sent[0]!.cursorStyle).toBe(settings.terminalCursorStyle ?? 'block') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(false) + expect(sent).toHaveLength(1) + }) + + it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => { + _resetTerminalViewAttributesPublisherForTest() + const publishMock = vi.fn() + ;(globalThis as unknown as { window: unknown }).window = { + api: { pty: { publishTerminalViewAttributes: publishMock } } + } + try { + const settings = getDefaultSettings('/tmp') + publishTerminalViewAttributesAtAppStart(settings, true) + expect(publishMock).toHaveBeenCalledTimes(1) + + // The first pane mount composes the identical app-global snapshot, so + // the publisher dedupe keeps it a single push. + const manager = { + getPanes: () => [], + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + applyTerminalAppearance( + manager, + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publishMock).toHaveBeenCalledTimes(1) + } finally { + delete (globalThis as { window?: unknown }).window + _resetTerminalViewAttributesPublisherForTest() + } + }) + + it('publishes nothing before settings are loaded', () => { + _resetTerminalViewAttributesPublisherForTest() + const send = vi.fn(() => true) + expect(publishTerminalViewAttributesAtAppStart(null, true, send)).toBe(false) + expect(send).not.toHaveBeenCalled() + }) +}) + describe('hexToRgba', () => { it('converts 6-char hex to rgba', () => { expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)') diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index f2c4ac1c731..a2e072a93ed 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -16,6 +16,7 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' export { mode2031SequenceFor } @@ -196,6 +197,28 @@ export function composeActiveTerminalTheme( return theme } +/** App-start publication (terminal-query-authority.md §Phase 6 + * prerequisites): hidden-at-launch PTYs can query OSC 10/11 before any + * terminal pane mounts, and main's responder is silent-until-first-push. + * Composes the same theme applyTerminalAppearance would and publishes it + * through the same deduped publisher, so the later pane-mount apply is a + * no-op re-push. Returns whether a publish actually went out. */ +export function publishTerminalViewAttributesAtAppStart( + settings: GlobalSettings | null | undefined, + systemPrefersDark: boolean, + send?: (attributes: TerminalViewAttributes) => boolean +): boolean { + if (!settings) { + return false + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) + const theme = composeActiveTerminalTheme(baseTheme, settings) + return send !== undefined + ? publishTerminalViewAttributes(theme, appearance.mode, settings, send) + : publishTerminalViewAttributes(theme, appearance.mode, settings) +} + // Value equality over composed ITheme objects (flat string slots plus the // extendedAnsi string array), used to gate the per-pane options.theme write. function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean { From 0ba2412788da7d04b64e2fe6c438ad74a5e8ce3a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:08:27 -0700 Subject: [PATCH 48/62] Delete the hidden renderer skip grammar Co-authored-by: Orca --- docs/reference/terminal-query-authority.md | 29 +- src/main/ipc/pty.ts | 1 - .../hidden-renderer-skip-eligibility.test.ts | 224 ----- .../hidden-renderer-skip-eligibility.ts | 188 ----- .../parked-terminal-mode2031-responder.ts | 5 + .../terminal-pane/pty-connection.test.ts | 776 ++---------------- .../terminal-pane/pty-connection.ts | 281 +------ .../terminal-pane/pty-dispatcher.ts | 3 +- ...icial-opencode-hidden-pressure-scenario.ts | 20 +- ...cial-opencode-revisit-pressure-scenario.ts | 9 +- .../artificial-opencode-terminal-load.spec.ts | 39 +- ...terminal-hidden-tui-visual-restore.spec.ts | 4 +- ...terminal-long-table-scroll-restore.spec.ts | 32 - tests/e2e/terminal-parked-memory.spec.ts | 2 +- 14 files changed, 164 insertions(+), 1449 deletions(-) delete mode 100644 src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index 5fb163d26d0..a2fee4f7313 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -175,9 +175,6 @@ closed by the slice-3 `initiallyHidden` spawn flag (races section). - Visible or unmarked PTY (chunk was delivered). - Renderer delivery interest registered (chunk was delivered to a sidecar). -- Codex startup window active — the renderer never marks the PTY hidden while - the window runs (`pty-connection.ts:2259-2261`), so the gate predicate is - structurally false; the live xterm answers startup probes. - Remote-runtime (`remote:`) PTYs — never markable (`isHiddenDeliveryGateManagedPty`), bytes never transit local main. - Remote view subscriber attached (mobile/web/remote desktop owns replies). @@ -210,13 +207,12 @@ point per chunk); where the race costs anything it costs a missing reply. That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or tolerate silence, as they did for every hidden pane before this phase). The one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible -pane or an active codex startup window answers it from the renderer xterm. -A PTY spawned hidden **without** the startup window previously had no -answerer until the renderer's hidden mark landed in main (one IPC hop after -spawn). Slice 3 closes that window with the `initiallyHidden` spawn-record -flag: the renderer declares hidden-at-spawn on `pty:spawn` (never while the -codex startup window would run, and never for remote-runtime transports), -and main marks the PTY hidden before the first byte — pre-spawn for +pane answers it from the renderer xterm. A PTY spawned hidden previously had +no answerer until the renderer's hidden mark landed in main (one IPC hop +after spawn). Slice 3 closes that window with the `initiallyHidden` +spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn` +(never for remote-runtime transports), and main marks the PTY hidden before +the first byte — pre-spawn for daemon-host sessions whose id is minted up front, immediately after `provider.spawn` resolves otherwise — so the gate and responder own queries from byte one. The pane's first visibility sync then re-marks or unmarks @@ -293,15 +289,20 @@ otherwise untouched in this phase. ## What Phase 6 (delete skip grammar + startup window) requires from this design +Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s +codex startup renderer-query window are deleted. Kill-switch-off hidden panes +fall back to the pre-grammar path — hidden bytes ride the bounded background +scheduler queue; overflow latches the model-snapshot restore — and never run +a per-chunk content scan. + Accepted and shipped in slice 3 (except where noted): - **Mark-before-first-byte** (shipped): panes spawned without a visible view are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn` (spawn-record flag, not a renderer round trip) so startup queries — - including ConPTY's blocking DA1 — are main-owned from byte zero. Codex - startup probes stay renderer-answered while the 10s window exists: the - renderer never sets the flag for codex startups; once Phase 6 deletes the - window, dropping that exclusion makes codex spawns main-owned too. + including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6 + removed the codex exclusion with the window: codex spawns are main-owned + from byte zero too, the responder answering their startup probes. - **Attributes before spawn** (shipped): the renderer pushes composed view attributes once at app start (right after settings load, before terminal reconnect/spawn), so spawn-time view-attribute queries no longer fall into diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 6e71dfa591f..4d894e57ed0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2219,7 +2219,6 @@ export function registerPtyHandlers( // §races) — the renderer knows at spawn time that no visible view // will consume this PTY's bytes, so main marks it hidden BEFORE the // first byte and the gate + model responder own spawn-time queries. - // The renderer never sets this while the codex startup window runs. initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → 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 deleted file mode 100644 index da233792582..00000000000 --- a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -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('skips hidden width-stable Latin output when a snapshot restore is available', () => { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data: 'café déjà vu São Tomé Żubrówka Ḃḃ\r\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('skips complete hidden title OSC chunks when a snapshot restore is available', () => { - for (const data of ['\x1b]0;window title\x07', '\x1b]1;icon title\x07', '\x1b]2;both\x1b\\']) { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data - }) - ).toBe(true) - } - }) - - it('skips hidden title OSC mixed with otherwise restorable plain output', () => { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data: 'line before\r\n\x1b]0;next title\x07line after\r\n' - }) - ).toBe(true) - }) - - it('keeps hidden synchronized redraw chunks live without the model restore gate', () => { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: true, - data: '\x1b[?2026h\x1b[1;1H\x1b[2J\x1b[32mready\x1b[0m\x1b[?25l\x1b[?2026l\n' - }) - ).toBe(false) - }) - - it('skips model-restorable synchronized rich chunks when model restore is allowed', () => { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: true, - allowSynchronizedModelRestore: true, - data: '\x1b[?2026h\x1b[?1049h\x1b[2J\x1b[H╭ rich 😀 ╮\r\n\x1b[?25l\x1b[?2026l' - }) - ).toBe(true) - }) - - it('skips synchronized model output with PTY-mapped CRCRLF newlines', () => { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: true, - allowSynchronizedModelRestore: true, - data: '\x1b[?2026h\x1b[2J\x1b[H╭ rich 😀 ╮\r\r\n\x1b[?2026l' - }) - ).toBe(true) - }) - - it('keeps query and incomplete synchronized chunks live even with model restore allowed', () => { - for (const data of [ - '\x1b[?2026h\x1b[6n', - '\x1b[?2026h\x1b[c', - '\x1b[?2026h\x1b[?25', - '\x1b[?2026h\x9b6n' - ]) { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: true, - allowSynchronizedModelRestore: true, - data - }) - ).toBe(false) - } - }) - - it('keeps startup query windows live', () => { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: true, - synchronizedOutputActive: false, - data: 'plain\r\n' - }) - ).toBe(false) - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: true, - synchronizedOutputActive: false, - data: '\x1b]0;title\x07' - }) - ).toBe(false) - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: true, - synchronizedOutputActive: true, - allowSynchronizedModelRestore: true, - data: '\x1b[?2026h\x1b[2J\x1b[Hmodel-restorable\r\n\x1b[?2026l' - }) - ).toBe(false) - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data: '\x1b[?1;2c' - }) - ).toBe(false) - }) - - it('keeps query and incomplete control chunks live', () => { - for (const data of ['\x1b[6n', '\x1b[c', '\x1b[?25', '\x1b[?1049h']) { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data - }) - ).toBe(false) - } - }) - - it('keeps non-title OSC and incomplete title OSC chunks live', () => { - for (const data of ['\x1b]52;c;clipboard\x07', '\x1b]9;notify\x07', '\x1b]0;partial-title']) { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data - }) - ).toBe(false) - } - }) - - it('keeps rewrite and wide or combining unicode chunks live', () => { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data: 'progress 10%\rprogress 20%' - }) - ).toBe(false) - for (const data of ['emoji 😀\r\n', '漢字 table\r\n', 'combining e\u0301\r\n']) { - expect( - shouldSkipHiddenRendererOutput({ - foreground: false, - canRestoreHiddenOutput: true, - startupRendererQueryWindowActive: false, - synchronizedOutputActive: false, - data - }) - ).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 deleted file mode 100644 index 015096c63cc..00000000000 --- a/src/renderer/src/components/terminal-pane/hidden-renderer-skip-eligibility.ts +++ /dev/null @@ -1,188 +0,0 @@ -export type HiddenRendererSkipEligibility = { - foreground: boolean - canRestoreHiddenOutput: boolean - startupRendererQueryWindowActive: boolean - synchronizedOutputActive: boolean - allowSynchronizedModelRestore?: boolean - data: string -} - -function isAllowedPlainHiddenOutputCodePoint(codePoint: number): boolean { - if (codePoint === 0x09 || codePoint === 0x0a) { - return true - } - if (codePoint >= 0x20 && codePoint <= 0x7e) { - return true - } - // Why: hidden restore can safely replay ordinary single-cell Latin text from - // headless state, while wide/combining/table glyph classes stay live. - return ( - (codePoint >= 0x00a0 && codePoint <= 0x024f) || (codePoint >= 0x1e00 && codePoint <= 0x1eff) - ) -} - -function findTitleOscEnd(data: string, startIndex: number): number | null { - const command = data.charCodeAt(startIndex + 2) - if ( - data.charCodeAt(startIndex) !== 0x1b || - data.charCodeAt(startIndex + 1) !== 0x5d || - (command !== 0x30 && command !== 0x31 && command !== 0x32) || - data.charCodeAt(startIndex + 3) !== 0x3b - ) { - return null - } - - for (let index = startIndex + 4; index < data.length; index++) { - const code = data.charCodeAt(index) - if (code === 0x07) { - return index + 1 - } - if (code === 0x1b) { - return data.charCodeAt(index + 1) === 0x5c ? index + 2 : null - } - } - return null -} - -function findSafeCsiEnd( - data: string, - startIndex: number, - mode: 'plain' | 'synchronized-model' = 'plain' -): number | null { - if (data.charCodeAt(startIndex) !== 0x1b || data.charCodeAt(startIndex + 1) !== 0x5b) { - return null - } - - for (let index = startIndex + 2; index < data.length; index++) { - const code = data.charCodeAt(index) - if (code < 0x40 || code > 0x7e) { - continue - } - const body = data.slice(startIndex + 2, index) - const final = data[index] - if (isSafeHiddenRedrawCsi(body, final, mode)) { - return index + 1 - } - return null - } - return null -} - -function isSafeHiddenRedrawCsi( - body: string, - final: string, - mode: 'plain' | 'synchronized-model' -): boolean { - if (/[^0-9;?]/.test(body)) { - return false - } - if (final === 'h' || final === 'l') { - return body === '?2026' || body === '?25' || (mode === 'synchronized-model' && body === '?1049') - } - return ( - final === 'm' || - final === 'H' || - final === 'f' || - final === 'A' || - final === 'B' || - final === 'C' || - final === 'D' || - final === 'G' || - final === 'J' || - final === 'K' - ) -} - -function containsOnlyRestorableHiddenOutput(data: string): boolean { - for (let index = 0; index < data.length; ) { - const code = data.charCodeAt(index) - if (code === 0x1b) { - const nextIndex = findTitleOscEnd(data, index) ?? findSafeCsiEnd(data, index) - if (nextIndex === null) { - return false - } - index = nextIndex - continue - } - if (code === 0x0d) { - if (data.charCodeAt(index + 1) !== 0x0a) { - return false - } - index += 1 - continue - } - const codePoint = data.codePointAt(index) - if (typeof codePoint !== 'number' || !isAllowedPlainHiddenOutputCodePoint(codePoint)) { - return false - } - index += codePoint > 0xffff ? 2 : 1 - } - return true -} - -function containsOnlyModelRestorableSynchronizedOutput(data: string): boolean { - for (let index = 0; index < data.length; ) { - const code = data.charCodeAt(index) - if (code === 0x1b) { - const nextIndex = - findTitleOscEnd(data, index) ?? findSafeCsiEnd(data, index, 'synchronized-model') - if (nextIndex === null) { - return false - } - index = nextIndex - continue - } - if (code === 0x0d) { - let newlineIndex = index + 1 - // Why: real PTYs can map an app-written CRLF into CRCRLF. Treat only - // CR runs that immediately end in LF as newlines, not cursor rewrites. - while (data.charCodeAt(newlineIndex) === 0x0d) { - newlineIndex += 1 - } - if (data.charCodeAt(newlineIndex) !== 0x0a) { - return false - } - index = newlineIndex + 1 - continue - } - const codePoint = data.codePointAt(index) - if ( - typeof codePoint !== 'number' || - codePoint < 0x09 || - codePoint === 0x7f || - (codePoint >= 0x80 && codePoint <= 0x9f) - ) { - return false - } - if (codePoint < 0x20 && codePoint !== 0x09 && codePoint !== 0x0a) { - return false - } - index += codePoint > 0xffff ? 2 : 1 - } - return true -} - -export function shouldSkipHiddenRendererOutput({ - foreground, - canRestoreHiddenOutput, - startupRendererQueryWindowActive, - synchronizedOutputActive, - allowSynchronizedModelRestore = false, - data -}: HiddenRendererSkipEligibility): boolean { - if ( - foreground || - !canRestoreHiddenOutput || - startupRendererQueryWindowActive || - data.length === 0 - ) { - return false - } - if (synchronizedOutputActive) { - if (!allowSynchronizedModelRestore) { - return false - } - return containsOnlyModelRestorableSynchronizedOutput(data) - } - return containsOnlyRestorableHiddenOutput(data) -} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts index 8305c5c1edd..af37d4bbb90 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -8,6 +8,11 @@ * as a delivery-interest signal, so it is only used while the hidden-delivery * gate is OFF — gated parked PTYs answer from the main tracker's * '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts). + * + * Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes + * answer 2031 from xterm once the background queue drains, but a PARKED tab + * has no xterm in any switch-off mode, and the '2031-subscribe' fact is only + * consumed while the gate is ON — this sidecar stays the only answerer here. */ import { mode2031SequenceFor, 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 8afcbadb240..e189f2be709 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2958,59 +2958,11 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('restores safe hidden terminal-control bytes from the main snapshot', async () => { - 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 - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'control snapshot\r\n', - cols: 100, - rows: 30, - seq: 64 - }) - - 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) - - expect(capturedDataCallback.current).not.toBeNull() - const controlOutput = '\x1b[2J\x1b[Hhello\r\n' - capturedDataCallback.current?.(controlOutput, { - seq: controlOutput.length, - rawLength: controlOutput.length - }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(controlOutput, expect.any(Function)) - - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.('visible\r\n', { - seq: controlOutput.length + 'visible\r\n'.length, - rawLength: 'visible\r\n'.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining('control snapshot'), - expect.any(Function) - ) - }) - - it('keeps visually rich hidden PTY bytes on the live xterm path', async () => { + // Why: Phase 6 deleted the hidden-skip eligibility grammar. With the kill + // switch off, EVERY hidden chunk — plain, control-heavy, rich glyphs, + // synchronized frames, embedded queries — rides the bounded background + // scheduler queue and parses in xterm; nothing is content-scanned per chunk. + it('queues hidden PTY bytes on the background scheduler without per-chunk scanning', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -3032,57 +2984,32 @@ describe('connectPanePty', () => { expect(capturedDataCallback.current).not.toBeNull() vi.useFakeTimers() try { - const hiddenTuiChunk = '\x1b[2J\x1b[H╭ table 😀 ╮\r\n' - capturedDataCallback.current?.(hiddenTuiChunk) + const hiddenChunks = [ + 'plain hidden text\r\n', + '\x1b[2J\x1b[Hcontrol redraw\r\n', + '\x1b[2J\x1b[H╭ table 😀 ╮\r\n', + '\x1b[?2026h| Sam Syntax | 😀 |\r\n\x1b[?2026l', + '\x1b[?2026h\x1b[6n' + ] + for (const chunk of hiddenChunks) { + capturedDataCallback.current?.(chunk) + } - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTuiChunk) + // Background path defers writes; nothing is written synchronously. + expect(pane.terminal.write).not.toHaveBeenCalled() vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(hiddenTuiChunk) + // The drain may coalesce queued chunks into one write — assert content. + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + for (const chunk of hiddenChunks) { + expect(written).toContain(chunk) + } + // No model restore is latched for bounded hidden output. expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() } finally { vi.useRealTimers() } }) - it('skips every chunk of a rich hidden synchronized frame for model-backed restore', 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) - - 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) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h\x1b[2J\x1b[Hsafe heading\r\n' - const richChunk = '| Sam Syntax | 😀 |\r\n' - const tailChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' - - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(richChunk) - capturedDataCallback.current?.(tailChunk) - - vi.advanceTimersByTime(50) - expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(startChunk)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(richChunk)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(tailChunk)) - } finally { - vi.useRealTimers() - } - }) - describe('hidden-delivery gate', () => { function enableMainAuthority(): void { mockStoreState.settings = { @@ -3142,6 +3069,11 @@ describe('connectPanePty', () => { dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + // Why: with the skip grammar gone, gated drops latch the restore via + // main's out-of-band marker, not a renderer-side content scan. + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + // Reveal rides the visible-resume backlog recovery hook. ;(deps.isVisibleRef as { current: boolean }).current = true const { requestTerminalBacklogRecovery } = @@ -3179,56 +3111,33 @@ describe('connectPanePty', () => { expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) }) - it('never marks hidden while the codex startup renderer-query window is active', async () => { + it('marks hidden codex panes immediately — no startup renderer-query window remains', async () => { enableMainAuthority() - // Why: only fake the clock — the default fake set would also replace - // the suite's synchronous requestAnimationFrame mock and the deferred - // connect frame would never run. - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - try { - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const { transport, dataCallback } = await connectHiddenPane(deps) - const setHiddenRendererPty = getSetHiddenRendererPtyMock() - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const factsHandler = await import('./terminal-side-effect-facts-handler') - - dataCallback('startup probe output\r\n') - expect(setHiddenRendererPty).not.toHaveBeenCalledWith('pty-id', true) - - // Why: the fact is the sole 2031 responder for gate-managed PTYs — - // even during the startup window (the xterm-side CSI reply is - // suppressed by the lifecycle for these panes), so a fact racing the - // hidden mark can never produce zero or two replies. - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 8, - facts: [{ kind: '2031-subscribe' }] - }) - expect(transport.sendInput).toHaveBeenCalledTimes(1) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') - - // Why: after the 10s window lapses, hidden output gates normally. - vi.advanceTimersByTime(10_001) - dataCallback('post window output\r\n') - expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) - - // Gated now — a new subscribe fact still gets exactly one reply. - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 16, - facts: [{ kind: '2031-subscribe' }] - }) - expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n') - } finally { - vi.useRealTimers() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: Phase 6 deleted the 10s codex window — codex startups gate like + // any hidden pane and the main responder answers their startup probes. + dataCallback('startup probe output\r\n') + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // The fact stays the sole 2031 responder for gate-managed PTYs. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 8, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') }) it('latches model restore from the out-of-band marker and restores on reveal', async () => { @@ -3338,12 +3247,12 @@ describe('connectPanePty', () => { expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) }) - it('declares hidden-at-spawn on connect for hidden non-codex panes', async () => { + it('declares hidden-at-spawn on connect for hidden panes', async () => { enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: false } }) const { transport } = await connectHiddenPane(deps) // Why: waiting for the first dataCallback sync left a spawn-time query - // window where neither side replied (the non-codex DA1 loss). The flag + // window where neither side replied (the spawn-time DA1 loss). The flag // lets main mark the PTY hidden before its first byte. expect(transport.connect).toHaveBeenCalledWith( expect.objectContaining({ initiallyHidden: true }) @@ -3357,16 +3266,21 @@ describe('connectPanePty', () => { expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') }) - it('never declares hidden-at-spawn while the codex startup window is active', async () => { + it('declares hidden-at-spawn for hidden codex panes too', async () => { enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: false }, startup: { command: 'codex' } }) const { transport } = await connectHiddenPane(deps) - // Codex startup probes need live renderer delivery for the 10s window; - // a spawn-time hidden mark would gate them (codex spawns keep delivery). - expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + // Why: the 10s codex startup window is deleted — codex spawns are + // main-owned from byte zero, with the model responder answering their + // startup probes (including ConPTY's blocking DA1; the main-side pin is + // pty.test.ts 'answers DA1 from the model on the first chunk of a + // hidden-at-spawn PTY'). + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) }) it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { @@ -3675,292 +3589,6 @@ describe('connectPanePty', () => { }) }) - it('skips split hidden synchronized output frames for model-backed restore', async () => { - 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 - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot table\r\nLONG_TABLE_SCROLL_RESTORE_marker\r\n', - cols: 100, - rows: 30, - seq: 80 - }) - - 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) - - expect(capturedDataCallback.current).not.toBeNull() - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' - const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' - - vi.useFakeTimers() - try { - capturedDataCallback.current?.(startChunk, { - seq: startChunk.length, - rawLength: startChunk.length - }) - capturedDataCallback.current?.(plainRowChunk, { - seq: startChunk.length + plainRowChunk.length, - rawLength: plainRowChunk.length - }) - capturedDataCallback.current?.(endChunk, { - seq: startChunk.length + plainRowChunk.length + endChunk.length, - rawLength: endChunk.length - }) - - vi.advanceTimersByTime(50) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(startChunk)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(plainRowChunk)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(endChunk)) - } finally { - vi.useRealTimers() - } - }) - - it('detects split hidden synchronized starts before skipping later payload', 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) - - 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) - - expect(capturedDataCallback.current).not.toBeNull() - const splitStartHead = '\x1b[?20' - const splitStartTail = '26h\x1b[2J\x1b[H' - const payload = 'split synchronized payload\r\n\x1b[?2026l' - - vi.useFakeTimers() - try { - capturedDataCallback.current?.(splitStartHead) - capturedDataCallback.current?.(splitStartTail) - capturedDataCallback.current?.(payload) - - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(splitStartHead)) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(splitStartTail)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(expect.stringContaining(payload)) - } finally { - vi.useRealTimers() - } - }) - - it('clears hidden synchronized state when the end marker arrives while visible', 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) - - const pane = createPane(1) - const manager = createManager(1) - const isVisibleRef = { current: false } - const deps = createDeps({ isVisibleRef }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - const hiddenSyncQueryChunk = '\x1b[?2026h\x1b[6n' - const foregroundEndChunk = 'done\x1b[?2026l' - const hiddenWideGlyphChunk = '│ 漢字 ║ 🚀 │\r\n' - - vi.useFakeTimers() - try { - capturedDataCallback.current?.(hiddenSyncQueryChunk) - isVisibleRef.current = true - capturedDataCallback.current?.(foregroundEndChunk) - isVisibleRef.current = false - capturedDataCallback.current?.(hiddenWideGlyphChunk) - - vi.advanceTimersByTime(50) - // Why: the synchronized frame ended while visible, so the wide-glyph - // hidden output must be judged by the strict plain rules and stay live. - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining(hiddenWideGlyphChunk) - ) - } finally { - vi.useRealTimers() - } - }) - - it('does not inherit hidden synchronized state across PTY restarts', 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) - - 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) - - expect(capturedDataCallback.current).not.toBeNull() - const hiddenSyncQueryChunk = '\x1b[?2026h\x1b[6n' - const hiddenWideGlyphChunk = '│ 漢字 ║ 🚀 │\r\n' - - vi.useFakeTimers() - try { - capturedDataCallback.current?.(hiddenSyncQueryChunk) - ;(transport.attach as unknown as (opts: { existingPtyId: string }) => void)({ - existingPtyId: 'pty-id-2' - }) - capturedDataCallback.current?.(hiddenWideGlyphChunk) - - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining(hiddenWideGlyphChunk) - ) - } finally { - vi.useRealTimers() - } - }) - - it('keeps hidden synchronized terminal queries on the live xterm path', 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) - - 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) - - const queryChunk = '\x1b[?2026h\x1b[6n' - vi.useFakeTimers() - try { - capturedDataCallback.current?.(queryChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(expect.stringContaining(queryChunk)) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('restores default hidden rich synchronized output from the headless model', async () => { - 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() @@ -4060,115 +3688,6 @@ describe('connectPanePty', () => { expect(pane.terminal.write).toHaveBeenCalledWith('backgrounded document output\r\n') }) - it('keeps hidden Codex telemetry startup output parsing briefly', 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) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { - command: 'wrapped-agent', - telemetry: { - agent_kind: 'codex', - launch_source: 'tab_bar_quick_launch', - request_kind: 'new' - } - } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - - expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b]11;?\x1b\\startup frame\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - - it('keeps hidden bare Codex startup commands parsing briefly', 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) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - - expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b]11;?\x1b\\startup frame\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - - it('skips arbitrary hidden startup output parsing', 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) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'printf noisy startup' } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('hidden startup output\r\n') - - expect(pane.terminal.write).not.toHaveBeenCalledWith( - 'hidden startup output\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -4206,157 +3725,21 @@ describe('connectPanePty', () => { binding.dispose() }) - 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: { - 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 hidden = 'small-hidden-output\r\n' - const live = 'visible-after-hidden\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: `snapshot-with-${hidden}`, - cols: 100, - rows: 30, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - 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?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) - - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - 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('restores hidden Latin text from the main snapshot when the pane returns', async () => { - 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 hidden = 'café déjà vu São Tomé Żubrówka\r\n' - const live = 'visible-after-hidden\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: `snapshot-with-${hidden}`, - cols: 100, - rows: 30, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - 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?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) - - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - 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('skips hidden title OSC renderer writes while keeping pane title handling wired', async () => { - 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 pane = createPane(1) - 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) - - const hiddenTitle = '\x1b]0;hidden title\x07' - capturedDataCallback.current?.(hiddenTitle, { - seq: hiddenTitle.length, - rawLength: hiddenTitle.length - }) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTitle, expect.any(Function)) - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - titleHandler('hidden title', 'hidden title') - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', pane.id, 'hidden title') - disposable.dispose() - }) - - it('restores plain hidden remote runtime output from its serialized snapshot', async () => { + it('restores overflowed 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 } + // Why: with the skip grammar gone, the model restore for remote-runtime + // PTYs is latched by background-queue overflow, not per-chunk scanning. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible remote output\r\n' transport.serializeBuffer = vi.fn().mockResolvedValue({ data: 'remote snapshot with hidden remote output\r\n', cols: 120, rows: 40, - seq: 40, + seq: hidden.length + live.length, source: 'headless' }) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -4374,14 +3757,12 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' - const live = 'visible remote output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) ;(deps.isVisibleRef as { current: boolean }).current = true capturedDataCallback.current?.(live, { - seq: 40 + live.length, + seq: hidden.length + live.length, rawLength: live.length }) await flushAsyncTicks(20) @@ -4430,7 +3811,9 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflowing the background queue is what latches the model + // restore now — the per-chunk skip grammar is gone. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -4494,7 +3877,8 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -4518,7 +3902,7 @@ describe('connectPanePty', () => { } }) - it('retries null remote snapshots for skipped plain hidden runtime output', async () => { + it('retries null remote snapshots for overflowed hidden runtime output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { @@ -4543,7 +3927,8 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const firstLive = 'first visible output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -4575,6 +3960,9 @@ describe('connectPanePty', () => { disposable.dispose() }) + // Why: pins the entire switch-off hidden fallback chain — hidden bytes ride + // the background queue, the 2MB lossy cap drops the backlog and latches the + // restore, and reveal repaints from the model snapshot. it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 1f151949f3f..aa20dbee4b3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -33,8 +33,7 @@ import { import { getSystemPrefersDark } from '@/lib/terminal-theme' import { mode2031SequenceFor, - resolveTerminalColorSchemeMode, - scanMode2031Sequences + resolveTerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' @@ -92,7 +91,6 @@ import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' import { isWslUncPath } from '../../../../shared/wsl-paths' -import { shouldSkipHiddenRendererOutput } from './hidden-renderer-skip-eligibility' import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, @@ -115,10 +113,7 @@ const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 -const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000 -const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 -const SYNCHRONIZED_OUTPUT_SCAN_TAIL_CHARS = 16 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 @@ -162,14 +157,11 @@ type E2eTerminalHiddenSnapshotOverride = { const e2eTerminalHiddenSnapshotOverrides = new Map() +// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes +// either never reach the renderer (delivery gate) or ride the background +// scheduler queue. Only the mode-2031 fact-reply counter still has a producer. type E2eTerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number - hiddenRendererLiveSynchronizedChars: number - hiddenRendererLiveNonSynchronizedChars: number - hiddenRendererStartupWindowChars: number - hiddenRendererSplitBoundaryChars: number } type E2eTerminalPtyOutputDebugApi = { @@ -182,23 +174,11 @@ type E2eTerminalPtyOutputDebugWindow = Window & { } const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: 0, - hiddenRendererSkippedChars: 0, - hiddenRendererMode2031ReplyCount: 0, - hiddenRendererLiveSynchronizedChars: 0, - hiddenRendererLiveNonSynchronizedChars: 0, - hiddenRendererStartupWindowChars: 0, - hiddenRendererSplitBoundaryChars: 0 + hiddenRendererMode2031ReplyCount: 0 } function resetE2eTerminalPtyOutputDebug(): void { - e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0 - e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0 e2eTerminalPtyOutputDebugState.hiddenRendererMode2031ReplyCount = 0 - e2eTerminalPtyOutputDebugState.hiddenRendererLiveSynchronizedChars = 0 - e2eTerminalPtyOutputDebugState.hiddenRendererLiveNonSynchronizedChars = 0 - e2eTerminalPtyOutputDebugState.hiddenRendererStartupWindowChars = 0 - e2eTerminalPtyOutputDebugState.hiddenRendererSplitBoundaryChars = 0 } function exposeE2eTerminalPtyOutputDebug(): void { @@ -212,34 +192,6 @@ function exposeE2eTerminalPtyOutputDebug(): void { } } -function recordHiddenRendererSkip(chars: number): void { - if (!e2eConfig.exposeStore) { - return - } - exposeE2eTerminalPtyOutputDebug() - e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount += 1 - e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars -} - -function recordHiddenRendererLiveOutput( - chars: number, - reason: 'synchronized' | 'non-synchronized' | 'startup-window' | 'split-boundary' -): void { - if (!e2eConfig.exposeStore) { - return - } - exposeE2eTerminalPtyOutputDebug() - if (reason === 'synchronized') { - e2eTerminalPtyOutputDebugState.hiddenRendererLiveSynchronizedChars += chars - } else if (reason === 'non-synchronized') { - e2eTerminalPtyOutputDebugState.hiddenRendererLiveNonSynchronizedChars += chars - } else if (reason === 'startup-window') { - e2eTerminalPtyOutputDebugState.hiddenRendererStartupWindowChars += chars - } else { - e2eTerminalPtyOutputDebugState.hiddenRendererSplitBoundaryChars += chars - } -} - function recordHiddenMode2031Reply(): void { if (!e2eConfig.exposeStore) { return @@ -319,33 +271,6 @@ function readE2eHiddenSnapshotOverride(ptyId: string): Promise 1) { - const end = trimmed.indexOf(quote, 1) - if (end > 1) { - return trimmed.slice(1, end) - } - } - return trimmed.split(/\s+/)[0] ?? '' -} - -function isCodexStartupCommand(command: string): boolean { - const executable = firstStartupCommandToken(command) - .split(/[\\/]/) - .pop() - ?.toLowerCase() - .replace(STARTUP_COMMAND_EXTENSION_RE, '') - return executable === 'codex' || executable?.startsWith('codex-') === true -} - -function shouldKeepHiddenStartupRendererQueriesLive( - startup: PtyConnectionDeps['startup'] -): boolean { - return startup?.telemetry?.agent_kind === 'codex' || isCodexStartupCommand(startup?.command ?? '') -} - let codexRestartNoticePresenceSource: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -579,23 +504,6 @@ function shouldSynchronizedOutputRemainActive(data: string, wasActive: boolean): return lastStartIndex > lastEndIndex } -function updateSynchronizedOutputScanTail(data: string): string { - return data.slice(-SYNCHRONIZED_OUTPUT_SCAN_TAIL_CHARS) -} - -function containsSequenceAcrossBoundary(tail: string, data: string, sequence: string): boolean { - const maxPrefixLength = Math.min(sequence.length - 1, tail.length, data.length) - for (let prefixLength = 1; prefixLength <= maxPrefixLength; prefixLength++) { - if ( - tail.endsWith(sequence.slice(0, prefixLength)) && - data.startsWith(sequence.slice(prefixLength)) - ) { - return true - } - } - return false -} - function containsCursorPositionSequence(data: string): boolean { let offset = data.indexOf('\x1b[') while (offset !== -1) { @@ -644,10 +552,9 @@ export function connectPanePty( let terminalBellNotificationTimer: ReturnType | null = null let pendingTerminalBellNotification = false let reattachIdleAgentCursorResetTimer: ReturnType | null = null + // Why: DEC 2026 tracking survives only for the FOREGROUND native-Windows + // repaint protection — hidden chunks are no longer classified per chunk. let synchronizedForegroundOutputActive = false - let synchronizedHiddenOutputActive = false - let synchronizedHiddenOutputScanTail = '' - let synchronizedHiddenOutputPtyId: string | null = null // Why: hidden-delivery gate sync is wired up alongside the deferred PTY // output plumbing inside the connect frame; lifecycle hooks (visibility // flips, exit, dispose) run before/after it exists, so start with no-ops. @@ -2169,10 +2076,6 @@ export function connectPanePty( } let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 - let hiddenMode2031ScanTail = '' - const hiddenStartupRendererQueryUntil = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) - ? Date.now() + HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS - : 0 function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) @@ -2205,29 +2108,19 @@ export function connectPanePty( return transport.serializeBuffer(opts) } - function isHiddenStartupRendererQueryWindowActive(): boolean { - return ( - paneStartup !== null && - Date.now() < hiddenStartupRendererQueryUntil && - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - } - // Why: hidden/parked panes used to mark hidden only at the first // dataCallback sync, leaving a spawn-time window where neither side - // answered queries (the non-codex DA1 loss). Declaring hidden on the - // spawn IPC lets main mark the PTY before its first byte. Codex startups - // are excluded — their startup window needs live renderer delivery, and - // the window predicate is checked at connect time (same tick the flag is - // sent), so the two decisions cannot disagree. Remote-runtime PTYs are - // never gate-markable (no local main transit). + // answered queries (the spawn-time DA1 loss). Declaring hidden on the + // spawn IPC lets main mark the PTY before its first byte — including + // codex spawns: the model responder answers their startup probes from + // byte zero now that the 10s renderer query window is gone. + // Remote-runtime PTYs are never gate-markable (no local main transit). function shouldDeclareHiddenAtSpawn(): boolean { return ( hiddenDeliveryGateActive && !runtimeEnvironmentId && !disposed && - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) && - !isHiddenStartupRendererQueryWindowActive() + !shouldWritePtyOutputForeground(deps.isVisibleRef.current) ) } @@ -2251,12 +2144,8 @@ export function connectPanePty( if (disposed) { return } - // Why: dropped bytes invalidate every cross-chunk carry — a DEC 2026 - // classification or partial OSC-9999 prefix spanning the gap would - // corrupt the next live chunk. - synchronizedHiddenOutputActive = false - synchronizedHiddenOutputScanTail = '' - hiddenMode2031ScanTail = '' + // Why: dropped bytes invalidate every cross-chunk carry — a partial + // OSC-9999 prefix spanning the gap would corrupt the next live chunk. transport.resetCrossChunkParserState?.() // Why: parity with the hidden skip path — a marker landing while a // restore is in flight means the in-flight snapshot may predate the @@ -2301,12 +2190,7 @@ export function connectPanePty( if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) { return } - const shouldHide = - !disposed && - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) && - // Why: codex startup probes need the live xterm to answer renderer - // queries for 10s — never gate delivery while the window is active. - !isHiddenStartupRendererQueryWindowActive() + const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId hiddenDeliverySyncedPtyId = ptyId if (shouldHide) { @@ -2333,26 +2217,6 @@ export function connectPanePty( modelRestoreSubscribedPtyId = null } - function respondToSkippedMode2031Subscribe(data: string): void { - // Why: gate-managed PTYs answer 2031 from main's '2031-subscribe' fact - // (sole responder); scanning skipped chunks here too would answer the - // same subscribe twice. - if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) { - return - } - const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) - hiddenMode2031ScanTail = scan.tail - if (!scan.subscribe) { - return - } - const settings = useAppStore.getState().settings - const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) - // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer - // mode 2031 out-of-band so TUIs still render the snapshot with the same - // theme-dependent styling they would have used in a visible pane. - transport.sendInput(mode2031SequenceFor(mode)) - recordHiddenMode2031Reply() - } function beforeTerminalOutputWrite(): void { recordTerminalOutput(pane.terminal) } @@ -2453,10 +2317,6 @@ export function connectPanePty( if (foreground) { resetHiddenOutputRestoreIfPtyChanged() } - const parseHiddenStartupOutput = - !foreground && - canUseHiddenOutputSnapshot(transport.getPtyId()) && - isHiddenStartupRendererQueryWindowActive() const synchronizedOutputStarted = shouldProtectNativeWindowsSynchronizedOutput && foreground && @@ -2478,17 +2338,16 @@ export function connectPanePty( const nativeWindowsCursorRestore = shouldProtectNativeWindowsSynchronizedOutput && foreground && containsCursorRestore(data) synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive - if (hiddenMode2031ScanTail) { - respondToSkippedMode2031Subscribe(data) - } writeTerminalOutput(pane.terminal, data, { - foreground: foreground || parseHiddenStartupOutput, + foreground, beforeWrite: beforeTerminalOutputWrite, + // Why: hidden bytes ride the bounded background queue; on overflow the + // scheduler swaps the backlog for this restore latch and the reveal + // repaints from the model snapshot (the pre-grammar fallback). onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, - latencySensitive: - !foreground || parseHiddenStartupOutput ? true : isLatencySensitiveForegroundOutput(data), + latencySensitive: !foreground ? true : isLatencySensitiveForegroundOutput(data), forceForegroundRefresh: - (foreground || parseHiddenStartupOutput) && + foreground && (synchronizedForegroundOutput || nativeWindowsCursorRestore || shouldForceForegroundRenderRefresh(data)), @@ -2524,15 +2383,6 @@ export function connectPanePty( } } - function skipHiddenRendererOutput(data: string): void { - respondToSkippedMode2031Subscribe(data) - markHiddenOutputRestoreNeeded() - if (hiddenOutputRestoreInFlight) { - hiddenOutputRestoreFreshSnapshotNeeded = true - } - recordHiddenRendererSkip(data.length) - } - function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void { if (!data) { return @@ -3019,8 +2869,7 @@ export function connectPanePty( // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) - // Why: latch the hidden-delivery gate from the byte path too — covers - // the startup-query-window expiring without a visibility event and a + // Why: latch the hidden-delivery gate from the byte path too — covers a // PTY id arriving after the initial sync. No-op when state is current. if (!foreground) { syncHiddenRendererPtyDelivery() @@ -3048,58 +2897,7 @@ export function connectPanePty( meta = reconciliation.meta const restoreAppliesToCurrentPty = hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId - const dataPtyId = transport.getPtyId() - if (synchronizedHiddenOutputPtyId !== dataPtyId) { - // Why: DEC 2026 state is per PTY stream; a restarted/reattached PTY - // must not inherit synchronized classification from the old shell. - synchronizedHiddenOutputPtyId = dataPtyId - synchronizedHiddenOutputActive = false - synchronizedHiddenOutputScanTail = '' - } - const hiddenSynchronizedScanData = synchronizedHiddenOutputScanTail + data - const synchronizedOutputStarted = containsSynchronizedOutputStart(hiddenSynchronizedScanData) - const synchronizedOutputEnded = containsSynchronizedOutputEnd(hiddenSynchronizedScanData) - // Why: if a DEC 2026 marker spans chunks, xterm may already hold the - // first bytes of that escape. Complete that boundary live, then skip. - const splitSynchronizedBoundary = - !foreground && - (containsSequenceAcrossBoundary( - synchronizedHiddenOutputScanTail, - data, - SYNCHRONIZED_OUTPUT_START_SEQUENCE - ) || - containsSequenceAcrossBoundary( - synchronizedHiddenOutputScanTail, - data, - SYNCHRONIZED_OUTPUT_END_SEQUENCE - )) - const synchronizedHiddenOutput = - !foreground && - (synchronizedHiddenOutputActive || synchronizedOutputStarted || synchronizedOutputEnded) - const hiddenStartupRendererQueryWindowActive = isHiddenStartupRendererQueryWindowActive() - const shouldSkipHiddenOutput = shouldSkipHiddenRendererOutput({ - foreground, - canRestoreHiddenOutput: canUseHiddenOutputSnapshot(transport.getPtyId()), - startupRendererQueryWindowActive: hiddenStartupRendererQueryWindowActive, - synchronizedOutputActive: synchronizedHiddenOutput, - allowSynchronizedModelRestore: true, - data - }) - if (shouldSkipHiddenOutput && !splitSynchronizedBoundary) { - skipHiddenRendererOutput(data) - } else if (synchronizedHiddenOutput) { - if (!foreground) { - recordHiddenRendererLiveOutput( - data.length, - splitSynchronizedBoundary - ? 'split-boundary' - : hiddenStartupRendererQueryWindowActive - ? 'startup-window' - : 'synchronized' - ) - } - writePtyOutputToXterm(data, foreground) - } else if ( + if ( (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && restoreAppliesToCurrentPty ) { @@ -3110,33 +2908,16 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } + // Why: hidden chunks with a restore already latched are dropped here — + // the model snapshot fetched on reveal covers their bytes. } else { - if (!foreground) { - recordHiddenRendererLiveOutput( - data.length, - hiddenStartupRendererQueryWindowActive ? 'startup-window' : 'non-synchronized' - ) - } + // Why: gate-managed hidden panes normally receive no bytes (main + // drops after model ingestion). Any hidden chunk that still arrives + // (kill switch off, interest-held delivery) rides the bounded + // background scheduler queue; overflow latches the model restore. + // There is no per-chunk content grammar anymore. writePtyOutputToXterm(data, foreground) } - if (!foreground) { - synchronizedHiddenOutputActive = shouldSynchronizedOutputRemainActive( - hiddenSynchronizedScanData, - synchronizedHiddenOutputActive - ) - synchronizedHiddenOutputScanTail = updateSynchronizedOutputScanTail( - hiddenSynchronizedScanData - ) - } else { - // Why: a DEC 2026 end consumed while visible must still clear hidden - // synchronized state, or later hidden plain output is misclassified - // under the permissive synchronized model grammar. - synchronizedHiddenOutputActive = shouldSynchronizedOutputRemainActive( - hiddenSynchronizedScanData, - synchronizedHiddenOutputActive - ) - synchronizedHiddenOutputScanTail = '' - } schedulePendingStartupCommandDelivery() } diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 4c5164d0933..12c4d0ef00c 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -317,8 +317,7 @@ export type PtyTransport = { /** Hidden-at-spawn declaration (terminal-query-authority.md): no visible * view will consume this PTY's bytes, so main marks it hidden BEFORE the * first byte and the gate + model responder own spawn-time queries. - * Never set while the codex startup window would run, and ignored by - * remote-runtime transports (their PTYs are not gate-markable). */ + * Ignored by remote-runtime transports (not gate-markable). */ initiallyHidden?: boolean callbacks: { onConnect?: () => void diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 4764795d65f..9705c15dd5e 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -54,9 +54,10 @@ type HiddenPressureDeps void } +// Why: the renderer hidden-skip counters are gone with the skip grammar — +// withheld hidden output is observed via main's delivery-drop counters only. type HiddenPressureDebug = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number } type HiddenPressureMeasurement = { @@ -185,11 +186,11 @@ export async function runHiddenRealPtyPressureScenario< ackGate ) - // New hidden-delivery contract (all pressure modes): bytes never reach the - // renderer, so hidden skips may legitimately be zero — at most a pre-latch - // trickle below one pane's output — and main's renderer-delivery pressure - // must stay clearly below the old 2 MB backpressure target. - expect(debug?.hiddenRendererSkippedChars ?? 0).toBeLessThan(pressureOutputChars) + // Hidden-delivery contract (all pressure modes): bytes never reach the + // renderer — main's drop counter is the withheld-output signal (the + // renderer skip counters were deleted with the skip grammar) — and main's + // renderer-delivery pressure must stay clearly below the old 2 MB + // backpressure target. expect(mainPressure?.hiddenDeliveryDroppedChars ?? 0).toBeGreaterThanOrEqual( pressureOutputChars ) @@ -214,7 +215,7 @@ export async function runHiddenRealPtyPressureScenario< type: `opencode-hidden-real-pty-restore${annotationSuffix ?? ''}`, description: `panes=${hiddenPanes.length + 1} restore=${restoreLatencyMs.toFixed( 1 - )}ms hiddenSkippedChars=${debug?.hiddenRendererSkippedChars ?? 0} hiddenDeliveryDroppedChars=${ + )}ms hiddenDeliveryDroppedChars=${ mainPressure?.hiddenDeliveryDroppedChars ?? 0 } mainPeakInFlightChars=${mainPressure?.peakRendererInFlightChars ?? 0} heldAckChars=${ ackGate?.heldAckChars ?? 0 @@ -236,8 +237,7 @@ export async function runHiddenRealPtyPressureScenario< // Why: replaces the old waitForMainPtyPressureBacklog premise — the Phase-4 // gate drops hidden bytes in main, so renderer-delivery pressure never builds; -// readiness is the gate reporting one pane's worth of dropped output. The 30s -// timeout covers the rich-model 11s startup-window delay. +// readiness is the gate reporting one pane's worth of dropped output. async function waitForMainHiddenDeliveryDrops( orcaPage: Page, deps: { readMainPtyPressureDebug: (page: Page) => Promise }, diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts index 3e3d5e5f51f..c6a9d6e35a4 100644 --- a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -26,7 +26,9 @@ type RevisitPressureMeasurement = { maxTimerDriftMs: number } -type RevisitPressureDebug = { hiddenRendererSkipCount: number; hiddenRendererSkippedChars: number } +// Why: the renderer hidden-skip counters were deleted with the skip grammar; +// only the mode-2031 fact-reply counter still exists renderer-side. +type RevisitPressureDebug = { hiddenRendererMode2031ReplyCount: number } type RevisitPressureSchedulerSnapshot = { peakQueuedChars: number @@ -180,7 +182,6 @@ export async function runRendererBackpressureRevisitScenario< expectPressureStayedBounded({ ackGate, - hiddenDebug, mainRendererPressureTargetChars, maxMedianKeyLatencyMs, maxRendererSchedulerQueuedChars, @@ -273,7 +274,6 @@ async function waitForMarkerLatency( function expectPressureStayedBounded({ ackGate, - hiddenDebug, mainRendererPressureTargetChars, maxMedianKeyLatencyMs, maxRendererSchedulerQueuedChars, @@ -285,7 +285,6 @@ function expectPressureStayedBounded
    —