From 859d60fff539d7d0c10117f0b0a038213286342f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 22:09:42 +0800 Subject: [PATCH] fix(tui): preserve compaction indicator ownership --- packages/ui/tui/src/index.ts | 14 ++++++---- packages/ui/tui/tests/tui.spec.ts | 43 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 96e8ccc68d..ded1e617c3 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1524,11 +1524,13 @@ export function createTuiChat( // live durable bracket can announce its in-flight state without mistaking // a stale resumed orphan for current work. if (event.type === 'compact/start' && event.data.turn === null) { - compacting = { - startedAt: now(), - timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), + if (compacting === undefined) { + compacting = { + startedAt: now(), + timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), + } + runtime.terminal.setProgress(true) } - runtime.terminal.setProgress(true) requestRender() return } @@ -1538,7 +1540,9 @@ export function createTuiChat( if (event.data.error !== undefined) { appendNotice(`Compaction failed: ${event.data.error}`, 'warning') } - beginFadeOut(COMPACTING_GLYPH) + // A concurrently running turn owns the indicator. Keep its timer and + // progress bit instead of letting the compaction fade clear that state. + if (runningStatus === undefined) beginFadeOut(COMPACTING_GLYPH) requestRender() return } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 411d10b2b6..bca8889946 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2075,9 +2075,52 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('dsh ◍ ') expect(result.terminal.output).not.toContain('dsh ⊙ ') + result.session.append('compact/end', { turn: null }) + await tick() + result.terminal.output = '' + result.terminal.resize(result.terminal.columns + 1) + await tick() + + expect(result.terminal.output).toContain('dsh ◍ ') + expect(result.terminal.output).not.toContain('dsh ⊙ ') + expect(result.terminal.progress.at(-1)).toBe(true) await dispose(result) }) + it('treats duplicate live compaction starts as one owned bracket', async () => { + const intervalSpy = vi.spyOn(globalThis, 'setInterval') + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval') + let result: Awaited> | undefined + let didDispose = false + let clock = 0 + try { + result = await setup({ omitInitialLifecycle: true, now: () => clock }) + intervalSpy.mockClear() + clearIntervalSpy.mockClear() + result.session.append('compact/start', { turn: null }) + clock = 1_000 + result.terminal.output = '' + result.session.append('compact/start', { turn: null }) + await tick() + + expect(intervalSpy).toHaveBeenCalledOnce() + expect(result.terminal.output).toContain('dsh ⊙ ') + expect(result.terminal.progress.at(-1)).toBe(true) + + result.session.append('compact/end', { turn: null }) + await tick() + expect(clearIntervalSpy).toHaveBeenCalledOnce() + expect(result.terminal.progress.at(-1)).toBe(false) + + await dispose(result) + didDispose = true + } finally { + if (result !== undefined && !didDispose) await dispose(result) + intervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + } + }) + it('does not show compaction progress for a resumed orphaned start', async () => { const result = await setup({ omitInitialLifecycle: true,