diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 4fb96385f4c..4e468c200a2 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/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts index 635b35db996..fcba6a96705 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 { busyIndicatorWidth, statusBarSegments, statusRuleWidths } from '../components/appChrome.js' describe('statusRuleWidths', () => { it('keeps the status rule within the terminal width', () => { @@ -29,4 +29,92 @@ 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 + } + }) +}) + +describe('busyIndicatorWidth', () => { + it('reserves a bare spinner for the verb-less unicode style', () => { + // unicode is a 1-col braille spinner with no verb; far slimmer than the + // kaomoji face which carries a wide glyph + rotating verb. + expect(busyIndicatorWidth('unicode', false)).toBeLessThan(busyIndicatorWidth('kaomoji', false)) + expect(busyIndicatorWidth('unicode', false)).toBe(1) + }) + + it('reserves room for the elapsed-time tail only when a turn is timed', () => { + for (const style of ['kaomoji', 'emoji', 'ascii', 'unicode'] as const) { + expect(busyIndicatorWidth(style, true)).toBeGreaterThan(busyIndicatorWidth(style, false)) + } + }) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 43e8a2ed628..6915c4c5f09 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1035,7 +1035,10 @@ export function useMainApp(gw: GatewayClient) { const appStatus = useMemo( () => ({ - cwdLabel: fmtCwdBranch(cwd, gitBranch), + // Cap the status-bar cwd/branch label tighter than the shared default so + // it doesn't dominate the bar; the status rule reserves the left-side + // essentials and truncates this further on narrow terminals. + cwdLabel: fmtCwdBranch(cwd, gitBranch, 28), goodVibesTick, sessionStartedAt: ui.sid ? sessionStartedAt : null, showStickyPrompt: !!stickyPrompt, diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 0823b924e7a..01f9595e62c 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -6,7 +6,6 @@ import unicodeSpinners from 'unicode-animations' import { $delegationState } from '../app/delegationStore.js' import type { IndicatorStyle } from '../app/interfaces.js' import { useTurnSelector } from '../app/turnStore.js' -import { $uiState } from '../app/uiStore.js' import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' @@ -75,9 +74,48 @@ const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender = return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false } } -function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) { - const ui = useStore($uiState) - const style = ui.indicatorStyle +// `FACES` / `EMOJI_FRAMES` are static, so measure their widest glyph once at +// module load instead of rescanning on every status render. +const KAOMOJI_FRAME_WIDTH = FACES.reduce((max, f) => Math.max(max, stringWidth(f)), 1) +const EMOJI_FRAME_WIDTH = EMOJI_FRAMES.reduce((max, f) => Math.max(max, stringWidth(f)), 1) + +const indicatorFrameWidth = (style: IndicatorStyle): number => { + if (style === 'kaomoji') { + return KAOMOJI_FRAME_WIDTH + } + + if (style === 'emoji') { + return EMOJI_FRAME_WIDTH + } + + // 'ascii' and 'unicode' are single-column glyphs. + return 1 +} + +// Bounded width of the elapsed-time clock, derived from `fmtDuration` itself so +// the reservation/budget stays consistent with what actually renders (it emits +// a space between units, e.g. `59m 59s` / `99h 59m`). Durations beyond this +// (100h+) are left to clip rather than reserving unbounded width. +export const MAX_DURATION_WIDTH = Math.max( + stringWidth(fmtDuration(59 * 60_000 + 59_000)), // "59m 59s" + stringWidth(fmtDuration(99 * 3_600_000 + 59 * 60_000)) // "99h 59m" +) + +// Display width to reserve for the busy indicator so its verb + elapsed-time +// tail can't shove the model off-screen on narrow terminals. Style-aware: +// `unicode` is a bare 1-col braille spinner with no verb, while kaomoji/emoji/ +// ascii add a fixed-width verb; any style adds a bounded elapsed-time tail. +// Mirrors FaceTicker's `frame + verbSegment + durationSegment` layout. +export const busyIndicatorWidth = (style: IndicatorStyle, hasDuration: boolean): number => { + const { showVerb } = renderIndicator(style, 0) + const verb = showVerb ? 1 + VERB_PAD_LEN : 0 + // ` · ` plus the bounded clock (e.g. `59m 59s`). + const duration = hasDuration ? stringWidth(' · ') + MAX_DURATION_WIDTH : 0 + + return indicatorFrameWidth(style) + verb + duration +} + +function FaceTicker({ color, startedAt, style }: { color: string; startedAt?: null | number; style: IndicatorStyle }) { const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length)) const [now, setNow] = useState(() => Date.now()) @@ -154,10 +192,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 +217,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. @@ -300,6 +375,7 @@ export function StatusRule({ model, modelFast, modelReasoningEffort, + indicatorStyle = 'kaomoji', usage, bgCount, liveSessionCount, @@ -312,81 +388,124 @@ 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.compactCtx && usage.context_max ? ctxBar(pct) : '' + const modelText = modelLabel(model, modelReasoningEffort, modelFast) + + // 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('─ ') + + (busy ? busyIndicatorWidth(indicatorStyle, turnStartedAt != null) : stringWidth(status)) + + stringWidth(' │ ') + + stringWidth(modelText) + + (ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0) + + 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 + // descending priority order — bar, duration, compressions, voice, session + // count, bg, cost. Lower-priority segments drop first and nothing truncates + // mid-segment, so status/model/context are never crushed. + 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 + MAX_DURATION_WIDTH) + 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} - - )} - - {' │ '} - {modelLabel(model, modelReasoningEffort, modelFast)} - - {ctxLabel ? ( + {/* Pinned essentials — never shrink, always visible. */} + + {'─ '} + {busy ? ( + + ) : ( + + {status} + + )} {' │ '} - {ctxLabel} + {modelText} - ) : null} - {bar ? ( + {ctxLabel ? ( + + {' │ '} + {ctxLabel} + + ) : null} + + {showBar ? ( {' │ '} [{bar}] {pct != null ? `${pct}%` : ''} ) : null} - {sessionStartedAt ? ( + {showDuration ? ( {' │ '} - + ) : null} - {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} - - {voiceLabel ? ( + {showVoice ? ( @@ -394,19 +513,23 @@ export function StatusRule({ {voiceLabel} ) : null} - {sessionCountNode} - {bgCount > 0 ? ( + {showSessionCount ? sessionCountNode : null} + {showBg ? ( {' │ '} {bgCount} bg ) : null} - {showCost && typeof usage.cost_usd === 'number' ? ( + {showCostSeg ? ( - {' │ $'} - {usage.cost_usd.toFixed(4)} + {' │ '} + {costText} ) : null} + {/* SpawnHud isn't part of the tail budget (its width is dynamic), so it + renders last — any overflow truncates the HUD itself rather than the + budgeted segments before it. It self-hides when no delegation runs. */} + {rightWidth > 0 ? ( @@ -530,6 +653,7 @@ interface StatusRuleProps { model: string modelFast?: boolean modelReasoningEffort?: string + indicatorStyle?: IndicatorStyle sessionStartedAt?: null | number showCost: boolean status: string diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 7f43bc11772..b036465f3a5 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -358,6 +358,7 @@ const StatusRulePane = memo(function StatusRulePane({ busy={ui.busy} cols={composer.cols} cwdLabel={status.cwdLabel} + indicatorStyle={ui.indicatorStyle} liveSessionCount={ui.liveSessionCount} model={ui.info?.model ?? ''} modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}