SLOPSHOPPER

clawd-pet

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

newbandguardcommandprompttimer
v1.0.6MITupdated 2026-10-02Revono/clawd-pet/plugins/clawd-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-pet
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /clawd ⎿ clawd-pet: Clawd went to nap. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

install

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

what he does

<img src="assets/looks.svg" width="100%" alt="all of Clawd's looks">

  • working shows what tool runs right now (terminal, pencil, magnifier, globe…)
  • needs you jumps and flashes when claude waits for a permission, a question or a plan
  • tests cheers when they pass, x x eyes when they fail
  • tantrum if you get frustrated 3 times in 20 min he loses it too. FIX IT?!
  • moods coffee in the morning, pyjamas at night, seasons and holidays (yes 1. august is in there 🇨🇭)
  • pet him there is a button. just try it

commands

/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

how it works

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

privacy

everything stays on your machine. clawd sends nothing anywhere, makes no network calls and starts no other programs.

what he looks at, and why:

  • your prompts only to spot frustration for the tantrum. never changed
  • tool calls to show what runs right now (tool name, file name, command or pattern) and to see if tests passed. never changed or blocked, he just watches
  • permission dialogs (the 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 normal
  • compaction and turn end to show compacting and done

he 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>

Source 3 files
hooks/register.tsx 792 lines
1import { 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}
792
hooks/sprites.ts 564 lines
1// 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}
564
types/index.d.ts 38 lines
1export 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