SLOPSHOPPER

clawd-actor

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

newbandspinnerguardtimer
v0.3.0MITupdated 2026-10-08BrianHuang813/clawd-actor
A shopper browsing a rack in a slop shop
README

clawd-actor

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.

Clawd watching a baby Clawd hatch, dancing with two backup dancers, walking with two little ones in a row, and cooking at a pot that stays put

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/.

What it does

  • Acts out the spinner word. 29 scenes, matched by word stem, so Flambéing cooks and Dilly-dallying strolls. A word it does not know falls back to what the turn is doing (typing for tool use, talking while responding, thinking otherwise).
  • Acts out the running tool. In a turn's first 15 seconds a running tool picks the scene instead: 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.
  • Changes scene every 15 seconds on long turns, whatever tools run, in an order shuffled per session that picks up where the last turn stopped, never the same scene twice in a row.
  • Wanders the stage. When all it carries is a thought bubble, notes or sparks, Clawd strolls left and right, swinging arms and legs, hopping at each turn and landing with a squash. By a pot, an oven, an anvil or a plant it stays put and works; the stage follows the terminal width (up to 60 columns). At the blackboard the board stays put and Clawd steps back to admire its work. By the campfire it sits still with a purple friend, and only the fire and their eyes move.
  • Brings little friends. Small Clawds in blue, yellow, green and pink tag along: one copies its idle hop a beat late, two dance backup, two walk in a row behind it (and lead the way back), two sit by the campfire when the stage is wide enough, and one hatches from the egg, stands in its shell and hops out to say hello.
  • Has a face. Happy ^ ^ eyes, blinks, glances around, and sways so its far side falls into shadow, after the official Clawd animation; on the move it looks where it is going.
  • Leaves when the turn ends, and draws nothing while idle (the frame timer only redraws during a turn).
SceneSpinner words (some of them)
thinkPondering, Musing, Clauding, Noodling, Ruminating
chalkDeciphering, Elucidating, Philosophising, Deliberating, Reasoning
cook / bake / brewSautéing, Simmering, Whisking / Baking, Kneading / Brewing, Percolating
walk / run / moonwalkMoseying, Wandering / Scampering, Galloping / Moonwalking
herdHerding, Wrangling, Mustering
spinSpinning, Churning, Reticulating, Combobulating
magic / levitateConjuring, Manifesting, Transmuting / Levitating, Hyperspacing
hatch / growHatching, Incubating / Sprouting, Germinating
forge / computeForging, Crafting, Creating / Computing, Crunching, Synthesizing
dance / juggle / honkVibing, Grooving, Boogieing / Juggling / Honking, Booping
sketch / weather / thunderSketching, Doodling / Misting, Billowing / Thundering
dig / flowBurrowing, Spelunking / Flowing, Undulating
kick / skate / campfireKicking, Dribbling / Skating, Gliding / Kindling, Smoldering — no built-in word matches these yet, so they mostly turn up in the rotation

Install

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

Plays well with other band mods

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.

How it works

  • 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.
  • One timer, started in 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.
  • Whether to draw at all follows the band's own 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.

Develop

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.

Notes

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.

Source 3 files
hooks/register.tsx 85 lines
1import { 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}
85
hooks/scenes.ts 542 lines
1// 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}
542
types/index.d.ts 8 lines
1export type Frame = number
2
3declare module 'claude-code' {
4  interface PluginState {
5    'clawd-actor': { frame: Frame }
6  }
7}
8