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.
This commit is contained in:
Neil
2026-08-18 22:51:37 -07:00
committed by GitHub
parent 9b5538d786
commit 3ffab9a6b3
8 changed files with 247 additions and 13 deletions
+41 -7
View File
@@ -18332,14 +18332,16 @@ export class OrcaRuntimeService {
async readTerminal(
handle: string,
opts: { cursor?: number; limit?: number } = {}
opts: { cursor?: number; limit?: number; screen?: boolean } = {}
): Promise<RuntimeTerminalRead> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
const read = this.readPtyTerminal(handle, pty.pty, opts)
const visibleRead = await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
const visibleRead = opts.screen
? await this.readRenderedScreen(pty.pty.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
return visibleRead
return labelTerminalReadSource(visibleRead)
}
const { leaf } = this.getLiveLeafForHandle(handle)
@@ -18355,11 +18357,33 @@ export class OrcaRuntimeService {
limit: opts.limit
})
if (!leaf.ptyId) {
return read
return { ...read, source: opts.screen ? 'screen-unavailable' : 'stream' }
}
const visibleRead = await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts)
const visibleRead = opts.screen
? await this.readRenderedScreen(leaf.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts)
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
return visibleRead
return labelTerminalReadSource(visibleRead)
}
// Why: the default read is the accumulated pty stream, which stacks every repaint of a line
// ("cclclecleaclear" for one `clear`) and drops spaces a prompt draws with cursor-forward.
// That is the right answer for "what happened over time" and the wrong one for "what is on
// screen", so an explicit screen read goes to the emulator state instead. When no rendered
// state exists the stream is still returned, but labelled `stream` rather than passed off as
// a screen — silently answering the other question is the defect this exists to stop.
private async readRenderedScreen(
ptyId: string,
read: RuntimeTerminalRead,
opts: { limit?: number } = {}
): Promise<RuntimeTerminalRead> {
const visibleState = await this.readVisibleTerminalState(ptyId)
const lines =
visibleState?.lines ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit))
if (lines.length === 0) {
return { ...read, source: 'screen-unavailable' }
}
return buildVisibleSnapshotReadFallback(read, lines, opts.limit)
}
// Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent
@@ -39560,6 +39584,15 @@ function visibleNonBlankTerminalLines(lines: string[]): string[] {
return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0)
}
// Why: every read carries its source, so a caller that asked for a screen and got a response
// with no source at all knows it reached a host that predates screen reads — rather than
// mistaking the stream for the screen. Rendered lines only ever enter a read through
// buildVisibleSnapshotReadFallback, which stamps `screen` itself, so anything still unlabelled
// here is the accumulated stream.
function labelTerminalReadSource(resolved: RuntimeTerminalRead): RuntimeTerminalRead {
return resolved.source ? resolved : { ...resolved, source: 'stream' }
}
function buildVisibleSnapshotReadFallback(
read: RuntimeTerminalRead,
visibleLines: string[],
@@ -39576,7 +39609,8 @@ function buildVisibleSnapshotReadFallback(
tail: charBoundedTail.tail,
limited:
read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited,
returnedLineCount: charBoundedTail.tail.length
returnedLineCount: charBoundedTail.tail.length,
source: 'screen'
}
}
+6 -2
View File
@@ -882,7 +882,10 @@ const TerminalRead = TerminalHandle.extend({
})
)
.optional(),
limit: OptionalFiniteNumber
limit: OptionalFiniteNumber,
// Why: optional so an older host that does not understand it simply drops the key and answers
// with its usual stream read; the response's `source` is what tells the caller which it got.
screen: z.literal(true).optional()
})
// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct.
@@ -1184,7 +1187,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime }) => ({
terminal: await runtime.readTerminal(params.terminal, {
cursor: params.cursor,
limit: params.limit
limit: params.limit,
screen: params.screen
})
})
}),