From e59b815c048110df1b2f51bd30b4ab18792b26b9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 20:26:41 -0500 Subject: [PATCH] fix(tui): prioritize status/model over cwd in the status bar on narrow terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/__tests__/statusRule.test.ts | 75 +++++++++++++++++++++++- ui-tui/src/components/appChrome.tsx | 78 +++++++++++++++++++++---- 2 files changed, 141 insertions(+), 12 deletions(-) diff --git a/ui-tui/src/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts index 635b35db9..6c5620339 100644 --- a/ui-tui/src/__tests__/statusRule.test.ts +++ b/ui-tui/src/__tests__/statusRule.test.ts @@ -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)[] = [ + '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 + } + }) }) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 0823b924e..972b68336 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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({ )} {' │ '} - {modelLabel(model, modelReasoningEffort, modelFast)} + {modelText} {ctxLabel ? ( @@ -366,13 +422,13 @@ export function StatusRule({ [{bar}] {pct != null ? `${pct}%` : ''} ) : null} - {sessionStartedAt ? ( + {segs.duration && sessionStartedAt ? ( {' │ '} ) : null} - {typeof usage.compressions === 'number' && usage.compressions > 0 ? ( + {segs.compressions && typeof usage.compressions === 'number' && usage.compressions > 0 ? ( {' │ '} ) : null} - {voiceLabel ? ( + {segs.voice && voiceLabel ? ( ) : null} {sessionCountNode} - {bgCount > 0 ? ( + {segs.bg && bgCount > 0 ? ( {' │ '} {bgCount} bg ) : null} - {showCost && typeof usage.cost_usd === 'number' ? ( + {segs.cost && showCost && typeof usage.cost_usd === 'number' ? ( {' │ $'} {usage.cost_usd.toFixed(4)}