diff --git a/docs/plans/opentui-usage-notice-chrome.md b/docs/plans/opentui-usage-notice-chrome.md new file mode 100644 index 00000000000..0c9b63f94bd --- /dev/null +++ b/docs/plans/opentui-usage-notice-chrome.md @@ -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 — + `` → composer `` → ``. The new banner mounts at + `App.tsx:144`, **directly above ``** (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 | 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. +- `` — 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 ``: +```tsx + + {/* new */} + + ... +``` + +**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. diff --git a/ui-opentui/src/logic/backgroundActivity.ts b/ui-opentui/src/logic/backgroundActivity.ts index 8091ea7c54b..a283a984c43 100644 --- a/ui-opentui/src/logic/backgroundActivity.ts +++ b/ui-opentui/src/logic/backgroundActivity.ts @@ -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' } /** diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index acec3305655..25d66a464aa 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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 | 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, diff --git a/ui-opentui/src/test/backgroundActivity.test.ts b/ui-opentui/src/test/backgroundActivity.test.ts index c5bef69033b..3acc507c228 100644 --- a/ui-opentui/src/test/backgroundActivity.test.ts +++ b/ui-opentui/src/test/backgroundActivity.test.ts @@ -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[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', () => { diff --git a/ui-opentui/src/test/noticeBanner.test.tsx b/ui-opentui/src/test/noticeBanner.test.tsx new file mode 100644 index 00000000000..c795c71f9b5 --- /dev/null +++ b/ui-opentui/src/test/noticeBanner.test.tsx @@ -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( + () => ( + + + + ), + { 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( + () => ( + + + + ), + { width: 60, height: 4 } + ) + expect(warn).toContain('Credits 90% used') + + const success = await captureFrame( + () => ( + + + + ), + { 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( + () => ( + + + + ), + { 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( + () => ( + + + + ), + { 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( + () => ( + + + + ), + { 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 + }) +}) diff --git a/ui-opentui/src/test/render.test.tsx b/ui-opentui/src/test/render.test.tsx index c68aa24139e..f60d2df791b 100644 --- a/ui-opentui/src/test/render.test.tsx +++ b/ui-opentui/src/test/render.test.tsx @@ -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( + () => ( + store.state.theme}> + + + ), + { 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 + }) }) diff --git a/ui-opentui/src/test/statusNotice.test.ts b/ui-opentui/src/test/statusNotice.test.ts new file mode 100644 index 00000000000..435bc18b8ef --- /dev/null +++ b/ui-opentui/src/test/statusNotice.test.ts @@ -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') + }) +}) diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index f5f74842ffe..2293b7458ae 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -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' }} > + 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 ( + + + + {text()} + + + + ) +}