claude-code-best/src/components/Spinner.tsx
claude-code-best fcbc882232 chore: 清理 src 下 113 项未使用导入和死代码
删除未使用的文件(BuiltinStatusLine.tsx、4 个重复的 .ts stub)、
移除约 55 个文件中未使用的 React 导入、
清理约 50 处未使用的导入/变量/参数。
净减少 ~296 行代码,precheck 4077 测试全部通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:05:15 +08:00

546 lines
22 KiB
TypeScript

// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { Box, Text, stringWidth } from '@anthropic/ink';
import * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { computeGlimmerIndex, computeShimmerSegments, SHIMMER_INTERVAL_MS } from '../bridge/bridgeStatusUtil.js';
import { feature } from 'bun:bundle';
import { getKairosActive, getUserMsgOptIn } from '../bootstrap/state.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
import { isEnvTruthy } from '../utils/envUtils.js';
import { count } from '../utils/array.js';
import sample from 'lodash-es/sample.js';
import { formatDuration, formatNumber } from '../utils/format.js';
import type { Theme } from 'src/utils/theme.js';
import { activityManager } from '../utils/activityManager.js';
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js';
import { MessageResponse } from './MessageResponse.js';
import { TaskListV2 } from './TaskListV2.js';
import { useTasksV2 } from '../hooks/useTasksV2.js';
import type { Task } from '../utils/tasks.js';
import { useAppState } from '../state/AppState.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js';
import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js';
import { useSettings } from '../hooks/useSettings.js';
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js';
import { isBackgroundTask } from '../tasks/types.js';
import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
import { getEffortSuffix } from '../utils/effort.js';
import { getMainLoopModel } from '../utils/model/model.js';
import { getViewedTeammateTask } from '../state/selectors.js';
import { TEARDROP_ASTERISK } from '../constants/figures.js';
import figures from 'figures';
import { getCurrentTurnTokenBudget, getTurnOutputTokens } from '../bootstrap/state.js';
import { TeammateSpinnerTree } from './Spinner/TeammateSpinnerTree.js';
import { useAnimationFrame } from '@anthropic/ink';
import { getGlobalConfig } from '../utils/config.js';
export type { SpinnerMode } from './Spinner/index.js';
const DEFAULT_CHARACTERS = getDefaultCharacters();
const SPINNER_FRAMES = [...DEFAULT_CHARACTERS, ...[...DEFAULT_CHARACTERS].reverse()];
type Props = {
mode: SpinnerMode;
loadingStartTimeRef: React.RefObject<number>;
totalPausedMsRef: React.RefObject<number>;
pauseStartTimeRef: React.RefObject<number | null>;
spinnerTip?: string;
responseLengthRef: React.RefObject<number>;
apiMetricsRef?: React.RefObject<
Array<{
ttftMs: number;
firstTokenTime: number;
lastTokenTime: number;
responseLengthBaseline: number;
endResponseLength: number;
}>
>;
overrideColor?: keyof Theme | null;
overrideShimmerColor?: keyof Theme | null;
overrideMessage?: string | null;
spinnerSuffix?: string | null;
verbose: boolean;
hasActiveTools?: boolean;
/** Leader's turn has completed (no active query). Used to suppress stall-red spinner when only teammates are running. */
leaderIsIdle?: boolean;
};
// Thin wrapper: branches on isBriefOnly so the two variants have independent
// hook call chains. Without this split, toggling /brief mid-render would
// violate Rules of Hooks (the inner variant calls ~10 more hooks).
export function SpinnerWithVerb(props: Props): React.ReactNode {
const isBriefOnly = useAppState(s => s.isBriefOnly);
// REPL overrides isBriefOnly→false when viewing a teammate transcript
// (see isBriefOnly={viewedTeammateTask ? false : isBriefOnly}). That
// prop isn't threaded here, so replicate the gate from the store —
// teammate view needs the real spinner (which shows teammate status).
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
// Hoisted to mount-time — this component re-renders at animation framerate.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false;
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
// BriefTool.ts would leak tool-name strings into external builds. Single
// spinner instance → hooks stay unconditional (two subs, negligible).
if (
(feature('KAIROS') || feature('KAIROS_BRIEF')) &&
(getKairosActive() ||
(getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
isBriefOnly &&
!viewingAgentTaskId
) {
return <BriefSpinner mode={props.mode} overrideMessage={props.overrideMessage} />;
}
return <SpinnerWithVerbInner {...props} />;
}
function SpinnerWithVerbInner({
mode,
loadingStartTimeRef,
totalPausedMsRef,
pauseStartTimeRef,
spinnerTip,
responseLengthRef,
overrideColor,
overrideShimmerColor,
overrideMessage,
spinnerSuffix,
verbose,
hasActiveTools = false,
leaderIsIdle = false,
}: Props): React.ReactNode {
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
// NOTE: useAnimationFrame(50) lives in SpinnerAnimationRow, not here.
// This component only re-renders when props or app state change —
// it is no longer on the 50ms clock. All `time`-derived values
// (frame, glimmer, stalled intensity, token counter, thinking shimmer,
// elapsed-time timer) are computed inside the child.
const tasks = useAppState(s => s.tasks);
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
const expandedView = useAppState(s => s.expandedView);
const showExpandedTodos = expandedView === 'tasks';
const showSpinnerTree = expandedView === 'teammates';
const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex);
const viewSelectionMode = useAppState(s => s.viewSelectionMode);
// Get foregrounded teammate (if viewing a teammate's transcript)
const foregroundedTeammate = viewingAgentTaskId ? getViewedTeammateTask({ viewingAgentTaskId, tasks }) : undefined;
const { columns } = useTerminalSize();
const tasksV2 = useTasksV2();
// Track thinking status: 'thinking' | number (duration in ms) | null
// Shows each state for minimum 2s to avoid UI jank
const [thinkingStatus, setThinkingStatus] = useState<'thinking' | number | null>(null);
const thinkingStartRef = useRef<number | null>(null);
useEffect(() => {
let showDurationTimer: ReturnType<typeof setTimeout> | null = null;
let clearStatusTimer: ReturnType<typeof setTimeout> | null = null;
if (mode === 'thinking') {
// Started thinking
if (thinkingStartRef.current === null) {
thinkingStartRef.current = Date.now();
setThinkingStatus('thinking');
}
} else if (thinkingStartRef.current !== null) {
// Stopped thinking - calculate duration and ensure 2s minimum display
const duration = Date.now() - thinkingStartRef.current;
const elapsed = Date.now() - thinkingStartRef.current;
const remainingThinkingTime = Math.max(0, 2000 - elapsed);
thinkingStartRef.current = null;
// Show "thinking..." for remaining time if < 2s elapsed, then show duration
const showDuration = (): void => {
setThinkingStatus(duration);
// Clear after 2s
clearStatusTimer = setTimeout(setThinkingStatus, 2000, null);
};
if (remainingThinkingTime > 0) {
showDurationTimer = setTimeout(showDuration, remainingThinkingTime);
} else {
showDuration();
}
}
return () => {
if (showDurationTimer) clearTimeout(showDurationTimer);
if (clearStatusTimer) clearTimeout(clearStatusTimer);
};
}, [mode]);
// Find the current in-progress task and next pending task
const currentTodo = tasksV2?.find(task => task.status !== 'pending' && task.status !== 'completed');
const nextTask = findNextPendingTask(tasksV2);
// Use useState with initializer to pick a random verb once on mount
const [randomVerb] = useState(() => sample(getSpinnerVerbs()));
// Leader's own verb (always the leader's, regardless of who is foregrounded)
const leaderVerb = overrideMessage ?? currentTodo?.activeForm ?? currentTodo?.subject ?? randomVerb;
const effectiveVerb =
foregroundedTeammate && !foregroundedTeammate.isIdle
? (foregroundedTeammate.spinnerVerb ?? randomVerb)
: leaderVerb;
const message = effectiveVerb + '…';
// Track CLI activity when spinner is active
useEffect(() => {
const operationId = 'spinner-' + mode;
activityManager.startCLIActivity(operationId);
return () => {
activityManager.endCLIActivity(operationId);
};
}, [mode]);
const effortValue = useAppState(s => s.effortValue);
const effortSuffix = getEffortSuffix(getMainLoopModel(), effortValue);
// Check if any running in-process teammates exist (needed for both modes)
const runningTeammates = getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running');
const hasRunningTeammates = runningTeammates.length > 0;
const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle);
// Gather aggregate token stats from all running swarm teammates
// In spinner-tree mode, skip aggregation (teammates have their own lines in the tree)
let teammateTokens = 0;
if (!showSpinnerTree) {
for (const task of Object.values(tasks)) {
if (isInProcessTeammateTask(task) && task.status === 'running') {
if (task.progress?.tokenCount) {
teammateTokens += task.progress.tokenCount;
}
}
}
}
// Stale read of the refs for showBtwTip below — we're off the 50ms clock
// so this only updates when props/app state change, which is sufficient for
// a coarse 30s threshold.
const elapsedSnapshot =
pauseStartTimeRef.current !== null
? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current
: Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
// Leader token count for TeammateSpinnerTree — read raw (non-animated) from
// the ref. The tree is only shown when teammates are running; teammate
// progress updates to s.tasks trigger re-renders that keep this fresh.
const leaderTokenCount = Math.round(responseLengthRef.current / 4);
const defaultColor: keyof Theme = 'claude';
const defaultShimmerColor = 'claudeShimmer';
const messageColor = overrideColor ?? defaultColor;
const shimmerColor = overrideShimmerColor ?? defaultShimmerColor;
// TTFT display is gated to internal builds — apiMetricsRef was removed from
// props during a refactor, so skip this until it's re-threaded.
const _ttftText: string | null = null;
// When leader is idle but teammates are running (and we're viewing the leader),
// show a static dim idle display instead of the animated spinner — otherwise
// useStalledAnimation detects no new tokens after 3s and turns the spinner red.
if (leaderIsIdle && hasRunningTeammates && !foregroundedTeammate) {
return (
<Box flexDirection="column" width="100%" alignItems="flex-start">
<Box flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
<Text dimColor>
{TEARDROP_ASTERISK} Idle
{!allIdle && ' · teammates running'}
</Text>
</Box>
{showSpinnerTree && (
<TeammateSpinnerTree
selectedIndex={selectedIPAgentIndex}
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
allIdle={allIdle}
leaderTokenCount={leaderTokenCount}
leaderIdleText="Idle"
/>
)}
</Box>
);
}
// When viewing an idle teammate, show static idle display instead of animated spinner
if (foregroundedTeammate?.isIdle) {
const idleText = allIdle
? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}`
: `${TEARDROP_ASTERISK} Idle`;
return (
<Box flexDirection="column" width="100%" alignItems="flex-start">
<Box flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
<Text dimColor>{idleText}</Text>
</Box>
{showSpinnerTree && hasRunningTeammates && (
<TeammateSpinnerTree
selectedIndex={selectedIPAgentIndex}
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
allIdle={allIdle}
leaderVerb={leaderIsIdle ? undefined : leaderVerb}
leaderIdleText={leaderIsIdle ? 'Idle' : undefined}
leaderTokenCount={leaderTokenCount}
/>
)}
</Box>
);
}
// Time-based tip overrides: coarse thresholds so a stale ref read (we're
// off the 50ms clock) is fine. Other triggers (mode change, setMessages)
// cause re-renders that refresh this in practice.
let contextTipsActive = false;
const tipsEnabled = settings.spinnerTipsEnabled !== false;
const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000;
const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount;
const effectiveTip = contextTipsActive
? undefined
: showClearTip && !nextTask
? 'Use /clear to start fresh when switching topics and free up context'
: showBtwTip && !nextTask
? "Use /btw to ask a quick side question without interrupting Claude's current work"
: spinnerTip;
// Budget text (ant-only) — shown above the tip line
let budgetText: string | null = null;
if (feature('TOKEN_BUDGET')) {
const budget = getCurrentTurnTokenBudget();
if (budget !== null && budget > 0) {
const tokens = getTurnOutputTokens();
if (tokens >= budget) {
budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})`;
} else {
const pct = Math.round((tokens / budget) * 100);
const remaining = budget - tokens;
const rate = elapsedSnapshot > 5000 && tokens >= 2000 ? tokens / elapsedSnapshot : 0;
const eta = rate > 0 ? ` \u00B7 ~${formatDuration(remaining / rate, { mostSignificantOnly: true })}` : '';
budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}`;
}
}
}
return (
<Box flexDirection="column" width="100%" alignItems="flex-start">
<SpinnerAnimationRow
mode={mode}
reducedMotion={reducedMotion}
hasActiveTools={hasActiveTools}
responseLengthRef={responseLengthRef}
message={message}
messageColor={messageColor}
shimmerColor={shimmerColor}
overrideColor={overrideColor}
loadingStartTimeRef={loadingStartTimeRef}
totalPausedMsRef={totalPausedMsRef}
pauseStartTimeRef={pauseStartTimeRef}
spinnerSuffix={spinnerSuffix}
verbose={verbose}
columns={columns}
hasRunningTeammates={hasRunningTeammates}
teammateTokens={teammateTokens}
foregroundedTeammate={foregroundedTeammate}
leaderIsIdle={leaderIsIdle}
thinkingStatus={thinkingStatus}
effortSuffix={effortSuffix}
/>
{showSpinnerTree && hasRunningTeammates ? (
<TeammateSpinnerTree
selectedIndex={selectedIPAgentIndex}
isInSelectionMode={viewSelectionMode === 'selecting-agent'}
allIdle={allIdle}
leaderVerb={leaderIsIdle ? undefined : leaderVerb}
leaderIdleText={leaderIsIdle ? 'Idle' : undefined}
leaderTokenCount={leaderTokenCount}
/>
) : showExpandedTodos && tasksV2 && tasksV2.length > 0 ? (
<Box width="100%" flexDirection="column">
<MessageResponse>
<TaskListV2 tasks={tasksV2} />
</MessageResponse>
</Box>
) : nextTask || effectiveTip || budgetText ? (
// IMPORTANT: we need this width="100%" to avoid an Ink bug where the
// tip gets duplicated over and over while the spinner is running if
// the terminal is very small. TODO: fix this in Ink.
<Box width="100%" flexDirection="column">
{budgetText && (
<MessageResponse>
<Text dimColor>{budgetText}</Text>
</MessageResponse>
)}
{(nextTask || effectiveTip) && (
<MessageResponse>
<Text dimColor>{nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`}</Text>
</MessageResponse>
)}
</Box>
) : null}
</Box>
);
}
// Brief/assistant mode spinner: single status line. PromptInput drops its
// own marginTop when isBriefOnly is active, so this component owns the
// 2-row footprint between messages and input. Footprint is [blank, content]
// — one blank row above (breathing room under the messages list), spinner
// flush against the input bar. PromptInput's absolute-positioned
// Notifications overlay compensates with marginTop=-2 in brief mode
// (PromptInput.tsx:~2928) so it floats into the blank row above the
// spinner, not over the spinner content. Paired with BriefIdleStatus which
// keeps the same footprint when idle.
type BriefSpinnerProps = {
mode: SpinnerMode;
overrideMessage?: string | null;
};
function BriefSpinner({ mode, overrideMessage }: BriefSpinnerProps): React.ReactNode {
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
const [randomVerb] = useState(() => sample(getSpinnerVerbs()) ?? 'Working');
const verb = overrideMessage ?? randomVerb;
const connStatus = useAppState(s => s.remoteConnectionStatus);
// Track CLI activity so OS/IDE "busy" indicators fire in brief mode too
useEffect(() => {
const operationId = 'spinner-' + mode;
activityManager.startCLIActivity(operationId);
return () => {
activityManager.endCLIActivity(operationId);
};
}, [mode]);
// Drive both dot cycle and shimmer from the shared clock. The viewport
// ref is unused — the spinner unmounts on turn end so viewport-based
// pausing isn't needed.
const [, time] = useAnimationFrame(reducedMotion ? null : 120);
// Local tasks + remote tasks are mutually exclusive (viewer mode has an
// empty local AppState.tasks; local mode has remoteBackgroundTaskCount=0).
// Summing avoids a mode branch.
const runningCount = useAppState(s => count(Object.values(s.tasks), isBackgroundTask) + s.remoteBackgroundTaskCount);
// Connection trouble overrides the verb — `claude assistant` is a pure viewer,
// nothing useful is happening while the WS is down.
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
const connText = connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected';
// Dots padded to a fixed 3 columns so the right-aligned count doesn't
// jitter as the cycle advances.
const dotFrame = Math.floor(time / 300) % 3;
const dots = reducedMotion ? '… ' : '.'.repeat(dotFrame + 1).padEnd(3);
// Shimmer: reverse-sweep highlight across the verb. Skip for connection
// warnings (shimmer reads as "working"; Reconnecting/Disconnected is not).
const verbWidth = useMemo(() => stringWidth(verb), [verb]);
const glimmerIndex =
reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth);
const { before, shimmer, after } = computeShimmerSegments(verb, glimmerIndex);
const { columns } = useTerminalSize();
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
// Manual right-align via space padding — flexGrow spacers inside
// FullscreenLayout's `main` slot don't resolve a width and caused the
// diff engine to miss dot-frame updates.
const leftWidth = (showConnWarning ? stringWidth(connText) : verbWidth) + 3;
const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText));
return (
<Box flexDirection="row" width="100%" marginTop={1} paddingLeft={2}>
{showConnWarning ? (
<Text color="error">{connText + dots}</Text>
) : (
<>
{before ? <Text dimColor>{before}</Text> : null}
{shimmer ? <Text>{shimmer}</Text> : null}
{after ? <Text dimColor>{after}</Text> : null}
<Text dimColor>{dots}</Text>
</>
)}
{rightText ? (
<>
<Text>{' '.repeat(pad)}</Text>
<Text color="subtle">{rightText}</Text>
</>
) : null}
</Box>
);
}
// Idle placeholder for brief mode. Same 2-row [blank, content] footprint
// as BriefSpinner so the input bar never jumps when toggling between
// working/idle/disconnected. See BriefSpinner's comment for the
// Notifications overlay coupling.
export function BriefIdleStatus(): React.ReactNode {
const connStatus = useAppState(s => s.remoteConnectionStatus);
const runningCount = useAppState(s => count(Object.values(s.tasks), isBackgroundTask) + s.remoteBackgroundTaskCount);
const { columns } = useTerminalSize();
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
const connText = connStatus === 'reconnecting' ? 'Reconnecting…' : 'Disconnected';
const leftText = showConnWarning ? connText : '';
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
if (!leftText && !rightText) return <Box height={2} />;
const pad = Math.max(1, columns - 2 - stringWidth(leftText) - stringWidth(rightText));
return (
<Box marginTop={1} paddingLeft={2}>
<Text>
{leftText ? <Text color="error">{leftText}</Text> : null}
{rightText ? (
<>
<Text>{' '.repeat(pad)}</Text>
<Text color="subtle">{rightText}</Text>
</>
) : null}
</Text>
</Box>
);
}
export function Spinner(): React.ReactNode {
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
const [ref, time] = useAnimationFrame(reducedMotion ? null : 120);
// Reduced motion: static dot instead of animated spinner
if (reducedMotion) {
return (
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
<Text color="text"></Text>
</Box>
);
}
// Derive frame from synced time - all spinners animate together
const frame = Math.floor(time / 120) % SPINNER_FRAMES.length;
return (
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
<Text color="text">{SPINNER_FRAMES[frame]}</Text>
</Box>
);
}
function findNextPendingTask(tasks: Task[] | undefined): Task | undefined {
if (!tasks) {
return undefined;
}
const pendingTasks = tasks.filter(t => t.status === 'pending');
if (pendingTasks.length === 0) {
return undefined;
}
const unresolvedIds = new Set(tasks.filter(t => t.status !== 'completed').map(t => t.id));
return pendingTasks.find(t => !t.blockedBy.some(id => unresolvedIds.has(id))) ?? pendingTasks[0];
}