fix(runtime): return authoritative visible TUI output

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-17 13:51:49 -04:00
co-authored by Orca
parent 1c54171c55
commit e4c4f96269
2 changed files with 797 additions and 32 deletions
+491
View File
@@ -12794,6 +12794,497 @@ describe('OrcaRuntimeService', () => {
})
})
it('reads and shows the runtime-owned alternate-screen grid without serialization', async () => {
const serializeBuffer = vi.fn()
const serializeProviderBuffer = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
serializeProviderBuffer
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData(
'pty-1',
'shell history\r\n\x1b[?1049h\x1b[2J\x1b[HClaude Code\r\nWorking on fix\r\nTool: Read\r\n',
100
)
const read = await runtime.readTerminal(terminal.handle)
const shown = await runtime.showTerminal(terminal.handle)
expect(read.tail).toEqual(['Claude Code', 'Working on fix', 'Tool: Read'])
expect(shown.preview).toBe('Claude Code\nWorking on fix\nTool: Read')
expect(serializeBuffer).not.toHaveBeenCalled()
expect(serializeProviderBuffer).not.toHaveBeenCalled()
})
it('classifies older provider alternate-screen snapshots from ANSI', async () => {
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '\x1b[?1049h\x1b[2J\x1b[HRemote Vim\r\nediting README.md\r\n',
cols: 80,
rows: 24,
seq: 900,
source: 'headless'
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const read = await runtime.readTerminal(terminal.handle)
expect(read.tail).toEqual(['Remote Vim', 'editing README.md'])
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(true)
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('honors the last ANSI screen transition when older provider metadata is absent', async () => {
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '\x1b[?1049hOld TUI\r\n\x1b[?1049l',
cols: 80,
rows: 24,
seq: 900,
source: 'headless'
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const read = await runtime.readTerminal(terminal.handle)
expect(read.tail).toEqual(['shell history'])
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(false)
})
it.each([
{
name: 'live exit wins over an alternate provider snapshot',
snapshotData: '\x1b[?1049h\x1b[2J\x1b[HStale TUI\r\n',
snapshotMode: true,
liveData: '\x1b[?1049lreturned shell\r\n',
expectedMode: false,
expectedLine: 'shell history'
},
{
name: 'live entry wins over a primary provider snapshot',
snapshotData: 'stale shell\r\n',
snapshotMode: false,
liveData: '\x1b[?1049h\x1b[2J\x1b[HLive TUI\r\nnew frame\r\n',
expectedMode: true,
expectedLine: 'Live TUI'
}
])('$name', async ({ snapshotData, snapshotMode, liveData, expectedMode, expectedLine }) => {
type Snapshot = {
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}
let resolveSnapshot!: (snapshot: Snapshot) => void
const serializeProviderBuffer = vi.fn(
() =>
new Promise<Snapshot>((resolve) => {
resolveSnapshot = resolve
})
)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const readPromise = runtime.readTerminal(terminal.handle)
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
runtime.onPtyData('pty-1', liveData, 101)
resolveSnapshot({
data: snapshotData,
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: snapshotMode
})
const read = await readPromise
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(expectedMode)
expect(read.tail.join('\n')).toContain(expectedLine)
expect(read.tail.join('\n')).not.toContain('Stale TUI')
})
it('shares one provider snapshot between concurrent read and show calls', async () => {
type Snapshot = {
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}
let resolveSnapshot!: (snapshot: Snapshot) => void
const serializeProviderBuffer = vi.fn(
() =>
new Promise<Snapshot>((resolve) => {
resolveSnapshot = resolve
})
)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const readPromise = runtime.readTerminal(terminal.handle)
const showPromise = runtime.showTerminal(terminal.handle)
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
resolveSnapshot({
data: '\x1b[?1049h\x1b[2J\x1b[HShared TUI frame\r\n',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
const [read, shown] = await Promise.all([readPromise, showPromise])
expect(read.tail).toEqual(['Shared TUI frame'])
expect(shown.preview).toBe('Shared TUI frame')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('keeps cursor reads provider-free on restored alternate-screen PTYs', async () => {
const serializeProviderBuffer = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const read = await runtime.readTerminal(terminal.handle, { cursor: 0 })
expect(read.tail).toEqual(['shell history'])
expect(serializeProviderBuffer).not.toHaveBeenCalled()
})
it('backs off after a provider snapshot failure instead of retrying for show', async () => {
const serializeProviderBuffer = vi.fn().mockRejectedValue(new Error('provider unavailable'))
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const read = await runtime.readTerminal(terminal.handle)
const shown = await runtime.showTerminal(terminal.handle)
expect(read.tail).toEqual(['shell history'])
expect(shown.preview).toBe('shell history')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('falls back once to a mounted renderer when a known alternate provider fails', async () => {
const serializeProviderBuffer = vi
.fn()
.mockResolvedValueOnce({
data: '\x1b[?1049h\x1b[2J\x1b[HProvider TUI\r\n',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
.mockRejectedValue(new Error('provider unavailable'))
const serializeBuffer = vi.fn().mockResolvedValue({
data: '\x1b[?1049h\x1b[2J\x1b[HRenderer TUI\r\n',
cols: 80,
rows: 24
})
let rendererMounted = false
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
serializeProviderBuffer,
hasRendererSerializer: () => rendererMounted
})
syncSinglePty(runtime)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({
tail: ['Provider TUI']
})
runtime.onPtyData('pty-1', 'new output', 100)
rendererMounted = true
const read = await runtime.readTerminal(terminal.handle)
expect(read.tail).toEqual(['Renderer TUI'])
expect(serializeProviderBuffer).toHaveBeenCalledTimes(2)
expect(serializeBuffer).toHaveBeenCalledOnce()
})
it('bounds a stuck provider snapshot and shares the timed-out request', async () => {
vi.useFakeTimers()
try {
const serializeProviderBuffer = vi.fn(() => new Promise<never>(() => undefined))
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const reads = Promise.all([
runtime.readTerminal(terminal.handle),
runtime.showTerminal(terminal.handle)
])
await vi.advanceTimersByTimeAsync(750)
const [read, shown] = await reads
expect(read.tail).toEqual(['shell history'])
expect(shown.preview).toBe('shell history')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('discards a provider frame when the PTY generation resets during capture', async () => {
type Snapshot = {
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}
let resolveSnapshot!: (snapshot: Snapshot) => void
const serializeProviderBuffer = vi.fn(
() =>
new Promise<Snapshot>((resolve) => {
resolveSnapshot = resolve
})
)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'old shell\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const readPromise = runtime.readTerminal(terminal.handle)
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 0, generation: 'reset' },
runtime.getPtyOutputSequence('pty-1')
)
resolveSnapshot({
data: '\x1b[?1049hStale generation TUI\r\n',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
const read = await readPromise
expect(read.tail.join('\n')).not.toContain('Stale generation TUI')
expect(runtime.isTerminalAlternateScreen('pty-1')).toBe(false)
})
it('rejects a visible frame when its terminal handle changes during capture', async () => {
type Snapshot = {
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}
let resolveSnapshot!: (snapshot: Snapshot) => void
const serializeProviderBuffer = vi.fn(
() =>
new Promise<Snapshot>((resolve) => {
resolveSnapshot = resolve
})
)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const readPromise = runtime.readTerminal(terminal.handle)
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
syncSinglePty(runtime, 'pty-2')
resolveSnapshot({
data: '\x1b[?1049hWrong terminal frame\r\n',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
await expect(readPromise).rejects.toThrow('terminal_handle_stale')
})
it('bounds provider visible frames for read and show responses', async () => {
const visibleLines = [
'skip-1',
'skip-2',
'A'.repeat(49),
'B'.repeat(49),
'C'.repeat(49),
'D'.repeat(49),
'E'.repeat(49),
'F'.repeat(50)
]
const expectedTail = visibleLines.slice(-6)
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: `\x1b[?1049h\x1b[2J\x1b[H${visibleLines.join('\r\n')}\r\n`,
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const read = await runtime.readTerminal(terminal.handle, { limit: 6 })
const shown = await runtime.showTerminal(terminal.handle)
expect(read.tail).toEqual(expectedTail)
expect(read.returnedLineCount).toBe(6)
expect(shown.preview).toBe(expectedTail.join('\n'))
expect(shown.preview.length).toBe(300)
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('returns renderer visible screen lines through terminal.read RPC JSON result', async () => {
const serializeBuffer = vi.fn().mockResolvedValue({
data: '\x1b[?1049hClaude Code\r\nChecking files\r\nWaiting for input\r\n',
+306 -32
View File
@@ -1194,6 +1194,19 @@ type RuntimeHeadlessTerminal = {
writeChain: Promise<void>
}
type RuntimeVisibleTerminalState = {
lines: string[]
isAlternateScreen: boolean
sequence: number
generation: number
}
type ProviderBufferAcquisition = {
generation: number
scrollbackRows: number
promise: Promise<PtyProviderBufferSnapshot | null>
}
type HeadlessSeedMetadata = {
cwd?: string | null
oscLinks?: TerminalOscLinkRange[]
@@ -2289,6 +2302,12 @@ export class OrcaRuntimeService {
string,
Set<TerminalKittyKeyboardModeTracker>
>()
private providerBufferAcquisitionsByPtyId = new Map<string, ProviderBufferAcquisition>()
private providerVisibleStateByPtyId = new Map<string, RuntimeVisibleTerminalState>()
private providerVisibleRetryAtByPtyId = new Map<string, number>()
private providerSnapshotsWithLiveModeTransition = new WeakSet<PtyProviderBufferSnapshot>()
private ptyLifecycleGenerationById = new Map<string, number>()
private nextPtyLifecycleGeneration = 1
private recentPtyPathCandidatesById = new Map<string, string[]>()
// Why: OSC 9999 status can span PTY chunks. Keeping parser state in the
// runtime lets hidden/model-owned terminals observe agent state without a
@@ -6906,6 +6925,25 @@ export class OrcaRuntimeService {
return this.ptyOutputSequenceById.get(ptyId) ?? 0
}
private getPtyLifecycleGeneration(ptyId: string): number {
const existing = this.ptyLifecycleGenerationById.get(ptyId)
if (existing !== undefined) {
return existing
}
const generation = this.nextPtyLifecycleGeneration++
this.ptyLifecycleGenerationById.set(ptyId, generation)
return generation
}
private advancePtyLifecycleGeneration(ptyId: string): void {
this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++)
// Why: a provider response belongs to the process generation that issued
// it; a respawn must neither reuse its frame nor join its in-flight call.
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
this.providerVisibleStateByPtyId.delete(ptyId)
this.providerVisibleRetryAtByPtyId.delete(ptyId)
}
synchronizePtyOutputSequenceFromProvider(
ptyId: string,
providerSequence: { value: number; generation: 'continued' | 'reset' },
@@ -6932,6 +6970,7 @@ export class OrcaRuntimeService {
const providerBaseline = providerOffset + baseline
if (providerSequence.generation === 'reset') {
this.advancePtyLifecycleGeneration(ptyId)
// Why: daemon respawn/cold restore starts a new absolute domain. Old
// emulator state cannot remain authoritative over the replacement.
if (replacesExistingRuntimeGeneration) {
@@ -7606,7 +7645,35 @@ export class OrcaRuntimeService {
private async serializeProviderTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
opts: { scrollbackRows?: number } = {},
wait: { timeoutMs?: number } = {}
): Promise<PtyProviderBufferSnapshot | null> {
const generation = this.getPtyLifecycleGeneration(ptyId)
const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0))
let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId)
if (
!acquisition ||
acquisition.generation !== generation ||
acquisition.scrollbackRows < scrollbackRows
) {
const promise = this.captureProviderTerminalBuffer(ptyId, opts, generation)
acquisition = { generation, scrollbackRows, promise }
this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition)
void promise.finally(() => {
if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) {
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
}
})
}
return typeof wait.timeoutMs === 'number'
? withTimeout(acquisition.promise, wait.timeoutMs, null)
: acquisition.promise
}
private async captureProviderTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number },
generation: number
): Promise<PtyProviderBufferSnapshot | null> {
const liveModeTracker = new TerminalKittyKeyboardModeTracker()
let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId)
@@ -7619,24 +7686,44 @@ export class OrcaRuntimeService {
// Why: daemon PTYs survive an app relaunch before any renderer mounts.
// Mobile still needs their retained history without navigating desktop.
const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts)
if (typeof snapshot?.alternateScreen === 'boolean') {
if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) {
return null
}
const snapshotModeTracker = new TerminalKittyKeyboardModeTracker()
if (typeof snapshot.alternateScreen === 'boolean') {
snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
} else {
// Why: older providers omit mode metadata, but their ANSI snapshot
// still carries the DECSET/DECRST needed to classify the active screen.
snapshotModeTracker.scanReplay(snapshot.data)
}
const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch
let effectiveAlternateScreen: boolean | undefined
if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) {
const modeTracker = new TerminalKittyKeyboardModeTracker()
if (snapshot.alternateScreen) {
modeTracker.scan('\x1b[?1049h')
if (observedSnapshotMode) {
modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
}
// Why: stream bytes received after the request began can be newer
// than snapshot metadata, so an observed live transition wins.
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
}
this.providerModeTrackersByPtyId.set(ptyId, modeTracker)
}
if (!snapshot) {
return null
effectiveAlternateScreen = modeTracker.isAlternateScreen
}
const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0
return this.preferTrackedLastTitle(ptyId, {
const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, {
...snapshot,
seq: providerOffset + snapshot.seq
seq: providerOffset + snapshot.seq,
...(effectiveAlternateScreen !== undefined
? { alternateScreen: effectiveAlternateScreen }
: {})
})
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot)
}
return reconciledSnapshot
} catch {
return null
} finally {
@@ -7652,16 +7739,164 @@ export class OrcaRuntimeService {
read: RuntimeTerminalRead,
opts: { cursor?: number; limit?: number } = {}
): Promise<RuntimeTerminalRead> {
if (!shouldFallbackToVisibleTerminalSnapshot(read, opts)) {
if (typeof opts.cursor === 'number') {
return read
}
const lines = await this.readRendererVisibleSnapshotLines(ptyId)
const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts)
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
const providerModeUnknown =
this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId)
if (
!blankFallback &&
!providerModeUnknown &&
!knownAlternateScreen &&
!this.headlessTerminals.has(ptyId)
) {
return read
}
const visibleState = await this.readVisibleTerminalState(ptyId)
if (!blankFallback && !knownAlternateScreen && !visibleState?.isAlternateScreen) {
return read
}
let lines = visibleState?.lines ?? []
if (lines.length === 0) {
lines = await this.readRendererVisibleSnapshotLines(ptyId)
}
if (lines.length === 0) {
return read
}
return buildVisibleSnapshotReadFallback(read, lines, opts.limit)
}
private async visibleSnapshotPreview(ptyId: string, preview: string): Promise<string> {
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
const providerModeUnknown =
this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId)
if (!providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId)) {
return preview
}
const visibleState = await this.readVisibleTerminalState(ptyId)
if (!knownAlternateScreen && !visibleState?.isAlternateScreen) {
return preview
}
let lines = visibleState?.lines ?? []
if (lines.length === 0) {
lines = await this.readRendererVisibleSnapshotLines(ptyId)
}
return lines.length > 0 ? buildPreview(lines, '') : preview
}
private async readVisibleTerminalState(
ptyId: string
): Promise<RuntimeVisibleTerminalState | null> {
if (!this.providerSnapshotPreferredPtys.has(ptyId)) {
return this.readHeadlessVisibleTerminalState(ptyId)
}
const generation = this.getPtyLifecycleGeneration(ptyId)
const outputSequence = this.getPtyOutputSequence(ptyId)
const cached = this.providerVisibleStateByPtyId.get(ptyId)
const trackedMode = this.providerModeTrackersByPtyId.get(ptyId)
if (
cached?.generation === generation &&
outputSequence <= cached.sequence &&
(!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen)
) {
return cached
}
if (trackedMode && !trackedMode.isAlternateScreen) {
const headlessState = await this.readHeadlessVisibleTerminalState(ptyId)
return headlessState
? { ...headlessState, isAlternateScreen: false }
: {
lines: [],
isAlternateScreen: false,
sequence: outputSequence,
generation
}
}
if ((this.providerVisibleRetryAtByPtyId.get(ptyId) ?? 0) > Date.now()) {
return null
}
const snapshot = await this.serializeProviderTerminalBuffer(
ptyId,
{ scrollbackRows: 0 },
{ timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS }
)
if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) {
this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS)
return null
}
this.providerVisibleRetryAtByPtyId.delete(ptyId)
if (this.providerSnapshotsWithLiveModeTransition.has(snapshot)) {
// Why: the provider frame can predate a mode switch observed while its
// RPC was pending; the ordered live emulator owns the post-switch grid.
const liveState = await this.readHeadlessVisibleTerminalState(ptyId)
if (liveState && liveState.isAlternateScreen === (snapshot.alternateScreen ?? false)) {
return liveState
}
}
const lines = await this.parseVisibleSnapshotLines(snapshot)
if (this.getPtyLifecycleGeneration(ptyId) !== generation) {
return null
}
const visibleState: RuntimeVisibleTerminalState = {
lines,
isAlternateScreen: snapshot.alternateScreen ?? false,
sequence: snapshot.seq,
generation
}
if (this.getPtyOutputSequence(ptyId) <= snapshot.seq) {
this.providerVisibleStateByPtyId.set(ptyId, visibleState)
}
return visibleState
}
private async readHeadlessVisibleTerminalState(
ptyId: string
): Promise<RuntimeVisibleTerminalState | null> {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
return null
}
const generation = this.getPtyLifecycleGeneration(ptyId)
await state.writeChain
if (
this.headlessTerminals.get(ptyId) !== state ||
this.getPtyLifecycleGeneration(ptyId) !== generation
) {
return null
}
return {
lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()),
isAlternateScreen: state.emulator.isAlternateScreen,
sequence: state.outputSequence,
generation
}
}
private async parseVisibleSnapshotLines(snapshot: {
data: string
cols: number
rows: number
}): Promise<string[]> {
if (snapshot.data.length === 0) {
return []
}
const emulator = new HeadlessEmulator({
cols: snapshot.cols,
rows: snapshot.rows,
scrollback: 0
})
try {
await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`)
return visibleNonBlankTerminalLines(emulator.getVisibleLines())
} finally {
emulator.dispose()
}
}
private async readRendererVisibleSnapshotLines(ptyId: string): Promise<string[]> {
const controller = this.ptyController
if (!controller?.serializeBuffer) {
@@ -7674,27 +7909,18 @@ export class OrcaRuntimeService {
// Why: raw PTY tails can be whitespace-only while a full-screen TUI is
// visibly nonblank in renderer xterm. Ask the renderer for the active
// screen instead of reusing the headless transcript path.
const snapshot = await controller.serializeBuffer(ptyId, {
scrollbackRows: 0,
altScreenForcesZeroRows: false
})
const snapshot = await withTimeout(
controller.serializeBuffer(ptyId, {
scrollbackRows: 0,
altScreenForcesZeroRows: false
}),
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
null
)
if (!snapshot || snapshot.data.length === 0) {
return []
}
const emulator = new HeadlessEmulator({
cols: snapshot.cols,
rows: snapshot.rows,
scrollback: 0
})
try {
await emulator.write(snapshot.data)
return emulator
.getVisibleLines()
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0)
} finally {
emulator.dispose()
}
return this.parseVisibleSnapshotLines(snapshot)
} catch {
return []
}
@@ -8803,6 +9029,7 @@ export class OrcaRuntimeService {
}
onPtyExit(ptyId: string, exitCode: number): void {
this.advancePtyLifecycleGeneration(ptyId)
advertisedUrlWatcher.unbindPty(ptyId)
// Clean up new mobile state for this PTY
this.mobileSubscribers.delete(ptyId)
@@ -8819,6 +9046,9 @@ export class OrcaRuntimeService {
this.providerSnapshotPreferredPtys.delete(ptyId)
this.providerModeTrackersByPtyId.delete(ptyId)
this.providerModeSnapshotScansByPtyId.delete(ptyId)
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
this.providerVisibleStateByPtyId.delete(ptyId)
this.providerVisibleRetryAtByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
@@ -10968,8 +11198,12 @@ export class OrcaRuntimeService {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
const worktreesById = await this.getResolvedWorktreeMap()
const summary = this.buildPtyTerminalSummary(pty.pty, worktreesById)
const preview = await this.visibleSnapshotPreview(pty.pty.ptyId, summary.preview)
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
return {
...this.buildPtyTerminalSummary(pty.pty, worktreesById),
...summary,
preview,
tabId: pty.pty.tabId ?? pty.record.tabId,
leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId,
paneRuntimeId: -1,
@@ -10982,8 +11216,16 @@ export class OrcaRuntimeService {
this.assertStableReadyGraph(graphEpoch)
const { leaf } = this.getLiveLeafForHandle(handle)
const summary = this.buildTerminalSummary(leaf, worktreesById)
const preview = leaf.ptyId
? await this.visibleSnapshotPreview(leaf.ptyId, summary.preview)
: summary.preview
this.assertStableReadyGraph(graphEpoch)
if (leaf.ptyId) {
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
}
return {
...summary,
preview,
paneRuntimeId: leaf.paneRuntimeId,
ptyId: leaf.ptyId,
rendererGraphEpoch: this.rendererGraphEpoch
@@ -10997,7 +11239,9 @@ export class OrcaRuntimeService {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
const read = this.readPtyTerminal(handle, pty.pty, opts)
return this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
const visibleRead = await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
return visibleRead
}
const { leaf } = this.getLiveLeafForHandle(handle)
@@ -11011,7 +11255,12 @@ export class OrcaRuntimeService {
cursor: opts.cursor,
limit: opts.limit
})
return leaf.ptyId ? this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) : read
if (!leaf.ptyId) {
return read
}
const visibleRead = await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts)
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
return visibleRead
}
async sendTerminal(
@@ -21328,6 +21577,8 @@ export class OrcaRuntimeService {
}
private dropDisconnectedPtyRecord(ptyId: string): void {
// Why: pruning can remove a PTY without the normal exit callback.
this.advancePtyLifecycleGeneration(ptyId)
this.ptysById.delete(ptyId)
this.recentPtyOutputById.delete(ptyId)
this.clearWaitBlockedCheckState(ptyId)
@@ -21338,6 +21589,9 @@ export class OrcaRuntimeService {
this.providerSnapshotPreferredPtys.delete(ptyId)
this.providerModeTrackersByPtyId.delete(ptyId)
this.providerModeSnapshotScansByPtyId.delete(ptyId)
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
this.providerVisibleStateByPtyId.delete(ptyId)
this.providerVisibleRetryAtByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
@@ -22728,6 +22982,20 @@ export class OrcaRuntimeService {
return { record, pty }
}
private assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void {
const runtimePty = this.getLivePtyForHandle(handle)
if (runtimePty) {
if (runtimePty.pty.ptyId !== expectedPtyId) {
throw new Error('terminal_handle_stale')
}
return
}
const { leaf } = this.getLiveLeafForHandle(handle)
if (leaf.ptyId !== expectedPtyId) {
throw new Error('terminal_handle_stale')
}
}
private readPtyTerminal(
handle: string,
pty: RuntimePtyWorktreeRecord,
@@ -25867,6 +26135,8 @@ const MAX_TAIL_PENDING_ANSI_CHARS = 4096
const DEFAULT_TERMINAL_READ_LIMIT = 120
const MAX_TERMINAL_READ_LIMIT = 2000
const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750
const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000
const MAX_PREVIEW_LINES = 6
const MAX_PREVIEW_CHARS = 300
const WORKTREE_STATUS_PRIORITY: Record<RuntimeWorktreeStatus, number> = {
@@ -27178,6 +27448,10 @@ function shouldFallbackToVisibleTerminalSnapshot(
return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0)
}
function visibleNonBlankTerminalLines(lines: string[]): string[] {
return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0)
}
function buildVisibleSnapshotReadFallback(
read: RuntimeTerminalRead,
visibleLines: string[],