mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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 25–26 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user