opentui(v6): credits/usage notice chrome banner (Ink parity)

The OpenTUI engine received the gateway's credits/usage notices but
mis-rendered them as scrolling inline transcript cards with no lifecycle.
Render them instead as a persistent, level-tinted chrome banner pinned
directly above the status bar, matching the Ink engine — no gateway/agent
changes (the wire + credits policy stay the source of truth).

- backgroundActivity.ts: widen level to include `success` (was silently
  dropped to info) + add isChromeNotice() (kind sticky|ttl) discriminator.
- store.ts: port the Ink turnController notice lifecycle — showNotice/
  applyNotice/clearNotice/flushPendingNotice/clearNoticeState, a single TTL
  timer (latest-wins, id-guarded), mid-turn hold + turn-end reveal (the
  three end sites: message.complete, gateway.exited, error), flash-and-yield
  for credits.usage/grant_spent at message.start, and a notice reset on
  clearTranscript + commitSnapshot so it can't bleed across sessions. Route
  notification.show by kind: sticky|ttl -> chrome banner, everything else
  (process/background completion cards) -> existing inline path, unchanged.
  Distinct clones for notice vs lastNotification (createStore aliasing).
- noticeBanner.tsx + App.tsx: a single sticky row above the status bar,
  text rendered verbatim (already glyphed by the policy), tinted by level,
  width-truncated so it can never wrap and push the composer.

Tests: statusNotice.test.ts (lifecycle/routing/TTL/flash-and-yield),
noticeBanner.test.tsx (render/color/truncation), backgroundActivity +
render additions. npm run check OK (768 tests).
This commit is contained in:
alt-glitch 2026-06-14 12:54:38 +05:30
parent f5823277dc
commit 8e3b320eb8
9 changed files with 796 additions and 5 deletions

View file

@ -0,0 +1,217 @@
# OpenTUI — usage/credits notice in the composer chrome
**Status:** spec (not started) · **Engine:** `ui-opentui/` · **Author:** glitch · 2026-06-14
## Goal
Render the gateway's **usage / credits notices** as a persistent, level-tinted
**chrome banner pinned at the top of the input zone** (directly above the status
bar), with the same lifecycle the Ink engine already has — sticky vs TTL,
mid-turn hold + turn-end reveal, and "flash-and-yield" for the usage bands.
Today the OpenTUI engine **receives** these notices but mis-renders them as
scrolling inline transcript cards with no lifecycle. This spec fixes that without
touching the gateway or the agent (the data already flows correctly).
## What already exists (verified)
### The wire (source of truth — do NOT change)
The gateway emits one event for every notice, snake_case payload:
```
notification.show payload { text, level, kind, ttl_ms, key, id } # tui_gateway/server.py:2878
notification.clear payload { key } # tui_gateway/server.py:2890
```
These come from `AgentNotice` (`agent/credits_tracker.py:177`). The credits
policy (`evaluate_credits_notices`, `agent/credits_tracker.py:245`) emits exactly
four notices — the full catalog this feature renders:
| `key` | `text` (already glyphed by policy) | `level` | `kind` | `ttl_ms` | lifecycle |
|-----------------------|-------------------------------------------------|-----------|----------|----------|----------------|
| `credits.usage` | `⚠/• Credits N% used · $X cap` (bands 50/75/90) | info/warn | `sticky` | — | flash-and-yield |
| `credits.grant_spent` | `• Grant spent · $X top-up left` | info | `sticky` | — | flash-and-yield |
| `credits.depleted` | `✕ Credit access paused · run /usage for balance` | error | `sticky` | — | sticky |
| `credits.restored` | `✓ Credit access restored` | success | `ttl` | `8000` | TTL self-expire |
**Load-bearing facts:**
- `text` is **already glyphed** (⚠ • ✕ ✓) by the Python policy — the renderer
**must not** prepend another glyph. It only tints by `level`.
- `level` includes **`success`** (green) — a level the current OpenTUI parser
silently drops to `info`.
- `kind` is the **lifecycle marker** (`sticky` | `ttl`), NOT a display label.
`id` == `key` (stable per kind, not unique per emission).
- Notices are **reconciled**: the policy emits `to_clear` (a `notification.clear`)
then `to_show`. A band change clears `credits.usage` then re-shows it.
### The Ink reference behavior (what we're matching)
`ui-tui/src/app/turnController.ts` + `appChrome.tsx`:
- `showNotice` (`:181`): if **busy**, hold in `pendingNotice` (latest-wins);
if idle, apply now.
- `applyNotice` (`:213`): set the visible notice; for `kind: 'ttl'` with
`ttl_ms > 0`, arm a self-expiry timer (clearing any prior timer first).
- `clearNotice(key)` (`:198`): drop the visible **and** pending notice only when
the key matches (a stale clear must not wipe a newer notice).
- `flushPendingNotice` (`:245`): at **turn end** (only the real end sites) apply
the held notice — its TTL clock starts here, when it first becomes visible.
- **Flash-and-yield** (`startMessage`, `:917`): at **turn start**, if the visible
notice's key is `credits.usage` or `credits.grant_spent`, clear it — "show
once, then get out of the way." `credits.depleted` and others stay sticky. The
Python `active` latch keeps the key so it won't re-fire next turn.
- Session reset clears all notice state so session A's notice can't bleed into B.
- Color by level: `error→error`, `warn→warn`, `success→statusGood`,
`info→accent` (`noticeColor`, `appChrome.tsx:192`).
### The OpenTUI side (what we change)
- `notification.show``parseNotification``pushNotification` → **inline card**
in the transcript (`store.ts:832`, `notificationCard.tsx`). All kinds, no
lifecycle. The Option B process-completion card (`kind: 'process.complete'`)
and `background.complete` (`kind: 'background task complete'`) also use this
path — **they must keep working unchanged.**
- `parseNotification` coerces `level` to `info|warn|error` only
(`backgroundActivity.ts:48`) — drops `success`.
- Store carries `lastNotification` (OSC seam), `bgTasks`; **no** `notice` slot.
- Theme has `accent`, `warn`, `error`, `ok`/`statusGood`, `muted`
(`logic/theme.ts`) — `success` maps to `statusGood`.
- Input zone layout (`view/App.tsx:140-211`): a top-bordered column —
`<StatusBar>` → composer `<Switch>``<AgentsTray>`. The new banner mounts at
`App.tsx:144`, **directly above `<StatusBar>`** (the topmost line of the chrome).
- Turn lifecycle hooks: `case 'message.start'` (`store.ts:779`, sets
`info.running = true`) and `case 'message.complete'` (`store.ts:811`, sets
`info.running = false`). `clearTranscript` (`store.ts:631`) is the reset site.
- `Date.now()` is used freely in the store (`:877`) — `setTimeout` for TTL is fine.
## The one design decision: routing
`kind` is the discriminator. **`notification.show` with `kind === 'sticky'` or
`kind === 'ttl'` → the new chrome-notice path; every other kind → the existing
inline-card path, untouched.** This mirrors Ink's `Notice.kind: 'sticky' | 'ttl'`
exactly, and the credits policy sets `kind` to one of those for all four notices,
while the process/background cards use label-strings (`process.complete`,
`background task complete`) that are neither — so they stay inline cards. No
gateway change, no key-prefix sniffing.
**Divergence from Ink (intentional):** Ink hides the notice while busy because the
FaceTicker shares its one status slot. OpenTUI's busy face (`StatusLine`) lives in
the transcript area, so the banner has a **dedicated row** and stays visible
through a turn (a depletion warning shouldn't vanish mid-turn). We still **hold
new notices** that arrive mid-turn (`pendingNotice`) and reveal them at turn end —
matching Ink's "don't pop a fresh banner mid-stream" intent.
## Implementation
### Phase 1 — parser + type (`logic/backgroundActivity.ts`)
1. Widen `ActivityNotification.level` to `'info' | 'warn' | 'error' | 'success'`.
2. `coerceLevel`: also accept `'success'` (still fall back to `'info'`).
3. Add `export function isChromeNotice(n: ActivityNotification): boolean`
`n.kind === 'sticky' || n.kind === 'ttl'`.
4. `parseNotification` already maps `ttl_ms → ttlMs` and preserves `key`/`id`
no shape change beyond the widened level.
**Tests** (`backgroundActivity.test.ts` or `notificationCard.test.tsx`):
`success` survives parse; `kind: 'ttl'` + `ttl_ms``ttlMs`; `isChromeNotice`
true for sticky/ttl, false for `process.complete`/`''`.
### Phase 2 — store lifecycle (`logic/store.ts`)
Add state + a private (non-reactive) timer handle in `createSessionStore`:
- `notice: ActivityNotification | null` (visible chrome notice) — new state field,
init `null`.
- `pendingNotice: ActivityNotification | null` — held mid-turn, init `null`.
- `let noticeTimer: ReturnType<typeof setTimeout> | undefined` (closure var).
Functions (port of `turnController`):
- `showNotice(n)`: `state.info.running ? setState('pendingNotice', n) : applyNotice(n)`
(latest-wins — assigning replaces any prior pending).
- `applyNotice(n)`: clear `noticeTimer`; `setState('notice', n)`; if
`n.kind === 'ttl' && n.ttlMs && n.ttlMs > 0`, arm `setTimeout(n.ttlMs)` that
clears `notice` only if `state.notice?.id === n.id` (defensive guard).
- `clearNotice(key)`: if `state.pendingNotice?.key === key` → null it; if
`state.notice?.key === key` → clear timer + null `notice`.
- `flushPendingNotice()`: if `state.pendingNotice``applyNotice` it, null pending.
- `clearNoticeState()`: null `notice` + `pendingNotice`, clear timer.
Wire into the event reducer:
- `notification.show` (`store.ts:832`): route —
`const n = parseNotification(...); if (!n) break; if (isChromeNotice(n)) showNotice(n); else pushNotification(n)`.
(Still record `lastNotification` for the OSC seam in **both** paths — extract
the `setState('lastNotification', {...n})` so a chrome notice also pings a
blurred terminal, matching the inline-card behavior.)
- `notification.clear` (`store.ts:837`): call **both** `clearNotificationCards(key)`
(cards) **and** `clearNotice(key)` (chrome) — a key only ever lives in one, so
calling both is safe and avoids guessing.
- `message.start` (`store.ts:779`): flash-and-yield — if
`state.notice?.key === 'credits.usage' || === 'credits.grant_spent'`
`clearNotice(state.notice.key)`. (Do this **before** flipping `running` true so
the read is clean.)
- `message.complete` (`store.ts:811`): call `flushPendingNotice()` (after the
`running = false` set, so a held notice reveals on the now-idle bar).
- `clearTranscript` (`store.ts:631`) and any session-switch reset:
`clearNoticeState()`.
Export `notice` via the store's state and `showNotice`/`clearNotice` if a test or
future slash command needs them.
**Tests** (`statusNotice.test.ts`, new):
- idle `showNotice``state.notice` set, no card pushed.
- routing: `notification.show` `kind:'sticky'``notice` set, **no** transcript
card; `kind:'process.complete'` → card pushed, `notice` still null.
- mid-turn hold: `message.start``showNotice``notice` stays null,
`pendingNotice` set → `message.complete``notice` revealed.
- `clearNotice` by key drops visible + pending; non-matching key is a no-op.
- TTL: `kind:'ttl', ttlMs:50` auto-clears (vitest fake timers).
- flash-and-yield: visible `credits.usage` cleared on `message.start`;
`credits.depleted` persists across a start/complete cycle.
- `clearTranscript` resets `notice` + `pendingNotice`.
- `success` notice keeps its level.
### Phase 3 — view (`view/noticeBanner.tsx` + `App.tsx`)
New `NoticeBanner` (sibling style to `notificationCard.tsx`):
- Props: `notice: ActivityNotification | null`, plus terminal width for truncation.
- `<Show when={notice}>` — renders nothing when null.
- One row, `flexShrink: 0`, `paddingLeft: 1`, `selectable={false}`.
- Text rendered **verbatim** (glyph already present), tinted by level:
`error→error`, `warn→warn`, `success→statusGood`, `info→accent`.
- Truncate to width with `truncRight` (`logic/truncate.ts`) so a long notice can
never push the composer or wrap.
Mount in `App.tsx:144`, the first child of the top-bordered input zone, directly
above `<StatusBar store={...} />`:
```tsx
<box border={['top']} ...>
<NoticeBanner notice={props.store.state.notice} /> {/* new */}
<StatusBar store={props.store} />
...
```
**Tests** (`noticeBanner.test.tsx`, frame): renders the text without adding a
glyph; warn→warn color, success→statusGood color; truncates at narrow width;
renders an empty frame when `notice` is null.
### Phase 4 — parity verification + docs
- `npm run check` green (prettier + eslint + vitest).
- Headless frame dump: a `credits.usage` warn banner above the status bar; a
`credits.depleted` error banner surviving a turn; a `credits.restored` success
banner that disappears after its TTL.
- tmux smoke per `docs/opentui-dev-handoff.md` (inject the three notices via the
test harness / a scripted gateway event; screenshot the chrome).
- Cross-check the four-notice catalog renders identically in tone to Ink's
`appChromeStatusRule` (color-by-level, no double glyph, truncation).
## Non-goals
- No gateway/agent changes — the wire and the policy are the source of truth.
- No new notice kinds — render exactly the four the policy emits.
- The inline-card path (process/background completions) is **unchanged**.
- No status-bar segment changes — the banner is its own row above the bar.
## Risk / footguns
- **Schema decode-at-boundary**: `notification.show` payload is a loose Record
read by `parseNotification`, not strict-decoded — a wrong-typed field won't blank
the bar (unlike `applyInfo`). Keep the loose reads.
- **createStore reference-aliasing**: store `notice` and `pendingNotice` distinct
objects; when applying pending, it's already its own object — don't alias it to
`lastNotification`. (See `[[solid-createstore-reference-aliasing]]`.)
- **Timer leak**: `clearNoticeState` must clear `noticeTimer`; ensure session
reset and store dispose clear it so a TTL callback can't fire into a dead store.
- **Routing regression**: assert in tests that `process.complete` /
`background task complete` still produce **cards**, not banners — the whole
feature hinges on the `kind` discriminator.

View file

@ -17,7 +17,7 @@ export interface ActivityNotification {
id: string
key?: string
text: string
level: 'info' | 'warn' | 'error'
level: 'info' | 'warn' | 'error' | 'success'
kind: string
ttlMs?: number
}
@ -46,7 +46,14 @@ function readNum(value: unknown, key: string): number | undefined {
/** Coerce any wire `level` to the closed union; anything that isn't a known
* level (absent, garbage, wrong-typed) falls back to 'info'. */
function coerceLevel(value: unknown): ActivityNotification['level'] {
return value === 'warn' || value === 'error' ? value : 'info'
return value === 'warn' || value === 'error' || value === 'success' ? value : 'info'
}
/** A chrome notice (status-bar banner with lifecycle), distinguished from an
* inline card by its lifecycle kind. Credits/usage notices set kind sticky|ttl;
* process/background cards use label kinds (process.complete, etc.). */
export function isChromeNotice(n: ActivityNotification): boolean {
return n.kind === 'sticky' || n.kind === 'ttl'
}
/**

View file

@ -26,7 +26,12 @@ import { diffStats, type DiffStats } from './diff.ts'
import type { SessionTabId } from './sessionPicker.ts'
import { envFlag, envOutputUnlimited } from './env.ts'
import { registerNotifier } from './notify.ts'
import { parseNotification, type ActivityNotification, type BackgroundProcess } from './backgroundActivity.ts'
import {
isChromeNotice,
parseNotification,
type ActivityNotification,
type BackgroundProcess
} from './backgroundActivity.ts'
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
@ -271,6 +276,14 @@ export interface StoreState {
* seam (terminalChrome) watches this to fire a desktop ping; the inline card
* lives in `messages`. Undefined until the first notification. */
lastNotification: ActivityNotification | undefined
/** The visible CHROME notice a persistent status-bar banner with a lifecycle
* (credits/usage `kind:'sticky'|'ttl'`), distinct from the inline `messages`
* cards. Phase 3 renders it; the store owns its state + lifecycle. null = none. */
notice: ActivityNotification | null
/** A chrome notice held mid-turn (arrived while `info.running`) applied on
* `message.complete` so a notice never flashes over a live reply. Latest-wins
* (a newer pending replaces an older). null = nothing held. */
pendingNotice: ActivityNotification | null
/** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */
info: SessionInfo
/** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" item 11);
@ -479,6 +492,8 @@ export function createSessionStore(options?: SessionStoreOptions) {
backgroundProcesses: [],
bgTasks: [],
lastNotification: undefined,
notice: null,
pendingNotice: null,
status: undefined,
// startedAt is set ONCE here (store creation ≈ session start) — the status
// bar's session-duration segment ticks from it; wire patches never carry it.
@ -513,6 +528,12 @@ export function createSessionStore(options?: SessionStoreOptions) {
// queue here and replay after the snapshot is reconciled (opencode sync-v2).
let buffering: GatewayEvent[] | null = null
// Chrome-notice TTL timer (NOT store state — a transient handle, not reactive
// data). At most ONE armed at a time: every applyNotice clears the prior before
// arming a new one (latest-wins), and the callback nulls it on fire. Mirrors the
// Ink turnController's single `noticeTimer` handle.
let noticeTimer: ReturnType<typeof setTimeout> | undefined
// Anti-flood for `gateway.stderr`: a crashing child can emit a torrent of
// stderr lines, so we do NOT push each to the transcript. Instead we keep a
// small ring of the most-recent lines and only surface a TAIL of it when a
@ -627,6 +648,66 @@ export function createSessionStore(options?: SessionStoreOptions) {
)
}
// ── chrome notice lifecycle (port of ui-tui turnController showNotice/
// applyNotice/clearNotice/flushPendingNotice) — the persistent status-bar
// banner, distinct from the inline `messages` cards. ────────────────────────
/** Make a notice VISIBLE now (the actual `state.notice` write + TTL arming).
* Latest-wins: always clear any prior TTL timer first, so a fresh notice can't
* be expired by a stale predecessor's timer. A `ttl` kind with a positive
* ttlMs self-clears after ttlMs but only if it's still the same notice on
* screen (an `id` guard, so a later notice that replaced it isn't yanked). */
function applyNotice(n: ActivityNotification) {
if (noticeTimer) clearTimeout(noticeTimer)
noticeTimer = undefined
setState('notice', n)
if (n.kind === 'ttl' && typeof n.ttlMs === 'number' && n.ttlMs > 0) {
noticeTimer = setTimeout(() => {
noticeTimer = undefined
if (state.notice?.id === n.id) setState('notice', null)
}, n.ttlMs)
}
}
/** Show a chrome notice, deferring it mid-turn. While a turn runs (`info.running`)
* a banner would flash over the live reply, so HOLD it as `pendingNotice`
* (latest-wins a newer pending just replaces the older) and apply it on
* message.complete (flushPendingNotice). Idle apply immediately. */
function showNotice(n: ActivityNotification) {
if (state.info.running) setState('pendingNotice', n)
else applyNotice(n)
}
/** Clear a notice by key (`notification.clear`) from BOTH the pending hold and
* the visible slot. A key lives in only one notice at a time, but clearing
* both is safe (and matches the Ink semantics). Clears the TTL timer when the
* visible notice is the one removed. */
function clearNotice(key: string) {
if (state.pendingNotice?.key === key) setState('pendingNotice', null)
if (state.notice?.key === key) {
if (noticeTimer) clearTimeout(noticeTimer)
noticeTimer = undefined
setState('notice', null)
}
}
/** Apply any notice held mid-turn (call on message.complete). No-op when none. */
function flushPendingNotice() {
const p = state.pendingNotice
if (!p) return
setState('pendingNotice', null)
applyNotice(p)
}
/** Reset all notice state (timer + visible + pending) so a notice can't bleed
* across a /clear or /new into the next session. */
function clearNoticeState() {
if (noticeTimer) clearTimeout(noticeTimer)
noticeTimer = undefined
setState('notice', null)
setState('pendingNotice', null)
}
/** Clear the transcript (e.g. /clear, /new) and any tracked subagents. */
function clearTranscript() {
setState('messages', [])
@ -634,6 +715,8 @@ export function createSessionStore(options?: SessionStoreOptions) {
setState('dropped', 0)
// Drop the dedup history too — a fresh transcript should re-process any id.
applied.clear()
// A chrome notice must not survive a transcript reset (new session context).
clearNoticeState()
}
/** Open / close the agents dashboard overlay (/agents). The optional `agentId`
@ -778,6 +861,14 @@ export function createSessionStore(options?: SessionStoreOptions) {
break
case 'message.start':
setState('status', undefined)
// Flash-and-yield: a credits usage/grant notice yields to the live turn it
// precedes (it's stale the moment the user sends), so clear it before the
// turn opens. Sticky credits notices (e.g. depleted) persist. Port of the
// Ink turnController startMessage flash-and-yield.
{
const k = state.notice?.key
if (k === 'credits.usage' || k === 'credits.grant_spent') clearNotice(k)
}
setState('info', prev => ({ ...prev, running: true }))
setState(
produce(draft => {
@ -814,6 +905,9 @@ export function createSessionStore(options?: SessionStoreOptions) {
)
setState('status', undefined)
setState('info', prev => ({ ...prev, running: false }))
// Apply any chrome notice held mid-turn now the turn's done (no flash over
// a live reply). No-op when nothing was held.
flushPendingNotice()
// message.complete carries the latest usage/context — refresh the bar.
if (event.payload) applyInfo(event.payload)
break
@ -831,12 +925,25 @@ export function createSessionStore(options?: SessionStoreOptions) {
// records lastNotification so the OSC seam can ping a blurred terminal.
case 'notification.show': {
const n = parseNotification(event.payload)
if (n) pushNotification(n)
if (!n) break
// Route by chrome-ness. Approach (a): lastNotification (the OSC seam) is
// recorded by `pushNotification` for the CARD path, so we set it here only
// for the CHROME path — avoids double-setting and keeps the existing
// pushNotification callers (background.complete) recording it correctly.
if (isChromeNotice(n)) {
setState('lastNotification', { ...n }) // OSC seam (distinct clone — aliasing footgun)
showNotice({ ...n }) // distinct clone again: notice + lastNotification must not share a ref
} else {
pushNotification(n) // pushNotification records lastNotification itself (card path)
}
break
}
case 'notification.clear': {
const key = event.payload?.key
if (key) clearNotificationCards(key)
if (key) {
clearNotificationCards(key) // inline-card path
clearNotice(key) // chrome path (key lives in one path; both is safe)
}
break
}
// A background PROMPT (`/bg`) finished: drop it from the in-flight set (the
@ -1039,6 +1146,9 @@ export function createSessionStore(options?: SessionStoreOptions) {
// the user their in-flight reply was lost, and show a recovering status.
case 'gateway.exited': {
setState('info', prev => ({ ...prev, running: false }))
// A turn can also end via the child exiting mid-reply (no message.complete) —
// flush any held notice here too, the third turn-end site (Ink interruptTurn).
flushPendingNotice()
// Neutral status: we don't ALWAYS recover (budget exhaustion). The
// "recovering…" wording now comes from the gateway.recovering case,
// which fires only when a respawn is actually scheduled.
@ -1075,6 +1185,9 @@ export function createSessionStore(options?: SessionStoreOptions) {
case 'error': {
const message = event.payload?.message
pushSystem(message ? `error: ${message}` : 'error')
// A turn can end via error without message.complete — flush any held
// notice here too, matching Ink recordError.
flushPendingNotice()
break
}
// Other event types (chrome) are reduced in later phases; unhandled members
@ -1097,6 +1210,12 @@ export function createSessionStore(options?: SessionStoreOptions) {
/** Replace history with the resume snapshot, then replay events buffered meanwhile. */
function commitSnapshot(snapshot: Message[]): void {
// Resume replaces session context — a prior session's notice/timer must not
// bleed in; mirrors Ink reset(). Deliberate tradeoff: a same-session
// auto-heal reconnect momentarily drops a standing sticky banner, which the
// gateway re-emits on the next header parse — acceptable vs. cross-session
// bleed + a leaked timer.
clearNoticeState()
// Slice to the cap BEFORE the first setState, not after. Yoga (WASM) layout
// memory is grow-only, so even a TRANSIENT mount of an over-cap resume
// snapshot would permanently ratchet the high-water mark — a set-then-trim
@ -1145,6 +1264,8 @@ export function createSessionStore(options?: SessionStoreOptions) {
pushUser,
pushSystem,
pushNotification,
showNotice,
clearNotice,
setCatalog,
setSessionId,
clearTranscript,

View file

@ -7,6 +7,7 @@ import { describe, expect, test } from 'vitest'
import {
type BackgroundProcess,
isChromeNotice,
parseNotification,
parseProcessList,
runningCount
@ -56,6 +57,31 @@ describe('parseNotification', () => {
const n = parseNotification({ id: 'a', text: 'x', ttl_ms: 'soon' })
expect(n?.ttlMs).toBeUndefined()
})
test('preserves level "success" (previously dropped to info)', () => {
expect(parseNotification({ id: 's', level: 'success', text: 'credits topped up' })?.level).toBe('success')
})
test('ttl credits notice: kind "ttl" + ttl_ms → kind/ttlMs preserved', () => {
const n = parseNotification({ kind: 'ttl', text: 'low on credits', ttl_ms: 8000 })
expect(n?.kind).toBe('ttl')
expect(n?.ttlMs).toBe(8000)
})
})
describe('isChromeNotice', () => {
const mk = (kind: string): Parameters<typeof isChromeNotice>[0] => ({ id: 'i', kind, level: 'info', text: 't' })
test('true for lifecycle kinds sticky | ttl (credits/usage notices)', () => {
expect(isChromeNotice(mk('sticky'))).toBe(true)
expect(isChromeNotice(mk('ttl'))).toBe(true)
})
test('false for label kinds / empty (inline process+background cards)', () => {
expect(isChromeNotice(mk('process.complete'))).toBe(false)
expect(isChromeNotice(mk(''))).toBe(false)
expect(isChromeNotice(mk('background task complete'))).toBe(false)
})
})
describe('parseProcessList', () => {

View file

@ -0,0 +1,116 @@
/**
* NoticeBanner frame tests the persistent chrome banner for `state.notice`.
* 1. renders the (already-glyphed) text VERBATIM, with no extra leading marker;
* 2. each level (warn/success/error) renders its text + picks the right tint;
* 3. a null notice renders an empty frame;
* 4. long text truncates to the terminal width (never wraps).
*/
import { RGBA } from '@opentui/core'
import { describe, expect, test } from 'vitest'
import { createSessionStore } from '../logic/store.ts'
import { NoticeBanner } from '../view/noticeBanner.tsx'
import { ThemeProvider } from '../view/theme.tsx'
import { captureFrame, renderProbe } from './lib/render.ts'
const theme = () => createSessionStore().state.theme
describe('NoticeBanner frame', () => {
test('renders the glyphed text verbatim, with no doubled marker', async () => {
const frame = await captureFrame(
() => (
<ThemeProvider theme={theme}>
<NoticeBanner notice={{ id: 'n1', text: '⚠ Credits 90% used · $20 cap', level: 'warn', kind: 'usage' }} />
</ThemeProvider>
),
{ width: 60, height: 4 }
)
expect(frame).toContain('Credits 90% used')
expect(frame).toContain('⚠') // the gateway glyph survives verbatim…
expect(frame).not.toContain('◆') // …and the card marker is NOT added
})
test('a warn notice and a success notice each render their text', async () => {
const warn = await captureFrame(
() => (
<ThemeProvider theme={theme}>
<NoticeBanner notice={{ id: 'w', text: '⚠ Credits 90% used', level: 'warn', kind: 'usage' }} />
</ThemeProvider>
),
{ width: 60, height: 4 }
)
expect(warn).toContain('Credits 90% used')
const success = await captureFrame(
() => (
<ThemeProvider theme={theme}>
<NoticeBanner notice={{ id: 's', text: '✓ Credit access restored', level: 'success', kind: 'usage' }} />
</ThemeProvider>
),
{ width: 60, height: 4 }
)
expect(success).toContain('Credit access restored')
})
test('tints by level: warn → theme warn, success → statusGood, error → error', async () => {
const colors = theme().color
const cases: Array<{ level: 'warn' | 'success' | 'error'; text: string; expected: string }> = [
{ level: 'warn', text: '⚠ warn line', expected: colors.warn },
{ level: 'success', text: '✓ ok line', expected: colors.statusGood },
{ level: 'error', text: '✕ err line', expected: colors.error }
]
for (const c of cases) {
const probe = await renderProbe(
() => (
<ThemeProvider theme={theme}>
<NoticeBanner notice={{ id: c.level, text: c.text, level: c.level, kind: 'usage' }} />
</ThemeProvider>
),
{ width: 60, height: 4 }
)
try {
let span: { fg: RGBA } | undefined
for (const line of probe.spans().lines) {
for (const s of line.spans) {
if (s.text.includes('line')) span = s
}
}
expect(span).toBeDefined()
expect(span!.fg.toInts().slice(0, 3)).toEqual(RGBA.fromHex(c.expected).toInts().slice(0, 3))
} finally {
probe.destroy()
}
}
})
test('a null notice renders an empty frame (no banner text)', async () => {
const frame = await captureFrame(
() => (
<ThemeProvider theme={theme}>
<NoticeBanner notice={null} />
</ThemeProvider>
),
{ width: 60, height: 4 }
)
expect(frame).not.toContain('Credits')
expect(frame.trim()).toBe('')
})
test('long text truncates to the terminal width (never wraps)', async () => {
const width = 20
const long = '⚠ Credits 90% used · $20 cap · run /usage for your current balance details'
const frame = await captureFrame(
() => (
<ThemeProvider theme={theme}>
<NoticeBanner notice={{ id: 'l', text: long, level: 'warn', kind: 'usage' }} />
</ThemeProvider>
),
{ width, height: 4 }
)
// No rendered line exceeds the terminal width → it clipped instead of wrapping.
for (const line of frame.split('\n')) {
expect(line.length).toBeLessThanOrEqual(width)
}
expect(frame).toContain('…') // truncRight ellipsis present
})
})

View file

@ -339,4 +339,36 @@ describe('App render (Phase 1, themed)', () => {
expect(frame).toContain('select') // footer hint "↑↓ select"
expect(frame).not.toContain('parent turn') // transcript replaced by the dashboard
})
test('a chrome usage notice renders as a banner in the input zone (mount integration)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
// A credits/usage notice (kind sticky) → routed to the chrome banner, NOT an
// inline transcript card. Verifies the NoticeBanner mount above the status bar
// and that the (already-glyphed) text renders verbatim in the real App layout.
store.apply({
type: 'notification.show',
payload: {
text: '⚠ Credits 90% used · $20 cap',
level: 'warn',
kind: 'sticky',
key: 'credits.usage',
id: 'credits.usage'
}
})
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'ready', width: 60, height: 16 }
)
expect(frame).toContain('Credits 90% used') // banner text, verbatim
expect(frame).not.toContain('◆') // NOT the inline-card marker — it's a banner, not a card
expect(store.state.notice?.key).toBe('credits.usage') // routed to the chrome slot
expect(store.state.messages.some(m => m.role === 'notification')).toBe(false) // no card pushed
})
})

View file

@ -0,0 +1,229 @@
/**
* Chrome-notice lifecycle (P2) the persistent status-bar banner state machine,
* distinct from the inline notification cards. Ports the ui-tui turnController
* showNotice/applyNotice/clearNotice/flushPendingNotice semantics:
* 1. routing: sticky/ttl `notification.show` `state.notice` (NOT a card).
* 2. routing (card path intact): label-kind notice an inline card, notice null.
* 3. mid-turn hold + flush: a notice during a turn is held, applied on complete.
* 4. clearNotice by key (and a non-matching key is a no-op).
* 5. TTL auto-expiry (fake timers).
* 6. flash-and-yield: credits.usage yields to a starting turn; sticky persists.
* 7. clearTranscript resets notice + pending.
* 8. error-path flush: a turn ending via error (no message.complete) flushes held.
* 8b. gateway.exited flush: a turn ending via the child exiting mid-reply flushes held.
* 9. resume reset: commitSnapshot clears notice + pending + armed timer.
* 10. flash-and-yield: credits.grant_spent yields to a starting turn.
* 11. success-level notice keeps its level.
*/
import { describe, expect, test, vi } from 'vitest'
import { createSessionStore } from '../logic/store.ts'
describe('chrome notice lifecycle', () => {
test('routing: a sticky notice sets state.notice and pushes NO card', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: '⚠ Credits 90% used', level: 'warn', key: 'credits.usage', id: 'credits.usage' }
})
expect(store.state.notice?.id).toBe('credits.usage')
expect(store.state.notice?.text).toBe('⚠ Credits 90% used')
expect(store.state.messages.some(m => m.role === 'notification')).toBe(false)
})
test('routing (card path intact): a label-kind notice pushes a card, notice stays null', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'notification.show',
payload: { kind: 'process.complete', text: 'build exited 0', key: 'proc:1' }
})
expect(store.state.notice).toBeNull()
const last = store.state.messages.at(-1)
expect(last?.role).toBe('notification')
expect(last?.notification?.text).toBe('build exited 0')
})
test('mid-turn hold + flush: notice is held during a turn, applied on complete', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'held notice', level: 'info', key: 'credits.usage', id: 'h1' }
})
expect(store.state.notice).toBeNull()
expect(store.state.pendingNotice?.id).toBe('h1')
store.apply({ type: 'message.complete' })
expect(store.state.notice?.id).toBe('h1')
expect(store.state.pendingNotice).toBeNull()
})
test('clearNotice by key clears the visible notice; a non-matching key is a no-op', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'notification.show',
payload: {
kind: 'sticky',
text: 'no credits left',
level: 'error',
key: 'credits.depleted',
id: 'credits.depleted'
}
})
expect(store.state.notice?.key).toBe('credits.depleted')
store.apply({ type: 'notification.clear', payload: { key: 'nope' } })
expect(store.state.notice?.key).toBe('credits.depleted') // no-op
store.apply({ type: 'notification.clear', payload: { key: 'credits.depleted' } })
expect(store.state.notice).toBeNull()
})
test('TTL auto-expiry: a ttl notice clears itself after ttlMs', () => {
vi.useFakeTimers()
try {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'notification.show',
payload: { kind: 'ttl', ttl_ms: 50, text: 'transient', level: 'info', key: 'credits.usage', id: 't1' }
})
expect(store.state.notice?.id).toBe('t1')
vi.advanceTimersByTime(50)
expect(store.state.notice).toBeNull()
} finally {
vi.useRealTimers()
}
})
test('flash-and-yield: credits.usage yields to a starting turn; sticky persists', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
// credits.usage: cleared the moment a turn opens.
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: '90% used', level: 'warn', key: 'credits.usage', id: 'u1' }
})
expect(store.state.notice?.id).toBe('u1')
store.apply({ type: 'message.start' })
expect(store.state.notice).toBeNull()
store.apply({ type: 'message.complete' })
// credits.depleted (sticky, non-yielding): survives a start→complete cycle.
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'depleted', level: 'error', key: 'credits.depleted', id: 'd1' }
})
store.apply({ type: 'message.start' })
store.apply({ type: 'message.complete' })
expect(store.state.notice?.id).toBe('d1')
})
test('clearTranscript resets both the visible notice and a pending one', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
// visible notice
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'visible', level: 'info', key: 'credits.depleted', id: 'v1' }
})
// pending notice (held mid-turn)
store.apply({ type: 'message.start' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'held', level: 'info', key: 'credits.usage', id: 'p1' }
})
expect(store.state.notice?.id).toBe('v1')
expect(store.state.pendingNotice?.id).toBe('p1')
store.clearTranscript()
expect(store.state.notice).toBeNull()
expect(store.state.pendingNotice).toBeNull()
})
test('error-path flush: a notice held mid-turn is applied when the turn ends via error', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'held notice', level: 'info', key: 'credits.usage', id: 'e1' }
})
// held mid-turn — not yet visible
expect(store.state.notice).toBeNull()
expect(store.state.pendingNotice?.id).toBe('e1')
// turn ends via error (no message.complete) — held notice must flush now
store.apply({ type: 'error', payload: { message: 'boom' } })
expect(store.state.notice?.id).toBe('e1')
expect(store.state.pendingNotice).toBeNull()
})
test('gateway.exited flush: a notice held mid-turn is applied when the child exits mid-reply', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'held notice', level: 'info', key: 'credits.usage', id: 'x1' }
})
// held mid-turn — not yet visible
expect(store.state.notice).toBeNull()
expect(store.state.pendingNotice?.id).toBe('x1')
// turn ends via the child exiting mid-reply (no message.complete) — held notice must flush now
store.apply({ type: 'gateway.exited', payload: { reason: 'boom' } })
expect(store.state.notice?.id).toBe('x1')
expect(store.state.pendingNotice).toBeNull()
})
test('resume reset: commitSnapshot clears the visible notice, a pending one, and any armed timer', () => {
vi.useFakeTimers()
try {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
// visible ttl notice (idle) → armed TTL timer + state.notice set
store.apply({
type: 'notification.show',
payload: { kind: 'ttl', ttl_ms: 50, text: 'visible', level: 'info', key: 'credits.usage', id: 'rv1' }
})
expect(store.state.notice?.id).toBe('rv1')
// also hold a pending one mid-turn
store.apply({ type: 'message.start' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'held', level: 'info', key: 'credits.depleted', id: 'rp1' }
})
expect(store.state.pendingNotice?.id).toBe('rp1')
// resume replaces session context — notice + pending + timer must reset
store.commitSnapshot([])
expect(store.state.notice).toBeNull()
expect(store.state.pendingNotice).toBeNull()
// the surviving timer must not fire and null a different session's notice
vi.advanceTimersByTime(100)
expect(store.state.notice).toBeNull()
} finally {
vi.useRealTimers()
}
})
test('flash-and-yield: credits.grant_spent yields to a starting turn', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'grant spent', level: 'warn', key: 'credits.grant_spent', id: 'gs1' }
})
expect(store.state.notice?.id).toBe('gs1')
store.apply({ type: 'message.start' })
expect(store.state.notice).toBeNull()
})
test('success level: a success-level sticky notice keeps level === "success"', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'notification.show',
payload: { kind: 'sticky', text: 'grant applied', level: 'success', key: 'credits.grant', id: 'g1' }
})
expect(store.state.notice?.level).toBe('success')
})
})

View file

@ -26,6 +26,7 @@ import { AgentsTray, type AgentsTrayApi } from './agentsTray.tsx'
import { Composer } from './composer.tsx'
import { DimensionsProvider } from './dimensions.tsx'
import { Header } from './header.tsx'
import { NoticeBanner } from './noticeBanner.tsx'
import { AgentsDashboard } from './overlays/agentsDashboard.tsx'
import { BackgroundPanel } from './overlays/backgroundPanel.tsx'
import { Pager } from './overlays/pager.tsx'
@ -142,6 +143,7 @@ export function App(props: AppProps) {
borderColor={theme().color.border}
style={{ flexShrink: 0, flexDirection: 'column' }}
>
<NoticeBanner notice={props.store.state.notice} />
<StatusBar store={props.store} />
<Switch
fallback={

View file

@ -0,0 +1,41 @@
/**
* NoticeBanner the persistent chrome banner for `state.notice` (the visible
* credits/usage notice with a lifecycle, set by the store from gateway policy).
* Distinct from `NotificationCard`: the card is an inline transcript row (one per
* `notification.show`); THIS is a single sticky chrome line that sits directly
* above the status bar, mirroring the bar's left padding.
*
* The `text` is ALREADY glyphed by the gateway policy (e.g. `⚠ Credits 90% used`,
* `✕ Credit access paused`, `✓ Credit access restored`), so the banner renders it
* VERBATIM it only tints by level, never prepends another glyph. Truncates to
* the terminal width so it can never wrap and push the composer.
*/
import { Show } from 'solid-js'
import type { ActivityNotification } from '../logic/backgroundActivity.ts'
import { truncRight } from '../logic/truncate.ts'
import { useDimensions } from './dimensions.tsx'
import { useTheme } from './theme.tsx'
export function NoticeBanner(props: { notice: ActivityNotification | null }) {
const theme = useTheme()
const dims = useDimensions()
const n = () => props.notice
const levelColor = () => {
const c = theme().color
const level = n()?.level
return level === 'error' ? c.error : level === 'warn' ? c.warn : level === 'success' ? c.statusGood : c.accent
}
// Budget the text into the row so it never wraps: total paddingLeft(1) a
// right margin(1). Matches how statusBar/backgroundPanel budget chrome rows.
const text = () => truncRight(n()?.text ?? '', Math.max(8, dims().width - 2))
return (
<Show when={props.notice}>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text selectable={false}>
<span style={{ fg: levelColor() }}>{text()}</span>
</text>
</box>
</Show>
)
}