feat(tui): show live compaction progress
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user