fix(ui): keep SpinnerModeGlyph visible inside status parens (#2047)

* fix(ui): keep SpinnerModeGlyph visible inside status parens

Always render the ↑/↓ mode glyph for leader spins so early requesting
and thinking-only phases are not blank, and place it as the first
status part inside the parentheses next to other activity cues.

Closes #2033

* fix(ui): preserve narrow-terminal thinking with mode glyph

Restore a second-chance width gate for leader thinking-only status under
the new inside-parens glyph layout, and suppress the mode glyph when the
row cannot fit minimal status chrome.

* fix(ui): prefer bare thinking over glyph-only on narrow rows

When leader thinking-only cannot fit glyph+thinking chrome, fall back to
bare (thinking) instead of empty mode-glyph status. Tighten glyph residual
budget to account for GlimmerMessage trailing space.

* fix(ui): budget glimmer space in bare thinking fallback

Bare leader thinking-only residual must reserve the GlimmerMessage
trailing space so equality-width terminals do not overflow by one column.

* fix(ui): nest teammate bare thinking under reduced motion

Apply the bareThinkingOnly nested (thinking) wrap in both shimmer and
dimColor branches so teammate thinking-only status keeps parentheses
when reduced motion disables the shimmer arm.

* test(ui): assert exact SpinnerAnimationRow status rows

Fix TS1355 from invalid null as const in baseProps and replace partial
toContain/regex checks with full ANSI-stripped row equality for the
glyph placement regressions CodeRabbit requested.

* fix(ui): prefer status content over empty mode-glyph chrome

When reserving the SpinnerModeGlyph would drop tokens/timer from the
status row, drop the glyph instead. Keep thinking full-chrome recovery,
default unknown modes to down-arrow, and tighten exact-row tests for
typecheck plus CodeRabbit feedback.

* fix(ui): preserve spinner tokens when glyph crowds status

* fix(ui): suppress empty glyph chrome and preserve token recovery

Skip glyph-only status when numeric thinkingStatus cannot fit on narrow terminals, and refuse glyph-free recovery that would swap visible tokens for a timer-only layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): tighten glyph recovery for duration and timer bands

Exclude numeric post-thinking duration from glyph-only status (including requesting), prefer streaming tokens over duration when both cannot fit, and recover timer+token rows at the col-30 boundary.

* fix(ui): harden SpinnerAnimationRow glyph recovery priorities

Prefer tokens over timer/duration on mid-narrow rows, recover exact-fit
token columns, keep full effort text when glyph chrome fits, and prefer
active thinking over timer-only status. Tests now use production
Thinking… message width and frozen-clock exact row assertions.

* fix(ui): address CodeRabbit SpinnerAnimationRow recovery nits

Document token-over-thinking tie-break and > vs >= bare-pass split, drop
redundant suppressModeGlyph assignments on token-only fallbacks, and make
exact-row tests use PROD_MESSAGE plus an explicit padded mode-glyph helper.

* fix(ui): drop overflowing spinner suffix for tokens/thinking

Recover mid-narrow status when stop-hook/tool suffixes overflow bare
chrome, prefer live tokens over duration after the drop, and cover long
production verb column bands plus suffix regressions.

* fix(ui): recover status after SpinnerAnimationRow suffix overflow

Re-gate tokens when thinkingStatus is null after dropping a crowding
suffix, restore teammate nested thinking on the same path, and drop the
mode glyph before truncating a suffix that still fits under bare parens.

* fix(ui): complete SpinnerAnimationRow suffix and glyph recovery

Restore timer symmetrically after suffix drop, drop crowding suffixes
when preferTokens would overflow, keep already-visible thinking when
tokens unlock, and prefer tokens over a bare-fitting suffix that cannot
share the row.

* fix(ui): harden SpinnerAnimationRow recovery against wrap cliffs

Budget timer co-restore against all visible parts, re-gate tokens onto
timer-only rows after tokens-over-suffix, prefer thinking over a crowding
bare-fit suffix, and restore the mode glyph only after a suffix-keep
cascade.

* fix(ui): close SpinnerAnimationRow mid-narrow recovery cliffs

Prefer tokens over thinking when they cannot share bare chrome, tighten
thinking-over-suffix exact-fit to avoid a one-column suffix cliff, and
restore the mode glyph whenever recovered leader content fits.

* test(ui): cover SpinnerAnimationRow cliff and glyph-restore cases

* fix(ui): restore tokens beside thinking after suffix recovery

* fix(ui): close SpinnerAnimationRow suffix recovery cliffs

Budget timer and rendered thinking width in suffix-fit predicates so
widening does not drop the elapsed timer or streaming tokens. Co-restore
teammate tokens when thinking crowds timer-only rows, clear the mode glyph
when thinking+token recovery cannot fit glyph chrome, and budget full
effort text before keeping a stop-hook suffix.

* refactor(ui): collapse redundant SpinnerAnimationRow suffix-fit branches

Rely on the combined all-visible suffix budget check instead of
duplicate tokens-only paths. Keeps timer+thinking and no-token
thinking fallbacks unchanged.

* fix(ui): re-gate recovered spinner glyph

* test(ui): tighten spinner layout coverage

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
JATMN
2026-07-29 09:40:32 +08:00
committed by GitHub
co-authored by Cursor
parent 2fe1e1b148
commit 10a9190bea
2 changed files with 1477 additions and 117 deletions
File diff suppressed because it is too large Load Diff
+449 -50
View File
@@ -169,46 +169,438 @@ export function SpinnerAnimationRow({
// === Progressive width gating ===
// messageWidth = glimmer text + spinner glyph (width: 2).
// parensWidth = 3 accounts for " (" and ")" wrapping the status parts.
// The extra 1 is a safety margin so content never touches the right edge.
// Base parens chrome is 4: glimmer trailing space + "(" + ")" + safety.
// Leader spins may also reserve mode glyph (width 2) + separator.
// Physical bare chrome (no safety) is 3 — used for exact-fit recovery so
// higher-value status (tokens/thinking) appears as soon as it actually fits.
const messageWidth = glimmerMessageWidth + 2;
const sep = SEP_WIDTH;
// Non-teammate spins prepend the ↑/↓ mode glyph (width 2) + separator to
// the status parts, so reserve that space in the gating math too.
const parensWidth = hasRunningTeammates ? 4 : 4 + 2 + SEP_WIDTH;
const suffixWidth = spinnerSuffix ? stringWidth(spinnerSuffix) + sep : 0;
const MODE_GLYPH_RESERVE = 2 + SEP_WIDTH;
const BASE_PARENS_WIDTH = 4;
const PHYSICAL_BARE_PARENS = 3;
// Full glyph+thinking chrome budget includes +1 safety vs physical Ink width.
const leaderThinkingOnlyChrome = 2 + 2 + SEP_WIDTH + 1;
const suffixTextWidth = spinnerSuffix ? stringWidth(spinnerSuffix) : 0;
const suffixWidth = spinnerSuffix ? suffixTextWidth + sep : 0;
const wantsThinking = thinkingStatus !== null;
const wantsTimer = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TIMER_AFTER_MS;
const wantsTokens = true;
const availableSpace = columns - messageWidth - parensWidth;
let showThinking = wantsThinking && availableSpace > suffixWidth + thinkingWidthValue;
const fullThinkingText = thinkingText;
const fullThinkingWidth = thinkingWidthValue;
// Prefer layout with the mode glyph when it fits alongside other status.
let reserveModeGlyph = !hasRunningTeammates;
let showSuffix = Boolean(spinnerSuffix);
let effectiveSuffixWidth = showSuffix ? suffixWidth : 0;
let parensWidth = BASE_PARENS_WIDTH + (reserveModeGlyph ? MODE_GLYPH_RESERVE : 0);
let availableSpace = columns - messageWidth - parensWidth;
// Try full effort text against the looser leader glyph chrome before shrinking,
// so "(↓ · thinking with high effort)" wins when it physically fits.
let showThinking = wantsThinking && availableSpace > effectiveSuffixWidth + thinkingWidthValue;
if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && !hasRunningTeammates && !showSuffix && reserveModeGlyph) {
const leaderThinkingAvailable = columns - messageWidth - leaderThinkingOnlyChrome;
if (leaderThinkingAvailable >= fullThinkingWidth) {
showThinking = true;
}
}
if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && effortSuffix) {
if (availableSpace > suffixWidth + THINKING_BARE_WIDTH) {
if (availableSpace > effectiveSuffixWidth + THINKING_BARE_WIDTH) {
thinkingText = 'thinking';
thinkingWidthValue = THINKING_BARE_WIDTH;
showThinking = true;
}
}
const usedAfterThinking = suffixWidth + (showThinking ? thinkingWidthValue + sep : 0);
const showTimer = wantsTimer && availableSpace > usedAfterThinking + timerWidth;
const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0);
const showTokens = wantsTokens && hasTokenContent && availableSpace > usedAfterTimer + tokensWidth;
// Second chance for narrow terminals: the gating above reserves space for
// the mode glyph + separator, but a would-be thinking-only spin renders
// neither the glyph nor the wrapping parens beyond "( )". When nothing
// else will show, re-try the thinking gate with that space returned so
// "(thinking)" appears instead of nothing.
if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && !hasRunningTeammates && !spinnerSuffix && !showTimer && !showTokens) {
const bareAvailable = columns - messageWidth - 2;
if (bareAvailable > thinkingWidthValue) {
showThinking = true;
} else if (effortSuffix && bareAvailable > THINKING_BARE_WIDTH) {
thinkingText = 'thinking';
thinkingWidthValue = THINKING_BARE_WIDTH;
showThinking = true;
let usedAfterThinking = effectiveSuffixWidth + (showThinking ? thinkingWidthValue + sep : 0);
let showTimer = wantsTimer && availableSpace > usedAfterThinking + timerWidth;
let usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0);
let showTokens = wantsTokens && hasTokenContent && availableSpace > usedAfterTimer + tokensWidth;
// Re-check without the glyph. Keep it when it fits alongside the same
// content, but prefer higher-value status when reserving the glyph hides it.
// Use physical bare chrome for recovery so exact-fit columns are not rejected
// by the +1 safety margin that primary gating already applied.
if (reserveModeGlyph) {
const bareAvailableSpace = columns - messageWidth - PHYSICAL_BARE_PARENS;
let bareThinkingText = fullThinkingText;
let bareThinkingWidth = fullThinkingWidth;
// Thinking/timer keep `>` so they leave one safety column; token checks use
// `>=` so live token counts may consume the exact physical bare budget.
let bareShowThinking = wantsThinking && bareAvailableSpace > effectiveSuffixWidth + bareThinkingWidth;
if (!bareShowThinking && wantsThinking && thinkingStatus === 'thinking' && effortSuffix) {
if (bareAvailableSpace > effectiveSuffixWidth + THINKING_BARE_WIDTH) {
bareThinkingText = 'thinking';
bareThinkingWidth = THINKING_BARE_WIDTH;
bareShowThinking = true;
}
}
const bareUsedAfterThinking = effectiveSuffixWidth + (bareShowThinking ? bareThinkingWidth + sep : 0);
const bareShowTimer = wantsTimer && bareAvailableSpace > bareUsedAfterThinking + timerWidth;
const bareUsedAfterTimer = bareUsedAfterThinking + (bareShowTimer ? timerWidth + sep : 0);
const bareShowTokens = wantsTokens && hasTokenContent && bareAvailableSpace >= bareUsedAfterTimer + tokensWidth;
const tokensFitBareAlone = wantsTokens && hasTokenContent && bareAvailableSpace >= tokensWidth;
const timerAndTokensFitBare = wantsTimer && hasTokenContent && bareAvailableSpace >= timerWidth + sep + tokensWidth;
// Thinking-only recovery stays in the dedicated second-chance block below
// so full "(↓ · thinking)" chrome still wins when it fits.
const glyphHidesThinking = bareShowThinking && !showThinking;
const glyphHidesTimer = bareShowTimer && !showTimer;
const glyphHidesTokens = bareShowTokens && !showTokens;
// Timer under glyph with no tokens and thinking not already visible: prefer
// tokens alone (or timer+tokens) over timer-only / glyph+timer chrome.
// Do not enter this shortcut when thinking is already on-screen — the else
// bare layout can keep thinking+timer+tokens together when they fit.
const preferTokensOverTimerOnly = Boolean(showTimer && !showTokens && !showThinking && tokensFitBareAlone);
// Active thinking hidden behind glyph+timer: prefer thinking (drop timer if needed).
const preferThinkingOverTimerOnly = Boolean(showTimer && !showThinking && bareShowThinking && thinkingStatus === 'thinking');
// Do not swap already-visible tokens for a timer that only fits without the glyph,
// unless we are explicitly choosing tokens-over-timer-only below.
const glyphRecoveryLosesTokens = showTokens && !bareShowTokens && !preferTokensOverTimerOnly;
if ((glyphHidesThinking || glyphHidesTimer || glyphHidesTokens || preferTokensOverTimerOnly || preferThinkingOverTimerOnly) && !glyphRecoveryLosesTokens) {
reserveModeGlyph = false;
parensWidth = BASE_PARENS_WIDTH;
availableSpace = columns - messageWidth - BASE_PARENS_WIDTH;
if (preferTokensOverTimerOnly) {
// Tie-break: when tokens and thinking both unlock by dropping the glyph
// (preferTokensOverTimerOnly && preferThinkingOverTimerOnly), tokens win.
// Keep tokens; include timer only when both fit without the glyph.
// If a suffix is still reserved and cannot share the row with tokens
// (and timer when both fit), drop it — otherwise Ink wraps/truncates.
const timerTokensWidth = timerAndTokensFitBare ? timerWidth + sep + tokensWidth : tokensWidth;
if (showSuffix && bareAvailableSpace < effectiveSuffixWidth + timerTokensWidth) {
showSuffix = false;
effectiveSuffixWidth = 0;
}
showThinking = false;
showTimer = Boolean(wantsTimer && (showSuffix ? bareAvailableSpace >= effectiveSuffixWidth + timerWidth + sep + tokensWidth : timerAndTokensFitBare));
showTokens = true;
thinkingText = fullThinkingText;
thinkingWidthValue = fullThinkingWidth;
usedAfterThinking = effectiveSuffixWidth;
usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0);
} else if (preferThinkingOverTimerOnly && !bareShowTimer) {
// Thinking fits bare; timer does not fit alongside — drop timer.
thinkingText = bareThinkingText ?? fullThinkingText;
thinkingWidthValue = bareThinkingWidth;
showThinking = true;
showTimer = false;
showTokens = false;
usedAfterThinking = effectiveSuffixWidth + thinkingWidthValue + sep;
usedAfterTimer = usedAfterThinking;
} else {
thinkingText = bareThinkingText ?? fullThinkingText;
thinkingWidthValue = bareThinkingWidth;
showThinking = bareShowThinking;
usedAfterThinking = bareUsedAfterThinking;
showTimer = bareShowTimer;
usedAfterTimer = bareUsedAfterTimer;
showTokens = bareShowTokens;
}
}
}
const thinkingOnly = showThinking && thinkingStatus === 'thinking' && !spinnerSuffix && !showTimer && !showTokens && true;
// Drop an overflowing spinner suffix before other recovery so mid-narrow rows
// can keep tokens/thinking instead of wrapping glyph+suffix-only chrome.
// Budget must include mode-glyph chrome when reserved — comparing suffix text
// alone against bare parens leaves glyph+suffix rows that Ink truncates mid-string.
// Prefer dropping the glyph to keep a suffix that still fits under bare parens.
const physicalBareBudget = columns - messageWidth - PHYSICAL_BARE_PARENS;
if (showSuffix) {
const suffixFitsBare = physicalBareBudget >= suffixTextWidth;
const suffixFitsWithGlyph = reserveModeGlyph ? physicalBareBudget - MODE_GLYPH_RESERVE >= suffixTextWidth : suffixFitsBare;
if (!suffixFitsWithGlyph) {
if (reserveModeGlyph && suffixFitsBare) {
reserveModeGlyph = false;
parensWidth = BASE_PARENS_WIDTH;
availableSpace = columns - messageWidth - BASE_PARENS_WIDTH;
} else {
showSuffix = false;
effectiveSuffixWidth = 0;
}
}
}
// Prefer live streaming tokens (and active thinking) over a bare-fitting
// suffix when both cannot share the row. Avoids non-monotonic cliffs where
// widening from overflow-drop to exact suffix-fit swaps higher-value status
// for suffix-only chrome.
if (showSuffix) {
// Budget companions at the widths actually rendered (or about to be).
let thinkingWidthForSuffix = 0;
if (showThinking || wantsThinking) {
if (typeof thinkingStatus === 'number') {
thinkingWidthForSuffix = fullThinkingWidth;
} else if (thinkingStatus === 'thinking') {
// Effort text may be restored after this pass — budget full width so
// suffix is not kept when only bare "thinking" fit in the estimate.
thinkingWidthForSuffix = effortSuffix
? Math.max(thinkingWidthValue, fullThinkingWidth)
: thinkingWidthValue;
}
}
const timerWantedVisible = showTimer || wantsTimer;
let suffixWithAllVisibleFit = suffixTextWidth;
if (timerWantedVisible) suffixWithAllVisibleFit += sep + timerWidth;
if (hasTokenContent) suffixWithAllVisibleFit += sep + tokensWidth;
if (wantsThinking && (showThinking || thinkingWidthForSuffix > 0)) {
suffixWithAllVisibleFit += sep + thinkingWidthForSuffix;
}
// Use `>` (not `>=`) so exact-fit suffix+thinking does not keep the suffix
// while glyph chrome still cannot show thinking — avoids a one-column cliff.
const thinkingWithSuffixFit =
!wantsThinking ||
physicalBareBudget > suffixTextWidth + sep + thinkingWidthForSuffix;
const suffixWithTimerAndThinkingFit =
!(timerWantedVisible && wantsThinking) ||
physicalBareBudget >
suffixTextWidth + sep + timerWidth + sep + thinkingWidthForSuffix;
if (hasTokenContent && physicalBareBudget >= tokensWidth && physicalBareBudget < suffixWithAllVisibleFit) {
showSuffix = false;
effectiveSuffixWidth = 0;
} else if (
timerWantedVisible &&
wantsThinking &&
physicalBareBudget >= timerWidth + sep + thinkingWidthForSuffix &&
!suffixWithTimerAndThinkingFit
) {
// Timer+thinking fit without suffix; keep them and drop suffix until all three fit.
showSuffix = false;
effectiveSuffixWidth = 0;
} else if (
!hasTokenContent &&
wantsThinking &&
physicalBareBudget >= thinkingWidthForSuffix &&
!thinkingWithSuffixFit
) {
showSuffix = false;
effectiveSuffixWidth = 0;
}
}
// After dropping a crowding suffix (or preferring tokens/thinking over it),
// recover status primary gating hid while suffix width was reserved.
// Parts order is timer · tokens · thinking — any co-restore must budget all
// already-visible parts or Ink wraps on wide timers / early token counts.
if (!showSuffix) {
const tokensFitBareAlone = wantsTokens && hasTokenContent && physicalBareBudget >= tokensWidth;
const timerFitBareAlone = wantsTimer && physicalBareBudget > timerWidth;
const timerAndTokensFitBare = wantsTimer && hasTokenContent && physicalBareBudget >= timerWidth + sep + tokensWidth;
const withGlyphSpace = columns - messageWidth - BASE_PARENS_WIDTH - MODE_GLYPH_RESERVE;
if (!showTokens && !showTimer && !showThinking) {
if (tokensFitBareAlone) {
showTokens = true;
showTimer = Boolean(timerAndTokensFitBare);
usedAfterThinking = 0;
usedAfterTimer = showTimer ? timerWidth + sep : 0;
const recoveredWidth = usedAfterTimer + tokensWidth;
if (reserveModeGlyph && !(withGlyphSpace > recoveredWidth)) {
reserveModeGlyph = false;
}
} else if (!wantsThinking && timerFitBareAlone) {
showTimer = true;
usedAfterThinking = 0;
usedAfterTimer = timerWidth + sep;
if (reserveModeGlyph && !(withGlyphSpace > timerWidth)) {
reserveModeGlyph = false;
}
}
} else if (showTimer && !showTokens && tokensFitBareAlone) {
// Suffix drop freed budget that primary spent on suffix+timer only.
const thinkingAndTokensFit =
showThinking && physicalBareBudget >= thinkingWidthValue + sep + tokensWidth;
const timerThinkingTokensFit =
showThinking &&
physicalBareBudget >= timerWidth + sep + thinkingWidthValue + sep + tokensWidth;
if (showThinking && timerThinkingTokensFit) {
showTokens = true;
usedAfterTimer = timerWidth + sep + tokensWidth;
} else if (thinkingAndTokensFit) {
// Prefer tokens + thinking over timer + thinking when timer crowds tokens.
showTimer = false;
showTokens = true;
usedAfterThinking = thinkingWidthValue + sep;
usedAfterTimer = 0;
} else if (
timerAndTokensFitBare &&
physicalBareBudget >= timerWidth + sep + tokensWidth + (showThinking ? sep + thinkingWidthValue : 0)
) {
showTokens = true;
usedAfterTimer = timerWidth + sep + tokensWidth;
} else if (!showThinking) {
// Prefer tokens over timer-only when both cannot share the row.
showTimer = false;
showTokens = true;
usedAfterThinking = 0;
usedAfterTimer = 0;
}
} else if (showThinking && !showTokens && tokensFitBareAlone) {
// Thinking recovered after suffix drop — co-restore tokens when they fit.
let total = thinkingWidthValue + sep + tokensWidth;
if (showTimer) total += timerWidth + sep;
if (physicalBareBudget >= total) {
showTokens = true;
if (reserveModeGlyph && !(withGlyphSpace > total)) {
reserveModeGlyph = false;
}
}
} else if (showTokens && !showTimer && wantsTimer) {
let total = timerWidth + sep + tokensWidth;
if (showThinking) total += sep + thinkingWidthValue;
if (physicalBareBudget >= total) {
if (reserveModeGlyph && !(withGlyphSpace > total)) {
reserveModeGlyph = false;
}
showTimer = true;
usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0;
usedAfterTimer = timerWidth + sep + tokensWidth;
}
}
}
// Prefer live streaming tokens over numeric post-thinking duration when both
// cannot fit (including when primary already chose duration under the glyph,
// or when a dropped/crowding suffix hid both). Keep suffix only when it still
// fits beside tokens.
let suppressModeGlyphForBareThinking = false;
if (typeof thinkingStatus === 'number' && hasTokenContent && !showTokens) {
const tokensFitBareAlone = physicalBareBudget >= tokensWidth;
const thinkingAndTokensFit = physicalBareBudget >= fullThinkingWidth + sep + tokensWidth;
const durationShowingWithoutTokens = showThinking && !thinkingAndTokensFit;
const nothingVisibleYet = !showThinking && !showTimer;
if (tokensFitBareAlone && !thinkingAndTokensFit && (durationShowingWithoutTokens || nothingVisibleYet)) {
showThinking = false;
showTokens = true;
showTimer = false;
// reserveModeGlyph=false is enough to keep canShowModeGlyph false.
reserveModeGlyph = false;
if (showSuffix && physicalBareBudget < suffixWidth + tokensWidth) {
showSuffix = false;
effectiveSuffixWidth = 0;
}
usedAfterThinking = effectiveSuffixWidth;
usedAfterTimer = usedAfterThinking;
}
}
// Thinking-only prefers the mode glyph inside outer parens for leaders
// ("(↓ · thinking)") when both fit. When full chrome does not fit, fall
// back to bare "(thinking)" without the glyph (same band as pre-glyph
// layout) rather than empty glyph-only status. Teammates skip glyph chrome
// and recover nested "(thinking)" here after suffix overflow drop so
// stop-hook/tool suffixes do not permanently hide thinking.
// Allow replacing timer-only chrome when thinking fits (alone or with timer).
if (!showThinking && wantsThinking && !showSuffix && !showTokens) {
let recoveredThinking = false;
if (reserveModeGlyph && !hasRunningTeammates) {
const leaderThinkingAvailable = columns - messageWidth - leaderThinkingOnlyChrome;
if (leaderThinkingAvailable >= fullThinkingWidth) {
thinkingText = fullThinkingText;
thinkingWidthValue = fullThinkingWidth;
showThinking = true;
recoveredThinking = true;
} else if (thinkingStatus === 'thinking' && effortSuffix && leaderThinkingAvailable >= THINKING_BARE_WIDTH) {
thinkingText = 'thinking';
thinkingWidthValue = THINKING_BARE_WIDTH;
showThinking = true;
recoveredThinking = true;
}
}
if (!showThinking) {
// Bare chrome: glimmer trailing space + "(" + ")" (matches physical row).
// Teammates nest these parens on the thinking word via bareThinkingOnly.
const bareAvailable = physicalBareBudget;
const tokensFitBareAlone = wantsTokens && hasTokenContent && bareAvailable >= tokensWidth;
const thinkingAndTokensFitBare = bareAvailable >= fullThinkingWidth + sep + tokensWidth;
if (typeof thinkingStatus === 'number' && tokensFitBareAlone && !thinkingAndTokensFitBare && !showTokens) {
showTokens = true;
showTimer = false;
reserveModeGlyph = false;
} else if (bareAvailable >= fullThinkingWidth) {
thinkingText = fullThinkingText;
thinkingWidthValue = fullThinkingWidth;
showThinking = true;
suppressModeGlyphForBareThinking = true;
reserveModeGlyph = false;
recoveredThinking = true;
} else if (thinkingStatus === 'thinking' && effortSuffix && bareAvailable >= THINKING_BARE_WIDTH) {
thinkingText = 'thinking';
thinkingWidthValue = THINKING_BARE_WIDTH;
showThinking = true;
suppressModeGlyphForBareThinking = true;
reserveModeGlyph = false;
recoveredThinking = true;
}
}
// If thinking recovered onto a timer-only row, keep timer only when both fit.
if (recoveredThinking && showTimer) {
const need = timerWidth + sep + thinkingWidthValue;
if (physicalBareBudget < need) {
showTimer = false;
}
}
}
// After thinking recovery from a dropped suffix, co-restore a fitting timer
// only when current glyph chrome (or bare, if glyph already dropped) has room —
// matching the primary `availableSpace > … + timerWidth` gate so we do not
// strip an already-correct glyph+thinking row or wrap wide timers onto tokens.
if (!showSuffix && showThinking && !showTimer && wantsTimer) {
const space = columns - messageWidth - BASE_PARENS_WIDTH - (reserveModeGlyph ? MODE_GLYPH_RESERVE : 0);
const usedBeforeTimer = (showTokens ? tokensWidth + sep : 0) + thinkingWidthValue + sep;
if (space > usedBeforeTimer + timerWidth) {
showTimer = true;
usedAfterThinking = thinkingWidthValue + sep;
usedAfterTimer = timerWidth + sep + (showTokens ? tokensWidth : 0);
}
}
// Prefer live tokens over active thinking when both cannot share bare chrome
// (same tie-break as preferTokensOverTimerOnly). Prevents a widen cliff where
// tokens visible at cols 2526 disappear once leader thinking unlocks at 27.
if (showThinking && !showTokens && thinkingStatus === 'thinking' && hasTokenContent && physicalBareBudget >= tokensWidth) {
const thinkingAndTokensFit = physicalBareBudget >= thinkingWidthValue + sep + tokensWidth;
if (!thinkingAndTokensFit) {
showThinking = false;
showTokens = true;
showTimer = Boolean(wantsTimer && physicalBareBudget >= timerWidth + sep + tokensWidth);
suppressModeGlyphForBareThinking = false;
reserveModeGlyph = false;
usedAfterThinking = 0;
usedAfterTimer = showTimer ? timerWidth + sep : 0;
}
}
// If thinking could not claim an empty post-suffix row, recover timer-only
// rather than leaving blank / empty glyph-only chrome.
if (!showSuffix && !showThinking && !showTimer && !showTokens && wantsTimer && physicalBareBudget > timerWidth) {
showTimer = true;
const withGlyphSpace = columns - messageWidth - BASE_PARENS_WIDTH - MODE_GLYPH_RESERVE;
if (reserveModeGlyph && !(withGlyphSpace > timerWidth)) {
reserveModeGlyph = false;
}
}
// Token recovery can restore content using bare chrome, so a glyph reserved
// earlier may no longer fit once tokens are visible. Thinking-only layouts
// have their own looser dedicated chrome budget above.
const modeGlyphAvailableSpace = columns - messageWidth - BASE_PARENS_WIDTH - MODE_GLYPH_RESERVE;
let modeGlyphContentWidth = 0;
if (showTimer) modeGlyphContentWidth += timerWidth;
if (showTokens) modeGlyphContentWidth += (modeGlyphContentWidth > 0 ? sep : 0) + tokensWidth;
if (showThinking) modeGlyphContentWidth += (modeGlyphContentWidth > 0 ? sep : 0) + thinkingWidthValue;
if (reserveModeGlyph && showTokens && !(modeGlyphAvailableSpace > modeGlyphContentWidth)) {
reserveModeGlyph = false;
}
// Restore mode glyph whenever leader content fits under glyph chrome —
// covers suffix-keep cascades and bare recovery that cleared the glyph.
if (!showSuffix && !hasRunningTeammates && !suppressModeGlyphForBareThinking && !reserveModeGlyph && (showTokens || showTimer || showThinking)) {
if (modeGlyphAvailableSpace > modeGlyphContentWidth) {
reserveModeGlyph = true;
}
}
// Nested "(thinking)" is reserved for teammate spins that skip the mode glyph
// and therefore have no outer status parens. Leader bare fallback still uses
// outer parens with just the thinking word: "(thinking)".
const isThinkingOnlyStatus = showThinking && thinkingStatus === 'thinking' && !showSuffix && !showTimer && !showTokens;
const bareThinkingOnly = isThinkingOnlyStatus && hasRunningTeammates;
// Minimum residual for leader status chrome after the glimmer trailing space:
// " " + "(" + glyph Box(width=2) + ")". Below this, omit the glyph rather
// than overflow. Also omit when content recovery or bare-thinking fallback
// dropped the glyph so higher-value status can show.
const residualForStatus = columns - messageWidth;
const hasVisibleStatusContent = Boolean(showSuffix) || showTimer || showTokens || showThinking;
// Requesting and active "thinking" may show glyph-only chrome; completed-thought
// duration and other states must not render an empty "(↓ )" row.
const allowGlyphOnlyStatus = (mode === 'requesting' || thinkingStatus === 'thinking') && typeof thinkingStatus !== 'number';
const canShowModeGlyph = reserveModeGlyph && !suppressModeGlyphForBareThinking && residualForStatus >= 5 && (hasVisibleStatusContent || allowGlyphOnlyStatus);
// === Thinking shimmer color (formerly ThinkingShimmerText's own timer) ===
// Same sine-wave opacity, but derived from our shared `time` instead of a
@@ -218,22 +610,26 @@ export function SpinnerAnimationRow({
const thinkingShimmerColor = toRGBColor(interpolateColor(THINKING_INACTIVE, THINKING_INACTIVE_SHIMMER, thinkingOpacity));
// === Build status parts ===
const parts = [...(spinnerSuffix ? [<Text dimColor key="suffix">
// bareThinkingOnly nests parens on the thinking word (no outer status parens).
// Apply in both shimmer and reduced-motion arms so teammate bare status is
// always "(thinking)", not a bare word jammed after the verb.
const thinkingDisplay = thinkingText ? bareThinkingOnly ? `(${thinkingText})` : thinkingText : null;
const parts = [...(showSuffix && spinnerSuffix ? [<Text dimColor key="suffix">
{spinnerSuffix}
</Text>] : []), ...(showTimer ? [<Text dimColor key="elapsedTime">
{timerText}
</Text>] : []), ...(showTokens ? [<Text dimColor key="tokens">
{tokensText}
</Text>] : []), ...(showThinking && thinkingText ? [thinkingStatus === 'thinking' && !reducedMotion ? <Text key="thinking" color={thinkingShimmerColor}>
{thinkingOnly ? `(${thinkingText})` : thinkingText}
</Text>] : []), ...(showThinking && thinkingDisplay ? [thinkingStatus === 'thinking' && !reducedMotion ? <Text key="thinking" color={thinkingShimmerColor}>
{thinkingDisplay}
</Text> : <Text dimColor key="thinking">
{thinkingText}
{thinkingDisplay}
</Text>] : [])];
// Lead the status with the request-direction glyph (↑ requesting /
// ↓ responding) so the mode is visible whenever any status shows — it was
// previously buried inside the tokens part, which only appears after 30s.
// Skipped for thinkingOnly (kept minimal) and teammate spins (tree has it).
if (!hasRunningTeammates && !thinkingOnly && parts.length > 0) {
// ↓ streaming) inside the status parens when residual width allows and the
// glyph did not force higher-value status (tokens/timer/thinking) off the
// row. Teammate spins skip it (the teammate tree carries its own cue).
if (canShowModeGlyph) {
parts.unshift(<Box flexDirection="row" key="mode">
<SpinnerModeGlyph mode={mode} />
</Box>);
@@ -244,7 +640,7 @@ export function SpinnerAnimationRow({
{foregroundedTeammate.identity.agentName}
</Text>
<Text dimColor>)</Text>
</> : !foregroundedTeammate && parts.length > 0 ? thinkingOnly ? <Byline>{parts}</Byline> : <>
</> : !foregroundedTeammate && parts.length > 0 ? bareThinkingOnly ? <Byline>{parts}</Byline> : <>
<Text dimColor>(</Text>
<Byline>{parts}</Byline>
<Text dimColor>)</Text>
@@ -263,20 +659,6 @@ function SpinnerModeGlyph(t0) {
mode
} = t0;
switch (mode) {
case "tool-input":
case "tool-use":
case "responding":
case "thinking":
{
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Box width={2}><Text dimColor={true}>{figures.arrowDown}</Text></Box>;
$[0] = t1;
} else {
t1 = $[0];
}
return t1;
}
case "requesting":
{
let t1;
@@ -288,5 +670,22 @@ function SpinnerModeGlyph(t0) {
}
return t1;
}
case "tool-input":
case "tool-use":
case "responding":
case "thinking":
default:
{
// Default to down-arrow for known streaming modes and any open-union
// SpinnerMode string so unmapped modes never unshift an empty child.
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Box width={2}><Text dimColor={true}>{figures.arrowDown}</Text></Box>;
$[0] = t1;
} else {
t1 = $[0];
}
return t1;
}
}
}