claude-code/components/Spinner/SpinnerAnimationRow.tsx

265 lines
42 KiB
TypeScript
Raw Normal View History

import { c as _c } from "react/compiler-runtime";
import figures from 'figures';
import * as React from 'react';
import { useMemo, useRef } from 'react';
import { stringWidth } from '../../ink/stringWidth.js';
import { Box, Text, useAnimationFrame } from '../../ink.js';
import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js';
import { formatDuration, formatNumber } from '../../utils/format.js';
import { toInkColor } from '../../utils/ink.js';
import type { Theme } from '../../utils/theme.js';
import { Byline } from '../design-system/Byline.js';
import { GlimmerMessage } from './GlimmerMessage.js';
import { SpinnerGlyph } from './SpinnerGlyph.js';
import type { SpinnerMode } from './types.js';
import { useStalledAnimation } from './useStalledAnimation.js';
import { interpolateColor, toRGBColor } from './utils.js';
const SEP_WIDTH = stringWidth(' · ');
const THINKING_BARE_WIDTH = stringWidth('thinking');
const SHOW_TOKENS_AFTER_MS = 30_000;
// Thinking shimmer constants. Previously lived in a separate ThinkingShimmerText
// component with its own useAnimationFrame(50) — inlined here to reuse our
// existing 50ms clock and eliminate the redundant subscriber.
const THINKING_INACTIVE = {
r: 153,
g: 153,
b: 153
};
const THINKING_INACTIVE_SHIMMER = {
r: 185,
g: 185,
b: 185
};
const THINKING_DELAY_MS = 3000;
const THINKING_GLOW_PERIOD_S = 2;
export type SpinnerAnimationRowProps = {
// Animation inputs
mode: SpinnerMode;
reducedMotion: boolean;
hasActiveTools: boolean;
responseLengthRef: React.RefObject<number>;
// Message (stable within a turn)
message: string;
messageColor: keyof Theme;
shimmerColor: keyof Theme;
overrideColor?: keyof Theme | null;
// Timer refs (stable references)
loadingStartTimeRef: React.RefObject<number>;
totalPausedMsRef: React.RefObject<number>;
pauseStartTimeRef: React.RefObject<number | null>;
// Display flags
spinnerSuffix?: string | null;
verbose: boolean;
columns: number;
// Teammate-derived (computed by parent from tasks)
hasRunningTeammates: boolean;
teammateTokens: number;
foregroundedTeammate: InProcessTeammateTaskState | undefined;
/** Leader's turn has completed. Suppresses stall-red since responseLengthRef/hasActiveTools track leader state only. */
leaderIsIdle?: boolean;
// Thinking (state owned by parent, mode-dependent)
thinkingStatus: 'thinking' | number | null;
effortSuffix: string;
};
/**
* The 50ms-animated portion of SpinnerWithVerb. Owns useAnimationFrame(50)
* and all values derived from the animation clock (frame, glimmer, token
* counter animation, elapsed-time, stalled intensity, thinking shimmer).
*
* The parent SpinnerWithVerb is freed from the 50ms render loop and only
* re-renders when its props/app state change (~25x/turn instead of ~383x).
* That keeps the outer Box shells, useAppState selectors, task filtering,
* and tip/tree subtrees out of the hot animation path.
*/
export function SpinnerAnimationRow({
mode,
reducedMotion,
hasActiveTools,
responseLengthRef,
message,
messageColor,
shimmerColor,
overrideColor,
loadingStartTimeRef,
totalPausedMsRef,
pauseStartTimeRef,
spinnerSuffix,
verbose,
columns,
hasRunningTeammates,
teammateTokens,
foregroundedTeammate,
leaderIsIdle = false,
thinkingStatus,
effortSuffix
}: SpinnerAnimationRowProps): React.ReactNode {
const [viewportRef, time] = useAnimationFrame(reducedMotion ? null : 50);
// === Elapsed time (wall-clock, derived from refs each frame) ===
const now = Date.now();
const elapsedTimeMs = pauseStartTimeRef.current !== null ? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current : now - loadingStartTimeRef.current - totalPausedMsRef.current;
// Track wall-clock turn start for teammates. While a swarm is running the
// leader's elapsedTimeMs may jump around (new API calls reset
// loadingStartTimeRef; pauses freeze it), so we anchor to the earliest
// derived start seen so far. When no teammates are running this just tracks
// derivedStart every frame, effectively resetting for the next swarm.
const derivedStart = now - elapsedTimeMs;
const turnStartRef = useRef(derivedStart);
if (!hasRunningTeammates || derivedStart < turnStartRef.current) {
turnStartRef.current = derivedStart;
}
// === Animation derivations from `time` ===
const currentResponseLength = responseLengthRef.current;
// Suppress stall detection when leader is idle — responseLengthRef and
// hasActiveTools both track leader state. When viewing an active teammate
// while leader is idle, they'd otherwise flag a false stall after 3s.
// Treating leaderIsIdle like hasActiveTools resets the stall timer.
const {
isStalled,
stalledIntensity
} = useStalledAnimation(time, currentResponseLength, hasActiveTools || leaderIsIdle, reducedMotion);
const frame = reducedMotion ? 0 : Math.floor(time / 120);
const glimmerSpeed = mode === 'requesting' ? 50 : 200;
// message is stable within a turn; stringWidth is expensive enough (Bun native
// call per code point) to memoize explicitly across the 50ms loop.
const glimmerMessageWidth = useMemo(() => stringWidth(message), [message]);
const cycleLength = glimmerMessageWidth + 20;
const cyclePosition = Math.floor(time / glimmerSpeed);
const glimmerIndex = reducedMotion ? -100 : isStalled ? -100 : mode === 'requesting' ? cyclePosition % cycleLength - 10 : glimmerMessageWidth + 10 - cyclePosition % cycleLength;
const flashOpacity = reducedMotion ? 0 : mode === 'tool-use' ? (Math.sin(time / 1000 * Math.PI) + 1) / 2 : 0;
// === Token counter animation (smooth increment, driven by 50ms clock) ===
const tokenCounterRef = useRef(currentResponseLength);
if (reducedMotion) {
tokenCounterRef.current = currentResponseLength;
} else {
const gap = currentResponseLength - tokenCounterRef.current;
if (gap > 0) {
let increment;
if (gap < 70) {
increment = 3;
} else if (gap < 200) {
increment = Math.max(8, Math.ceil(gap * 0.15));
} else {
increment = 50;
}
tokenCounterRef.current = Math.min(tokenCounterRef.current + increment, currentResponseLength);
}
}
const displayedResponseLength = tokenCounterRef.current;
const leaderTokens = Math.round(displayedResponseLength / 4);
const effectiveElapsedMs = hasRunningTeammates ? Math.max(elapsedTimeMs, now - turnStartRef.current) : elapsedTimeMs;
const timerText = formatDuration(effectiveElapsedMs);
const timerWidth = stringWidth(timerText);
// === Token count (leader + teammates, or foregrounded teammate) ===
const totalTokens = foregroundedTeammate && !foregroundedTeammate.isIdle ? foregroundedTeammate.progress?.tokenCount ?? 0 : leaderTokens + teammateTokens;
const tokenCount = formatNumber(totalTokens);
const tokensText = hasRunningTeammates ? `${tokenCount} tokens` : `${figures.arrowDown} ${tokenCount} tokens`;
const tokensWidth = stringWidth(tokensText);
// === Thinking text (may shrink to fit) ===
let thinkingText = thinkingStatus === 'thinking' ? `thinking${effortSuffix}` : typeof thinkingStatus === 'number' ? `thought for ${Math.max(1, Math.round(thinkingStatus / 1000))}s` : null;
let thinkingWidthValue = thinkingText ? stringWidth(thinkingText) : 0;
// === Progressive width gating ===
const messageWidth = glimmerMessageWidth + 2;
const sep = SEP_WIDTH;
const wantsThinking = thinkingStatus !== null;
const wantsTimerAndTokens = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS;
const availableSpace = columns - messageWidth - 5;
let showThinking = wantsThinking && availableSpace > thinkingWidthValue;
if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && effortSuffix) {
if (availableSpace > THINKING_BARE_WIDTH) {
thinkingText = 'thinking';
thinkingWidthValue = THINKING_BARE_WIDTH;
showThinking = true;
}
}
const usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0;
const showTimer = wantsTimerAndTokens && availableSpace > usedAfterThinking + timerWidth;
const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0);
const showTokens = wantsTimerAndTokens && totalTokens > 0 && availableSpace > usedAfterTimer + tokensWidth;
const thinkingOnly = showThinking && thinkingStatus === 'thinking' && !spinnerSuffix && !showTimer && !showTokens && true;
// === Thinking shimmer color (formerly ThinkingShimmerText's own timer) ===
// Same sine-wave opacity, but derived from our shared `time` instead of a
// second useAnimationFrame(50) subscription.
const thinkingElapsedSec = (time - THINKING_DELAY_MS) / 1000;
const thinkingOpacity = time < THINKING_DELAY_MS ? 0 : (Math.sin(thinkingElapsedSec * Math.PI * 2 / THINKING_GLOW_PERIOD_S) + 1) / 2;
const thinkingShimmerColor = toRGBColor(interpolateColor(THINKING_INACTIVE, THINKING_INACTIVE_SHIMMER, thinkingOpacity));
// === Build status parts ===
const parts = [...(spinnerSuffix ? [<Text dimColor key="suffix">
{spinnerSuffix}
</Text>] : []), ...(showTimer ? [<Text dimColor key="elapsedTime">
{timerText}
</Text>] : []), ...(showTokens ? [<Box flexDirection="row" key="tokens">
{!hasRunningTeammates && <SpinnerModeGlyph mode={mode} />}
<Text dimColor>{tokenCount} tokens</Text>
</Box>] : []), ...(showThinking && thinkingText ? [thinkingStatus === 'thinking' && !reducedMotion ? <Text key="thinking" color={thinkingShimmerColor}>
{thinkingOnly ? `(${thinkingText})` : thinkingText}
</Text> : <Text dimColor key="thinking">
{thinkingText}
</Text>] : [])];
const status = foregroundedTeammate && !foregroundedTeammate.isIdle ? <>
<Text dimColor>(esc to interrupt </Text>
<Text color={toInkColor(foregroundedTeammate.identity.color)}>
{foregroundedTeammate.identity.agentName}
</Text>
<Text dimColor>)</Text>
</> : !foregroundedTeammate && parts.length > 0 ? thinkingOnly ? <Byline>{parts}</Byline> : <>
<Text dimColor>(</Text>
<Byline>{parts}</Byline>
<Text dimColor>)</Text>
</> : null;
return <Box ref={viewportRef} flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
<SpinnerGlyph frame={frame} messageColor={messageColor} stalledIntensity={overrideColor ? 0 : stalledIntensity} reducedMotion={reducedMotion} time={time} />
<GlimmerMessage message={message} mode={mode} messageColor={messageColor} glimmerIndex={glimmerIndex} flashOpacity={flashOpacity} shimmerColor={shimmerColor} stalledIntensity={overrideColor ? 0 : stalledIntensity} />
{status}
</Box>;
}
function SpinnerModeGlyph(t0) {
const $ = _c(2);
const {
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;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Box width={2}><Text dimColor={true}>{figures.arrowUp}</Text></Box>;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
}
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmaWd1cmVzIiwiUmVhY3QiLCJ1c2VNZW1vIiwidXNlUmVmIiwic3RyaW5nV2lkdGgiLCJCb3giLCJUZXh0IiwidXNlQW5pbWF0aW9uRnJhbWUiLCJJblByb2Nlc3NUZWFtbWF0ZVRhc2tTdGF0ZSIsImZvcm1hdER1cmF0aW9uIiwiZm9ybWF0TnVtYmVyIiwidG9JbmtDb2xvciIsIlRoZW1lIiwiQnlsaW5lIiwiR2xpbW1lck1lc3NhZ2UiLCJTcGlubmVyR2x5cGgiLCJTcGlubmVyTW9kZSIsInVzZVN0YWxsZWRBbmltYXRpb24iLCJpbnRlcnBvbGF0ZUNvbG9yIiwidG9SR0JDb2xvciIsIlNFUF9XSURUSCIsIlRISU5LSU5HX0JBUkVfV0lEVEgiLCJTSE9XX1RPS0VOU19BRlRFUl9NUyIsIlRISU5LSU5HX0lOQUNUSVZFIiwiciIsImciLCJiIiwiVEhJTktJTkdfSU5BQ1RJVkVfU0hJTU1FUiIsIlRISU5LSU5HX0RFTEFZX01TIiwiVEhJTktJTkdfR0xPV19QRVJJT0RfUyIsIlNwaW5uZXJBbmltYXRpb25Sb3dQcm9wcyIsIm1vZGUiLCJyZWR1Y2VkTW90aW9uIiwiaGFzQWN0aXZlVG9vbHMiLCJyZXNwb25zZUxlbmd0aFJlZiIsIlJlZk9iamVjdCIsIm1lc3NhZ2UiLCJtZXNzYWdlQ29sb3IiLCJzaGltbWVyQ29sb3IiLCJvdmVycmlkZUNvbG9yIiwibG9hZGluZ1N0YXJ0VGltZVJlZiIsInRvdGFsUGF1c2VkTXNSZWYiLCJwYXVzZVN0YXJ0VGltZVJlZiIsInNwaW5uZXJTdWZmaXgiLCJ2ZXJib3NlIiwiY29sdW1ucyIsImhhc1J1bm5pbmdUZWFtbWF0ZXMiLCJ0ZWFtbWF0ZVRva2VucyIsImZvcmVncm91bmRlZFRlYW1tYXRlIiwibGVhZGVySXNJZGxlIiwidGhpbmtpbmdTdGF0dXMiLCJlZmZvcnRTdWZmaXgiLCJTcGlubmVyQW5pbWF0aW9uUm93IiwiUmVhY3ROb2RlIiwidmlld3BvcnRSZWYiLCJ0aW1lIiwibm93IiwiRGF0ZSIsImVsYXBzZWRUaW1lTXMiLCJjdXJyZW50IiwiZGVyaXZlZFN0YXJ0IiwidHVyblN0YXJ0UmVmIiwiY3VycmVudFJlc3BvbnNlTGVuZ3RoIiwiaXNTdGFsbGVkIiwic3RhbGxlZEludGVuc2l0eSIsImZyYW1lIiwiTWF0aCIsImZsb29yIiwiZ2xpbW1lclNwZWVkIiwiZ2xpbW1lck1lc3NhZ2VXaWR0aCIsImN5Y2xlTGVuZ3RoIiwiY3ljbGVQb3NpdGlvbiIsImdsaW1tZXJJbmRleCIsImZsYXNoT3BhY2l0eSIsInNpbiIsIlBJIiwidG9rZW5Db3VudGVyUmVmIiwiZ2FwIiwiaW5jcmVtZW50IiwibWF4IiwiY2VpbCIsIm1pbiIsImRpc3BsYXllZFJlc3BvbnNlTGVuZ3RoIiwibGVhZGVyVG9rZW5zIiwicm91bmQiLCJlZmZlY3RpdmVFbGFwc2VkTXMiLCJ0aW1lclRleHQiLCJ0aW1lcldpZHRoIiwidG90YWxUb2tlbnMiLCJpc0lkbGUiLCJwcm9ncmVzcyIsInRva2VuQ291bnQiLCJ0b2tlbnNUZXh0IiwiYXJyb3dEb3duIiwidG9rZW5zV2lkdGgiLCJ0aGlua2luZ1RleHQiLCJ0aGlua2luZ1dpZHRoVmFsdWUiLCJtZXNzYWdlV2lkdGgiLCJzZXAiLCJ3YW50c1RoaW5raW5nIiwid2FudHNUaW1lckFuZFRva2VucyIsImF2YWlsYWJsZVNwYWNlIiwic2hvd1RoaW5raW5nIiwidXNlZEFmdGVyVGhpbmtpbmciLCJzaG93VGltZXIiLCJ1c2VkQWZ0ZXJUaW1lciIsInNob3dUb2tlbnMiLCJ0aGlua2luZ09ubHkiLCJ0aGlua2luZ0VsYXBzZWRTZWMiLCJ0aGlua2luZ09wYWNpdHkiLCJ0aGlua2luZ1NoaW1tZXJDb2xvciIsInBhcnRzIiwic3RhdHVzIiwiaWRlbnRpdHkiLCJjb2xvciIsImFnZW50TmFtZSIsImxlbmd0aCIsIlNwaW5uZXJNb2RlR2x5cGgiLCJ0MCIsIiQiLCJfYyIsInQxIiwiU3ltYm9sIiwiZm9yIiwiYXJyb3dVcCJdLCJzb3VyY2VzIjpbIlNwaW5uZXJBbmltYXRpb25Sb3cudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBmaWd1cmVzIGZyb20gJ2ZpZ3VyZXMnXG5pbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZU1lbW8sIHVzZVJlZiB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgc3RyaW5nV2lkdGggfSBmcm9tICcuLi8uLi9pbmsvc3RyaW5nV2lkdGguanMnXG5pbXBvcnQgeyBCb3gsIFRleHQsIHVzZUFuaW1hdGlvbkZyYW1lIH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHR5cGUgeyBJblByb2Nlc3NUZWFtbWF0ZVRhc2tTdGF0ZSB9IGZyb20gJy4uLy4uL3Rhc2tzL0luUHJvY2Vzc1RlYW1tYXRlVGFzay90eXBlcy5qcydcbmltcG9ydCB7IGZvcm1hdER1cmF0aW9uLCBmb3JtYXROdW1iZXIgfSBmcm9tICcuLi8uLi91dGlscy9mb3JtYXQuanMnXG5pbXBvcnQgeyB0b0lua0NvbG9yIH0gZnJvbSAnLi4vLi4vdXRpbHMvaW5rLmpzJ1xuaW1wb3J0IHR5cGUgeyBUaGVtZSB9IGZyb20gJy4uLy4uL3V0aWxzL3RoZW1lLmpzJ1xuaW1wb3J0IHsgQnlsaW5lIH0gZnJvbSAnLi4vZGVzaWduLXN5c3RlbS9CeWxpbmUuanMnXG5pbXBvcnQgeyBHbGltbWVyTWVzc2FnZSB9IGZyb20gJy4vR2xpbW1lck1lc3NhZ2UuanMnXG5pbXBvcnQgeyBTcGlubmVyR2x5cGggfSBmcm9tICcuL1NwaW5uZXJHbHlwaC5qcydcbmltcG9ydCB0eXBlIHsgU3Bpbm5lck1vZGUgfSBmcm9tICcuL3R5cGVzLmpzJ1xuaW1wb3J0IHsgdXNlU3RhbGxlZEFuaW1hdGlvbiB9IGZyb20gJy4vdXNlU3RhbGxlZEFuaW1hdGlvbi5qcydcbmltcG9ydCB7IGludGVycG9sYXRlQ29sb3IsIHRvUkdCQ29sb3IgfSBmcm9tICcuL3V0aWxzLmpzJ1xuXG5jb25zdCBTRVBfV0lEVEggPSBzdHJpbmdXaWR0aCgnIMK3ICcpXG5jb25zdCBUSElOS0lOR19CQVJFX1dJRFRIID0gc3RyaW5nV2lkdGgoJ3RoaW5raW5nJylcbmNvbnN0IFNIT1dfVE9LRU5TX0FGVEVSX01TID0gMzBfMDAwXG5cbi8vIFRoaW5raW5nIHNoaW1tZXIgY29uc3RhbnRzLiBQcmV2aW91c2x5IGxpdmVkIGluIGEgc2VwYXJhdGUgVGhpbmtpbmdTaGltbWVyVGV4dFxuLy8gY29tcG9uZW50IHdpdGggaXRzIG93biB1c2VBbmltYXRpb25GcmFtZSg1MCkg4oCUIGlubGluZWQgaGVyZSB0byByZXVzZSBvdXJcbi8vIGV4aXN0aW5nIDUwbXMgY2xvY2sgYW5kIGVsaW1pbmF0ZSB0aGUgcmVkdW5kYW50IHN1YnNjcmliZXIuXG5jb25zdCBUSElOS0lOR19JTkFDVElWRSA9IHsgcjogMTUzLCBnOiAxNTM