Keep Codex terminal transcripts readable during redraws (#5858)
This commit is contained in:
parent
3478efca08
commit
d6cb38b45a
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue