I shipped a feature this week that has zero productivity value, takes about 250ms to set up, and made me laugh out loud the first time I saw it work. It's a setting in 1DevTool that puts a tiny cartoon manager next to your Claude Code terminal who shakes and screams "IS IT DONE YET?!" while the agent is generating.
There are 17 other animations like this. A whip that cracks at the running text. A rubber duck that blinks at you. A cat that walks across the spinner. A firefighter who sprays foam at flames burning the "Kneading..." line. Donald Trump cycling through 20 catchphrases including "MAKE CODING GREAT AGAIN" and "NOBODY WRITES CODE BETTER THAN ME".
I want to write about why I built this, how the interesting bit actually works (it's harder than it looks), and what the architecture ended up being. There's a real technical problem hiding inside what looks like a joke feature.
Why does this exist
I use Claude Code, Codex, Gemini, and Amp every day inside 1DevTool, which is the dev workbench I'm building. A normal session has me staring at "Kneading… (3s)" or "Pondering… (12s)" for a long time. Sometimes a really long time.
A user joked in our Discord: "I wish I could crack a whip on Claude when it's slow." I laughed, then thought about it for ten more seconds, and realized: yeah, I could just do that. As a setting. With a tiny SVG whip. It would be funny every single time.
So I built it. Then I built 17 more. The full list:
- Kind ✨ — Magic Wand sprinkling sparkles, Feed Coffee delivery with steam, a patient Rubber Duck that bobs and blinks, a Handshake, a head-petting hand with floating hearts, a Good Dog wagging its tail
- Motivational 💪 — Whip Crack, Hammer, Cooling Fan, Finger Poke, Electric Shock, FASTER Rain (Matrix-style), Manager Shouting, Trump Mode
- Chaos 🌪️ — Bug Swarm crawling everywhere, Firefighting Mode, a Cat walking across the keyboard, an Agent Meeting where seven colored speech bubbles pop in arguing "ACTUALLY...", "NO BUT WAIT", "I DISAGREE", "TECHNICALLY!"
You pick one in Settings → Terminal → Fun Agent Animation. The animation appears the moment your AI agent starts generating and vanishes the instant it goes idle.
The interesting problem: where exactly is the AI thinking?
Here's the thing that took the feature from "amusing toy" to "actually delightful." The first version put the whip in a fixed corner of the terminal pane. The animation played fine but it felt disconnected — like a sticker someone slapped onto the screen. The whip was over here. The "Kneading..." text was over there.
The user pushed back with a screenshot. They drew a red box around the actual running status line and an arrow saying "the animation should point at the running text of AI like the section in image."
That's the real problem. The animation needs to follow where the agent is currently rendering its spinner — and that location is dynamic. It moves when you scroll. It moves when the terminal resizes. It moves when the AI clears the line and redraws it. It moves between agents (Claude Code's TUI and Codex's TUI render their status lines in different places).
So how do you find a moving text target inside a terminal buffer from React?
Reading the xterm.js buffer
1DevTool uses xterm.js for terminal rendering. xterm.js exposes its buffer through an API, which means I can iterate through the visible rows and read the text on each one. The trick is knowing what to look for.
I made a list of three signals:
-
Spinner glyphs — Most TUIs use one of the braille spinner characters like
⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏. Claude Code uses these. So does Codex. - Claude Code's rotating verbs — Claude has a charming detail where it cycles through verbs like "Kneading", "Pondering", "Boondoggling", "Brewing", "Conjuring", "Spelunking", "Noodling". I built a regex with all of them.
-
The seconds timer — Almost every running indicator includes a parenthesized timer like
(3s)or(47s). That's a strong signal that's agent-agnostic.
If any line in the visible viewport matches any of those, that's the running line. Here's the actual hook:
// useAgentRunningLine.ts
const SPINNER_CHARS = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◓◑◒✢✳✦✧⭒⭑●·]/
const RUNNING_VERBS =
/\b(?:Kneading|Thinking|Contemplating|Pondering|Ruminating|Boondoggling|Dithering|Processing|Running|Wrangling|Simmering|Cogitating|Generating|Working|Musing|Brewing|Puzzling|Noodling|Incubating|Cooking|Churning|Fermenting|Mulling|Spelunking|Herding|Forging|Conjuring|Hatching|Sorting|Calculating|Weaving|Scheming|Chewing|Gnawing|Doodling|Wondering)\b/i
const SECONDS_TIMER = /\(\d+s\)/
export function useAgentRunningLine(terminalId: string, active: boolean): number | null {
const [anchorY, setAnchorY] = useState<number | null>(null)
useEffect(() => {
if (!active) {
setAnchorY(null)
return
}
const scan = () => {
const instance = useTerminalStore.getState().instances.get(terminalId)
if (!instance) return
const { xterm } = instance
const element = xterm.element
if (!element) return
const containerHeight = element.getBoundingClientRect().height
if (containerHeight <= 0) return
const buffer = xterm.buffer.active
const rows = xterm.rows
const viewportStart = buffer.viewportY
// Scan bottom-up — the status line usually sits near the prompt
let foundRow: number | null = null
for (let y = rows - 1; y >= 0; y--) {
const line = buffer.getLine(viewportStart + y)
if (!line) continue
const text = line.translateToString(true)
if (!text) continue
if (SPINNER_CHARS.test(text) || SECONDS_TIMER.test(text) || RUNNING_VERBS.test(text)) {
foundRow = y
break
}
}
// Fallback: status line typically sits a couple rows above the cursor
if (foundRow === null) {
foundRow = Math.max(0, buffer.cursorY - 2)
}
// Convert row index to pixel Y
const cellHeight = containerHeight / rows
setAnchorY(foundRow * cellHeight + cellHeight / 2)
}
scan()
const interval = setInterval(scan, 250)
return () => clearInterval(interval)
}, [terminalId, active])
return anchorY
}
A few things worth pointing out:
- Bottom-up scan. The status line is almost always near the input prompt at the bottom. Scanning bottom-first means we usually find it within 2-3 line reads instead of iterating through the whole viewport.
-
Polling at 250ms. I tried
requestAnimationFramefirst and it was wasteful. The status line moves at most a few times per second when the agent is generating. 250ms feels instant and costs basically nothing — reading 30 lines of text is cheap. - Cursor fallback. If the regex misses (edge case: the agent is between status updates), the cursor's row is a decent approximation. Subtract two because the input prompt sits below the status line.
-
No row → pixel coupling with xterm internals. I don't reach into
_renderServiceor any private APIs. I just computecontainerHeight / rowsfor the cell height. It's a hair less accurate than xterm's internal calculation but it survives version upgrades. -
The hook returns
nullwhen inactive. That's how the overlay knows to render nothing — no state, no flicker, no placeholder.
That value gets passed to the overlay, and every animation positions itself relative to it.
Architecture: one file per animation, plus a registry
The first version of this feature was a single 1100-line FunAnimationOverlay.tsx file with {type === 'whip' && (...)} blocks for every animation. By the time I had eight animations it was already painful to navigate. By eighteen it was untenable.
I split it. Now there's a directory:
src/renderer/components/terminal/
├── FunAnimationOverlay.tsx # 40-line dispatcher
├── useAgentRunningLine.ts # the hook above
└── animations/
├── types.ts # shared AnimationProps interface
├── keyframes.ts # all @keyframes in one CSS string
├── registry.ts # FunAnimationType → Component map
├── whip.tsx
├── hammer.tsx
├── trump.tsx
└── ... (15 more)
The dispatcher is now this:
// FunAnimationOverlay.tsx
export function FunAnimationOverlay({ type, active, anchorY }: Props) {
if (!active || type === 'none') return null
if (anchorY === null) return null // wait for first scan
const Animation = FUN_ANIMATION_REGISTRY[type]
if (!Animation) return null
return (
<div className="absolute inset-0 z-20 pointer-events-none overflow-hidden">
<style>{FUN_ANIMATION_KEYFRAMES}</style>
<Animation anchorY={anchorY} />
</div>
)
}
The registry is just a map:
// animations/registry.ts
export const FUN_ANIMATION_REGISTRY: Record<
Exclude<FunAnimationType, 'none'>,
ComponentType<AnimationProps>
> = {
whip: WhipAnimation,
hammer: HammerAnimation,
trump: TrumpAnimation,
// ... 15 more
}
Adding a new animation is now four steps: write animations/foo.tsx, add its keyframe to keyframes.ts, register it in registry.ts, add the variant to the FunAnimationType union and the settings picker. No more hunting through one giant file.
Each animation positions itself
Every animation receives the anchorY pixel value and decides what part of itself should land on that line. For most, the visual center sits on the line:
// animations/manager.tsx
export function ManagerAnimation({ anchorY }: AnimationProps) {
return (
<div
className="flex items-start gap-2"
style={{
position: 'absolute',
right: 12,
top: anchorY,
transform: 'translateY(-50%)', // center on the anchor line
}}
>
{/* speech bubble + shaking manager avatar SVG */}
</div>
)
}
But some animations need their strike point on the line, not their center:
-
Whip — The tail tip sits at the bottom of the SVG, so
translateY(-85%)lifts the whip up so the tail lands on the line -
Hammer — The head is at the bottom of the viewBox and the swing pivot is at the top. With
translateY(-77%), the pivot sits above the line and the head arcs through the line as it swings -
Cat / Dog / Duck —
translateY(-95%)puts the feet on the line so they look like they're standing on it -
Fire —
translateY(-100%)puts the bottom of the flame row exactly on the line, so flames burn upward from the running text -
Pet —
translateY(-100%)puts the palm hovering above the line, so it looks like it's petting the running text -
Rain — Wraps the whole rain effect in a container with
height: anchorYandoverflow: hidden, so the falling motivational words stop right at the running line instead of falling past it
That's it. There's no math. No physics. Just translateY percentages picked by eyeballing each animation against a real terminal session. Took maybe 90 seconds per animation.
The Trump catchphrase rotation
This deserves a callout because it has its own state. The other animations are pure SVG + CSS keyframes — completely stateless. Trump Mode rotates through 20 catchphrases on its own timer:
const TRUMP_CATCHPHRASES = [
'MAKE CODING GREAT AGAIN',
"YOU'RE ABSOLUTELY WRONG",
'WRONG. VERY WRONG.',
'FAKE CODE. SAD!',
'BAD CODE. VERY BAD.',
'HUGE PERFORMANCE BOOST',
'TREMENDOUS UPDATE',
'NOBODY WRITES CODE BETTER THAN ME',
// ... 12 more
]
export function TrumpAnimation({ anchorY }: AnimationProps) {
const [phraseIndex, setPhraseIndex] = useState(() =>
Math.floor(Math.random() * TRUMP_CATCHPHRASES.length),
)
useEffect(() => {
const interval = setInterval(() => {
setPhraseIndex((i) => (i + 1) % TRUMP_CATCHPHRASES.length)
}, 2500)
return () => clearInterval(interval)
}, [])
return (
<div className="flex items-start gap-2" style={{ /* ...anchored... */ }}>
<CartoonCaricature />
<SpeechBubble>{TRUMP_CATCHPHRASES[phraseIndex]}</SpeechBubble>
</div>
)
}
The starting index is randomized so two AI terminals running side-by-side don't say the same thing at the same time. The phrase changes every 2.5 seconds.
What I'd do differently if this got bigger
If I were going to scale this past ~30 animations, I'd reach for Lottie or Rive. Hand-drawing SVG paths is meditative for a while and then it's tiring. The cat ended up okay. The Trump caricature is decidedly "programmer art". A real designer working in After Effects could produce the same library in an afternoon and it would look ten times better.
The tradeoff: that's another ~250 KB of bundle for lottie-web, plus an asset pipeline. For 18 animations in an Electron app I already ship, the SVG-and-keyframes approach was the right call. But I'd switch in a heartbeat if I had a designer collaborator.
The other thing I'd reconsider: polling. 250ms is fine but it's still polling. xterm.js has a onWriteParsed event that fires after each chunk of buffer writes. I could subscribe to that and only re-scan when something actually changed in the buffer. I tried it briefly and got entangled in scroll-position edge cases, so I shipped the polling version. It's on my list.
Try it
If you use 1DevTool, update to 1.11.4 and head to Settings → Terminal → Fun Agent Animation. The picker is grouped into Off · Kind ✨ · Motivational 💪 · Chaos 🌪️. Pick one, fire up a Claude Code or Codex session, and watch the animation track the running spinner.
If you're building your own terminal-adjacent feature on top of xterm.js and want to do anything similar — anchoring HUD elements to TUI text, injecting overlays at the cursor row, highlighting specific status lines — the hook source is in useAgentRunningLine.ts and it's about 80 lines including comments. The technique generalizes to any TUI that uses a spinner glyph or a parenthesized timer, which is most of them.
The thing I love about this feature is that it's 100% useless and 100% delightful. The whip cracks, and Claude keeps Boondoggling, and somehow my afternoon is 1% better. Sometimes the best engineering work is the work that just makes you smile.
If you build something fun on top of this idea — or if you have an animation suggestion I should add — I'd love to see it. The next one might be a tiny version of you, refreshing twitter.
Happy coding.




Top comments (0)