Promote strictness in ui-tui-opentui-v2:
- eslint: @typescript-eslint/no-non-null-assertion: error (with a test
override block keeping `!` in *.test.ts/tsx fixtures), and promote
unused-imports/no-unused-vars warn → error.
- tsconfig: add noUnusedLocals + noImplicitReturns.
Remove all 24 production `!` non-null assertions by replacing each with
a real guard / default / early-return, preserving rendered behavior:
- gateway/client.ts: read the pending entry once and guard (vs has()+get()!).
- logic/theme.ts: guard the parseHex regex match; `?? 0` on the always-
in-bounds XTERM_6_LEVELS lookups; restructure backgroundLuminance to
branch into a typed tuple and use charAt() for the 3-digit hex expand.
- view/homeHint.tsx: use Solid's <Show>{value => …} callback form to
narrow info().model / info().cwd instead of `!`.
- view/reasoningPart.tsx: guard the regex match before slicing m[0].
- view/statusBar.tsx: read model/cwd/pct into locals + guard; `?? 0` on
the showBar()-guarded context-bar percentage.
- view/toolPart.tsx: guard the single-arg entry; `?? 0` on the
duration-guarded fmtDuration.
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
/**
|
|
* 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 { useDimensions } from './dimensions.tsx'
|
|
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 = useDimensions()
|
|
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 = () => {
|
|
const m = info().model
|
|
return m ? shortModel(m) : ''
|
|
}
|
|
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 p = pct()
|
|
return showBar() && p !== undefined ? `${ctxBar(p, CTX_BAR_CELLS)} ${p}%` : ''
|
|
}
|
|
|
|
// Right side: cwd (branch), left-truncated to whatever the left side leaves.
|
|
const cwdFull = createMemo(() => {
|
|
const cwd = info().cwd
|
|
const c = cwd ? shortCwd(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 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()}>
|
|
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
|
<text selectable={false}>
|
|
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
|
|
</text>
|
|
</box>
|
|
</Show>
|
|
</box>
|
|
)
|
|
}
|