mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
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:
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user