Welcome to the final chapter of the Spinner project tutorial!
In the previous chapter, Text Glimmer Effects, we made our text look alive with a shimmering wave animation. Our UI now looks professional and active.
However, there is a danger in making a UI look too active. If the animation keeps spinning happily while the AI process has actually crashed or frozen, the user will sit there waiting forever.
We have all experienced this: you are downloading a file. The progress bar hits 99%... and stops. You stare at it. Is it still working? Has the internet cut out? Should you cancel it?
In Spinner, we are streaming text from an AI. Sometimes the network lags. Sometimes the AI takes a long time to think.
We need a Heartbeat Monitor.
Intensity is a number from 0.0 (Normal) to 1.0 (Full Alert).
We use a custom hook called useStalledAnimation. This hook doesn't draw anything; it just doing the math.
It relies on the Animation Loop we built in Chapter 4: Isolated Animation Loop.
// Inside your component
const { isStalled, stalledIntensity } = useStalledAnimation(
time, // Current animation time (e.g., 5000ms)
tokenCount, // Total tokens received so far (e.g., 42)
isToolActive // Is the AI using a tool? (If yes, don't panic)
);
// Pass the result to the visual component
<SpinnerGlyph
frame={frame}
messageColor="processing"
stalledIntensity={stalledIntensity}
/>
What happens here?
tokenCount changes, the timer resets.tokenCount stops changing, stalledIntensity starts climbing from 0 to 1.SpinnerGlyph uses that number to mix Purple and Red.Let's visualize the "Watchdog" logic.
The logic lives in useStalledAnimation.ts. Let's break down how it calculates that intensity.
We need to remember when the last token arrived. We use useRef to store this timestamp without causing re-renders.
// Inside useStalledAnimation.ts
export function useStalledAnimation(time, currentResponseLength) {
const lastTokenTime = useRef(time);
const lastResponseLength = useRef(currentResponseLength);
// DID WE GET A NEW TOKEN?
if (currentResponseLength > lastResponseLength.current) {
// Yes! Reset the timer.
lastTokenTime.current = time;
// Update our "last known" count
lastResponseLength.current = currentResponseLength;
}
// ...
}
Now we calculate how long it has been since that last reset.
// How long has it been?
// Current Time - Time of Last Token
const timeSinceLastToken = time - lastTokenTime.current;
This is the math part. We want a "Grace Period" of 3000ms (3 seconds). After that, we want to fade to red over the next 2000ms (2 seconds).
// Do we have a problem? (More than 3 seconds silence)
const isStalled = timeSinceLastToken > 3000;
const intensity = isStalled
? Math.min((timeSinceLastToken - 3000) / 2000, 1) // Math to get 0.0 to 1.0
: 0; // Everything is fine
Beginner Tip:
Math.min(..., 1)ensures that even if the silence lasts for an hour, the intensity never goes above 1.0.
Now that we have the math, we apply it. We learned about SpinnerGlyph in Chapter 3: Theme & Glyph Utilities. Here is how it uses the intensity.
It uses an interpolateColor function, which mixes paints.
// Inside SpinnerGlyph.tsx
if (stalledIntensity > 0) {
// Mix "Processing Purple" with "Error Red"
// If intensity is 0.5, we get a muddy reddish-purple.
const mixedColor = interpolateColor(
PURPLE_RGB,
RED_RGB,
stalledIntensity
);
return <Text color={mixedColor}>{spinnerChar}</Text>;
}
There is one exception to this rule. If the AI says "I am running a Python script", it might take 10 seconds to finish. It hasn't crashed; it's just working hard.
We pass a flag hasActiveTools to disable the stall detection during these moments.
// Inside useStalledAnimation.ts
if (hasActiveTools) {
// Pause the timer!
// Fake update the "last time" so the gap stays at 0
lastTokenTime.current = time;
}
Congratulations! You have completed the Spinner tutorial series.
You have built a sophisticated, high-performance CLI interface that:
You now have all the tools needed to build beautiful, responsive command-line tools for AI agents. Happy coding!
Generated by Code IQ