fix(tui): prioritize status/model over cwd in the status bar on narrow terminals
The status rule reserved only 8 cols for the left segments, so the cwd + git-branch label on the right could grow until the loading indicator, model, and context read-out were crushed to almost nothing (sometimes collapsing to a single illegible line) on small screens. Reverse the priority: `statusRuleWidths` now reserves the display width of the must-keep left content (status indicator + model + context) so the cwd/branch segment truncates first. Add `statusBarSegments(cols)` progressive disclosure — as the terminal narrows the low-priority tail sheds in order (cost → bg → voice → compressions → duration → context bar), and below the bar breakpoint the context read-out collapses to a bare token count. Status and model are always guaranteed room. Default `minLeftContent = 0` keeps `statusRuleWidths` byte-identical for existing callers.
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { statusRuleWidths } from '../components/appChrome.js'
|
||||
import { statusBarSegments, statusRuleWidths } from '../components/appChrome.js'
|
||||
|
||||
describe('statusRuleWidths', () => {
|
||||
it('keeps the status rule within the terminal width', () => {
|
||||
@ -29,4 +29,77 @@ describe('statusRuleWidths', () => {
|
||||
expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(30)
|
||||
expect(widths.rightWidth).toBeGreaterThan('目录/分支'.length)
|
||||
})
|
||||
|
||||
it('reserves the high-priority left content so the cwd/branch yields first', () => {
|
||||
const cwd = '~/src/hermes-agent/apps/desktop (bb/tui-statusbar-responsive)'
|
||||
|
||||
const greedy = statusRuleWidths(70, cwd) // legacy behaviour: cwd hogs the row
|
||||
const reserved = statusRuleWidths(70, cwd, 40) // reserve indicator+model+ctx
|
||||
|
||||
expect(reserved.leftWidth).toBeGreaterThanOrEqual(40)
|
||||
expect(reserved.leftWidth).toBeGreaterThan(greedy.leftWidth)
|
||||
expect(reserved.rightWidth).toBeLessThan(greedy.rightWidth)
|
||||
expect(reserved.leftWidth + reserved.separatorWidth + reserved.rightWidth).toBeLessThanOrEqual(70)
|
||||
})
|
||||
|
||||
it('drops the cwd entirely when the essential left content needs the whole row', () => {
|
||||
expect(statusRuleWidths(40, '~/some/cwd (branch)', 60)).toEqual({
|
||||
leftWidth: 40,
|
||||
rightWidth: 0,
|
||||
separatorWidth: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the default (no reservation) behaviour identical for legacy callers', () => {
|
||||
const cwd = '~/src/hermes-agent/main (some-long-branch-name)'
|
||||
|
||||
expect(statusRuleWidths(80, cwd, 0)).toEqual(statusRuleWidths(80, cwd))
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusBarSegments', () => {
|
||||
it('shows every segment on a wide terminal', () => {
|
||||
const s = statusBarSegments(120)
|
||||
|
||||
expect(s).toEqual({
|
||||
compactCtx: false,
|
||||
bar: true,
|
||||
duration: true,
|
||||
compressions: true,
|
||||
voice: true,
|
||||
bg: true,
|
||||
cost: true
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses the context bar to a token count on narrow terminals', () => {
|
||||
const s = statusBarSegments(60)
|
||||
|
||||
expect(s.compactCtx).toBe(true)
|
||||
expect(s.bar).toBe(false)
|
||||
expect(s.duration).toBe(false)
|
||||
expect(s.cost).toBe(false)
|
||||
})
|
||||
|
||||
it('sheds tail segments in priority order as the terminal narrows', () => {
|
||||
// cost is the first to go, the context bar the last of the tail.
|
||||
const order: (keyof ReturnType<typeof statusBarSegments>)[] = [
|
||||
'bar',
|
||||
'duration',
|
||||
'compressions',
|
||||
'voice',
|
||||
'bg',
|
||||
'cost'
|
||||
]
|
||||
|
||||
let prevCount = Infinity
|
||||
|
||||
for (const cols of [120, 95, 87, 83, 79, 75, 71]) {
|
||||
const s = statusBarSegments(cols)
|
||||
const visible = order.filter(k => s[k]).length
|
||||
|
||||
expect(visible).toBeLessThanOrEqual(prevCount)
|
||||
prevCount = visible
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -154,10 +154,18 @@ function ctxBar(pct: number | undefined, w = 10) {
|
||||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||
}
|
||||
|
||||
export function statusRuleWidths(cols: number, cwdLabel: string) {
|
||||
// `minLeftContent` is the display width of the high-priority left segments
|
||||
// (status indicator + model + context). Reserving it makes the cwd/branch
|
||||
// segment on the right yield FIRST on narrow terminals, instead of squeezing
|
||||
// the loading indicator and model down to nothing.
|
||||
export function statusRuleWidths(cols: number, cwdLabel: string, minLeftContent = 0) {
|
||||
const width = Math.max(1, Math.floor(cols || 1))
|
||||
const desiredSeparatorWidth = width >= 24 ? 3 : 1
|
||||
const minLeftWidth = width >= 24 ? 8 : 1
|
||||
const baseMinLeft = width >= 24 ? 8 : 1
|
||||
// Never reserve more than the terminal width; never less than the historical
|
||||
// floor. With the default `minLeftContent = 0` this is identical to the old
|
||||
// behaviour, so callers that don't pass content are unaffected.
|
||||
const minLeftWidth = Math.min(width, Math.max(baseMinLeft, Math.floor(minLeftContent)))
|
||||
const maxRightWidth = Math.max(0, width - desiredSeparatorWidth - minLeftWidth)
|
||||
|
||||
if (!cwdLabel || maxRightWidth <= 0) {
|
||||
@ -171,6 +179,35 @@ export function statusRuleWidths(cols: number, cwdLabel: string) {
|
||||
return { leftWidth, rightWidth, separatorWidth }
|
||||
}
|
||||
|
||||
// Progressive disclosure for the status rule's lower-priority tail segments.
|
||||
// As the terminal narrows we shed the least important pieces first (cost →
|
||||
// bg → voice → compressions → duration → context bar), and below the bar
|
||||
// breakpoint the context read-out collapses to a bare token count. Status and
|
||||
// model are never gated here — they're guaranteed room by `statusRuleWidths`.
|
||||
export interface StatusBarSegments {
|
||||
bar: boolean
|
||||
bg: boolean
|
||||
compactCtx: boolean
|
||||
compressions: boolean
|
||||
cost: boolean
|
||||
duration: boolean
|
||||
voice: boolean
|
||||
}
|
||||
|
||||
export function statusBarSegments(cols: number): StatusBarSegments {
|
||||
const w = Math.max(1, Math.floor(cols || 1))
|
||||
|
||||
return {
|
||||
compactCtx: w < 72,
|
||||
bar: w >= 72,
|
||||
duration: w >= 76,
|
||||
compressions: w >= 80,
|
||||
voice: w >= 84,
|
||||
bg: w >= 88,
|
||||
cost: w >= 96
|
||||
}
|
||||
}
|
||||
|
||||
function SpawnHud({ t }: { t: Theme }) {
|
||||
// Tight HUD that only appears when the session is actually fanning out.
|
||||
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
||||
@ -312,15 +349,34 @@ export function StatusRule({
|
||||
}: StatusRuleProps) {
|
||||
const pct = usage.context_percent
|
||||
const barColor = ctxBarColor(pct, t)
|
||||
const segs = statusBarSegments(cols)
|
||||
|
||||
// On narrow terminals the context read-out collapses to a bare token count
|
||||
// (`12k tok`) and the visual fill bar is dropped entirely.
|
||||
const ctxLabel = usage.context_max
|
||||
? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
|
||||
? segs.compactCtx
|
||||
? `${fmtK(usage.context_used ?? 0)} tok`
|
||||
: `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
|
||||
: usage.total > 0
|
||||
? `${fmtK(usage.total)} tok`
|
||||
: ''
|
||||
|
||||
const bar = usage.context_max ? ctxBar(pct) : ''
|
||||
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel)
|
||||
const bar = segs.bar && 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 =
|
||||
stringWidth('─ ') +
|
||||
// The busy face carries a verb + elapsed-time tail; reserve enough that it
|
||||
// can't shove the model off-screen, but not the whole (growing) duration.
|
||||
(busy ? 10 : stringWidth(status)) +
|
||||
stringWidth(' │ ') +
|
||||
stringWidth(modelText) +
|
||||
(ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0)
|
||||
|
||||
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel, minLeftContent)
|
||||
const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : ''
|
||||
const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => {
|
||||
event.stopImmediatePropagation?.()
|
||||
@ -352,7 +408,7 @@ export function StatusRule({
|
||||
)}
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{modelLabel(model, modelReasoningEffort, modelFast)}
|
||||
{modelText}
|
||||
</Text>
|
||||
{ctxLabel ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
@ -366,13 +422,13 @@ export function StatusRule({
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionStartedAt ? (
|
||||
{segs.duration && sessionStartedAt ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
) : null}
|
||||
{typeof usage.compressions === 'number' && usage.compressions > 0 ? (
|
||||
{segs.compressions && typeof usage.compressions === 'number' && usage.compressions > 0 ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
<Text
|
||||
@ -383,7 +439,7 @@ export function StatusRule({
|
||||
</Text>
|
||||
) : null}
|
||||
<SpawnHud t={t} />
|
||||
{voiceLabel ? (
|
||||
{segs.voice && voiceLabel ? (
|
||||
<Text
|
||||
color={
|
||||
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.muted
|
||||
@ -395,13 +451,13 @@ export function StatusRule({
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionCountNode}
|
||||
{bgCount > 0 ? (
|
||||
{segs.bg && bgCount > 0 ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
{bgCount} bg
|
||||
</Text>
|
||||
) : null}
|
||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||
{segs.cost && showCost && typeof usage.cost_usd === 'number' ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ $'}
|
||||
{usage.cost_usd.toFixed(4)}
|
||||
|
||||
Reference in New Issue
Block a user