From e4c4f96269e50ffdbea25cc3cb4971abb8741bae Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 15 Jul 2026 18:48:30 -0400 Subject: [PATCH] fix(runtime): return authoritative visible TUI output Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 491 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 338 ++++++++++++++++-- 2 files changed, 797 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 055d1c2ac48..041ca44e9e4 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12794,6 +12794,497 @@ describe('OrcaRuntimeService', () => { }) }) + it('reads and shows the runtime-owned alternate-screen grid without serialization', async () => { + const serializeBuffer = vi.fn() + const serializeProviderBuffer = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + serializeProviderBuffer + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData( + 'pty-1', + 'shell history\r\n\x1b[?1049h\x1b[2J\x1b[HClaude Code\r\nWorking on fix\r\nTool: Read\r\n', + 100 + ) + + const read = await runtime.readTerminal(terminal.handle) + const shown = await runtime.showTerminal(terminal.handle) + + expect(read.tail).toEqual(['Claude Code', 'Working on fix', 'Tool: Read']) + expect(shown.preview).toBe('Claude Code\nWorking on fix\nTool: Read') + expect(serializeBuffer).not.toHaveBeenCalled() + expect(serializeProviderBuffer).not.toHaveBeenCalled() + }) + + it('classifies older provider alternate-screen snapshots from ANSI', async () => { + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049h\x1b[2J\x1b[HRemote Vim\r\nediting README.md\r\n', + cols: 80, + rows: 24, + seq: 900, + source: 'headless' + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + + const [terminal] = (await runtime.listTerminals()).terminals + const read = await runtime.readTerminal(terminal.handle) + + expect(read.tail).toEqual(['Remote Vim', 'editing README.md']) + expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(true) + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + + it('honors the last ANSI screen transition when older provider metadata is absent', async () => { + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049hOld TUI\r\n\x1b[?1049l', + cols: 80, + rows: 24, + seq: 900, + source: 'headless' + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'shell history\r\n', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + + const [terminal] = (await runtime.listTerminals()).terminals + const read = await runtime.readTerminal(terminal.handle) + + expect(read.tail).toEqual(['shell history']) + expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(false) + }) + + it.each([ + { + name: 'live exit wins over an alternate provider snapshot', + snapshotData: '\x1b[?1049h\x1b[2J\x1b[HStale TUI\r\n', + snapshotMode: true, + liveData: '\x1b[?1049lreturned shell\r\n', + expectedMode: false, + expectedLine: 'shell history' + }, + { + name: 'live entry wins over a primary provider snapshot', + snapshotData: 'stale shell\r\n', + snapshotMode: false, + liveData: '\x1b[?1049h\x1b[2J\x1b[HLive TUI\r\nnew frame\r\n', + expectedMode: true, + expectedLine: 'Live TUI' + } + ])('$name', async ({ snapshotData, snapshotMode, liveData, expectedMode, expectedLine }) => { + type Snapshot = { + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + } + let resolveSnapshot!: (snapshot: Snapshot) => void + const serializeProviderBuffer = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'shell history\r\n', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const readPromise = runtime.readTerminal(terminal.handle) + await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce()) + runtime.onPtyData('pty-1', liveData, 101) + resolveSnapshot({ + data: snapshotData, + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: snapshotMode + }) + + const read = await readPromise + expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(expectedMode) + expect(read.tail.join('\n')).toContain(expectedLine) + expect(read.tail.join('\n')).not.toContain('Stale TUI') + }) + + it('shares one provider snapshot between concurrent read and show calls', async () => { + type Snapshot = { + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + } + let resolveSnapshot!: (snapshot: Snapshot) => void + const serializeProviderBuffer = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const readPromise = runtime.readTerminal(terminal.handle) + const showPromise = runtime.showTerminal(terminal.handle) + await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce()) + resolveSnapshot({ + data: '\x1b[?1049h\x1b[2J\x1b[HShared TUI frame\r\n', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + + const [read, shown] = await Promise.all([readPromise, showPromise]) + expect(read.tail).toEqual(['Shared TUI frame']) + expect(shown.preview).toBe('Shared TUI frame') + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + + it('keeps cursor reads provider-free on restored alternate-screen PTYs', async () => { + const serializeProviderBuffer = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'shell history\r\n', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + + expect(read.tail).toEqual(['shell history']) + expect(serializeProviderBuffer).not.toHaveBeenCalled() + }) + + it('backs off after a provider snapshot failure instead of retrying for show', async () => { + const serializeProviderBuffer = vi.fn().mockRejectedValue(new Error('provider unavailable')) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'shell history\r\n', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle) + const shown = await runtime.showTerminal(terminal.handle) + + expect(read.tail).toEqual(['shell history']) + expect(shown.preview).toBe('shell history') + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + + it('falls back once to a mounted renderer when a known alternate provider fails', async () => { + const serializeProviderBuffer = vi + .fn() + .mockResolvedValueOnce({ + data: '\x1b[?1049h\x1b[2J\x1b[HProvider TUI\r\n', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + .mockRejectedValue(new Error('provider unavailable')) + const serializeBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049h\x1b[2J\x1b[HRenderer TUI\r\n', + cols: 80, + rows: 24 + }) + let rendererMounted = false + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + serializeProviderBuffer, + hasRendererSerializer: () => rendererMounted + }) + syncSinglePty(runtime) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({ + tail: ['Provider TUI'] + }) + runtime.onPtyData('pty-1', 'new output', 100) + rendererMounted = true + + const read = await runtime.readTerminal(terminal.handle) + + expect(read.tail).toEqual(['Renderer TUI']) + expect(serializeProviderBuffer).toHaveBeenCalledTimes(2) + expect(serializeBuffer).toHaveBeenCalledOnce() + }) + + it('bounds a stuck provider snapshot and shares the timed-out request', async () => { + vi.useFakeTimers() + try { + const serializeProviderBuffer = vi.fn(() => new Promise(() => undefined)) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'shell history\r\n', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const reads = Promise.all([ + runtime.readTerminal(terminal.handle), + runtime.showTerminal(terminal.handle) + ]) + await vi.advanceTimersByTimeAsync(750) + const [read, shown] = await reads + + expect(read.tail).toEqual(['shell history']) + expect(shown.preview).toBe('shell history') + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('discards a provider frame when the PTY generation resets during capture', async () => { + type Snapshot = { + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + } + let resolveSnapshot!: (snapshot: Snapshot) => void + const serializeProviderBuffer = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'old shell\r\n', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const readPromise = runtime.readTerminal(terminal.handle) + await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce()) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 0, generation: 'reset' }, + runtime.getPtyOutputSequence('pty-1') + ) + resolveSnapshot({ + data: '\x1b[?1049hStale generation TUI\r\n', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + + const read = await readPromise + expect(read.tail.join('\n')).not.toContain('Stale generation TUI') + expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(false) + }) + + it('rejects a visible frame when its terminal handle changes during capture', async () => { + type Snapshot = { + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + } + let resolveSnapshot!: (snapshot: Snapshot) => void + const serializeProviderBuffer = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const readPromise = runtime.readTerminal(terminal.handle) + await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce()) + syncSinglePty(runtime, 'pty-2') + resolveSnapshot({ + data: '\x1b[?1049hWrong terminal frame\r\n', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + + await expect(readPromise).rejects.toThrow('terminal_handle_stale') + }) + + it('bounds provider visible frames for read and show responses', async () => { + const visibleLines = [ + 'skip-1', + 'skip-2', + 'A'.repeat(49), + 'B'.repeat(49), + 'C'.repeat(49), + 'D'.repeat(49), + 'E'.repeat(49), + 'F'.repeat(50) + ] + const expectedTail = visibleLines.slice(-6) + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: `\x1b[?1049h\x1b[2J\x1b[H${visibleLines.join('\r\n')}\r\n`, + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle, { limit: 6 }) + const shown = await runtime.showTerminal(terminal.handle) + + expect(read.tail).toEqual(expectedTail) + expect(read.returnedLineCount).toBe(6) + expect(shown.preview).toBe(expectedTail.join('\n')) + expect(shown.preview.length).toBe(300) + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + it('returns renderer visible screen lines through terminal.read RPC JSON result', async () => { const serializeBuffer = vi.fn().mockResolvedValue({ data: '\x1b[?1049hClaude Code\r\nChecking files\r\nWaiting for input\r\n', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e1bce48a5a..e348c1ddbae 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1194,6 +1194,19 @@ type RuntimeHeadlessTerminal = { writeChain: Promise } +type RuntimeVisibleTerminalState = { + lines: string[] + isAlternateScreen: boolean + sequence: number + generation: number +} + +type ProviderBufferAcquisition = { + generation: number + scrollbackRows: number + promise: Promise +} + type HeadlessSeedMetadata = { cwd?: string | null oscLinks?: TerminalOscLinkRange[] @@ -2289,6 +2302,12 @@ export class OrcaRuntimeService { string, Set >() + private providerBufferAcquisitionsByPtyId = new Map() + private providerVisibleStateByPtyId = new Map() + private providerVisibleRetryAtByPtyId = new Map() + private providerSnapshotsWithLiveModeTransition = new WeakSet() + private ptyLifecycleGenerationById = new Map() + private nextPtyLifecycleGeneration = 1 private recentPtyPathCandidatesById = new Map() // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a @@ -6906,6 +6925,25 @@ export class OrcaRuntimeService { return this.ptyOutputSequenceById.get(ptyId) ?? 0 } + private getPtyLifecycleGeneration(ptyId: string): number { + const existing = this.ptyLifecycleGenerationById.get(ptyId) + if (existing !== undefined) { + return existing + } + const generation = this.nextPtyLifecycleGeneration++ + this.ptyLifecycleGenerationById.set(ptyId, generation) + return generation + } + + private advancePtyLifecycleGeneration(ptyId: string): void { + this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) + // Why: a provider response belongs to the process generation that issued + // it; a respawn must neither reuse its frame nor join its in-flight call. + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + this.providerVisibleStateByPtyId.delete(ptyId) + this.providerVisibleRetryAtByPtyId.delete(ptyId) + } + synchronizePtyOutputSequenceFromProvider( ptyId: string, providerSequence: { value: number; generation: 'continued' | 'reset' }, @@ -6932,6 +6970,7 @@ export class OrcaRuntimeService { const providerBaseline = providerOffset + baseline if (providerSequence.generation === 'reset') { + this.advancePtyLifecycleGeneration(ptyId) // Why: daemon respawn/cold restore starts a new absolute domain. Old // emulator state cannot remain authoritative over the replacement. if (replacesExistingRuntimeGeneration) { @@ -7606,7 +7645,35 @@ export class OrcaRuntimeService { private async serializeProviderTerminalBuffer( ptyId: string, - opts: { scrollbackRows?: number } = {} + opts: { scrollbackRows?: number } = {}, + wait: { timeoutMs?: number } = {} + ): Promise { + const generation = this.getPtyLifecycleGeneration(ptyId) + const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0)) + let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId) + if ( + !acquisition || + acquisition.generation !== generation || + acquisition.scrollbackRows < scrollbackRows + ) { + const promise = this.captureProviderTerminalBuffer(ptyId, opts, generation) + acquisition = { generation, scrollbackRows, promise } + this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition) + void promise.finally(() => { + if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) { + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + } + }) + } + return typeof wait.timeoutMs === 'number' + ? withTimeout(acquisition.promise, wait.timeoutMs, null) + : acquisition.promise + } + + private async captureProviderTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number }, + generation: number ): Promise { const liveModeTracker = new TerminalKittyKeyboardModeTracker() let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId) @@ -7619,24 +7686,44 @@ export class OrcaRuntimeService { // Why: daemon PTYs survive an app relaunch before any renderer mounts. // Mobile still needs their retained history without navigating desktop. const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) - if (typeof snapshot?.alternateScreen === 'boolean') { + if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { + return null + } + const snapshotModeTracker = new TerminalKittyKeyboardModeTracker() + if (typeof snapshot.alternateScreen === 'boolean') { + snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') + } else { + // Why: older providers omit mode metadata, but their ANSI snapshot + // still carries the DECSET/DECRST needed to classify the active screen. + snapshotModeTracker.scanReplay(snapshot.data) + } + const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch + let effectiveAlternateScreen: boolean | undefined + if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) { const modeTracker = new TerminalKittyKeyboardModeTracker() - if (snapshot.alternateScreen) { - modeTracker.scan('\x1b[?1049h') + if (observedSnapshotMode) { + modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') } + // Why: stream bytes received after the request began can be newer + // than snapshot metadata, so an observed live transition wins. if (liveModeTracker.hasObservedAlternateScreenSwitch) { modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') } this.providerModeTrackersByPtyId.set(ptyId, modeTracker) - } - if (!snapshot) { - return null + effectiveAlternateScreen = modeTracker.isAlternateScreen } const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0 - return this.preferTrackedLastTitle(ptyId, { + const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, { ...snapshot, - seq: providerOffset + snapshot.seq + seq: providerOffset + snapshot.seq, + ...(effectiveAlternateScreen !== undefined + ? { alternateScreen: effectiveAlternateScreen } + : {}) }) + if (liveModeTracker.hasObservedAlternateScreenSwitch) { + this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot) + } + return reconciledSnapshot } catch { return null } finally { @@ -7652,16 +7739,164 @@ export class OrcaRuntimeService { read: RuntimeTerminalRead, opts: { cursor?: number; limit?: number } = {} ): Promise { - if (!shouldFallbackToVisibleTerminalSnapshot(read, opts)) { + if (typeof opts.cursor === 'number') { return read } - const lines = await this.readRendererVisibleSnapshotLines(ptyId) + const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts) + const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) + const providerModeUnknown = + this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) + if ( + !blankFallback && + !providerModeUnknown && + !knownAlternateScreen && + !this.headlessTerminals.has(ptyId) + ) { + return read + } + const visibleState = await this.readVisibleTerminalState(ptyId) + if (!blankFallback && !knownAlternateScreen && !visibleState?.isAlternateScreen) { + return read + } + let lines = visibleState?.lines ?? [] + if (lines.length === 0) { + lines = await this.readRendererVisibleSnapshotLines(ptyId) + } if (lines.length === 0) { return read } return buildVisibleSnapshotReadFallback(read, lines, opts.limit) } + private async visibleSnapshotPreview(ptyId: string, preview: string): Promise { + const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) + const providerModeUnknown = + this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) + if (!providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId)) { + return preview + } + const visibleState = await this.readVisibleTerminalState(ptyId) + if (!knownAlternateScreen && !visibleState?.isAlternateScreen) { + return preview + } + let lines = visibleState?.lines ?? [] + if (lines.length === 0) { + lines = await this.readRendererVisibleSnapshotLines(ptyId) + } + return lines.length > 0 ? buildPreview(lines, '') : preview + } + + private async readVisibleTerminalState( + ptyId: string + ): Promise { + if (!this.providerSnapshotPreferredPtys.has(ptyId)) { + return this.readHeadlessVisibleTerminalState(ptyId) + } + + const generation = this.getPtyLifecycleGeneration(ptyId) + const outputSequence = this.getPtyOutputSequence(ptyId) + const cached = this.providerVisibleStateByPtyId.get(ptyId) + const trackedMode = this.providerModeTrackersByPtyId.get(ptyId) + if ( + cached?.generation === generation && + outputSequence <= cached.sequence && + (!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen) + ) { + return cached + } + if (trackedMode && !trackedMode.isAlternateScreen) { + const headlessState = await this.readHeadlessVisibleTerminalState(ptyId) + return headlessState + ? { ...headlessState, isAlternateScreen: false } + : { + lines: [], + isAlternateScreen: false, + sequence: outputSequence, + generation + } + } + if ((this.providerVisibleRetryAtByPtyId.get(ptyId) ?? 0) > Date.now()) { + return null + } + + const snapshot = await this.serializeProviderTerminalBuffer( + ptyId, + { scrollbackRows: 0 }, + { timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } + ) + if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { + this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS) + return null + } + this.providerVisibleRetryAtByPtyId.delete(ptyId) + if (this.providerSnapshotsWithLiveModeTransition.has(snapshot)) { + // Why: the provider frame can predate a mode switch observed while its + // RPC was pending; the ordered live emulator owns the post-switch grid. + const liveState = await this.readHeadlessVisibleTerminalState(ptyId) + if (liveState && liveState.isAlternateScreen === (snapshot.alternateScreen ?? false)) { + return liveState + } + } + const lines = await this.parseVisibleSnapshotLines(snapshot) + if (this.getPtyLifecycleGeneration(ptyId) !== generation) { + return null + } + const visibleState: RuntimeVisibleTerminalState = { + lines, + isAlternateScreen: snapshot.alternateScreen ?? false, + sequence: snapshot.seq, + generation + } + if (this.getPtyOutputSequence(ptyId) <= snapshot.seq) { + this.providerVisibleStateByPtyId.set(ptyId, visibleState) + } + return visibleState + } + + private async readHeadlessVisibleTerminalState( + ptyId: string + ): Promise { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return null + } + const generation = this.getPtyLifecycleGeneration(ptyId) + await state.writeChain + if ( + this.headlessTerminals.get(ptyId) !== state || + this.getPtyLifecycleGeneration(ptyId) !== generation + ) { + return null + } + return { + lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()), + isAlternateScreen: state.emulator.isAlternateScreen, + sequence: state.outputSequence, + generation + } + } + + private async parseVisibleSnapshotLines(snapshot: { + data: string + cols: number + rows: number + }): Promise { + if (snapshot.data.length === 0) { + return [] + } + const emulator = new HeadlessEmulator({ + cols: snapshot.cols, + rows: snapshot.rows, + scrollback: 0 + }) + try { + await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + return visibleNonBlankTerminalLines(emulator.getVisibleLines()) + } finally { + emulator.dispose() + } + } + private async readRendererVisibleSnapshotLines(ptyId: string): Promise { const controller = this.ptyController if (!controller?.serializeBuffer) { @@ -7674,27 +7909,18 @@ export class OrcaRuntimeService { // Why: raw PTY tails can be whitespace-only while a full-screen TUI is // visibly nonblank in renderer xterm. Ask the renderer for the active // screen instead of reusing the headless transcript path. - const snapshot = await controller.serializeBuffer(ptyId, { - scrollbackRows: 0, - altScreenForcesZeroRows: false - }) + const snapshot = await withTimeout( + controller.serializeBuffer(ptyId, { + scrollbackRows: 0, + altScreenForcesZeroRows: false + }), + VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, + null + ) if (!snapshot || snapshot.data.length === 0) { return [] } - const emulator = new HeadlessEmulator({ - cols: snapshot.cols, - rows: snapshot.rows, - scrollback: 0 - }) - try { - await emulator.write(snapshot.data) - return emulator - .getVisibleLines() - .map((line) => line.trimEnd()) - .filter((line) => line.trim().length > 0) - } finally { - emulator.dispose() - } + return this.parseVisibleSnapshotLines(snapshot) } catch { return [] } @@ -8803,6 +9029,7 @@ export class OrcaRuntimeService { } onPtyExit(ptyId: string, exitCode: number): void { + this.advancePtyLifecycleGeneration(ptyId) advertisedUrlWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) @@ -8819,6 +9046,9 @@ export class OrcaRuntimeService { this.providerSnapshotPreferredPtys.delete(ptyId) this.providerModeTrackersByPtyId.delete(ptyId) this.providerModeSnapshotScansByPtyId.delete(ptyId) + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + this.providerVisibleStateByPtyId.delete(ptyId) + this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -10968,8 +11198,12 @@ export class OrcaRuntimeService { const pty = this.getLivePtyForHandle(handle) if (pty) { const worktreesById = await this.getResolvedWorktreeMap() + const summary = this.buildPtyTerminalSummary(pty.pty, worktreesById) + const preview = await this.visibleSnapshotPreview(pty.pty.ptyId, summary.preview) + this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) return { - ...this.buildPtyTerminalSummary(pty.pty, worktreesById), + ...summary, + preview, tabId: pty.pty.tabId ?? pty.record.tabId, leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId, paneRuntimeId: -1, @@ -10982,8 +11216,16 @@ export class OrcaRuntimeService { this.assertStableReadyGraph(graphEpoch) const { leaf } = this.getLiveLeafForHandle(handle) const summary = this.buildTerminalSummary(leaf, worktreesById) + const preview = leaf.ptyId + ? await this.visibleSnapshotPreview(leaf.ptyId, summary.preview) + : summary.preview + this.assertStableReadyGraph(graphEpoch) + if (leaf.ptyId) { + this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) + } return { ...summary, + preview, paneRuntimeId: leaf.paneRuntimeId, ptyId: leaf.ptyId, rendererGraphEpoch: this.rendererGraphEpoch @@ -10997,7 +11239,9 @@ export class OrcaRuntimeService { const pty = this.getLivePtyForHandle(handle) if (pty) { const read = this.readPtyTerminal(handle, pty.pty, opts) - return this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts) + const visibleRead = await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts) + this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) + return visibleRead } const { leaf } = this.getLiveLeafForHandle(handle) @@ -11011,7 +11255,12 @@ export class OrcaRuntimeService { cursor: opts.cursor, limit: opts.limit }) - return leaf.ptyId ? this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) : read + if (!leaf.ptyId) { + return read + } + const visibleRead = await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) + this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) + return visibleRead } async sendTerminal( @@ -21328,6 +21577,8 @@ export class OrcaRuntimeService { } private dropDisconnectedPtyRecord(ptyId: string): void { + // Why: pruning can remove a PTY without the normal exit callback. + this.advancePtyLifecycleGeneration(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) @@ -21338,6 +21589,9 @@ export class OrcaRuntimeService { this.providerSnapshotPreferredPtys.delete(ptyId) this.providerModeTrackersByPtyId.delete(ptyId) this.providerModeSnapshotScansByPtyId.delete(ptyId) + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + this.providerVisibleStateByPtyId.delete(ptyId) + this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -22728,6 +22982,20 @@ export class OrcaRuntimeService { return { record, pty } } + private assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void { + const runtimePty = this.getLivePtyForHandle(handle) + if (runtimePty) { + if (runtimePty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_handle_stale') + } + return + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_handle_stale') + } + } + private readPtyTerminal( handle: string, pty: RuntimePtyWorktreeRecord, @@ -25867,6 +26135,8 @@ const MAX_TAIL_PENDING_ANSI_CHARS = 4096 const DEFAULT_TERMINAL_READ_LIMIT = 120 const MAX_TERMINAL_READ_LIMIT = 2000 const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024 +const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750 +const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000 const MAX_PREVIEW_LINES = 6 const MAX_PREVIEW_CHARS = 300 const WORKTREE_STATUS_PRIORITY: Record = { @@ -27178,6 +27448,10 @@ function shouldFallbackToVisibleTerminalSnapshot( return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0) } +function visibleNonBlankTerminalLines(lines: string[]): string[] { + return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0) +} + function buildVisibleSnapshotReadFallback( read: RuntimeTerminalRead, visibleLines: string[],