SLOPSHOPPER

dancing-clawd

A tiny Claude mascot above the prompt that dances to the spinner's word, moonwalks on Moonwalking, and grooves between turns.

newpanebandspinnerguardcommand
v1.0.0MITupdated 2026-10-14TaggalongTech/dancing-clawd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dancing-clawd
│ ┃ Clawd's dance card ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Dance card: 1 of 21 moves │ dancing-clawd │ │ ┃ ⏺ Read(src/auth.ts) │ Clawd is doing all 21 dances in a row, │ │ ┃ · ??? · ??? ⎿ Read 6 lines │ about 65s. Watch above the prompt. │ │ ┃ · ??? · ??? ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ · ??? · ??? ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ · ??? · ??? ⏺ Bash(bun test) │ dancing-clawd │ │ ┃ · ??? ✓ Facepalm ⎿ 3 pass, 1 fail │ Clawd is chilling. /clawd-chill again to │ │ ┃ · ??? · ??? │ get him dancing. │ │ ┃ · ??? · ??? ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ · ??? · ??? │ ┃ · ??? ★ ??? ✻ Worked for 42s · done 4:20 PM │ ┃ · ??? ★ ??? │ ┃ · ??? › /clawd-demo │ ┃ │ ┃ Hover over Clawd for ♥ ☕ ♪, or ctrl+x tab │ ┃ to his strip and press 1 2 3. │ ┃ /clawd-chill he chills, or dances again │ ┃ /clawd-demo every move in a row │ ┃ ★ moves are secret. Esc closes this. │ ▐▗ ▗ ▀▜▀▜▀▘ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▐▗ ▗ ▀▜▀▜▀▘
Pane · Clawd's dance card
Dance card: 1 of 21 moves · ??? · ??? · ??? · ??? · ??? · ??? · ??? · ??? · ??? ✓ Facepalm · ??? · ??? · ??? · ??? · ??? · ??? · ??? ★ ??? · ??? ★ ??? · ??? Hover over Clawd for ♥ ☕ ♪, or ctrl+x tab to his strip and press 1 2 3. /clawd-chill he chills, or dances again /clawd-demo every move in a row ★ moves are secret. Esc closes this.
README

Dancing Clawd

An unofficial fan mod. Not affiliated with or endorsed by Anthropic.

A tiny Claude mascot for Claude Code. He stands just above your prompt, about 5 cells wide and 2 rows tall, drawn in the same block style as Claude Code's own logo, and he loves to dance:

Clawd dancing to Claude Code's spinner words, cheering at passing tests, chilling, catching hearts, and a glimpse of the party

  • While Claude works he dances to the spinner's word, travelling back and forth across his strip as he goes. Each word gets its own dance: pondering words look around with a hand to the chin, cooking words stir the pot, spinning words spin, frolicking words skip, wandering words walk the strip, and every other word gets a mix of moves picked from the word itself.
  • Only "Moonwalking…" gets the moonwalk: he glides backwards across his strip, facing forward.
  • He reacts to what Claude does, in short bursts: peers around while it reads, hammers while it edits, types while it runs commands, does a victory dance when tests pass, facepalms when they fail, throws confetti on a git commit, and takes a little bow when a reply is done. He never changes or slows anything Claude does.
  • Between turns he hangs about: mostly still, a blink, a glance, a small move now and then. After 3 quiet minutes he naps and stops animating entirely; your next message wakes him with a stretch.
  • Play with him: hover over him for ♥ (pet him), ☕ (coffee) and ♪ (request a dance). Without a mouse, ctrl+x tab to his strip and press 1, 2 or 3.
  • Collect his moves: the first time you see one, he tells you. /clawd opens your dance card, with everything he can do. Some moves are secret.
  • /clawd-chill tells him to stop dancing and chill: eyes shut, a cigarette in hand, smoke curling up. Run it again to get him dancing. He remembers between sessions.
  • Now and then he does something else entirely. Keep an eye on him.
  • The right words in a prompt start a party. We're not saying which. 🪩

Requirements

  • Claude Code 2.1.296 or newer. He's a mod built on function hooks, an early-access part of Claude Code; older versions may not load him.
  • Claude Code running in a terminal. He doesn't appear in the desktop app.
  • Looks best on a dark theme. Built and tested in VS Code's integrated terminal; other terminals draw block characters their own way and may differ slightly.

Install

Type this at the prompt of a Claude Code terminal session:

/plugin install dancing-clawd --marketplace TaggalongTech/dancing-clawd

Answer y to add the marketplace, then press Enter for the user scope.

Tuck him away, or remove him

  • Collapse his strip with ctrl+x ctrl+a, or click the [-] at its end. Do the same to bring him back.
  • Remove him for good with /plugin uninstall dancing-clawd.

See every dance

/clawd-demo

Plays every dance back to back, then /clawd-chill, his reactions and his nap, each labeled with what sets it off. The demo doesn't count toward your dance card.

/clawd, /clawd-demo and /clawd-chill work even while Claude is busy: they take effect at once, never hold Claude up, and answer with a pop-up rather than a message in the conversation.

Working on it

Load your working copy instead of an installed one, so each save reloads him:

claude --plugin-dir ~/claude-mods/dancing-clawd

or, for every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Don't also install him from the marketplace, or two copies load.

  • hooks/register.tsx: the sprite, the dances, and the hooks. Moves are poses strung into phrases (PHRASES); spinner words pick phrases through THEMES.
  • tests/dance.test.tsx: run with claude plugin test .
  • scripts/build_preview.py: rebuilds assets/preview.svg from his real frames after you change a move: python3 scripts/build_preview.py
  • His solid parts are painted as background color and he only ever stands on even pixels, so no frame uses ▛: terminals draw that character as two pieces, which shows a hairline when cells are an odd number of pixels wide. A test checks every frame.
  • Check before committing: claude plugin validate . and, once Claude Code has loaded the folder (it writes .claude-plugin/types/), npx tsc -p .

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 1170 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { DemoLabel, SeenMoves } from '../types'
5
6// A mini Clawd, drawn the way Claude Code's own logo is: quadrant blocks, so
7// each terminal cell is 2x2 pixels. The stage is 28x4 pixels, 14 cells by 2
8// rows; Clawd is 11x4 of it and moves about the rest, with room for two of
9// him. He only ever stands on even pixels: that keeps every outline cell
10// clear of ▛, which terminals draw as two pieces with a hairline between.
11const W = 28
12const H = 4
13const HOME = 8 // Clawd's left edge, in pixels, when he stands centered
14const FAR_RIGHT = W - 12 // his left edge at the stage's right end
15export const COLUMNS = W / 2
16export const ROWS = H / 2
17export const TICK_MS = 120 // one move per beat while Claude works; one per two beats idle
18
19const ORANGE = 0xd77757
20const EYE = 0x191a1b
21const DEFAULT = 0x01000000 // the terminal's own color
22const QUADRANTS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█' // indexed by mask: UL=1 UR=2 LL=4 LR=8
23
24type Arm = 'mid' | 'up' | 'down' | 'in'
25type Pose = {
26  x: number // left edge on the stage, in pixels
27  left: Arm
28  right: Arm
29  stride: number // feet stay on the ground; walking steps them out to his edges (1)
30  look: number // eyes glance left (-1) or right (1)
31  face: 'front' | 'side' | 'back'
32  blink: boolean
33}
34
35const STAND: Pose = {
36  x: HOME,
37  left: 'mid',
38  right: 'mid',
39  stride: 0,
40  look: 0,
41  face: 'front',
42  blink: false,
43}
44const P = (changes: Partial<Pose>): Pose => ({ ...STAND, ...changes })
45const ROOF = P({ left: 'up', right: 'up' })
46
47// Short phrases a dance is strung together from.
48const PHRASES = {
49  roof: [STAND, ROOF, STAND, ROOF],
50  swayLeft: [P({ x: HOME - 2, left: 'up', look: -1 }), P({ x: HOME - 2, look: -1 }), STAND],
51  swayRight: [P({ x: HOME + 2, right: 'up', look: 1 }), P({ x: HOME + 2, look: 1 }), STAND],
52  wave: [P({ left: 'up', look: -1 }), P({ right: 'up', look: 1 }), P({ left: 'up', look: -1 }), P({ right: 'up', look: 1 })],
53  shimmy: [
54    P({ x: HOME + 2, left: 'up', right: 'down' }),
55    P({ x: HOME - 2, left: 'down', right: 'up' }),
56    P({ x: HOME + 2, left: 'up', right: 'down' }),
57    P({ x: HOME - 2, left: 'down', right: 'up' }),
58    STAND,
59  ],
60  spin: [
61    P({ face: 'side', look: 1, left: 'in', right: 'in' }),
62    P({ face: 'back' }),
63    P({ face: 'side', look: -1, left: 'in', right: 'in' }),
64    STAND,
65    ROOF,
66  ],
67  ponder: [
68    P({ look: -1 }),
69    P({ look: -1 }),
70    P({ look: 1 }),
71    P({ look: 1 }),
72    P({ right: 'up' }),
73    P({ right: 'up', blink: true }),
74    P({ right: 'up' }),
75  ],
76  stir: [P({ right: 'up', look: 1 }), P({ look: 1 }), P({ right: 'down', look: 1 }), P({ look: 1 })],
77  hammer: [
78    P({ left: 'up', right: 'up' }),
79    P({ left: 'down', right: 'down' }),
80    P({ left: 'up', right: 'up' }),
81    P({ left: 'down', right: 'down', look: 1 }),
82  ],
83  frolic: [
84    P({ x: HOME - 2, left: 'up' }),
85    P({ x: HOME, right: 'up' }),
86    P({ x: HOME + 2, right: 'up' }),
87    P({ x: HOME, left: 'up' }),
88  ],
89  flail: [
90    P({ left: 'up', right: 'down', look: -1 }),
91    P({ left: 'down', right: 'up', look: 1 }),
92    P({ left: 'in', right: 'up', blink: true }),
93    P({ left: 'up', right: 'in' }),
94  ],
95  blink: [STAND, P({ blink: true }), STAND],
96} satisfies Record<string, Pose[]>
97type Phrase = keyof typeof PHRASES
98
99// Walking: a cell a beat, feet stepping out and in, from one spot to another.
100function walk(from: number, to: number, look: number): Pose[] {
101  const poses: Pose[] = []
102  const dir = 2 * Math.sign(to - from)
103  for (let x = from, i = 0; x !== to + dir; x += dir, i++) {
104    poses.push(P({ x, look, stride: i % 2 }))
105  }
106  return poses
107}
108
109// The moonwalk glides backwards: facing one way, sliding the other.
110const MOONWALK_POSES = [
111  ...walk(FAR_RIGHT, 0, 1),
112  P({ x: 0, face: 'side', look: -1, left: 'in', right: 'in' }),
113  ...walk(0, FAR_RIGHT, -1),
114  P({ x: FAR_RIGHT, face: 'side', look: 1, left: 'in', right: 'in' }),
115]
116const WANDER_POSES = [
117  ...walk(HOME, FAR_RIGHT, 1),
118  P({ x: FAR_RIGHT, face: 'side', look: -1, left: 'in', right: 'in' }),
119  ...walk(FAR_RIGHT, 0, -1),
120  P({ x: 0, face: 'side', look: 1, left: 'in', right: 'in' }),
121  ...walk(0, HOME, 1),
122]
123
124// Spinner words with a dance of their own; the moonwalk is Moonwalking's alone.
125const THEMES: { name: string; match: RegExp; phrases: Phrase[] }[] = [
126  { name: 'spin', match: /spin|whirr|whirl|twirl|swirl|churn|reticulat|spiral|rotat/, phrases: ['spin', 'spin'] },
127  {
128    name: 'shimmy',
129    match: /shimm|jiv|vib|boogi|groov|danc|wiggl|jitterbug|tango|waltz|rock/,
130    phrases: ['shimmy', 'roof', 'shimmy'],
131  },
132  {
133    name: 'ponder',
134    match: /wonder|ponder|mus(e|ing)|contemplat|consider|mull|rumina|cogitat|deliberat|think|philosoph|cerebrat|reflect|specul|puzzl|medit|ideat|imagin|envision|inferr|deciph/,
135    phrases: ['ponder', 'blink', 'ponder'],
136  },
137  {
138    name: 'stir',
139    match: /cook|brew|bak|simmer|stew|marinat|concoct|percolat|saut|whisk|stir|fry|roast|ferment|caramel|garnish|knead|season|chef/,
140    phrases: ['stir', 'stir', 'roof'],
141  },
142  {
143    name: 'hammer',
144    match: /work|craft|forg|tinker|build|hustl|comput|process|crunch|hammer|wrangl|construct|assembl|engineer|smith|putter|accomplish|effect|doing/,
145    phrases: ['hammer', 'hammer', 'roof'],
146  },
147  {
148    name: 'frolic',
149    match: /frolic|skip|gallivant|pranc|romp|cavort|hop|scamper|bound|caper|frisk|gambol/,
150    phrases: ['frolic', 'frolic', 'roof'],
151  },
152  {
153    name: 'flail',
154    match: /flibbertigibbet|discombobul|combobul|wibbl|befuddl|finagl|bamboozl|kerfuffl|flummox|razzle|hullabaloo|shenanigan|lollygag/,
155    phrases: ['flail', 'shimmy', 'flail'],
156  },
157  {
158    name: 'magic',
159    match: /conjur|enchant|wizard|divin|manifest|transmut|alchem|spell|sorcer|summon|channel|hatch|incubat|germinat/,
160    phrases: ['roof', 'spin', 'blink'],
161  },
162  { name: 'wave', match: /greet|wav|hello|salut|beckon|honk|boop/, phrases: ['wave', 'wave'] },
163]
164const MIXABLE: Phrase[] = [
165  'roof',
166  'swayLeft',
167  'swayRight',
168  'wave',
169  'shimmy',
170  'spin',
171  'ponder',
172  'stir',
173  'hammer',
174  'frolic',
175  'flail',
176]
177
178function hash(text: string): number {
179  let h = 0x811c9dc5
180  for (let i = 0; i < text.length; i++) {
181    h ^= text.charCodeAt(i)
182    h = Math.imul(h, 0x01000193) >>> 0
183  }
184  return h
185}
186
187// A few phrases picked from the word itself, so each word dances its own way.
188function mix(word: string, count: number, skip: Phrase[] = []): Phrase[] {
189  let h = hash(word)
190  const pool = MIXABLE.filter(p => !skip.includes(p))
191  const picked: Phrase[] = []
192  while (picked.length < count && pool.length > 0) {
193    picked.push(pool.splice(h % pool.length, 1)[0]!)
194    h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) >>> 0
195  }
196  return picked
197}
198
199// A frame is everyone on stage at one beat: Clawd, or Clawd and his twin.
200type Frame = Pose[]
201// `tempo` is beats per frame (1 when absent); `dress` adds what poses can't
202// draw, such as eyes, sparkles, a cigarette or smoke, to a frame's cells, placed
203// from `x`, where Clawd stands in that frame.
204export type Dance = {
205  name: string
206  frames: Frame[]
207  tempo?: number
208  dress?: (cells: Cell[], frame: number, x: number) => void
209}
210const solo = (poses: Pose[]): Frame[] => poses.map(pose => [pose])
211
212export function danceFor(word: string | undefined): Dance {
213  const w = (word ?? '').toLowerCase()
214  if (w.startsWith('moonwalk')) return { name: 'moonwalk', frames: solo(MOONWALK_POSES) }
215  if (/wander|meander|perambulat|schlep|mosey|spelunk|stroll|saunter|roam|explor|traips|herd/.test(w)) {
216    return { name: 'wander', frames: solo(WANDER_POSES) }
217  }
218  const theme = THEMES.find(t => t.match.test(w))
219  const phrases = theme ? [...theme.phrases, ...mix(w, 1, theme.phrases)] : mix(w, 3)
220  return {
221    name: `${theme?.name ?? 'mix'}:${phrases.join('+')}`,
222    frames: solo(phrases.flatMap(p => PHRASES[p])),
223  }
224}
225
226// Between turns he hangs about: mostly still, a blink, a glance each way, and
227// one small move in a loop of about ten seconds.
228const still = (n: number): Pose[] => Array.from({ length: n }, () => STAND)
229export const IDLE: Dance = {
230  name: 'idle',
231  frames: solo([
232    ...still(10),
233    P({ blink: true }),
234    ...still(6),
235    P({ look: -1 }),
236    P({ look: -1 }),
237    P({ look: -1 }),
238    ...still(2),
239    P({ look: 1 }),
240    P({ look: 1 }),
241    P({ look: 1 }),
242    ...still(5),
243    P({ blink: true }),
244    ...still(3),
245    P({ left: 'up', look: -1 }),
246    STAND,
247    P({ right: 'up', look: 1 }),
248    ...still(5),
249  ]),
250  tempo: 2,
251}
252
253// The secret move: Clawd shakes, splits in two, the twins dance facing each
254// other and then side by side, and they merge back into one.
255const TWIN_L = 2 // the twins' spots, a cell apart
256const TWIN_R = 14
257const facing = (a: Partial<Pose> = {}, b: Partial<Pose> = {}): Frame => [
258  P({ x: TWIN_L, look: 1, ...a }),
259  P({ x: TWIN_R, look: -1, ...b }),
260]
261const together = (pose: Partial<Pose> = {}, dx = 0): Frame => [
262  P({ ...pose, x: TWIN_L + dx }),
263  P({ ...pose, x: TWIN_R + dx }),
264]
265const one = (pose: Partial<Pose> = {}): Frame => [P(pose)]
266const halfway: Frame = [P({ x: HOME - 4, look: 1 }), P({ x: HOME + 4, look: -1 })]
267const stretched: Frame = [P({ x: HOME - 2 }), P({ x: HOME + 2 })]
268// The right twin steps in so their raised hands meet on one pixel.
269const highFive = facing({ right: 'up' }, { x: TWIN_L + 10, left: 'up' })
270
271export const SPLIT: Dance = {
272  name: 'split',
273  frames: [
274    // shake, stretch, split
275    one(),
276    one({ x: HOME - 2, blink: true }),
277    one({ x: HOME + 2, blink: true }),
278    one({ x: HOME - 2, blink: true }),
279    one({ x: HOME + 2, blink: true }),
280    stretched,
281    halfway,
282    facing(),
283    facing(),
284    // face to face: wave, high five, step apart
285    facing({ right: 'up' }, { left: 'up' }),
286    facing(),
287    facing({ right: 'up' }, { left: 'up' }),
288    facing(),
289    highFive,
290    highFive,
291    facing(),
292    facing({ x: TWIN_L - 2, left: 'up', look: -1 }, { x: TWIN_R + 2, right: 'up', look: 1 }),
293    facing({ x: TWIN_L - 2, look: -1 }, { x: TWIN_R + 2, look: 1 }),
294    facing(),
295    // side by side: raise the roof, sway left, sway right, spin
296    together(),
297    together({ left: 'up', right: 'up' }),
298    together(),
299    together({ left: 'up', right: 'up' }),
300    together({ left: 'up', look: -1 }, -2),
301    together({ look: -1 }, -2),
302    together(),
303    together({ right: 'up', look: 1 }, 2),
304    together({ look: 1 }, 2),
305    together(),
306    together({ face: 'side', look: 1, left: 'in', right: 'in' }),
307    together({ face: 'back' }),
308    together({ face: 'side', look: -1, left: 'in', right: 'in' }),
309    together(),
310    // back into one
311    facing(),
312    halfway,
313    stretched,
314    one({ blink: true }),
315    one(),
316    one({ left: 'up', right: 'up' }),
317    one(),
318  ],
319}
320
321// Roughly one turn in SECRET_ODDS starts with the split, decided by the turn's id.
322const SECRET_ODDS = 20
323export const isSecretTurn = (id: string) => hash(`split:${id}`) % SECRET_ODDS === 0
324
325function pixels(frame: Frame): Uint8Array {
326  const grid = new Uint8Array(W * H)
327  for (const pose of frame) draw(grid, pose)
328  return grid
329}
330
331const EYE_PIXEL = 2
332
333function draw(grid: Uint8Array, p: Pose) {
334  const set = (x: number, y: number, value = 1) => {
335    const at = p.x + x
336    if (at >= 0 && at < W && y >= 0 && y < H) grid[y * W + at] = value
337  }
338
339  // A 9x3 body (7 wide side-on), eyes two pixels in from its edges, an arm
340  // each side, two feet. Pixels are 1 for body, 2 for an eye.
341  const side = p.face === 'side'
342  for (let y = 0; y < 3; y++) for (let x = side ? 2 : 1; x <= (side ? 8 : 9); x++) set(x, y)
343  if (!p.blink && p.face === 'front') {
344    set(3 + p.look, 1, EYE_PIXEL)
345    set(7 + p.look, 1, EYE_PIXEL)
346  }
347  if (!p.blink && side) set(p.look < 0 ? 3 : 7, 1, EYE_PIXEL)
348
349  const ARM_ROW: Record<Arm, number | null> = { up: 0, mid: 2, down: 3, in: null }
350  const leftRow = side ? null : ARM_ROW[p.left]
351  const rightRow = side ? null : ARM_ROW[p.right]
352  if (leftRow !== null) set(0, leftRow)
353  if (rightRow !== null) set(10, rightRow)
354
355  // Feet on odd pixels, the right half of a cell, so the body above them is
356  // never drawn with ▛.
357  set(3 - 2 * p.stride, 3)
358  set(7 + 2 * p.stride, 3)
359}
360
361const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
362
363function base64(bytes: Uint8Array): string {
364  let out = ''
365  for (let i = 0; i < bytes.length; i += 3) {
366    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
367    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
368    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
369    out += i + 2 < bytes.length ? B64[n & 63]! : '='
370  }
371  return out
372}
373
374// A Raster's cells: per cell, little-endian u32 [codePoint, foreground, background].
375//
376// A cell wholly inside Clawd is painted as background, with his eye drawn on
377// it: terminals paint backgrounds as solid blocks, while a block character
378// such as ▛ is two pieces that can leave a hairline between them. A cell on
379// his outline must show the terminal's own background, so it stays a block
380// character in orange.
381type Cell = [glyph: number, foreground: number, background: number]
382const BLANK: Cell = [0x20, DEFAULT, DEFAULT]
383
384function cellsOf(frame: Frame): Cell[] {
385  const grid = pixels(frame)
386  const at = (x: number, y: number) => grid[y * W + x] ?? 0
387  const cells: Cell[] = []
388  for (let r = 0; r < ROWS; r++) {
389    for (let c = 0; c < COLUMNS; c++) {
390      const x = c * 2
391      const y = r * 2
392      const quadrants = [at(x, y), at(x + 1, y), at(x, y + 1), at(x + 1, y + 1)]
393      const maskOf = (value: number) => quadrants.reduce((mask, q, i) => (q === value ? mask | (1 << i) : mask), 0)
394      const isInside = !quadrants.includes(0)
395      const mask = isInside ? maskOf(EYE_PIXEL) : maskOf(1)
396      const ink = isInside ? EYE : ORANGE
397      cells.push([QUADRANTS.charCodeAt(mask), mask ? ink : DEFAULT, isInside ? ORANGE : DEFAULT])
398    }
399  }
400  return cells
401}
402
403function pack(cells: Cell[]): string {
404  const bytes = new Uint8Array(cells.length * 12)
405  const view = new DataView(bytes.buffer)
406  cells.forEach(([glyph, foreground, background], i) => {
407    view.setUint32(i * 12, glyph, true)
408    view.setUint32(i * 12 + 4, foreground, true)
409    view.setUint32(i * 12 + 8, background, true)
410  })
411  return base64(bytes)
412}
413
414export function encode(frame: Frame): string {
415  return pack(cellsOf(frame))
416}
417
418// Disco: an easter egg. A certain phrase in a prompt sets a disco ball down
419// beside Clawd for that whole reply (DISCO_MIN_BEATS at least): its facets
420// spin, sparkles twinkle, and a burst pops off his hand whenever he strikes a
421// pose. The phrase lives here only as its SHA-256 fingerprint.
422const DISCO_FINGERPRINT = 'cd6d54a65a9209b90d5568f8fe39c82069f35a6558881bc1db3a6dd863656bf2'
423const DISCO_WORDS = 3
424export const DISCO_MIN_BEATS = Math.ceil(15_000 / TICK_MS)
425
426export function wordsOf(text: string): string[] {
427  return text.toLowerCase().replace(/['’]/g, '').split(/[^a-z0-9]+/).filter(Boolean)
428}
429
430async function sha256(text: string): Promise<string> {
431  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
432  return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
433}
434
435// Whether any run of `size` words in the text has the fingerprint.
436export async function hasPhrase(text: string, fingerprint = DISCO_FINGERPRINT, size = DISCO_WORDS): Promise<boolean> {
437  const words = wordsOf(text).slice(0, 2_000)
438  for (let i = 0; i + size <= words.length; i++) {
439    if ((await sha256(words.slice(i, i + size).join(' '))) === fingerprint) return true
440  }
441  return false
442}
443
444const SILVER = 0xc9ccd1
445const FACET_DARK = 0x6f7680
446const FACET_LIGHT = 0xe9ebee
447const GLINT = 0xffffff
448const SPARKLE_COLORS = [0xf7e7b4, 0xffb3c7, 0xa8e6ff, 0xcdb8ff, 0xffffff] // champagne, rose, aqua, lilac, white
449const SPARKLE = ['·', '✧', '✦', '✧', '·'] // a sparkle's life, a beat a step
450const BALL = ['..####..', '.######.', '.######.', '..####..'] // 8x4 pixels at the stage's left
451
452// He dances right of the ball, on even pixels like everywhere else.
453const DISCO_HOME = 12
454const DISCO_POSES: Pose[] = [
455  // the point: up to the right, then up to the left
456  P({ x: DISCO_HOME + 2, right: 'up', left: 'down', look: 1 }),
457  P({ x: DISCO_HOME + 2, right: 'up', left: 'down', look: 1 }),
458  P({ x: DISCO_HOME }),
459  P({ x: DISCO_HOME - 2, left: 'up', right: 'down', look: -1 }),
460  P({ x: DISCO_HOME - 2, left: 'up', right: 'down', look: -1 }),
461  P({ x: DISCO_HOME }),
462  // raise the roof
463  P({ x: DISCO_HOME, left: 'up', right: 'up' }),
464  P({ x: DISCO_HOME }),
465  P({ x: DISCO_HOME, left: 'up', right: 'up', blink: true }),
466  P({ x: DISCO_HOME }),
467  // spin, and ta-da
468  P({ x: DISCO_HOME, face: 'side', look: 1, left: 'in', right: 'in' }),
469  P({ x: DISCO_HOME, face: 'back' }),
470  P({ x: DISCO_HOME, face: 'side', look: -1, left: 'in', right: 'in' }),
471  P({ x: DISCO_HOME, left: 'up', right: 'up' }),
472  P({ x: DISCO_HOME }),
473  P({ x: DISCO_HOME }),
474]
475export const DISCO_DANCE: Dance = { name: 'disco', frames: solo(DISCO_POSES) }
476
477const poseAt = (beat: number) => DISCO_POSES[beat % DISCO_POSES.length]!
478
479// The sparkles a beat sets off, as [row, column, color]: now and then one
480// anywhere, and a burst beside each hand he has just thrown up.
481function sparksAt(beat: number): [number, number, number][] {
482  const h = hash(`spark:${beat}`)
483  const sparks: [number, number, number][] = []
484  if (h % 5 < 3) {
485    sparks.push([(h >>> 4) % ROWS, (h >>> 8) % COLUMNS, SPARKLE_COLORS[(h >>> 16) % SPARKLE_COLORS.length]!])
486  }
487  const pose = poseAt(beat)
488  const before = beat > 0 ? poseAt(beat - 1) : STAND
489  const color = (k: number) => SPARKLE_COLORS[(beat + k) % SPARKLE_COLORS.length]!
490  if (pose.left === 'up' && before.left !== 'up') {
491    const hand = pose.x >> 1
492    sparks.push([0, hand - 1, color(0)], [0, hand - 2, color(1)], [1, hand - 1, color(2)])
493  }
494  if (pose.right === 'up' && before.right !== 'up') {
495    const hand = (pose.x + 10) >> 1
496    sparks.push([0, hand + 1, color(3)], [0, hand + 2, color(4)], [1, hand + 1, color(0)])
497  }
498  return sparks
499}
500
501export function discoCells(beat: number): Cell[] {
502  const cells = cellsOf([poseAt(beat)])
503
504  // The ball, its facets flipping each beat as it turns and a glint going round.
505  const spin = beat % 2
506  const glint = (beat >> 1) % 4
507  for (let r = 0; r < ROWS; r++) {
508    for (let c = 0; c < BALL[0]!.length / 2; c++) {
509      const on = (dx: number, dy: number) => (BALL[r * 2 + dy]![c * 2 + dx] === '#' ? 1 : 0)
510      const mask = on(0, 0) | (on(1, 0) << 1) | (on(0, 1) << 2) | (on(1, 1) << 3)
511      if (mask === 15) {
512        const facet = (r + c + spin) % 2 ? '▚' : '▞'
513        const isGlint = [1, 2, COLUMNS + 2, COLUMNS + 1][glint] === r * COLUMNS + c
514        cells[r * COLUMNS + c] = [facet.charCodeAt(0), isGlint ? GLINT : FACET_LIGHT, FACET_DARK]
515      } else if (mask) {
516        cells[r * COLUMNS + c] = [QUADRANTS.charCodeAt(mask), SILVER, DEFAULT]
517      }
518    }
519  }
520
521  // Sparkles from the last few beats, each a step further through its life.
522  for (let age = SPARKLE.length - 1; age >= 0; age--) {
523    if (beat - age < 0) continue
524    for (const [row, column, color] of sparksAt(beat - age)) {
525      if (column < 0 || column >= COLUMNS) continue
526      const cell = row * COLUMNS + column
527      const [glyph, , background] = cells[cell] ?? BLANK
528      if (glyph !== 0x20 || background !== DEFAULT) continue
529      cells[cell] = [SPARKLE[age]!.charCodeAt(0), color, DEFAULT]
530    }
531  }
532  return cells
533}
534
535export const discoFrame = (beat: number) => pack(discoCells(beat))
536
537function put(cells: Cell[], row: number, column: number, glyph: string, foreground: number, background = DEFAULT) {
538  if (column >= 0 && column < COLUMNS) cells[row * COLUMNS + column] = [glyph.charCodeAt(0), foreground, background]
539}
540
541// Calm (/clawd-chill): no dancing. He chills, eyes shut, a cigarette in hand,
542// ASCII smoke curling up from the tip; the tip glows when he takes a drag, and
543// now and then he opens his eyes to watch the smoke.
544const SMOKE = [0xb8b8b8, 0x8f8f8f, 0x666666] // fading as it rises
545const PUFF: [row: number, dx: number, glyph: string][] = [
546  [0, 0, '.'],
547  [0, 0, '('],
548  [0, 1, ')'],
549  [0, 1, '~'],
550  [0, 2, '~'],
551  [0, 2, "'"],
552]
553const CALM_FRAMES = 24
554const PUFF_EVERY = 4
555export const CALM: Dance = {
556  name: 'calm',
557  frames: solo(Array.from({ length: CALM_FRAMES }, (_, i) => P({ blink: i < 14 || i >= 18, look: 1 }))),
558  tempo: 3,
559  dress: (cells, i, x) => {
560    const c = x >> 1
561    const tip = c + 7
562    const isDrag = i % 12 < 2
563    // Shut eyes as two relaxed lines.
564    if (i < 14 || i >= 18) {
565      put(cells, 0, c + 1, '▁', EYE, ORANGE)
566      put(cells, 0, c + 3, '▁', EYE, ORANGE)
567    }
568    // His hand, the cigarette and its tip, in pixels at hand height.
569    put(cells, 1, c + 5, '▀', ORANGE)
570    put(cells, 1, c + 6, '▀', 0xf2f2f2)
571    put(cells, 1, tip, '▘', isDrag ? 0xffb066 : 0xe8553d)
572    for (let start = i - (PUFF.length - 1); start <= i; start++) {
573      if ((start + CALM_FRAMES) % PUFF_EVERY !== 0) continue
574      const step = i - start
575      const [row, dx, glyph] = PUFF[step]!
576      put(cells, row, tip + dx, glyph, SMOKE[step >> 1]!)
577    }
578  },
579}
580
581// Typing, for when Claude runs a command.
582const TYPING_POSES = [
583  P({ left: 'down' }),
584  P({ right: 'down' }),
585  P({ left: 'down' }),
586  P({ right: 'down' }),
587  P({ left: 'down', right: 'down' }),
588  STAND,
589  P({ left: 'down' }),
590  P({ right: 'down' }),
591]
592
593// Reactions: short bursts for what Claude is doing, and for being played with.
594const SHUT_EYES = (cells: Cell[], x: number) => {
595  put(cells, 0, (x >> 1) + 1, '▁', EYE, ORANGE)
596  put(cells, 0, (x >> 1) + 3, '▁', EYE, ORANGE)
597}
598const HAPPY_EYES = (cells: Cell[], x: number) => {
599  put(cells, 0, (x >> 1) + 1, '^', EYE, ORANGE)
600  put(cells, 0, (x >> 1) + 3, '^', EYE, ORANGE)
601}
602const GOLD = [0xffd479, 0xffffff, 0xf7e7b4]
603const CONFETTI = [0xff8fb1, 0x7fd4ff, 0xffd479, 0x9be7a6, 0xcdb8ff]
604// The columns either side of him, standing at x.
605const freeColumns = (x: number) => Array.from({ length: COLUMNS }, (_, c) => c).filter(c => c < x >> 1 || c > (x >> 1) + 5)
606
607export const PEER: Dance = {
608  name: 'peer',
609  frames: solo([P({ look: -1 }), P({ look: -1 }), P({ look: 1 }), P({ look: 1 }), P({ look: -1 }), P({ look: 1 })]),
610  tempo: 2,
611}
612export const HAMMER_TIME: Dance = { name: 'hammer-time', frames: solo([...PHRASES.hammer, ...PHRASES.hammer]) }
613export const TAP: Dance = { name: 'tap', frames: solo(TYPING_POSES) }
614export const BOW: Dance = {
615  name: 'bow',
616  frames: solo([P({ left: 'down', right: 'down', blink: true }), P({ left: 'down', right: 'down', blink: true }), STAND]),
617  tempo: 3,
618}
619export const STRETCH: Dance = {
620  name: 'stretch',
621  frames: solo([P({ blink: true }), P({ left: 'up', right: 'up', blink: true }), ROOF, STAND]),
622  tempo: 3,
623  dress: (cells, i, x) => i < 2 && SHUT_EYES(cells, x),
624}
625export const CHEER: Dance = {
626  name: 'cheer',
627  frames: solo([ROOF, STAND, ROOF, STAND, ROOF, ROOF, STAND, ROOF]),
628  dress: (cells, i, x) => {
629    HAPPY_EYES(cells, x)
630    const c = x >> 1
631    for (const [row, column, k] of [[0, c - 1, 0], [0, c + 6, 1], [1, c - 2, 2], [1, c + 7, 0]] as const) {
632      put(cells, row, column, (i + k) % 2 ? '✦' : '✧', GOLD[(i + k) % GOLD.length]!)
633    }
634  },
635}
636export const FACEPALM: Dance = {
637  name: 'facepalm',
638  frames: solo(Array.from({ length: 8 }, () => P({ left: 'up' }))),
639  tempo: 2,
640  dress: (cells, i, x) => {
641    SHUT_EYES(cells, x)
642    const c = x >> 1
643    const drop = ['▝', '▗', '▝', '▗'][i >> 1]!
644    put(cells, i < 4 ? 0 : 1, c + 6, drop, 0x7fc8ff)
645  },
646}
647export const CONFETTI_DANCE: Dance = {
648  name: 'confetti',
649  frames: solo([ROOF, STAND, ROOF, STAND, ROOF, STAND, ROOF, STAND, ROOF, STAND, ROOF, STAND]),
650  dress: (cells, i, x) => {
651    HAPPY_EYES(cells, x)
652    const free = freeColumns(x)
653    for (let k = 0; k < 6; k++) {
654      const age = i - k
655      if (age < 0 || age > 5) continue
656      const column = free[(k * 3 + 1) % free.length]!
657      put(cells, age < 3 ? 0 : 1, column, ['*', '•', '·'][age % 3]!, CONFETTI[k % CONFETTI.length]!)
658    }
659  },
660}
661export const HEARTS: Dance = {
662  name: 'hearts',
663  frames: solo(Array.from({ length: 10 }, (_, i) => P({ blink: false, look: i % 4 < 2 ? -1 : 1 }))),
664  tempo: 2,
665  dress: (cells, i, x) => {
666    HAPPY_EYES(cells, x)
667    const c = x >> 1
668    const path: [number, number][] = [[1, c + 6], [0, c + 6], [0, c + 7], [0, c + 8]]
669    for (const [start, side] of [[0, 1], [3, -1]] as const) {
670      const step = i - start
671      if (step < 0 || step >= path.length) continue
672      const [row, column] = path[step]!
673      put(cells, row, side > 0 ? column : 2 * c + 5 - column, '♥', step < 2 ? 0xff6b9a : 0xffb3c7)
674    }
675  },
676}
677export const COFFEE: Dance = {
678  name: 'coffee',
679  frames: solo([...PHRASES.shimmy, ...PHRASES.shimmy, ROOF, STAND]),
680  dress: (cells, i, x) => i < 4 && put(cells, 0, (x >> 1) + 6, '!', 0xffd479),
681}
682export const NAP: Dance = {
683  name: 'nap',
684  frames: solo([P({ blink: true })]),
685  dress: (cells, _i, x) => {
686    SHUT_EYES(cells, x)
687    put(cells, 0, (x >> 1) + 6, 'z', 0x9fa8da)
688    put(cells, 0, (x >> 1) + 7, 'Z', 0xc5cae9)
689  },
690}
691
692// Bash commands that run tests, for cheering or facepalming at the outcome.
693export const isTestCommand = (command: string) =>
694  /\b(test|tests|pytest|jest|vitest|mocha|rspec|phpunit|unittest|ctest|go test|cargo test|claude plugin test|apex run test)\b/.test(command)
695export const isCommitCommand = (command: string) => /\bgit\s+commit\b/.test(command)
696
697// Whether a test run passed, read from what it printed: Claude chains commands
698// (`tests 2>&1; ls`), so the exit status alone often says nothing about the
699// tests. Only when the output names no result does the exit status decide.
700export function testsPassed(output: string, isOk: boolean): boolean {
701  if (/\bFAIL(ED)?\b|\b[1-9]\d* (failed|failing)\b|failures=[1-9]|errors=[1-9]|\bERROR\b|✗|✕/.test(output)) return false
702  if (/\bOK\b|\b[1-9]\d* passed\b|\bPASS(ED)?\b|\b[1-9]\d* passing\b|✓/.test(output)) return true
703  return isOk
704}
705const READING_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'LS'])
706const EDITING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
707
708// The dance card: every move there is to see, two of them secret.
709export const CARD: { id: string; name: string; isSecret?: true }[] = [
710  { id: 'moonwalk', name: 'Moonwalk' },
711  { id: 'wander', name: 'Stroll' },
712  { id: 'spin', name: 'Spin' },
713  { id: 'shimmy', name: 'Shimmy' },
714  { id: 'ponder', name: 'Ponder' },
715  { id: 'stir', name: 'Stir the pot' },
716  { id: 'hammer', name: 'Hammer time' },
717  { id: 'frolic', name: 'Frolic' },
718  { id: 'flail', name: 'Flail' },
719  { id: 'magic', name: 'Magic' },
720  { id: 'wave', name: 'Wave' },
721  { id: 'mix', name: 'Freestyle' },
722  { id: 'calm', name: 'Chill' },
723  { id: 'nap', name: 'Nap' },
724  { id: 'cheer', name: 'Victory dance' },
725  { id: 'facepalm', name: 'Facepalm' },
726  { id: 'confetti', name: 'Confetti' },
727  { id: 'hearts', name: 'Hearts' },
728  { id: 'coffee', name: 'Caffeinated' },
729  { id: 'split', name: 'Twin split', isSecret: true },
730  { id: 'disco', name: 'Disco', isSecret: true },
731]
732// The card entry a dance counts toward, if any.
733export const cardIdOf = (danceName: string) => {
734  const id = danceName.split(':')[0]!
735  return CARD.some(entry => entry.id === id) ? id : undefined
736}
737
738const frames = new Map<string, string[]>()
739export function framesOf(dance: Dance): string[] {
740  let encoded = frames.get(dance.name)
741  if (encoded === undefined) {
742    encoded = dance.frames.map((frame, i) => {
743      const cells = cellsOf(frame)
744      dance.dress?.(cells, i, frame[0]?.x ?? HOME)
745      return pack(cells)
746    })
747    frames.set(dance.name, encoded)
748  }
749  return encoded
750}
751
752// /clawd-demo: every dance in a row, each for at least DEMO_MIN_BEATS.
753const DEMO_MIN_BEATS = 16
754const DEMO_WORDS = [
755  'Moonwalking',
756  'Wandering',
757  'Spinning',
758  'Shimmying',
759  'Pondering',
760  'Cooking',
761  'Working',
762  'Frolicking',
763  'Flibbertigibbeting',
764  'Conjuring',
765  'Booping',
766  'Clauding',
767  'Noodling',
768]
769export const DEMO = [
770  ...DEMO_WORDS.map(word => ({ word: `${word}…`, dance: danceFor(word) })),
771  { word: 'between turns', dance: IDLE },
772  { word: '/clawd-chill', dance: CALM },
773  { word: 'tests pass', dance: CHEER },
774  { word: 'tests fail', dance: FACEPALM },
775  { word: 'git commit', dance: CONFETTI_DANCE },
776  { word: '♥', dance: HEARTS },
777  { word: '☕', dance: COFFEE },
778  { word: 'after 3 quiet minutes', dance: NAP },
779]
780const loopOf = (dance: Dance) => dance.frames.length * (dance.tempo ?? 1)
781const beatsOf = (dance: Dance) => loopOf(dance) * Math.ceil(DEMO_MIN_BEATS / loopOf(dance))
782export const DEMO_MS = DEMO.reduce((sum, act) => sum + beatsOf(act.dance), 0) * TICK_MS
783
784const demoLabel = atom({ plugin: 'dancing-clawd', key: 'demoLabel' } as const, null as DemoLabel)
785const seenMoves = atom({ plugin: 'dancing-clawd', key: 'seen' } as const, [] as SeenMoves)
786const CARD_PANE = 'clawd-card'
787
788// After a few quiet minutes he falls asleep and the ticking stops.
789export const SLEEP_AFTER_TICKS = Math.round((3 * 60_000) / TICK_MS)
790const REQUESTABLE = DEMO_WORDS.filter(word => word !== 'Moonwalking') // the moonwalk stays Moonwalking's
791
792// What the band shows and why; module state starts over on each reload.
793let bandId: string | undefined
794let shown = encode([STAND])
795let isTicking = false
796let isWorking = false
797let turnId: string | undefined
798let spinnerWord: string | undefined
799let dance = IDLE.name
800let beat = 0
801let ticks = 0
802let demoAct = -1 // the DEMO entry playing, or -1
803let demoBeat = 0
804let secretBeat = -1 // the SPLIT frame playing, or -1
805let discoBeat = -1 // beats into the party, or -1
806let discoTurn: string | undefined // the reply the party lasts for
807let isDiscoTurnDone = false
808let isCalm = false // /clawd-chill, remembered in the store
809let reaction: { dance: Dance; beat: number } | undefined // a short burst playing
810let idleTicks = 0
811let isAsleep = false
812let timer: Timer | undefined
813let seen = new Set<string>() // the dance card, mirrored in the store and in `seenMoves`
814
815function paint($: EngineInterface, cells: string) {
816  if (cells === shown) return
817  shown = cells
818  if (bandId === undefined) return
819  // Denied while the band is collapsed; the next draw shows `shown`.
820  $.ui.blit({ requestId: bandId, key: 'clawd', cells }).catch(() => {})
821}
822
823// While Claude works his dances travel: the whole dance drifts across the
824// strip and back, a cell (two pixels, so still on even pixels) every
825// TRAVEL_EVERY beats, up to TRAVEL_REACH pixels either side of home.
826const TRAVEL_EVERY = 4
827const TRAVEL_REACH = 6
828export const travelOffset = (tick: number) => {
829  const steps = TRAVEL_REACH // one way, in cells
830  const k = Math.floor(tick / TRAVEL_EVERY) % (2 * steps)
831  return (k <= steps ? k : 2 * steps - k) * 2 - TRAVEL_REACH
832}
833const travelled = new Map<string, Dance>()
834export function travelling(dance: Dance, dx: number): Dance {
835  if (dx === 0) return dance
836  const key = `${dance.name}@${dx}`
837  let moved = travelled.get(key)
838  if (moved === undefined) {
839    moved = { ...dance, name: key, frames: dance.frames.map(frame => frame.map(pose => ({ ...pose, x: pose.x + dx }))) }
840    travelled.set(key, moved)
841  }
842  return moved
843}
844// The moonwalk and the stroll have paths of their own.
845const travels = (dance: Dance) => isWorking && !isCalm && dance.name !== 'moonwalk' && dance.name !== 'wander'
846
847// Calm when he's been told to chill, and otherwise
848// the spinner word's dance while Claude works and the idle groove in between.
849function currentDance(): Dance {
850  if (isCalm) return CALM
851  return isWorking ? danceFor(spinnerWord) : IDLE
852}
853
854function tick($: EngineInterface) {
855  ticks += 1
856  if (demoAct >= 0) return stepDemo($)
857  if (discoBeat >= 0) return stepDisco($)
858  if (secretBeat >= 0) return stepSecret($)
859  if (reaction !== undefined) return stepReaction($)
860  idleTicks = isWorking ? 0 : idleTicks + 1
861  if (idleTicks >= SLEEP_AFTER_TICKS) return fallAsleep($)
862  const next = currentDance()
863  if (ticks % (next.tempo ?? 1) !== 0) return
864  if (next.name !== dance) {
865    dance = next.name
866    beat = -1
867    markSeen($, cardIdOf(next.name))
868  }
869  const moves = framesOf(travels(next) ? travelling(next, travelOffset(ticks)) : next)
870  beat = (beat + 1) % moves.length
871  paint($, moves[beat]!)
872}
873
874function stepDemo($: EngineInterface) {
875  const act = DEMO[demoAct]
876  if (act === undefined) {
877    demoAct = -1
878    dance = ''
879    update($, demoLabel, () => null).catch(() => {})
880    return
881  }
882  if (demoBeat === 0) {
883    const label = `${act.word}  ${act.dance.name.split(':')[0]}  ${demoAct + 1}/${DEMO.length}`
884    update($, demoLabel, () => label).catch(() => {})
885  }
886  const moves = framesOf(act.dance)
887  paint($, moves[Math.floor(demoBeat / (act.dance.tempo ?? 1)) % moves.length]!)
888  demoBeat += 1
889  if (demoBeat >= beatsOf(act.dance)) {
890    demoAct += 1
891    demoBeat = 0
892  }
893}
894
895// The split plays once through at full speed, then the dance starts over.
896function stepSecret($: EngineInterface) {
897  const moves = framesOf(SPLIT)
898  paint($, moves[secretBeat]!)
899  secretBeat += 1
900  if (secretBeat >= moves.length) {
901    secretBeat = -1
902    dance = ''
903  }
904}
905
906// The party lasts the reply it started with, and DISCO_MIN_BEATS at least; a
907// prompt that starts no reply (a slash command) ends it after the minimum.
908function stepDisco($: EngineInterface) {
909  paint($, discoFrame(discoBeat))
910  discoBeat += 1
911  if (discoTurn === undefined && discoBeat > 16) isDiscoTurnDone = true
912  if (isDiscoTurnDone && discoBeat >= DISCO_MIN_BEATS) {
913    discoBeat = -1
914    discoTurn = undefined
915    dance = ''
916  }
917}
918
919// A reaction plays once through at its own tempo, travelling with him while Claude works.
920function stepReaction($: EngineInterface) {
921  const playing = reaction!
922  const tempo = playing.dance.tempo ?? 1
923  const moves = framesOf(isWorking && !isCalm ? travelling(playing.dance, travelOffset(ticks)) : playing.dance)
924  if (playing.beat % tempo === 0) paint($, moves[playing.beat / tempo]!)
925  playing.beat += 1
926  if (playing.beat >= moves.length * tempo) {
927    reaction = undefined
928    dance = ''
929  }
930}
931
932// Something Claude did, or a press of his buttons. A reaction to an outcome
933// (`isOutcome`) cuts in; one to a call starting waits for any playing to end.
934function react($: EngineInterface, burst: Dance, isOutcome = false) {
935  if (!isOutcome && reaction !== undefined) return
936  reaction = { dance: burst, beat: 0 }
937  idleTicks = 0
938  startDancing($)
939  markSeen($, cardIdOf(burst.name))
940}
941
942function fallAsleep($: EngineInterface) {
943  paint($, framesOf(NAP)[0]!)
944  markSeen($, 'nap')
945  isAsleep = true
946  isTicking = false
947  timer?.cancel()
948  timer = undefined
949}
950
951// The first time a move shows, it goes on the dance card (the demo doesn't count).
952function markSeen($: EngineInterface, id: string | undefined) {
953  if (id === undefined || seen.has(id) || demoAct >= 0) return
954  const entry = CARD.find(card => card.id === id)
955  if (entry === undefined) return
956  seen.add(id)
957  const list = [...seen]
958  $.store.set('seen', list).catch(() => {})
959  update($, seenMoves, () => list).catch(() => {})
960  $.ui.toast(`New move: ${entry.name}! ${seen.size} of ${CARD.length} on your dance card (/clawd).`)
961}
962
963function noticeToolStart($: EngineInterface, tool: string) {
964  if (isCalm) return
965  if (EDITING_TOOLS.has(tool)) react($, HAMMER_TIME)
966  else if (READING_TOOLS.has(tool)) react($, PEER)
967  else if (tool === 'Bash') react($, TAP)
968}
969
970// Tests that pass get a victory dance, or confetti when the same command
971// commits too; tests that fail get a facepalm.
972function noticeCommand($: EngineInterface, command: string, output: string, isOk: boolean) {
973  if (isCalm) return
974  if (isTestCommand(command)) {
975    const passed = testsPassed(output, isOk)
976    react($, !passed ? FACEPALM : isCommitCommand(command) ? CONFETTI_DANCE : CHEER, true)
977  } else if (isCommitCommand(command) && isOk) react($, CONFETTI_DANCE, true)
978}
979
980function pet($: EngineInterface) {
981  react($, HEARTS, true)
982}
983
984function giveCoffee($: EngineInterface) {
985  react($, COFFEE, true)
986}
987
988function requestDance($: EngineInterface) {
989  react($, danceFor(REQUESTABLE[hash(`request:${ticks}`) % REQUESTABLE.length]), true)
990}
991
992// Starts the ticking, waking him with a stretch if he's asleep.
993function startDancing($: EngineInterface) {
994  if (isAsleep) {
995    isAsleep = false
996    reaction = { dance: STRETCH, beat: 0 }
997  }
998  idleTicks = 0
999  if (isTicking) return
1000  isTicking = true
1001  timer = $.clock.every(TICK_MS, () => tick($))
1002}
1003
1004export const register: Register = on => {
1005  on('session.start', async ($, e, next) => {
1006    if (e.isInteractive) startDancing($)
1007    // Both run at once, even mid-reply, and answer with a toast: Clawd never
1008    // holds Claude up or puts words in its conversation.
1009    await $.command.register({
1010      name: 'clawd-demo',
1011      description: 'Watch Clawd do every dance, one after another',
1012      immediate: true,
1013    })
1014    await $.command.register({
1015      name: 'clawd-chill',
1016      description: 'Clawd stops dancing and chills; run it again to get him dancing',
1017      immediate: true,
1018    })
1019    await $.command.register({
1020      name: 'clawd',
1021      description: "Clawd's dance card, and everything he can do",
1022      immediate: true,
1023    })
1024    try {
1025      const stored = await $.store.get('seen')
1026      seen = new Set(Array.isArray(stored) ? stored.filter((id): id is string => typeof id === 'string') : [])
1027      const list = [...seen]
1028      await update($, seenMoves, () => list)
1029      isCalm = (await $.store.get('calm')) === true
1030    } catch {
1031      // Without the store he starts fresh.
1032    }
1033    return next(e)
1034  })
1035
1036  on('command.run', { command: 'clawd' }, async $ => {
1037    const opened = await $.ui.open({ id: CARD_PANE, title: "Clawd's dance card", focus: true, closeOnEscape: true })
1038    if (!opened.isPlaced) $.ui.toast(`${seen.size} of ${CARD.length} moves on your dance card. Widen the terminal to see it.`)
1039    return {}
1040  })
1041
1042  on('command.run', { command: 'clawd-chill' }, async $ => {
1043    startDancing($)
1044    isCalm = !isCalm
1045    if (isCalm) markSeen($, 'calm')
1046    await $.store.set('calm', isCalm).catch(() => {})
1047    $.ui.toast(isCalm ? 'Clawd is chilling. /clawd-chill again to get him dancing.' : 'Clawd is back on the dance floor.')
1048    return {}
1049  })
1050
1051  on('command.run', { command: 'clawd-demo' }, $ => {
1052    startDancing($)
1053    demoAct = 0
1054    demoBeat = 0
1055    const seconds = Math.round(DEMO_MS / 1000)
1056    $.ui.toast(`Clawd is doing all ${DEMO.length} dances in a row, about ${seconds}s. Watch above the prompt.`)
1057    return {}
1058  })
1059
1060  on('turn.start', ($, e, next) => {
1061    startDancing($)
1062    isWorking = true
1063    turnId = e.turnId
1064    if (discoBeat >= 0 && discoTurn === undefined) discoTurn = e.turnId
1065    else if (isSecretTurn(e.turnId) && demoAct < 0 && discoBeat < 0 && !isCalm) {
1066      secretBeat = 0
1067      markSeen($, 'split')
1068    }
1069    return next(e)
1070  })
1071
1072  on('turn.complete', ($, e, next) => {
1073    if (e.agentId === undefined && e.turnId === turnId) {
1074      isWorking = false
1075      if (!isCalm) react($, BOW)
1076    }
1077    if (e.agentId === undefined && e.turnId === discoTurn) isDiscoTurnDone = true
1078    return next(e)
1079  })
1080
1081  // He reacts to what Claude does, never changing it: the call and its result
1082  // pass through untouched.
1083  on('tool.call', async ($, e, next) => {
1084    noticeToolStart($, String(e.tool))
1085    const result = await next(e)
1086    if (e.tool === 'Bash' && result.deny === undefined) {
1087      const record = result.result as { backgroundTaskId?: string } | undefined
1088      if (record?.backgroundTaskId === undefined) noticeCommand($, e.command, result.text ?? '', result.isError !== true)
1089    }
1090    return result
1091  }).catch(($, e, next) => next(e)) // a fault of his never stops a call or runs it twice
1092
1093  // The prompt goes on to Claude unchanged; the right words also start a party.
1094  on('prompt.submit', async ($, e, next) => {
1095    startDancing($)
1096    if (await hasPhrase(e.text)) {
1097      markSeen($, 'disco')
1098      discoBeat = 0
1099      discoTurn = e.turnId
1100      isDiscoTurnDone = false
1101    }
1102    return next(e)
1103  }).catch(($, e, next) => next(e))
1104
1105  // The spinner's word ("Moonwalking", "Pondering") picks the dance; the
1106  // spinner itself is drawn as the engine draws it.
1107  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
1108    spinnerWord = e.props.word
1109    return next(e)
1110  })
1111
1112  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1113    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
1114
1115    bandId = e.requestId
1116    isWorking = e.props.isWorking
1117    const label = await read($, demoLabel)
1118
1119    const { Box, Button, Raster, Text } = $.ui.resolve(e)
1120    // His toys show while the pointer is over him; where nothing hovers, always (dim).
1121    const canHover = e.viewport?.isFullscreen === true
1122    const reveal = canHover ? ({ display: 'none', hover: { display: 'flex' } } as const) : {}
1123    return (
1124      <Box key="clawd-band" alignItems="center">
1125        <Raster key="clawd" columns={COLUMNS} rows={ROWS} cells={shown} />
1126        <Box gap={1} {...reveal}>
1127          <Button key="pet" plain hotkey="1" onPress={() => pet($)}>
1128            <Text dimColor={!canHover}>♥</Text>
1129          </Button>
1130          <Button key="coffee" plain hotkey="2" onPress={() => giveCoffee($)}>
1131            <Text dimColor={!canHover}>☕</Text>
1132          </Button>
1133          <Button key="dance" plain hotkey="3" onPress={() => requestDance($)}>
1134            <Text dimColor={!canHover}>♪</Text>
1135          </Button>
1136        </Box>
1137        {label === null ? null : <Text dimColor>{' ' + label}</Text>}
1138      </Box>
1139    )
1140  })
1141
1142  // /clawd: the dance card, and how to play with him.
1143  on('ui.render', { component: 'Pane', requestId: CARD_PANE }, async ($, e) => {
1144    const ids = await read($, seenMoves)
1145    const { Box, Text } = $.ui.resolve(e)
1146    const line = (entry: (typeof CARD)[number]) =>
1147      ids.includes(entry.id) ? (
1148        <Text>{`✓ ${entry.name}`}</Text>
1149      ) : (
1150        <Text dimColor>{entry.isSecret ? '★ ???' : '· ???'}</Text>
1151      )
1152    const half = Math.ceil(CARD.length / 2)
1153    return (
1154      <Box flexDirection="column" paddingX={1}>
1155        <Text bold>{`Dance card: ${ids.length} of ${CARD.length} moves`}</Text>
1156        <Box gap={4} marginTop={1}>
1157          <Box flexDirection="column">{CARD.slice(0, half).map(line)}</Box>
1158          <Box flexDirection="column">{CARD.slice(half).map(line)}</Box>
1159        </Box>
1160        <Box flexDirection="column" marginTop={1}>
1161          <Text dimColor>Hover over Clawd for ♥ ☕ ♪, or ctrl+x tab to his strip and press 1 2 3.</Text>
1162          <Text dimColor>/clawd-chill  he chills, or dances again</Text>
1163          <Text dimColor>/clawd-demo   every move in a row</Text>
1164          <Text dimColor>★ moves are secret. Esc closes this.</Text>
1165        </Box>
1166      </Box>
1167    )
1168  })
1169}
1170
types/index.d.ts 11 lines
1// The line shown beside Clawd during /clawd-demo: the dance playing, or null.
2export type DemoLabel = string | null
3// The dance card entries seen so far, by id.
4export type SeenMoves = string[]
5
6declare module 'claude-code' {
7  interface PluginState {
8    'dancing-clawd': { demoLabel: DemoLabel; seen: SeenMoves }
9  }
10}
11