mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
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:
parent
26f6929eb4
commit
93793b6af5
7 changed files with 347 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
134
ui-tui-opentui-v2/src/view/statusBar.tsx
Normal file
134
ui-tui-opentui-v2/src/view/statusBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue