mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
f5823277dc
commit
8e3b320eb8
9 changed files with 796 additions and 5 deletions
217
docs/plans/opentui-usage-notice-chrome.md
Normal file
217
docs/plans/opentui-usage-notice-chrome.md
Normal 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.
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
116
ui-opentui/src/test/noticeBanner.test.tsx
Normal file
116
ui-opentui/src/test/noticeBanner.test.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
229
ui-opentui/src/test/statusNotice.test.ts
Normal file
229
ui-opentui/src/test/statusNotice.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
41
ui-opentui/src/view/noticeBanner.tsx
Normal file
41
ui-opentui/src/view/noticeBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue