Fix terminal read for blank TUI tails (#5050)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9c199a5b85
commit
7d666fafab
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue