From f36d9a58fe7f33e10f82efdb72d3d46ddc5d7fce Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 21 May 2026 16:58:45 -0400 Subject: [PATCH] Improve Orca CLI terminal read pagination (#2553) Co-authored-by: Orca --- skills/orca-cli/SKILL.md | 4 + src/cli/format.test.ts | 42 +++++- src/cli/format.ts | 10 +- src/cli/handlers/terminal.ts | 3 +- src/cli/help.ts | 2 +- src/cli/specs/core.ts | 7 +- src/main/runtime/orca-runtime.test.ts | 84 ++++++++++++ src/main/runtime/orca-runtime.ts | 159 ++++++++++++++--------- src/main/runtime/rpc/methods/terminal.ts | 8 +- src/shared/runtime-types.ts | 4 + 10 files changed, 256 insertions(+), 67 deletions(-) diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 8bf298804..e31df7b64 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -197,6 +197,7 @@ Use selectors to discover terminals, then use the returned handle for repeated l orca terminal list --worktree id: --json orca terminal show --terminal --json orca terminal read --terminal --json +orca terminal read --terminal --cursor --limit 1000 --json orca terminal send --terminal --text "continue" --enter --json orca terminal wait --terminal --for exit --timeout-ms 5000 --json orca terminal wait --terminal --for tui-idle --timeout-ms 30000 --json @@ -215,6 +216,8 @@ orca terminal read --json Why: `--terminal` is optional for most commands. When omitted, Orca auto-resolves to the active terminal in the current worktree (same as browser commands target the active tab). Use explicit `--terminal ` when operating on a specific pane. +Why: long terminal transcripts should be read with cursors. Save `nextCursor` from a JSON read, then call `terminal read --cursor --limit ` to page forward. Cursor reads default to the retained transcript size; `--limit` can request a smaller page. If `limited` is true, keep reading with the returned `nextCursor`. If `truncated` is true, older output has already fallen out of the retained buffer; use `oldestCursor` as the earliest available cursor. + Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca returns `terminal_handle_stale`, reacquire a fresh handle with `terminal list`. Why: `--direction horizontal` splits the pane **left and right** (new pane appears to the right). `--direction vertical` splits the pane **top and bottom** (new pane appears below). This matches VS Code's split convention. Default is horizontal. @@ -238,6 +241,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea - Orca only injects `ORCA_WORKTREE_PATH`-style variables for some setup-hook flows, so they are not a general detection contract for agents. - Use `terminal list` to reacquire handles after Orca reloads. - Use `terminal read` before `terminal send` unless the next input is obvious. +- For long agent responses, use `terminal read --json` with `nextCursor`, `--cursor`, and `--limit` instead of relying on the default human preview. Continue paging while `limited` is true; treat `truncated` as a signal that the requested cursor was older than the retained output. - Use `terminal wait --terminal --for exit` only when the task actually depends on process completion. - Use `terminal wait --terminal --for tui-idle` to wait for an agent CLI (Claude Code, Gemini, Codex, etc.) to finish its current task. This detects the working→idle OSC title transition. Always pass `--timeout-ms` as a safety net — unsupported CLIs will hang until timeout. - Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. In local Orca sessions, `--command "codex"` is routed through Orca's visible terminal path automatically so Codex does not start as a headless/background PTY. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching. diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts index 6bed14237..bcaf96c97 100644 --- a/src/cli/format.test.ts +++ b/src/cli/format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { RuntimeRpcFailureError } from './runtime-client' -import { formatCliError, formatWorktreeList } from './format' +import { formatCliError, formatTerminalRead, formatWorktreeList } from './format' import type { RuntimeWorktreeRecord } from '../shared/runtime-types' function worktree(overrides: Partial = {}): RuntimeWorktreeRecord { @@ -89,3 +89,43 @@ describe('formatWorktreeList', () => { expect(output).toContain('childWorktreeIds: []') }) }) + +describe('formatTerminalRead', () => { + it('prints cursor metadata and limit warnings when the runtime returns them', () => { + const output = formatTerminalRead({ + terminal: { + handle: 'term_1', + status: 'running', + tail: ['line 1'], + truncated: false, + limited: true, + oldestCursor: '0', + nextCursor: '50', + latestCursor: '150', + returnedLineCount: 1 + } + }) + + expect(output).toContain('cursor: 50') + expect(output).toContain('oldest cursor: 0') + expect(output).toContain('latest cursor: 150') + expect(output).toContain('warning: output limited; read again with the returned cursor') + }) + + it('keeps older runtime read responses readable', () => { + const output = formatTerminalRead({ + terminal: { + handle: 'term_1', + status: 'running', + tail: ['old server output'], + truncated: true, + nextCursor: '12' + } + }) + + expect(output).toContain('cursor: 12') + expect(output).toContain('warning: older output is no longer retained') + expect(output).toContain('old server output') + expect(output).not.toContain('undefined') + }) +}) diff --git a/src/cli/format.ts b/src/cli/format.ts index 6e50d14b5..8feb1bc2c 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -174,10 +174,18 @@ export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): s export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string { const terminal = result.terminal + const oldestCursor = + typeof terminal.oldestCursor === 'string' ? [`oldest cursor: ${terminal.oldestCursor}`] : [] + const latestCursor = + typeof terminal.latestCursor === 'string' ? [`latest cursor: ${terminal.latestCursor}`] : [] const header = [ `handle: ${terminal.handle}`, `status: ${terminal.status}`, - ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []) + ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []), + ...oldestCursor, + ...latestCursor, + ...(terminal.truncated ? ['warning: older output is no longer retained'] : []), + ...(terminal.limited ? ['warning: output limited; read again with the returned cursor'] : []) ] return [...header, '', ...terminal.tail].join('\n') } diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 8ea26d488..3759081ad 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -75,7 +75,8 @@ export const TERMINAL_HANDLERS: Record = { } const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { terminal: await getTerminalHandle(flags, cwd, client), - ...(cursor !== undefined ? { cursor } : {}) + ...(cursor !== undefined ? { cursor } : {}), + limit: getOptionalPositiveIntegerFlag(flags, 'limit') }) printResult(result, json, formatTerminalRead) }, diff --git a/src/cli/help.ts b/src/cli/help.ts index 2a2c80793..a269e5404 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -169,7 +169,7 @@ Common Commands: orca worktree ps [--limit ] [--json] orca terminal list [--worktree ] [--limit ] [--json] orca terminal show [--terminal ] [--json] - orca terminal read [--terminal ] [--json] + orca terminal read [--terminal ] [--cursor ] [--limit ] [--json] orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] orca terminal stop --worktree [--json] diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index fa1377e7b..6eea616b4 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -162,16 +162,17 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ { path: ['terminal', 'read'], summary: 'Read bounded terminal output', - usage: 'orca terminal read [--terminal ] [--cursor ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor'], + usage: 'orca terminal read [--terminal ] [--cursor ] [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor', 'limit'], notes: [ 'Omit --terminal to target the active terminal in the current worktree.', 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', + 'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.', 'Useful for capturing the response to a command: read before sending, then read --cursor after waiting.' ], examples: [ 'orca terminal read --json', - 'orca terminal read --terminal term_abc123 --cursor 42 --json' + 'orca terminal read --terminal term_abc123 --cursor 42 --limit 1000 --json' ] }, { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 66d549b4c..e49e2b9be 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2686,6 +2686,90 @@ describe('OrcaRuntimeService', () => { expect(thirdRead.nextCursor).toBe('2') }) + it('paginates retained terminal output with explicit limits and truncation metadata', async () => { + const runtime = new OrcaRuntimeService(store) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData( + 'pty-1', + `${Array.from({ length: 150 }, (_, index) => `line-${index}`).join('\n')}\n`, + 100 + ) + + const preview = await runtime.readTerminal(terminal.handle) + expect(preview.tail).toHaveLength(120) + expect(preview.tail[0]).toBe('line-30') + expect(preview.limited).toBe(true) + expect(preview.oldestCursor).toBe('0') + expect(preview.latestCursor).toBe('150') + + const defaultCursorRead = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + expect(defaultCursorRead.tail).toHaveLength(150) + expect(defaultCursorRead.nextCursor).toBe('150') + expect(defaultCursorRead.limited).toBe(false) + + const firstPage = await runtime.readTerminal(terminal.handle, { cursor: 0, limit: 50 }) + expect(firstPage.tail).toHaveLength(50) + expect(firstPage.tail[0]).toBe('line-0') + expect(firstPage.nextCursor).toBe('50') + expect(firstPage.limited).toBe(true) + expect(firstPage.truncated).toBe(false) + + const secondPage = await runtime.readTerminal(terminal.handle, { + cursor: Number(firstPage.nextCursor), + limit: 200 + }) + expect(secondPage.tail).toHaveLength(100) + expect(secondPage.tail[0]).toBe('line-50') + expect(secondPage.nextCursor).toBe('150') + expect(secondPage.limited).toBe(false) + + runtime.onPtyData( + 'pty-1', + `${Array.from({ length: 2100 }, (_, index) => `later-${index}`).join('\n')}\n`, + 101 + ) + + const staleCursorRead = await runtime.readTerminal(terminal.handle, { cursor: 0, limit: 5 }) + expect(staleCursorRead.truncated).toBe(true) + expect(staleCursorRead.oldestCursor).toBe('250') + expect(staleCursorRead.tail).toEqual([ + 'later-100', + 'later-101', + 'later-102', + 'later-103', + 'later-104' + ]) + expect(staleCursorRead.nextCursor).toBe('255') + + const futureCursorRead = await runtime.readTerminal(terminal.handle, { cursor: 9999 }) + expect(futureCursorRead.tail).toEqual([]) + expect(futureCursorRead.nextCursor).toBe('2250') + expect(futureCursorRead.limited).toBe(false) + }) + it('delivers pending orchestration messages to an already-idle agent', async () => { vi.useFakeTimers() try { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d1420e6b4..46659cdc8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4216,49 +4216,27 @@ export class OrcaRuntimeService { } } - async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise { + async readTerminal( + handle: string, + opts: { cursor?: number; limit?: number } = {} + ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { return this.readPtyTerminal(handle, pty.pty, opts) } const { leaf } = this.getLiveLeafForHandle(handle) - const allLines = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine) - - let tail: string[] - let truncated: boolean - - if (typeof opts.cursor === 'number' && opts.cursor >= 0) { - // Why: the buffer only retains the last MAX_TAIL_LINES lines. If the - // caller's cursor points to lines that were already evicted, we can only - // return what's still in memory and mark truncated=true to signal the gap. - const bufferStart = leaf.tailLinesTotal - leaf.tailBuffer.length - const sliceFrom = Math.max(0, opts.cursor - bufferStart) - // Why: cursor-based reads return only completed lines, excluding the - // trailing partial line. Including the partial would cause duplication: - // the consumer sees "hel" now, then "hello\n" on the next read after - // the line completes — same content delivered twice. - tail = leaf.tailBuffer.slice(sliceFrom) - truncated = opts.cursor < bufferStart - } else { - tail = allLines - // Why: Orca does not have a truthful main-owned screen model yet, - // especially for hidden panes. Focused v1 therefore returns the bounded - // tail lines directly instead of duplicating the same text in a fake - // screen field that would waste agent tokens. - truncated = leaf.tailTruncated - } - - return { + const read = readTerminalTail({ handle, status: getTerminalState(leaf), - tail, - truncated, - // Why: cursors advance by completed lines only. If we count the current - // partial line here, later reads can skip continued output on that same - // line because no new complete line was emitted yet. - nextCursor: String(leaf.tailLinesTotal) - } + completedLines: leaf.tailBuffer, + partialLine: leaf.tailPartialLine, + completedLineCount: leaf.tailLinesTotal, + bufferTruncated: leaf.tailTruncated, + cursor: opts.cursor, + limit: opts.limit + }) + return read } async sendTerminal( @@ -9092,30 +9070,18 @@ export class OrcaRuntimeService { private readPtyTerminal( handle: string, pty: RuntimePtyWorktreeRecord, - opts: { cursor?: number } = {} + opts: { cursor?: number; limit?: number } = {} ): RuntimeTerminalRead { - const allLines = buildTailLines(pty.tailBuffer, pty.tailPartialLine) - - let tail: string[] - let truncated: boolean - - if (typeof opts.cursor === 'number' && opts.cursor >= 0) { - const bufferStart = pty.tailLinesTotal - pty.tailBuffer.length - const sliceFrom = Math.max(0, opts.cursor - bufferStart) - tail = pty.tailBuffer.slice(sliceFrom) - truncated = opts.cursor < bufferStart - } else { - tail = allLines - truncated = pty.tailTruncated - } - - return { + return readTerminalTail({ handle, status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', - tail, - truncated, - nextCursor: String(pty.tailLinesTotal) - } + completedLines: pty.tailBuffer, + partialLine: pty.tailPartialLine, + completedLineCount: pty.tailLinesTotal, + bufferTruncated: pty.tailTruncated, + cursor: opts.cursor, + limit: opts.limit + }) } private issueHandle(leaf: RuntimeLeafRecord): string { @@ -10128,8 +10094,10 @@ export class OrcaRuntimeService { } } -const MAX_TAIL_LINES = 120 -const MAX_TAIL_CHARS = 4000 +const MAX_TAIL_LINES = 2000 +const MAX_TAIL_CHARS = 256 * 1024 +const DEFAULT_TERMINAL_READ_LIMIT = 120 +const MAX_TERMINAL_READ_LIMIT = 2000 const MAX_PREVIEW_LINES = 6 const MAX_PREVIEW_CHARS = 300 const WORKTREE_STATUS_PRIORITY: Record = { @@ -10247,6 +10215,81 @@ function buildTailLines(lines: string[], partialLine: string): string[] { return partialLine.length > 0 ? [...lines, partialLine] : lines } +function terminalReadLimit(limit: number | undefined, defaultLimit: number): number { + if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) { + return defaultLimit + } + return Math.min(Math.floor(limit), MAX_TERMINAL_READ_LIMIT) +} + +function readTerminalTail(args: { + handle: string + status: RuntimeTerminalState + completedLines: string[] + partialLine: string + completedLineCount: number + bufferTruncated: boolean + cursor?: number + limit?: number +}): RuntimeTerminalRead { + const oldestCursor = Math.max(0, args.completedLineCount - args.completedLines.length) + const latestCursor = args.completedLineCount + + if (typeof args.cursor === 'number' && args.cursor >= 0) { + const limit = terminalReadLimit(args.limit, MAX_TERMINAL_READ_LIMIT) + if (args.cursor > latestCursor) { + return { + handle: args.handle, + status: args.status, + tail: [], + truncated: false, + limited: false, + oldestCursor: String(oldestCursor), + nextCursor: String(latestCursor), + latestCursor: String(latestCursor), + returnedLineCount: 0 + } + } + // Why: cursor reads are transcript/pagination reads. They return completed + // lines only so a partial line is not delivered once as "hel" and again as + // "hello" after the newline arrives. + const startCursor = Math.max(args.cursor, oldestCursor) + const startIndex = startCursor - oldestCursor + const available = args.completedLines.slice(startIndex) + const tail = available.slice(0, limit) + const nextCursor = startCursor + tail.length + return { + handle: args.handle, + status: args.status, + tail, + truncated: args.cursor < oldestCursor, + limited: tail.length < available.length, + oldestCursor: String(oldestCursor), + nextCursor: String(nextCursor), + latestCursor: String(latestCursor), + returnedLineCount: tail.length + } + } + + // Why: un-cursored reads are preview reads for humans/agents. Return the + // latest bounded view, while the larger retained buffer remains available + // through cursor reads plus --limit. + const limit = terminalReadLimit(args.limit, DEFAULT_TERMINAL_READ_LIMIT) + const allLines = buildTailLines(args.completedLines, args.partialLine) + const tail = allLines.slice(-limit) + return { + handle: args.handle, + status: args.status, + tail, + truncated: args.bufferTruncated, + limited: tail.length < allLines.length, + oldestCursor: String(oldestCursor), + nextCursor: String(latestCursor), + latestCursor: String(latestCursor), + returnedLineCount: tail.length + } +} + function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState { if (leaf.connected) { return 'running' diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 6086c72e7..247254748 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -272,7 +272,8 @@ const TerminalRead = TerminalHandle.extend({ message: 'Cursor must be a non-negative integer' }) ) - .optional() + .optional(), + limit: OptionalFiniteNumber }) // Why: the legacy handler allowed `title: string | null` and rejected every @@ -472,7 +473,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.read', params: TerminalRead, handler: async (params, { runtime }) => ({ - terminal: await runtime.readTerminal(params.terminal, { cursor: params.cursor }) + terminal: await runtime.readTerminal(params.terminal, { + cursor: params.cursor, + limit: params.limit + }) }) }), defineMethod({ diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index ab4d16096..663629b34 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -310,7 +310,11 @@ export type RuntimeTerminalRead = { status: RuntimeTerminalState tail: string[] truncated: boolean + limited?: boolean + oldestCursor?: string nextCursor: string | null + latestCursor?: string + returnedLineCount?: number } export type RuntimeTerminalRename = {