diff --git a/ui-tui/src/__tests__/turnControllerNotice.test.ts b/ui-tui/src/__tests__/turnControllerNotice.test.ts index b25f860b626..57bfbb2f7be 100644 --- a/ui-tui/src/__tests__/turnControllerNotice.test.ts +++ b/ui-tui/src/__tests__/turnControllerNotice.test.ts @@ -4,10 +4,11 @@ import { turnController } from '../app/turnController.js' import { resetTurnState } from '../app/turnStore.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' -// turnController.startMessage() treats the usage-band notice (credits.usage) as -// "show until next prompt": a 50/75/90 heads-up flashes, then yields when the next -// turn starts. Depletion (and other notices) are sticky until the policy clears them. -describe('turnController.startMessage — usage-band notice clears on next prompt', () => { +// turnController.startMessage() treats "flash and yield" notices (the usage-band +// credits.usage and the one-time credits.grant_spent transition) as "show until next +// prompt": they flash once, then yield when the next turn starts. Depletion (and +// other notices) are sticky until the policy clears them. +describe('turnController.startMessage — flash-and-yield notices clear on next prompt', () => { beforeEach(() => { resetUiState() resetTurnState() @@ -22,6 +23,16 @@ describe('turnController.startMessage — usage-band notice clears on next promp expect(getUiState().notice).toBeNull() }) + it('clears a standing credits.grant_spent notice when a new turn starts', () => { + // One-time "you've crossed onto top-up" heads-up — shouldn't camp the bar + // (e.g. "Grant spent · $990 top-up left" with plenty of top-up remaining). + patchUiState({ + notice: { key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $990.00 top-up left' } + }) + turnController.startMessage() + expect(getUiState().notice).toBeNull() + }) + it('leaves a sticky credits.depleted notice across a new turn', () => { patchUiState({ notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /usage for balance' } diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index eda0e485519..9e713cc4bec 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -907,12 +907,16 @@ class TurnController { this.toolTokenAcc = 0 this.interrupted = false this.persistedToolLabels.clear() - // Usage-band notices (credits.usage) are "show until next prompt": a 50/75/90 - // heads-up should flash and then yield, not camp the bar. Clear it as a new - // turn starts. Depletion (credits.depleted) and other notices stay — they're - // explicitly sticky until the policy clears them. - if (getUiState().notice?.key === 'credits.usage') { - this.clearNotice('credits.usage') + // "Flash and yield" notices clear when a new turn starts: a usage-band heads-up + // (credits.usage, 50/75/90%) and the one-time "grant spent" transition + // (credits.grant_spent) should show once, then get out of the way — not camp the + // bar (e.g. "Grant spent · $990 top-up left" sitting there with plenty of top-up + // left). Depletion (credits.depleted) and other notices stay — they're explicitly + // sticky until the policy clears them. The Python `active` latch retains the key, + // so a yielded notice won't re-fire on the next turn. + const yieldingNoticeKey = getUiState().notice?.key + if (yieldingNoticeKey === 'credits.usage' || yieldingNoticeKey === 'credits.grant_spent') { + this.clearNotice(yieldingNoticeKey) } patchUiState({ busy: true }) patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })