While Claude works, Clawd acts out the spinner's -ing word above the prompt, and the tool that is running

A Claude Code mod: while Claude works, Clawd walks onto a little stage above the prompt and acts out what is going on — the spinner's -ing word (Sautéing, Pondering, *Herding*…) and whatever tool is running right now.

Four scenes at the real frame rate, about 15 s in all (a real turn changes scene every 15 s). Every frame is the mod's own output, rendered by tools/.
Bash types, Read/Grep/Glob think, Edit/Write sketch, Agent herds sheep, Web* checks the weather, TodoWrite/Skill juggles, MCP tools cast spells, anything else is hammered at the forge.| Scene | Spinner words (some of them) |
|---|---|
| think | Pondering, Musing, Clauding, Noodling, Ruminating |
| chalk | Deciphering, Elucidating, Philosophising, Deliberating, Reasoning |
| cook / bake / brew | Sautéing, Simmering, Whisking / Baking, Kneading / Brewing, Percolating |
| walk / run / moonwalk | Moseying, Wandering / Scampering, Galloping / Moonwalking |
| herd | Herding, Wrangling, Mustering |
| spin | Spinning, Churning, Reticulating, Combobulating |
| magic / levitate | Conjuring, Manifesting, Transmuting / Levitating, Hyperspacing |
| hatch / grow | Hatching, Incubating / Sprouting, Germinating |
| forge / compute | Forging, Crafting, Creating / Computing, Crunching, Synthesizing |
| dance / juggle / honk | Vibing, Grooving, Boogieing / Juggling / Honking, Booping |
| sketch / weather / thunder | Sketching, Doodling / Misting, Billowing / Thundering |
| dig / flow | Burrowing, Spelunking / Flowing, Undulating |
| kick / skate / campfire | Kicking, Dribbling / Skating, Gliding / Kindling, Smoldering — no built-in word matches these yet, so they mostly turn up in the rotation |
Requires a Claude Code build with function-hook mods (claude plugin validate knows hooks.json modules). Developed and tested on Claude Code 2.1.289.
From this repository as a marketplace:
/plugin marketplace add BrianHuang813/clawd-actor
/plugin install clawd-actor@clawd-actor
/reload-plugins
Or straight from a clone, for one session:
git clone https://github.com/BrianHuang813/clawd-actor
claude --plugin-dir ./clawd-actor
The stage is drawn in the AbovePrompt band. The hook always calls next(e) and stacks what the mods beneath it draw under the stage, so other band mods keep showing. A band mod that answers without calling next(e) hides everything beneath it — if Clawd never shows up, look for one.
ui.render on Spinner only observes: it remembers the word and mode and lets the engine draw the spinner as usual.tool.call notes the tool in flight and forgets it when the call returns.session.start, advances a frame counter in $.state every 140 ms while a turn runs; the band reads it, which subscribes it to redraws. A timer started inside a turn.start hook does not outlive that dispatch, which is why it lives in session.start.isWorking prop.hooks/scenes.ts is pure: a 4-row grid of cells, a scene per name, sceneAt() choosing the scene for a frame and frameRows() turning the grid into coloured runs.claude plugin validate .
claude plugin test .
The tests check that every scene keeps the stage size at 30, 40 and 60 columns and moves Clawd about only in the scenes without set pieces (the rest are checked to stay put), that no prop is ever drawn over Clawd's face, that a turn opens on the running tool or the spinner word and then rotates through every other scene whatever tools run, picking up where the last turn stopped, and that the timer animates Clawd during a turn and lets go after it.
Block characters and symbols are drawn one cell wide; a terminal font that draws some of them wider may shift a frame.
To regenerate assets/demo.gif after changing a scene (Node 22+ and Python with Pillow):
node tools/frames.ts > frames.json # the frames, straight from hooks/scenes.ts
python tools/render.py frames.json assets/demo.gif
tools/render.py uses DejaVu Sans Mono and draws block elements as exact rectangles; edit the font path at its top for your system.
Clawd is Claude Code's mascot; the sprite follows the one on Claude Code's welcome screen. This is a fan-made mod, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 85 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { ACT_FRAMES, FRAME_MS, frameRows, H, sceneAt, type Mode } from './scenes'
5
6const MAX_STAGE = 60 // widest the wander roams, however wide the terminal
7const frame = atom({ plugin: 'clawd-actor', key: 'frame' } as const, 0)
8
9// The spinner's word, read while it draws. State cannot be written during a render,
10// so it lives here; the frame timer's redraws pick it up.
11let word = 'Thinking'
12let mode: Mode = 'thinking'
13let isTurnRunning = false
14let turnBase = 0 // the frame the turn started on: acts count from it
15let seed = 0 // shuffles the session's rotation of scenes
16let played = 0 // rotation acts earlier turns got through
17const running: { id: string; tool: string }[] = [] // tools in flight, newest last
18
19export const register: Register = on => {
20 // A timer must start where work may outlive the dispatch: session.start (fires again on reload).
21 // It ticks all session long but only writes, and so redraws, while a turn runs.
22 on('session.start', async ($, e, next) => {
23 const r = await next(e)
24 seed = Math.floor(await $.clock.now()) % 9973
25 played = 0
26 $.clock.every(FRAME_MS, () => (isTurnRunning ? update($, frame, f => (f + 1) % 100_000) : undefined))
27 return r
28 })
29
30 on('turn.start', async ($, e, next) => {
31 isTurnRunning = true
32 turnBase = await read($, frame)
33 running.length = 0
34 return next(e)
35 })
36
37 // While a tool runs Clawd acts it out; back to the rotation once it returns
38 on('tool.call', async ($, e, next) => {
39 running.push({ id: e.tool_use_id, tool: e.tool })
40 try {
41 return await next(e)
42 } finally {
43 const i = running.findIndex(r => r.id === e.tool_use_id)
44 if (i >= 0) running.splice(i, 1)
45 }
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 isTurnRunning = false
50 played += Math.floor(((await read($, frame)) - turnBase) / ACT_FRAMES) // acts after the opener
51 return next(e)
52 })
53
54 // Observe only: remember what the spinner says, let the engine draw it as usual
55 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
56 word = e.props.message ?? e.props.word
57 mode = e.props.mode
58 return next(e)
59 })
60
61 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
62 const below = await next(e) // keep whatever other band mods draw here
63 // isWorking is the engine's own word on whether a turn runs
64 if (e.props.hasSurvey || !e.props.isWorking) return below
65
66 const t = await read($, frame)
67 const { Box, Text } = $.ui.resolve(e)
68 const scene = sceneAt({ word, mode, tick: t - turnBase, seed, played, tool: running[running.length - 1]?.tool })
69 const rows = frameRows(word, mode, t, Math.min(e.props.bodyColumns, MAX_STAGE), scene)
70
71 return (
72 <Box flexDirection="column">
73 <Box flexDirection="column" height={H}>
74 {rows.map(runs => (
75 <Text>
76 {runs.map(r => (r.color ? <Text color={r.color} backgroundColor={r.bg}>{r.text}</Text> : r.text))}
77 </Text>
78 ))}
79 </Box>
80 {below}
81 </Box>
82 )
83 })
84}
85hooks/scenes.ts 542 lines1// Clawd's stage: a width x H grid of cells. Every scene draws Clawd plus props for one frame,
2// then the whole frame wanders left and right across the stage.
3// Pure functions only, so the frames can be checked without the engine.
4
5export const W = 40 // default stage width; the band passes its own
6export const H = 4
7export const ORANGE = '#D77757'
8const SHADE = '#B35A3E' // the side turned away, as the official animation shades it
9const INK = '#1E1E1E' // eye colour where an eye is drawn on top of the body
10const PURPLE = '#A374F5'
11
12export type Cell = { ch: string; color?: string; bg?: string }
13export type Grid = Cell[][]
14
15const blank = (width: number): Grid => Array.from({ length: H }, () => Array.from({ length: width }, () => ({ ch: ' ' })))
16
17/** Paints `s` at (x, y); spaces are transparent so props can overlap Clawd's gaps. */
18const put = (g: Grid, x: number, y: number, s: string, color?: string, bg?: string) => {
19 if (y < 0 || y >= H) return
20 ;[...s].forEach((ch, i) => {
21 const cx = x + i
22 if (ch !== ' ' && cx >= 0 && cx < g[y].length) g[y][cx] = bg ? { ch, color, bg } : { ch, color }
23 })
24}
25/** Recolours what is already drawn at (x, y), keeping the glyph. */
26const tint = (g: Grid, x: number, y: number, color: string) => {
27 const c = g[y]?.[x]
28 if (c && c.ch !== ' ') c.color = color
29}
30
31// Clawd, 9 columns x 3 rows, as the welcome banner draws it.
32// smile is drawn as blink with two ^ eyes laid over it (see clawd()).
33const TOP = { open: ' ▐▛███▜▌ ', blink: ' ▐█████▌ ', smile: ' ▐█████▌ ', left: ' ▐▜███▜▌ ', right: ' ▐▛███▛▌ ' }
34const MID = { down: '▝▜█████▛▘', up: '▗▟█████▙▖', left: '▗▟█████▛▘', right: '▝▜█████▙▖', flat: '▟███████▙' }
35const LEGS = { stand: ' ▘▘ ▝▝ ', a: ' ▘ ▘▝ ▝ ', b: ' ▘ ▘ ▝ ▝ ', curl: ' ▘▝ ▘▝ ', tuck: ' ' }
36
37type Pose = {
38 x?: number
39 /** 1 hops a row up, -1 squashes a row down (legs out of sight, body spread) */
40 lift?: -1 | 0 | 1
41 eyes?: keyof typeof TOP
42 arms?: keyof typeof MID
43 legs?: keyof typeof LEGS
44 /** which side has turned away from us; the body sways between them on its own */
45 shade?: 'left' | 'right' | 'none'
46 color?: string
47}
48
49// What the wander is doing this frame; scenes read it through clawd() so every pose walks along
50const stride = { moving: false, hop: false, squash: false, dir: 1, width: W }
51
52/**
53 * The face when a scene leaves it alone: mostly content ^ ^ eyes, a look around,
54 * a blink or a double blink; while travelling it looks the way it goes.
55 */
56const face = (t: number): keyof typeof TOP => {
57 if (stride.moving) return t % 20 === 0 ? 'blink' : stride.dir > 0 ? 'right' : 'left'
58 const p = t % 56
59 if (p < 14) return p === 7 ? 'blink' : 'open'
60 if (p < 36) return 'smile'
61 if (p < 40) return 'left'
62 if (p < 44) return 'right'
63 return p === 49 || p === 51 ? 'blink' : 'open'
64}
65/** A slow sway: turn one way, face front, turn the other way. */
66const sway = (t: number): NonNullable<Pose['shade']> => {
67 if (stride.moving) return stride.dir > 0 ? 'left' : 'right'
68 const p = t % 16
69 return p < 6 ? 'left' : p < 8 ? 'none' : p < 14 ? 'right' : 'none'
70}
71/** Columns a travelling scene may roam: the stage less Clawd and its trailing props. */
72const roam = (extra: number) => Math.max(4, stride.width - 11 - extra)
73
74const clawd = (g: Grid, t: number, pose: Pose = {}) => {
75 const x = pose.x ?? 2
76 const lift = pose.lift ?? (stride.hop ? 1 : stride.squash ? -1 : 0)
77 const y = 1 - lift
78 const eyes = pose.eyes ?? face(t)
79 const swing = t % 2 === 0 ? 'left' : 'right'
80 const color = pose.color ?? ORANGE
81 const shade = pose.shade ?? sway(t)
82 put(g, x, y, TOP[eyes], color)
83 if (eyes === 'smile') {
84 // the happy closed eyes: dark arcs sitting on the body
85 put(g, x + 2, y, '^', INK, color)
86 put(g, x + 6, y, '^', INK, color)
87 }
88 put(g, x, y + 1, MID[lift < 0 ? 'flat' : pose.arms ?? (stride.moving ? swing : 'down')], color)
89 const legs = pose.legs ?? (stride.moving ? walkLegs(t) : shade === 'none' ? 'curl' : 'stand')
90 put(g, x, y + 2, LEGS[legs], color)
91 // turned a little: the far edge of the body falls into shadow
92 if (color === ORANGE && shade !== 'none') {
93 const [top, mid] = shade === 'left' ? [x + 1, x] : [x + 7, x + 8]
94 tint(g, top, y, SHADE)
95 tint(g, mid, y + 1, SHADE)
96 tint(g, shade === 'left' ? x + 1 : x + 7, y + 1, SHADE)
97 }
98}
99
100// A little Clawd, 5 columns x 2 rows, standing on the ground (or a row up when it hops)
101const MINI_TOP = { open: '▗▛█▜▖', blink: '▗███▖', left: '▗▜█▜▖', right: '▗▛█▛▖' }
102// second row: the top halves close under the eyes, the bottom halves are the legs (out, or tucked in mid-step)
103const MINI_LEGS = { stand: '▝▛▀▜▘', a: '▝▛▀▜▘', b: '▝▜▀▛▘' }
104const MINI_COLORS = ['#6FA8DC', '#E8C547', '#7BC67E', '#E88AB5'] // blue, yellow, green, pink
105const mini = (g: Grid, x: number, color: string, { lift = 0, eyes = 'open', legs = 'stand' }: { lift?: number; eyes?: keyof typeof MINI_TOP; legs?: keyof typeof MINI_LEGS } = {}) => {
106 put(g, x, 2 - lift, MINI_TOP[eyes], color)
107 put(g, x, 3 - lift, MINI_LEGS[legs], color)
108}
109
110/** Back-and-forth position over `span` columns. */
111const pingPong = (t: number, span: number) => {
112 const p = t % (span * 2)
113 return p < span ? p : span * 2 - p
114}
115const walkLegs = (t: number) => (t % 2 === 0 ? 'a' : 'b') as Pose['legs']
116/** Which way a pingPong(t, span) is heading. */
117const goingRight = (t: number, span: number) => t % (span * 2) < span
118/** Looks and turns toward `right`, with a blink now and then. */
119const facing = (t: number, right: boolean): Pose => ({
120 eyes: t % 20 === 0 ? 'blink' : right ? 'right' : 'left',
121 shade: right ? 'left' : 'right',
122})
123const pick = <T,>(xs: readonly T[], t: number) => xs[t % xs.length]
124
125type Scene = (g: Grid, t: number) => void
126
127const GRAY = 'gray'
128const BOARD = '#2E4A3B'
129const WOOD = '#8B5A2B'
130// What Clawd works out on the blackboard, two lines a go (8 columns each)
131const LESSONS: [string, string][] = [['a+b=c', 'x^2+1=y'], ['f(x)=?', ' = 42'], ['1+1=2', 'Q.E.D.'], ['if (ok)', ' ship!']]
132const scenes: Record<string, Scene> = {
133 idle: (g, t) => {
134 // a little hop, then it lands with a squash and settles
135 const p = t % 24
136 clawd(g, t, { lift: p === 10 ? 1 : p === 11 || p === 12 ? -1 : 0, eyes: p >= 9 && p <= 16 ? 'smile' : undefined })
137 // a little one copies the hop a beat late
138 mini(g, 12, MINI_COLORS[0], { lift: p === 12 ? 1 : 0, eyes: p % 12 === 5 ? 'blink' : 'left' })
139 },
140
141 think: (g, t) => {
142 clawd(g, t, { arms: t % 16 < 8 ? 'down' : 'right' })
143 const bubble = ['·', '· ∘', '· ∘ ○', '· ∘ ○ ?', '· ∘ ○ !'][Math.floor(t / 3) % 5]
144 put(g, 11, 0, bubble, GRAY)
145 },
146
147 cook: (g, t) => {
148 clawd(g, t, { arms: t % 4 < 2 ? 'left' : 'right' }) // stirring
149 put(g, 12, 1, pick([' ~ ', ' ~ ≈ ', '≈ ~ ≈', ' ≈ ~ '], t), GRAY)
150 put(g, 12, 2, '▄▄▄▄▄', 'white')
151 put(g, 12, 3, '▀███▀', 'white')
152 put(g, 11, 2, t % 4 < 2 ? '\\' : '|', 'yellow') // spoon
153 },
154
155 bake: (g, t) => {
156 clawd(g, t, { arms: t % 10 < 5 ? 'right' : 'down' })
157 put(g, 12, 1, '┌────┐', 'white')
158 put(g, 12, 2, `│${pick(['▒░▒░', '░▒░▒'], t)}│`, 'white')
159 put(g, 13, 2, pick(['▒░▒░', '░▒░▒'], t), 'red')
160 put(g, 12, 3, '└────┘', 'white')
161 put(g, 14, 0, pick(['~', ' ~', '~ ~'], Math.floor(t / 2)), GRAY)
162 },
163
164 brew: (g, t) => {
165 clawd(g, t, { arms: 'right' })
166 put(g, 12, 1, pick([' ≈ ', '≈ ', ' ≈'], Math.floor(t / 2)), GRAY)
167 put(g, 12, 2, '╭─╮╮', 'white')
168 put(g, 12, 3, '╰─╯ ', 'white')
169 put(g, 13, 2, '▀', 'yellow')
170 },
171
172 walk: (g, t) => {
173 // two little ones in a row behind it; at the end they all about-face, so the little ones lead the way back
174 const right = goingRight(t, roam(12))
175 const x = 13 + pingPong(t, roam(12))
176 clawd(g, t, { x, legs: walkLegs(t), arms: t % 2 ? 'left' : 'right', ...facing(t, right) })
177 const eyes = right ? 'right' : 'left'
178 mini(g, x - 6, MINI_COLORS[1], { eyes, legs: t % 2 ? 'a' : 'b' })
179 mini(g, x - 12, MINI_COLORS[2], { eyes, legs: t % 2 ? 'b' : 'a' })
180 },
181
182 run: (g, t) => {
183 const x = 1 + pingPong(t * 2, roam(0))
184 const right = goingRight(t * 2, roam(0))
185 clawd(g, t, { x, legs: walkLegs(t), lift: t % 4 === 0 ? 1 : 0, arms: t % 2 ? 'left' : 'right', ...facing(t, right) })
186 put(g, right ? x - 2 : x + 10, 2, '≡', GRAY) // speed lines, behind
187 },
188
189 moonwalk: (g, t) => {
190 const x = 1 + roam(3) - pingPong(t, roam(3))
191 // gliding backwards: it faces the other way from where it goes
192 clawd(g, t, { x, legs: walkLegs(t), arms: 'right', ...facing(t, goingRight(t, roam(3))) })
193 put(g, x + 10, 0, pick(['♪', ' ♫', '♪ ♫'], t), 'cyan')
194 },
195
196 herd: (g, t) => {
197 const x = 1 + pingPong(t, roam(7))
198 clawd(g, t, { x, legs: walkLegs(t), arms: 'up', ...facing(t, goingRight(t, roam(7))) })
199 const dir = t % (2 * roam(7)) < roam(7) ? 1 : -1
200 put(g, x + (dir > 0 ? 11 : -6), 3, pick(['o o o', ' o o o', 'o o o'], t), 'white') // the flock
201 },
202
203 spin: (g, t) => {
204 clawd(g, t, { x: 2 + (t % 2), arms: pick(['left', 'up', 'right', 'down'] as const, t) })
205 put(g, 12, 0, t % 2 ? '↻' : '↺', 'cyan')
206 put(g, 0, 2, pick(['(', ' ', ')', ' '], t), GRAY)
207 },
208
209 magic: (g, t) => {
210 clawd(g, t, { arms: 'up', lift: t % 8 < 4 ? 1 : 0 })
211 const sparks = ['✦', '✧', '⋆', '✶', '·']
212 for (let i = 0; i < 5; i++) put(g, 11 + ((i * 3 + t) % 9), (i + t) % 3, pick(sparks, t + i), pick(['magenta', 'cyan', 'yellow'], i + t))
213 },
214
215 levitate: (g, t) => {
216 clawd(g, t, { lift: 1, legs: 'tuck', arms: 'up' })
217 put(g, 3, 3, pick(['· ✧ ·', '✧ · ✧'], t), 'cyan')
218 },
219
220 hatch: (g, t) => {
221 // the egg wobbles and cracks, bursts, and a baby Clawd stands in the shell, then hops out beside it
222 const p = t % 48
223 const baby = MINI_COLORS[Math.floor(t / 48) % MINI_COLORS.length]
224 const hatched = p >= 26
225 clawd(g, t, { arms: 'right', lift: p === 27 ? 1 : 0, eyes: hatched ? 'smile' : t % 12 === 0 ? 'blink' : 'right', shade: 'left' })
226 if (!hatched) {
227 const wobble = p >= 6 && p < 24 ? [0, 1, 0, -1][t % 4] : 0
228 put(g, 13 + wobble, 2, p < 12 ? '▗▄▖' : p < 18 ? '▗╱▖' : p < 24 ? '▗╳▖' : '✧ ✧', 'white')
229 put(g, 13 + wobble, 3, '▝▀▘', 'white')
230 if (p >= 24) put(g, 12, 1, '✦ ✦', 'yellow')
231 return
232 }
233 put(g, 13, 3, '▝▀▘', 'white') // the bottom half of the shell stays behind
234 const eyes = p % 7 === 0 ? 'blink' : 'left' // looking up at the big one
235 if (p < 34) mini(g, 12, baby, { lift: 1, eyes })
236 else if (p === 34) mini(g, 14, baby, { lift: 2, eyes })
237 else mini(g, 17, baby, { eyes, lift: p === 40 || p === 42 ? 1 : 0 })
238 },
239
240 grow: (g, t) => {
241 clawd(g, t, { arms: 'right' })
242 const h = Math.floor(t / 4) % 4
243 for (let i = 0; i < h; i++) put(g, 13, 3 - i, i === h - 1 ? '❀' : '│', i === h - 1 ? 'magenta' : 'green')
244 put(g, 12, 3, '▁▁▁', 'yellow')
245 },
246
247 forge: (g, t) => {
248 const hit = t % 4 === 0
249 clawd(g, t, { arms: hit ? 'right' : 'up' })
250 put(g, 11, hit ? 2 : 0, hit ? '╾' : '╤', 'white') // hammer
251 put(g, 12, 3, '▀▀█▀▀', 'white')
252 if (hit) put(g, 12, 1, pick(['* ✶', '✶ *'], t), 'yellow')
253 },
254
255 compute: (g, t) => {
256 clawd(g, t, { arms: t % 2 ? 'left' : 'right' })
257 for (let row = 1; row <= 3; row++) {
258 const bits = Array.from({ length: 8 }, (_, i) => (((i + 1) * 7 + t * (row + 2)) % 3 === 0 ? '1' : '0')).join('')
259 put(g, 12, row, bits, 'green')
260 }
261 },
262
263 type: (g, t) => {
264 clawd(g, t, { arms: t % 2 ? 'left' : 'right' })
265 const keys = '▤▤▤▤▤▤'
266 put(g, 12, 3, keys, GRAY)
267 put(g, 12 + (t % keys.length), 3, '▣', 'white')
268 },
269
270 talk: (g, t) => {
271 clawd(g, t, { arms: t % 6 < 3 ? 'right' : 'down' })
272 put(g, 12, 1, pick(['‹ . ›', '‹ .. ›', '‹ ... ›'], t), 'white')
273 },
274
275 dance: (g, t) => {
276 clawd(g, t, { lift: t % 2 ? 1 : 0, arms: t % 4 < 2 ? 'left' : 'right', legs: walkLegs(t), eyes: t % 16 < 10 ? 'smile' : 'open', shade: t % 4 < 2 ? 'left' : 'right' })
277 put(g, 12, 0, pick(['♪', '♪ ♫', ' ♫ ♪', '♫'], t), 'cyan')
278 // two backup dancers, bouncing on the off-beat
279 mini(g, 12, MINI_COLORS[3], { lift: t % 2 ? 0 : 1, eyes: 'right', legs: t % 4 < 2 ? 'a' : 'b' })
280 mini(g, 18, MINI_COLORS[0], { lift: t % 2 ? 0 : 1, eyes: 'left', legs: t % 4 < 2 ? 'b' : 'a' })
281 },
282
283 juggle: (g, t) => {
284 clawd(g, t, { arms: t % 2 ? 'left' : 'right', lift: 0 }) // no hop: the balls on row 0 would land on its face
285 // left hand → over the head → right hand and back; row 1 only beside the body
286 const arc: [number, number][] = [[-1, 1], [1, 0], [4, 0], [7, 0], [9, 1], [7, 0], [4, 0], [1, 0]]
287 for (let b = 0; b < 3; b++) {
288 const [dx, y] = arc[(t + b * 3) % arc.length]
289 put(g, 2 + dx, y, 'o', pick(['red', 'yellow', 'cyan'], b))
290 }
291 },
292
293 honk: (g, t) => {
294 clawd(g, t, { arms: 'up', lift: t % 6 < 2 ? 1 : 0 })
295 if (t % 6 < 4) put(g, 12, 1, '< HONK! >', 'yellow')
296 },
297
298 sketch: (g, t) => {
299 clawd(g, t, { arms: t % 2 ? 'right' : 'down' })
300 put(g, 12, 1, '┌──────┐', 'white')
301 put(g, 12, 2, `│${'╱╲'.repeat(3).slice(0, (t % 7))}`.padEnd(7) + '│', 'white')
302 put(g, 12, 3, '└──────┘', 'white')
303 },
304
305 chalk: (g, t) => {
306 // the board stays put; Clawd writes, walks back to admire it, walks up again and wipes it
307 const BX = 16 // board columns 16-25, Clawd writes from 10 columns to its left
308 const p = t % 48
309 const [l1, l2] = pick(LESSONS, Math.floor(t / 48))
310 const wipe = (from: number, to: number) => {
311 for (let y = from; y < 3; y++) for (let x = BX; x < Math.min(to, g[y].length); x++) g[y][x] = { ch: ' ', color: 'white', bg: BOARD }
312 }
313 wipe(0, BX + 10)
314 put(g, BX, 3, '▀▀▀▀▀▀▀▀▀▀', WOOD) // chalk tray
315 const n = p < 24 ? p : l1.length + l2.length
316 put(g, BX + 1, 1, l1.slice(0, n), 'white', BOARD)
317 put(g, BX + 1, 2, l2.slice(0, Math.max(0, n - l1.length)), 'white', BOARD)
318 const home = BX - 10
319 if (p < 24) {
320 // scribbling: the hand bobs, chalk taps the board
321 clawd(g, t, { x: home, eyes: t % 12 === 0 ? 'blink' : 'right', arms: t % 2 ? 'right' : 'down', shade: 'left' })
322 put(g, BX - 1, 1 + (t % 2), '·', 'white')
323 } else if (p < 28) {
324 // backs away, still looking at the board
325 clawd(g, t, { x: home - (p - 23), eyes: 'right', legs: walkLegs(t), shade: 'left' })
326 } else if (p < 36) {
327 // admires the work
328 clawd(g, t, { x: home - 4, eyes: p === 28 ? 'open' : 'smile', arms: 'down', lift: p === 31 ? 1 : 0, shade: 'none' })
329 } else if (p < 40) {
330 clawd(g, t, { x: home - 3 + (p - 36), legs: walkLegs(t), ...facing(t, true) })
331 } else {
332 // wipes left to right
333 const ex = BX + (p - 40) * 2
334 wipe(1, Math.min(ex, BX + 10))
335 put(g, Math.min(ex, BX + 8), 1, '▆▆', WOOD, BOARD)
336 clawd(g, t, { x: home, eyes: 'right', arms: t % 2 ? 'right' : 'down', shade: 'left' })
337 }
338 },
339
340 weather: (g, t) => {
341 clawd(g, t, { eyes: 'open', arms: 'up' })
342 // the cloud hangs beside it, so the rain never falls on its face
343 put(g, 12, 0, '☁☁☁', 'white')
344 for (let y = 1; y < H; y++) put(g, 12 + ((t + y) % 3), y, '╵', 'cyan')
345 },
346
347 thunder: (g, t) => {
348 clawd(g, t, { arms: t % 4 === 0 ? 'up' : 'down', lift: t % 4 === 0 ? 1 : 0 })
349 put(g, 12, 0, '☁☁', GRAY)
350 if (t % 4 === 0) put(g, 13, 1, 'ϟ', 'yellow')
351 },
352
353 dig: (g, t) => {
354 // tunnels along the ground at a third of walking pace, dirt flying out behind
355 const x = 1 + pingPong(Math.floor(t / 3), roam(2))
356 const soil = '▒▓▒░▓▒▒░▓▒▓░'
357 put(g, 0, 3, Array.from({ length: stride.width }, (_, i) => soil[i % soil.length]).join(''), 'yellow')
358 clawd(g, t, { x, lift: 0, arms: t % 2 ? 'left' : 'right', legs: 'tuck' })
359 put(g, x - 2, 2, pick(['· ', ' ∙', '∙·'], t), 'yellow')
360 },
361
362 kick: (g, t) => {
363 const p = t % 14 // 0-3 wind up, 3 kick, 4-7 ball flies, 7+ celebrate
364 const kicking = p === 3
365 clawd(g, t, { arms: p >= 7 ? 'up' : kicking ? 'right' : 'down', legs: kicking ? 'b' : undefined, lift: p >= 7 && p % 2 ? 1 : 0 })
366 put(g, 18, 1, '┌─', 'white') // goal
367 put(g, 18, 2, '│#', 'white')
368 put(g, 18, 3, '│#', 'white')
369 const bx = p < 4 ? 11 : Math.min(19, 11 + (p - 3) * 2)
370 put(g, bx, p >= 4 && p <= 6 ? 2 : 3, '●', 'white')
371 if (p >= 7) put(g, 12, 0, 'GOAL!', 'yellow')
372 },
373
374 skate: (g, t) => {
375 // glides across the ice on one blade then the other, arms out for balance, frost trailing behind
376 const span = roam(2)
377 const x = 1 + pingPong(t, span)
378 const right = t % (2 * span) < span
379 put(g, 0, 3, '─'.repeat(stride.width), 'cyan') // the rink
380 const legs = Math.floor(t / 2) % 2 ? 'a' : 'b'
381 clawd(g, t, { x, lift: 1, arms: t % 8 < 4 ? 'up' : right ? 'right' : 'left', legs, ...facing(t, right) })
382 // a solid blade right under each foot, so it reads apart from the thin ice line
383 put(g, x + 1, 3, '▀▀▀', 'white')
384 put(g, x + 5, 3, '▀▀▀', 'white')
385 put(g, right ? x - 3 : x + 10, 2, pick(['· ', ' · ', ' ·'], t), 'white')
386 },
387
388 campfire: (g, t) => {
389 // two Clawds sit still on either side of the fire, gazing in: a slow blink now and then,
390 // a hand held out to the warmth; only the fire and the eyes move.
391 // With room to spare a little one sits on each outer side, blinking out of step with the big ones.
392 const o = stride.width >= 37 ? 4 : 0
393 const warm = t % 20 < 12
394 // eyes close happily while the hands are warm
395 const eyes = t % 30 < 2 ? 'blink' : warm ? 'smile' : 'open'
396 clawd(g, t, { x: 2 + o, eyes, arms: warm ? 'right' : 'down' })
397 clawd(g, t, { x: 18 + o, eyes, arms: warm ? 'left' : 'down', color: PURPLE })
398 if (o) {
399 mini(g, 0, MINI_COLORS[1], { eyes: t % 30 === 11 || t % 30 === 13 ? 'blink' : 'right' })
400 mini(g, 32, MINI_COLORS[3], { eyes: t % 30 >= 20 && t % 30 < 22 ? 'blink' : 'left' })
401 }
402 put(g, 13 + o, 0, pick([' ·', ' ˙ ', '· ', ' ', ' · '], Math.floor(t / 2)), 'yellow') // sparks
403 put(g, 13 + o, 1, pick([' ▲ ', '▴▲ ', ' ▲▴', ' ▴ '], t), 'yellow')
404 put(g, 13 + o, 2, pick(['▟█▙', '▟▙▙', '▟█▟', '▙█▙'], t), 'red')
405 put(g, 14 + o, 2, '▒', 'yellow') // the hot core
406 put(g, 12 + o, 3, '═╳═╳═', '#8B5A2B') // logs
407 },
408
409 flow: (g, t) => {
410 clawd(g, t, { lift: t % 6 < 3 ? 1 : 0 })
411 const wave = '∿~'
412 put(g, 0, 3, Array.from({ length: 16 }, (_, i) => wave[(i + t) % 2]).join(''), 'cyan')
413 },
414}
415
416// Spinner word → scene, by stem; the first match wins
417const VERBS: [RegExp, string][] = [
418 [/moonwalk/, 'moonwalk'],
419 [/campfir|bonfir|kindl|smolder|smoulder|stok|crackl|flicker|glow|fire/, 'campfire'],
420 [/skat|glid|slid|skid|coast/, 'skate'],
421 [/kick|dribbl|scor|punt|volley|soccer|footbal/, 'kick'],
422 [/levitat|hyperspac/, 'levitate'],
423 [/thunder/, 'thunder'],
424 [/honk|boop|hullaballoo|shenanigan|tomfool|razzle/, 'honk'],
425 [/juggl/, 'juggle'],
426 [/sketch|doodl|draw/, 'sketch'],
427 [/deciph|elucidat|philosoph|pontificat|deliberat|determin|reason|theoriz|theoris|hypothes|deduc|formulat|scribbl|jott|lectur|teach|explain/, 'chalk'],
428 [/bak|proof|leaven|knead|frost|roast/, 'bake'],
429 [/brew|percolat|ferment|steep/, 'brew'],
430 [/cook|simmer|stew|saut|marinat|flamb|julienn|season|garnish|zest|blanch|carameli|drizzl|whisk|concoct|infus|temper|boil|fry/, 'cook'],
431 [/herd|wrangl|muster/, 'herd'],
432 [/scamper|scurr|skedaddl|gallop|hustl|catapult|swoop|pounc|zoom|dash/, 'run'],
433 [/mosey|schlep|putter|wander|meander|perambul|gallivant|stroll|waddl|lollygag|dilly|zigzag|scuttl/, 'walk'],
434 [/spin|churn|whirr|swirl|whirlpool|twist|reticulat|orbit|cascad|topsy|wibbl|combobul|coalesc|smoosh/, 'spin'],
435 [/conjur|manifest|transmut|transfigur|enchant|prestidig|metamorph|quantum|warp|sublimat|ioniz|crystalli|channel|alchem/, 'magic'],
436 [/hatch|incubat|nest|roost/, 'hatch'],
437 [/germinat|sprout|pollinat|photosynth|cultivat|propagat|unfurl|symbiot|bloom|nucleat/, 'grow'],
438 [/vib|groov|boogi|jitterbug|shimm|sock|bebop|frolic|choreograph|harmoni|orchestrat|compos|improvis|jam/, 'dance'],
439 [/calculat|comput|crunch|process|infer|synthesi|analy|hash|gitif|quantif/, 'compute'],
440 [/forg|craft|build|construct|tinker|form|creat|generat|actualiz|accomplish|action|effect|embellish|bootstrap|doing|work/, 'forge'],
441 [/mist|nebuli|evaporat|billow|gust|precipitat|cloud/, 'weather'],
442 [/burrow|spelunk|slither|dig/, 'dig'],
443 [/flow|ebb|osmos|undulat|wav/, 'flow'],
444 [/ponder|mus|mull|ruminat|cogitat|cerebrat|consider|contemplat|think|puzzl|envision|imagin|ideat|perus|bloviat|befuddl|flummox|noodl|claud/, 'think'],
445]
446
447export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
448
449/** Which scene a spinner word acts out; an unknown word falls back to what the turn is doing. */
450export const sceneFor = (word: string, mode: Mode) => {
451 const w = word.toLowerCase().replace(/[^a-z]/g, '')
452 const hit = VERBS.find(([re]) => re.test(w))
453 if (hit) return hit[1]
454 return mode === 'tool-use' || mode === 'tool-input' ? 'type' : mode === 'responding' ? 'talk' : mode === 'thinking' ? 'think' : 'idle'
455}
456
457// Only scenes whose extras belong to Clawd (a bubble, notes, sparks) wander; the rest travel on their own
458// or stand by a pot, a board, an anvil… that must stay put while Clawd works at it
459export const WANDERS = new Set(['idle', 'think', 'talk', 'dance', 'juggle', 'honk', 'levitate', 'magic', 'spin'])
460const PROPS_WIDTH = 23 // the widest scene's Clawd plus props, from column 0 (dance with its backup dancers)
461const PAUSE = 10 // frames spent standing at each end
462
463/** Where the wander puts the frame: stroll right, pause, stroll left, pause; a hop at each turn. */
464export const wander = (t: number, width: number) => {
465 const span = Math.max(0, Math.min(width - PROPS_WIDTH, 30))
466 if (span === 0) return { dx: 0, moving: false, hop: false, squash: false, dir: 1 }
467 const period = 2 * (span + PAUSE)
468 const p = t % period
469 // each end: hop, land with a squash for two frames, then stand
470 const atEnd = (q: number) => ({ moving: false, hop: q === 0, squash: q === 1 || q === 2 })
471 if (p < span) return { dx: p, moving: true, hop: false, squash: false, dir: 1 }
472 if (p < span + PAUSE) return { dx: span, ...atEnd(p - span), dir: 1 }
473 if (p < 2 * span + PAUSE) return { dx: 2 * span + PAUSE - p, moving: true, hop: false, squash: false, dir: -1 }
474 return { dx: 0, ...atEnd(p - 2 * span - PAUSE), dir: -1 }
475}
476
477const shift = (g: Grid, dx: number): Grid =>
478 dx === 0 ? g : g.map(row => row.map((_, x) => (x - dx >= 0 ? row[x - dx] : { ch: ' ' })))
479
480/** One frame of the stage, as rows of same-colour runs. */
481export const frameRows = (word: string, mode: Mode, t: number, width: number = W, scene?: string) => {
482 const name = scene ?? sceneFor(word, mode)
483 const step = WANDERS.has(name) ? wander(t, width) : { dx: 0, moving: false, hop: false, squash: false, dir: 1 }
484 stride.moving = step.moving
485 stride.hop = step.hop
486 stride.squash = step.squash
487 stride.dir = step.dir
488 stride.width = width
489 const g0 = blank(width)
490 ;(scenes[name] ?? scenes.idle)(g0, t)
491 const g = shift(g0, step.dx)
492 return g.map(row => {
493 const runs: { text: string; color?: string; bg?: string }[] = []
494 for (const c of row) {
495 const last = runs[runs.length - 1]
496 if (last && last.color === c.color && last.bg === c.bg) last.text += c.ch
497 else runs.push({ text: c.ch, color: c.color, bg: c.bg })
498 }
499 return runs
500 })
501}
502
503export const SCENE_NAMES = Object.keys(scenes)
504
505// ---- Which scene plays when: a running tool first, else the spinner word's act, then a rotation ----
506
507/** A running tool's scene, by tool name; any other tool hammers away. */
508const TOOL_SCENES: [RegExp, string][] = [
509 [/^(Bash|PowerShell|Monitor)$/, 'type'],
510 [/^(Read|Grep|Glob|LS)$/, 'think'],
511 [/^(Edit|Write|MultiEdit|NotebookEdit)$/, 'sketch'],
512 [/^(Agent|Task|SendMessage)$/, 'herd'],
513 [/^Web/, 'weather'],
514 [/^(TodoWrite|Skill|ToolSearch)$/, 'juggle'],
515 [/^mcp__/, 'magic'],
516]
517export const sceneForTool = (tool: string) => TOOL_SCENES.find(([re]) => re.test(tool))?.[1] ?? 'forge'
518
519export const FRAME_MS = 140
520export const ACT_MS = 15_000 // each act runs a quarter of a minute
521export const ACT_FRAMES = Math.round(ACT_MS / FRAME_MS)
522const ROTATION = SCENE_NAMES.filter(n => n !== 'idle' && n !== 'type' && n !== 'talk')
523
524/** `played`: rotation acts earlier turns of the session got through, so the rotation picks up where it left off */
525export type Cue = { word: string; mode: Mode; tick: number; seed: number; tool?: string; played?: number }
526
527/**
528 * The scene for this frame of a turn. Act 0 opens on the running tool, else the spinner word;
529 * every later act steps through the rotation in an order the session's seed shuffles
530 * (a step coprime with the list's length visits all), whatever tool runs, skipping the opener.
531 */
532export const sceneAt = ({ word, mode, tick, seed, tool, played = 0 }: Cue) => {
533 const first = tool ? sceneForTool(tool) : sceneFor(word, mode)
534 const act = Math.floor(Math.max(0, tick) / ACT_FRAMES)
535 if (act === 0) return first
536 const n = ROTATION.length
537 const step = [7, 5, 3, 11, 13].find(k => n % k !== 0) ?? 1
538 const i = seed + (played + act - 1) * step
539 // the next one along is neither of its neighbours, as the step is not ±1
540 return ROTATION[i % n] === first ? ROTATION[(i + 1) % n] : ROTATION[i % n]
541}
542types/index.d.ts 8 lines1export type Frame = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'clawd-actor': { frame: Frame }
6 }
7}
8