Clawd, Claude Code's mascot, animated above the prompt: blinks while idle, walks while working, cheers when a turn is done

Clawd, Claude Code's mascot, animated at the left of the mod band above the prompt. The pixels and colors are the ones Claude Code itself draws him with (its built-in mods guide), painted as Raster cells in the terminal and a crisp Svg on desktop, VS Code and mobile.
..CCCCCCCCCCCCC..
..CCCCCCCCCCCCC..
..CCCECCCCCCCEC.. C body #d77757
..CCCECCCCCCCEC.. E eyes #141414
CCCCCCCCCCCCCCCCC
CCCCCCCCCCCCCCCCC
..CCCCCCCCCCCCC..
..CCCCCCCCCCCCC..
..C.C.......C.C..
..C.C.......C.C..
/plugin install buddy --marketplace katipally/modz
Clawd acts out what the agent is doing:
| What he does | When |
|---|---|
| types, code lines scroll, arms alternate | an ordinary Bash command |
| progress bar fills, foot taps | tests run (npm test, pytest, ...) |
| a commit dot hops along a branch | a git command |
| a package drops into a box | npm install, pip install, brew install, ... |
| gears turn, he cranks | build, tsc, make, ... |
| swings a hammer, sparks fly | Edit, Write or NotebookEdit runs |
| a lens slides across, his eyes follow | Read, Grep or Glob runs |
| signal waves grow | WebFetch or WebSearch runs |
| little helpers hop beside him | an Agent (subagent) runs |
| ticks a checklist | TodoWrite or a task tool runs |
a ? bounces, his hand is up | he is asking you a question |
| a plug clicks into a socket | an MCP tool runs |
| waves a wand, sparkles | a skill runs |
| ponders, a bulb lights | entering or leaving plan mode |
| paces, looks around, a bubble rises | thinking, no tool running |
| hops with arms up, sparks fly | a turn finished |
eyes shut, flails, a red ! flashes | a turn was stopped or errored |
sits down, z rises | 2 minutes with nothing happening |
| blinks, glances both ways, waves hello | idle |
Click him and he plays one of 14 animations: jump, look, spin, peekaboo, drop, wink, boop, tap, sneeze, turn, coin-hop, skip, waddle or peek. They come from a shuffled deck, so every one plays before any repeats and never the same one twice in a row. Clicks during an animation are ignored. (Terminal only; the pixel version keeps one hop.)
On top of his mood, three things can bother him (idle or working):
| What he does | When |
|---|---|
| sweat runs down his side | any limit or the context is at 85% or more |
| a clock hand turns beside him | one tool has run for 10 seconds (delegating does not count) |
| snow drifts down and he shivers | the prompt cache has gone cold (needs the cache-timer mod) |
Everything he holds stays inside his own box, so the band never changes size. The frames never redraw the band:
⌨ typing, ✎ editing, ⌕ reading, ◎ browsing, ⚑ delegating, ☑ planning, ◔ testing, ◆ git, ↓ installing, ▤ building, ? asking, ↯ connecting, ✦ casting, ◇ drafting). Only thinking paces on his feet. He sits in a box of nine cells by three rows, and a frame may push him down or sideways inside it, which clips him: that is how he hides, peeks and walks out of sight without the band ever changing size. He runs as a Client region on his own clock, so only his cells repaint. Clicks need the fullscreen terminal ("tui": "fullscreen").Styles: /buddy skin classic | ink | mint | sky | berry | gold (classic is Clawd's own color; the choice is remembered).
Only the main agent's turns and tools move him; subagents do not.
hooks/register.tsx 169 lines1import { atom, read, update } from 'claude-code'
2import type { Register, BoxProps, ElementConstructor, EngineInterface, RenderElement, RenderNode, SessionContextUsage, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Activity, Mood, Strain } from '../types'
5import { H, PX, SKINS, toSvg, W } from './clawd'
6
7// Which prop Clawd picks up for which tool the main agent runs; any other tool keeps him thinking.
8const TOOL_PROPS: Record<string, Activity> = {
9 Edit: 'hammer',
10 Write: 'hammer',
11 NotebookEdit: 'hammer',
12 Read: 'magnify',
13 Grep: 'magnify',
14 Glob: 'magnify',
15 WebFetch: 'web',
16 WebSearch: 'web',
17 Agent: 'agents',
18 TodoWrite: 'notes',
19 TaskCreate: 'notes',
20 TaskUpdate: 'notes',
21 AskUserQuestion: 'ask',
22 Skill: 'skill',
23 EnterPlanMode: 'plan',
24 ExitPlanMode: 'plan',
25}
26// A Bash command is typing unless it is clearly one of these jobs.
27const BASH_JOBS: [RegExp, Activity][] = [
28 [/^\s*git\b/, 'git'],
29 [/\b(test|pytest|jest|vitest|mocha)\b/, 'test'],
30 [/\b(npm|pnpm|yarn|bun|pip3?|brew|apt(-get)?|cargo|gem) (install|add|i|ci)\b/, 'install'],
31 [/\b(build|tsc|make|compile|webpack|gradle|mvn)\b/, 'build'],
32]
33const activityOf = (tool: string, command: string): Activity | undefined =>
34 tool === 'Bash' ? BASH_JOBS.find(([re]) => re.test(command))?.[1] ?? 'laptop' : tool.startsWith('mcp__') ? 'mcp' : TOOL_PROPS[tool]
35// Moods that play for a while after a turn and then settle back to idle.
36const SETTLE_MS: Partial<Record<Mood, number>> = { happy: 2400, oops: 1200 }
37const ASLEEP_AFTER_MS = 120_000
38// He sweats from 85% of any limit, and checks a clock when one tool has run this long.
39const HOT_PERCENT = 85
40const SLOW_MS = 10_000
41
42const mood = atom({ plugin: 'buddy', key: 'mood' } as const, 'idle')
43const activity = atom({ plugin: 'buddy', key: 'activity' } as const, 'think')
44const skin = atom({ plugin: 'buddy', key: 'skin' } as const, 'classic')
45const signals = atom({ plugin: 'buddy', key: 'signals' } as const, { hot: false, slow: 0 })
46// The cache-timer mod owns these; without it the cache is simply never cold.
47const cacheLast = atom({ plugin: 'cache-timer', key: 'last' } as const, null)
48const cacheTtl = atom({ plugin: 'cache-timer', key: 'ttlMin' } as const, 60)
49
50const isHot = (context: SessionContextUsage, limits: SessionRateLimit[]) =>
51 [context.percent, ...limits.map(l => l.percentUsed)].some(p => p !== undefined && p >= HOT_PERCENT)
52
53// Every modz mod places its tile in one shared band: a lead column (the bot), then a column of tiles, then an optional
54// right column. A column with no tiles is not drawn, and columns wrap under each other when the width runs out.
55const BAND = 'modz-band'
56const SLOTS: Record<string, 'lead' | 'left' | 'right'> = {
57 buddy: 'lead',
58 'usage-limits': 'left',
59 'cache-timer': 'left',
60 'model-info': 'left',
61 orchestrator: 'left',
62}
63const ORDER = Object.keys(SLOTS)
64const keyOf = (n: RenderNode) => (typeof n !== 'string' && n.type === 'Box' ? String(n.props?.key ?? '') : '')
65const rank = (n: RenderNode) => {
66 const i = ORDER.indexOf(keyOf(n))
67 return i < 0 ? ORDER.length : i
68}
69const pack = (Box: ElementConstructor<BoxProps>, below: RenderElement, tile: RenderElement) => {
70 const isEmpty = below.type === 'Box' && !below.children?.length
71 const tiles = keyOf(below) === BAND
72 ? (below.type === 'Box' ? below.children ?? [] : []).flatMap(col => (typeof col !== 'string' && col.type === 'Box' ? col.children ?? [] : [col]))
73 : isEmpty ? [] : [below]
74 const all = [...tiles, tile].sort((a, b) => rank(a) - rank(b))
75 const [lead, left, right] = (['lead', 'left', 'right'] as const).map(slot => all.filter(n => (SLOTS[keyOf(n)] ?? 'left') === slot))
76 return (
77 <Box key={BAND} flexDirection="row" flexWrap="wrap" columnGap={3}>
78 {lead!.length > 0 && <Box key="modz-lead" flexDirection="column" justifyContent="center">{lead}</Box>}
79 {left!.length > 0 && <Box key="modz-left" flexDirection="column" flexGrow={right!.length > 0 ? 1 : 0}>{left}</Box>}
80 {right!.length > 0 && <Box key="modz-right" flexDirection="column" alignItems="flex-end">{right}</Box>}
81 </Box>
82 )
83}
84
85// The band redraws only when the mood or the prop changes; the frames themselves play inside Clawd's own region.
86let settle: Timer | undefined
87
88const become = async ($: EngineInterface, next: Mood) => {
89 settle?.cancel()
90 await update($, mood, () => next)
91 const after = next === 'idle' ? ASLEEP_AFTER_MS : SETTLE_MS[next]
92 if (after !== undefined) settle = $.clock.after(after, () => void become($, next === 'idle' ? 'asleep' : 'idle'))
93}
94
95export const register: Register = on => {
96 on('session.start', async ($, e, next) => {
97 await $.command.register({ name: 'buddy', description: 'Change how Clawd looks', argumentHint: `skin ${Object.keys(SKINS).join(' | ')}` })
98 const saved = (await $.store.get('skin')) as string | undefined
99 if (saved && saved in SKINS) await update($, skin, () => saved)
100 await become($, 'idle')
101 return next(e)
102 })
103
104 on('command.run', { command: 'buddy' }, async ($, e) => {
105 const [word, name = ''] = e.args.trim().split(/\s+/)
106 if (word !== 'skin' || !(name in SKINS)) return { text: `Usage: /buddy skin ${Object.keys(SKINS).join(' | ')}` }
107 await update($, skin, () => name)
108 await $.store.set('skin', name)
109 return { text: `Clawd is now ${name}.` }
110 })
111
112 on('turn.start', async ($, e, next) => {
113 await update($, activity, () => 'think' as Activity)
114 await become($, 'working')
115 return next(e)
116 })
117
118 on('session.measure', async ($, e, next) => {
119 await update($, signals, s => ({ ...s, hot: isHot(e.context, e.rateLimits) }))
120 return next(e)
121 })
122
123 on('tool.call', async ($, e, next) => {
124 const isMain = e.agentId === undefined
125 const prop = isMain ? activityOf(e.tool, 'command' in e ? String(e.command) : '') : undefined
126 if (prop) await update($, activity, () => prop)
127 // Delegating is long by design, so only other tools count as slow.
128 let isSlow = false
129 const watch = isMain && e.tool !== 'Agent'
130 ? $.clock.after(SLOW_MS, async () => {
131 isSlow = true
132 await update($, signals, s => ({ ...s, slow: s.slow + 1 }))
133 })
134 : undefined
135 try {
136 return await next(e)
137 } finally {
138 watch?.cancel()
139 if (isSlow) await update($, signals, s => ({ ...s, slow: s.slow - 1 }))
140 if (prop) await update($, activity, () => 'think' as Activity)
141 }
142 })
143
144 on('turn.complete', async ($, e, next) => {
145 if (e.agentId === undefined) await become($, e.isAborted || e.reason === 'error' ? 'oops' : 'happy')
146 return next(e)
147 })
148
149 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150 const below = await next(e)
151 if (e.props.hasSurvey) return below
152 const elements = $.ui.resolve(e)
153 const { Box } = elements
154 const [m, act, s, sig, last, ttl] = await Promise.all([read($, mood), read($, activity), read($, skin), read($, signals), read($, cacheLast), read($, cacheTtl)])
155 const isCold = last !== null && Date.now() > last.at + ttl * 60_000
156 const strain: Strain = sig.hot ? 'hot' : sig.slow > 0 ? 'slow' : isCold ? 'cold' : 'none'
157 const isStill = (await $.settings.read()).prefersReducedMotion === true
158 // The terminal runs Clawd as a Client on its own clock; elsewhere he is one Svg that animates itself.
159 const art = e.surface === 'terminal' && 'Client' in elements
160 ? <elements.Client key="clawd" module="./clawd-term.tsx" props={{ mood: m, activity: act, skin: s, isStill, strain }} />
161 : 'Svg' in elements
162 ? <elements.Svg source={toSvg(m, act, SKINS[s] ?? SKINS.classic!, isStill, strain)} alt={`Clawd, ${m}`} width={W * PX} height={H * PX} isInteractive={!isStill} />
163 : null
164 if (!art) return below
165
166 return pack(Box, below, <Box key="buddy">{art}</Box>)
167 })
168}
169hooks/clawd.ts 244 lines1import type { Activity, Mood, Strain } from '../types'
2
3// Clawd exactly as Claude Code draws him (its built-in mods guide's CLAWD_POSES): C body, E eye, . nothing.
4const CLAWD = [
5 '..CCCCCCCCCCCCC..',
6 '..CCCCCCCCCCCCC..',
7 '..CCCECCCCCCCEC..',
8 '..CCCECCCCCCCEC..',
9 'CCCCCCCCCCCCCCCCC',
10 'CCCCCCCCCCCCCCCCC',
11 '..CCCCCCCCCCCCC..',
12 '..CCCCCCCCCCCCC..',
13 '..C.C.......C.C..',
14 '..C.C.......C.C..',
15]
16const EYE_ROWS = [2, 3]
17const EYE_COLS = [5, 13]
18const ARM_ROWS = [4, 5]
19const ARM_COLS = { left: [0, 1], right: [15, 16] }
20const LEG_ROW = 9
21const LEGS = { outer: [2, 14], inner: [4, 12] }
22
23// Two rows above him for hats, hearts and antennas, and a column each side for the head shake.
24// Everything he holds stays inside this box, so the tile never changes size.
25const TOP = 2
26const LEFT = 1
27export const W = LEFT + CLAWD[0]!.length + 1
28export const H = TOP + CLAWD.length
29
30// Props share Clawd's pixel style; each letter is one palette color, C is the body.
31export const PALETTE: Record<string, number> = {
32 E: 0x141414, // eyes
33 G: 0x3a3a40, // dark metal
34 g: 0xb8bcc4, // light metal
35 b: 0x7aa2f7, // screen glow
36 W: 0xffffff,
37 w: 0x8b5a2b, // wood
38 y: 0xf5c542,
39 r: 0xe5484d,
40 n: 0x5fd38d,
41 N: 0x5a6fd6, // nightcap
42}
43export const SKINS: Record<string, number> = {
44 classic: 0xd77757,
45 ink: 0xe8e6e3,
46 mint: 0x7fd1ae,
47 sky: 0x7aa2f7,
48 berry: 0xe07ab0,
49 gold: 0xe5c07b,
50}
51
52const LAPTOP = ['GGGGGGGGG', 'GGGGgGGGG', 'GGGGGGGGG', '.ggggggggggg']
53const LENS = ['.GG.', 'GbbG', 'GbbG', '.GG.']
54const PAPER = ['WWWWW', 'WGGGW', 'WWWWW', 'WGGGW', 'WWWWW']
55const MINI = ['CCC', 'C.C']
56const HEART = ['rr.rr', '.rrr.']
57const CROWN = ['y.y.y', 'yyyyy']
58const NIGHTCAP = ['....NNNN.', '.NNNNNNNNW']
59const CONFETTI = ['y', 'r', 'n', 'b', 'W']
60
61// How fast each mood plays, and how many frames make one loop of it.
62export const FRAME_MS: Record<Mood, number> = { idle: 300, working: 200, happy: 160, oops: 90, asleep: 900, loved: 140 }
63export const LOOP: Record<Mood, number> = { idle: 40, working: 8, happy: 8, oops: 8, asleep: 3, loved: 10 }
64
65type Gesture = 'none' | 'blink' | 'look-left' | 'look-right' | 'wave'
66
67// Idle Clawd blinks, glances both ways and waves once per loop, at uneven beats so it never feels mechanical.
68const idleGesture = (f: number): Gesture => {
69 const beat = f % LOOP.idle
70 if (beat === 7 || beat === 23 || beat === 25) return 'blink'
71 if (beat >= 12 && beat < 16) return 'look-left'
72 if (beat >= 28 && beat < 32) return 'look-right'
73 if (beat >= 34 && beat < 38) return 'wave'
74 return 'none'
75}
76
77// A pixel is empty (undefined) or a palette letter.
78export type Pixels = (string | undefined)[][]
79
80const put = (canvas: Pixels, x: number, y: number, c: string) => {
81 if (y >= 0 && y < H && x >= 0 && x < W) canvas[y]![x] = c
82}
83
84const stamp = (canvas: Pixels, art: string[], x: number, y: number) =>
85 art.forEach((row, dy) => [...row].forEach((c, dx) => c !== '.' && put(canvas, x + dx, y + dy, c)))
86
87// One frame: Clawd's pose for the mood, then what he holds or wears for what the agent is doing.
88// The pixel Clawd has one prop per kind of tool; the newer kinds of work borrow the closest one.
89const PIXEL_PROP: Record<Activity, Activity> = {
90 think: 'think', laptop: 'laptop', hammer: 'hammer', magnify: 'magnify', notes: 'notes', web: 'web', agents: 'agents',
91 test: 'laptop', git: 'laptop', install: 'laptop', build: 'hammer', ask: 'think', mcp: 'web', skill: 'notes', plan: 'think',
92}
93
94export const draw = (m: Mood, activity: Activity, f: number, strain: Strain = 'none'): Pixels => {
95 const act = PIXEL_PROP[activity]
96 const g = m === 'idle' ? idleGesture(f) : 'none'
97 const isWorking = m === 'working'
98 const body = CLAWD.map(r => [...r])
99 const isShut = m === 'asleep' || m === 'oops' || g === 'blink'
100 const isSquint = m === 'happy' || m === 'loved'
101 // Over a laptop or a page he looks down; otherwise he glances where the gesture or the work points.
102 const isDown = isWorking && (act === 'laptop' || act === 'notes')
103 const shift = g === 'look-left' ? -1 : g === 'look-right' || (isWorking && !isDown) ? 1 : 0
104 for (const col of EYE_COLS) {
105 for (const row of EYE_ROWS) body[row]![col] = 'C'
106 if (isShut) continue
107 for (const row of isSquint || isDown ? [EYE_ROWS[1]!] : EYE_ROWS) body[row]![col + shift] = 'E'
108 }
109 const raise = (cols: number[]) => {
110 for (const col of cols) {
111 for (const row of ARM_ROWS) body[row]![col] = '.'
112 for (const row of EYE_ROWS) body[row]![col] = 'C'
113 }
114 }
115 const isBounce = (m === 'happy' || m === 'loved') && f % 2 === 0
116 if (isBounce) raise([...ARM_COLS.left, ...ARM_COLS.right])
117 if (g === 'wave' && f % 2 === 0) raise(ARM_COLS.right)
118 if (isWorking && act === 'hammer' && f % 4 < 2) raise(ARM_COLS.right)
119 if (isWorking && act === 'think') for (const col of f % 2 === 0 ? LEGS.inner : LEGS.outer) body[LEG_ROW]![col] = '.'
120 if (m === 'asleep') for (const col of [...LEGS.inner, ...LEGS.outer]) body[LEG_ROW]![col] = '.'
121
122 const ox = LEFT + (m === 'oops' ? (f % 2 === 0 ? -1 : 1) : 0)
123 const oy = TOP + ((m === 'happy' || m === 'loved') && f % 2 === 1 ? 1 : m === 'asleep' ? 1 : 0)
124 const canvas: Pixels = Array.from({ length: H }, () => Array<string | undefined>(W).fill(undefined))
125 stamp(canvas, body.map(r => r.join('')), ox, oy)
126
127 if (isWorking && act === 'laptop') {
128 // Typing: the lid faces us, his arms tap the keys in turn and the screen's glow flickers on the lid's edge.
129 stamp(canvas, LAPTOP, ox + 3, oy + 6)
130 put(canvas, ox + (f % 2 === 0 ? 1 : 15), oy + 6, 'C')
131 if (f % 3 !== 0) put(canvas, ox + 4 + (f % 7), oy + 5, 'b')
132 }
133 if (isWorking && act === 'hammer') {
134 if (f % 4 < 2) {
135 put(canvas, ox + 16, oy + 1, 'w')
136 put(canvas, ox + 16, oy, 'w')
137 stamp(canvas, ['GGG', 'GGG'], ox + 15, oy - 2)
138 } else {
139 put(canvas, ox + 17, oy + 5, 'w')
140 put(canvas, ox + 17, oy + 6, 'w')
141 stamp(canvas, ['GG', 'GG'], ox + 16, oy + 7)
142 if (f % 4 === 2) put(canvas, ox + 15, oy + 9, 'y')
143 }
144 }
145 if (isWorking && act === 'magnify') {
146 // The lens drifts over his right eye, which looks bigger behind the glass.
147 const bob = f % 4 < 2 ? 0 : 1
148 stamp(canvas, LENS, ox + 11, oy + 1 + bob)
149 stamp(canvas, ['EE', 'EE'], ox + 12, oy + 2 + bob)
150 put(canvas, ox + 15, oy + 5 + bob, 'w')
151 }
152 if (isWorking && act === 'notes') {
153 stamp(canvas, PAPER, ox + 6, oy + 5)
154 // Ticks land on the page's lines one by one.
155 if (f % 3 >= 1) put(canvas, ox + 7, oy + 6, 'n')
156 if (f % 3 >= 2) put(canvas, ox + 7, oy + 8, 'n')
157 }
158 if (isWorking && act === 'web') {
159 put(canvas, ox + 8, oy - 1, 'G')
160 put(canvas, ox + 8, oy - 2, f % 2 === 0 ? 'r' : 'y')
161 if (f % 4 < 2) {
162 put(canvas, ox + 6, oy - 2, 'b')
163 put(canvas, ox + 10, oy - 2, 'b')
164 } else {
165 put(canvas, ox + 5, oy - 1, 'b')
166 put(canvas, ox + 11, oy - 1, 'b')
167 }
168 }
169 if (isWorking && act === 'agents') {
170 // Little helpers hop beside his head while subagents run.
171 stamp(canvas, MINI, ox, oy - 2 + (f % 2))
172 stamp(canvas, MINI, ox + 14, oy - 1 - (f % 2))
173 }
174 if (isWorking && act === 'think') {
175 const bubbles: [number, number][] = [[ox + 13, oy - 1], [ox + 15, oy - 2], [ox + 17, oy - 2]]
176 bubbles.slice(0, (f % 4) + 1).forEach(([x, y]) => put(canvas, x, y, 'W'))
177 }
178 if (m === 'happy') {
179 stamp(canvas, CROWN, ox + 6, oy - 2)
180 for (let k = 0; k < 6; k++) {
181 const x = (f * 5 + k * 7) % W
182 const y = (f + k * 3) % H
183 if (canvas[y]![x] === undefined) put(canvas, x, y, CONFETTI[k % CONFETTI.length]!)
184 }
185 }
186 if (m === 'loved') stamp(canvas, HEART, ox + 6, oy - 2)
187 if (m === 'asleep') stamp(canvas, NIGHTCAP, ox + 3, oy - 2)
188 if (m === 'oops') put(canvas, ox + 15, oy + 1 + (f % 2), 'b')
189 if (m === 'idle' || isWorking) {
190 const free = (x: number, y: number, c: string) => canvas[y]?.[x] === undefined && put(canvas, x, y, c)
191 // Sweat runs down his left side, snow drifts down his right, a clock hand turns above his shoulder.
192 if (strain === 'hot') for (const dy of [0, 1]) free(ox, oy + (f % 4) + dy, 'b')
193 if (strain === 'cold') {
194 free(ox + 16, oy + (f % 4), 'W')
195 free(ox + 17, oy + ((f + 2) % 4), 'W')
196 }
197 if (strain === 'slow') {
198 stamp(canvas, ['ggg', 'gWg', 'ggg'], ox + 15, oy - 2)
199 const [hx, hy] = [[1, 0], [2, 1], [1, 2], [0, 1]][f % 4]!
200 put(canvas, ox + 15 + hx!, oy - 2 + hy!, 'r')
201 }
202 }
203 return canvas
204}
205
206export const colorOf = (c: string | undefined, body: number) => (c === undefined ? undefined : c === 'C' ? body : PALETTE[c])
207
208export const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
209
210// Each run of same-colored pixels in a row becomes one rect, which keeps a whole loop of frames small.
211export const PX = 4
212const rectsOf = (px: Pixels, body: number) =>
213 px.flatMap((row, y) => {
214 const runs: string[] = []
215 for (let x = 0; x < W;) {
216 const color = colorOf(row[x], body)
217 let end = x + 1
218 while (end < W && colorOf(row[end], body) === color) end++
219 if (color !== undefined) runs.push(`<rect x="${x * PX}" y="${y * PX}" width="${(end - x) * PX}" height="${PX}" fill="${hex(color)}"/>`)
220 x = end
221 }
222 return runs
223 }).join('')
224
225// The desktop's Clawd is one Svg that animates itself: each distinct frame is a group shown for its slice of the
226// loop, so the page never swaps the picture. A click on him plays the loved jump with a heart, all inside the Svg.
227export const toSvg = (m: Mood, act: Activity, body: number, isStill: boolean, strain: Strain = 'none') => {
228 const n = isStill ? 1 : LOOP[m]
229 const frames = Array.from({ length: n }, (_, f) => rectsOf(draw(m, act, f, strain), body))
230 const spans: { rects: string; from: number; to: number }[] = []
231 frames.forEach((rects, f) => {
232 const last = spans.at(-1)
233 if (last?.rects === rects) last.to = f + 1
234 else spans.push({ rects, from: f, to: f + 1 })
235 })
236 const dur = (n * FRAME_MS[m]) / 1000
237 const groups = spans.length === 1
238 ? `<g>${spans[0]!.rects}</g>`
239 : spans.map(s => `<g opacity="0">${s.rects}<animate attributeName="opacity" calcMode="discrete" dur="${dur}s" repeatCount="indefinite" keyTimes="0;${s.from / n};${s.to / n}" values="0;1;0"/></g>`).join('')
240 const loved = rectsOf(draw('loved', act, 0), body)
241 const tap = isStill ? '' : `<g opacity="0">${loved}<set attributeName="opacity" to="1" begin="hit.click" dur="0.9s"/><animateTransform attributeName="transform" type="translate" values="0 0;0 -8;0 0;0 -4;0 0" begin="hit.click" dur="0.9s"/></g><animate href="#mood" attributeName="opacity" values="0;0" begin="hit.click" dur="0.9s"/>`
242 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * PX}" height="${H * PX}" shape-rendering="crispEdges"><g id="mood">${groups}</g>${tap}<rect id="hit" width="${W * PX}" height="${H * PX}" fill="transparent" style="cursor:pointer"/></svg>`
243}
244hooks/clawd-term.tsx 167 lines1import type { ClientModule } from 'claude-code'
2
3import type { Activity, Mood, Scene, Strain } from '../types'
4import { FRAME_MS, hex, LOOP, SKINS } from './clawd'
5import { BODY, CLICK_MS, CLICKS, dealClicks, FEET, HEAD, marksOf, place } from './clawd-moves'
6import type { Frame, Mark, Sprite } from './clawd-moves'
7
8// `scene` is refreshed on every draw so the one ticker always plays the latest mood.
9type Local = { scene: Scene; frame: number; elapsed: number; click?: { name: string; step: number }; deck: string[]; last?: string }
10
11const TICK_MS = 30
12const PROP_W = 5
13
14// What he is doing reads beside him: a glyph in its own color and a word.
15const DOING: Record<Activity, { glyph: string; word: string; color: number }> = {
16 think: { glyph: '…', word: 'thinking', color: 0xb8bcc4 },
17 laptop: { glyph: '⌨', word: 'typing', color: 0x7aa2f7 },
18 hammer: { glyph: '✎', word: 'editing', color: 0xf5c542 },
19 magnify: { glyph: '⌕', word: 'reading', color: 0x7fd1ae },
20 web: { glyph: '◎', word: 'browsing', color: 0x7aa2f7 },
21 agents: { glyph: '⚑', word: 'delegating', color: 0xe07ab0 },
22 notes: { glyph: '☑', word: 'planning', color: 0x5fd38d },
23 test: { glyph: '◔', word: 'testing', color: 0x5fd38d },
24 git: { glyph: '◆', word: 'git', color: 0xf0883e },
25 install: { glyph: '↓', word: 'installing', color: 0x7fd1ae },
26 build: { glyph: '▤', word: 'building', color: 0xf5c542 },
27 ask: { glyph: '?', word: 'asking', color: 0xe07ab0 },
28 mcp: { glyph: '↯', word: 'connecting', color: 0x7aa2f7 },
29 skill: { glyph: '✦', word: 'casting', color: 0xc792ea },
30 plan: { glyph: '◇', word: 'drafting', color: 0xb8bcc4 },
31}
32
33// One frame: the sprite, the prop beside him (one cell per row, PROP_W wide) and the prop's color.
34type Look = Frame & { prop: string[]; tint: number }
35
36const GREY = 0xb8bcc4
37const GOLD = 0xf5c542
38const RED = 0xe5484d
39const at = (row: number, text: string) => [0, 1, 2].map(r => (r === row ? text : ''))
40const look = (head: string, body: string, feet: string, prop: string[], tint: number, extra: Omit<Frame, 'rows'> = {}): Look => ({ rows: [head, body, feet], prop, tint, ...extra })
41const SPARKS = ['✦', '✧', '⋆', '·']
42const ARC = ['◜', '◠', '◝', '◞', '◡', '◟']
43const arc = (i: number) => ARC[((i % ARC.length) + ARC.length) % ARC.length]!
44const alternate = (f: number, a: string, b: string, every = 1) => (Math.floor(f / every) % 2 === 0 ? a : b)
45
46// Each kind of work owns its motion. He only paces while thinking, so stepping never stands in for a prop.
47const WORK: Record<Activity, (f: number) => Look> = {
48 think: f => look(alternate(f, HEAD.left, HEAD.right, 4), BODY.rest, f % 2 ? FEET.step : FEET.stand, at(2 - Math.min(f % 4, 2), ['·', '∘', '○', '◯'][f % 4]!), DOING.think.color),
49 laptop: f => look(HEAD.down, alternate(f, BODY.right, BODY.left), FEET.desk, [0, 1, 2].map(r => '▬'.repeat((f + 2 * r) % 4)), DOING.laptop.color),
50 hammer: f => look(HEAD.open, f % 4 < 2 ? BODY.right : BODY.rest, FEET.stand, f % 4 < 2 ? ['▀█▀', ' █', ''] : ['', ' █', `▄█▄${f % 4 === 2 ? '✦' : '·'}`], DOING.hammer.color, f % 4 === 2 ? { offset: 1, dust: true } : {}),
51 magnify: f => look(alternate(f, HEAD.left, HEAD.right, 3), BODY.rest, FEET.stand, f % 4 < 2 ? ['◎', ' ╲', ''] : ['', '◎', ' ╲'], DOING.magnify.color),
52 web: f => look(HEAD.open, f % 4 < 2 ? BODY.both : BODY.rest, FEET.stand, at(0, `·${')'.repeat(f % 4)}`), DOING.web.color),
53 agents: f => look(HEAD.open, alternate(f, BODY.both, BODY.rest), FEET.stand, f % 2 ? ['', ' ▟▙', '▟▙'] : ['', '▟▙', ' ▟▙'], DOING.agents.color),
54 notes: f => look(HEAD.down, alternate(f, BODY.right, BODY.rest), FEET.stand, [0, 1, 2].map(r => (r < f % 4 ? '☑' : '☐')), DOING.notes.color),
55 test: f => look(HEAD.open, BODY.rest, alternate(f, FEET.tapLeft, FEET.tapRight), at(1, `${'▰'.repeat(f % 6)}${'▱'.repeat(5 - (f % 6))}`), DOING.test.color),
56 git: f => look(HEAD.open, alternate(f, BODY.right, BODY.rest, 2), FEET.stand, at(1, [0, 1, 2].map(i => (i === f % 3 ? '●' : '○')).join('─')), DOING.git.color),
57 install: f => look(HEAD.open, f % 3 === 2 ? BODY.both : BODY.rest, FEET.stand, [0, 1, 2].map(r => (r === 2 ? (f % 3 === 2 ? '╰▣╯' : '╰ ╯') : r === f % 3 ? ' ▣' : '')), DOING.install.color),
58 build: f => look(HEAD.down, alternate(f, BODY.left, BODY.right), FEET.stand, at(1, `${arc(f)} ${arc(-f)}`), DOING.build.color),
59 ask: f => look(HEAD.open, BODY.right, alternate(f, FEET.tapLeft, FEET.tapRight, 2), at(0, f % 2 ? ' ?' : '?'), DOING.ask.color),
60 mcp: f => look(HEAD.open, alternate(f, BODY.both, BODY.rest, 2), FEET.stand, at(1, ['─┤ ├─', '─┤↯├─', '─┤↯├─', '─┤ ├─'][f % 4]!), DOING.mcp.color),
61 skill: f => look(HEAD.open, BODY.right, FEET.stand, [0, 1, 2].map(r => (r === f % 3 ? ` ${SPARKS[f % SPARKS.length]}` : r === (f + 1) % 3 ? '⋆' : '')), DOING.skill.color),
62 plan: f => look(alternate(f, HEAD.left, HEAD.right, 4), BODY.rest, FEET.stand, at(0, f % 4 === 3 ? '◉' : '○'), DOING.plan.color),
63}
64
65// Idle blinks, glances both ways and waves one arm with a greeting at uneven beats; the other moods are one loop each.
66const moodLook = (m: Mood, act: Activity, f: number): Look => {
67 switch (m) {
68 case 'working':
69 return WORK[act](f)
70 case 'happy': {
71 const beat = f % 4
72 const sparks = [0, 1, 2].map(r => `${' '.repeat(r % 2)}${SPARKS[(f + r) % SPARKS.length]}`)
73 return beat === 0 ? look(HEAD.open, BODY.rest, FEET.stand, sparks, GOLD, { offset: 1, dust: true })
74 : look(HEAD.open, BODY.both, beat === 3 ? FEET.stand : FEET.none, sparks, GOLD)
75 }
76 case 'asleep':
77 return look(HEAD.shut, BODY.rest, FEET.sit, at(2 - (f % 3), f % 3 ? 'Z' : 'z'), GREY)
78 case 'oops':
79 return f % 2 ? look(HEAD.shut, BODY.rest, FEET.stand, at(0, ''), RED, { offset: 1, dust: true }) : look(HEAD.shut, BODY.both, FEET.stand, at(0, '!'), RED)
80 default: {
81 const beat = f % LOOP.idle
82 const isWave = beat >= 34 && beat < 38
83 const head = beat === 7 || beat === 23 || beat === 25 ? HEAD.shut : beat >= 12 && beat < 16 ? HEAD.left : beat >= 28 && beat < 32 ? HEAD.right : HEAD.open
84 return look(head, isWave && f % 2 === 0 ? BODY.right : BODY.rest, FEET.stand, at(0, isWave ? 'hi!' : ''), GREY)
85 }
86 }
87}
88
89// A strain shows in the cell left of him, one row at a time: sweat falls, snow falls, a clock hand turns.
90const STRAIN: Record<Exclude<Strain, 'none'>, { cells: string[]; color: number; row: (f: number) => number }> = {
91 hot: { cells: ['°'], color: 0x7aa2f7, row: f => f % 3 },
92 cold: { cells: ['*'], color: 0x9ad0ff, row: f => (f + 1) % 3 },
93 slow: { cells: ['◴', '◷', '◶', '◵'], color: GOLD, row: () => 0 },
94}
95
96// A row's cells, each marked dim where dust or a shadow was drawn over it, grouped into runs of one look.
97const runsOf = (row: string, r: number, marks: Mark[]) => {
98 const cells = [...row].map(ch => ({ ch, isDim: false }))
99 for (const m of marks) if (m.row === r) [...m.text].forEach((ch, i) => void (cells[m.col + i] = { ch, isDim: true }))
100 return cells.reduce<{ text: string; isDim: boolean }[]>((runs, c) => {
101 const last = runs.at(-1)
102 return last?.isDim === c.isDim ? (last.text += c.ch, runs) : [...runs, { text: c.ch, isDim: c.isDim }]
103 }, [])
104}
105
106const Clawd: ClientModule<Scene, Local> = (scene, surface) => {
107 const { Box, Text } = surface.elements
108 const isFirst = surface.state === undefined
109 const live: Local = surface.state ?? { scene, frame: 0, elapsed: 0, deck: [] }
110 live.scene = scene
111
112 if (isFirst) {
113 surface.setState(live)
114 surface.every(TICK_MS, () => {
115 const cur = surface.state!
116 const frames = cur.click ? CLICKS[cur.click.name]! : undefined
117 if (cur.scene.isStill && !frames) return
118 const elapsed = cur.elapsed + TICK_MS
119 if (elapsed < (frames ? CLICK_MS : FRAME_MS[cur.scene.mood])) return void (cur.elapsed = elapsed)
120 const step = (cur.click?.step ?? 0) + 1
121 surface.setState({ ...cur, frame: cur.frame + 1, elapsed: 0, click: cur.click && step < frames!.length ? { ...cur.click, step } : undefined })
122 })
123 // Each click plays the next animation from a shuffled deck, so he never does the same one twice in a row.
124 surface.onPointer(e => {
125 const cur = surface.state!
126 if (e.type !== 'down' || cur.click || cur.scene.isStill) return
127 const deck = cur.deck.length ? cur.deck : dealClicks(cur.last)
128 const [name, ...rest] = deck
129 surface.setState({ ...cur, click: { name: name!, step: 0 }, deck: rest, last: name, elapsed: 0 })
130 })
131 }
132
133 const f = live.frame
134 const body = hex(SKINS[scene.skin] ?? SKINS.classic!)
135 const isClick = live.click !== undefined
136 const strain = !isClick && (scene.mood === 'idle' || scene.mood === 'working') && scene.strain !== 'none' ? STRAIN[scene.strain] : undefined
137 const l: Look = isClick ? { ...CLICKS[live.click!.name]![live.click!.step]!, prop: [], tint: GREY } : moodLook(scene.mood, scene.activity, f)
138 const shift = strain && scene.strain === 'cold' ? f % 2 : 0
139 const rows: Sprite = place({ ...l, x: (l.x ?? 0) + shift })
140 const marks = marksOf(l)
141 const doing = !isClick && scene.mood === 'working' ? DOING[scene.activity] : undefined
142
143 return (
144 <Box flexDirection="row" columnGap={1}>
145 <Box flexDirection="column">
146 {rows.map((row, r) => (
147 <Text>
148 <Text color={strain ? hex(strain.color) : undefined}>{strain && strain.row(f) === r ? strain.cells[f % strain.cells.length] : ' '}</Text>
149 {runsOf(row, r, marks).map(run => <Text color={run.isDim ? undefined : body} dimColor={run.isDim}>{run.text}</Text>)}
150 <Text> </Text>
151 </Text>
152 ))}
153 </Box>
154 <Box flexDirection="column">
155 {[0, 1, 2].map(r => (
156 <Text>
157 <Text color={hex(l.tint)}>{(l.prop[r] ?? '').padEnd(PROP_W)}</Text>
158 {r === 1 && doing && <Text color={hex(doing.color)}>{doing.glyph} {doing.word}</Text>}
159 </Text>
160 ))}
161 </Box>
162 </Box>
163 )
164}
165
166export default Clawd
167hooks/clawd-moves.ts 114 lines1// Clawd in the terminal is three rows of nine cells. A frame may push him down or sideways inside that box,
2// which clips him, so one sprite can hop, hide, peek and walk out of sight without the box ever changing size.
3export const ROWS = 3
4export const COLS = 9
5
6export type Sprite = [head: string, body: string, feet: string]
7
8export const HEAD = {
9 open: ' ▐▛███▜▌ ',
10 shut: ' ▐█████▌ ',
11 down: ' ▐▙███▟▌ ',
12 left: ' ▐▜███▜▌ ',
13 right: ' ▐▛███▛▌ ',
14 wink: ' ▐▛████▌ ',
15}
16export const BODY = { rest: '▝▜█████▛▘', both: '▗▟█████▙▖', left: '▗▟█████▛▘', right: '▝▜█████▙▖' }
17export const FEET = {
18 stand: ' ▘▘ ▝▝ ',
19 step: ' ▝▘ ▘▝ ',
20 sit: ' ▀▀ ▀▀ ',
21 none: ' ',
22 desk: ' ▄▄▄▄▄▄▄ ',
23 tapLeft: ' ▘▘ ',
24 tapRight: ' ▝▝ ',
25}
26
27export const STAND: Sprite = [HEAD.open, BODY.rest, FEET.stand]
28const CHEER: Sprite = [HEAD.open, BODY.both, FEET.stand]
29const AIR: Sprite = [HEAD.open, BODY.both, FEET.none]
30const LEFT: Sprite = [HEAD.left, BODY.rest, FEET.stand]
31const RIGHT: Sprite = [HEAD.right, BODY.rest, FEET.stand]
32const UP: Sprite = [HEAD.down, BODY.rest, FEET.stand]
33const SHUT: Sprite = [HEAD.shut, BODY.rest, FEET.stand]
34const WINK: Sprite = [HEAD.wink, BODY.rest, FEET.stand]
35
36// Turning around, front to back and round again: each step is a little narrower than the last.
37const THREE_QUARTERS: Sprite = [' ▐█▛██▌ ', ' ▝▜████▛▘', ' ▘▘ ▝▝ ']
38const EDGE: Sprite = [' ▐██▌ ', ' ▝███▘ ', ' ▝▝ ']
39const BACK: Sprite = [HEAD.shut, BODY.rest, FEET.stand]
40const THREE_QUARTERS_LEFT: Sprite = [' ▐██▜█▌ ', '▝▜████▛▘ ', ' ▘▘ ▝▝ ']
41const TURNAROUND = [STAND, THREE_QUARTERS, EDGE, BACK, EDGE, THREE_QUARTERS_LEFT, STAND]
42
43// `dust` puffs on both sides of a crouch, `shadow` is the ground under a hop.
44export type Frame = { rows: Sprite; offset?: number; x?: number; dust?: boolean; shadow?: boolean }
45export type Mark = { row: number; col: number; text: string }
46
47const blankRow = ' '.repeat(COLS)
48const shiftRow = (row: string, x: number) =>
49 x >= 0 ? (' '.repeat(Math.min(x, COLS)) + row).slice(0, COLS) : (row + ' '.repeat(Math.min(-x, COLS))).slice(Math.min(-x, COLS))
50
51// The sprite moved down by `offset` rows and right by `x` cells, whatever leaves the box is cut off.
52export const place = ({ rows, offset = 0, x = 0 }: Frame): Sprite => rows.map((_, r) => shiftRow(rows[r - offset] ?? blankRow, x)) as Sprite
53
54export const marksOf = ({ offset = 0, dust, shadow }: Frame): Mark[] => [
55 ...(dust && offset > 0 ? [{ row: ROWS - 1, col: 0, text: '·' }, { row: ROWS - 1, col: COLS - 1, text: '·' }] : []),
56 ...(shadow ? [{ row: ROWS - 1, col: 3, text: '▁▁▁' }] : []),
57]
58
59const hold = (rows: Sprite, n = 1, extra: Omit<Frame, 'rows'> = {}): Frame[] => Array.from({ length: n }, () => ({ rows, ...extra }))
60const crouch = (rows: Sprite = STAND, n = 2) => hold(rows, n, { offset: 1, dust: true })
61const blink = [...hold(SHUT), ...hold(STAND)]
62const turning = (n = 1, extra: Omit<Frame, 'rows'> = {}) => TURNAROUND.flatMap(rows => hold(rows, n, extra))
63const hops = (xs: number[]) => xs.flatMap(x => [...hold(AIR, 1, { x }), ...hold(STAND, 1, { x, offset: 1, dust: true })])
64const walk = (xs: number[], look: Sprite) =>
65 xs.flatMap((x, i) => hold([look[0], look[1], i % 2 === 0 ? FEET.tapLeft : FEET.tapRight], 2, { x }))
66
67export const CLICK_MS = 70
68
69// What a click plays, one by name, each frame lasting CLICK_MS. Every one ends back on STAND.
70export const CLICKS: Record<string, Frame[]> = {
71 jump: [...crouch(), ...hold(AIR, 3), ...crouch(STAND, 1), ...hold(AIR, 2), ...crouch(), ...hold(CHEER, 2), ...hold(STAND)],
72 look: [...hold(RIGHT, 4), ...hold(LEFT, 4), ...hold(UP, 3), ...hold(STAND)],
73 spin: [...turning(), ...turning(), ...hold(CHEER, 2), ...hold(STAND)],
74 peekaboo: [
75 ...hold(STAND, 1, { offset: ROWS }), ...hold(STAND, 3, { offset: 2 }), ...hold(RIGHT, 3, { offset: 2 }),
76 ...hold(LEFT, 3, { offset: 2 }), ...hold(STAND, 2, { offset: 2 }), ...hold(STAND, 1, { offset: 1 }), ...hold(CHEER, 3), ...blink,
77 ],
78 drop: [
79 ...hold(CHEER, 1, { offset: -ROWS }), ...hold(CHEER, 1, { offset: -2 }), ...hold(CHEER, 1, { offset: -1 }),
80 ...crouch(), ...hold(AIR, 1), ...crouch(STAND, 1), ...blink,
81 ],
82 wink: [...hold(WINK, 5), ...hold(STAND)],
83 boop: [...crouch(SHUT, 3), ...hold(LEFT, 4), ...hold(STAND, 2), ...blink],
84 tap: [
85 ...[2, 2, 2, 2, 1, 1].flatMap((n, i) => hold([HEAD.open, BODY.rest, i % 2 === 0 ? FEET.tapLeft : FEET.tapRight], n)),
86 ...hold(UP, 2), ...hold(CHEER, 2), ...hold(STAND),
87 ],
88 sneeze: [
89 ...hold([HEAD.shut, BODY.both, FEET.stand], 4), ...crouch(SHUT, 2),
90 ...[1, -1, 1, -1].flatMap(x => hold(SHUT, 1, { x })), ...hold(STAND, 2), ...blink,
91 ],
92 turn: [...turning(2), ...hold(STAND)],
93 'coin-hop': [
94 ...crouch(), ...TURNAROUND.slice(1, -1).flatMap(([head, body]) => hold([head, body, FEET.none], 1, { shadow: true })), ...hold(AIR, 1, { shadow: true }),
95 ...crouch(STAND, 1), ...hold(CHEER, 2), ...hold(STAND),
96 ],
97 skip: [...hops([3, 6, COLS]), ...hops([-6, -3]), ...hold(CHEER, 2), ...hold(STAND)],
98 waddle: [...walk([-2, -4, -6, -COLS], LEFT), ...hold(STAND, 3, { x: -COLS }), ...walk([COLS - 2, 6, 4, 2, 1, 0], LEFT), ...hold(STAND, 2), ...blink],
99 peek: [
100 ...walk([-3, -6, -COLS], LEFT), ...hold(RIGHT, 3, { x: -COLS }), ...hold(RIGHT, 4, { x: -6 }), ...hold(RIGHT, 2, { x: -COLS }),
101 ...hold(CHEER, 4, { x: -5 }), ...walk([-3, -1, 0], RIGHT), ...hold(STAND, 2),
102 ],
103}
104
105// Every click is played once before any repeats, and a new round never opens with the one just played.
106export const dealClicks = (last?: string, random: () => number = Math.random): string[] => {
107 const deck = Object.keys(CLICKS)
108 for (let i = deck.length - 1; i > 0; i--) {
109 const j = Math.floor(random() * (i + 1))
110 ;[deck[i], deck[j]] = [deck[j]!, deck[i]!]
111 }
112 return deck[0] === last ? [...deck.slice(1), deck[0]!] : deck
113}
114types/index.d.ts 20 lines1export type Mood = 'idle' | 'working' | 'happy' | 'oops' | 'asleep' | 'loved'
2
3export type Activity =
4 | 'think' | 'laptop' | 'hammer' | 'magnify' | 'notes' | 'web' | 'agents'
5 | 'test' | 'git' | 'install' | 'build' | 'ask' | 'mcp' | 'skill' | 'plan'
6
7// What is bothering him on top of his mood: usage near a limit, a cold cache, or a tool that has run a long time.
8export type Strain = 'none' | 'hot' | 'cold' | 'slow'
9
10export type Scene = { mood: Mood; activity: Activity; skin: string; isStill: boolean; strain: Strain }
11
12export type Signals = { hot: boolean; slow: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 buddy: { mood: Mood; activity: Activity; skin: string; signals: Signals }
17 'cache-timer': { last: { at: number; read: number; write: number; fresh: number } | null; now: number; ttlMin: number; twinkle: number }
18 }
19}
20