diff --git a/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts b/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts index e817544a689..8a82e50a4ee 100644 --- a/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts +++ b/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts @@ -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 diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 1b4f5463c9a..9d92217d2ec 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -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 }>('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 }>( + '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 }) } diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index ade5cd387f2..2f52982e5b0 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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 { + const usageRaw = payload['usage'] + const usage: { readonly [k: string]: unknown } = + usageRaw && typeof usageRaw === 'object' ? (usageRaw as { readonly [k: string]: unknown }) : {} + const patch: Partial = {} + 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, diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 01a02b1ebe4..0756817c1e7 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -100,7 +100,7 @@ describe('App render (Phase 1, themed)', () => { ), - { 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( + () => ( + store.state.theme}> + + + ), + { 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' }) diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 89f16375d5a..583ff099eb3 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -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() diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 4dd4468b97d..eda0cb44539 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -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={ <> + {/* transient busy face floats at the bottom of the transcript area */} - 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). */} + - - - - - {sessions => } - - - {p => ( - { - p().onPick(value) - closePicker() - }} - onClose={closePicker} + + props.store.state.completions ?? []} + onDismiss={() => props.store.clearCompletions()} /> - )} - - + } + > + + + + + {sessions => } + + + {p => ( + { + p().onPick(value) + closePicker() + }} + onClose={closePicker} + /> + )} + + + } > diff --git a/ui-tui-opentui-v2/src/view/statusBar.tsx b/ui-tui-opentui-v2/src/view/statusBar.tsx new file mode 100644 index 00000000000..fbdd9c00e5b --- /dev/null +++ b/ui-tui-opentui-v2/src/view/statusBar.tsx @@ -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 ( + + {/* left: turn/connection dot + model + effort + context bar */} + + + {dot()} + + {` ${model()}`} + {effort()} + + + {' '} + {ctxBar(pct()!, CTX_BAR_CELLS)} + {` ${pct()}%`} + + + + + {/* spacer pushes the cwd to the right edge */} + + + {/* right: cwd (branch), pre-truncated so the row never wraps */} + + + + {rightText()} + + + + + ) +}