Fix terminal read for blank TUI tails (#5050)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-09 18:53:39 -04:00 committed by GitHub
parent 9c199a5b85
commit 7d666fafab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 264 additions and 2 deletions

View File

@ -1348,6 +1348,42 @@ describe('orca cli worktree awareness', () => {
})
})
it('prints terminal.read fallback screen lines in json mode', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_read', {
terminal: {
handle: 'term_worker',
status: 'running',
tail: ['Claude Code', 'Checking files', 'Waiting for input'],
truncated: false,
limited: true,
oldestCursor: '0',
nextCursor: '3000',
latestCursor: '3000',
returnedLineCount: 3
}
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['terminal', 'read', '--terminal', 'term_worker', '--limit', '120', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.read', {
terminal: 'term_worker',
limit: 120
})
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
expect(printed.result.terminal.tail).toEqual([
'Claude Code',
'Checking files',
'Waiting for input'
])
})
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,

View File

@ -152,6 +152,15 @@ export class HeadlessEmulator {
return this.terminal.buffer.active.type === 'alternate'
}
getVisibleLines(): string[] {
const buffer = this.terminal.buffer.active
const lines: string[] = []
for (let row = buffer.viewportY; row < buffer.viewportY + this.terminal.rows; row += 1) {
lines.push(buffer.getLine(row)?.translateToString(true) ?? '')
}
return lines
}
getCwd(): string | null {
return this.cwd
}

View File

@ -48,6 +48,9 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s
import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceSession } from '../../shared/constants'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { makePaneKey } from '../../shared/stable-pane-id'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest } from './rpc/core'
import { TERMINAL_METHODS } from './rpc/methods/terminal'
const electronMocks = vi.hoisted(() => {
type Listener = (...args: unknown[]) => void
@ -620,6 +623,10 @@ function createRuntime(): OrcaRuntimeService {
return new OrcaRuntimeService(store)
}
function makeRpcRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function makeWorktreeMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
return {
displayName: '',
@ -5389,6 +5396,127 @@ describe('OrcaRuntimeService', () => {
expect(shiftCallCount).toBe(0)
})
it('falls back to renderer visible screen when uncursored TUI tail is blank', async () => {
const serializeBuffer = vi.fn().mockResolvedValue({
data: '\x1b[?1049hClaude Code\r\nWorking on fix\r\nTool: Read\r\n',
cols: 80,
rows: 24
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
hasRendererSerializer: () => true,
serializeBuffer
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', `${Array.from({ length: 3000 }, () => ' ').join('\n')}\n`, 100)
const read = await runtime.readTerminal(terminal.handle)
expect(read.tail).toEqual(['Claude Code', 'Working on fix', 'Tool: Read'])
expect(serializeBuffer).toHaveBeenCalledWith('pty-1', {
scrollbackRows: 0,
altScreenForcesZeroRows: false
})
})
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',
cols: 80,
rows: 24
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
hasRendererSerializer: () => true,
serializeBuffer
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', `${Array.from({ length: 3000 }, () => '').join('\n')}\n`, 100)
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRpcRequest('terminal.read', { terminal: terminal.handle })
)
expect(response.ok).toBe(true)
if (!response.ok) {
throw new Error(response.error.message)
}
expect(response.result).toMatchObject({
terminal: {
handle: terminal.handle,
status: 'running',
tail: ['Claude Code', 'Checking files', 'Waiting for input']
}
})
})
it('does not use renderer visible-screen fallback for cursor transcript reads', async () => {
const serializeBuffer = vi.fn().mockResolvedValue({
data: 'Visible TUI\n',
cols: 80,
rows: 24
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
hasRendererSerializer: () => true,
serializeBuffer
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', ' \n', 100)
const read = await runtime.readTerminal(terminal.handle, { cursor: 0 })
expect(read.tail).toEqual([''])
expect(serializeBuffer).not.toHaveBeenCalledWith('pty-1', {
scrollbackRows: 0,
altScreenForcesZeroRows: false
})
})
it('does not use renderer visible-screen fallback for a short blank shell tail', async () => {
const serializeBuffer = vi.fn().mockResolvedValue({
data: 'shell prompt\n',
cols: 80,
rows: 24
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
hasRendererSerializer: () => true,
serializeBuffer
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\n\n', 100)
const read = await runtime.readTerminal(terminal.handle)
expect(read.tail).toEqual(['', ''])
expect(serializeBuffer).not.toHaveBeenCalledWith('pty-1', {
scrollbackRows: 0,
altScreenForcesZeroRows: false
})
})
it('trims oversized terminal output bursts without per-line array shifts', async () => {
const shiftSpy = vi.spyOn(Array.prototype, 'shift')
const lines = Array.from({ length: 5000 }, (_, index) => `line-${index}`)

View File

@ -3825,6 +3825,59 @@ export class OrcaRuntimeService {
return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null
}
private async withVisibleSnapshotFallback(
ptyId: string,
read: RuntimeTerminalRead,
opts: { cursor?: number; limit?: number } = {}
): Promise<RuntimeTerminalRead> {
if (!shouldFallbackToVisibleTerminalSnapshot(read, opts)) {
return read
}
const lines = await this.readRendererVisibleSnapshotLines(ptyId)
if (lines.length === 0) {
return read
}
return buildVisibleSnapshotReadFallback(read, lines, opts.limit)
}
private async readRendererVisibleSnapshotLines(ptyId: string): Promise<string[]> {
const controller = this.ptyController
if (!controller?.serializeBuffer) {
return []
}
if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) {
return []
}
try {
// 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
})
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()
}
} catch {
return []
}
}
private async serializeHeadlessTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number; includeEmpty?: boolean } = {}
@ -5930,7 +5983,8 @@ export class OrcaRuntimeService {
): Promise<RuntimeTerminalRead> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
return this.readPtyTerminal(handle, pty.pty, opts)
const read = this.readPtyTerminal(handle, pty.pty, opts)
return this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
}
const { leaf } = this.getLiveLeafForHandle(handle)
@ -5944,7 +5998,7 @@ export class OrcaRuntimeService {
cursor: opts.cursor,
limit: opts.limit
})
return read
return leaf.ptyId ? this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) : read
}
async sendTerminal(
@ -15388,6 +15442,41 @@ function readTerminalTail(args: {
}
}
function shouldFallbackToVisibleTerminalSnapshot(
read: RuntimeTerminalRead,
opts: { cursor?: number; limit?: number }
): boolean {
if (typeof opts.cursor === 'number') {
return false
}
if (read.tail.length === 0) {
return false
}
const hasSubstantialBlankTail =
read.limited === true || read.truncated || read.tail.length >= DEFAULT_TERMINAL_READ_LIMIT
return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0)
}
function buildVisibleSnapshotReadFallback(
read: RuntimeTerminalRead,
visibleLines: string[],
limit: number | undefined
): RuntimeTerminalRead {
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
const lineBoundedTail = visibleLines.slice(-lineLimit)
const charBoundedTail = trimTerminalPreviewToCharacterBudget(
lineBoundedTail,
MAX_TERMINAL_PREVIEW_CHARS
)
return {
...read,
tail: charBoundedTail.tail,
limited:
read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited,
returnedLineCount: charBoundedTail.tail.length
}
}
function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState {
if (leaf.connected) {
return 'running'