Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type Props = {
pauseStartTimeRef: React.RefObject<number | null>;
spinnerTip?: string;
responseLengthRef: React.RefObject<number>;
responseLength?: number;
overrideColor?: keyof Theme | null;
overrideShimmerColor?: keyof Theme | null;
overrideMessage?: string | null;
Expand Down Expand Up @@ -86,6 +87,7 @@ function SpinnerWithVerbInner({
pauseStartTimeRef,
spinnerTip,
responseLengthRef,
responseLength,
overrideColor,
overrideShimmerColor,
overrideMessage,
Expand All @@ -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);
Expand Down Expand Up @@ -269,7 +271,7 @@ function SpinnerWithVerbInner({
}
}
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} />
<SpinnerAnimationRow mode={mode} reducedMotion={reducedMotion} hasActiveTools={hasActiveTools} responseLengthRef={responseLengthRef} responseLength={responseLength} 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} />
Expand Down
98 changes: 98 additions & 0 deletions src/components/Spinner/SpinnerAnimationRow.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SpinnerAnimationRow
mode="responding"
reducedMotion
hasActiveTools={false}
responseLengthRef={{ current: 0 }}
responseLength={4_000}
message="Thinking"
messageColor="text"
shimmerColor="text"
loadingStartTimeRef={{ current: now }}
totalPausedMsRef={{ current: 0 }}
pauseStartTimeRef={createRef<number | null>()}
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(
<SpinnerAnimationRow
mode="responding"
reducedMotion
hasActiveTools={false}
responseLengthRef={{ current: 0 }}
responseLength={1}
message="Thinking"
messageColor="text"
shimmerColor="text"
loadingStartTimeRef={{ current: now }}
totalPausedMsRef={{ current: 0 }}
pauseStartTimeRef={createRef<number | null>()}
verbose={false}
columns={120}
hasRunningTeammates={false}
teammateTokens={0}
foregroundedTeammate={undefined}
thinkingStatus={null}
effortSuffix=""
/>,
120,
)

expect(output).toContain('0 tokens')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

it('does not overflow a narrow row when a spinner suffix is present', async () => {
const now = Date.now()
const output = await renderToString(
<SpinnerAnimationRow
mode="responding"
reducedMotion
hasActiveTools={false}
responseLengthRef={{ current: 4_000 }}
message="Thinking"
messageColor="text"
shimmerColor="text"
loadingStartTimeRef={{ current: now }}
totalPausedMsRef={{ current: 0 }}
pauseStartTimeRef={createRef<number | null>()}
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')
})
})
55 changes: 23 additions & 32 deletions src/components/Spinner/SpinnerAnimationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<number>;
/** Throttled live response length for reduced-motion rendering. */
responseLength?: number;

// Message (stable within a turn)
message: string;
Expand Down Expand Up @@ -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).
Expand All @@ -88,6 +93,7 @@ export function SpinnerAnimationRow({
reducedMotion,
hasActiveTools,
responseLengthRef,
responseLength,
message,
messageColor,
shimmerColor,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/components/tasks/RemoteSessionProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 36 additions & 4 deletions src/screens/REPL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof setTimeout> | 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
Expand All @@ -1575,7 +1598,6 @@ export function REPL({
const [streamingText, setStreamingText] = useState<string | null>(null);
const streamingTextRef = useRef<string | null>(null);
const lastFlushedStreamingVisibleRef = useRef<string | null>(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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -4906,7 +4938,7 @@ export function REPL({
</Box>}
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
<Box flexGrow={1} />
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix ?? activeToolSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} responseLength={reducedMotion ? reducedMotionResponseLength : undefined} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix ?? activeToolSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 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. */}
Expand Down