opentui(v2): status bar (status·model·effort·context·dir) above composer

Item 14: a persistent bottom-chrome status bar, ported from Ink's appChrome
StatusRule. Sourced from the session.info event (model / reasoning_effort /
fast / cwd / branch / running / usage.context_*) which was decoded but dropped
until now; also folded session.create/resume result.info and message.complete
usage into a new store `info` slice.

- store: SessionInfo slice + applyInfo(); session.info handler; message.start/
  complete flip `running` (the flag the Ctrl-C interrupt will read); refresh
  usage on complete.
- schema: MessageComplete.payload gains loose `usage` so it survives decode.
- view/statusBar.tsx: width-aware (Ink progressive disclosure) — context bar
  drops on narrow terminals, cwd compacts to last two segments + left-truncates
  so the row never wraps. Turn/connection dot ◐/●/○.
- App: status bar sits ABOVE the composer; a top-edge rule (border:['top'])
  visually separates the status bar + textbox input region from the transcript.
- tests: store info slice (3) + headless status-bar render (1); bumped the
  approval-prompt capture height for the taller input region. 59 pass.

Live-smoked: bar shows model·effort·context%·dir; context updates 0→4% across a
turn; running dot flips; separator divides input region from transcript.
This commit is contained in:
alt-glitch 2026-06-08 17:38:10 +00:00
parent 26f6929eb4
commit 93793b6af5
7 changed files with 347 additions and 37 deletions

View file

@ -58,7 +58,9 @@ const MessageDelta = Schema.Struct({
const MessageComplete = Schema.Struct({
type: Schema.Literal('message.complete'),
session_id: opt(Str),
payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str) }))
// `usage` carries the post-turn token/context totals → refreshes the status bar
// (item 14). Kept loose (Record) — the chrome reader narrows what it needs.
payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str), usage: opt(Schema.Record(Str, Schema.Unknown)) }))
})
// reasoning / thinking — toTaggedUnion needs ONE literal per member, so the

View file

@ -60,10 +60,14 @@ const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: stri
Effect.gen(function* () {
store.beginBuffer()
const t0 = Date.now()
const resumed = yield* gateway.request<{ messages?: unknown }>('session.resume', { cols, session_id: sid })
const resumed = yield* gateway.request<{ messages?: unknown; info?: Record<string, unknown> }>('session.resume', {
cols,
session_id: sid
})
const t1 = Date.now()
const snapshot = mapResumeHistory(resumed?.messages)
store.commitSnapshot(snapshot)
if (resumed?.info) store.applyInfo(resumed.info)
getLog().info('bootstrap', 'session resumed', {
count: snapshot.length,
hydrate_ms: Date.now() - t1,
@ -106,12 +110,16 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp
}
yield* resumeInto(gateway, store, sid, input.cols)
} else {
const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols })
const created = yield* gateway.request<{ session_id?: string; info?: Record<string, unknown> }>(
'session.create',
{ cols: input.cols }
)
sid = created?.session_id ?? gateway.sessionId()
if (!sid) {
log.warn('bootstrap', 'session.create returned no session_id')
return
}
if (created?.info) store.applyInfo(created.info)
log.info('bootstrap', 'session created', { sid })
}

View file

@ -105,6 +105,26 @@ export interface SubagentInfo {
lastTool?: string
}
/**
* Live session chrome (the status bar item 14). Sourced from the `session.info`
* event (and the `session.create`/`resume` result's `info`), refreshed whenever
* the gateway's agent/config state changes. `running` is the turn-active flag the
* Ctrl-C interrupt (item 11) reads; we also flip it locally on message.start/
* complete so the bar reacts instantly even if a `session.info` lags.
*/
export interface SessionInfo {
model?: string
effort?: string
fast?: boolean
cwd?: string
branch?: string
running?: boolean
contextUsed?: number
contextMax?: number
contextPercent?: number
compressions?: number
}
export interface StoreState {
ready: boolean
messages: Message[]
@ -126,6 +146,8 @@ export interface StoreState {
/** Transient busy indicator (the kaomoji face/verb from `thinking.delta`/`status.update`);
* shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */
status: string | undefined
/** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */
info: SessionInfo
}
const LRU_LIMIT = 1000
@ -142,6 +164,51 @@ function readNum(payload: { readonly [k: string]: unknown }, key: string): numbe
return typeof v === 'number' ? v : 0
}
/** Read an optional number (undefined when absent) — distinguishes "0" from "missing". */
function readOptNum(payload: { readonly [k: string]: unknown }, key: string): number | undefined {
const v = payload[key]
return typeof v === 'number' ? v : undefined
}
/** Read a boolean field (undefined when absent). */
function readOptBool(payload: { readonly [k: string]: unknown }, key: string): boolean | undefined {
const v = payload[key]
return typeof v === 'boolean' ? v : undefined
}
/**
* Fold a `session.info` / `session.create.info` / `session.usage` payload into a
* partial SessionInfo, reading context/usage fields from either a nested `usage`
* object or the top level (the gateway shapes vary by RPC vs event §gateway map).
*/
function readInfoPatch(payload: { readonly [k: string]: unknown }): Partial<SessionInfo> {
const usageRaw = payload['usage']
const usage: { readonly [k: string]: unknown } =
usageRaw && typeof usageRaw === 'object' ? (usageRaw as { readonly [k: string]: unknown }) : {}
const patch: Partial<SessionInfo> = {}
const model = readStr(payload, 'model')
if (model) patch.model = model
const effort = readStr(payload, 'reasoning_effort')
if (effort) patch.effort = effort
const fast = readOptBool(payload, 'fast')
if (fast !== undefined) patch.fast = fast
const cwd = readStr(payload, 'cwd')
if (cwd) patch.cwd = cwd
const branch = readStr(payload, 'branch')
if (branch) patch.branch = branch
const running = readOptBool(payload, 'running')
if (running !== undefined) patch.running = running
const used = readOptNum(usage, 'context_used') ?? readOptNum(payload, 'context_used')
if (used !== undefined) patch.contextUsed = used
const max = readOptNum(usage, 'context_max') ?? readOptNum(payload, 'context_max')
if (max !== undefined) patch.contextMax = max
const pct = readOptNum(usage, 'context_percent') ?? readOptNum(payload, 'context_percent')
if (pct !== undefined) patch.contextPercent = pct
const comp = readOptNum(usage, 'compressions') ?? readOptNum(payload, 'compressions')
if (comp !== undefined) patch.compressions = comp
return patch
}
/** The subagent status implied by an event type (an explicit payload `status` wins). */
function subagentStatusFor(type: string): string {
if (type === 'subagent.complete') return 'complete'
@ -163,7 +230,8 @@ export function createSessionStore() {
completions: undefined,
subagents: [],
dashboard: false,
status: undefined
status: undefined,
info: {}
})
// Monotonic part id (stable `key` per part so a new tool part below a streaming
@ -296,6 +364,12 @@ export function createSessionStore() {
setState('picker', undefined)
}
/** Merge a session-info patch into the chrome state (status bar — item 14). */
function applyInfo(raw: { readonly [k: string]: unknown }): void {
const patch = readInfoPatch(raw)
if (Object.keys(patch).length) setState('info', prev => ({ ...prev, ...patch }))
}
/** Set / clear the live slash-completion candidates (composer dropdown). */
function setCompletions(items: CompletionItem[]) {
setState('completions', items.length ? items : undefined)
@ -322,8 +396,12 @@ export function createSessionStore() {
case 'skin.changed':
setSkin(event.payload)
break
case 'session.info':
applyInfo(event.payload)
break
case 'message.start':
setState('status', undefined)
setState('info', prev => ({ ...prev, running: true }))
setState(
produce(draft => {
draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true })
@ -355,6 +433,9 @@ export function createSessionStore() {
})
)
setState('status', undefined)
setState('info', prev => ({ ...prev, running: false }))
// message.complete carries the latest usage/context — refresh the bar.
if (event.payload) applyInfo(event.payload)
break
// thinking.delta / status.update are the TRANSIENT busy indicator (kaomoji
// face/verb) — route them to the status line, NOT the transcript (gotcha: Ink
@ -516,6 +597,7 @@ export function createSessionStore() {
closePicker,
setCompletions,
clearCompletions,
applyInfo,
openDashboard,
closeDashboard,
hydrate,

View file

@ -100,7 +100,7 @@ describe('App render (Phase 1, themed)', () => {
<App store={store} />
</ThemeProvider>
),
{ until: 'Approval required', width: 72, height: 18 }
{ until: 'Approval required', width: 72, height: 24 }
)
expect(frame).toContain('Approval required')
@ -177,6 +177,34 @@ describe('App render (Phase 1, themed)', () => {
expect(frame).toContain('Tab complete') // dropdown hint
})
test('the status bar renders model · context% · cwd (item 14)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'session.info',
payload: {
model: 'anthropic/claude-opus-4-8',
cwd: '/tmp/proj',
branch: 'main',
usage: { context_percent: 42 }
}
})
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'claude-opus', width: 72, height: 18 }
)
expect(frame).toContain('claude-opus-4-8') // model (provider prefix trimmed)
expect(frame).toContain('42%') // context usage percent
expect(frame).toContain('/tmp/proj') // cwd
expect(frame).toContain('main') // branch
})
test('the agents dashboard renders the subagent tree and replaces the transcript', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })

View file

@ -199,6 +199,49 @@ describe('session store — subagents (Phase 5e agents dashboard)', () => {
})
})
describe('session store — session chrome / status bar (item 14)', () => {
test('session.info populates model/effort/cwd/branch and nested usage context', () => {
const store = createSessionStore()
store.apply({
type: 'session.info',
payload: {
model: 'anthropic/claude-opus-4-8',
reasoning_effort: 'high',
fast: true,
cwd: '/home/x/proj',
branch: 'main',
running: false,
usage: { context_used: 42000, context_max: 200000, context_percent: 21 }
}
})
const info = store.state.info
expect(info.model).toBe('anthropic/claude-opus-4-8')
expect(info.effort).toBe('high')
expect(info.fast).toBe(true)
expect(info.cwd).toBe('/home/x/proj')
expect(info.branch).toBe('main')
expect(info.contextPercent).toBe(21)
expect(info.contextMax).toBe(200000)
})
test('message.start sets running, message.complete clears it + refreshes usage', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
expect(store.state.info.running).toBe(true)
store.apply({ type: 'message.delta', payload: { text: 'hi' } })
store.apply({ type: 'message.complete', payload: { usage: { context_percent: 33 } } })
expect(store.state.info.running).toBe(false)
expect(store.state.info.contextPercent).toBe(33)
})
test('applyInfo merges a session.create info patch without clobbering prior fields', () => {
const store = createSessionStore()
store.applyInfo({ model: 'gpt-5.4', cwd: '/tmp' })
store.applyInfo({ branch: 'dev' }) // partial patch — model/cwd must survive
expect(store.state.info).toMatchObject({ model: 'gpt-5.4', cwd: '/tmp', branch: 'dev' })
})
})
describe('session store — resume hydrate (Phase 4b)', () => {
test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => {
const store = createSessionStore()

View file

@ -23,7 +23,9 @@ import { Pager } from './overlays/pager.tsx'
import { Picker } from './overlays/picker.tsx'
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
import { PromptOverlay } from './prompts/promptOverlay.tsx'
import { StatusBar } from './statusBar.tsx'
import { StatusLine } from './statusLine.tsx'
import { useTheme } from './theme.tsx'
import { Transcript } from './transcript.tsx'
export interface AppProps {
@ -41,6 +43,7 @@ const NOOP_RESUME = () => {}
const NO_SESSION = () => undefined
export function App(props: AppProps) {
const theme = useTheme()
const blocked = () => props.store.state.prompt !== undefined
const pager = () => props.store.state.pager
const dashboard = () => props.store.state.dashboard
@ -65,41 +68,51 @@ export function App(props: AppProps) {
fallback={
<>
<Transcript store={props.store} />
{/* transient busy face floats at the bottom of the transcript area */}
<StatusLine store={props.store} />
<Switch
fallback={
<Composer
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
onDismiss={() => props.store.clearCompletions()}
/>
}
{/* input region a top-edge rule separates the status bar + textbox from the
transcript above; the status bar sits directly ABOVE the composer (item 14). */}
<box
border={['top']}
borderColor={theme().color.border}
style={{ flexShrink: 0, flexDirection: 'column' }}
>
<Match when={blocked()}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Match>
<Match when={picker()}>
{p => (
<Picker
title={p().title}
items={p().items}
onPick={value => {
p().onPick(value)
closePicker()
}}
onClose={closePicker}
<StatusBar store={props.store} />
<Switch
fallback={
<Composer
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
onDismiss={() => props.store.clearCompletions()}
/>
)}
</Match>
</Switch>
}
>
<Match when={blocked()}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Match>
<Match when={picker()}>
{p => (
<Picker
title={p().title}
items={p().items}
onPick={value => {
p().onPick(value)
closePicker()
}}
onClose={closePicker}
/>
)}
</Match>
</Switch>
</box>
</>
}
>

View file

@ -0,0 +1,134 @@
/**
* StatusBar the persistent bottom chrome (spec §3; Ink's `appChrome.tsx`
* StatusRule, item 14). One themed row pinned below the input zone:
*
* model ·effort 42% ~/dir (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.
*/
import { useTerminalDimensions } from '@opentui/solid'
import { createMemo, Show } from 'solid-js'
import { useTheme } from './theme.tsx'
import type { SessionStore } from '../logic/store.ts'
const HOME = process.env.HOME ?? ''
const CTX_BAR_CELLS = 8
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`; trims the provider prefix (Ink shortModelLabel). */
function shortModel(model: string): string {
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model
}
/** Reasoning effort → a compact suffix; hidden for the default/medium effort. */
function effortSuffix(effort: string | undefined, fast: boolean | undefined): string {
const parts: string[] = []
if (effort && effort !== 'medium' && effort !== 'default') parts.push(effort)
if (fast) parts.push('fast')
return parts.length ? ` ·${parts.join('·')}` : ''
}
/** Abbreviate cwd with `~` for $HOME, then collapse to the last two path segments
* (`…/lively-thrush/hermes-agent`) so deep worktree paths stay readable (Ink fmtCwdBranch). */
function shortCwd(cwd: string): string {
const home = HOME && (cwd === HOME || cwd.startsWith(HOME + '/')) ? '~' + cwd.slice(HOME.length) : cwd
const segs = home.split('/').filter(Boolean)
return segs.length <= 3 ? home : '…/' + segs.slice(-2).join('/')
}
/** Keep the TAIL of a string, prefixing with `…` when it must be clipped. */
function truncLeft(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : '…' + s.slice(s.length - 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)
}
export function StatusBar(props: { store: SessionStore }) {
const theme = useTheme()
const dims = useTerminalDimensions()
const info = () => props.store.state.info
// Context-bar colour escalates with pressure (Ink ctxBarColor good→warn→bad→critical).
const ctxColor = (pct: number) =>
pct >= 92
? theme().color.statusCritical
: pct >= 80
? theme().color.statusBad
: pct >= 60
? theme().color.statusWarn
: theme().color.statusGood
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 model = () => (info().model ? shortModel(info().model!) : '')
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 = () => (showBar() ? `${ctxBar(pct()!, CTX_BAR_CELLS)} ${pct()}%` : '')
// Right side: cwd (branch), left-truncated to whatever the left side leaves.
const cwdFull = createMemo(() => {
const c = info().cwd ? shortCwd(info().cwd!) : ''
if (!c) return ''
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
return budget > 4 ? truncLeft(cwdFull(), budget) : ''
})
return (
<box
style={{
flexShrink: 0,
flexDirection: 'row',
backgroundColor: theme().color.statusBg,
paddingLeft: 1,
paddingRight: 1
}}
>
{/* left: turn/connection dot + model + effort + context bar */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text>
<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()}>
<span style={{ fg: theme().color.muted }}>{' '}</span>
<span style={{ fg: ctxColor(pct()!) }}>{ctxBar(pct()!, 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' }}>
<text>
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
</text>
</box>
</Show>
</box>
)
}