fix(tui): pin status/model, whole-segment tail disclosure, smaller cwd

The previous reservation set the left box width but everything still
shared one flex row, so the lower-priority tail + cwd could still shrink
`ready`/model down to fragments ("re"). Pin the essentials (indicator +
model + context) in a non-shrinking group, and render the tail segments
(bar, duration, compressions, voice, session count, bg, cost) only when
the whole segment fits in the leftover space — in priority order — so
nothing truncates mid-segment and the low-value tail drops first.

Also shrink the cwd/branch label (max 40 → 28) so it stops dominating the
bar on roomy-but-not-huge terminals.
This commit is contained in:
Brooklyn Nicholson 2026-06-01 20:32:27 -05:00
parent 1d7a1c00b4
commit 2f171743b7
3 changed files with 103 additions and 51 deletions

View File

@ -81,4 +81,33 @@ describe('StatusRule session count click target', () => {
clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() }) clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() })
expect(openSwitcher).toHaveBeenCalledOnce() expect(openSwitcher).toHaveBeenCalledOnce()
}) })
it('keeps status + model and drops the low-value tail on a narrow terminal', () => {
const element = StatusRule({
bgCount: 0,
busy: false,
cols: 44,
cwdLabel: '~/src/hermes-agent/apps/desktop (bb/tui-statusbar-responsive)',
liveSessionCount: 3,
model: 'opus-4.8',
onSessionCountClick: vi.fn(),
sessionStartedAt: Date.now() - 60_000,
showCost: true,
status: 'ready',
statusColor: DEFAULT_THEME.color.ok,
t: DEFAULT_THEME,
turnStartedAt: null,
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, cost_usd: 0.5, total: 50_000 },
voiceLabel: 'voice off'
})
const rendered = textContent(element)
// Must-keep essentials survive intact …
expect(rendered).toContain('ready')
expect(rendered).toContain('opus 4.8')
// … while the low-value tail (session count, cost) is dropped, not truncated.
expect(rendered).not.toContain('3 sessions')
expect(rendered).not.toContain('$0.5000')
})
}) })

View File

@ -390,89 +390,112 @@ export function StatusRule({
? `${fmtK(usage.total)} tok` ? `${fmtK(usage.total)} tok`
: '' : ''
const bar = segs.bar && usage.context_max ? ctxBar(pct) : '' const bar = !segs.compactCtx && usage.context_max ? ctxBar(pct) : ''
const modelText = modelLabel(model, modelReasoningEffort, modelFast) const modelText = modelLabel(model, modelReasoningEffort, modelFast)
// Reserve room for the must-keep left segments (indicator + model + context) // Width of the must-keep left segments (indicator + model + context). They
// so the cwd/branch on the right truncates before they do. The busy face can // are pinned (never shrink) and reserved so the cwd/branch on the right
// grow with its verb/duration tail, but only the glyph itself is essential. // yields first. The busy face width depends on the active /indicator style
const minLeftContent = // (kaomoji is wide + verb; unicode is a bare 1-col spinner).
const essentialWidth =
stringWidth('─ ') + stringWidth('─ ') +
// The busy face width depends on the active /indicator style (kaomoji is
// wide with a verb; unicode is a bare 1-col spinner) — reserve accordingly
// so the model survives, without reserving the unbounded duration tail.
(busy ? busyIndicatorWidth(indicatorStyle, turnStartedAt != null) : stringWidth(status)) + (busy ? busyIndicatorWidth(indicatorStyle, turnStartedAt != null) : stringWidth(status)) +
stringWidth(' │ ') + stringWidth(' │ ') +
stringWidth(modelText) + stringWidth(modelText) +
(ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0) (ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0)
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel, minLeftContent) const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel, essentialWidth)
// Whole-segment progressive disclosure for the tail: a segment renders only
// if it fits in the space left after the pinned essentials, evaluated in
// priority order. No mid-segment truncation, and the low-value tail (incl.
// the session count) drops first instead of crushing status/model/context.
const SEP = stringWidth(' │ ')
let tailBudget = Math.max(0, leftWidth - essentialWidth)
const fits = (w: number) => {
if (tailBudget >= w) {
tailBudget -= w
return true
}
return false
}
const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : '' const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : ''
const compressions = typeof usage.compressions === 'number' ? usage.compressions : 0
const costText = typeof usage.cost_usd === 'number' ? `$${usage.cost_usd.toFixed(4)}` : ''
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + 6)
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
const showCostSeg = segs.cost && showCost && !!costText && fits(SEP + stringWidth(costText))
const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => { const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => {
event.stopImmediatePropagation?.() event.stopImmediatePropagation?.()
onSessionCountClick?.() onSessionCountClick?.()
} }
const sessionCountNode = sessionCountText ? ( const sessionCountNode = onSessionCountClick ? (
onSessionCountClick ? ( <Box flexShrink={0} onClick={handleSessionCountClick}>
<Box flexShrink={0} onClick={handleSessionCountClick}> <Text color={t.color.accent}> {sessionCountText}</Text>
<Text color={t.color.accent}> {sessionCountText}</Text> </Box>
</Box> ) : (
) : ( <Text color={t.color.muted}> {sessionCountText}</Text>
<Text color={t.color.muted}> {sessionCountText}</Text> )
)
) : null
return ( return (
<Box height={1}> <Box height={1}>
<Box flexDirection="row" flexShrink={1} overflow="hidden" width={leftWidth}> <Box flexDirection="row" flexShrink={1} overflow="hidden" width={leftWidth}>
<Text color={t.color.border} wrap="truncate-end"> {/* Pinned essentials — never shrink, always visible. */}
{'─ '} <Box flexDirection="row" flexShrink={0}>
</Text> <Text color={t.color.border}>{'─ '}</Text>
{busy ? ( {busy ? (
<FaceTicker color={statusColor} startedAt={turnStartedAt} /> <FaceTicker color={statusColor} startedAt={turnStartedAt} />
) : ( ) : (
<Text color={statusColor} wrap="truncate-end"> <Text color={statusColor} wrap="truncate-end">
{status} {status}
</Text> </Text>
)} )}
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{modelText}
</Text>
{ctxLabel ? (
<Text color={t.color.muted} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{' │ '} {' │ '}
{ctxLabel} {modelText}
</Text> </Text>
) : null} {ctxLabel ? (
{bar ? ( <Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{ctxLabel}
</Text>
) : null}
</Box>
{showBar ? (
<Text color={t.color.muted} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{' │ '} {' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text> <Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
</Text> </Text>
) : null} ) : null}
{segs.duration && sessionStartedAt ? ( {showDuration ? (
<Text color={t.color.muted} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{' │ '} {' │ '}
<SessionDuration startedAt={sessionStartedAt} /> <SessionDuration startedAt={sessionStartedAt!} />
</Text> </Text>
) : null} ) : null}
{segs.compressions && typeof usage.compressions === 'number' && usage.compressions > 0 ? ( {showCompressions ? (
<Text color={t.color.muted} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{' │ '} {' │ '}
<Text <Text color={compressions >= 10 ? t.color.error : compressions >= 5 ? t.color.warn : t.color.muted}>
color={usage.compressions >= 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted} cmp {compressions}
>
cmp {usage.compressions}
</Text> </Text>
</Text> </Text>
) : null} ) : null}
<SpawnHud t={t} /> <SpawnHud t={t} />
{segs.voice && voiceLabel ? ( {showVoice ? (
<Text <Text
color={ color={
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.muted voiceLabel!.startsWith('●') ? t.color.error : voiceLabel!.startsWith('◉') ? t.color.warn : t.color.muted
} }
wrap="truncate-end" wrap="truncate-end"
> >
@ -480,17 +503,17 @@ export function StatusRule({
{voiceLabel} {voiceLabel}
</Text> </Text>
) : null} ) : null}
{sessionCountNode} {showSessionCount ? sessionCountNode : null}
{segs.bg && bgCount > 0 ? ( {showBg ? (
<Text color={t.color.muted} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{' │ '} {' │ '}
{bgCount} bg {bgCount} bg
</Text> </Text>
) : null} ) : null}
{segs.cost && showCost && typeof usage.cost_usd === 'number' ? ( {showCostSeg ? (
<Text color={t.color.muted} wrap="truncate-end"> <Text color={t.color.muted} wrap="truncate-end">
{' │ $'} {' │ '}
{usage.cost_usd.toFixed(4)} {costText}
</Text> </Text>
) : null} ) : null}
</Box> </Box>

View File

@ -5,7 +5,7 @@ export const shortCwd = (cwd: string, max = 28) => {
return p.length <= max ? p : `${p.slice(-(max - 1))}` return p.length <= max ? p : `${p.slice(-(max - 1))}`
} }
export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => { export const fmtCwdBranch = (cwd: string, branch: null | string, max = 28) => {
if (!branch) { if (!branch) {
return shortCwd(cwd, max) return shortCwd(cwd, max)
} }