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:
parent
1d7a1c00b4
commit
2f171743b7
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user