feat(tui): show live compaction progress

This commit is contained in:
Hypatia May
2026-07-30 21:59:33 +08:00
parent df0c108e1b
commit bbff2c58de
15 changed files with 306 additions and 33 deletions

View File

@@ -137,6 +137,12 @@ async function tick(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
function promptWidth(output: string): number {
const row = output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
async function setup(options: TuiHarnessOptions = {}) {
const terminal = new FakeTerminal()
const exit = vi.fn()
@@ -1950,12 +1956,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
// `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
// cursor never shifts. Assert both the glyph slot and that constant width
// (color is off in this harness, so output carries no ANSI to strip).
const promptWidth = (): number => {
const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
// Each phase swaps only the glyph character in the same slot at equal width.
const phaseGlyph: [() => void, string][] = [
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
@@ -1968,8 +1968,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
drive()
await tick()
expect(result.terminal.output).toContain(expected)
runningWidth ??= promptWidth()
expect(promptWidth()).toBe(runningWidth)
runningWidth ??= promptWidth(result.terminal.output)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
}
// Idle begins a fade-out; once it settles (clock past the fade window) the
@@ -1986,12 +1986,136 @@ describe('pi-tui chat lifecycle and transcript', () => {
return rows.at(-1) ?? ''
}
expect(promptRow()).toContain('dsh > ')
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
expect(promptWidth()).toBe(runningWidth)
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙⊙]/u)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
await dispose(result)
})
it('shows one compaction glyph cell for a live standalone bracket while idle', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
const idleWidth = promptWidth(result.terminal.output)
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(promptWidth(result.terminal.output)).toBe(idleWidth)
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('ignores a numbered compaction bracket while the status line is idle', async () => {
const result = await setup({ now: () => 1_000 })
result.session.append('compact/start', { turn: 1 })
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('fades a closed standalone compaction back to the plain caret', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
clock = 1_000
result.session.append('compact/start', { turn: null })
await tick()
result.session.append('compact/end', { turn: null })
await tick()
clock = 2_000
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 120))
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('reports a failed standalone compaction when its live bracket closes', async () => {
const result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
result.session.append('compact/start', { turn: null })
result.terminal.output = ''
result.session.append('compact/end', { turn: null, error: 'summary failed' })
await tick()
expect(result.terminal.output).toContain('Compaction failed: summary failed')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('preserves live compaction progress across an idle status edge', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
result.ctx.emit('agent/status', result.agent, 'idle')
await tick()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('keeps a running turn phase glyph ahead of standalone compaction', async () => {
let clock = 0
const result = await setup({ status: 'running', now: () => clock })
clock = 1_000
result.terminal.output = ''
result.session.append('compact/start', { turn: null })
await tick()
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
await dispose(result)
})
it('does not show compaction progress for a resumed orphaned start', async () => {
const result = await setup({
omitInitialLifecycle: true,
now: () => 1_000,
beforeMount(session) {
session.append('compact/start', { turn: null })
},
})
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('releases the live compaction timer and progress bit on dispose', async () => {
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
let result: Awaited<ReturnType<typeof setup>> | undefined
let didDispose = false
try {
result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
intervalSpy.mockClear()
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
expect(intervalSpy).toHaveBeenCalledOnce()
await dispose(result)
didDispose = true
expect(clearIntervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.progress.at(-1)).toBe(false)
} finally {
if (result !== undefined && !didDispose) await dispose(result)
intervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})
// Extract the running glyph's interpolated gray channel from a rendered frame.
const glyphGray = (frame: string): number => {
const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
@@ -2099,7 +2223,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('shows the plain prompt caret while idle', async () => {
const result = await setup({ now: () => 0 })
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
await dispose(result)
})