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

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:
ctrl+x tab to his strip and press 1, 2 or 3./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.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.
ctrl+x ctrl+a, or click the [-] at its end. Do the same to bring him back./plugin uninstall dancing-clawd./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.
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▛: 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.claude plugin validate . and, once Claude Code has loaded the folder (it writes .claude-plugin/types/), npx tsc -p .MIT. See LICENSE.
hooks/register.tsx 1170 lines1import { 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}
1170types/index.d.ts 11 lines1// 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