From cbdd750f731464a7faaccc375bcd85c1acf24524 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:07:24 -0700 Subject: [PATCH] fix(terminal): keep redraw transcript cursors stable (#9081) Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 81 +++++++++- src/main/runtime/orca-runtime.ts | 147 ++++++++++++++++-- ...ned-tail-redraw-window.equivalence.test.ts | 3 + 3 files changed, 212 insertions(+), 19 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e5021aebb..40521e2ba 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -13435,7 +13435,7 @@ describe('OrcaRuntimeService', () => { cursor: Number(beforeRedraw.nextCursor) }) expect(cursorRead.tail).toEqual(['• Working.', 'Tool call finished']) - expect(cursorRead.oldestCursor).toBe('2') + expect(cursorRead.oldestCursor).toBe('0') expect(cursorRead.nextCursor).toBe('4') }) @@ -13459,10 +13459,85 @@ describe('OrcaRuntimeService', () => { cursor: Number(beforeRedraw.nextCursor) }) expect(cursorRead.tail).toEqual(['B2']) - expect(cursorRead.oldestCursor).toBe('2') + expect(cursorRead.oldestCursor).toBe('0') expect(cursorRead.nextCursor).toBe('4') }) + it('keeps cursor pagination stable across a CUU redraw of the live screen', async () => { + const runtime = new OrcaRuntimeService(store) + const liveScreen = new HeadlessEmulator({ cols: 80, rows: 24 }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + const initialOutput = 'A\r\nB\r\nC\r\n' + const redraw = '\x1b[2A\x1b[2K\x1b[1GB2\r\n' + runtime.onPtyData('pty-1', initialOutput, 100) + await liveScreen.write(initialOutput) + + const firstPage = await runtime.readTerminal(terminal.handle, { cursor: 0, limit: 2 }) + expect(firstPage).toMatchObject({ + tail: ['A', 'B'], + truncated: false, + limited: true, + oldestCursor: '0', + nextCursor: '2', + latestCursor: '3', + returnedLineCount: 2 + }) + + runtime.onPtyData('pty-1', redraw, 101) + await liveScreen.write(redraw) + + // Why: CUU changes the mutable screen, but completed-line pagination is a distinct contract. + expect(liveScreen.getVisibleLines().filter((line) => line.length > 0)).toEqual(['A', 'B2', 'C']) + await expect(runtime.readTerminal(terminal.handle, { cursor: 2 })).resolves.toMatchObject({ + tail: ['C', 'B2'], + truncated: false, + limited: false, + oldestCursor: '0', + nextCursor: '4', + latestCursor: '4', + returnedLineCount: 2 + }) + + liveScreen.dispose() + }) + + it('records completed transcript lines before later CUU redraws in the same PTY chunk', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'A\r\nB\r\nC\r\n\x1b[2A\x1b[2K\x1b[1GB2\r\n', 100) + + await expect(runtime.readTerminal(terminal.handle, { cursor: 0 })).resolves.toMatchObject({ + tail: ['A', 'B', 'C', 'B2'], + truncated: false, + oldestCursor: '0', + nextCursor: '4', + latestCursor: '4' + }) + }) + + it('bounds completed transcript entries from a single multi-line redraw chunk', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + const lines = Array.from({ length: 2100 }, (_, index) => `redraw-${index}`) + runtime.onPtyData('pty-1', `\x1b[1A${lines.join('\n')}\n`, 100) + + await expect( + runtime.readTerminal(terminal.handle, { cursor: 0, limit: 3 }) + ).resolves.toMatchObject({ + tail: ['redraw-100', 'redraw-101', 'redraw-102'], + truncated: true, + oldestCursor: '100', + nextCursor: '103', + latestCursor: '2100' + }) + }) + it('retains multi-line ANSI redraws when the last footer row stays partial', async () => { const runtime = new OrcaRuntimeService(store) syncSinglePty(runtime) @@ -13486,7 +13561,7 @@ describe('OrcaRuntimeService', () => { cursor: Number(beforeRedraw.nextCursor) }) expect(cursorRead.tail).toEqual(['• Working.']) - expect(cursorRead.oldestCursor).toBe('2') + expect(cursorRead.oldestCursor).toBe('0') expect(cursorRead.nextCursor).toBe('3') runtime.onPtyData('pty-1', '\n', 102) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c9fcca82e..205f27fd6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -975,6 +975,8 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { lastOutputAt: number | null lastExitCode: number | null tailBuffer: string[] + tailTranscriptBuffer: string[] + tailTranscriptChars: number tailPartialLine: string tailPendingAnsi: string tailRedrawCursor: RetainedTailRedrawCursor | null @@ -1031,6 +1033,8 @@ type RuntimePtyWorktreeRecord = { titleUpdatedAt: number | null lastOutputAt: number | null tailBuffer: string[] + tailTranscriptBuffer: string[] + tailTranscriptChars: number tailPartialLine: string tailPendingAnsi: string tailRedrawCursor: RetainedTailRedrawCursor | null @@ -3332,6 +3336,8 @@ export class OrcaRuntimeService { lastOutputAt: tailSource?.lastOutputAt ?? null, lastExitCode: tailSource?.lastExitCode ?? null, tailBuffer: tailSource?.tailBuffer ?? [], + tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [], + tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0, tailPartialLine: tailSource?.tailPartialLine ?? '', tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', tailRedrawCursor: tailSource?.tailRedrawCursor ?? null, @@ -6040,6 +6046,7 @@ export class OrcaRuntimeService { const ptyTailBefore = pty ? { lines: pty.tailBuffer, + transcriptLines: pty.tailTranscriptBuffer, partialLine: pty.tailPartialLine, pendingAnsi: pty.tailPendingAnsi, redrawCursor: pty.tailRedrawCursor, @@ -6061,10 +6068,18 @@ export class OrcaRuntimeService { pty.tailRedrawCursor ) ptyTailAfter = nextTail + const nextTranscript = appendCompletedTerminalTranscript( + pty.tailTranscriptBuffer, + pty.tailTranscriptChars, + nextTail.newlyCompletedLines, + nextTail.newCompleteLines + ) pty.tailBuffer = nextTail.lines + pty.tailTranscriptBuffer = nextTranscript.lines + pty.tailTranscriptChars = nextTranscript.characters pty.tailPartialLine = nextTail.partialLine pty.tailRedrawCursor = nextTail.redrawCursor - pty.tailTruncated = pty.tailTruncated || nextTail.truncated + pty.tailTruncated = pty.tailTruncated || nextTail.truncated || nextTranscript.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) this.scheduleWaitBlockedCheck(ptyId, normalized.text, at) @@ -6087,6 +6102,7 @@ export class OrcaRuntimeService { ptyTailAfter && tailStateMatches( leaf.tailBuffer, + leaf.tailTranscriptBuffer, leaf.tailPartialLine, leaf.tailPendingAnsi, leaf.tailRedrawCursor, @@ -6098,6 +6114,8 @@ export class OrcaRuntimeService { // Why: the leaf and PTY record usually mirror the same terminal. Reuse // the PTY tail update instead of splitting large output twice. leaf.tailBuffer = pty.tailBuffer + leaf.tailTranscriptBuffer = pty.tailTranscriptBuffer + leaf.tailTranscriptChars = pty.tailTranscriptChars leaf.tailPartialLine = pty.tailPartialLine leaf.tailPendingAnsi = pty.tailPendingAnsi leaf.tailRedrawCursor = pty.tailRedrawCursor @@ -6123,6 +6141,12 @@ export class OrcaRuntimeService { normalized.text, leaf.tailRedrawCursor ) + const nextTranscript = appendCompletedTerminalTranscript( + leaf.tailTranscriptBuffer, + leaf.tailTranscriptChars, + nextTail.newlyCompletedLines, + nextTail.newCompleteLines + ) const nextWaitState = computeTerminalTailWaitState( nextTail.lines, nextTail.partialLine, @@ -6133,9 +6157,11 @@ export class OrcaRuntimeService { } leaf.tailWaitState = nextWaitState leaf.tailBuffer = nextTail.lines + leaf.tailTranscriptBuffer = nextTranscript.lines + leaf.tailTranscriptChars = nextTranscript.characters leaf.tailPartialLine = nextTail.partialLine leaf.tailRedrawCursor = nextTail.redrawCursor - leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated + leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated || nextTranscript.truncated leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) } @@ -11037,7 +11063,8 @@ export class OrcaRuntimeService { const read = readTerminalTail({ handle, status: getTerminalState(leaf), - completedLines: leaf.tailBuffer, + previewLines: leaf.tailBuffer, + completedLines: leaf.tailTranscriptBuffer, partialLine: leaf.tailPartialLine, completedLineCount: leaf.tailLinesTotal, bufferTruncated: leaf.tailTruncated, @@ -21333,6 +21360,8 @@ export class OrcaRuntimeService { titleUpdatedAt: titleObservedAt, lastOutputAt: state.lastOutputAt ?? null, tailBuffer: [], + tailTranscriptBuffer: [], + tailTranscriptChars: 0, tailPartialLine: '', tailPendingAnsi: '', tailRedrawCursor: null, @@ -21470,6 +21499,8 @@ export class OrcaRuntimeService { // Why: disconnected PTY records can stay addressable for status/exit reads, // but their retained transcripts must not accumulate after the process dies. pty.tailBuffer = [] + pty.tailTranscriptBuffer = [] + pty.tailTranscriptChars = 0 pty.tailPartialLine = '' pty.tailPendingAnsi = '' pty.tailRedrawCursor = null @@ -22903,7 +22934,8 @@ export class OrcaRuntimeService { return readTerminalTail({ handle, status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', - completedLines: pty.tailBuffer, + previewLines: pty.tailBuffer, + completedLines: pty.tailTranscriptBuffer, partialLine: pty.tailPartialLine, completedLineCount: pty.tailLinesTotal, bufferTruncated: pty.tailTruncated, @@ -26532,6 +26564,7 @@ export function appendNormalizedToTailBuffer( redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number + newlyCompletedLines: string[] } { if (normalizedChunk.length === 0) { return { @@ -26539,7 +26572,8 @@ export function appendNormalizedToTailBuffer( partialLine: previousPartialLine, redrawCursor: previousRedrawCursor, truncated: false, - newCompleteLines: 0 + newCompleteLines: 0, + newlyCompletedLines: [] } } @@ -26563,6 +26597,7 @@ export function appendNormalizedToTailBuffer( // visible redraw segment instead of appending every spinner frame. const segments = splitRetainedTerminalTailSegments(combinedChunk) const pieces = processTerminalTailCompleteSegments(segments.completeSegments) + const newlyCompletedLines = pieces.map((line) => trimTerminalLineRight(line)) const partialResult = applyTerminalLineControls(segments.partialSegment) const nextPartialLine = trimTerminalLineRight(partialResult.text) const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) @@ -26570,10 +26605,7 @@ export function appendNormalizedToTailBuffer( const omittedNewCompleteLines = newCompleteLines - pieces.length let nextLines = newCompleteLines > 0 - ? [ - ...(omittedNewCompleteLines > 0 ? [] : previousLines), - ...pieces.map((line) => line.replace(/[ \t]+$/g, '')) - ] + ? [...(omittedNewCompleteLines > 0 ? [] : previousLines), ...newlyCompletedLines] : previousLines let truncated = previousPartialWasCapped || @@ -26615,7 +26647,8 @@ export function appendNormalizedToTailBuffer( partialLine: retainedPartialLine, redrawCursor, truncated, - newCompleteLines + newCompleteLines, + newlyCompletedLines } } @@ -26667,6 +26700,7 @@ function appendNormalizedToMultilineTailBuffer( redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number + newlyCompletedLines: string[] } { const windowRows = maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS @@ -26724,7 +26758,8 @@ function appendNormalizedToMultilineTailBuffer( partialLine: windowed.partialLine, redrawCursor: windowed.redrawCursor, truncated, - newCompleteLines: windowed.newCompleteLines + newCompleteLines: windowed.newCompleteLines, + newlyCompletedLines: windowed.newlyCompletedLines } } @@ -26740,6 +26775,7 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number + newlyCompletedLines: string[] } { const rows: RetainedTerminalRow[] = [ ...previousLines.map((line) => ({ text: line, completed: true })), @@ -26750,8 +26786,29 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( : rows.length - 1 let cursorColumn = previousRedrawCursor?.column ?? boundedPreviousPartialLine.length let newCompleteLines = 0 + const newlyCompletedLines: string[] = [] + let newlyCompletedLineCharacters = 0 + let newlyCompletedLineStart = 0 let truncated = previousPartialWasCapped + const retainNewlyCompletedLine = (line: string): void => { + newlyCompletedLines.push(line) + newlyCompletedLineCharacters += line.length + while ( + newlyCompletedLines.length - newlyCompletedLineStart > MAX_TAIL_LINES || + newlyCompletedLineCharacters > MAX_TAIL_CHARS + ) { + newlyCompletedLineCharacters -= newlyCompletedLines[newlyCompletedLineStart]!.length + newlyCompletedLineStart += 1 + } + // Why: a single PTY chunk can contain unbounded newlines; compact in batches + // while retaining the suffix needed for stable transcript pagination. + if (newlyCompletedLineStart >= MAX_TAIL_LINES) { + newlyCompletedLines.splice(0, newlyCompletedLineStart) + newlyCompletedLineStart = 0 + } + } + const ensureCursorRow = (): void => { while (cursorRow >= rows.length) { rows.push({ text: '', completed: false }) @@ -26807,6 +26864,7 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( ensureCursorRow() rows[cursorRow]!.completed = true newCompleteLines += 1 + retainNewlyCompletedLine(trimTerminalLineRight(rows[cursorRow]!.text)) cursorRow += 1 cursorColumn = 0 ensureCursorRow() @@ -26848,7 +26906,16 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( writeChar(char) } - return finalizeRetainedTerminalRows(rows, cursorRow, cursorColumn, truncated, newCompleteLines) + return finalizeRetainedTerminalRows( + rows, + cursorRow, + cursorColumn, + truncated, + newCompleteLines, + newlyCompletedLineStart > 0 + ? newlyCompletedLines.slice(newlyCompletedLineStart) + : newlyCompletedLines + ) } type RetainedTailRedrawCursor = { @@ -26866,13 +26933,15 @@ function finalizeRetainedTerminalRows( cursorRow: number, cursorColumn: number, initialTruncated: boolean, - newCompleteLines: number + newCompleteLines: number, + newlyCompletedLines: string[] ): { lines: string[] partialLine: string redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number + newlyCompletedLines: string[] } { let truncated = initialTruncated let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') })) @@ -26934,7 +27003,8 @@ function finalizeRetainedTerminalRows( partialLine, redrawCursor, truncated, - newCompleteLines + newCompleteLines, + newlyCompletedLines } } @@ -27161,8 +27231,43 @@ function containsTerminalVerticalLineControl(value: string): boolean { return false } +function appendCompletedTerminalTranscript( + previousLines: string[], + previousCharacters: number, + newlyCompletedLines: string[], + newCompleteLineCount: number +): { lines: string[]; characters: number; truncated: boolean } { + if (newCompleteLineCount === 0) { + return { lines: previousLines, characters: previousCharacters, truncated: false } + } + + const omittedNewLineCount = Math.max(0, newCompleteLineCount - newlyCompletedLines.length) + const lines = omittedNewLineCount > 0 ? [] : [...previousLines] + let characters = omittedNewLineCount > 0 ? 0 : previousCharacters + for (const line of newlyCompletedLines) { + lines.push(line) + characters += line.length + } + + let dropCount = Math.max(0, lines.length - MAX_TAIL_LINES) + for (let index = 0; index < dropCount; index += 1) { + characters -= lines[index]!.length + } + while (dropCount < lines.length && characters > MAX_TAIL_CHARS) { + characters -= lines[dropCount]!.length + dropCount += 1 + } + + return { + lines: dropCount > 0 ? lines.slice(dropCount) : lines, + characters, + truncated: omittedNewLineCount > 0 || dropCount > 0 + } +} + function tailStateMatches( lines: string[], + transcriptLines: string[], partialLine: string, pendingAnsi: string, redrawCursor: RetainedTailRedrawCursor | null, @@ -27170,6 +27275,7 @@ function tailStateMatches( linesTotal: number, snapshot: { lines: string[] + transcriptLines: string[] partialLine: string pendingAnsi: string redrawCursor: RetainedTailRedrawCursor | null @@ -27183,7 +27289,8 @@ function tailStateMatches( !tailRedrawCursorsMatch(redrawCursor, snapshot.redrawCursor) || truncated !== snapshot.truncated || linesTotal !== snapshot.linesTotal || - lines.length !== snapshot.lines.length + lines.length !== snapshot.lines.length || + transcriptLines.length !== snapshot.transcriptLines.length ) { return false } @@ -27195,6 +27302,13 @@ function tailStateMatches( return false } } + if (transcriptLines !== snapshot.transcriptLines) { + for (let index = 0; index < transcriptLines.length; index++) { + if (transcriptLines[index] !== snapshot.transcriptLines[index]) { + return false + } + } + } return true } @@ -27253,6 +27367,7 @@ function trimTerminalPreviewToCharacterBudget( function readTerminalTail(args: { handle: string status: RuntimeTerminalState + previewLines: string[] completedLines: string[] partialLine: string completedLineCount: number @@ -27303,7 +27418,7 @@ function readTerminalTail(args: { // latest bounded view, while the larger retained buffer remains available // through cursor reads plus --limit. const limit = terminalReadLimit(args.limit, DEFAULT_TERMINAL_READ_LIMIT) - const allLines = buildTailLines(args.completedLines, args.partialLine) + const allLines = buildTailLines(args.previewLines, args.partialLine) const lineBoundedTail = allLines.slice(-limit) const charBoundedTail = trimTerminalPreviewToCharacterBudget( lineBoundedTail, diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts index 32685f93b..30b364836 100644 --- a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -85,6 +85,9 @@ describe('windowed redraw tail equivalence', () => { expect(actual.redrawCursor, `round ${round} cursor`).toEqual(expected.redrawCursor) expect(actual.truncated, `round ${round} truncated`).toBe(expected.truncated) expect(actual.newCompleteLines, `round ${round} newLines`).toBe(expected.newCompleteLines) + expect(actual.newlyCompletedLines, `round ${round} completedLines`).toEqual( + expected.newlyCompletedLines + ) } }) })