From eaad47a6f6826a9fde7450e2a92e0715fa97c6a9 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 21:24:58 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20header=20chrome=20=E2=80=94=20de?= =?UTF-8?q?nse=20status=20bar=20(Variant=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/boundary/schema/SessionInfo.ts | 23 +- ui-opentui/src/logic/store.ts | 25 +- ui-opentui/src/test/statusChrome.test.tsx | 239 ++++++++++++++ ui-opentui/src/view/header.tsx | 7 +- ui-opentui/src/view/statusBar.tsx | 302 +++++++++++++++--- 5 files changed, 534 insertions(+), 62 deletions(-) create mode 100644 ui-opentui/src/test/statusChrome.test.tsx diff --git a/ui-opentui/src/boundary/schema/SessionInfo.ts b/ui-opentui/src/boundary/schema/SessionInfo.ts index d67c16d13a9..20c3425ee87 100644 --- a/ui-opentui/src/boundary/schema/SessionInfo.ts +++ b/ui-opentui/src/boundary/schema/SessionInfo.ts @@ -10,11 +10,15 @@ * unset — a stray shape never crashes the reducer. * * Wire field names are verified against `tui_gateway/server.py`: - * - session.info → `_session_info()` (server.py:~1830): top-level `model`, - * `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, plus a nested - * `usage` (`_get_usage()`, server.py:~1698) carrying `context_used`, - * `context_max`, `context_percent`, `compressions` (context_* only present - * when the compressor knows a context length). + * - session.info → `_session_info()` (server.py:~1798): top-level `model`, + * `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, `profile_name`, + * `update_behind` (Optional[int] — null until the prefetched check lands), + * `update_command`, `mcp_servers` (list of {name,transport,connected,tools} + * dicts from `get_mcp_status()`), plus a nested `usage` (`_get_usage()`, + * server.py:~1683) carrying `context_used`, `context_max`, + * `context_percent`, `compressions` (context_* only present when the + * compressor knows a context length) and `cost_usd` (only when the pricing + * estimate succeeds). * - startup.catalog → `@method("startup.catalog")` (server.py:~8521): * `{ tools:{total, toolsets:[{name,count,enabled,tools}]}, * skills:{total, categories:[{name,count}]}, mcp:{servers:[]} }`. @@ -38,7 +42,8 @@ const UsageSchema = Schema.Struct({ context_used: opt(Num), context_max: opt(Num), context_percent: opt(Num), - compressions: opt(Num) + compressions: opt(Num), + cost_usd: opt(Num) }) export const SessionInfoPatchSchema = Schema.Struct({ @@ -48,6 +53,12 @@ export const SessionInfoPatchSchema = Schema.Struct({ cwd: opt(Str), branch: opt(Str), running: opt(Bool), + // status-bar chrome extras (Epic 1.3): update banner, profile badge, MCP count. + // `update_behind` is null on the wire until the async update check resolves. + update_behind: opt(Schema.NullOr(Num)), + update_command: opt(Str), + profile_name: opt(Str), + mcp_servers: opt(Schema.Array(Schema.Unknown)), // top-level context fallback (used when there's no nested `usage`) context_used: opt(Num), context_max: opt(Num), diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index aa4396b5e98..3a4ed292235 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -164,6 +164,21 @@ export interface SessionInfo { contextMax?: number contextPercent?: number compressions?: number + /** Estimated session cost in USD (`usage.cost_usd` — only when the gateway's + * pricing estimate succeeds; absent otherwise). */ + costUsd?: number + /** Commits behind the remote (`update_behind`) — null/absent until the async + * update check resolves; >0 drives the transient update notice in the bar. */ + updateBehind?: number + /** The recommended update command (`update_command`), paired with updateBehind. */ + updateCommand?: string + /** Active profile name (`profile_name`); the bar badges it when non-default. */ + profileName?: string + /** Count of configured MCP servers (`mcp_servers.length`) for the bar's `N mcp`. */ + mcpServers?: number + /** Epoch ms when this TUI session started (set once at store creation; never + * patched from the wire) — drives the status-bar session duration. */ + startedAt?: number } /** Startup catalog (tools/skills/MCP) for the home-screen panel (item 9 / banner parity). */ @@ -287,6 +302,12 @@ function infoPatchFrom(d: SessionInfoPatchDecoded): Partial { if (pct !== undefined) patch.contextPercent = pct const comp = d.usage?.compressions ?? d.compressions if (comp !== undefined) patch.compressions = comp + if (d.usage?.cost_usd !== undefined) patch.costUsd = d.usage.cost_usd + // null = "update check not resolved yet" — leave the prior value alone. + if (typeof d.update_behind === 'number') patch.updateBehind = d.update_behind + if (d.update_command) patch.updateCommand = d.update_command + if (d.profile_name) patch.profileName = d.profile_name + if (d.mcp_servers) patch.mcpServers = d.mcp_servers.length return patch } @@ -365,7 +386,9 @@ export function createSessionStore() { dashboard: false, dashboardAgent: undefined, status: undefined, - info: {}, + // startedAt is set ONCE here (store creation ≈ session start) — the status + // bar's session-duration segment ticks from it; wire patches never carry it. + info: { startedAt: Date.now() }, hint: undefined, catalog: undefined, modelItems: undefined, diff --git a/ui-opentui/src/test/statusChrome.test.tsx b/ui-opentui/src/test/statusChrome.test.tsx new file mode 100644 index 00000000000..a6192431cff --- /dev/null +++ b/ui-opentui/src/test/statusChrome.test.tsx @@ -0,0 +1,239 @@ +/** + * Epic 1.3 — header/status chrome (Variant A dense single status bar). + * Layers covered: + * 1. schema: the new SessionInfo wire fields decode (and null/absence is safe) + * 2. store: applyInfo merges the new usage/chrome fields + * 3. pure logic: statusSegments width table (priority drop order) + the + * ctx/cmp threshold levels + compact formatters + * 4. frames: the bar renders the dense segment layout with separators, drops + * tail segments when narrow, and the update notice borrows the line + */ +import { Option } from 'effect' +import { describe, expect, test } from 'vitest' + +import { decodeSessionInfoPatch } from '../boundary/schema/SessionInfo.ts' +import { createSessionStore, type SessionStore } from '../logic/store.ts' +import { cmpLevel, ctxLevel, fmtShortDuration, fmtTokens, StatusBar, statusSegments } from '../view/statusBar.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { captureFrame, renderProbe } from './lib/render.ts' + +// ── 1. schema ──────────────────────────────────────────────────────────── + +describe('SessionInfoPatchSchema — Epic 1.3 wire fields', () => { + test('decodes the new chrome fields (update/profile/mcp/cost)', () => { + const decoded = decodeSessionInfoPatch({ + model: 'anthropic/claude-opus-4-8', + update_behind: 3, + update_command: 'hermes update', + profile_name: 'researcher', + mcp_servers: [{ name: 'railway' }, { name: 'beeper' }], + usage: { context_percent: 42, context_used: 84_000, cost_usd: 0.41, compressions: 2 } + }) + expect(Option.isSome(decoded)).toBe(true) + if (Option.isSome(decoded)) { + expect(decoded.value.update_behind).toBe(3) + expect(decoded.value.update_command).toBe('hermes update') + expect(decoded.value.profile_name).toBe('researcher') + expect(decoded.value.mcp_servers).toHaveLength(2) + expect(decoded.value.usage?.cost_usd).toBe(0.41) + } + }) + + test('update_behind: null (check not resolved yet) decodes — None-safe', () => { + const decoded = decodeSessionInfoPatch({ model: 'm', update_behind: null, update_command: '' }) + expect(Option.isSome(decoded)).toBe(true) + if (Option.isSome(decoded)) expect(decoded.value.update_behind).toBeNull() + }) + + test('all new fields absent still decodes (every key optional)', () => { + expect(Option.isSome(decodeSessionInfoPatch({ model: 'm' }))).toBe(true) + }) +}) + +// ── 2. store applyInfo ─────────────────────────────────────────────────── + +describe('store.applyInfo — Epic 1.3 chrome merge', () => { + test('merges cost/update/profile/mcp into SessionInfo', () => { + const store = createSessionStore() + store.applyInfo({ + model: 'opus', + update_behind: 4, + update_command: 'uv tool upgrade hermes', + profile_name: 'researcher', + mcp_servers: [{}, {}, {}], + usage: { cost_usd: 0.4129, context_percent: 42 } + }) + expect(store.state.info.costUsd).toBeCloseTo(0.4129) + expect(store.state.info.updateBehind).toBe(4) + expect(store.state.info.updateCommand).toBe('uv tool upgrade hermes') + expect(store.state.info.profileName).toBe('researcher') + expect(store.state.info.mcpServers).toBe(3) + }) + + test('update_behind: null leaves the prior value alone (partial-patch rule)', () => { + const store = createSessionStore() + store.applyInfo({ update_behind: 2 }) + store.applyInfo({ update_behind: null }) + expect(store.state.info.updateBehind).toBe(2) + }) + + test('a usage patch with cost does not clobber unrelated chrome', () => { + const store = createSessionStore() + store.applyInfo({ model: 'opus', profile_name: 'researcher' }) + store.applyInfo({ usage: { cost_usd: 0.1 } }) + expect(store.state.info).toMatchObject({ model: 'opus', profileName: 'researcher', costUsd: 0.1 }) + }) + + test('startedAt is seeded at store creation and never patched off the wire', () => { + const before = Date.now() + const store = createSessionStore() + expect(store.state.info.startedAt).toBeGreaterThanOrEqual(before) + const seeded = store.state.info.startedAt + store.applyInfo({ model: 'opus' }) + expect(store.state.info.startedAt).toBe(seeded) + }) +}) + +// ── 3. pure logic ──────────────────────────────────────────────────────── + +describe('statusSegments — progressive disclosure table', () => { + test('full width shows everything', () => { + expect(statusSegments(220)).toEqual({ + ctxDetail: true, + duration: true, + compressions: true, + cost: true, + profile: true, + bg: true, + mcp: true + }) + }) + + test('segments drop in reverse priority as width shrinks: mcp → bg → profile → cost → duration/cmp → ctx detail', () => { + // each row: [width, expected visible flags] + const table: Array<[number, Partial>]> = [ + [115, { mcp: false, bg: true }], // mcp drops first + [107, { mcp: false, bg: false, profile: true }], // then bg + [99, { profile: false, cost: true }], // then profile + [91, { cost: false, duration: true, compressions: true }], // then cost + [79, { duration: false, compressions: false, ctxDetail: true }], // then duration/cmp + [71, { ctxDetail: false }] // finally the bar/token detail collapses to bare `42%` + ] + for (const [width, expected] of table) { + expect(statusSegments(width)).toMatchObject(expected) + } + }) + + test('pinned essentials are never gated: statusSegments only governs the tail', () => { + // even at absurdly narrow widths the table stays well-formed (booleans, no throw) + const segs = statusSegments(10) + expect(Object.values(segs).every(v => v === false)).toBe(true) + }) +}) + +describe('threshold levels (spec 50/80/95 and cmp 5/10)', () => { + test('ctxLevel boundaries', () => { + expect(ctxLevel(0)).toBe('ok') + expect(ctxLevel(49)).toBe('ok') + expect(ctxLevel(50)).toBe('warn') + expect(ctxLevel(79)).toBe('warn') + expect(ctxLevel(80)).toBe('bad') + expect(ctxLevel(94)).toBe('bad') + expect(ctxLevel(95)).toBe('critical') + expect(ctxLevel(100)).toBe('critical') + }) + + test('cmpLevel boundaries', () => { + expect(cmpLevel(0)).toBe('ok') + expect(cmpLevel(4)).toBe('ok') + expect(cmpLevel(5)).toBe('warn') + expect(cmpLevel(9)).toBe('warn') + expect(cmpLevel(10)).toBe('bad') + }) +}) + +describe('compact formatters', () => { + test('fmtTokens', () => { + expect(fmtTokens(950)).toBe('950') + expect(fmtTokens(84_321)).toBe('84k') + expect(fmtTokens(1_000_000)).toBe('1M') + expect(fmtTokens(1_250_000)).toBe('1.3M') + }) + + test('fmtShortDuration', () => { + expect(fmtShortDuration(42)).toBe('42s') + expect(fmtShortDuration(23 * 60)).toBe('23m') + expect(fmtShortDuration(65 * 60)).toBe('1h05m') + }) +}) + +// ── 4. frames ──────────────────────────────────────────────────────────── + +function seededStore(): SessionStore { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.applyInfo({ + model: 'anthropic/claude-opus-4-8', + reasoning_effort: 'high', + cwd: '/tmp/proj', + branch: 'main', + profile_name: 'researcher', + mcp_servers: [{}, {}], + usage: { context_percent: 42, context_used: 84_000, context_max: 200_000, cost_usd: 0.41, compressions: 2 } + }) + return store +} + +function bar(store: SessionStore) { + return () => ( + store.state.theme}> + + + ) +} + +describe('StatusBar frames (Variant A)', () => { + test('full width renders every segment with │ separators', async () => { + const frame = await captureFrame(bar(seededStore()), { width: 160, height: 3 }) + expect(frame).toContain('claude-opus-4-8') + expect(frame).toContain('·high') // effort suffix + expect(frame).toContain('42%') + expect(frame).toContain('84k') // token-count detail + expect(frame).toContain('░') // the meter is partially filled at 42% + expect(frame).toContain('$0.41') + expect(frame).toContain('cmp 2') + expect(frame).toContain('researcher') // profile badge + expect(frame).toContain('2 mcp') + expect(frame).toContain('/tmp/proj (main)') + expect(frame).toContain('│') + }) + + test('narrow width drops the tail (no mcp/profile/cost) and compacts the context read-out', async () => { + const frame = await captureFrame(bar(seededStore()), { width: 70, height: 3 }) + expect(frame).toContain('claude-opus-4-8') // pinned + expect(frame).toContain('42%') // pinned (compact) + expect(frame).not.toContain('█') // bar detail dropped + expect(frame).not.toContain('84k') + expect(frame).not.toContain('$0.41') + expect(frame).not.toContain('cmp 2') + expect(frame).not.toContain('researcher') + expect(frame).not.toContain('mcp') + }) + + test('update notice borrows the line and Esc dismisses it back to the normal bar', async () => { + const store = seededStore() + store.applyInfo({ update_behind: 3, update_command: 'hermes update' }) + const probe = await renderProbe(bar(store), { width: 120, height: 3, kittyKeyboard: true }) + try { + expect(probe.frame()).toContain('3 commits behind') + expect(probe.frame()).toContain('hermes update') + expect(probe.frame()).not.toContain('$0.41') // the notice replaced the segments + probe.keys.pressEscape() + await probe.settle() + const after = await probe.waitForFrame(f => f.includes('$0.41')) + expect(after).not.toContain('commits behind') + } finally { + probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/view/header.tsx b/ui-opentui/src/view/header.tsx index 249aaede3d1..1dc2cde018b 100644 --- a/ui-opentui/src/view/header.tsx +++ b/ui-opentui/src/view/header.tsx @@ -1,8 +1,9 @@ /** - * Header — the top chrome line (spec v4 §2 `view/header.tsx`). Phase 2 skeleton: + * Header — the top chrome line (spec v4 §2 `view/header.tsx`). Variant A + * (v6 Epic 1.3, signed off): the header STAYS this minimal brand line — * brand · engine · ready/connecting, fully themed (`useTheme()`, NO hardcoded - * styles — §7.5). Model / cwd / context% / cost land in Phase 5b once - * `session.info` + `Usage` are wired. + * styles — §7.5). All session chrome (model/context/cost/duration/profile/mcp/ + * cwd) lives in the dense bottom status bar (`statusBar.tsx`). */ import { Show } from 'solid-js' diff --git a/ui-opentui/src/view/statusBar.tsx b/ui-opentui/src/view/statusBar.tsx index 281ddabb37b..c7060b681dd 100644 --- a/ui-opentui/src/view/statusBar.tsx +++ b/ui-opentui/src/view/statusBar.tsx @@ -1,23 +1,110 @@ /** - * StatusBar — the persistent bottom chrome (spec §3; Ink's `appChrome.tsx` - * StatusRule, item 14). One themed row pinned below the input zone: + * StatusBar — Variant A dense single status bar (v6 Epic 1.3; signed-off design). + * The header stays the minimal brand line; EVERYTHING else lives here, one + * themed row pinned above the composer: * - * ● model ·effort ████░░░░ 42% ~/dir (branch) + * ● model ·effort │ ███░░ 42% 84k │ $0.41 · 23m · cmp 2 │ profile │ 2 mcp │ …/cwd (branch) * - * Fields are sourced from `store.state.info` (the `session.info` event + - * session.create/resume result; see store `SessionInfo`). Width-aware (Ink's - * `statusRuleWidths` progressive disclosure): the context bar drops on narrow - * terminals and the cwd is left-truncated (`…/tail`) so the row NEVER wraps or - * clips. Read-only chrome — no input handling here. + * Progressive disclosure (Ink's `statusRuleWidths` idiom): the dot+model and + * the context % are PINNED; the tail segments drop whole as columns shrink, in + * reverse priority — mcp → bg → profile → cost → duration/cmp → token/bar + * detail — and the cwd left-truncates into whatever remains. `statusSegments` + * is the pure width→visibility table (table-tested); nothing truncates + * mid-segment, so the row NEVER wraps or clips. + * + * A pending update (`info.update_behind > 0`) BORROWS the whole line as a + * transient notice (Variant A decision — no permanent transcript row); it + * dismisses on Esc or after NOTICE_TTL_MS. + * + * Parity notes (data that does not reach this TUI yet — reported, not faked): + * - `N bg` (background tasks): the OpenTUI store has no background-task + * tracking (Ink counts `prompt.background` task_ids + `background.complete` + * locally); the segment slot exists in `statusSegments` but renders nothing. + * - `display.show_cost`: Ink reads it from its `config.get` polling loop, + * which this TUI doesn't have — cost shows whenever `usage.cost_usd` is + * present instead. + * + * Read-only chrome — the only input handled is Esc-to-dismiss for the notice. */ -import { useDimensions } from './dimensions.tsx' -import { createMemo, Show } from 'solid-js' +import { useKeyboard } from '@opentui/solid' +import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-js' -import { useTheme } from './theme.tsx' import type { SessionStore } from '../logic/store.ts' +import { useDimensions } from './dimensions.tsx' +import { elapsedSeconds, useElapsedTick } from './elapsed.ts' +import { useTheme } from './theme.tsx' const HOME = process.env.HOME ?? '' -const CTX_BAR_CELLS = 8 +const CTX_BAR_CELLS = 5 +const SEP = ' │ ' +const DOT_SEP = ' · ' +/** How long the transient update notice may borrow the bar line. */ +const NOTICE_TTL_MS = 30_000 + +// ── pure, table-tested width/threshold logic ──────────────────────────── + +/** Which tail segments are visible at a given column count. Drop order as the + * terminal narrows (reverse priority, spec Epic 1.3): mcp → bg → profile → + * cost → duration/cmp → ctxDetail (bar+token count collapse to a bare `42%`). + * Dot+model and the context % are pinned and never gated here. */ +export interface StatusSegments { + /** Full `███░░ 42% 84k` read-out; false → compact bare `42%`. */ + ctxDetail: boolean + duration: boolean + compressions: boolean + cost: boolean + profile: boolean + /** Background-tasks count — reserved; no store data feeds it yet (see header). */ + bg: boolean + mcp: boolean +} + +export function statusSegments(cols: number): StatusSegments { + const w = Math.max(1, Math.floor(cols || 1)) + return { + ctxDetail: w >= 72, + duration: w >= 80, + compressions: w >= 80, + cost: w >= 92, + profile: w >= 100, + bg: w >= 108, + mcp: w >= 116 + } +} + +/** Context-pressure level for the bar/% colour (spec thresholds 50/80/95). */ +export type CtxLevel = 'ok' | 'warn' | 'bad' | 'critical' +export function ctxLevel(pct: number): CtxLevel { + if (pct >= 95) return 'critical' + if (pct >= 80) return 'bad' + if (pct >= 50) return 'warn' + return 'ok' +} + +/** Compression-count level (spec: warn ≥5, error ≥10). */ +export type CmpLevel = 'ok' | 'warn' | 'bad' +export function cmpLevel(n: number): CmpLevel { + if (n >= 10) return 'bad' + if (n >= 5) return 'warn' + return 'ok' +} + +/** Compact token count: 84321 → `84k`, 1_250_000 → `1.3M`, 950 → `950`. */ +export function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` + if (n >= 1_000) return `${Math.round(n / 1_000)}k` + return `${Math.max(0, Math.round(n))}` +} + +/** Compact session duration: 42 → `42s`, 23*60 → `23m`, 65*60 → `1h05m`. */ +export function fmtShortDuration(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)) + if (s < 60) return `${s}s` + if (s < 3600) return `${Math.floor(s / 60)}m` + return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m` +} + +// ── local formatting helpers ──────────────────────────────────────────── /** `anthropic/claude-opus-4-8` → `claude-opus-4-8`; trims the provider prefix (Ink shortModelLabel). */ function shortModel(model: string): string { @@ -46,7 +133,13 @@ function truncLeft(s: string, max: number): string { return s.length <= max ? s : '…' + s.slice(s.length - max + 1) } -/** A unicode meter: `████░░░░` filled to `pct`% over `width` cells (Ink ctxBar). */ +/** Keep the HEAD of a string, suffixing with `…` when it must be clipped. */ +function truncRight(s: string, max: number): string { + if (max <= 1) return s.length > max ? '…' : s + return s.length <= max ? s : s.slice(0, max - 1) + '…' +} + +/** A unicode meter: `███░░` filled to `pct`% over `width` cells (Ink ctxBar). */ function ctxBar(pct: number, width: number): string { const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width))) return '█'.repeat(filled) + '░'.repeat(width - filled) @@ -56,21 +149,50 @@ export function StatusBar(props: { store: SessionStore }) { const theme = useTheme() const dims = useDimensions() const info = () => props.store.state.info + const tick = useElapsedTick() - // Context-bar colour escalates with pressure (Ink ctxBarColor good→warn→bad→critical). - const ctxColor = (pct: number) => - pct >= 92 + const ctxColorOf = (pct: number) => { + const level = ctxLevel(pct) + return level === 'critical' ? theme().color.statusCritical - : pct >= 80 + : level === 'bad' ? theme().color.statusBad - : pct >= 60 + : level === 'warn' ? theme().color.statusWarn : theme().color.statusGood + } + const cmpColorOf = (n: number) => { + const level = cmpLevel(n) + return level === 'bad' ? theme().color.error : level === 'warn' ? theme().color.warn : theme().color.muted + } const dot = () => (info().running ? '◐' : props.store.state.ready ? '●' : '○') const dotColor = () => info().running ? theme().color.statusWarn : props.store.state.ready ? theme().color.statusGood : theme().color.muted + const segs = createMemo(() => statusSegments(dims().width)) + + // ── transient update notice (borrows the whole line; Esc / TTL dismisses) ── + const [dismissed, setDismissed] = createSignal(false) + const noticeText = createMemo(() => { + const behind = info().updateBehind + if (dismissed() || behind === undefined || behind <= 0) return '' + const cmd = info().updateCommand + const base = `↑ hermes is ${behind} commit${behind === 1 ? '' : 's'} behind` + return `${base}${cmd ? ` — update: ${cmd}` : ''}${SEP}Esc to dismiss` + }) + createEffect(() => { + if (!noticeText()) return + const timer = setTimeout(() => setDismissed(true), NOTICE_TTL_MS) + onCleanup(() => clearTimeout(timer)) + }) + // Dismiss-only handler: never swallows Esc from overlays/composer (they keep + // their own handlers); dismissing the notice alongside is benign. + useKeyboard(key => { + if (key.name === 'escape' && noticeText()) setDismissed(true) + }) + + // ── segment texts (each '' when hidden/absent — also feeds the width budget) ── const model = () => { const m = info().model return m ? shortModel(m) : '' @@ -78,15 +200,48 @@ export function StatusBar(props: { store: SessionStore }) { const effort = () => effortSuffix(info().effort, info().fast) const pct = () => info().contextPercent - // Progressive disclosure budget (the row is `width - 2` after the box padding). - // left = dot+space+model+effort ; the context bar shows only when there's room. - const showBar = createMemo(() => pct() !== undefined && dims().width >= 64) - const ctxText = () => { + const ctxText = createMemo(() => { const p = pct() - return showBar() && p !== undefined ? `${ctxBar(p, CTX_BAR_CELLS)} ${p}%` : '' - } + if (p === undefined) return '' + if (!segs().ctxDetail) return `${p}%` + const used = info().contextUsed + return `${ctxBar(p, CTX_BAR_CELLS)} ${p}%${used !== undefined ? ` ${fmtTokens(used)}` : ''}` + }) - // Right side: cwd (branch), left-truncated to whatever the left side leaves. + const costText = createMemo(() => { + const c = info().costUsd + return segs().cost && c !== undefined ? `$${c.toFixed(2)}` : '' + }) + const durationText = createMemo(() => { + const started = info().startedAt + if (!segs().duration || !started || !model()) return '' + tick() // re-derive once per second while shown + return fmtShortDuration(elapsedSeconds(started)) + }) + const cmpCount = () => info().compressions ?? 0 + const cmpText = createMemo(() => (segs().compressions && cmpCount() > 0 ? `cmp ${cmpCount()}` : '')) + /** cost · duration · cmp as ONE bar segment (the spec's `$0.41 · 23m · cmp 2`). */ + const meterText = createMemo(() => [costText(), durationText(), cmpText()].filter(Boolean).join(DOT_SEP)) + + const profileText = createMemo(() => { + const p = info().profileName + return segs().profile && p && p !== 'default' && p !== 'custom' ? p : '' + }) + const mcpText = createMemo(() => { + const n = info().mcpServers ?? 0 + return segs().mcp && n > 0 ? `${n} mcp` : '' + }) + + // Width budget for the right-aligned cwd: total minus box padding minus the + // plain-text width of every visible left segment (all monospace-1-col chars). + const leftLen = createMemo(() => { + let len = 1 // dot + if (model()) len += 1 + model().length + effort().length + for (const seg of [ctxText(), meterText(), profileText(), mcpText()]) { + if (seg) len += SEP.length + seg.length + } + return len + }) const cwdFull = createMemo(() => { const cwd = info().cwd const c = cwd ? shortCwd(cwd) : '' @@ -94,8 +249,10 @@ export function StatusBar(props: { store: SessionStore }) { return info().branch ? `${c} (${info().branch})` : c }) const rightText = createMemo(() => { - const leftLen = 2 + model().length + effort().length + (showBar() ? ctxText().length + 3 : 0) - const budget = dims().width - 2 - leftLen - 2 // box padding + a 2-col gap + // dims() is the TERMINAL width; the bar's row is narrower by the app shell's + // horizontal padding (2) + this box's own padding (2), and we keep a 2-col + // gap so the cwd never butts against the left segments. + const budget = dims().width - 4 - leftLen() - 2 return budget > 4 ? truncLeft(cwdFull(), budget) : '' }) @@ -109,35 +266,76 @@ export function StatusBar(props: { store: SessionStore }) { paddingRight: 1 }} > - {/* left: turn/connection dot + model + effort + context bar */} - - - {dot()} - - {` ${model()}`} - {effort()} - - - {/* a dim divider segments the bar into scannable fields (item 8). - showBar() already guarantees pct() is defined; `?? 0` only - satisfies the type and is never reached. */} - {' │ '} - {ctxBar(pct() ?? 0, CTX_BAR_CELLS)} - {` ${pct()}%`} - - - - - {/* spacer pushes the cwd to the right edge */} - - - {/* right: cwd (branch), pre-truncated so the row never wraps */} - + + {truncRight(noticeText(), Math.max(1, dims().width - 4))} + + } + > + {/* left: pinned dot+model, then the priority-ordered tail segments */} - {rightText()} + {dot()} + + {` ${model()}`} + {effort()} + + + {SEP} + {/* ctxText() truthy guarantees pct() is defined; `?? 0` only satisfies the type. */} + {ctxText()}}> + {ctxBar(pct() ?? 0, CTX_BAR_CELLS)} + {` ${pct()}%`} + + {` ${fmtTokens(info().contextUsed ?? 0)}`} + + + + + {SEP} + + {costText()} + + + {DOT_SEP} + + + {durationText()} + + + {DOT_SEP} + + + {cmpText()} + + + + {SEP} + {profileText()} + + {/* `N bg` would slot here (segs().bg) — no store data feeds it yet (see header). */} + + {SEP} + {mcpText()} + + + {/* spacer pushes the cwd to the right edge */} + + + {/* right: cwd (branch), pre-truncated so the row never wraps */} + + + + {rightText()} + + + )