diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 107a5acd8b..dbb3dea340 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -46,6 +46,7 @@ type Props = { pauseStartTimeRef: React.RefObject; spinnerTip?: string; responseLengthRef: React.RefObject; + responseLength?: number; overrideColor?: keyof Theme | null; overrideShimmerColor?: keyof Theme | null; overrideMessage?: string | null; @@ -86,6 +87,7 @@ function SpinnerWithVerbInner({ pauseStartTimeRef, spinnerTip, responseLengthRef, + responseLength, overrideColor, overrideShimmerColor, overrideMessage, @@ -100,7 +102,7 @@ function SpinnerWithVerbInner({ // 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, + // (frame, glimmer, stalled intensity, token display, thinking shimmer, // elapsed-time timer) are computed inside the child. const tasks = useAppState(s => s.tasks); @@ -269,7 +271,7 @@ function SpinnerWithVerbInner({ } } return - + {showSpinnerTree && hasRunningTeammates ? : showExpandedTodos && tasksV2 && tasksV2.length > 0 ? diff --git a/src/components/Spinner/SpinnerAnimationRow.test.tsx b/src/components/Spinner/SpinnerAnimationRow.test.tsx new file mode 100644 index 0000000000..ad28e25789 --- /dev/null +++ b/src/components/Spinner/SpinnerAnimationRow.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'bun:test' +import { createRef } from 'react' +import { renderToString } from '../../utils/staticRender.js' +import { getCurrentResponseTokenCount, SpinnerAnimationRow } from './SpinnerAnimationRow.js' + +describe('SpinnerAnimationRow', () => { + it('uses the current response length without smoothing', () => { + expect(getCurrentResponseTokenCount(4_000)).toBe(1_000) + }) + + it('shows the current token count immediately when streaming begins', async () => { + const now = Date.now() + const output = await renderToString( + ()} + verbose={false} + columns={120} + hasRunningTeammates={false} + teammateTokens={0} + foregroundedTeammate={undefined} + thinkingStatus={null} + effortSuffix="" + />, + 120, + ) + + expect(output).toContain('1.0k tokens') + }) + + it('shows zero tokens as soon as the first response character arrives', async () => { + const now = Date.now() + const output = await renderToString( + ()} + verbose={false} + columns={120} + hasRunningTeammates={false} + teammateTokens={0} + foregroundedTeammate={undefined} + thinkingStatus={null} + effortSuffix="" + />, + 120, + ) + + expect(output).toContain('0 tokens') + }) + + it('does not overflow a narrow row when a spinner suffix is present', async () => { + const now = Date.now() + const output = await renderToString( + ()} + spinnerSuffix="running stop hooks… 1/1" + verbose={false} + columns={45} + hasRunningTeammates={false} + teammateTokens={0} + foregroundedTeammate={undefined} + thinkingStatus={null} + effortSuffix="" + />, + 45, + ) + + expect(output).toContain('running stop hooks… 1/1') + expect(output).not.toContain('tokens') + }) +}) diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index f4294aa89a..18196ee517 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -17,11 +17,9 @@ import { useStalledAnimation } from './useStalledAnimation.js'; import { interpolateColor, toRGBColor } from './utils.js'; const SEP_WIDTH = stringWidth(' · '); const THINKING_BARE_WIDTH = stringWidth('thinking'); -// Show the elapsed-time counter early (reassurance that work is in flight, -// especially during long tool calls where no tokens stream) but hold the token -// count back until the turn is clearly long-running. +// Show the elapsed-time counter after a short delay so long tool calls still +// provide reassurance even when no tokens stream. const SHOW_TIMER_AFTER_MS = 5_000; -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 @@ -38,12 +36,19 @@ const THINKING_INACTIVE_SHIMMER = { }; const THINKING_DELAY_MS = 3000; const THINKING_GLOW_PERIOD_S = 2; + +export function getCurrentResponseTokenCount(responseLength: number): number { + return Math.round(responseLength / 4); +} + export type SpinnerAnimationRowProps = { // Animation inputs mode: SpinnerMode; reducedMotion: boolean; hasActiveTools: boolean; responseLengthRef: React.RefObject; + /** Throttled live response length for reduced-motion rendering. */ + responseLength?: number; // Message (stable within a turn) message: string; @@ -75,8 +80,8 @@ export type SpinnerAnimationRowProps = { /** * 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). + * and all values derived from the animation clock (frame, glimmer, + * 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). @@ -88,6 +93,7 @@ export function SpinnerAnimationRow({ reducedMotion, hasActiveTools, responseLengthRef, + responseLength, message, messageColor, shimmerColor, @@ -123,7 +129,7 @@ export function SpinnerAnimationRow({ } // === Animation derivations from `time` === - const currentResponseLength = responseLengthRef.current; + const currentResponseLength = responseLength ?? responseLengthRef.current; // Suppress stall detection when leader is idle — responseLengthRef and // hasActiveTools both track leader state. When viewing an active teammate @@ -143,32 +149,16 @@ export function SpinnerAnimationRow({ 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); + // Display the latest observed response length without smoothing so the token + // count is current as soon as the model starts streaming. + const leaderTokens = getCurrentResponseTokenCount(currentResponseLength); 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 hasTokenContent = foregroundedTeammate && !foregroundedTeammate.isIdle ? totalTokens > 0 : currentResponseLength > 0 || teammateTokens > 0; const tokenCount = formatNumber(totalTokens); const tokensText = `${tokenCount} tokens`; const tokensWidth = stringWidth(tokensText); @@ -186,22 +176,23 @@ export function SpinnerAnimationRow({ // 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 wantsThinking = thinkingStatus !== null; const wantsTimer = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TIMER_AFTER_MS; - const wantsTokens = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS; + const wantsTokens = true; const availableSpace = columns - messageWidth - parensWidth; - let showThinking = wantsThinking && availableSpace > thinkingWidthValue; + let showThinking = wantsThinking && availableSpace > suffixWidth + thinkingWidthValue; if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && effortSuffix) { - if (availableSpace > THINKING_BARE_WIDTH) { + if (availableSpace > suffixWidth + THINKING_BARE_WIDTH) { thinkingText = 'thinking'; thinkingWidthValue = THINKING_BARE_WIDTH; showThinking = true; } } - const usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0; + const usedAfterThinking = suffixWidth + (showThinking ? thinkingWidthValue + sep : 0); const showTimer = wantsTimer && availableSpace > usedAfterThinking + timerWidth; const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0); - const showTokens = wantsTokens && totalTokens > 0 && availableSpace > usedAfterTimer + tokensWidth; + 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 diff --git a/src/components/tasks/RemoteSessionProgress.tsx b/src/components/tasks/RemoteSessionProgress.tsx index 512c2c1a52..f677947c3f 100644 --- a/src/components/tasks/RemoteSessionProgress.tsx +++ b/src/components/tasks/RemoteSessionProgress.tsx @@ -67,9 +67,8 @@ function RainbowText(t0) { return t3; } -// Smooth-tick a count toward target, +1 per frame. Same pattern as the -// token counter in SpinnerAnimationRow — the ref survives re-renders and -// the animation clock drives the tick. Target jumps (2→5) display as +// Smooth-tick a count toward target, +1 per frame. The ref survives re-renders +// and the animation clock drives the tick. Target jumps (2→5) display as // 2→3→4→5 instead of snapping. When `snap` is set (reduced motion, or // the clock is frozen), bypass the tick and jump straight to target — // otherwise a frozen `time` would leave the ref stuck at its init value. diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index e36575bf3a..c299f7cb44 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1555,11 +1555,34 @@ export function REPL({ startupChecksStartedRef.current = true; void performStartupChecks(setAppState); }, [setAppState, isRemoteSession, hasHadFirstSubmission]); + const reducedMotion = useAppState(s => s.settings.prefersReducedMotion) ?? false; // Ref instead of state to avoid triggering React re-renders on every - // streaming text_delta. The spinner reads this via its animation timer. + // streaming text_delta. In reduced-motion mode we publish an initial update + // and then throttle subsequent token-display refreshes. const responseLengthRef = useRef(0); + const [reducedMotionResponseLength, setReducedMotionResponseLength] = useState(0); + const reducedMotionRef = useRef(reducedMotion); + const reducedMotionResponseLengthTimerRef = useRef | null>(null); + useEffect(() => { + reducedMotionRef.current = reducedMotion; + if (reducedMotion) { + setReducedMotionResponseLength(responseLengthRef.current); + } + }, [reducedMotion]); + useEffect(() => () => { + if (reducedMotionResponseLengthTimerRef.current) { + clearTimeout(reducedMotionResponseLengthTimerRef.current); + } + }, []); const setResponseLength = useCallback((f: (prev: number) => number) => { - responseLengthRef.current = f(responseLengthRef.current); + const next = f(responseLengthRef.current); + responseLengthRef.current = next; + if (!reducedMotionRef.current || reducedMotionResponseLengthTimerRef.current) return; + setReducedMotionResponseLength(next); + reducedMotionResponseLengthTimerRef.current = setTimeout(() => { + reducedMotionResponseLengthTimerRef.current = null; + setReducedMotionResponseLength(responseLengthRef.current); + }, 200); }, []); // Streaming text display. streamingTextRef holds the full accumulated text @@ -1575,7 +1598,6 @@ export function REPL({ const [streamingText, setStreamingText] = useState(null); const streamingTextRef = useRef(null); const lastFlushedStreamingVisibleRef = useRef(null); - const reducedMotion = useAppState(s => s.settings.prefersReducedMotion) ?? false; const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug(); const onStreamingText = useCallback((f: (current: string | null) => string | null) => { // decideStreamingTextUpdate keeps the ref current even when the live preview @@ -1716,6 +1738,11 @@ export function REPL({ // does not leave the progress bar rendered in the idle UI. setCompactProgressRatio(null); responseLengthRef.current = 0; + setReducedMotionResponseLength(0); + if (reducedMotionResponseLengthTimerRef.current) { + clearTimeout(reducedMotionResponseLengthTimerRef.current); + reducedMotionResponseLengthTimerRef.current = null; + } streamingTextRef.current = null; lastFlushedStreamingVisibleRef.current = null; setStreamingText(null); @@ -3180,6 +3207,11 @@ export function REPL({ hasInterruptionCorrectionRequestOnlyMessage = requestOnlyMessages.length > 0; setMessages(persistentMessages); responseLengthRef.current = 0; + setReducedMotionResponseLength(0); + if (reducedMotionResponseLengthTimerRef.current) { + clearTimeout(reducedMotionResponseLengthTimerRef.current); + reducedMotionResponseLengthTimerRef.current = null; + } if (feature('TOKEN_BUDGET')) { const parsedBudget = input ? parseTokenBudget(input) : null; snapshotOutputTokensForTurn(parsedBudget ?? getCurrentTurnTokenBudget()); @@ -4906,7 +4938,7 @@ export function REPL({ } {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} - {showSpinner && 0} leaderIsIdle={!isLoading} />} + {showSpinner && 0} leaderIsIdle={!isLoading} />} {/* Permanently mounted: it observes the isLoading transition to flash `✓ Done` for ~1.5s. Suppressed wherever another element owns the row or the user's attention. */}