opentui(v6): header chrome — dense status bar (Variant A)
This commit is contained in:
parent
8c3060342f
commit
eaad47a6f6
@ -10,11 +10,15 @@
|
|||||||
* unset — a stray shape never crashes the reducer.
|
* unset — a stray shape never crashes the reducer.
|
||||||
*
|
*
|
||||||
* Wire field names are verified against `tui_gateway/server.py`:
|
* Wire field names are verified against `tui_gateway/server.py`:
|
||||||
* - session.info → `_session_info()` (server.py:~1830): top-level `model`,
|
* - session.info → `_session_info()` (server.py:~1798): top-level `model`,
|
||||||
* `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, plus a nested
|
* `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, `profile_name`,
|
||||||
* `usage` (`_get_usage()`, server.py:~1698) carrying `context_used`,
|
* `update_behind` (Optional[int] — null until the prefetched check lands),
|
||||||
* `context_max`, `context_percent`, `compressions` (context_* only present
|
* `update_command`, `mcp_servers` (list of {name,transport,connected,tools}
|
||||||
* when the compressor knows a context length).
|
* 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):
|
* - startup.catalog → `@method("startup.catalog")` (server.py:~8521):
|
||||||
* `{ tools:{total, toolsets:[{name,count,enabled,tools}]},
|
* `{ tools:{total, toolsets:[{name,count,enabled,tools}]},
|
||||||
* skills:{total, categories:[{name,count}]}, mcp:{servers:[]} }`.
|
* skills:{total, categories:[{name,count}]}, mcp:{servers:[]} }`.
|
||||||
@ -38,7 +42,8 @@ const UsageSchema = Schema.Struct({
|
|||||||
context_used: opt(Num),
|
context_used: opt(Num),
|
||||||
context_max: opt(Num),
|
context_max: opt(Num),
|
||||||
context_percent: opt(Num),
|
context_percent: opt(Num),
|
||||||
compressions: opt(Num)
|
compressions: opt(Num),
|
||||||
|
cost_usd: opt(Num)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const SessionInfoPatchSchema = Schema.Struct({
|
export const SessionInfoPatchSchema = Schema.Struct({
|
||||||
@ -48,6 +53,12 @@ export const SessionInfoPatchSchema = Schema.Struct({
|
|||||||
cwd: opt(Str),
|
cwd: opt(Str),
|
||||||
branch: opt(Str),
|
branch: opt(Str),
|
||||||
running: opt(Bool),
|
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`)
|
// top-level context fallback (used when there's no nested `usage`)
|
||||||
context_used: opt(Num),
|
context_used: opt(Num),
|
||||||
context_max: opt(Num),
|
context_max: opt(Num),
|
||||||
|
|||||||
@ -164,6 +164,21 @@ export interface SessionInfo {
|
|||||||
contextMax?: number
|
contextMax?: number
|
||||||
contextPercent?: number
|
contextPercent?: number
|
||||||
compressions?: 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). */
|
/** 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
|
if (pct !== undefined) patch.contextPercent = pct
|
||||||
const comp = d.usage?.compressions ?? d.compressions
|
const comp = d.usage?.compressions ?? d.compressions
|
||||||
if (comp !== undefined) patch.compressions = comp
|
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
|
return patch
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,7 +386,9 @@ export function createSessionStore() {
|
|||||||
dashboard: false,
|
dashboard: false,
|
||||||
dashboardAgent: undefined,
|
dashboardAgent: undefined,
|
||||||
status: 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,
|
hint: undefined,
|
||||||
catalog: undefined,
|
catalog: undefined,
|
||||||
modelItems: undefined,
|
modelItems: undefined,
|
||||||
|
|||||||
239
ui-opentui/src/test/statusChrome.test.tsx
Normal file
239
ui-opentui/src/test/statusChrome.test.tsx
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
* brand · engine · ready/connecting, fully themed (`useTheme()`, NO hardcoded
|
||||||
* styles — §7.5). Model / cwd / context% / cost land in Phase 5b once
|
* styles — §7.5). All session chrome (model/context/cost/duration/profile/mcp/
|
||||||
* `session.info` + `Usage` are wired.
|
* cwd) lives in the dense bottom status bar (`statusBar.tsx`).
|
||||||
*/
|
*/
|
||||||
import { Show } from 'solid-js'
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,110 @@
|
|||||||
/**
|
/**
|
||||||
* StatusBar — the persistent bottom chrome (spec §3; Ink's `appChrome.tsx`
|
* StatusBar — Variant A dense single status bar (v6 Epic 1.3; signed-off design).
|
||||||
* StatusRule, item 14). One themed row pinned below the input zone:
|
* 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 +
|
* Progressive disclosure (Ink's `statusRuleWidths` idiom): the dot+model and
|
||||||
* session.create/resume result; see store `SessionInfo`). Width-aware (Ink's
|
* the context % are PINNED; the tail segments drop whole as columns shrink, in
|
||||||
* `statusRuleWidths` progressive disclosure): the context bar drops on narrow
|
* reverse priority — mcp → bg → profile → cost → duration/cmp → token/bar
|
||||||
* terminals and the cwd is left-truncated (`…/tail`) so the row NEVER wraps or
|
* detail — and the cwd left-truncates into whatever remains. `statusSegments`
|
||||||
* clips. Read-only chrome — no input handling here.
|
* 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 { useKeyboard } from '@opentui/solid'
|
||||||
import { createMemo, Show } from 'solid-js'
|
import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-js'
|
||||||
|
|
||||||
import { useTheme } from './theme.tsx'
|
|
||||||
import type { SessionStore } from '../logic/store.ts'
|
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 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). */
|
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`; trims the provider prefix (Ink shortModelLabel). */
|
||||||
function shortModel(model: string): string {
|
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)
|
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 {
|
function ctxBar(pct: number, width: number): string {
|
||||||
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
|
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
|
||||||
return '█'.repeat(filled) + '░'.repeat(width - filled)
|
return '█'.repeat(filled) + '░'.repeat(width - filled)
|
||||||
@ -56,21 +149,50 @@ export function StatusBar(props: { store: SessionStore }) {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const dims = useDimensions()
|
const dims = useDimensions()
|
||||||
const info = () => props.store.state.info
|
const info = () => props.store.state.info
|
||||||
|
const tick = useElapsedTick()
|
||||||
|
|
||||||
// Context-bar colour escalates with pressure (Ink ctxBarColor good→warn→bad→critical).
|
const ctxColorOf = (pct: number) => {
|
||||||
const ctxColor = (pct: number) =>
|
const level = ctxLevel(pct)
|
||||||
pct >= 92
|
return level === 'critical'
|
||||||
? theme().color.statusCritical
|
? theme().color.statusCritical
|
||||||
: pct >= 80
|
: level === 'bad'
|
||||||
? theme().color.statusBad
|
? theme().color.statusBad
|
||||||
: pct >= 60
|
: level === 'warn'
|
||||||
? theme().color.statusWarn
|
? theme().color.statusWarn
|
||||||
: theme().color.statusGood
|
: 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 dot = () => (info().running ? '◐' : props.store.state.ready ? '●' : '○')
|
||||||
const dotColor = () =>
|
const dotColor = () =>
|
||||||
info().running ? theme().color.statusWarn : props.store.state.ready ? theme().color.statusGood : theme().color.muted
|
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 model = () => {
|
||||||
const m = info().model
|
const m = info().model
|
||||||
return m ? shortModel(m) : ''
|
return m ? shortModel(m) : ''
|
||||||
@ -78,15 +200,48 @@ export function StatusBar(props: { store: SessionStore }) {
|
|||||||
const effort = () => effortSuffix(info().effort, info().fast)
|
const effort = () => effortSuffix(info().effort, info().fast)
|
||||||
const pct = () => info().contextPercent
|
const pct = () => info().contextPercent
|
||||||
|
|
||||||
// Progressive disclosure budget (the row is `width - 2` after the box padding).
|
const ctxText = createMemo(() => {
|
||||||
// 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 p = pct()
|
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 cwdFull = createMemo(() => {
|
||||||
const cwd = info().cwd
|
const cwd = info().cwd
|
||||||
const c = cwd ? shortCwd(cwd) : ''
|
const c = cwd ? shortCwd(cwd) : ''
|
||||||
@ -94,8 +249,10 @@ export function StatusBar(props: { store: SessionStore }) {
|
|||||||
return info().branch ? `${c} (${info().branch})` : c
|
return info().branch ? `${c} (${info().branch})` : c
|
||||||
})
|
})
|
||||||
const rightText = createMemo(() => {
|
const rightText = createMemo(() => {
|
||||||
const leftLen = 2 + model().length + effort().length + (showBar() ? ctxText().length + 3 : 0)
|
// dims() is the TERMINAL width; the bar's row is narrower by the app shell's
|
||||||
const budget = dims().width - 2 - leftLen - 2 // box padding + a 2-col gap
|
// 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) : ''
|
return budget > 4 ? truncLeft(cwdFull(), budget) : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -109,35 +266,76 @@ export function StatusBar(props: { store: SessionStore }) {
|
|||||||
paddingRight: 1
|
paddingRight: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* left: turn/connection dot + model + effort + context bar */}
|
<Show
|
||||||
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
when={!noticeText()}
|
||||||
<text selectable={false}>
|
fallback={
|
||||||
<span style={{ fg: dotColor() }}>{dot()}</span>
|
// the update notice borrows the WHOLE line (Variant A) — warn-tinted,
|
||||||
<Show when={model()}>
|
// head-truncated so the Esc hint clips last only on absurd widths.
|
||||||
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
|
<text selectable={false}>
|
||||||
<span style={{ fg: theme().color.muted }}>{effort()}</span>
|
<span style={{ fg: theme().color.warn }}>{truncRight(noticeText(), Math.max(1, dims().width - 4))}</span>
|
||||||
</Show>
|
</text>
|
||||||
<Show when={showBar()}>
|
}
|
||||||
{/* a dim divider segments the bar into scannable fields (item 8).
|
>
|
||||||
showBar() already guarantees pct() is defined; `?? 0` only
|
{/* left: pinned dot+model, then the priority-ordered tail segments */}
|
||||||
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()}>
|
|
||||||
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
||||||
<text selectable={false}>
|
<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>
|
</text>
|
||||||
</box>
|
</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>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user