diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 4fb96385f..4e468c200 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -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') + }) }) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index dde6689f6..91278cd4c 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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 ? ( - - │ {sessionCountText} - - ) : ( - │ {sessionCountText} - ) - ) : null + const sessionCountNode = onSessionCountClick ? ( + + │ {sessionCountText} + + ) : ( + │ {sessionCountText} + ) return ( - - {'─ '} - - {busy ? ( - - ) : ( - - {status} - - )} - - {' │ '} - {modelText} - - {ctxLabel ? ( + {/* Pinned essentials — never shrink, always visible. */} + + {'─ '} + {busy ? ( + + ) : ( + + {status} + + )} {' │ '} - {ctxLabel} + {modelText} - ) : null} - {bar ? ( + {ctxLabel ? ( + + {' │ '} + {ctxLabel} + + ) : null} + + {showBar ? ( {' │ '} [{bar}] {pct != null ? `${pct}%` : ''} ) : null} - {segs.duration && sessionStartedAt ? ( + {showDuration ? ( {' │ '} - + ) : null} - {segs.compressions && typeof usage.compressions === 'number' && usage.compressions > 0 ? ( + {showCompressions ? ( {' │ '} - = 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted} - > - cmp {usage.compressions} + = 10 ? t.color.error : compressions >= 5 ? t.color.warn : t.color.muted}> + cmp {compressions} ) : null} - {segs.voice && voiceLabel ? ( + {showVoice ? ( @@ -480,17 +503,17 @@ export function StatusRule({ {voiceLabel} ) : null} - {sessionCountNode} - {segs.bg && bgCount > 0 ? ( + {showSessionCount ? sessionCountNode : null} + {showBg ? ( {' │ '} {bgCount} bg ) : null} - {segs.cost && showCost && typeof usage.cost_usd === 'number' ? ( + {showCostSeg ? ( - {' │ $'} - {usage.cost_usd.toFixed(4)} + {' │ '} + {costText} ) : null} diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts index 43c023b6b..90483451c 100644 --- a/ui-tui/src/domain/paths.ts +++ b/ui-tui/src/domain/paths.ts @@ -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) }