From e59b815c048110df1b2f51bd30b4ab18792b26b9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 20:26:41 -0500 Subject: [PATCH 1/7] 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 635b35db996..6c562033933 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 0823b924e7a..972b68336b8 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)} From 1d7a1c00b4408d52b8f3ff1f9bc44a5d2492afbd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 20:28:43 -0500 Subject: [PATCH 2/7] fix(tui): make busy status-bar reservation /indicator-style aware The left-content reservation used a flat constant for the busy face, but its width varies by /indicator style: kaomoji is a wide glyph plus a rotating verb, while unicode is a bare 1-col braille spinner with no verb. Reserve the real width via busyIndicatorWidth(style, hasDuration) so the model stays on-screen across styles without over-reserving the unbounded elapsed-time tail. --- ui-tui/src/__tests__/statusRule.test.ts | 17 +++++++++++- ui-tui/src/components/appChrome.tsx | 37 +++++++++++++++++++++++-- ui-tui/src/components/appLayout.tsx | 1 + 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts index 6c562033933..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 { statusBarSegments, statusRuleWidths } from '../components/appChrome.js' +import { busyIndicatorWidth, statusBarSegments, statusRuleWidths } from '../components/appChrome.js' describe('statusRuleWidths', () => { it('keeps the status rule within the terminal width', () => { @@ -103,3 +103,18 @@ describe('statusBarSegments', () => { } }) }) + +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/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 972b68336b8..dde6689f62f 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -75,6 +75,34 @@ const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender = return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false } } +const indicatorFrameWidth = (style: IndicatorStyle): number => { + if (style === 'kaomoji') { + return FACES.reduce((max, f) => Math.max(max, stringWidth(f)), 1) + } + + if (style === 'emoji') { + return EMOJI_FRAMES.reduce((max, f) => Math.max(max, stringWidth(f)), 1) + } + + // 'ascii' and 'unicode' are single-column glyphs. + return 1 +} + +// 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 a bounded clock (e.g. `59m59s`); long-running durations let the + // tail clip rather than reserving unbounded width. + const duration = hasDuration ? stringWidth(' · ') + 6 : 0 + + return indicatorFrameWidth(style) + verb + duration +} + function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) { const ui = useStore($uiState) const style = ui.indicatorStyle @@ -337,6 +365,7 @@ export function StatusRule({ model, modelFast, modelReasoningEffort, + indicatorStyle = 'kaomoji', usage, bgCount, liveSessionCount, @@ -369,9 +398,10 @@ export function StatusRule({ // 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)) + + // 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) @@ -586,6 +616,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'} From 2f171743b7ba3f898ab58589dc73a53da06bc19a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 20:32:27 -0500 Subject: [PATCH 3/7] fix(tui): pin status/model, whole-segment tail disclosure, smaller cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/appChromeStatusRule.test.tsx | 29 +++++ ui-tui/src/components/appChrome.tsx | 123 +++++++++++------- ui-tui/src/domain/paths.ts | 2 +- 3 files changed, 103 insertions(+), 51 deletions(-) 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/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index dde6689f62f..91278cd4c36 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 43c023b6ba9..90483451c83 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) } From 9cb7d40d8dbe9ced87842b6c65c4b2671d7fdecb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 20:42:04 -0500 Subject: [PATCH 4/7] fix(tui): derive busy/duration reservation width from fmtDuration fmtDuration renders a space between units (e.g. `59m 59s`), so the flat 6-col reservation under-counted and could let the elapsed-time tail shove the model off-screen / break the whole-segment budget. Reserve the bounded clock width from fmtDuration itself (MAX_DURATION_WIDTH) in both the busy indicator reservation and the tail duration budget. --- ui-tui/src/components/appChrome.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 91278cd4c36..7808b1cba2a 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -88,6 +88,15 @@ const indicatorFrameWidth = (style: IndicatorStyle): number => { 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/ @@ -96,9 +105,8 @@ const indicatorFrameWidth = (style: IndicatorStyle): number => { export const busyIndicatorWidth = (style: IndicatorStyle, hasDuration: boolean): number => { const { showVerb } = renderIndicator(style, 0) const verb = showVerb ? 1 + VERB_PAD_LEN : 0 - // ` · ` plus a bounded clock (e.g. `59m59s`); long-running durations let the - // tail clip rather than reserving unbounded width. - const duration = hasDuration ? stringWidth(' · ') + 6 : 0 + // ` · ` plus the bounded clock (e.g. `59m 59s`). + const duration = hasDuration ? stringWidth(' · ') + MAX_DURATION_WIDTH : 0 return indicatorFrameWidth(style) + verb + duration } @@ -427,7 +435,7 @@ export function StatusRule({ 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 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)) From e25b2a6e187e346c28f02ee981dc38e92a7faba1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 20:49:51 -0500 Subject: [PATCH 5/7] fix(tui): address Copilot review on status-bar tail disclosure - Render SpawnHud last in the tail so its un-budgeted (dynamic) width can only truncate itself, never push budgeted segments past leftWidth. - Precompute kaomoji/emoji frame widths once at module load instead of rescanning FACES/EMOJI_FRAMES on every status render. - Correct the tail-priority comment to match the actual fits() order (bar, duration, compressions, voice, session count, bg, cost). --- ui-tui/src/components/appChrome.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 7808b1cba2a..a42e3e0e957 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -75,13 +75,18 @@ const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender = return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false } } +// `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 FACES.reduce((max, f) => Math.max(max, stringWidth(f)), 1) + return KAOMOJI_FRAME_WIDTH } if (style === 'emoji') { - return EMOJI_FRAMES.reduce((max, f) => Math.max(max, stringWidth(f)), 1) + return EMOJI_FRAME_WIDTH } // 'ascii' and 'unicode' are single-column glyphs. @@ -416,8 +421,9 @@ export function StatusRule({ // 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. + // 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) => { @@ -499,7 +505,6 @@ export function StatusRule({ ) : null} - {showVoice ? ( ) : 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 ? ( From 899e8b9067e2d2020713465fc263409c2e217988 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 20:55:14 -0500 Subject: [PATCH 6/7] fix(tui): keep fmtCwdBranch default, cap cwd at the status-bar call site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the shared fmtCwdBranch default (28 → 40) so it isn't an API/ behavior change for other callers, and instead passes max=28 explicitly from the status-bar caller where the tighter cap is intended. --- ui-tui/src/app/useMainApp.ts | 5 ++++- ui-tui/src/domain/paths.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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/domain/paths.ts b/ui-tui/src/domain/paths.ts index 90483451c83..43c023b6ba9 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 = 28) => { +export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => { if (!branch) { return shortCwd(cwd, max) } From 13a2350c8d2d9c1777bbf372acbaaa70e4766ad8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 1 Jun 2026 21:02:32 -0500 Subject: [PATCH 7/7] fix(tui): pass indicatorStyle into FaceTicker so render matches reservation FaceTicker now takes the indicator style as a prop (same value used by busyIndicatorWidth) instead of reading the store independently, so the rendered busy indicator and its reserved width can't desync on /indicator changes. --- ui-tui/src/components/appChrome.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index a42e3e0e957..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' @@ -116,9 +115,7 @@ export const busyIndicatorWidth = (style: IndicatorStyle, hasDuration: boolean): return indicatorFrameWidth(style) + verb + duration } -function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) { - const ui = useStore($uiState) - const style = ui.indicatorStyle +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()) @@ -468,7 +465,7 @@ export function StatusRule({ {'─ '} {busy ? ( - + ) : ( {status}