mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
opentui(v6): header chrome — dense status bar (Variant A)
This commit is contained in:
parent
8c3060342f
commit
eaad47a6f6
5 changed files with 534 additions and 62 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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
|
||||
* 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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue