diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d3c2db15b..defcad12f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7440,6 +7440,9 @@ describe('OrcaRuntimeService', () => { const cursorRedraw = appendNormalizedToTailBuffer([], '', 'Working 10%\x1b[3D25%') expect(cursorRedraw.partialLine).toBe('Working 25%') + const eraseLineKeepsCursor = appendNormalizedToTailBuffer([], '', 'ABC\x1b[2KXY\n') + expect(eraseLineKeepsCursor.lines).toEqual([' XY']) + const eraseWithoutCarriageReturn = appendNormalizedToTailBuffer( [], 'Downloading 10%', @@ -7462,6 +7465,160 @@ describe('OrcaRuntimeService', () => { expect(read.latestCursor).toBe('1') }) + it('retains ANSI-only status redraws instead of appending every frame', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '• Working', 100) + const initialPreview = await runtime.readTerminal(terminal.handle) + expect(initialPreview.tail).toEqual(['• Working']) + + runtime.onPtyData('pty-1', '\x1b[2K\x1b[1G• Working.', 101) + const redrawPreview = await runtime.readTerminal(terminal.handle) + expect(redrawPreview.tail).toEqual(['• Working.']) + expect(redrawPreview.tail.join('\n')).not.toContain('• Working• Working') + + runtime.onPtyData('pty-1', '\x1b[2K\x1b[1G• Working..', 102) + const latestPreview = await runtime.readTerminal(terminal.handle) + expect(latestPreview.tail).toEqual(['• Working..']) + expect(latestPreview.latestCursor).toBe('0') + + const cursorReadBeforeNewline = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + expect(cursorReadBeforeNewline.tail).toEqual([]) + expect(cursorReadBeforeNewline.nextCursor).toBe('0') + + runtime.onPtyData('pty-1', '\n', 103) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['• Working..']) + expect(read.latestCursor).toBe('1') + expect(read.tail.join('\n')).not.toContain('• Working• Working') + + const cursorReadAfterNewline = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + expect(cursorReadAfterNewline.tail).toEqual(['• Working..']) + expect(cursorReadAfterNewline.nextCursor).toBe('1') + }) + + it('retains multi-line ANSI footer redraws instead of appending old frames', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '• Working\nTool call\n', 100) + runtime.onPtyData( + 'pty-1', + '\x1b[2A\x1b[2K\x1b[1G• Working.\n\x1b[2K\x1b[1GTool call finished\n', + 101 + ) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['• Working.', 'Tool call finished']) + expect(read.latestCursor).toBe('4') + expect(read.tail.join('\n')).not.toContain('• Working\nTool call\n• Working.') + expect(read.tail.join('\n')).not.toContain('2A') + expect(read.tail.join('\n')).not.toContain('2K') + }) + + it('keeps the cursor column when erasing full lines in multi-line redraws', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'ABC\nxyz', 100) + runtime.onPtyData('pty-1', '\x1b[1A\x1b[2KXY\n', 101) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual([' XY']) + expect(read.tail.join('\n')).not.toContain('ABCXY') + expect(read.tail.join('\n')).not.toContain('2K') + }) + + it('retains split multi-line ANSI footer redraw state across chunks', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '• Working\nTool call\n', 100) + const beforeRedraw = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + expect(beforeRedraw.tail).toEqual(['• Working', 'Tool call']) + expect(beforeRedraw.nextCursor).toBe('2') + + runtime.onPtyData('pty-1', '\x1b[2A', 101) + const betweenChunks = await runtime.readTerminal(terminal.handle) + expect(betweenChunks.tail).toEqual(['• Working']) + expect(betweenChunks.latestCursor).toBe('2') + + runtime.onPtyData('pty-1', '\x1b[2K\x1b[1G• Working.\n\x1b[2K\x1b[1GTool call finished\n', 102) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['• Working.', 'Tool call finished']) + expect(read.latestCursor).toBe('4') + + const cursorRead = await runtime.readTerminal(terminal.handle, { + cursor: Number(beforeRedraw.nextCursor) + }) + expect(cursorRead.tail).toEqual(['• Working.', 'Tool call finished']) + expect(cursorRead.oldestCursor).toBe('2') + expect(cursorRead.nextCursor).toBe('4') + }) + + it('does not let stale lower rows hide earlier corrected footer rows from cursor reads', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'A\nB\nC\n', 100) + const beforeRedraw = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + expect(beforeRedraw.tail).toEqual(['A', 'B', 'C']) + expect(beforeRedraw.nextCursor).toBe('3') + + runtime.onPtyData('pty-1', '\x1b[2A\x1b[2K\x1b[1GB2\n', 101) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['A', 'B2']) + expect(read.latestCursor).toBe('4') + + const cursorRead = await runtime.readTerminal(terminal.handle, { + cursor: Number(beforeRedraw.nextCursor) + }) + expect(cursorRead.tail).toEqual(['B2']) + expect(cursorRead.oldestCursor).toBe('2') + expect(cursorRead.nextCursor).toBe('4') + }) + + it('retains multi-line ANSI redraws when the last footer row stays partial', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '• Working\nTool call\n', 100) + const beforeRedraw = await runtime.readTerminal(terminal.handle, { cursor: 0 }) + expect(beforeRedraw.nextCursor).toBe('2') + + runtime.onPtyData( + 'pty-1', + '\x1b[2A\x1b[2K\x1b[1G• Working.\n\x1b[2K\x1b[1GTool call still running', + 101 + ) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['• Working.', 'Tool call still running']) + expect(read.latestCursor).toBe('3') + + const cursorRead = await runtime.readTerminal(terminal.handle, { + cursor: Number(beforeRedraw.nextCursor) + }) + expect(cursorRead.tail).toEqual(['• Working.']) + expect(cursorRead.oldestCursor).toBe('2') + expect(cursorRead.nextCursor).toBe('3') + + runtime.onPtyData('pty-1', '\n', 102) + const completedPartialRead = await runtime.readTerminal(terminal.handle, { cursor: 3 }) + expect(completedPartialRead.tail).toEqual(['Tool call still running']) + expect(completedPartialRead.nextCursor).toBe('4') + }) + it('does not retain split ANSI controls as visible terminal preview text', async () => { const runtime = new OrcaRuntimeService(store) syncSinglePty(runtime) @@ -7501,6 +7658,133 @@ describe('OrcaRuntimeService', () => { expect(pty?.lastOscTitle).toBe('x'.repeat(MAX_OSC_TITLE_CHARS)) }) + it('applies ANSI split redraw controls without leaking raw params', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'Frame old', 100) + runtime.onPtyData('pty-1', '\x1b[', 101) + + const pendingEraseRead = await runtime.readTerminal(terminal.handle) + expect(pendingEraseRead.tail).toEqual(['Frame old']) + expect(pendingEraseRead.tail.join('\n')).not.toContain('[') + + runtime.onPtyData('pty-1', '2K\x1b[', 102) + const pendingColumnRead = await runtime.readTerminal(terminal.handle) + expect(pendingColumnRead.tail.join('\n')).not.toContain('2K') + expect(pendingColumnRead.tail.join('\n')).not.toContain('1G') + + runtime.onPtyData('pty-1', '1GFrame new\n', 103) + const read = await runtime.readTerminal(terminal.handle) + const retained = read.tail.join('\n') + expect(read.tail).toEqual(['Frame new']) + expect(retained).not.toContain('2K') + expect(retained).not.toContain('1G') + }) + + it('retains same-line redraw cursor position across split full-line erase chunks', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'ABC', 100) + runtime.onPtyData('pty-1', '\x1b[2K', 101) + const erasedRead = await runtime.readTerminal(terminal.handle) + expect(erasedRead.tail).toEqual([]) + + runtime.onPtyData('pty-1', 'XY\n', 102) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual([' XY']) + expect(read.tail.join('\n')).not.toContain('ABCXY') + expect(read.tail.join('\n')).not.toContain('2K') + }) + + it('keeps huge ANSI cursor movement params bounded in retained previews', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + const infinityParam = '9'.repeat(400) + runtime.onPtyData('pty-1', `A\x1b[1000000000CZ\n`, 100) + runtime.onPtyData('pty-1', `B\x1b[${infinityParam}GQ\n`, 101) + for (let index = 0; index < 50; index += 1) { + runtime.onPtyData('pty-1', `R${index}\x1b[2K\x1b[999999CZ\n`, 102 + index) + } + + const read = await runtime.readTerminal(terminal.handle, { cursor: 0, limit: 60 }) + expect(read.tail).toHaveLength(52) + for (const line of read.tail) { + expect(line.length).toBeLessThan(5000) + expect(line).not.toContain('1000000000') + expect(line).not.toContain(infinityParam) + expect(line).not.toContain('999999') + } + expect(read.tail[0]?.startsWith('A')).toBe(true) + expect(read.tail[0]?.endsWith('Z')).toBe(true) + expect(read.tail[1]?.startsWith('B')).toBe(true) + expect(read.tail[1]?.endsWith('Q')).toBe(true) + expect(read.tail.at(-1)?.endsWith('Z')).toBe(true) + }) + + it('bounds retained work for many newline-separated huge ANSI cursor movements', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b[4000GZ\n'.repeat(3000), 100) + + const read = await runtime.readTerminal(terminal.handle, { cursor: 0, limit: 2000 }) + expect(read.latestCursor).toBe('3000') + expect(read.oldestCursor).not.toBe('0') + expect(read.tail.length).toBeLessThan(100) + for (const line of read.tail) { + expect(line.length).toBeLessThanOrEqual(4000) + expect(line.endsWith('Z')).toBe(true) + expect(line).not.toContain('4000G') + } + }) + + it('applies ANSI erase-from-start line controls in retained previews', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'ABCDE\x1b[3D\x1b[1KXY\n', 100) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual([' XYE']) + expect(read.tail.join('\n')).not.toContain('ABC') + expect(read.tail.join('\n')).not.toContain('1K') + }) + + it('applies ANSI stripping for private or intermediate CSI line controls', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'ABCDE\x1b[?99DXY\n', 100) + runtime.onPtyData('pty-1', 'ABCDE\x1b[1$DXY\n', 101) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['ABCDEXY', 'ABCDEXY']) + expect(read.tail.join('\n')).not.toContain('?99D') + expect(read.tail.join('\n')).not.toContain('1$D') + }) + + it('applies ANSI stripping for unsupported erase-line modes', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'Old\x1b[3KNew\n', 100) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['OldNew']) + expect(read.tail.join('\n')).not.toContain('3K') + }) + it('does not retain split ST-terminated string controls as preview text', async () => { const runtime = new OrcaRuntimeService(store) syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a0e8241b5..20cbca92b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -824,6 +824,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { tailBuffer: string[] tailPartialLine: string tailPendingAnsi: string + tailRedrawCursor: RetainedTailRedrawCursor | null tailTruncated: boolean tailLinesTotal: number preview: string @@ -885,6 +886,7 @@ type RuntimePtyWorktreeRecord = { tailBuffer: string[] tailPartialLine: string tailPendingAnsi: string + tailRedrawCursor: RetainedTailRedrawCursor | null tailTruncated: boolean tailLinesTotal: number preview: string @@ -2556,6 +2558,7 @@ export class OrcaRuntimeService { tailBuffer: tailSource?.tailBuffer ?? [], tailPartialLine: tailSource?.tailPartialLine ?? '', tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', + tailRedrawCursor: tailSource?.tailRedrawCursor ?? null, tailTruncated: tailSource?.tailTruncated ?? false, tailLinesTotal: tailSource?.tailLinesTotal ?? 0, preview: tailSource?.preview ?? '', @@ -4737,6 +4740,7 @@ export class OrcaRuntimeService { lines: pty.tailBuffer, partialLine: pty.tailPartialLine, pendingAnsi: pty.tailPendingAnsi, + redrawCursor: pty.tailRedrawCursor, truncated: pty.tailTruncated, linesTotal: pty.tailLinesTotal } @@ -4751,11 +4755,13 @@ export class OrcaRuntimeService { const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, pty.tailPartialLine, - normalized.text + normalized.text, + pty.tailRedrawCursor ) ptyTailAfter = nextTail pty.tailBuffer = nextTail.lines pty.tailPartialLine = nextTail.partialLine + pty.tailRedrawCursor = nextTail.redrawCursor pty.tailTruncated = pty.tailTruncated || nextTail.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) @@ -4794,6 +4800,7 @@ export class OrcaRuntimeService { leaf.tailBuffer, leaf.tailPartialLine, leaf.tailPendingAnsi, + leaf.tailRedrawCursor, leaf.tailTruncated, leaf.tailLinesTotal, ptyTailBefore @@ -4804,6 +4811,7 @@ export class OrcaRuntimeService { leaf.tailBuffer = pty.tailBuffer leaf.tailPartialLine = pty.tailPartialLine leaf.tailPendingAnsi = pty.tailPendingAnsi + leaf.tailRedrawCursor = pty.tailRedrawCursor leaf.tailTruncated = pty.tailTruncated leaf.tailLinesTotal = pty.tailLinesTotal leaf.preview = pty.preview @@ -4813,10 +4821,12 @@ export class OrcaRuntimeService { const nextTail = appendNormalizedToTailBuffer( leaf.tailBuffer, leaf.tailPartialLine, - normalized.text + normalized.text, + leaf.tailRedrawCursor ) leaf.tailBuffer = nextTail.lines leaf.tailPartialLine = nextTail.partialLine + leaf.tailRedrawCursor = nextTail.redrawCursor leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) @@ -16112,6 +16122,7 @@ export class OrcaRuntimeService { tailBuffer: [], tailPartialLine: '', tailPendingAnsi: '', + tailRedrawCursor: null, tailTruncated: false, tailLinesTotal: 0, preview: state.preview ?? '' @@ -16231,6 +16242,7 @@ export class OrcaRuntimeService { pty.tailBuffer = [] pty.tailPartialLine = '' pty.tailPendingAnsi = '' + pty.tailRedrawCursor = null pty.tailTruncated = false pty.tailLinesTotal = 0 } @@ -20651,10 +20663,12 @@ function buildTerminalWaitText(lines: string[], partialLine: string, preview: st export function appendNormalizedToTailBuffer( previousLines: string[], previousPartialLine: string, - normalizedChunk: string + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null = null ): { lines: string[] partialLine: string + redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number } { @@ -20662,6 +20676,7 @@ export function appendNormalizedToTailBuffer( return { lines: previousLines, partialLine: previousPartialLine, + redrawCursor: previousRedrawCursor, truncated: false, newCompleteLines: 0 } @@ -20671,28 +20686,38 @@ export function appendNormalizedToTailBuffer( // larger line transcript for pagination, but keep partial-line work bounded. const previousPartialWasCapped = previousPartialLine.length > MAX_TAIL_PARTIAL_CHARS const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) - // Why: status UIs redraw a single line with CR/backspace/ANSI erase - // controls. Terminal previews are text, not a full screen model, so retain - // the latest visible redraw segment instead of appending every spinner frame. - const completedLines: string[] = [] - const combined = `${boundedPreviousPartialLine}${normalizedChunk}` - let lineStart = 0 - for (let index = 0; index < combined.length; index += 1) { - if (combined.charCodeAt(index) !== 0x0a) { - continue - } - completedLines.push( - trimTerminalLineRight(applyTerminalLineControls(combined.slice(lineStart, index))) + const combinedChunk = `${boundedPreviousPartialLine}${normalizedChunk}` + if (previousRedrawCursor || containsTerminalVerticalLineControl(combinedChunk)) { + return appendNormalizedToMultilineTailBuffer( + previousLines, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor ) - lineStart = index + 1 } - const nextPartialLine = trimTerminalLineRight( - applyTerminalLineControls(combined.slice(lineStart)) - ) + + // Why: status UIs redraw a single line with CR/backspace/ANSI erase controls. + // Terminal previews are text, not a full screen model, so retain the latest + // visible redraw segment instead of appending every spinner frame. + const segments = splitRetainedTerminalTailSegments(combinedChunk) + const pieces = processTerminalTailCompleteSegments(segments.completeSegments) + const partialResult = applyTerminalLineControls(segments.partialSegment) + const nextPartialLine = trimTerminalLineRight(partialResult.text) const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) - const newCompleteLines = completedLines.length - let nextLines = newCompleteLines > 0 ? [...previousLines, ...completedLines] : previousLines - let truncated = previousPartialWasCapped || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS + const newCompleteLines = segments.completeLineCount + const omittedNewCompleteLines = newCompleteLines - pieces.length + let nextLines = + newCompleteLines > 0 + ? [ + ...(omittedNewCompleteLines > 0 ? [] : previousLines), + ...pieces.map((line) => line.replace(/[ \t]+$/g, '')) + ] + : previousLines + let truncated = + previousPartialWasCapped || + omittedNewCompleteLines > 0 || + nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS if (nextLines.length > MAX_TAIL_LINES) { nextLines = nextLines.slice(nextLines.length - MAX_TAIL_LINES) @@ -20716,9 +20741,18 @@ export function appendNormalizedToTailBuffer( } } + const redrawCursor = + !partialResult.hadControl || partialResult.cursorColumn === nextPartialLine.length + ? null + : { + rowFromEnd: 0, + column: partialResult.cursorColumn + } + return { lines: nextLines, partialLine: retainedPartialLine, + redrawCursor, truncated, newCompleteLines } @@ -20736,16 +20770,303 @@ function trimTerminalLineRight(line: string): string { return end === line.length ? line : line.slice(0, end) } -function applyTerminalLineControls(line: string): string { +function appendNormalizedToMultilineTailBuffer( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number +} { + const rows: RetainedTerminalRow[] = [ + ...previousLines.map((line) => ({ text: line, completed: true })), + { text: boundedPreviousPartialLine, completed: false } + ] + let cursorRow = previousRedrawCursor + ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) + : rows.length - 1 + let cursorColumn = previousRedrawCursor?.column ?? boundedPreviousPartialLine.length + let newCompleteLines = 0 + let truncated = previousPartialWasCapped + + const ensureCursorRow = (): void => { + while (cursorRow >= rows.length) { + rows.push({ text: '', completed: false }) + } + } + const trimRows = (): void => { + const maxRows = MAX_TAIL_LINES + 1 + if (rows.length <= maxRows) { + return + } + const removeCount = rows.length - maxRows + rows.splice(0, removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + const moveCursorToColumn = (nextColumn: number): void => { + cursorColumn = clampTerminalPreviewCursor(nextColumn) + } + const markCursorRowRewritten = (): void => { + ensureCursorRow() + rows[cursorRow]!.completed = false + } + const writeChar = (char: string): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (cursorColumn > row.text.length) { + row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` + } + row.text = + cursorColumn >= row.text.length + ? `${row.text}${char}` + : `${row.text.slice(0, cursorColumn)}${char}${row.text.slice(cursorColumn + 1)}` + cursorColumn += 1 + } + const eraseLine = (mode: number): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (mode === 0) { + row.text = row.text.slice(0, cursorColumn) + } else if (mode === 1) { + const deleteCount = Math.min(cursorColumn + 1, row.text.length) + row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` + } else if (mode === 2) { + row.text = '' + } + } + + for (let index = 0; index < normalizedChunk.length; index += 1) { + const char = normalizedChunk[index] + if (char === '\n') { + ensureCursorRow() + rows[cursorRow]!.completed = true + newCompleteLines += 1 + cursorRow += 1 + cursorColumn = 0 + ensureCursorRow() + trimRows() + continue + } + if (char === '\r') { + cursorColumn = 0 + continue + } + if (char === '\u0008') { + cursorColumn = Math.max(0, cursorColumn - 1) + continue + } + if (char === '\u001b') { + const parsed = parseAnsiControlSequence(normalizedChunk, index) + if (!parsed) { + continue + } + index = parsed.endIndex + if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { + continue + } + const firstParam = parsed.firstParam ?? 1 + if (parsed.final === 'A') { + cursorRow = Math.max(0, cursorRow - firstParam) + rows.splice(cursorRow + 1) + } else if (parsed.final === 'K') { + eraseLine(parsed.firstParam ?? 0) + } else if (parsed.final === 'G' || parsed.final === '`') { + moveCursorToColumn(firstParam - 1) + } else if (parsed.final === 'D') { + cursorColumn = Math.max(0, cursorColumn - firstParam) + } else if (parsed.final === 'C') { + moveCursorToColumn(cursorColumn + firstParam) + } + continue + } + writeChar(char) + } + + return finalizeRetainedTerminalRows(rows, cursorRow, cursorColumn, truncated, newCompleteLines) +} + +type RetainedTailRedrawCursor = { + rowFromEnd: number + column: number +} + +type RetainedTerminalRow = { + text: string + completed: boolean +} + +function finalizeRetainedTerminalRows( + rows: RetainedTerminalRow[], + cursorRow: number, + cursorColumn: number, + initialTruncated: boolean, + newCompleteLines: number +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number +} { + let truncated = initialTruncated + let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') })) + + if (retainedRows.length > MAX_TAIL_LINES + 1) { + const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) + retainedRows = retainedRows.slice(removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + + let totalChars = retainedRows.reduce((sum, row) => sum + row.text.length, 0) + let trimStartIndex = 0 + while (trimStartIndex < retainedRows.length - 1 && totalChars > MAX_TAIL_CHARS) { + totalChars -= retainedRows[trimStartIndex]!.text.length + trimStartIndex += 1 + } + if (trimStartIndex > 0) { + retainedRows = retainedRows.slice(trimStartIndex) + cursorRow = Math.max(0, cursorRow - trimStartIndex) + truncated = true + } + while ( + retainedRows.length > 1 && + cursorRow < retainedRows.length - 1 && + retainedRows.at(-1)?.completed === false && + retainedRows.at(-1)?.text.length === 0 + ) { + retainedRows.pop() + } + + const lastRow = retainedRows.at(-1) + let partialLine = lastRow && !lastRow.completed ? lastRow.text : '' + let lines = (lastRow && !lastRow.completed ? retainedRows.slice(0, -1) : retainedRows).map( + (row) => row.text + ) + + if (partialLine.length > MAX_TAIL_PARTIAL_CHARS) { + partialLine = partialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + truncated = true + } + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + const outputRowCount = lines.length + 1 + const defaultCursorRow = outputRowCount - 1 + const defaultCursorColumn = partialLine.length + const redrawCursor = + cursorRow === defaultCursorRow && cursorColumn === defaultCursorColumn + ? null + : { + rowFromEnd: Math.max(0, outputRowCount - 1 - cursorRow), + column: clampTerminalPreviewCursor(cursorColumn) + } + + return { + lines, + partialLine, + redrawCursor, + truncated, + newCompleteLines + } +} + +function splitRetainedTerminalTailSegments(value: string): { + completeSegments: string[] + partialSegment: string + completeLineCount: number +} { + let completeLineCount = 0 + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '\n') { + completeLineCount += 1 + } + } + + const retainedCompleteCount = Math.min(completeLineCount, MAX_TAIL_LINES) + const omittedCompleteCount = completeLineCount - retainedCompleteCount + let startIndex = 0 + if (omittedCompleteCount > 0) { + let seen = 0 + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== '\n') { + continue + } + seen += 1 + if (seen === omittedCompleteCount) { + startIndex = index + 1 + break + } + } + } + + const completeSegments: string[] = [] + let segmentStart = startIndex + for (let index = startIndex; index < value.length; index += 1) { + if (value[index] !== '\n') { + continue + } + completeSegments.push(value.slice(segmentStart, index)) + segmentStart = index + 1 + } + + return { + completeSegments, + partialSegment: value.slice(segmentStart), + completeLineCount + } +} + +function processTerminalTailCompleteSegments(segments: string[]): string[] { + const processed: string[] = [] + let totalChars = 0 + for (let index = segments.length - 1; index >= 0; index -= 1) { + const line = applyTerminalLineControls(segments[index]!).text + processed.push(line) + totalChars += line.length + if (totalChars > MAX_TAIL_CHARS) { + break + } + } + processed.reverse() + return processed +} + +function applyTerminalLineControls(line: string): { + text: string + cursorColumn: number + hadControl: boolean +} { const carriageIndex = line.lastIndexOf('\r') const latestRedraw = carriageIndex >= 0 ? line.slice(carriageIndex + 1) : line if (!latestRedraw.includes('\u0008') && !latestRedraw.includes('\u001b')) { - return latestRedraw + return { + text: latestRedraw, + cursorColumn: latestRedraw.length, + hadControl: carriageIndex >= 0 + } } const chars: string[] = [] let cursor = 0 + const moveCursorTo = (nextCursor: number): void => { + cursor = clampTerminalPreviewCursor(nextCursor) + } const writeChar = (char: string): void => { + if (cursor > chars.length) { + const oldLength = chars.length + chars.length = cursor + chars.fill(' ', oldLength, cursor) + } if (cursor >= chars.length) { chars.push(char) } else { @@ -20768,36 +21089,45 @@ function applyTerminalLineControls(line: string): string { if (parsed.kind !== 'csi') { continue } + if (!hasCanonicalNumericCsiParams(parsed.params)) { + continue + } if (parsed.final === 'K') { const mode = parsed.firstParam ?? 0 if (mode === 0) { chars.length = cursor } else if (mode === 1) { - chars.splice(0, cursor) - cursor = 0 - } else if (mode === 2 || mode === 3) { + const deleteCount = Math.min(cursor + 1, chars.length) + chars.fill(' ', 0, deleteCount) + } else if (mode === 2) { chars.length = 0 - cursor = 0 } } else if (parsed.final === 'G' || parsed.final === '`') { - cursor = Math.min(chars.length, Math.max(0, (parsed.firstParam ?? 1) - 1)) + moveCursorTo((parsed.firstParam ?? 1) - 1) } else if (parsed.final === 'D') { cursor = Math.max(0, cursor - (parsed.firstParam ?? 1)) } else if (parsed.final === 'C') { - cursor = Math.min(chars.length, cursor + (parsed.firstParam ?? 1)) + moveCursorTo(cursor + (parsed.firstParam ?? 1)) } } else { writeChar(char) } } - return chars.join('') + return { text: chars.join(''), cursorColumn: cursor, hadControl: true } +} + +function clampTerminalPreviewCursor(nextCursor: number): number { + if (!Number.isFinite(nextCursor)) { + return MAX_TAIL_PARTIAL_CHARS + } + return Math.min(MAX_TAIL_PARTIAL_CHARS, Math.max(0, Math.floor(nextCursor))) } function parseAnsiControlSequence( value: string, escapeIndex: number ): - | { kind: 'csi'; final: string; firstParam: number | null; endIndex: number } + | { kind: 'csi'; final: string; params: string; firstParam: number | null; endIndex: number } | { kind: 'other' endIndex: number @@ -20811,10 +21141,11 @@ function parseAnsiControlSequence( continue } const params = value.slice(escapeIndex + 2, index) - const firstParamMatch = /^\??(\d+)/.exec(params) + const firstParamMatch = /^(\d+)/.exec(params) return { kind: 'csi', final: value[index] ?? '', + params, firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, endIndex: index } @@ -20847,16 +21178,43 @@ function isStTerminatedStringControlIntroducer(introducer: string | undefined): return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' } +function hasCanonicalNumericCsiParams(params: string): boolean { + return /^[0-9;]*$/.test(params) +} + +function containsTerminalVerticalLineControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== '\u001b') { + continue + } + const parsed = parseAnsiControlSequence(value, index) + if (!parsed) { + return false + } + index = parsed.endIndex + if ( + parsed.kind === 'csi' && + parsed.final === 'A' && + hasCanonicalNumericCsiParams(parsed.params) + ) { + return true + } + } + return false +} + function tailStateMatches( lines: string[], partialLine: string, pendingAnsi: string, + redrawCursor: RetainedTailRedrawCursor | null, truncated: boolean, linesTotal: number, snapshot: { lines: string[] partialLine: string pendingAnsi: string + redrawCursor: RetainedTailRedrawCursor | null truncated: boolean linesTotal: number } @@ -20864,6 +21222,7 @@ function tailStateMatches( if ( partialLine !== snapshot.partialLine || pendingAnsi !== snapshot.pendingAnsi || + !tailRedrawCursorsMatch(redrawCursor, snapshot.redrawCursor) || truncated !== snapshot.truncated || linesTotal !== snapshot.linesTotal || lines.length !== snapshot.lines.length @@ -20881,6 +21240,19 @@ function tailStateMatches( return true } +function tailRedrawCursorsMatch( + left: RetainedTailRedrawCursor | null, + right: RetainedTailRedrawCursor | null +): boolean { + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return left.rowFromEnd === right.rowFromEnd && left.column === right.column +} + function buildTailLines(lines: string[], partialLine: string): string[] { return partialLine.length > 0 ? [...lines, partialLine] : lines } @@ -21514,6 +21886,11 @@ function normalizeTerminalChunk( pendingAnsi: trimPendingAnsiControl(combined.slice(index)) } } + if (parsed.kind === 'csi' && isTerminalPreviewLineControl(parsed)) { + // Why: Codex can redraw status text with ANSI controls but no CR; keep + // those controls so the tail buffer overwrites the previous frame. + parts.push(combined.slice(index, parsed.endIndex + 1)) + } index = parsed.endIndex textStart = index + 1 continue @@ -21580,6 +21957,27 @@ function trimPendingAnsiControl(value: string): string { return `${introducer}${value.slice(-suffixBudget)}` } +function isTerminalPreviewLineControl(parsed: { + final: string + params: string + firstParam: number | null +}): boolean { + if (!hasCanonicalNumericCsiParams(parsed.params)) { + return false + } + if (parsed.final === 'K') { + const mode = parsed.firstParam ?? 0 + return mode === 0 || mode === 1 || mode === 2 + } + return ( + parsed.final === 'A' || + parsed.final === 'G' || + parsed.final === '`' || + parsed.final === 'D' || + parsed.final === 'C' + ) +} + function maxTimestamp(left: number | null, right: number | null): number | null { if (left === null) { return right