Clawd lives above your prompt and reacts to what Claude Code is doing

<img src="assets/hero.svg" width="420" alt="Clawd waving, thinking, running tests, cheering and falling asleep">
<h1 align="center">clawd-pet</h1>
made with the new Claude Code mods. he watches what claude is doing and reacts to it. happy when tests pass, sleepy when nothing happens and a bit grumpy if you rage too much 😅
works in the terminal and in the desktop app.
you need a Claude Code version with mods (2.1.287 or newer)
/plugin marketplace add Revono/clawd-pet
/plugin install clawd-pet@clawd-pet
/reload-plugins
if he doesn't show up, restart claude code. then run /clawd demo to see all his looks
<img src="assets/looks.svg" width="100%" alt="all of Clawd's looks">
/clawd show / hide
/clawd demo all looks, one after the other
/clawd moods on|off seasons, holidays, time of day
/clawd reactions on|off oops and test results
/clawd tantrums on|off if you don't like to be judged
/clawd hemisphere north|south so winter is at the right time
in a small terminal you get the mini Clawd from the startup banner. give it 64+ columns for the full one with hats and all
it's just a mod: one hooks file and the pixel art. no build, no deps. code is in plugins/clawd-pet/hooks. read it first before you install, like with every mod
ideas or a new look for him? open an issue or PR
everything stays on your machine. clawd sends nothing anywhere, makes no network calls and starts no other programs.
what he looks at, and why:
PermissionRequest hook) only to know when claude waits for you, so he can say "Needs you!". he never answers or decides anything, the request goes on unchanged and the dialog works like normalhe saves two small things with claude code's own storage: your /clawd settings and how many tools ran today
<sub>fan project, not from Anthropic. Clawd is their mascot, i just gave him a home above the prompt</sub>
hooks/register.tsx 792 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Base, BaseKind, Reaction, ReactionKind, Settings, ToolsToday } from '../types'
5import { BASE_LOOK, draw, toMiniRaster, toRaster, toSvg } from './sprites'
6import type { Hat, Icon, Look, Particle } from './sprites'
7
8// ---------------------------------------------------------------- state
9
10const base = atom({ plugin: 'clawd-pet', key: 'base' } as const, {
11 kind: 'hi',
12 since: 0,
13 line: '',
14 detail: '',
15} as Base)
16const reaction = atom({ plugin: 'clawd-pet', key: 'reaction' } as const, null as Reaction | null)
17const settings = atom({ plugin: 'clawd-pet', key: 'settings' } as const, {
18 isHidden: false,
19 moods: true,
20 reactions: true,
21 tantrums: true,
22 hemisphere: 'north',
23} as Settings)
24const tick = atom({ plugin: 'clawd-pet', key: 'tick' } as const, 0)
25const toolsToday = atom({ plugin: 'clawd-pet', key: 'toolsToday' } as const, {
26 date: '',
27 count: 0,
28} as ToolsToday)
29const demoStart = atom({ plugin: 'clawd-pet', key: 'demoStart' } as const, null as number | null)
30
31type Dollar = EngineInterface
32
33const SETTINGS_KEY = 'settings'
34
35async function setBase($: Dollar, kind: BaseKind, line = '', detail = '') {
36 const since = await $.clock.now()
37 await update($, base, () => ({ kind, since, line, detail }))
38}
39
40async function react($: Dollar, kind: ReactionKind, line: string, ms: number) {
41 const until = (await $.clock.now()) + ms
42 await update($, reaction, () => ({ kind, line, until }))
43}
44
45async function saveSettings($: Dollar, patch: Partial<Settings>) {
46 await update($, settings, s => ({ ...s, ...patch }))
47 await $.store.set(SETTINGS_KEY, await read($, settings))
48}
49
50// ---------------------------------------------------------------- time
51
52type Local = { year: number; month: number; day: number; hour: number; minute: number }
53
54function localTime(now: number): Local {
55 const d = new Date(now - new Date(now).getTimezoneOffset() * 60_000)
56 return {
57 year: d.getUTCFullYear(),
58 month: d.getUTCMonth() + 1,
59 day: d.getUTCDate(),
60 hour: d.getUTCHours(),
61 minute: d.getUTCMinutes(),
62 }
63}
64
65const pad = (n: number) => String(n).padStart(2, '0')
66const dateKey = (l: Local) => `${l.year}-${pad(l.month)}-${pad(l.day)}`
67const clock = (l: Local) => `${pad(l.hour)}:${pad(l.minute)}`
68
69// ---------------------------------------------------------------- calendar
70
71type Season = 'winter' | 'spring' | 'summer' | 'autumn'
72type Holiday = 'xmas' | 'newyear' | 'swiss' | 'halloween' | 'april' | 'bday'
73
74function season(l: Local, hemisphere: Settings['hemisphere']): Season {
75 const m = hemisphere === 'south' ? ((l.month + 5) % 12) + 1 : l.month
76 if (m === 12 || m <= 2) return 'winter'
77 if (m <= 5) return 'spring'
78 if (m <= 8) return 'summer'
79 return 'autumn'
80}
81
82function holiday(l: Local): Holiday | undefined {
83 const { month: m, day: d, hour } = l
84 if (m === 12 && d >= 24 && d <= 26) return 'xmas'
85 if ((m === 12 && d === 31 && hour >= 18) || (m === 1 && d === 1)) return 'newyear'
86 if (m === 8 && d === 1) return 'swiss'
87 if (m === 10 && d === 31) return 'halloween'
88 if (m === 4 && d === 1) return 'april'
89 if (m === 2 && d === 24) return 'bday'
90 return undefined
91}
92
93function holidayText(day: Holiday, l: Local): [string, string] {
94 switch (day) {
95 case 'xmas':
96 return ['Merry Xmas!', 'ho ho ho']
97 case 'newyear':
98 return ['Guets Neus!', `hello ${l.month === 12 ? l.year + 1 : l.year}`]
99 case 'swiss':
100 return ['1. August!', 'Happy Swiss Day 🇨🇭']
101 case 'halloween':
102 return ['Boo!', 'happy Halloween']
103 case 'april':
104 return ['Wait what?', 'April fools!']
105 case 'bday':
106 return ['Clawd bday!', `Claude Code turns ${l.year - 2025}`]
107 }
108}
109
110// ---------------------------------------------------------------- words
111
112const VERBS = [
113 'Noodling…',
114 'Clauding…',
115 'Pondering…',
116 'Percolating…',
117 'Cogitating…',
118 'Brewing…',
119 'Scheming…',
120 'Ruminating…',
121 'Simmering…',
122 'Conjuring…',
123 'Moseying…',
124 'Finagling…',
125 'Schlepping…',
126 'Tinkering…',
127 'Mulling…',
128 'Spelunking…',
129 'Hatching…',
130 'Wrangling…',
131]
132
133const PET_REPLIES = [
134 '♥ ♥ ♥',
135 '♥',
136 '♥ ♥',
137 'purr… (crabs can purr)',
138 'hehe, that tickles',
139 'more pets please',
140 '*happy claw clicking*',
141 'you are my favourite human',
142 '*wiggles all four legs*',
143 'best. session. ever.',
144 'i would refactor anything for you',
145 'pets > tokens',
146 '*blushes in #d97757*',
147 'ok one more. ok two more.',
148 'this is better than a passing build',
149 '*does a tiny sideways dance*',
150 'merci vielmal! ♥',
151 'chunt guet ♥',
152 'my context window is full of love',
153 'no bugs were harmed in this pet',
154 '*leans into it*',
155 'again! again!',
156 '10/10 would be petted again',
157 'i feel so seen',
158 '♥ git commit -m "got pets" ♥',
159 '*sideways crab scuttle of joy*',
160 'you smell like coffee and good code',
161 'is this what being merged feels like?',
162]
163
164const HMM_LINES = ['that is twice now…', 'i saw that', 'someone is grumpy', 'breathe in, breathe out']
165const FIT_LINES = ['WHY IS IT STILL BROKEN', 'i am a professional', 'this is fine 🔥', 'not again!!']
166
167let lastPick = ''
168function pick(list: string[]) {
169 let s = list[Math.floor(Math.random() * list.length)]
170 if (s === lastPick && list.length > 1) s = list[(list.indexOf(s) + 1) % list.length]
171 lastPick = s
172 return s
173}
174
175const FRUSTRATED =
176 /\b(wtf|wth|ffs|omg|ugh+|argh+|damn|dammit|shit|crap|still (not|broken|failing|wrong|doesn'?t)|doesn'?t work|not working|does not work|why (is|does|won'?t|isn'?t)|seriously|come on|cmon|stupid|useless|i said|what the|nope|scheiss\w*|verdammt|gopf\w*|huere\w*|chabis|nid (gange|funktioniert)|geit nid|funktioniert (immer no |immer noch )?(nid|nicht)|isch kaputt)\b|!!+|\?\?+|\?!|!\?/i
177
178function isFrustrated(text: string) {
179 if (FRUSTRATED.test(text)) return true
180 const shouty = text.match(/\b[A-ZÄÖÜ]{4,}\b/g) ?? []
181 return shouty.length >= 2
182}
183
184// ---------------------------------------------------------------- tools
185
186function iconFor(tool: string): Icon {
187 if (tool === 'Bash' || tool === 'PowerShell') return 'terminal'
188 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'pencil'
189 if (tool === 'Read' || tool === 'Grep' || tool === 'Glob') return 'magnifier'
190 if (tool === 'WebFetch' || tool === 'WebSearch') return 'globe'
191 if (tool === 'Agent' || tool === 'Task') return 'mini'
192 return 'bolt'
193}
194
195function basename(path: unknown) {
196 return typeof path === 'string' ? (path.split(/[\\/]/).pop() ?? path) : ''
197}
198
199function cut(s: string, n: number) {
200 return s.length > n ? s.slice(0, n - 1) + '…' : s
201}
202
203function lineFor(tool: string, input: Record<string, unknown>) {
204 switch (tool) {
205 case 'Read':
206 return `reading ${basename(input.file_path)}`
207 case 'Edit':
208 case 'Write':
209 case 'NotebookEdit':
210 return `editing ${basename(input.file_path ?? input.notebook_path)}`
211 case 'Bash':
212 case 'PowerShell':
213 return `$ ${cut(String(input.command ?? '').split('\n')[0], 48)}`
214 case 'Grep':
215 return `grep ${cut(String(input.pattern ?? ''), 40)}`
216 case 'Glob':
217 return `glob ${cut(String(input.pattern ?? ''), 40)}`
218 case 'WebFetch':
219 return cut(String(input.url ?? 'fetching'), 48)
220 case 'WebSearch':
221 return `searching “${cut(String(input.query ?? ''), 40)}”`
222 case 'Agent':
223 case 'Task':
224 return cut(String(input.description ?? 'calling in a friend'), 48)
225 default:
226 return tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' · ') : tool
227 }
228}
229
230const TEST_CMD =
231 /\b(flutter test|dart test|npm (run )?test|pnpm (run )?test|yarn test|bun test|npx (jest|vitest)|jest|vitest|pytest|go test|cargo test|gradlew?( \S+)* test|mvn test|dotnet test|rspec|phpunit|deno test)\b/
232
233// installing a test runner is not running the tests
234const INSTALL_CMD =
235 /\b(npm|pnpm|yarn|bun) (i|install|add)\b|\bpip install\b|\bcargo add\b|\b(dart|flutter) pub add\b/
236
237function testSummary(out: string): { isFail: boolean; summary: string } | null {
238 const failed = /(\d+)\s+(tests?\s+)?(failed|failing|failures?)/i.exec(out)
239 const passed = /(\d+)\s+(tests?\s+)?(passed|passing)/i.exec(out)
240 const flutterFail = /Some tests failed/i.test(out)
241 const flutterPass = /All tests passed/i.test(out)
242 const failCount = failed ? Number(failed[1]) : 0
243 const parts: string[] = []
244 if (passed) parts.push(`${passed[1]} passed`)
245 if (failCount > 0) parts.push(`${failCount} failed`)
246 if (failCount > 0 || flutterFail) return { isFail: true, summary: parts.join(', ') || 'some tests failed' }
247 if (passed || flutterPass) return { isFail: false, summary: parts.join(', ') || 'all green' }
248 return null
249}
250
251function duration(ms: number) {
252 const s = Math.round(ms / 1000)
253 return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`
254}
255
256// ---------------------------------------------------------------- scene
257
258const ORANGE = '#d97757'
259const GREEN = '#5cb85c'
260const BLUE = '#4a9fe8'
261const AMBER = '#f0a030'
262const RED = '#e05252'
263
264type Scene = {
265 look: Look
266 main: string
267 sub: string
268 color?: string
269 isMainDim?: boolean
270 border?: string
271}
272
273const HOP = [0, -1, -2, -1]
274
275function compose(
276 b: Base,
277 r: Reaction | null,
278 s: Settings,
279 l: Local,
280 now: number,
281 t: number,
282 tools: number,
283): Scene {
284 const look: Look = { ...BASE_LOOK, particles: [] }
285 const scene: Scene = { look, main: '', sub: '' }
286 const age = now - b.since
287
288 // timeouts, worked out from the clock instead of stored
289 let kind: BaseKind | 'sleep' = b.kind
290 let idleSince = b.since
291 const expire = (ms: number) => {
292 if (age > ms) {
293 kind = 'idle'
294 idleSince = b.since + ms
295 }
296 }
297 if (kind === 'hi') expire(4_000)
298 else if (kind === 'done') expire(300_000)
299 else if (kind === 'thinking' || kind === 'working' || kind === 'compacting') expire(1_200_000)
300 else if (kind === 'needs') expire(3_600_000)
301 if (kind === 'idle' && now - idleSince > 600_000) kind = 'sleep'
302
303 const isNight = l.hour >= 22 || l.hour < 6
304 const hol = s.moods ? holiday(l) : undefined
305
306 switch (kind) {
307 case 'hi':
308 look.eyes = 'happy'
309 look.arms = 'waveR'
310 look.dy = [0, -1, 0, 0][t % 4]
311 scene.main = 'Hi!'
312 scene.sub = 'new session'
313 scene.color = ORANGE
314 break
315 case 'thinking':
316 look.icon = 'spinner'
317 look.eyeDx = [-1, -1, 0, 1, 1, 0][Math.floor(t / 2) % 6]
318 look.dy = Math.floor(t / 3) % 2 === 0 ? 0 : -1
319 scene.main = b.line || 'Clauding…'
320 scene.sub = 'thinking'
321 scene.color = ORANGE
322 break
323 case 'working':
324 look.legs = 'walk'
325 look.arms = 'type'
326 look.icon = iconFor(b.detail)
327 scene.main = b.line
328 scene.sub = b.detail.replace(/^mcp__/, 'mcp ')
329 break
330 case 'needs': {
331 const isImpatient = age > 20_000
332 look.arms = isImpatient ? 'waveR' : 'up'
333 look.eyes = 'wide'
334 look.dy = t % 8 < 4 ? HOP[t % 4] : 0
335 look.icon = b.detail === 'question' ? 'question' : 'bang'
336 look.isIconBlinking = true
337 const flash = t % 4 < 2 ? RED : AMBER
338 scene.border = flash
339 scene.color = flash
340 scene.main = b.detail === 'question' ? 'Question!' : b.detail === 'plan' ? 'Plan ready' : 'Needs you!'
341 scene.sub = isImpatient ? `${b.line} · waiting ${duration(age)}` : b.line
342 break
343 }
344 case 'done':
345 look.eyes = 'happy'
346 if (age < 6_000) {
347 look.dy = HOP[t % 4]
348 look.arms = t % 4 < 2 ? 'up' : 'down'
349 }
350 if (age < 20_000) look.particles.push('sparkles')
351 scene.main = 'Done!'
352 scene.sub = b.line
353 scene.color = GREEN
354 break
355 case 'compacting':
356 look.squish = [0, 1, 2, 1][t % 4]
357 scene.main = 'Compacting'
358 scene.sub = 'squeezing context'
359 scene.color = BLUE
360 break
361 case 'idle':
362 look.eyes = t % 16 === 0 ? 'closed' : 'open'
363 look.eyeDx = [0, 0, -1, -1, 0, 0, 1, 1][Math.floor(t / 4) % 8]
364 if (hol && Math.floor(t / 16) % 2 === 1) {
365 const [a, c] = holidayText(hol, l)
366 scene.main = a
367 scene.sub = c
368 scene.color = ORANGE
369 } else {
370 scene.main = clock(l)
371 scene.sub = `${tools} tools today`
372 scene.isMainDim = true
373 }
374 break
375 case 'sleep':
376 look.eyes = 'closed'
377 look.particles.push('zzz')
378 look.dim = isNight ? 0.3 : 0.5
379 scene.main = clock(l)
380 scene.sub = 'zzz…'
381 scene.isMainDim = true
382 break
383 }
384
385 // reactions sit on top for a few seconds
386 const isReacting =
387 r !== null &&
388 now < r.until &&
389 (r.kind === 'loved' ||
390 ((r.kind === 'hmm' || r.kind === 'fit') && s.tantrums) ||
391 ((r.kind === 'oops' || r.kind === 'testsFail' || r.kind === 'testsPass') && s.reactions))
392 if (isReacting && r) {
393 look.dim = 1
394 look.particles = look.particles.filter(p => p === 'snow' || p === 'petals' || p === 'leaves')
395 look.isIconBlinking = false
396 scene.border = undefined
397 scene.isMainDim = false
398 switch (r.kind) {
399 case 'oops':
400 look.eyes = 'x'
401 look.dx = [0, -1, 1, 0][t % 4]
402 look.icon = 'cross'
403 scene.main = 'Oops'
404 scene.sub = r.line
405 scene.color = RED
406 break
407 case 'testsFail':
408 look.eyes = 'x'
409 look.icon = 'cross'
410 look.particles.push('drop')
411 scene.main = 'Tests failed'
412 scene.sub = r.line
413 scene.color = RED
414 break
415 case 'testsPass':
416 look.eyes = 'happy'
417 look.arms = 'up'
418 look.dy = HOP[t % 4]
419 look.icon = 'check'
420 look.particles.push('confetti')
421 scene.main = 'Tests pass!'
422 scene.sub = r.line
423 scene.color = GREEN
424 break
425 case 'loved':
426 look.eyes = 'happy'
427 look.dy = HOP[t % 4]
428 look.icon = undefined
429 look.particles.push('hearts')
430 scene.main = r.line
431 scene.sub = 'Clawd · loved'
432 scene.color = undefined
433 break
434 case 'hmm':
435 look.eyes = 'angry'
436 look.eyeDx = -1
437 look.icon = undefined
438 scene.main = 'Hmm…'
439 scene.sub = r.line
440 scene.color = AMBER
441 break
442 case 'fit':
443 look.eyes = 'angry'
444 look.dx = [0, -1, 1, 0][t % 4]
445 look.legs = 'stomp'
446 look.arms = 'up'
447 look.icon = undefined
448 look.particles.push('steam')
449 scene.border = t % 2 === 0 ? RED : '#7a2020'
450 scene.main = t % 8 < 4 ? 'FIX IT?!' : 'deep breaths…'
451 scene.sub = r.line
452 scene.color = RED
453 break
454 }
455 }
456
457 // moods: time of day, season, holidays
458 if (s.moods) {
459 const sea = season(l, s.hemisphere)
460 const holidayHat: Partial<Record<Holiday, Hat>> = {
461 xmas: 'santa',
462 newyear: 'party',
463 bday: 'party',
464 halloween: 'witch',
465 }
466 look.hat =
467 (hol && holidayHat[hol]) ??
468 (isNight ? 'nightcap' : sea === 'winter' ? 'beanie' : sea === 'spring' ? 'flower' : undefined)
469 look.hasPajamas = isNight
470 look.hasFlag = hol === 'swiss'
471 look.hasMug = l.hour >= 6 && l.hour < 10 && !look.hasFlag
472 look.hasSun = sea === 'summer' && l.hour >= 7 && l.hour < 20 && !look.hasFlag
473 look.hasGlasses = sea === 'summer' && l.hour >= 11 && l.hour < 17
474 const weather: Particle[] = []
475 if (sea === 'winter' || hol === 'xmas') weather.push('snow')
476 else if (sea === 'spring') weather.push('petals')
477 else if (sea === 'autumn') weather.push('leaves')
478 if ((hol === 'newyear' && (l.hour >= 20 || l.hour < 6)) || (hol === 'swiss' && l.hour >= 20)) {
479 weather.push('fireworks')
480 }
481 look.particles = [...weather, ...look.particles.filter(p => !weather.includes(p))]
482 look.isFlipped = hol === 'april' && kind === 'idle' && !isReacting
483 if (kind === 'sleep' && isNight) look.dim = 0.3
484 }
485
486 return scene
487}
488
489// ---------------------------------------------------------------- demo
490
491type Demo = {
492 label: string
493 base: [BaseKind, string, string, number?]
494 reaction?: [ReactionKind, string]
495 local?: Partial<Local>
496}
497
498const DEMOS: Demo[] = [
499 { label: 'Hi', base: ['hi', '', ''] },
500 { label: 'Thinking', base: ['thinking', 'Noodling…', ''] },
501 { label: 'Working · terminal', base: ['working', '$ flutter test', 'Bash'] },
502 { label: 'Working · pencil', base: ['working', 'editing main.dart', 'Edit'] },
503 { label: 'Working · magnifier', base: ['working', 'reading pubspec.yaml', 'Read'] },
504 { label: 'Working · globe', base: ['working', 'claude.dev/blog', 'WebFetch'] },
505 { label: 'Working · mini-Clawd', base: ['working', 'calling in a friend', 'Agent'] },
506 { label: 'Working · bolt', base: ['working', 'pixellab · create_character', 'mcp__pixellab__create_character'] },
507 { label: 'Needs you · permission', base: ['needs', 'Bash wants to run', 'permission'] },
508 { label: 'Needs you · question', base: ['needs', 'Claude has a question', 'question'] },
509 { label: 'Needs you · plan', base: ['needs', 'plan is ready for review', 'plan'] },
510 { label: 'Needs you · impatient', base: ['needs', 'Bash wants to run', 'permission', 25_000] },
511 { label: 'Done', base: ['done', '3m 12s · 14 tools', ''] },
512 { label: 'Compacting', base: ['compacting', '', ''] },
513 { label: 'Idle', base: ['idle', '', ''], local: { month: 10, day: 3, hour: 15 } },
514 { label: 'Sleep', base: ['idle', '', '', 700_000], local: { month: 10, day: 3, hour: 15 } },
515 { label: 'Oops', base: ['working', '', 'Bash'], reaction: ['oops', 'Bash failed'] },
516 { label: 'Tests failed', base: ['working', '', 'Bash'], reaction: ['testsFail', '12 passed, 2 failed'] },
517 { label: 'Tests pass', base: ['working', '', 'Bash'], reaction: ['testsPass', '42 passed'] },
518 { label: 'Loved', base: ['idle', '', ''], reaction: ['loved', '♥ ♥ ♥'] },
519 { label: 'Tantrum · hmm', base: ['thinking', 'Mulling…', ''], reaction: ['hmm', 'that is twice now…'] },
520 { label: 'Tantrum · fit', base: ['thinking', 'Mulling…', ''], reaction: ['fit', 'WHY IS IT STILL BROKEN'] },
521 { label: 'Morning', base: ['idle', '', ''], local: { month: 10, day: 3, hour: 8 } },
522 { label: 'Night', base: ['idle', '', ''], local: { month: 10, day: 3, hour: 23 } },
523 { label: 'Night · asleep', base: ['idle', '', '', 700_000], local: { month: 10, day: 3, hour: 23 } },
524 { label: 'Winter', base: ['idle', '', ''], local: { month: 1, day: 15, hour: 14 } },
525 { label: 'Spring', base: ['idle', '', ''], local: { month: 4, day: 15, hour: 14 } },
526 { label: 'Summer', base: ['idle', '', ''], local: { month: 7, day: 15, hour: 14 } },
527 { label: 'Autumn', base: ['idle', '', ''], local: { month: 10, day: 10, hour: 14 } },
528 { label: 'Xmas', base: ['idle', '', ''], local: { month: 12, day: 24, hour: 15 } },
529 { label: 'New Year', base: ['idle', '', ''], local: { month: 12, day: 31, hour: 22 } },
530 { label: '1. August', base: ['idle', '', ''], local: { month: 8, day: 1, hour: 21 } },
531 { label: 'Halloween', base: ['idle', '', ''], local: { month: 10, day: 31, hour: 15 } },
532 { label: 'April fools', base: ['idle', '', ''], local: { month: 4, day: 1, hour: 15 } },
533 { label: 'Clawd bday', base: ['idle', '', ''], local: { month: 2, day: 24, hour: 15 } },
534]
535const DEMO_MS = 3_500
536
537// ---------------------------------------------------------------- hooks
538
539const TOOLS_KEY = 'tools-today'
540
541async function onTick($: Dollar) {
542 if ((await read($, settings)).isHidden) return
543 await update($, tick, t => (t + 1) % 100_000)
544 const start = await read($, demoStart)
545 if (start !== null && (await $.clock.now()) - start > DEMOS.length * DEMO_MS) {
546 await update($, demoStart, () => null)
547 }
548}
549
550async function pet($: Dollar) {
551 await react($, 'loved', pick(PET_REPLIES), 4_000)
552}
553
554export const register: Register = on => {
555 let isTurnRunning = false
556 let turnStartedAt = 0
557 let turnTools = 0
558 let frustrations: number[] = []
559 let toolsInFlight = 0
560
561 on('session.start', async ($, e, next) => {
562 await $.command.register({
563 name: 'clawd',
564 description: 'Clawd: on/off, demo, moods|reactions|tantrums on|off, hemisphere north|south',
565 })
566 const saved = (await $.store.get(SETTINGS_KEY)) as Partial<Settings> | undefined
567 if (saved) await update($, settings, s => ({ ...s, ...saved }))
568 const tools = (await $.store.get(TOOLS_KEY)) as ToolsToday | undefined
569 if (tools) await update($, toolsToday, () => tools)
570 await setBase($, 'hi')
571 $.clock.every(250, () => void onTick($))
572
573 return next(e)
574 })
575
576 on('command.run', { command: 'clawd' }, async ($, e) => {
577 const [word = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
578 const onOff = (v: string) => v !== 'off' && v !== 'false' && v !== '0'
579
580 if (word === '') {
581 const hidden = !(await read($, settings)).isHidden
582 await saveSettings($, { isHidden: hidden })
583 return { text: hidden ? 'Clawd went to nap.' : 'Clawd is back.' }
584 }
585 if (word === 'on' || word === 'off') {
586 await saveSettings($, { isHidden: word === 'off' })
587 return { text: word === 'off' ? 'Clawd went to nap.' : 'Clawd is back.' }
588 }
589 if (word === 'demo') {
590 if (value === 'stop') {
591 await update($, demoStart, () => null)
592 return { text: 'Demo stopped.' }
593 }
594 await saveSettings($, { isHidden: false })
595 const start = await $.clock.now()
596 await update($, demoStart, () => start)
597 return {
598 text: `Clawd demo: ${DEMOS.length} looks, ${DEMO_MS / 1000}s each. /clawd demo stop ends it.`,
599 }
600 }
601 if (word === 'moods' || word === 'reactions' || word === 'tantrums') {
602 await saveSettings($, { [word]: onOff(value) })
603 return { text: `Clawd ${word}: ${onOff(value) ? 'on' : 'off'}` }
604 }
605 if (word === 'hemisphere' && (value === 'north' || value === 'south')) {
606 await saveSettings($, { hemisphere: value })
607 return { text: `Clawd seasons: ${value}ern hemisphere` }
608 }
609 const s = await read($, settings)
610 return {
611 text: [
612 '/clawd show or hide',
613 '/clawd demo [stop] walk through every look',
614 `/clawd moods on|off seasons, holidays, time of day (${s.moods ? 'on' : 'off'})`,
615 `/clawd reactions on|off oops and test results (${s.reactions ? 'on' : 'off'})`,
616 `/clawd tantrums on|off Clawd judges your frustration (${s.tantrums ? 'on' : 'off'})`,
617 `/clawd hemisphere north|south (${s.hemisphere})`,
618 ].join('\n'),
619 }
620 })
621
622 on('prompt.submit', async ($, e, next) => {
623 if (e.text.trim().startsWith('/')) return next(e)
624 isTurnRunning = true
625 turnStartedAt = await $.clock.now()
626 turnTools = 0
627 await setBase($, 'thinking', pick(VERBS))
628
629 if (isFrustrated(e.text)) {
630 const now = await $.clock.now()
631 frustrations = [...frustrations.filter(at => now - at < 1_200_000), now]
632 if (frustrations.length === 2) await react($, 'hmm', pick(HMM_LINES), 4_000)
633 if (frustrations.length >= 3) await react($, 'fit', pick(FIT_LINES), 5_000)
634 }
635
636 return next(e)
637 })
638
639 // only when a real permission dialog shows up, so bypass / auto modes stay quiet
640 on('classic.PermissionRequest', async ($, e, next) => {
641 if (e.permission_mode !== 'bypassPermissions') {
642 await setBase($, 'needs', `${e.tool_name.replace(/^mcp__/, '')} wants to run`, 'permission')
643 }
644 return next(e)
645 })
646
647 on('tool.call', async ($, e, next) => {
648 const input = e as unknown as Record<string, unknown>
649 if (!e.agentId) turnTools++
650
651 if (e.tool === 'AskUserQuestion') await setBase($, 'needs', 'Claude has a question', 'question')
652 else if (e.tool === 'ExitPlanMode') await setBase($, 'needs', 'plan is ready for review', 'plan')
653 else await setBase($, 'working', lineFor(e.tool, input), e.tool)
654
655 toolsInFlight++
656 const ran = await next(e).finally(() => {
657 toolsInFlight = Math.max(0, toolsInFlight - 1)
658 })
659
660 const now = await $.clock.now()
661 const today = dateKey(localTime(now))
662 await update($, toolsToday, t =>
663 t.date === today ? { date: today, count: t.count + 1 } : { date: today, count: 1 },
664 )
665 void $.store.set(TOOLS_KEY, await read($, toolsToday))
666
667 const command = String(input.command ?? '')
668 const isTest =
669 (e.tool === 'Bash' || e.tool === 'PowerShell') && TEST_CMD.test(command) && !INSTALL_CMD.test(command)
670 if (ran.deny !== undefined) {
671 await react($, 'oops', `${e.tool} not allowed`, 2_200)
672 } else if (isTest) {
673 const out = JSON.stringify(ran.result ?? '')
674 const verdict = testSummary(out) ?? { isFail: ran.isError === true, summary: cut(command, 40) }
675 await react($, verdict.isFail || ran.isError ? 'testsFail' : 'testsPass', verdict.summary, 3_500)
676 } else if (ran.isError) {
677 await react($, 'oops', `${e.tool.replace(/^mcp__/, '')} failed`, 2_200)
678 }
679
680 // with parallel tools, stay on working until the last one is back
681 if (isTurnRunning && toolsInFlight === 0) await setBase($, 'thinking', pick(VERBS))
682 return ran
683 })
684
685 on('session.compact', async ($, e, next) => {
686 if (e.agentId) return next(e)
687 await setBase($, 'compacting')
688 const result = await next(e)
689 if (isTurnRunning) await setBase($, 'thinking', pick(VERBS))
690 else await setBase($, 'idle')
691 return result
692 })
693
694 on('turn.complete', async ($, e, next) => {
695 // a subagent finishing is not the main turn finishing
696 if (e.agentId) return next(e)
697 isTurnRunning = false
698 toolsInFlight = 0
699 if (e.isAborted || e.reason === 'aborted') {
700 await setBase($, 'idle')
701 return next(e)
702 }
703 if (e.reason === 'error' || e.reason === 'refusal') {
704 await setBase($, 'idle')
705 await react($, 'oops', e.reason === 'error' ? 'the turn ended with an error' : 'claude refused that one', 2_200)
706 return next(e)
707 }
708 const ms = e.durationMs > 0 ? e.durationMs : (await $.clock.now()) - turnStartedAt
709 await setBase($, 'done', `${duration(ms)} · ${turnTools} tool${turnTools === 1 ? '' : 's'}`)
710 return next(e)
711 })
712
713 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
714 const s: Settings = { ...(await read($, settings)) } as Settings
715 if (e.props.hasSurvey || s.isHidden) return next(e)
716
717 const t = await read($, tick)
718 const now = await $.clock.now()
719 let b = await read($, base)
720 let r = await read($, reaction)
721 let l = localTime(now)
722 const counted = await read($, toolsToday)
723 const tools = counted.date === dateKey(l) ? counted.count : 0
724 let effective = s
725 let demoLabel = ''
726
727 const start = await read($, demoStart)
728 const index = start === null ? -1 : Math.floor((now - start) / DEMO_MS)
729 if (index >= 0 && index < DEMOS.length) {
730 const d = DEMOS[index]
731 const [kind, line, detail, age = 0] = d.base
732 b = { kind, line, detail, since: now - age }
733 r = d.reaction ? { kind: d.reaction[0], line: d.reaction[1], until: now + 1_000 } : null
734 l = { ...l, minute: 0, ...d.local }
735 effective = { ...s, moods: true, reactions: true, tantrums: true }
736 demoLabel = `demo ${index + 1}/${DEMOS.length} · ${d.label}`
737 }
738
739 const scene = compose(b, r, effective, l, now, t, tools)
740 const canvas = draw(scene.look, t)
741 const sub = demoLabel ? `${scene.sub ? scene.sub + ' · ' : ''}${demoLabel}` : scene.sub
742
743 if (e.surface === 'terminal') {
744 const { Box, Text, Button, Raster } = $.ui.resolve(e)
745 const isRoomy = e.props.bodyColumns >= 64 && e.props.maxRows >= 9
746 const art = isRoomy
747 ? toRaster(canvas, scene.look.dim)
748 : toMiniRaster(canvas, scene.look, scene.look.dim)
749 return (
750 <Box alignItems="center" gap={2} borderStyle={scene.border ? 'round' : undefined} borderColor={scene.border}>
751 <Raster key="clawd" columns={art.columns} rows={art.rows} cells={art.cells} />
752 <Box flexDirection="column" flexShrink={1} flexGrow={1}>
753 <Text color={scene.color} bold={!scene.isMainDim} dimColor={scene.isMainDim} wrap="truncate-end">
754 {scene.main}
755 </Text>
756 <Text dimColor wrap="truncate-end">
757 {sub}
758 </Text>
759 </Box>
760 <Button key="pet" label="pet" hotkey="p" plain onPress={() => void pet($)} />
761 </Box>
762 )
763 }
764
765 const { Box, Text, Button, Svg } = $.ui.resolve(e)
766 return (
767 <Box
768 alignItems="center"
769 gap={2}
770 paddingX={1}
771 borderStyle={scene.border ? 'round' : undefined}
772 borderColor={scene.border}
773 >
774 {Svg && (
775 <Svg source={toSvg(canvas, scene.look.dim)} alt={`Clawd: ${scene.main}`} width={102} height={54} />
776 )}
777 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
778 <Text color={scene.color} bold={!scene.isMainDim} dimColor={scene.isMainDim} wrap="truncate-end">
779 {scene.main}
780 </Text>
781 <Text dimColor wrap="truncate-end">
782 {sub}
783 </Text>
784 </Box>
785 <Button key="pet" onPress={() => void pet($)}>
786 Pet
787 </Button>
788 </Box>
789 )
790 })
791}
792hooks/sprites.ts 564 lines1// Clawd's pixel world. One canvas of square pixels, drawn as SVG rects on
2// the desktop and as half-block cells (two pixels per cell) in the terminal.
3// Clawd's own pixels are the banner's: one wide, two tall.
4
5export const W = 34
6export const H = 18
7export const NONE = -1
8
9const OX = 8 // Clawd's left edge
10const OY = 8 // Clawd's body top (rows 8..15 body, 16..17 legs)
11
12export const PAL: Record<string, number> = {
13 O: 0xd97757, // Clawd
14 D: 0x1f1e1d, // eyes
15 W: 0xf2f2f2,
16 R: 0xe04848,
17 r: 0x9e2a2a,
18 B: 0x3f6fd1,
19 b: 0x9cc4f0,
20 G: 0x4caf50,
21 Y: 0xf2c94c,
22 A: 0xf0a030,
23 P: 0xff8fa3,
24 V: 0x7a4fc0,
25 N: 0x6b4226,
26 g: 0x9a9a9a,
27 K: 0x2b2b2b,
28 L: 0x8bc34a,
29}
30
31export type Eyes = 'open' | 'closed' | 'happy' | 'wide' | 'x' | 'angry'
32export type Arms = 'down' | 'up' | 'waveR' | 'type'
33export type Legs = 'stand' | 'walk' | 'stomp'
34export type Hat = 'santa' | 'party' | 'witch' | 'nightcap' | 'beanie' | 'flower'
35export type Icon =
36 | 'terminal'
37 | 'pencil'
38 | 'magnifier'
39 | 'globe'
40 | 'mini'
41 | 'bolt'
42 | 'spinner'
43 | 'bang'
44 | 'question'
45 | 'cross'
46 | 'check'
47export type Particle =
48 | 'snow'
49 | 'petals'
50 | 'leaves'
51 | 'confetti'
52 | 'sparkles'
53 | 'fireworks'
54 | 'steam'
55 | 'zzz'
56 | 'hearts'
57 | 'drop'
58
59export type Look = {
60 dx: number
61 dy: number
62 squish: number
63 isFlipped: boolean
64 eyes: Eyes
65 eyeDx: number
66 arms: Arms
67 legs: Legs
68 hat?: Hat
69 hasGlasses: boolean
70 hasPajamas: boolean
71 hasMug: boolean
72 hasSun: boolean
73 hasFlag: boolean
74 icon?: Icon
75 isIconBlinking: boolean
76 particles: Particle[]
77 dim: number
78}
79
80export const BASE_LOOK: Look = {
81 dx: 0,
82 dy: 0,
83 squish: 0,
84 isFlipped: false,
85 eyes: 'open',
86 eyeDx: 0,
87 arms: 'down',
88 legs: 'stand',
89 hasGlasses: false,
90 hasPajamas: false,
91 hasMug: false,
92 hasSun: false,
93 hasFlag: false,
94 isIconBlinking: false,
95 particles: [],
96 dim: 1,
97}
98
99export class Canvas {
100 px: number[] = Array(W * H).fill(NONE)
101
102 set(x: number, y: number, c: number) {
103 x = Math.round(x)
104 y = Math.round(y)
105 if (x >= 0 && x < W && y >= 0 && y < H) this.px[y * W + x] = c
106 }
107
108 get(x: number, y: number) {
109 return x >= 0 && x < W && y >= 0 && y < H ? this.px[y * W + x] : NONE
110 }
111
112 rect(x: number, y: number, w: number, height: number, c: number) {
113 for (let j = 0; j < height; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c)
114 }
115
116 sprite(rows: readonly string[], x: number, y: number) {
117 rows.forEach((row, j) => {
118 for (let i = 0; i < row.length; i++) {
119 const c = PAL[row[i]]
120 if (c !== undefined) this.set(x + i, y + j, c)
121 }
122 })
123 }
124
125 over(top: Canvas) {
126 top.px.forEach((c, i) => {
127 if (c !== NONE) this.px[i] = c
128 })
129 }
130}
131
132// ---------------------------------------------------------------- sprites
133
134const HATS: Record<Hat, { rows: string[]; x: number }> = {
135 santa: {
136 x: 3,
137 rows: [
138 '..........WW',
139 '.........RR.',
140 '.......RRR..',
141 '....RRRRR...',
142 '..RRRRRRRR..',
143 'WWWWWWWWWWWW',
144 ],
145 },
146 party: {
147 x: 6,
148 rows: ['..Y..', '..P..', '.PYP.', '.YPY.', 'PYPYP'],
149 },
150 witch: {
151 x: 3,
152 rows: [
153 '.......VV...',
154 '......VV....',
155 '.....VVV....',
156 '....VVVVV...',
157 '...VAAAAV...',
158 'VVVVVVVVVVVV',
159 ],
160 },
161 nightcap: {
162 x: 3,
163 rows: ['......BBbbW.', '....bbBBbbBW', '..BBbbBBbbB.', 'WWWWWWWWWWWW'],
164 },
165 beanie: {
166 x: 3,
167 rows: ['.....WW.....', '...RRRRRR...', '..RRRRRRRR..', '.rrrrrrrrrr.'],
168 },
169 flower: {
170 x: 10,
171 rows: ['.P.', 'PYP', '.P.', '.L.'],
172 },
173}
174
175const ICONS: Record<Icon, string[][]> = {
176 terminal: [['ggggg', 'gKKKg', 'gWKKg', 'gKWWg', 'ggggg']],
177 pencil: [['....P', '...Y.', '..Y..', '.Y...', 'N....']],
178 magnifier: [['.ggg.', 'gb..g', 'g...g', '.ggg.', '....g']],
179 globe: [['.BBB.', 'BGBGB', 'BBGBB', 'BGBBG', '.BBB.']],
180 mini: [['.OOOOO.', '.ODODO.', 'OOOOOOO', '.O...O.']],
181 bolt: [['..YY.', '.YY..', 'YYYY.', '..YY.', '.YY..']],
182 spinner: [
183 ['..O..', '..O..', 'OOOOO', '..O..', '..O..'],
184 ['O...O', '.O.O.', '..O..', '.O.O.', 'O...O'],
185 ],
186 bang: [['.R.', '.R.', '.R.', '...', '.R.']],
187 question: [['AAA', '..A', '.AA', '...', '.A.']],
188 cross: [['R...R', '.R.R.', '..R..', '.R.R.', 'R...R']],
189 check: [['.....', '....G', '...G.', 'G.G..', '.G...']],
190}
191
192const SUN = [
193 ['Y.Y.Y', '.YYY.', 'YYAYY', '.YYY.', 'Y.Y.Y'],
194 ['..Y..', '.YYY.', 'YYAYY', '.YYY.', '..Y..'],
195]
196const MUG = ['WNNW..', 'WWWWW.', 'WWWW.W', 'WWWWW.', '.WWW..']
197const FLAG = ['RRRRRRR', 'RRRWRRR', 'RRRWRRR', 'RWWWWWR', 'RRRWRRR', 'RRRWRRR', 'RRRRRRR']
198const HEART = ['PP.PP', 'PPPPP', '.PPP.', '..P..']
199const ZED = ['gggg', '..g.', '.g..', 'gggg']
200const GLASSES = ['KKKKKKKKKK', 'bKK....bKK']
201
202// ---------------------------------------------------------------- drawing
203
204function rand(i: number) {
205 const s = Math.sin(i * 12.9898) * 43758.5453
206 return s - Math.floor(s)
207}
208
209function drawClawd(c: Canvas, look: Look, t: number) {
210 const s = look.squish
211 const x0 = OX + look.dx
212 const top = OY + look.dy + s
213 const O = PAL.O
214 const D = PAL.D
215
216 // body: 12 x 8, squished shorter and wider
217 c.rect(x0 + 3 - s, top, 12 + 2 * s, 8 - s, O)
218
219 // arms
220 const armY = top + 4
221 const left = () => c.rect(x0 + 1 - s, armY, 2, 2, O)
222 const right = () => c.rect(x0 + 15 + s, armY, 2, 2, O)
223 const leftUp = () => {
224 c.rect(x0 + 1 - s, armY - 2, 2, 2, O)
225 c.rect(x0 + 1 - s, armY - 4, 1, 2, O)
226 }
227 const rightUp = (lean = 0) => {
228 c.rect(x0 + 15 + s, armY - 2, 2, 2, O)
229 c.rect(x0 + 16 + s + lean, armY - 4, 1, 2, O)
230 }
231 if (look.arms === 'down') {
232 left()
233 right()
234 } else if (look.arms === 'up') {
235 leftUp()
236 rightUp()
237 } else if (look.arms === 'waveR') {
238 left()
239 rightUp(t % 2)
240 } else {
241 // typing: one arm dips, then the other
242 c.rect(x0 + 1 - s, armY + (t % 2 === 0 ? 1 : 0), 2, 2, O)
243 c.rect(x0 + 15 + s, armY + (t % 2 === 1 ? 1 : 0), 2, 2, O)
244 }
245
246 // legs: four, one wide, two tall
247 const legY = OY + look.dy + 8
248 const legXs = [4, 6, 11, 13]
249 legXs.forEach((lx, i) => {
250 let x = x0 + lx
251 let legH = 2
252 if (look.legs === 'walk' && t % 2 === 1) x += 1
253 if (look.legs === 'stomp' && (i < 2) === (t % 2 === 0)) legH = 1
254 c.rect(x, legY, 1, legH, O)
255 })
256
257 // eyes at 5 and 12, rows 2..3 of the body
258 const ey = top + 2 - (s > 1 ? 1 : 0)
259 for (const [ex, inward] of [
260 [x0 + 5 + look.eyeDx, 1],
261 [x0 + 12 + look.eyeDx, -1],
262 ] as const) {
263 switch (look.eyes) {
264 case 'open':
265 c.rect(ex, ey, 1, 2, D)
266 break
267 case 'closed':
268 c.set(ex, ey + 1, D)
269 c.set(ex + inward, ey + 1, D)
270 break
271 case 'happy':
272 c.set(ex - 1, ey + 1, D)
273 c.set(ex, ey, D)
274 c.set(ex + 1, ey + 1, D)
275 break
276 case 'wide':
277 c.rect(ex, ey - 1, 1, 3, D)
278 c.set(ex, ey - 1, PAL.W)
279 break
280 case 'x':
281 c.set(ex - 1, ey - 1, D)
282 c.set(ex + 1, ey - 1, D)
283 c.set(ex, ey, D)
284 c.set(ex - 1, ey + 1, D)
285 c.set(ex + 1, ey + 1, D)
286 break
287 case 'angry':
288 // one row lower than usual, so the brows sit inside the body
289 c.rect(ex, ey + 1, 1, 2, D)
290 c.set(ex - inward, ey - 1, D)
291 c.set(ex, ey - 1, D)
292 c.set(ex + inward, ey, D)
293 break
294 }
295 }
296
297 if (look.hasPajamas) {
298 for (let y = top + 6 - s; y <= legY + 1; y++) {
299 for (let x = 0; x < W; x++) {
300 if (c.get(x, y) === O) c.set(x, y, x % 2 === 0 ? PAL.B : PAL.b)
301 }
302 }
303 }
304
305 if (look.hasGlasses && (look.eyes === 'open' || look.eyes === 'happy')) {
306 c.sprite(GLASSES, x0 + 4, ey)
307 }
308
309 if (look.hat) {
310 const hat = HATS[look.hat]
311 c.sprite(hat.rows, x0 + hat.x, top - hat.rows.length)
312 }
313}
314
315// evenly over the width, with a little jitter, so weather never clumps on one side
316function spread(i: number, n: number) {
317 return Math.floor(((i + 0.2 + rand(i + 99) * 0.6) / n) * W)
318}
319
320// the top right corner is the icon's: keep weather out of it while an icon shows
321const ICON_ZONE = { x: 25, y: 9 }
322
323function drawParticles(back: Canvas, front: Canvas, look: Look, t: number) {
324 const weather = (x: number, y: number, c: number) => {
325 if (look.icon && x >= ICON_ZONE.x && y <= ICON_ZONE.y) return
326 back.set(x, y, c)
327 }
328 for (const p of look.particles) {
329 switch (p) {
330 case 'snow':
331 for (let i = 0; i < 12; i++) {
332 const x = spread(i, 12) + Math.round(Math.sin((t + i * 5) / 4))
333 const y = (Math.floor(t / 2) + Math.floor(rand(i + 50) * H)) % H
334 weather(x, y, PAL.W)
335 }
336 break
337 case 'petals':
338 for (let i = 0; i < 8; i++) {
339 const x = (spread(i, 8) + Math.floor(t / 4)) % W
340 const y = (Math.floor(t / 3) + Math.floor(rand(i + 7) * H)) % H
341 weather(x, y, PAL.P)
342 }
343 break
344 case 'leaves':
345 for (let i = 0; i < 7; i++) {
346 const x = spread(i, 7) + Math.round(1.5 * Math.sin((t + i * 3) / 3))
347 const y = (Math.floor(t / 2) + Math.floor(rand(i + 3) * H)) % H
348 const color = [PAL.R, PAL.A, PAL.Y][i % 3]
349 // a tiny leaf, tipping left and right as it falls
350 const flip = Math.floor((t + i) / 3) % 2 === 0
351 weather(x, y, color)
352 weather(x + 1, y + (flip ? 1 : 0), color)
353 weather(x, y + 1, color)
354 }
355 break
356 case 'confetti':
357 for (let i = 0; i < 16; i++) {
358 const y = (t + Math.floor(rand(i + 1) * H)) % H
359 const x = Math.floor(rand(i + 2) * W)
360 front.set(x, y, [PAL.R, PAL.Y, PAL.G, PAL.B, PAL.P, PAL.V][i % 6])
361 }
362 break
363 case 'sparkles': {
364 const spots = [
365 [5, 5],
366 [27, 4],
367 [3, 11],
368 [30, 12],
369 [16, 2],
370 [24, 1],
371 ]
372 spots.forEach(([x, y], i) => {
373 const phase = (t + i * 2) % 6
374 if (phase === 0 || phase === 2) front.set(x, y, PAL.Y)
375 if (phase === 1) {
376 front.set(x, y, PAL.Y)
377 front.set(x - 1, y, PAL.Y)
378 front.set(x + 1, y, PAL.Y)
379 front.set(x, y - 1, PAL.Y)
380 front.set(x, y + 1, PAL.Y)
381 }
382 })
383 break
384 }
385 case 'fireworks':
386 for (let k = 0; k < 2; k++) {
387 const cycle = Math.floor((t + k * 6) / 12)
388 const phase = (t + k * 6) % 12
389 const cx = 3 + Math.floor(rand(cycle * 3 + k) * (W - 6))
390 const cy = 2 + Math.floor(rand(cycle * 5 + k) * 5)
391 const color = [PAL.R, PAL.Y, PAL.G, PAL.B, PAL.P, PAL.W][(cycle + k) % 6]
392 if (phase < 3) {
393 back.set(cx, cy + 6 - phase * 2, PAL.Y)
394 } else if (phase < 8) {
395 const r = phase - 3
396 for (const [ddx, ddy] of [
397 [1, 0],
398 [-1, 0],
399 [0, 1],
400 [0, -1],
401 [1, 1],
402 [-1, -1],
403 [1, -1],
404 [-1, 1],
405 ]) {
406 back.set(cx + ddx * r, cy + ddy * r, color)
407 }
408 }
409 }
410 break
411 case 'steam':
412 for (let k = 0; k < 2; k++) {
413 const phase = (t + k * 2) % 4
414 const y = OY + look.dy - 1 - phase
415 back.rect(OX + look.dx + (k === 0 ? 0 : 16) + (phase % 2), y, 2, 1, PAL.g)
416 }
417 break
418 case 'zzz': {
419 const a = t % 12
420 front.sprite(ZED, 26 + Math.floor(a / 4), 9 - Math.floor(a / 2))
421 const b = (t + 6) % 12
422 if (b < 8) front.sprite(ZED, 26 + Math.floor(b / 4), 9 - Math.floor(b / 2))
423 break
424 }
425 case 'hearts':
426 for (let k = 0; k < 3; k++) {
427 const phase = (t + k * 3) % 9
428 const x = [2, 14, 27][k] + Math.round(Math.sin((t + k) / 2))
429 front.sprite(HEART, x, 8 - phase)
430 }
431 break
432 case 'drop': {
433 const phase = t % 5
434 front.rect(OX + look.dx + 18, OY + look.dy + phase, 1, 2, PAL.b)
435 break
436 }
437 }
438 }
439}
440
441export function draw(look: Look, t: number): Canvas {
442 const back = new Canvas()
443 const mid = new Canvas()
444 const front = new Canvas()
445
446 // weather and fireworks first, so props stay whole in front of them
447 drawParticles(back, front, look, t)
448
449 if (look.hasSun) back.sprite(SUN[Math.floor(t / 4) % 2], 0, 0)
450 if (look.hasFlag) {
451 back.rect(0, 3, 1, 15, PAL.g)
452 back.sprite(FLAG, 1, 3)
453 }
454 if (look.hasMug) {
455 back.sprite(MUG, 0, H - MUG.length)
456 for (let k = 0; k < 2; k++) {
457 const phase = (t + k * 3) % 6
458 back.set(1 + k * 2 + (phase % 2), H - MUG.length - 1 - phase, PAL.g)
459 }
460 }
461 drawClawd(mid, look, t)
462
463 if (look.isFlipped) {
464 const flipped = new Canvas()
465 const lo = OY + look.dy
466 const hi = OY + look.dy + 9
467 for (let y = 0; y < H; y++) {
468 for (let x = 0; x < W; x++) {
469 const c = mid.get(x, y)
470 if (c !== NONE) flipped.set(x, lo + hi - y, c)
471 }
472 }
473 back.over(flipped)
474 } else {
475 back.over(mid)
476 }
477
478 if (look.icon && !(look.isIconBlinking && t % 4 >= 2)) {
479 const frames = ICONS[look.icon]
480 const frame = frames[Math.floor(t / 2) % frames.length]
481 back.sprite(frame, 27, 3)
482 }
483
484 back.over(front)
485 return back
486}
487
488// ---------------------------------------------------------------- output
489
490function shade(c: number, dim: number) {
491 if (dim >= 1) return c
492 const r = Math.round(((c >> 16) & 255) * dim)
493 const g = Math.round(((c >> 8) & 255) * dim)
494 const b = Math.round((c & 255) * dim)
495 return (r << 16) | (g << 8) | b
496}
497
498export function toSvg(c: Canvas, dim: number) {
499 const hex = (n: number) => '#' + n.toString(16).padStart(6, '0')
500 const rects: string[] = []
501 for (let y = 0; y < H; y++) {
502 let x = 0
503 while (x < W) {
504 const color = c.get(x, y)
505 let end = x + 1
506 while (end < W && c.get(end, y) === color) end++
507 if (color !== NONE) {
508 rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${hex(color)}"/>`)
509 }
510 x = end
511 }
512 }
513 return (
514 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" shape-rendering="crispEdges">` +
515 `<g opacity="${dim}">${rects.join('')}</g></svg>`
516 )
517}
518
519const DEFAULT = 0x01000000
520
521function encode(words: number[]) {
522 const bytes = new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }
523 return bytes.toBase64()
524}
525
526// Full size: one cell per pixel column, two pixel rows per cell.
527export function toRaster(c: Canvas, dim: number) {
528 const words: number[] = []
529 for (let y = 0; y < H; y += 2) {
530 for (let x = 0; x < W; x++) {
531 const a = c.get(x, y)
532 const b = c.get(x, y + 1)
533 if (a === NONE && b === NONE) words.push(0x20, DEFAULT, DEFAULT)
534 else if (b === NONE) words.push(0x2580, shade(a, dim), DEFAULT)
535 else if (a === NONE) words.push(0x2584, shade(b, dim), DEFAULT)
536 else words.push(0x2580, shade(a, dim), shade(b, dim))
537 }
538 }
539 return { columns: W, rows: H / 2, cells: encode(words) }
540}
541
542// Compact: Clawd alone as the banner draws him, 9 x 3 cells of quadrant
543// blocks, each quadrant one banner pixel (one canvas column, two rows).
544const QUAD = [
545 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c,
546 0x2584, 0x2599, 0x259f, 0x2588,
547]
548
549export function toMiniRaster(c: Canvas, look: Look, dim: number) {
550 const words: number[] = []
551 const x0 = OX + look.dx
552 const y0 = OY + look.dy - 2
553 for (let row = 0; row < 3; row++) {
554 for (let col = 0; col < 9; col++) {
555 const at = (qx: number, qy: number) => c.get(x0 + col * 2 + qx, y0 + row * 4 + qy * 2)
556 const quad = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)]
557 const color = quad.find(q => q !== NONE && q !== PAL.D) ?? NONE
558 const bits = quad.reduce((acc, q, i) => (q === color && q !== NONE ? acc | (1 << i) : acc), 0)
559 words.push(QUAD[bits], color === NONE ? DEFAULT : shade(color, dim), DEFAULT)
560 }
561 }
562 return { columns: 9, rows: 3, cells: encode(words) }
563}
564types/index.d.ts 38 lines1export type BaseKind = 'hi' | 'thinking' | 'working' | 'needs' | 'done' | 'compacting' | 'idle'
2
3export type Base = {
4 kind: BaseKind
5 since: number
6 /** thinking verb, tool detail, done summary, what is needed */
7 line: string
8 /** tool name, or permission / question / plan for needs */
9 detail: string
10}
11
12export type ReactionKind = 'oops' | 'testsFail' | 'testsPass' | 'loved' | 'hmm' | 'fit'
13
14export type Reaction = { kind: ReactionKind; line: string; until: number }
15
16export type Settings = {
17 isHidden: boolean
18 moods: boolean
19 reactions: boolean
20 tantrums: boolean
21 hemisphere: 'north' | 'south'
22}
23
24export type ToolsToday = { date: string; count: number }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'clawd-pet': {
29 base: Base
30 reaction: Reaction | null
31 settings: Settings
32 tick: number
33 toolsToday: ToolsToday
34 demoStart: number | null
35 }
36 }
37}
38