Files
orca/src/cli/terminal-format.ts
T
Neil 3ffab9a6b3 feat(terminal): read the rendered screen with terminal read --screen (STA-4792) (#15380)
* feat(terminal): read the rendered screen with `terminal read --screen` (STA-4792)

`terminal read` returns accumulated pty output with escape sequences stripped.
That is the right answer for "what happened over time" and the wrong one for
"what is on screen": any program that repaints a line comes back as stacked
fragments, so one `clear` typed key by key reads as `cclclecleaclear`, and a
prompt that draws a space by moving the cursor loses it. Nothing in the output
said which question had been answered, so it was used as rendering evidence and
produced false conclusions.

The runtime already knew how to render — it replays the byte stream through a
headless emulator — but only as a fallback for blank reads, alternate screen,
and never-attached ptys. A normal attached terminal never reached it. `--screen`
asks for it directly.

Every read now reports its source, which also surfaces the pre-existing
snapshot fallback that until now swapped rendered lines into an ordinary read
with no indication. `screen-unavailable` distinguishes "asked for a screen,
none could be rendered, here is the stream" from a stream the caller asked for,
and an absent source means the host predates the field. Because an older host
strips the unknown param and answers with its ordinary read, `--screen` against
one fails with that explanation rather than passing the stream off as a screen.

`--screen` and `--cursor` are mutually exclusive: a screen is the current frame
and has nothing behind it to page.

* refactor(terminal): stamp the screen source where rendered lines enter the read

Inferring it from tail array identity worked but made a load-bearing contract
out of reference equality; any later path spreading the read would silently
mislabel. Rendered lines only enter through one builder, so it stamps there and
anything still unlabelled is the stream.
2026-08-18 22:51:37 -07:00

244 lines
9.2 KiB
TypeScript

import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict'
import type {
RuntimeTerminalClose,
RuntimeTerminalCreate,
RuntimeTerminalFocus,
RuntimeTerminalListHostScope,
RuntimeTerminalListResult,
RuntimeTerminalVisualLayout,
RuntimeTerminalVisualLayoutNode,
RuntimeTerminalVisualPaneNode,
RuntimeTerminalVisualTab,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait
} from '../shared/runtime-types'
export function formatTerminalList(result: RuntimeTerminalListResult): string {
const scope = formatTerminalListHostScope(result.hostScope)
if (result.terminals.length === 0) {
return `No terminals listed.\n${scope}`
}
const body = result.terminals
.map(
(terminal) =>
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
)
.join('\n\n')
const visualLayout = formatTerminalVisualLayouts(result.visualLayouts)
const bodyWithLayout = visualLayout ? `${body}\n\nvisual layout:\n${visualLayout}` : body
const bodyWithScope = `${bodyWithLayout}\n\n${scope}`
return result.truncated
? `${bodyWithScope}\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
: bodyWithScope
}
// Why: a listing that does not say what it covers reads as absolute, and an
// absent scope means the host is too old to know — not that it covered everything.
function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string {
if (!scope) {
return 'scope: unverifiable — this host does not report which hosts it lists'
}
const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none'
const omitted =
scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : ''
return `scope: ${covered}${omitted}`
}
function formatTerminalVisualLayouts(
layouts: readonly RuntimeTerminalVisualLayout[] | undefined
): string | null {
if (!layouts || layouts.length === 0) {
return null
}
return layouts
.map((layout) =>
[
`worktree: ${layout.worktreePath || layout.worktreeId}`,
...formatVisualLayoutNode(layout.root, 0)
].join('\n')
)
.join('\n\n')
}
function formatVisualLayoutNode(node: RuntimeTerminalVisualLayoutNode, depth: number): string[] {
const indent = ' '.repeat(depth)
if (node.type === 'split') {
return [
`${indent}split ${node.direction}`,
...formatVisualLayoutNode(node.first, depth + 1),
...formatVisualLayoutNode(node.second, depth + 1)
]
}
return [
`${indent}group ${node.groupId ?? '(default)'}`,
...node.tabs.flatMap((tab) => formatVisualTab(tab, depth + 1))
]
}
function formatVisualTab(tab: RuntimeTerminalVisualTab, depth: number): string[] {
const indent = ' '.repeat(depth)
return [
`${indent}tab ${tab.tabId} ${tab.title ?? '(untitled)'}`,
...formatVisualPaneNode(tab.panes, depth + 1)
]
}
function formatVisualPaneNode(node: RuntimeTerminalVisualPaneNode, depth: number): string[] {
const indent = ' '.repeat(depth)
if (node.type === 'pane-split') {
return [
`${indent}pane split ${node.direction}`,
...formatVisualPaneNode(node.first, depth + 1),
...formatVisualPaneNode(node.second, depth + 1)
]
}
const marker = node.active ? '* ' : ' '
return [
`${indent}${marker}${node.handle} ${node.title ?? '(untitled)'} tab=${node.tabId} leaf=${node.leafId}`
]
}
export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string {
const terminal = result.terminal
return [
`handle: ${terminal.handle}`,
`title: ${terminal.title ?? '(untitled)'}`,
`worktree: ${terminal.worktreePath}`,
`branch: ${terminal.branch}`,
`leaf: ${terminal.leafId}`,
`ptyId: ${terminal.ptyId ?? 'none'}`,
`connected: ${terminal.connected}`,
`writable: ${terminal.writable}`,
// Why listed above the preview: the preview is where a reader would otherwise have to
// spot the prompt by eye, which is the work this line exists to remove.
`agentWait: ${formatAgentWait(terminal.agentWait)}`,
`preview: ${terminal.preview || '<empty>'}`
].join('\n')
}
function formatAgentWait(agentWait: RuntimeTerminalShow['agentWait']): string {
if (agentWait === undefined) {
return 'unknown (not evaluated)'
}
if (!agentWait) {
return 'none'
}
return `${agentWait.reason ?? 'interactive prompt'} (via ${agentWait.source})`
}
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 limitedWarning = formatTerminalReadLimitedWarning(terminal)
const header = [
`handle: ${terminal.handle}`,
`status: ${terminal.status}`,
...(terminal.source ? [`source: ${terminal.source}`] : []),
...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []),
...oldestCursor,
...latestCursor,
...(terminal.truncated ? ['warning: older output is no longer retained'] : []),
...(limitedWarning ? [limitedWarning] : []),
// Why: the caller asked for the rendered screen; say plainly that this is not it rather
// than let repaint fragments be read as what the terminal displayed.
...(terminal.source === 'screen-unavailable'
? [
'warning: no rendered screen was available, so this is accumulated output; repainted lines may appear as stacked fragments'
]
: [])
]
return [...header, '', ...terminal.tail].join('\n')
}
function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string | null {
if (!terminal.limited) {
return null
}
if (
typeof terminal.nextCursor === 'string' &&
typeof terminal.latestCursor === 'string' &&
terminal.nextCursor !== terminal.latestCursor
) {
return `warning: output limited; continue with --cursor ${terminal.nextCursor}`
}
if (
typeof terminal.oldestCursor === 'string' &&
typeof terminal.latestCursor === 'string' &&
terminal.oldestCursor !== terminal.latestCursor
) {
// A tail preview's next cursor is already latest, so oldestCursor is the retained history entry point.
return `warning: output limited; page retained output with --cursor ${terminal.oldestCursor} --limit <count>`
}
return 'warning: output limited'
}
export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string {
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
}
export function formatTerminalRename(result: { rename: RuntimeTerminalRename }): string {
return result.rename.title
? `Renamed terminal ${result.rename.handle} to "${result.rename.title}".`
: `Cleared title for terminal ${result.rename.handle}.`
}
export function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string {
const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : ''
const surfaceNote = result.terminal.surface ? ` [${result.terminal.surface}]` : ''
const warningNote = result.terminal.warning ? `\nwarning: ${result.terminal.warning}` : ''
return `Created terminal ${result.terminal.handle}${titleNote}${surfaceNote}${warningNote}`
}
export function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string {
return `Split pane ${result.split.handle} in tab ${result.split.tabId}`
}
export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): string {
if (result.focus.navigated === false) {
return `Focus request for terminal ${result.focus.handle} was superseded or host navigation was skipped (tab ${result.focus.tabId}).`
}
return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).`
}
/** "PTY killed." is a claim of observed death, so only a confirmed kill earns it. */
function describePtyStop(close: RuntimeTerminalClose): string {
if (close.ptyKilled) {
return ' PTY killed.'
}
if (close.ptyStopVerdict === 'live') {
return ` ${PTY_LIVE_NOTE}`
}
if (close.ptyStopVerdict === 'unverifiable') {
return ` ${describeUnconfirmedStop(close.ptyStopReason ?? 'its host could not be reached')}`
}
return ''
}
export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string {
if (result.close.closeMode === 'tab') {
return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).`
}
return `Closed terminal ${result.close.handle}.${describePtyStop(result.close)}`
}
export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
const lines = [
`handle: ${result.wait.handle}`,
`condition: ${result.wait.condition}`,
`satisfied: ${result.wait.satisfied}`,
`status: ${result.wait.status}`,
`exitCode: ${result.wait.exitCode ?? 'null'}`
]
if (result.wait.blockedReason) {
lines.push(`blockedReason: ${result.wait.blockedReason}`)
}
return lines.join('\n')
}