Improve Orca CLI terminal read pagination (#2553)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-21 16:58:45 -04:00 committed by GitHub
parent 3861d65a17
commit f36d9a58fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 256 additions and 67 deletions

View File

@ -197,6 +197,7 @@ Use selectors to discover terminals, then use the returned handle for repeated l
orca terminal list --worktree id:<worktreeId> --json
orca terminal show --terminal <handle> --json
orca terminal read --terminal <handle> --json
orca terminal read --terminal <handle> --cursor <nextCursor> --limit 1000 --json
orca terminal send --terminal <handle> --text "continue" --enter --json
orca terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
orca terminal wait --terminal <handle> --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 <handle>` 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 <nextCursor> --limit <n>` 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 <handle> --for exit` only when the task actually depends on process completion.
- Use `terminal wait --terminal <handle> --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.

View File

@ -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> = {}): 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')
})
})

View File

@ -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')
}

View File

@ -75,7 +75,8 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
}
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)
},

View File

@ -169,7 +169,7 @@ Common Commands:
orca worktree ps [--limit <n>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
orca terminal show [--terminal <handle>] [--json]
orca terminal read [--terminal <handle>] [--json]
orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
orca terminal stop --worktree <selector> [--json]

View File

@ -162,16 +162,17 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['terminal', 'read'],
summary: 'Read bounded terminal output',
usage: 'orca terminal read [--terminal <handle>] [--cursor <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor'],
usage: 'orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--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 <prev> 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'
]
},
{

View File

@ -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 {

View File

@ -4216,49 +4216,27 @@ export class OrcaRuntimeService {
}
}
async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise<RuntimeTerminalRead> {
async readTerminal(
handle: string,
opts: { cursor?: number; limit?: number } = {}
): Promise<RuntimeTerminalRead> {
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<RuntimeWorktreeStatus, number> = {
@ -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'

View File

@ -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({

View File

@ -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 = {