diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index ab476b4e0..deb5472b7 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1348,6 +1348,42 @@ describe('orca cli worktree awareness', () => { }) }) + it('prints terminal.read fallback screen lines in json mode', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_read', { + terminal: { + handle: 'term_worker', + status: 'running', + tail: ['Claude Code', 'Checking files', 'Waiting for input'], + truncated: false, + limited: true, + oldestCursor: '0', + nextCursor: '3000', + latestCursor: '3000', + returnedLineCount: 3 + } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['terminal', 'read', '--terminal', 'term_worker', '--limit', '120', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('terminal.read', { + terminal: 'term_worker', + limit: 120 + }) + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.terminal.tail).toEqual([ + 'Claude Code', + 'Checking files', + 'Waiting for input' + ]) + }) + it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => { queueFixtures( callMock, diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index db891419c..fa9231fac 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -152,6 +152,15 @@ export class HeadlessEmulator { return this.terminal.buffer.active.type === 'alternate' } + getVisibleLines(): string[] { + const buffer = this.terminal.buffer.active + const lines: string[] = [] + for (let row = buffer.viewportY; row < buffer.viewportY + this.terminal.rows; row += 1) { + lines.push(buffer.getLine(row)?.translateToString(true) ?? '') + } + return lines + } + getCwd(): string | null { return this.cwd } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e4557abce..6a08ce060 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -48,6 +48,9 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceSession } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { makePaneKey } from '../../shared/stable-pane-id' +import { RpcDispatcher } from './rpc/dispatcher' +import type { RpcRequest } from './rpc/core' +import { TERMINAL_METHODS } from './rpc/methods/terminal' const electronMocks = vi.hoisted(() => { type Listener = (...args: unknown[]) => void @@ -620,6 +623,10 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +function makeRpcRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { return { displayName: '', @@ -5389,6 +5396,127 @@ describe('OrcaRuntimeService', () => { expect(shiftCallCount).toBe(0) }) + it('falls back to renderer visible screen when uncursored TUI tail is blank', async () => { + const serializeBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049hClaude Code\r\nWorking on fix\r\nTool: Read\r\n', + cols: 80, + rows: 24 + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => true, + serializeBuffer + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', `${Array.from({ length: 3000 }, () => ' ').join('\n')}\n`, 100) + + const read = await runtime.readTerminal(terminal.handle) + + expect(read.tail).toEqual(['Claude Code', 'Working on fix', 'Tool: Read']) + expect(serializeBuffer).toHaveBeenCalledWith('pty-1', { + scrollbackRows: 0, + altScreenForcesZeroRows: false + }) + }) + + 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', + cols: 80, + rows: 24 + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => true, + serializeBuffer + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', `${Array.from({ length: 3000 }, () => '').join('\n')}\n`, 100) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRpcRequest('terminal.read', { terminal: terminal.handle }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toMatchObject({ + terminal: { + handle: terminal.handle, + status: 'running', + tail: ['Claude Code', 'Checking files', 'Waiting for input'] + } + }) + }) + + it('does not use renderer visible-screen fallback for cursor transcript reads', async () => { + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'Visible TUI\n', + cols: 80, + rows: 24 + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => true, + serializeBuffer + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', ' \n', 100) + + const read = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + + expect(read.tail).toEqual(['']) + expect(serializeBuffer).not.toHaveBeenCalledWith('pty-1', { + scrollbackRows: 0, + altScreenForcesZeroRows: false + }) + }) + + it('does not use renderer visible-screen fallback for a short blank shell tail', async () => { + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'shell prompt\n', + cols: 80, + rows: 24 + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => true, + serializeBuffer + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\n\n', 100) + + const read = await runtime.readTerminal(terminal.handle) + + expect(read.tail).toEqual(['', '']) + expect(serializeBuffer).not.toHaveBeenCalledWith('pty-1', { + scrollbackRows: 0, + altScreenForcesZeroRows: false + }) + }) + it('trims oversized terminal output bursts without per-line array shifts', async () => { const shiftSpy = vi.spyOn(Array.prototype, 'shift') const lines = Array.from({ length: 5000 }, (_, index) => `line-${index}`) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c91bf1829..fce1fdb27 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3825,6 +3825,59 @@ export class OrcaRuntimeService { return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null } + private async withVisibleSnapshotFallback( + ptyId: string, + read: RuntimeTerminalRead, + opts: { cursor?: number; limit?: number } = {} + ): Promise { + if (!shouldFallbackToVisibleTerminalSnapshot(read, opts)) { + return read + } + const lines = await this.readRendererVisibleSnapshotLines(ptyId) + if (lines.length === 0) { + return read + } + return buildVisibleSnapshotReadFallback(read, lines, opts.limit) + } + + private async readRendererVisibleSnapshotLines(ptyId: string): Promise { + const controller = this.ptyController + if (!controller?.serializeBuffer) { + return [] + } + if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) { + return [] + } + try { + // 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 + }) + 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() + } + } catch { + return [] + } + } + private async serializeHeadlessTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number; includeEmpty?: boolean } = {} @@ -5930,7 +5983,8 @@ export class OrcaRuntimeService { ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { - return this.readPtyTerminal(handle, pty.pty, opts) + const read = this.readPtyTerminal(handle, pty.pty, opts) + return this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts) } const { leaf } = this.getLiveLeafForHandle(handle) @@ -5944,7 +5998,7 @@ export class OrcaRuntimeService { cursor: opts.cursor, limit: opts.limit }) - return read + return leaf.ptyId ? this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) : read } async sendTerminal( @@ -15388,6 +15442,41 @@ function readTerminalTail(args: { } } +function shouldFallbackToVisibleTerminalSnapshot( + read: RuntimeTerminalRead, + opts: { cursor?: number; limit?: number } +): boolean { + if (typeof opts.cursor === 'number') { + return false + } + if (read.tail.length === 0) { + return false + } + const hasSubstantialBlankTail = + read.limited === true || read.truncated || read.tail.length >= DEFAULT_TERMINAL_READ_LIMIT + return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0) +} + +function buildVisibleSnapshotReadFallback( + read: RuntimeTerminalRead, + visibleLines: string[], + limit: number | undefined +): RuntimeTerminalRead { + const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) + const lineBoundedTail = visibleLines.slice(-lineLimit) + const charBoundedTail = trimTerminalPreviewToCharacterBudget( + lineBoundedTail, + MAX_TERMINAL_PREVIEW_CHARS + ) + return { + ...read, + tail: charBoundedTail.tail, + limited: + read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited, + returnedLineCount: charBoundedTail.tail.length + } +} + function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState { if (leaf.connected) { return 'running'