Improve Orca CLI terminal read pagination (#2553)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3861d65a17
commit
f36d9a58fe
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue