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.
This commit is contained in:
Brooklyn Nicholson 2026-06-01 20:28:43 -05:00
parent e59b815c04
commit 1d7a1c00b4
3 changed files with 51 additions and 4 deletions

View file

@ -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))
}
})
})

View file

@ -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

View file

@ -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'}