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:
@ -81,4 +81,33 @@ describe('StatusRule session count click target', () => {
|
||||
clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() })
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -390,89 +390,112 @@ export function StatusRule({
|
||||
? `${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)
|
||||
|
||||
// Reserve room for the must-keep left segments (indicator + model + context)
|
||||
// so the cwd/branch on the right truncates before they do. The busy face can
|
||||
// grow with its verb/duration tail, but only the glyph itself is essential.
|
||||
const minLeftContent =
|
||||
// Width of the must-keep left segments (indicator + model + context). They
|
||||
// are pinned (never shrink) and reserved so the cwd/branch on the right
|
||||
// yields first. The busy face width depends on the active /indicator style
|
||||
// (kaomoji is wide + verb; unicode is a bare 1-col spinner).
|
||||
const essentialWidth =
|
||||
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)) +
|
||||
stringWidth(' │ ') +
|
||||
stringWidth(modelText) +
|
||||
(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 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 }) => {
|
||||
event.stopImmediatePropagation?.()
|
||||
onSessionCountClick?.()
|
||||
}
|
||||
|
||||
const sessionCountNode = sessionCountText ? (
|
||||
onSessionCountClick ? (
|
||||
<Box flexShrink={0} onClick={handleSessionCountClick}>
|
||||
<Text color={t.color.accent}> │ {sessionCountText}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text color={t.color.muted}> │ {sessionCountText}</Text>
|
||||
)
|
||||
) : null
|
||||
const sessionCountNode = onSessionCountClick ? (
|
||||
<Box flexShrink={0} onClick={handleSessionCountClick}>
|
||||
<Text color={t.color.accent}> │ {sessionCountText}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text color={t.color.muted}> │ {sessionCountText}</Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Box flexDirection="row" flexShrink={1} overflow="hidden" width={leftWidth}>
|
||||
<Text color={t.color.border} wrap="truncate-end">
|
||||
{'─ '}
|
||||
</Text>
|
||||
{busy ? (
|
||||
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
|
||||
) : (
|
||||
<Text color={statusColor} wrap="truncate-end">
|
||||
{status}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{modelText}
|
||||
</Text>
|
||||
{ctxLabel ? (
|
||||
{/* Pinned essentials — never shrink, always visible. */}
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text color={t.color.border}>{'─ '}</Text>
|
||||
{busy ? (
|
||||
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
|
||||
) : (
|
||||
<Text color={statusColor} wrap="truncate-end">
|
||||
{status}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{ctxLabel}
|
||||
{modelText}
|
||||
</Text>
|
||||
) : null}
|
||||
{bar ? (
|
||||
{ctxLabel ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{ctxLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
{showBar ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{segs.duration && sessionStartedAt ? (
|
||||
{showDuration ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
<SessionDuration startedAt={sessionStartedAt!} />
|
||||
</Text>
|
||||
) : null}
|
||||
{segs.compressions && typeof usage.compressions === 'number' && usage.compressions > 0 ? (
|
||||
{showCompressions ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
<Text
|
||||
color={usage.compressions >= 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted}
|
||||
>
|
||||
cmp {usage.compressions}
|
||||
<Text color={compressions >= 10 ? t.color.error : compressions >= 5 ? t.color.warn : t.color.muted}>
|
||||
cmp {compressions}
|
||||
</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
<SpawnHud t={t} />
|
||||
{segs.voice && voiceLabel ? (
|
||||
{showVoice ? (
|
||||
<Text
|
||||
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"
|
||||
>
|
||||
@ -480,17 +503,17 @@ export function StatusRule({
|
||||
{voiceLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionCountNode}
|
||||
{segs.bg && bgCount > 0 ? (
|
||||
{showSessionCount ? sessionCountNode : null}
|
||||
{showBg ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{bgCount} bg
|
||||
</Text>
|
||||
) : null}
|
||||
{segs.cost && showCost && typeof usage.cost_usd === 'number' ? (
|
||||
{showCostSeg ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ $'}
|
||||
{usage.cost_usd.toFixed(4)}
|
||||
{' │ '}
|
||||
{costText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@ -5,7 +5,7 @@ export const shortCwd = (cwd: string, max = 28) => {
|
||||
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) {
|
||||
return shortCwd(cwd, max)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user