opentui(v6): header chrome — dense status bar (Variant A)

This commit is contained in:
alt-glitch 2026-06-10 21:24:58 +05:30
parent 8c3060342f
commit eaad47a6f6
5 changed files with 534 additions and 62 deletions

View file

@ -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),

View file

@ -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<SessionInfo> {
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,

View file

@ -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<ReturnType<typeof statusSegments>>]> = [
[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 () => (
<ThemeProvider theme={() => store.state.theme}>
<StatusBar store={store} />
</ThemeProvider>
)
}
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()
}
})
})

View file

@ -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'

View file

@ -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 widthvisibility 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 */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<Show when={showBar()}>
{/* 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. */}
<span style={{ fg: theme().color.border }}>{' │ '}</span>
<span style={{ fg: ctxColor(pct() ?? 0) }}>{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
</Show>
</text>
</box>
{/* spacer pushes the cwd to the right edge */}
<box style={{ flexGrow: 1, minWidth: 0 }} />
{/* right: cwd (branch), pre-truncated so the row never wraps */}
<Show when={rightText()}>
<Show
when={!noticeText()}
fallback={
// the update notice borrows the WHOLE line (Variant A) — warn-tinted,
// head-truncated so the Esc hint clips last only on absurd widths.
<text selectable={false}>
<span style={{ fg: theme().color.warn }}>{truncRight(noticeText(), Math.max(1, dims().width - 4))}</span>
</text>
}
>
{/* left: pinned dot+model, then the priority-ordered tail segments */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<Show when={ctxText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
{/* ctxText() truthy guarantees pct() is defined; `?? 0` only satisfies the type. */}
<Show when={segs().ctxDetail} fallback={<span style={{ fg: ctxColorOf(pct() ?? 0) }}>{ctxText()}</span>}>
<span style={{ fg: ctxColorOf(pct() ?? 0) }}>{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
<Show when={info().contextUsed !== undefined}>
<span style={{ fg: theme().color.muted }}>{` ${fmtTokens(info().contextUsed ?? 0)}`}</span>
</Show>
</Show>
</Show>
<Show when={meterText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<Show when={costText()}>
<span style={{ fg: theme().color.muted }}>{costText()}</span>
</Show>
<Show when={costText() && durationText()}>
<span style={{ fg: theme().color.muted }}>{DOT_SEP}</span>
</Show>
<Show when={durationText()}>
<span style={{ fg: theme().color.muted }}>{durationText()}</span>
</Show>
<Show when={(costText() || durationText()) && cmpText()}>
<span style={{ fg: theme().color.muted }}>{DOT_SEP}</span>
</Show>
<Show when={cmpText()}>
<span style={{ fg: cmpColorOf(cmpCount()) }}>{cmpText()}</span>
</Show>
</Show>
<Show when={profileText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<span style={{ fg: theme().color.accent }}>{profileText()}</span>
</Show>
{/* `N bg` would slot here (segs().bg) — no store data feeds it yet (see header). */}
<Show when={mcpText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<span style={{ fg: theme().color.muted }}>{mcpText()}</span>
</Show>
</text>
</box>
{/* spacer pushes the cwd to the right edge */}
<box style={{ flexGrow: 1, minWidth: 0 }} />
{/* right: cwd (branch), pre-truncated so the row never wraps */}
<Show when={rightText()}>
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
</text>
</box>
</Show>
</Show>
</box>
)