SLOPSHOPPER

clawdhouse

A pixel Clawd in a side pane that reacts to what Claude is doing

panebandguardcommandprompttimer
★ 6v0.1.0MITupdated 2026-10-02ishuagrawal/clawdhouse/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawdhouse
│ ┃ clawdhouse ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./clawd.tsx │ ⏺ 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 │ ⎿ clawdhouse: Clawd is here. /clawd stage | pane | hide moves him. │ │ ▣ client module ./clawd.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./clawd.tsx
Pane · clawdhouse
▣ client module ./clawd.tsx
README

clawdhouse

Clawd's 40 moods, each in the Claude Code desktop app

▶ Watch Clawd run through every mood, one per beat (36 s, with sound)

A pixel Clawd who keeps you company while Claude Code works. He sits on a small stage above the prompt (or in a side pane) and acts out what Claude is doing: thinking, reading files, editing, running tests, building, committing, browsing, waiting for your OK. He cheers when a turn finishes, sulks when it fails, and nods off after a minute and a half of quiet.

Everything is drawn in code (see plugin/hooks/engine.ts). There are no image files to download.

clawdhouse is built on function hooks, an early-access Claude Code feature whose API may change between releases. It was built and tested on Claude Code 2.1.286.

Install

Inside Claude Code:

/plugin marketplace add ishuagrawal/clawdhouse
/plugin install clawdhouse

Or from a shell:

claude plugin marketplace add ishuagrawal/clawdhouse
claude plugin install clawdhouse --config name=YourName

Start a new session (or run /reload-plugins) and Clawd shows up. Pull updates later with claude plugin marketplace update clawdhouse.

Options

OptionDefaultWhat it does
namefriendWhat Clawd calls you in greetings and cheers.

Pass --config name=YourName when installing from the shell, or change it later from the /plugin menu.

Commands

CommandWhat it does
/clawdShow Clawd (or list what he can do)
/clawd stagePut him above the prompt
/clawd paneMove him into a side pane
/clawd hideGive him a break
/clawd <mood>Preview a mood, such as /clawd testing or /clawd delegating 3
/clawd <reaction>Preview a reaction: pass, fail, edit, found, none, hello, denied, phew, combo, noted, streak

Run /clawd with no arguments for the full list of moods.

How it works

plugin/hooks/register.tsx hooks session, prompt, turn and tool events and maps each one to a mood: a Bash call running npm test becomes testing, an Edit becomes writing, a git commit becomes committing. It stores the mood in $.state and draws the stage with ui.render hooks for the AbovePrompt band and the Pane. plugin/hooks/engine.ts holds the sprite art, the tiny physics sim and the rasterizer that turns a frame into terminal cells or an SVG. In the terminal, plugin/hooks/clawd.tsx runs the animation as a client module. Surfaces that can't run one, like the desktop app, get frames stepped and drawn by the hooks module itself.

Hack on it

Clone the repo and load the plugin straight from disk. Saving a file hot-reloads it in the running session.

git clone https://github.com/ishuagrawal/clawdhouse.git
claude --plugin-dir ./clawdhouse/plugin

In the desktop app, where you can't pass flags, add the folder to the env block of ~/.claude/settings.json instead:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/clawdhouse/plugin"
  }
}

If you also installed clawdhouse with /plugin install, disable that copy (claude plugin disable clawdhouse) so it doesn't load twice.

Types, checks and tests

The first time Claude Code loads the plugin from your disk it writes the API's type declarations to plugin/.claude-plugin/types/. That folder is generated and git-ignored, and the plugin's tsconfig.json extends it, so load the plugin once before opening it in an editor.

claude plugin validate ./plugin
claude plugin test ./plugin

validate reads the manifest and hooks module the way the engine will and reports anything it would refuse. test runs plugin/tests/*.test.ts against the engine.

Layout

.claude-plugin/marketplace.json   lets /plugin install find the plugin
plugin/
  .claude-plugin/plugin.json      name, version, options (userConfig)
  hooks/hooks.json                points at the hooks module
  hooks/register.tsx              exports register(on, options)
  hooks/engine.ts                 sprite art, physics sim, rasterizer
  types/index.d.ts                the plugin's $.state contract
  tests/*.test.ts                 claude plugin test
media/                            the promo image and video, rendered from the plugin's engine

License

MIT

Source 4 files
hooks/register.tsx 691 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { Buddy, Mood, Place, ReactKind } from '../types'
5import { BODY, layout, newSim, rasterize, step, toSvg } from './engine'
6import type { Props as SimProps, Sim } from './engine'
7
8// What Clawd calls you: the `name` option (/config), set again on each load.
9let OWNER = 'friend'
10const PANE = 'clawdhouse'
11// Rows the stage above the prompt takes: room for a hat or a thought above him.
12const STAGE_ROWS = 16
13const SLEEP_AFTER_MS = 90_000
14const SETTLE_MS = 6_000
15
16const buddy = atom({ plugin: 'clawdhouse', key: 'buddy' } as const, {
17  mood: 'idle',
18  detail: '',
19  since: 0,
20  seq: 0,
21  helpers: 0,
22  tint: '',
23  hour: 12,
24  isBig: false,
25  month: 0,
26  day: 1,
27  react: { kind: 'hello', text: '', seq: 0 },
28} as Buddy)
29
30const LABEL: Record<Mood, string> = {
31  idle: 'hanging out',
32  sleepy: 'napping',
33  listening: 'got your message',
34  thinking: 'thinking',
35  pondering: 'deep in thought',
36  talking: 'writing a reply',
37  reading: 'reading',
38  searching: 'searching',
39  writing: 'editing',
40  terminal: 'running a command',
41  testing: 'running tests',
42  building: 'building',
43  installing: 'installing',
44  shipping: 'shipping it',
45  committing: 'committing',
46  cleaning: 'cleaning up',
47  planning: 'planning',
48  asking: 'has a question',
49  waiting: 'needs your OK',
50  timing: 'waiting on a timer',
51  delegating: 'sent helpers',
52  surfing: 'surfing the web',
53  plugging: 'using a tool',
54  snapping: 'taking a screenshot',
55  casting: 'using a skill',
56  compacting: 'compacting memory',
57  debugging: 'hunting a bug',
58  polishing: 'tidying up the code',
59  branching: 'tending the branches',
60  querying: 'asking the database',
61  serving: 'serving it up',
62  benchmarking: 'timing it',
63  designing: 'making it pretty',
64  exploring: 'exploring',
65  mailing: 'sending a message',
66  remembering: 'noting that down',
67  phoning: 'on the simulator',
68  happy: 'done!',
69  oops: 'oops',
70  sad: 'stopped',
71}
72
73const MOODS = Object.keys(LABEL) as Mood[]
74
75// ── Reading what Claude is doing ───────────────────────────────────────────
76
77type Doing = { mood: Mood; detail: string; tint?: string }
78
79// Each language gets its own colour: book covers, laptop stickers, code sparks.
80const EXT_TINT: Record<string, string> = {
81  swift: '#F05138',
82  ts: '#3178C6',
83  tsx: '#3178C6',
84  js: '#E8C547',
85  jsx: '#61DAFB',
86  mjs: '#E8C547',
87  py: '#4B8BBE',
88  rs: '#DEA584',
89  go: '#00ADD8',
90  md: '#D9D4C7',
91  json: '#9CCC65',
92  css: '#E44D9A',
93  scss: '#CF649A',
94  html: '#E34F26',
95  sh: '#6CC070',
96  yml: '#CB171E',
97  yaml: '#CB171E',
98  rb: '#CC342D',
99  java: '#E76F00',
100  kt: '#A97BFF',
101  c: '#5C6BC0',
102  cpp: '#00599C',
103  h: '#5C6BC0',
104  sql: '#E38C00',
105  toml: '#B5651D',
106  glsl: '#8E6FD0',
107}
108
109const TOOLCHAIN_TINT: [RegExp, string][] = [
110  [/\b(swift|xcodebuild|pod)\b/, '#F05138'],
111  [/\b(npm|npx|node)\b/, '#CB3837'],
112  [/\b(pnpm)\b/, '#F9AD00'],
113  [/\b(yarn)\b/, '#2C8EBB'],
114  [/\b(bun)\b/, '#F6DECE'],
115  [/\b(pip3?|python3?|pytest|uv|poetry)\b/, '#4B8BBE'],
116  [/\b(cargo|rustc)\b/, '#DEA584'],
117  [/\bgo\b/, '#00ADD8'],
118  [/\bbrew\b/, '#F9A03F'],
119  [/\bgit\b/, '#F05032'],
120  [/\bdocker\b/, '#2496ED'],
121  [/\b(vercel|next)\b/, '#E8E6E0'],
122]
123
124// Folders that hold projects rather than being one.
125const SHELVES = new Set(['Users', 'Desktop', 'Documents', 'Developer', 'Projects', 'projects', 'code', 'Code', 'repos', 'src', 'dev', 'private', 'tmp', 'var', 'home'])
126
127const PAL_PINK = '#EF7DA0'
128const basename = (path: string) => path.split('/').filter(Boolean).pop() ?? path
129const clip = (text: string, n = 40) => (text.length > n ? text.slice(0, n - 1) + '…' : text)
130const tintOfPath = (path: string) => EXT_TINT[path.split('.').pop()?.toLowerCase() ?? ''] ?? ''
131const tintOfCommand = (line: string) => TOOLCHAIN_TINT.find(([re]) => re.test(line))?.[1] ?? ''
132
133// `good-egg › main.ts` from /Users/ishu/Desktop/good-egg/src/main.ts.
134function where(path: string) {
135  const parts = path.split('/').filter(Boolean)
136  const file = parts.pop() ?? path
137  const home = parts[0] === 'Users' ? 2 : 0
138  const project = parts.slice(home).find(p => !SHELVES.has(p) && !p.startsWith('.'))
139  return project ? `${project} › ${file}` : file
140}
141
142function hostOf(url: string) {
143  try {
144    return new URL(url).host.replace(/^www\./, '')
145  } catch {
146    return ''
147  }
148}
149
150const TEST = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(pytest|jest|vitest|mocha|rspec|phpunit|ctest)\b|\b(go|cargo|swift|deno|dotnet|mix)\s+test\b|xcodebuild\b.*\btest\b|playwright\s+test/
151const BUILD = /\b(npm|pnpm|yarn|bun)\s+run\s+build\b|\b(make|tsc|webpack|esbuild|rollup|gradle|gradlew|mvn|cmake|ninja|xcodebuild)\b|\b(cargo|go|swift|vite|next|docker)\s+build\b/
152const INSTALL = /\b(npm|pnpm|yarn|bun)\s+(install|i|add|ci)\b|\bpip3?\s+install\b|\b(brew|gem|apt|apt-get|pod|bundle)\s+install\b|\b(cargo|uv|poetry)\s+add\b|\bgo\s+get\b|\bgit\s+(pull|fetch|clone)\b|\bnpx\s/
153const SHIP = /\bgit\s+push\b|\bgh\s+(pr|release)\s+create\b|\b(deploy|vercel|netlify|fly|wrangler)\b|\bfirebase\s+deploy\b|\bnpm\s+publish\b/
154const CLEAN = /(^|[;&|]\s*)rm\s|\brimraf\b|\bgit\s+clean\b|\bprune\b|\bclean\b/
155const FETCH = /\b(curl|wget|http|https)\s/
156const LOOK = /^\s*(git\s+(status|diff|log|show|blame|grep|branch)|ls|find|rg|grep|fd|tree|which|pwd|du|stat|file)\b/
157const PEEK = /^\s*(cat|head|tail|less|bat|wc)\b/
158const WAIT = /^\s*sleep\b|\bwait\b/
159const DEBUG = /\b(lldb|gdb|pdb|dlv|debug|debugger)\b|node\s+--inspect/
160const LINT = /\b(eslint|prettier|ruff|black|swiftlint|swift-format|swiftformat|rustfmt|gofmt|clippy|biome|stylelint|lint|fmt|format)\b/
161const BRANCH = /\bgit\s+(checkout|switch|branch|merge|rebase|stash|cherry-pick|worktree)\b/
162const DB = /\b(psql|mysql|sqlite3|mongosh|redis-cli|prisma|drizzle-kit|supabase|convex)\b/
163const SERVE = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|preview)\b|\bnext\s+dev\b|\bhttp\.server\b|^\s*vite\s*$|^\s*(npx\s+)?serve\b/
164const BENCH = /\b(hyperfine|bench|benchmark|perf|lighthouse|wrk)\b|^\s*time\s/
165const EXPLORE = /^\s*(tree|find)\b/
166const PHONE = /\b(simctl|xcrun\s+simctl|ios-deploy|devicectl)\b/
167
168function doingForBash(command: string): Doing {
169  const line = command.split('\n')[0].trim()
170  const detail = clip(line)
171  const tint = tintOfCommand(line)
172
173  if (/\bgit\s+commit\b/.test(line)) {
174    const message = /-m\s+["']([^"'\n]+)/.exec(command)?.[1]
175    return { mood: 'committing', detail: clip(message ?? 'a commit'), tint }
176  }
177  if (TEST.test(line)) return { mood: 'testing', detail, tint }
178  if (SHIP.test(line)) return { mood: 'shipping', detail, tint }
179  if (PHONE.test(line)) return { mood: 'phoning', detail, tint: '#F05138' }
180  if (DEBUG.test(line)) return { mood: 'debugging', detail, tint }
181  if (BENCH.test(line)) return { mood: 'benchmarking', detail, tint }
182  if (SERVE.test(line)) return { mood: 'serving', detail, tint }
183  if (BUILD.test(line)) return { mood: 'building', detail, tint }
184  if (INSTALL.test(line)) return { mood: 'installing', detail, tint }
185  if (DB.test(line)) return { mood: 'querying', detail, tint }
186  if (BRANCH.test(line)) return { mood: 'branching', detail, tint: '#F05032' }
187  if (LINT.test(line)) return { mood: 'polishing', detail, tint }
188  if (FETCH.test(line)) return { mood: 'surfing', detail: hostOf(/https?:\/\/\S+/.exec(line)?.[0] ?? '') || detail }
189  if (CLEAN.test(line)) return { mood: 'cleaning', detail, tint }
190  if (EXPLORE.test(line)) return { mood: 'exploring', detail, tint }
191  if (PEEK.test(line)) {
192    const file = line.split(/\s+/).find((w, i) => i > 0 && !w.startsWith('-')) ?? ''
193    return { mood: 'reading', detail: file ? where(file) : detail, tint: tintOfPath(file) }
194  }
195  if (LOOK.test(line)) return { mood: 'searching', detail, tint }
196  if (WAIT.test(line)) return { mood: 'timing', detail }
197
198  return { mood: 'terminal', detail, tint }
199}
200
201function doingForTool(tool: string, input: Record<string, unknown>): Doing {
202  const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
203  const short = tool.replace(/^mcp__[^_]+__/, '')
204  const path = str('file_path') || str('notebook_path')
205
206  switch (tool) {
207    case 'Read':
208      return { mood: 'reading', detail: where(path), tint: tintOfPath(path) }
209    case 'Grep':
210    case 'Glob':
211    case 'LS':
212    case 'LSP':
213    case 'ToolSearch':
214      return { mood: 'searching', detail: clip(str('pattern') || str('query') || str('path') || tool) }
215    case 'Edit':
216    case 'Write':
217    case 'MultiEdit':
218    case 'NotebookEdit':
219      if (/\/memory\/|CLAUDE\.md$|MEMORY\.md$/.test(path)) return { mood: 'remembering', detail: where(path), tint: PAL_PINK }
220      if (/\.(css|scss|sass|less|svg)$/i.test(path)) return { mood: 'designing', detail: where(path), tint: tintOfPath(path) }
221      return { mood: 'writing', detail: where(path), tint: tintOfPath(path) }
222    case 'Bash':
223      return doingForBash(str('command'))
224    case 'WebFetch':
225      return { mood: 'surfing', detail: hostOf(str('url')) || 'the web' }
226    case 'WebSearch':
227      return { mood: 'surfing', detail: `“${clip(str('query'), 34)}”` }
228    case 'Agent':
229    case 'Task':
230      if (str('subagent_type') === 'Explore') return { mood: 'exploring', detail: clip(str('description') || 'the codebase') }
231      return { mood: 'delegating', detail: clip(str('description') || 'a helper') }
232    case 'SendMessage':
233      return { mood: 'mailing', detail: `to ${clip(str('to') || 'a teammate', 30)}` }
234    case 'TodoWrite':
235    case 'TaskCreate':
236    case 'TaskUpdate':
237    case 'TaskList':
238    case 'EnterPlanMode':
239    case 'ExitPlanMode':
240      return { mood: 'planning', detail: tool === 'ExitPlanMode' ? 'plan ready' : 'the to-do list' }
241    case 'AskUserQuestion':
242      return { mood: 'asking', detail: `over to you, ${OWNER}` }
243    case 'Skill':
244      return { mood: 'casting', detail: str('skill') }
245    case 'Monitor':
246    case 'ScheduleWakeup':
247    case 'CronCreate':
248      return { mood: 'timing', detail: short }
249    case 'Artifact':
250      return { mood: 'shipping', detail: 'publishing a page' }
251    case 'SendUserFile':
252      return { mood: 'mailing', detail: `sending you a file, ${OWNER}` }
253  }
254
255  const action = str('action')
256  if (/screenshot|zoom/i.test(tool) || /screenshot|zoom/.test(action)) {
257    return { mood: 'snapping', detail: 'say cheese' }
258  }
259  if (/iOS_Simulator/i.test(tool)) return { mood: 'phoning', detail: clip(action || short), tint: '#F05138' }
260  if (/convex|supabase|postgres|sqlite|database|_query|runOneoffQuery|tables/i.test(tool)) return { mood: 'querying', detail: clip(short) }
261  if (/slack|gmail|mail|message|send_message|post_message/i.test(tool)) return { mood: 'mailing', detail: clip(short) }
262  if (/figma|design|canva/i.test(tool)) return { mood: 'designing', detail: clip(short) }
263  if (/deploy/i.test(short)) return { mood: 'shipping', detail: clip(short) }
264  if (/browser|chrome|navigate|get_page_text|read_page/i.test(tool)) {
265    return { mood: 'surfing', detail: hostOf(str('url')) || clip(short) }
266  }
267  if (tool.startsWith('mcp__')) {
268    return { mood: 'plugging', detail: clip(short) }
269  }
270
271  return { mood: 'thinking', detail: clip(short) }
272}
273
274// ── Reading what came back ─────────────────────────────────────────────────
275
276type Verdict = { kind: ReactKind; text: string; extra?: string }
277
278const lineCount = (text: string) => (text === '' ? 0 : text.split('\n').length)
279
280function verdictFor(tool: string, input: Record<string, unknown>, doing: Doing, text: string, isError: boolean): Verdict | undefined {
281  const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
282
283  if (doing.mood === 'testing') {
284    const swift = /Executed (\d+) tests?, with (\d+) failures?/.exec(text)
285    const passed = Number(swift ? Number(swift[1]) - Number(swift[2]) : /(\d+)\s+(passed|passing)/i.exec(text)?.[1] ?? NaN)
286    const failed = Number(swift?.[2] ?? /(\d+)\s+(failed|failing)/i.exec(text)?.[1] ?? 0)
287    if (isError || failed > 0) return { kind: 'fail', text: failed > 0 ? `✗ ${failed} failed` : '✗ tests failed' }
288    return { kind: 'pass', text: Number.isNaN(passed) ? '✓ tests pass' : `✓ ${passed} passed` }
289  }
290  if (isError) {
291    const code = /exit code (\d+)/i.exec(text)?.[1]
292    return { kind: 'fail', text: code ? `exit ${code}` : doing.mood === 'building' ? 'build failed' : 'failed' }
293  }
294
295  switch (doing.mood) {
296    case 'remembering':
297      return { kind: 'noted', text: 'noted ♥' }
298    case 'polishing':
299      return { kind: 'pass', text: '✦ tidy' }
300    case 'branching':
301      return { kind: 'found', text: /merge|rebase/.test(str('command')) ? 'merged' : 'switched' }
302    case 'writing': {
303      if (tool === 'Write') return { kind: 'edit', text: `+${lineCount(str('content'))}` }
304      const edits = Array.isArray(input.edits) ? (input.edits as { old_string?: string; new_string?: string }[]) : [{ old_string: str('old_string'), new_string: str('new_string') }]
305      const added = edits.reduce((n, x) => n + lineCount(x.new_string ?? ''), 0)
306      const removed = edits.reduce((n, x) => n + lineCount(x.old_string ?? ''), 0)
307      return { kind: 'edit', text: `+${added}`, extra: `−${removed}` }
308    }
309    case 'searching': {
310      if (tool !== 'Grep' && tool !== 'Glob') return undefined
311      const hits = text.split('\n').filter(l => l.trim() && !/^(No |Found \d)/.test(l)).length
312      if (hits === 0 || /^No (matches|files) found/m.test(text)) return { kind: 'none', text: 'no hits' }
313      return { kind: 'found', text: tool === 'Glob' ? `${hits} files` : `${hits} hits` }
314    }
315    case 'committing': {
316      const hash = /\[[^\]\s]+\s+([0-9a-f]{7})\]/.exec(text)?.[1]
317      return { kind: 'pass', text: hash ? `✓ ${hash}` : '✓ committed' }
318    }
319    case 'building':
320      return { kind: 'pass', text: '✓ built' }
321    case 'installing':
322      return { kind: 'pass', text: '✓ installed' }
323    case 'shipping':
324      return { kind: 'pass', text: tool === 'Bash' ? '✓ shipped' : '✓ sent' }
325    case 'delegating':
326      return { kind: 'found', text: 'helper’s back' }
327  }
328  return undefined
329}
330
331// ── State ──────────────────────────────────────────────────────────────────
332
333let isInTurn = false
334let lastChange = 0
335let current: Mood = 'idle'
336let helpers = 0
337let turnTools = 0
338let streak = 0
339let latest: Buddy | null = null
340
341// Surfaces whose Client can't run (the desktop app's sandbox refuses it) get
342// Clawd from here: this module steps the same engine and redraws the pane.
343const HOST_FPS = 8
344const frame = atom({ plugin: 'clawdhouse', key: 'frame' } as const, 0)
345const place = atom({ plugin: 'clawdhouse', key: 'place' } as const, 'stage' as Place)
346let hostSim: Sim | null = null
347let hostSize = { cols: 0, rows: 0 }
348let hostSeenAt = 0
349
350function simProps(b: Buddy): SimProps {
351  return {
352    mood: b.mood,
353    label: LABEL[b.mood],
354    detail: b.detail,
355    seq: b.seq,
356    helpers: b.helpers ?? 0,
357    tint: b.tint ?? '',
358    hour: b.hour ?? 12,
359    isBig: b.isBig ?? false,
360    month: b.month ?? today().month,
361    day: b.day ?? today().day,
362    react: b.react ?? { kind: 'hello', text: '', seq: 0 },
363  }
364}
365
366function today() {
367  try {
368    const d = new Date()
369    return { month: d.getMonth(), day: d.getDate() }
370  } catch {
371    return { month: 0, day: 1 }
372  }
373}
374
375function hourNow() {
376  try {
377    return new Date().getHours()
378  } catch {
379    return 12
380  }
381}
382
383async function setMood($: EngineInterface, mood: Mood, detail = '', extra: { tint?: string; isBig?: boolean } = {}) {
384  const now = await $.clock.now()
385  current = mood
386  lastChange = now
387  await update($, buddy, b => (latest = {
388    ...b,
389    mood,
390    detail,
391    since: now,
392    seq: b.seq + 1,
393    helpers,
394    tint: extra.tint ?? '',
395    isBig: extra.isBig ?? false,
396    hour: hourNow(),
397    ...today(),
398  }))
399}
400
401async function setReact($: EngineInterface, verdict: Verdict) {
402  await update($, buddy, b => (latest = { ...b, react: { ...verdict, seq: (b.react?.seq ?? 0) + 1 } }))
403}
404
405async function setHelpers($: EngineInterface, n: number) {
406  helpers = Math.max(0, n)
407  await update($, buddy, b => (latest = { ...b, helpers }))
408}
409
410// Back to idle a little after a turn ends, unless something else happened.
411function settleLater($: EngineInterface) {
412  const settledFrom = lastChange
413  $.clock.after(SETTLE_MS, () => {
414    if (!isInTurn && lastChange === settledFrom) void setMood($, 'idle')
415  })
416}
417
418const cheers = () => ['done!', 'ta-da!', 'all set', `nice one, ${OWNER}!`, 'that’s a wrap']
419
420// ── Hooks ──────────────────────────────────────────────────────────────────
421
422export const register: Register = (on, options) => {
423  OWNER = typeof options.name === 'string' && options.name.trim() ? options.name.trim() : 'friend'
424
425  on('session.start', async ($, e, next) => {
426    await $.command.register({
427      name: 'clawd',
428      description: 'Show Clawd (or /clawd <mood> to preview one)',
429    })
430    lastChange = await $.clock.now()
431
432    $.clock.every(5_000, () => {
433      void $.clock.now().then(now => {
434        if (current === 'idle' && now - lastChange > SLEEP_AFTER_MS) void setMood($, 'sleepy')
435      })
436    })
437
438    $.clock.every(Math.round(1000 / HOST_FPS), () => {
439      // Only while a host-drawn pane has been drawn lately.
440      if (!hostSim || !latest) return
441      void $.clock.now().then(now => {
442        if (!hostSim || !latest || now - hostSeenAt > 3_000) return
443        hostSim.props = simProps(latest)
444        const L = layout(hostSize.cols, hostSize.rows)
445        if (L.cols >= 18 && L.cellRows >= 4) step(hostSim, L, 1 / HOST_FPS)
446        void update($, frame, n => (n + 1) % 100_000)
447      })
448    })
449
450    if ((await $.state.get({ plugin: 'clawdhouse', key: 'place' } as const)).value === 'pane') {
451      void $.ui.open({ id: PANE, title: 'Clawd' })
452    }
453    const hour = hourNow()
454    const hello = hour < 5 ? `up late, ${OWNER}?` : hour < 12 ? `morning, ${OWNER}!` : hour < 18 ? `hi ${OWNER}!` : `evening, ${OWNER}!`
455    void setReact($, { kind: 'hello', text: hello })
456
457    return next(e)
458  })
459
460  on('command.run', { command: 'clawd' }, async ($, e) => {
461    const [ask, extra] = e.args.trim().split(/\s+/) as [string, string?]
462    const where = (await $.state.get({ plugin: 'clawdhouse', key: 'place' } as const)).value ?? 'stage'
463
464    if (ask === 'pane') {
465      await update($, place, () => 'pane' as Place)
466      await $.ui.open({ id: PANE, title: 'Clawd' })
467      return { text: 'Clawd moved into a side pane. /clawd stage brings him back above the prompt.' }
468    }
469    if (ask === 'stage' || ask === 'show' || (ask === '' && where === 'hidden')) {
470      await update($, place, () => 'stage' as Place)
471      await $.ui.close({ id: PANE })
472      return { text: 'Clawd is on stage above the prompt.' }
473    }
474    if (ask === 'hide') {
475      await update($, place, () => 'hidden' as Place)
476      await $.ui.close({ id: PANE })
477      return { text: 'Clawd is taking a break. /clawd brings him back.' }
478    }
479    if (where === 'hidden') await update($, place, () => 'stage' as Place)
480    if (where === 'pane') await $.ui.open({ id: PANE, title: 'Clawd' })
481
482    if (MOODS.includes(ask as Mood)) {
483      const mood = ask as Mood
484      if (!isInTurn) await setHelpers($, mood === 'delegating' ? Number(extra) || 2 : 0)
485      await setMood($, mood, 'on request', { tint: extra?.startsWith('#') ? extra : EXT_TINT[extra ?? ''], isBig: extra === 'big' })
486      if (!isInTurn) settleLater($)
487      return { text: `Clawd: ${LABEL[mood]}` }
488    }
489    const reactions: ReactKind[] = ['pass', 'fail', 'edit', 'found', 'none', 'hello', 'denied', 'phew', 'combo', 'noted', 'streak']
490    if (reactions.includes(ask as ReactKind)) {
491      const samples: Record<ReactKind, Verdict> = {
492        pass: { kind: 'pass', text: '✓ 42 passed' },
493        fail: { kind: 'fail', text: '✗ 2 failed' },
494        edit: { kind: 'edit', text: '+12', extra: '−3' },
495        found: { kind: 'found', text: '7 hits' },
496        none: { kind: 'none', text: 'no hits' },
497        hello: { kind: 'hello', text: `hi ${OWNER}!` },
498        denied: { kind: 'denied', text: 'ok, skipping' },
499        phew: { kind: 'phew', text: 'phew · 42s' },
500        combo: { kind: 'combo', text: 'combo ×10!' },
501        noted: { kind: 'noted', text: 'noted ♥' },
502        streak: { kind: 'streak', text: 'streak ×3' },
503      }
504      await setReact($, samples[ask as ReactKind])
505      return { text: `Clawd reacts: ${ask}` }
506    }
507
508    return { text: `Clawd is here. /clawd stage | pane | hide moves him. Moods: ${MOODS.join(', ')}. Reactions: ${reactions.join(', ')}.` }
509  })
510
511  on('prompt.submit', async ($, e, next) => {
512    await setMood($, 'listening', clip(e.text.replace(/\s+/g, ' '), 36))
513
514    return next(e)
515  })
516
517  on('turn.start', async ($, e, next) => {
518    isInTurn = true
519    turnTools = 0
520    await setMood($, 'thinking')
521
522    return next(e)
523  })
524
525  // Thinking and reply text stream in as chunks: switch on each change of kind.
526  on('turn.step', async function* ($, e, next) {
527    const isMain = !(e as { agentId?: string }).agentId
528    const stream = next(e)
529    let kind = ''
530
531    while (true) {
532      const r = await stream.next()
533      if (r.done) return r.value
534      const now = r.value.kind
535      if (isMain && now !== kind && (now === 'thinking' || now === 'text')) {
536        kind = now
537        void setMood($, now === 'thinking' ? 'pondering' : 'talking')
538      }
539      yield r.value
540    }
541  })
542
543  // A call the mode puts to the person: Clawd holds up a sign until it runs.
544  on('tool.check', async ($, e, next) => {
545    const verdict = await next(e)
546    if (e.tool_use_id && verdict.decision === 'ask') {
547      await setMood($, 'waiting', `${e.tool.replace(/^mcp__[^_]+__/, '')} needs your OK`)
548    }
549
550    return verdict
551  })
552
553  on('tool.call', async ($, e, next) => {
554    const input = e as unknown as Record<string, unknown>
555    const doing = doingForTool(e.tool, input)
556    const isHelper = doing.mood === 'delegating'
557    if (isHelper) await setHelpers($, helpers + 1)
558    await setMood($, doing.mood, doing.detail, { tint: doing.tint })
559
560    const startedAt = await $.clock.now()
561    try {
562      const ran = await next(e)
563      const isError = ran.deny === undefined && ran.isError === true
564      const took = Math.round(((await $.clock.now()) - startedAt) / 1000)
565      let verdict: Verdict | undefined =
566        ran.deny === undefined ? verdictFor(e.tool, input, doing, ran.text ?? '', isError) : { kind: 'denied', text: 'ok, skipping' }
567
568      // Streaks of green, long hauls and busy turns get their own moment.
569      const isCheck = doing.mood === 'testing' || doing.mood === 'building'
570      if (isCheck) streak = verdict?.kind === 'pass' ? streak + 1 : 0
571      turnTools += 1
572      if (isCheck && streak >= 3 && verdict?.kind === 'pass') verdict = { kind: 'streak', text: `streak ×${streak}` }
573      else if (!isError && took >= 30 && verdict?.kind !== 'fail') verdict = { kind: 'phew', text: `phew · ${took}s` }
574      else if (!verdict && turnTools % 10 === 0) verdict = { kind: 'combo', text: `combo ×${turnTools}!` }
575      if (verdict) await setReact($, verdict)
576
577      if (isError) {
578        await setMood($, 'oops', verdict?.text ?? `${e.tool} failed`)
579      } else if (isInTurn && (current === doing.mood || current === 'waiting')) {
580        await setMood($, isHelper && helpers > 1 ? 'delegating' : 'thinking')
581      }
582
583      return ran
584    } finally {
585      if (isHelper) await setHelpers($, helpers - 1)
586    }
587  })
588
589  on('session.compact', async ($, e, next) => {
590    if (e.agentId) return next(e)
591    await setMood($, 'compacting', e.trigger === 'auto' ? 'making room' : 'on request')
592    const done = await next(e)
593    await setMood($, isInTurn ? 'thinking' : 'idle')
594
595    return done
596  })
597
598  on('turn.complete', async ($, e, next) => {
599    if (e.agentId) {
600      return next(e)
601    }
602    isInTurn = false
603    const seconds = Math.round(e.durationMs / 1000)
604    const took = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`
605
606    if (e.reason === 'answer') {
607      await setMood($, 'happy', `took ${took}`, { isBig: seconds >= 60 })
608      const lines = cheers()
609      await setReact($, { kind: 'pass', text: lines[Math.floor(Math.random() * lines.length)] })
610    } else if (e.reason === 'aborted') await setMood($, 'sad', 'interrupted')
611    else await setMood($, 'sad', e.reason === 'refusal' ? 'declined' : 'hit an error')
612
613    settleLater($)
614
615    return next(e)
616  })
617
618  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
619    return drawStage($, e, e.props.bodyColumns, Math.max(8, e.props.scroll.bodyRows))
620  })
621
622  // The stage: a strip right above the prompt, as wide as the conversation.
623  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
624    if (e.props.hasSurvey || (await read($, place)) !== 'stage') return next(e)
625    return drawStage($, e, e.props.bodyColumns, Math.min(STAGE_ROWS, Math.max(8, e.props.maxRows - 1)))
626  })
627}
628
629async function drawStage($: EngineInterface, e: RenderInput<'Pane' | 'AbovePrompt'>, cols: number, rows: number) {
630  const b = await read($, buddy)
631
632  if (e.surface === 'terminal') {
633    const { Client } = $.ui.resolve(e)
634    return <Client key="clawd" module="./clawd.tsx" props={simProps(b)} width={cols} height={rows} />
635  }
636
637  // Every other surface: this module runs the engine and draws the frame.
638  await read($, frame)
639  latest = latest ?? b
640  hostSize = { cols, rows }
641  hostSeenAt = await $.clock.now()
642  hostSim = hostSim ?? newSim(simProps(b))
643  const L = layout(cols, rows)
644  const { Box, Text } = $.ui.resolve(e)
645  const shown = hostSim.shown
646  if (L.cols < 24 || L.cellRows < 4) return <Text color={BODY}>▐▛███▜▌ {shown.label}</Text>
647
648  const caption = (
649    <Box flexDirection="row">
650      <Text color={shown.tint || BODY}>{'● '}</Text>
651      <Text bold color={BODY}>
652        {shown.label}
653      </Text>
654      {shown.detail !== '' && (
655        <Text dimColor wrap="truncate-end">
656          {'  ' + shown.detail}
657        </Text>
658      )}
659    </Box>
660  )
661
662  // Surfaces that draw SVG get square pixels; text cells elsewhere.
663  if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
664    const { Svg } = $.ui.resolve(e)
665    const art = toSvg(hostSim, L)
666    return (
667      <Box flexDirection="column">
668        {caption}
669        <Svg source={art.source} alt={`Clawd, ${shown.label}`} />
670      </Box>
671    )
672  }
673
674  const lines = rasterize(hostSim, L)
675  return (
676    <Box flexDirection="column">
677      {caption}
678      <Text> </Text>
679      {lines.map(segs => (
680        <Box flexDirection="row">
681          {segs.map(seg => (
682            <Text color={seg.fg} backgroundColor={seg.bg}>
683              {seg.text}
684            </Text>
685          ))}
686        </Box>
687      ))}
688    </Box>
689  )
690}
691
hooks/engine.ts 2444 lines
1// Clawd's world: the sprite, the props, the simulation and the rasteriser.
2// Shared by the terminal's Client (clawd.tsx) and the host-drawn pane
3// (register.tsx), which every surface can show.
4import type { Mood, ReactKind, Reaction } from '../types'
5
6// ── Clawd ──────────────────────────────────────────────────────────────────
7// Anthropic's pixel crab on his official grid of 12 × 8 square pixels: a
8// body eight wide and six tall, two-by-two arms at mid-height, four legs two
9// tall at the body's edges, and two single-pixel eyes set wide; body colour
10// #DA7758. Each of his pixels is 2 × 2 fine pixels here (a fine pixel is a
11// column by half a text row, or one SVG unit), so his face has room to emote.
12//
13//   ..########..
14//   ..#■####■#..
15//   ############
16//   ############
17//   ..########..
18//   ..########..
19//   ..#.#..#.#..
20//   ..#.#..#.#..
21
22export const W0 = 24
23export const H0 = 16
24
25export const BODY = '#DA7758'
26export const BODY_LIGHT = '#E8917A'
27export const BODY_DARK = '#B9603F'
28export const MINI_BODY = '#E8957B'
29export const FLASH = '#E5484D'
30export const EYE = '#1A1918'
31export const BROW = '#8E3F28'
32export const BLUSH = '#F4A39A'
33export const FLOOR = '#4A4540'
34export const MUTED = '#B9A898'
35
36export const PAL: Record<string, string> = {
37  w: '#F4F1EA',
38  W: '#FFFFFF',
39  s: '#C9C5BE',
40  g: '#8A8580',
41  d: '#5A5550',
42  k: '#1A1918',
43  K: '#1B2433',
44  b: '#2F6FB0',
45  B: '#8EC5F0',
46  i: '#1D3F6E',
47  c: '#5FD4D4',
48  C: '#BDEFF0',
49  y: '#F2C46D',
50  Y: '#FFE38A',
51  u: '#C98A2E',
52  O: '#F59E5B',
53  n: '#9A6A45',
54  N: '#6E4A30',
55  m: '#C9965F',
56  r: '#E5484D',
57  R: '#A8323A',
58  p: '#EF7DA0',
59  q: BLUSH,
60  G: '#6CC070',
61  H: '#3F8F4A',
62  l: '#B5E07A',
63  P: '#8E6FD0',
64  V: '#5B4499',
65  v: '#C2B0EE',
66  o: BODY,
67}
68export const CONFETTI = ['#E5484D', '#F2C46D', '#6CC070', '#8EC5F0', '#8E6FD0', '#EF7DA0', '#5FD4D4']
69export const FLAG_COLORS = ['#6CC070', '#8EC5F0', '#EF7DA0', '#F2C46D', '#8E6FD0', '#5FD4D4', '#F59E5B']
70export const LIQUIDS = ['#6CC070', '#5FD4D4', '#B07CE8', '#F59E5B']
71
72export const TICK = 40
73// Seconds per step: the terminal's Client steps at 25 fps, the host at 8.
74let DT = TICK / 1000
75export const GRAVITY = 260
76export const CAPTION_ROWS = 2
77
78// ── Art: drawn facing right, mirrored when Clawd faces left ────────────────
79// 't' is the tint (from the file's language), 'T' its shadow, 'L' its light;
80// 'j' and 'J' are the flask's liquid.
81
82export type Art = readonly string[]
83
84export const ART = {
85  surfboard: [
86    '....wwwwwwwwwwtTtwwwwwwwwww...',
87    '..wWwwwwwwwwwwtTtwwwwwwwwwwww.',
88    '...ssssssssssssssssssssssss...',
89  ],
90  sun: [
91    '....Y....',
92    '.Y.....Y.',
93    '...yyy...',
94    '..yYYYy..',
95    'Y.yYWYy.Y',
96    '..yYYYy..',
97    '...yyy...',
98    '.Y.....Y.',
99    '....Y....',
100  ],
101  cloudPuff: ['..wwww...', '.wwwwwww.', 'wwwwwwwww', '.sssssss.'],
102  gullA: ['w...w', '.w.w.'],
103  gullB: ['..w..', 'ww.ww'],
104  book: [
105    '..wwwwwww..wwwwwww..',
106    '.wwgggggwwsgggggwww.',
107    '.wwwwwwwwswwgggggww.',
108    '.wwggggwwswwwwwwwww.',
109    'tttttttttTTttttttttt',
110    '.TTTTTTTTrTTTTTTTTT.',
111  ],
112  pageFlip: ['....ww', '...wws', '..ww..', '.ww...'],
113  lamp: [
114    '.yyyyy.',
115    'yYYYYYy',
116    'uuuuuuu',
117    '...n...',
118    '...n...',
119    '...n...',
120    '...n...',
121    '...n...',
122    '...n...',
123    '...n...',
124    '...n...',
125    '.NNNNN.',
126  ],
127  magnifier: [
128    '...sssss..',
129    '..sBWWBBs.',
130    '.sBWBBBBBs',
131    '.sBBBBBBBs',
132    '.sBBBBBBBs',
133    '..sBBBBBs.',
134    '...sssss..',
135    '..NN......',
136    '.NN.......',
137    'NN........',
138  ],
139  paper: ['wwws', 'wggw', 'wwww', 'wgww', 'wwww'],
140  paperTint: ['ttts', 'tLLt', 'tttt', 'tLtt', 'tttt'],
141  laptop: [
142    'sWWWWWWWWWWWWWWs',
143    'sssssstttsssssss',
144    'ssssstLtttssssss',
145    'ssssstttttssssss',
146    'sssssstttssssoss',
147    'gggggggggggggggg',
148    'dddddddddddddddd',
149  ],
150  mug: ['wwww..', 'wttwss', 'wttw.s', 'wwwwss', '.ww...'],
151  crt: [
152    '.ssssssssssssss.',
153    'sggggggggggggggd',
154    'sgKKKKKKKKKKKKgd',
155    'sgKKKKKKKKKKKKgd',
156    'sgKKKKKKKKKKKKgd',
157    'sgKKKKKKKKKKKKgd',
158    'sgKKKKKKKKKKKKgd',
159    'sgKKKKKKKKKKKKgd',
160    'sggggggggggGgggd',
161    '.dddddddddddddd.',
162    '.....gggggg.....',
163    '...dddddddddd...',
164  ],
165  keyboard: ['dsdsdsdsdsd', 'ddddddddddd'],
166  flask: ['...ss...', '...ss...', '..s..s..', '.s....s.', '.sJjjjs.', 'sjjJjjjs', 'sjjjjjjs', '.ssssss.'],
167  rack: [
168    '.s..s..s..s.',
169    '.r..y..B..G.',
170    '.r..y..B..G.',
171    '.R..u..b..H.',
172    'nnnnnnnnnnnn',
173    'n..........n',
174    'n..........n',
175  ],
176  hardhat: ['.....YYYY.....', '...yYYyyyyy...', '..yyyyyyyyyyu.', 'uyyyyyyyyyyyyu'],
177  anvil: [
178    'ssssssssssss..',
179    'gggggggggggggg',
180    '..gddddddddd..',
181    '....dddddd....',
182    '....dddddd....',
183    '...dddddddd...',
184    '..dddddddddd..',
185  ],
186  hammerUp: ['sssss', 'ggggg', '..N..', '..n..', '..n..', '..n..', '..n..'],
187  hammerDown: ['........sss', 'nnnnnnnnsgs', '........sss'],
188  box: [
189    'mmmmmttmmmmm',
190    'nnnnnttnnnnn',
191    'NNNNNTTNNNNN',
192    'nnwwwwnnnnnn',
193    'nnwggwnnnnnn',
194    'nnwwwwnnnnnn',
195    'nnnnnnnnnnnN',
196    'NNNNNNNNNNNN',
197  ],
198  rocket: [
199    '...rr...',
200    '..rrrr..',
201    '..wwww..',
202    '.wwBBww.',
203    '.wwBWww.',
204    '.wwwwww.',
205    '.wwttww.',
206    '.wwwwww.',
207    'rwwwwwwr',
208    'rr.ss.rr',
209  ],
210  pad: ['ssssssssssss', 'd..d....d..d'],
211  flagA: ['Ytttttt', 'NtLtttt', 'Nttttt.', 'Ntt....', 'N......', 'N......', 'N......', 'N......', 'N......', 'N......'],
212  flagB: ['Yttttt.', 'NtLtttt', 'Ntttttt', 'N..ttt.', 'N......', 'N......', 'N......', 'N......', 'N......', 'N......'],
213  broom: ['..N...', '..n...', '..n...', '..n...', '..n...', '..n...', '.uuuu.', 'yyyyyy', 'yYyYyy', 'y.y.y.'],
214  bandana: ['...rrrrrrrr...', '..rrWrrrWrrrr.', '.rWrrrWrrrWrrR', '............RR'],
215  dust: ['.s.', 'sgs'],
216  clipboard: [
217    '....sss....',
218    'nnnnsgsnnnn',
219    'nwwwwwwwwwn',
220    'nwkwgggggwn',
221    'nwwwwwwwwwn',
222    'nwkwggggwwn',
223    'nwwwwwwwwwn',
224    'nwkwgggggwn',
225    'nwwwwwwwwwn',
226    'nwkwgggwwwn',
227    'nwwwwwwwwwn',
228    'NNNNNNNNNNN',
229  ],
230  pencil: ['p', 'y', 'y', 'y', 'm', 'k'],
231  sign: [
232    'wwwwwwwwwww',
233    'wwwwwwwwwww',
234    'wwwwwwwwwww',
235    'wwwwwwwwwww',
236    'wwwwwwwwwww',
237    'wwwwwwwwwww',
238    'sssssssssss',
239    '.....n.....',
240    '.....n.....',
241    '.....n.....',
242    '.....n.....',
243    '.....n.....',
244    '.....n.....',
245  ],
246  hourglass: ['NNNNNNNNN', '.nC...Cn.', '.n.C.C.n.', '.n..C..n.', '.n.C.C.n.', '.nC...Cn.', 'NNNNNNNNN'],
247  megaphone: ['......Yu', '....YYyu', 'nsYYyyyu', 'nsyyyyyu', '....yyyu', '......yu'],
248  plug: ['.s..s.', '.s..s.', 'gggggg', 'ggGggg', '.dddd.'],
249  socket: ['wwww', 'wkkw', 'wwww', 'wkkw', 'wwww', 'ssss'],
250  camera: ['..gg....WW', 'dddddddddd', 'dgggsssggd', 'dgsKbBKsgd', 'dgsKbbKsgd', 'dggsKKsggd', 'dddddddddd'],
251  photo: ['wwwww', 'wBoBw', 'woooW', 'wBBBw', 'wwwww', 'wwwww'],
252  wizardHat: [
253    '........PP....',
254    '.......PPV....',
255    '......PPPV....',
256    '.....PYPPV....',
257    '.....PPPPPV...',
258    '....PPPPYPV...',
259    '...PPPPPPPVV..',
260    '..uuuuuuuuuu..',
261    'PPPPPPPPPPPPPP',
262  ],
263  wand: ['....Y.', '...YWY', '...nY.', '..n...', '.n....', 'n.....'],
264  partyHat: ['...W...', '...Y...', '...p...', '..pYp..', '..Ypp..', '.pYppY.', '.ppYpp.', 'pYppYpp'],
265  nightcap: [
266    '....iiiiiii.....',
267    '..iiBiiiiBiii...',
268    '.iiiiiiBiiiiiib.',
269    'wwwwwwwwwwwwwiib',
270    '..............WW',
271    '..............WW',
272  ],
273  moon: ['.YYY..', 'YY....', 'Y.....', 'Y.....', 'YY....', '.YYY..'],
274  thought: [
275    '....wwww...ww...',
276    '..wwwwwwwwwwwww.',
277    '.wwwwwwwwwwwwwww',
278    'wwwwwwwwwwwwwwww',
279    'wwwwwwwwwwwwwwww',
280    '.wwwwwwwwwwwwww.',
281    '..sswwwwwwwwss..',
282    '....ssssssss....',
283  ],
284  bulb: ['..YYYY..', '.YWWYYY.', 'YYWYYYYY', 'YYYYYYYY', 'YYYuuYYY', '.YYYYYY.', '..YYYY..', '..ssss..', '..gggg..', '...ss...'],
285  bubble: [
286    '.wwwwwwwwwwwwwwwwww.',
287    'wwwwwwwwwwwwwwwwwwww',
288    'wwwwwwwwwwwwwwwwwwww',
289    'wwwwwwwwwwwwwwwwwwww',
290    'wwwwwwwwwwwwwwwwwwww',
291    'wwwwwwwwwwwwwwwwwwww',
292    '.wwwwwwwwwwwwwwwwww.',
293    '..www...............',
294    '.ww.................',
295  ],
296  envelope: ['wwwwwwwwwwww', 'wswwwwwwwwsw', 'wwswwwwwwsww', 'wwwswwwwswww', 'wwwwsrrswwww', 'wwwwwrrwwwww', 'wwwwwwwwwwww', 'ssssssssssss'],
297  letter: ['wwwwwwwwwwww', 'wggggggggwww', 'wwwwwwwwwwww', 'wgggggggwwww', 'wwwwwwwwwwww', 'wggggwwwwwww', 'wwwwwwwwwppw', 'wwwwwwwwwwww'],
298  rainCloud: ['....ssss......', '..ssggggss.ss.', '.sggggggggggss', 'gggggggggggggg', '.dddddddddddd.'],
299  smoke: ['.gg.', 'gssg', '.gg.'],
300  sparkle: ['.Y.', 'YWY', '.Y.'],
301  heart: ['p.p', 'ppp', '.p.'],
302  butterflyA: ['pp.pp', 'ppkpp', '.p.p.'],
303  butterflyB: ['..k..', '.pkp.', '.....'],
304  miniHats: [
305    ['..yy..', '.yyyyu'],
306    ['..tt..', 'ttttt.'],
307    ['.Y.Y.Y', 'YYYYYY'],
308    ['..BB..', '.iiii.'],
309  ],
310  // Debugging: a bug that won't hold still, and a net.
311  bugA: ['.HGH.', 'GGlGG', 'k.k.k'],
312  bugB: ['.HGH.', 'GGlGG', '.k.k.'],
313  net: ['..sss..', '.s.w.s.', 's.w.w.s', '.s.w.s.', '..sss..', '...n...', '...n...', '...n...', '...n...', '...n...', '...n...', '...n...'],
314  // Linting: a spray bottle.
315  spray: ['dd....', 'ddd...', '..s...', '.www..', '.wBw..', '.wBw..', '.wBw..', '.wBw..', '.www..'],
316  // Branching: a watering can.
317  can: ['..bbb....', '.bBBBb..b', '.bbbbbbbb', '.bbbbbb..', '.bbbbbb..'],
318  // Querying: a database, LEDs blinking.
319  database: [
320    '.BBBBBBBB.',
321    'BBWBBBBBBB',
322    'bBBBBBBBBb',
323    'bbbbbbbbbb',
324    'bbGbbbbbbb',
325    'iBBBBBBBBi',
326    'bbbbbbbbbb',
327    'bbYbbbbbbb',
328    'iiiiiiiiii',
329  ],
330  // Serving: a rack of servers, and a bow tie for the host.
331  rack: [
332    'dddddddddd',
333    'dggggggggd',
334    'dg.g.gkkgd',
335    'dggggggggd',
336    'dg.g.gkkgd',
337    'dggggggggd',
338    'dg.g.gkkgd',
339    'dggggggggd',
340    'dg.g.gkkgd',
341    'dggggggggd',
342    'dg.g.gkkgd',
343    'dggggggggd',
344    'dddddddddd',
345    '.d......d.',
346    'dd......dd',
347  ],
348  bowtie: ['r..r', 'rRRr', 'r..r'],
349  // Benchmarking: a stopwatch over a treadmill.
350  stopwatch: ['..ss...', '...s...', '.wwwww.', 'wwwwwww', 'wwwwwww', 'wwwwwww', '.wwwww.'],
351  // Designing: beret, easel, palette and brush.
352  beret: ['....rr....', '.rrrrrrrr.', 'rrrrrrrrrr', '..RRRRRR..'],
353  easel: [
354    'ssssssssssssss',
355    'sWWWWWWWWWWWWs',
356    'sWWWWWWWWWWWWs',
357    'sWWWWWWWWWWWWs',
358    'sWWWWWWWWWWWWs',
359    'sWWWWWWWWWWWWs',
360    'sWWWWWWWWWWWWs',
361    'sWWWWWWWWWWWWs',
362    'sWWWWWWWWWWWWs',
363    'ssssssssssssss',
364    '...N......N...',
365    '..N........N..',
366    '..N........N..',
367    '.N..........N.',
368    '.N..........N.',
369    'N............N',
370  ],
371  palette: ['..mmmm..', '.mrmYmm.', 'mmBmmGmm', '.mmpmmm.', '...mm...'],
372  brush: ['...t', '..n.', '.n..', 'n...'],
373  // Exploring: pith helmet and treasure map.
374  helmet: ['....mmmmmm....', '...mmmmmmmm...', '..NNNNNNNNNN..', 'mmmmmmmmmmmmmm'],
375  map: [
376    'uYYYYYYYYYYYYYYu',
377    'YYYrYYYYYYYYYGGY',
378    'YYYYrYYYYYYYGGGY',
379    'YBBYYrrYYYYYYGYY',
380    'YBBYYYYrrYYYYYYY',
381    'YYYYYYYYYrYYrYrY',
382    'uYYYYYYYYYYYYrYu',
383  ],
384  // Mailing: paper planes.
385  planeR: ['w....', 'ww...', 'wwwwW', 'ss...'],
386  planeL: ['....w', '...ww', 'Wwwww', '...ss'],
387  // Phoning: an iPhone.
388  phone: ['kkkkkk', 'kKKKKk', 'kKKKKk', 'kKKKKk', 'kKKKKk', 'kKKKKk', 'kKKKKk', 'kKKKKk', 'kkskkk', 'kkkkkk'],
389  // Seasons and play.
390  pumpkin: ['...H...', '..oNo..', '.OoOoO.', 'OoOoOoO', 'OoOoOoO', '.OoOoO.'],
391  lantern: ['...H...', '..oNo..', '.OYOYO.', 'OoOoOoO', 'OYYYYYO', '.OoOoO.'],
392  ball: ['.rr.', 'rWrr', 'rrrr', '.rr.'],
393} satisfies Record<string, Art | Art[]>
394
395// A subagent's helper: Clawd at half size, one fine pixel per pixel.
396export const MINI = ['..oooooooo..', '..okooooko..', 'oooooooooooo', 'oooooooooooo', '..oooooooo..', '..oooooooo..']
397export const MINI_LEGS = [
398  ['..o.o..o.o..', '..o.o..o.o..'],
399  ['..o.o..o.o..', '....o....o..'],
400]
401
402// ── The face ───────────────────────────────────────────────────────────────
403
404export type EyeKind =
405  | 'open'
406  | 'wide'
407  | 'puppy'
408  | 'happy'
409  | 'closed'
410  | 'sad'
411  | 'dizzy'
412  | 'squeeze'
413  | 'star'
414  | 'heart'
415  | 'half'
416export type Mouth = 'none' | 'smile' | 'grin' | 'o' | 'small' | 'flat' | 'frown' | 'wavy' | 'tongue' | 'yawn' | 'grit' | 'side'
417export type Brows = 'none' | 'focus' | 'worried' | 'raised' | 'curious'
418export type Wear = 'none' | 'glasses' | 'goggles' | 'shades'
419
420export type Face = { eyes: EyeKind; mouth: Mouth; brows: Brows; blush: boolean; look: { dx: number; dy: number }; wear: Wear }
421
422export const face = (eyes: EyeKind, mouth: Mouth = 'none', more: Partial<Face> = {}): Face => ({
423  eyes,
424  mouth,
425  brows: 'none',
426  blush: false,
427  look: { dx: 0, dy: 0 },
428  wear: 'none',
429  ...more,
430})
431
432// ── Activities ─────────────────────────────────────────────────────────────
433
434export type Move = 'wander' | 'pace' | 'still' | 'station' | 'scan' | 'carry' | 'surf' | 'sweep' | 'party' | 'stomp' | 'loaf' | 'tap' | 'chase' | 'treadmill'
435export type ArmPose = 'out' | 'raise' | 'up' | 'down'
436export type Arms = { front: ArmPose; back: ArmPose }
437
438export type ActDef = {
439  move: Move
440  speed?: number
441  isWorking?: boolean
442  face: (sim: Sim) => Face
443  arms?: (sim: Sim) => Arms
444}
445
446export const ARMS_OUT: Arms = { front: 'out', back: 'out' }
447export const ARMS_UP: Arms = { front: 'raise', back: 'raise' }
448export const typing = (sim: Sim): Arms =>
449  Math.floor(sim.t * 8) % 2 ? { front: 'down', back: 'out' } : { front: 'out', back: 'down' }
450export const glance = (sim: Sim, every = 1.3) => (Math.floor(sim.t / every) % 2 ? 1 : -1)
451export const walkLook = (sim: Sim) => ({ dx: isWalking(sim) || !sim.isGrounded ? sim.dir : 0, dy: 0 })
452
453export const ACTS: Record<Mood, ActDef> = {
454  idle: { move: 'wander', speed: 7, face: idleFace, arms: idleArms },
455  sleepy: {
456    move: 'loaf',
457    face: () => face('closed', 'small', { blush: true }),
458  },
459  listening: {
460    move: 'still',
461    face: s => (s.moodT < 0.45 ? face('wide', 'o', { look: { dx: 0, dy: -1 }, brows: 'raised' }) : face('happy', 'grin', { blush: true })),
462    arms: s => (s.moodT > 0.45 ? ARMS_UP : ARMS_OUT),
463  },
464  thinking: {
465    move: 'pace',
466    speed: 10,
467    face: s => face('open', 'side', { look: { dx: isWalking(s) ? s.dir : glance(s), dy: -1 } }),
468  },
469  pondering: {
470    move: 'still',
471    face: s => face('open', 'small', { look: { dx: glance(s, 2), dy: -1 }, brows: 'curious' }),
472  },
473  talking: {
474    move: 'still',
475    face: s => face('happy', Math.floor(s.t * 6) % 3 ? 'o' : 'small', { blush: true }),
476    arms: s => (Math.floor(s.t * 1.5) % 2 ? { front: 'raise', back: 'out' } : ARMS_OUT),
477  },
478  reading: {
479    move: 'still',
480    isWorking: true,
481    face: () => face('open', 'none', { look: { dx: 0, dy: 1 }, wear: 'glasses' }),
482  },
483  searching: {
484    move: 'scan',
485    speed: 6,
486    isWorking: true,
487    face: s => face('open', 'o', { look: { dx: s.dir, dy: 0 }, brows: 'curious' }),
488  },
489  writing: {
490    move: 'station',
491    speed: 16,
492    isWorking: true,
493    face: () => face('open', 'none', { brows: 'focus' }),
494    arms: typing,
495  },
496  terminal: {
497    move: 'station',
498    speed: 16,
499    isWorking: true,
500    face: s => face('open', 'tongue', { look: { dx: s.dir, dy: 0 }, brows: 'focus' }),
501    arms: typing,
502  },
503  testing: {
504    move: 'still',
505    isWorking: true,
506    face: s => face('wide', 'wavy', { look: { dx: s.dir, dy: -1 }, wear: 'goggles' }),
507    arms: () => ({ front: 'raise', back: 'out' }),
508  },
509  building: {
510    move: 'station',
511    speed: 16,
512    isWorking: true,
513    face: s => face('open', 'grit', { look: { dx: s.dir, dy: 1 }, brows: 'focus' }),
514  },
515  installing: {
516    move: 'carry',
517    speed: 16,
518    isWorking: true,
519    face: s => face('squeeze', 'grit', { look: walkLook(s) }),
520    arms: () => ARMS_UP,
521  },
522  shipping: {
523    move: 'station',
524    speed: 16,
525    isWorking: true,
526    face: s => (rocketLift(s) > 0 ? face('star', 'grin', { blush: true }) : face('wide', 'o', { look: { dx: s.dir, dy: -1 } })),
527    arms: s => (s.isArrived && rocketLift(s) > 0 ? ARMS_UP : ARMS_OUT),
528  },
529  committing: {
530    move: 'still',
531    face: s => (s.isPlanted ? face('happy', 'smile', { blush: true }) : face('open', 'small', { brows: 'focus' })),
532    arms: s => (s.isPlanted ? ARMS_UP : { front: 'raise', back: 'out' }),
533  },
534  cleaning: {
535    move: 'sweep',
536    speed: 12,
537    isWorking: true,
538    face: () => face('closed', 'smile'),
539    arms: () => ({ front: 'down', back: 'out' }),
540  },
541  planning: {
542    move: 'still',
543    face: s => face('open', 'tongue', { look: { dx: s.dir, dy: 1 } }),
544    arms: () => ({ front: 'raise', back: 'raise' }),
545  },
546  asking: {
547    move: 'still',
548    face: () => face('open', 'o', { brows: 'curious' }),
549    arms: () => ({ front: 'raise', back: 'out' }),
550  },
551  waiting: {
552    move: 'tap',
553    face: () => face('puppy', 'small', { brows: 'worried', blush: true }),
554    arms: () => ({ front: 'raise', back: 'out' }),
555  },
556  timing: {
557    move: 'still',
558    face: s => face('half', 'flat', { look: { dx: s.dir, dy: 0 } }),
559  },
560  delegating: {
561    move: 'still',
562    face: s => face('open', Math.floor(s.t * 4) % 2 ? 'grin' : 'o', { brows: 'focus' }),
563    arms: () => ({ front: 'raise', back: 'out' }),
564  },
565  surfing: {
566    move: 'surf',
567    speed: 14,
568    isWorking: true,
569    face: () => face('open', 'grin', { wear: 'shades' }),
570    arms: s => (Math.floor(s.t * 1.2) % 2 ? { front: 'raise', back: 'out' } : ARMS_OUT),
571  },
572  plugging: {
573    move: 'still',
574    isWorking: true,
575    face: s => face('wide', 'o', { look: { dx: s.dir, dy: 0 } }),
576  },
577  snapping: {
578    move: 'still',
579    face: s => (s.moodT % 1.6 < 0.4 ? face('closed', 'grin') : face('happy', 'grin', { blush: true })),
580    arms: () => ({ front: 'raise', back: 'out' }),
581  },
582  casting: {
583    move: 'still',
584    face: () => face('star', 'smile', { blush: true }),
585    arms: () => ({ front: 'raise', back: 'out' }),
586  },
587  compacting: {
588    move: 'stomp',
589    face: () => face('squeeze', 'grit'),
590    arms: s => (s.isGrounded ? ARMS_OUT : ARMS_UP),
591  },
592  happy: {
593    move: 'party',
594    face: s => (Math.floor(s.t * 2) % 2 ? face('star', 'grin', { blush: true }) : face('happy', 'grin', { blush: true })),
595    arms: () => ARMS_UP,
596  },
597  oops: {
598    move: 'still',
599    face: () => face('squeeze', 'wavy', { brows: 'worried' }),
600  },
601  debugging: {
602    move: 'chase',
603    speed: 15,
604    isWorking: true,
605    face: s => face('wide', 'o', { look: { dx: s.critter && s.critter.x > s.x + 12 ? 1 : -1, dy: 1 } }),
606    arms: s => (Math.floor(s.t * 3) % 2 ? { front: 'up', back: 'out' } : { front: 'raise', back: 'out' }),
607  },
608  polishing: {
609    move: 'scan',
610    speed: 6,
611    isWorking: true,
612    face: () => face('happy', 'smile', { blush: true }),
613    arms: () => ({ front: 'raise', back: 'out' }),
614  },
615  branching: {
616    move: 'station',
617    speed: 16,
618    isWorking: true,
619    face: s => face('open', 'smile', { look: { dx: s.dir, dy: 0 } }),
620    arms: () => ({ front: 'raise', back: 'out' }),
621  },
622  querying: {
623    move: 'station',
624    speed: 16,
625    isWorking: true,
626    face: s => face('open', 'o', { look: { dx: s.dir, dy: 0 } }),
627    arms: typing,
628  },
629  serving: {
630    move: 'station',
631    speed: 16,
632    isWorking: true,
633    face: () => face('happy', 'smile', { blush: true }),
634    arms: s => (Math.floor(s.t * 1.2) % 3 === 0 ? { front: 'up', back: 'out' } : ARMS_OUT),
635  },
636  benchmarking: {
637    move: 'treadmill',
638    isWorking: true,
639    face: s => face(s.moodT > 3 ? 'squeeze' : 'open', 'o', { look: { dx: s.dir, dy: 0 } }),
640    arms: s => (Math.floor(s.t * 8) % 2 ? { front: 'raise', back: 'down' } : { front: 'down', back: 'raise' }),
641  },
642  designing: {
643    move: 'station',
644    speed: 16,
645    isWorking: true,
646    face: s => face('open', 'smile', { look: { dx: s.dir, dy: 0 } }),
647    arms: s => (Math.floor(s.t * 4) % 2 ? { front: 'raise', back: 'out' } : { front: 'out', back: 'out' }),
648  },
649  exploring: {
650    move: 'wander',
651    speed: 9,
652    isWorking: true,
653    face: s => face('open', 'o', { look: { dx: isWalking(s) ? s.dir : glance(s), dy: 1 } }),
654    arms: () => ({ front: 'raise', back: 'raise' }),
655  },
656  mailing: {
657    move: 'still',
658    face: () => face('happy', 'smile'),
659    arms: s => (s.moodT % 1.1 < 0.25 ? { front: 'out', back: 'out' } : { front: 'up', back: 'out' }),
660  },
661  remembering: {
662    move: 'still',
663    face: () => face('happy', 'smile', { blush: true }),
664  },
665  phoning: {
666    move: 'still',
667    isWorking: true,
668    face: s => face('open', 'none', { look: { dx: s.dir, dy: 1 } }),
669    arms: () => ({ front: 'raise', back: 'out' }),
670  },
671  sad: {
672    move: 'wander',
673    speed: 3,
674    face: () => face('sad', 'frown', { brows: 'worried', look: { dx: 0, dy: 1 } }),
675    arms: () => ({ front: 'down', back: 'down' }),
676  },
677}
678
679export const STATIONS: readonly Mood[] = ['writing', 'terminal', 'building', 'shipping', 'branching', 'querying', 'serving', 'designing']
680export const FACES_SIDE: readonly Mood[] = ['compacting', 'testing', 'plugging', 'snapping', 'reading', 'planning', 'delegating', 'timing', 'mailing', 'phoning', 'remembering', 'benchmarking']
681
682// ── Simulation ─────────────────────────────────────────────────────────────
683
684export type Props = {
685  mood: Mood
686  label: string
687  detail: string
688  seq: number
689  helpers: number
690  tint: string
691  hour: number
692  isBig: boolean
693  month: number
694  day: number
695  react: Reaction
696}
697
698export type Shown = { mood: Mood; label: string; detail: string; tint: string; isBig: boolean }
699
700export type Particle = {
701  x: number
702  y: number
703  vx: number
704  vy: number
705  g: number
706  life: number
707  glyph?: string
708  art?: Art
709  isFloat?: boolean
710  color: string
711}
712
713export type Mini = { x: number; dir: -1 | 1; targetX: number; stride: number; isLeaving: boolean; hat: number; tint: string }
714export type Fidget = 'look' | 'wave' | 'stretch' | 'whistle' | 'sit' | 'dance' | 'juggle' | 'ball'
715export type Critter = { x: number; vx: number; t: number }
716export type Ball = { x: number; vx: number; y: number; vy: number }
717export type Butterfly = { x: number; y: number; vx: number; t: number }
718
719export type Sim = {
720  props: Props
721  seq: number
722  reactSeq: number
723  shown: Shown
724  pending: Shown | null
725  mood: Mood
726  moodT: number
727  t: number
728  x: number
729  y: number
730  vx: number
731  vy: number
732  isGrounded: boolean
733  dir: -1 | 1
734  targetX: number
735  pause: number
736  stride: number
737  squash: number
738  flash: number
739  blink: number
740  nextBlink: number
741  emit: number
742  beat: number
743  anchorX: number
744  isArrived: boolean
745  isPlanted: boolean
746  variant: number
747  fidget: Fidget | null
748  fidgetT: number
749  butterfly: Butterfly | null
750  critter: Critter | null
751  ball: Ball | null
752  react: { kind: ReactKind | 'pet'; t: number } | null
753  flags: { x: number; color: string }[]
754  minis: Mini[]
755  particles: Particle[]
756  isHeld: boolean
757  grabX: number
758  grabY: number
759  hasDragged: boolean
760  prevX: number
761  prevY: number
762  isPlaced: boolean
763}
764
765export type State = { sim: Sim; frame: number }
766
767export const rand = (a: number, b: number) => a + Math.random() * (b - a)
768export const pick = <T,>(list: readonly T[]) => list[Math.floor(Math.random() * list.length)]
769export const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
770export const hash = (n: number) => {
771  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453
772  return s - Math.floor(s)
773}
774
775export function shade(hex: string, amount: number) {
776  const n = parseInt(hex.slice(1), 16)
777  const mix = (c: number) => Math.round(amount < 0 ? c * (1 + amount) : c + (255 - c) * amount)
778  const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(mix)
779  return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)
780}
781
782export const ROCKET_CYCLE = 3.6
783export function rocketLift(sim: Sim) {
784  const t = (sim.moodT % ROCKET_CYCLE) - 1.2
785  return t <= 0 ? 0 : 30 * t * t
786}
787
788export const isNight = (sim: Sim) => sim.props.hour >= 21 || sim.props.hour < 6
789
790// ── Layout ─────────────────────────────────────────────────────────────────
791
792export type Layout = ReturnType<typeof layout>
793
794export function layout(cols: number, rows: number) {
795  const cellRows = Math.max(0, rows - CAPTION_ROWS)
796  const pxH = cellRows * 2
797  const scale = cols >= 100 && cellRows >= 26 ? 2 : 1
798  const w = W0 * scale
799  const h = H0 * scale
800  const floorY = pxH - 1
801  return { cols, cellRows, pxH, scale, w, h, floorY, groundY: floorY - h, maxX: Math.max(0, cols - w) }
802}
803
804// A point in fine pixels from Clawd's top-left as if he faced right, in the
805// scene's columns and half-pixels; `w` is the prop's width, so a mirrored
806// prop lands on his other side.
807export function place(sim: Sim, L: Layout, rx: number, ry: number, w = 0) {
808  const S = L.scale
809  const x = sim.dir > 0 ? sim.x + rx * S : sim.x + (W0 - rx - w) * S
810  return { x, y: sim.y + ry * S }
811}
812
813// Props are laid out on an 18 × 10 frame (hands at x 16, floor at y 10).
814// Their centres stretch across his width; above his head they keep their
815// height, on his body they stretch, and on the floor they stay on it.
816const FRAME_W = 18
817const FRAME_H = 10
818export function anchor(sim: Sim, L: Layout, rx: number, ry: number, w = 0, h = 0) {
819  const x = Math.round(((rx + w / 2) * W0) / FRAME_W - w / 2)
820  const y = ry + h >= FRAME_H ? ry + (H0 - FRAME_H) : ry >= 0 ? Math.round((ry * H0) / FRAME_H) : ry
821  return place(sim, L, x, y, w)
822}
823
824export const waveTop = (L: Layout, x: number, t: number) =>
825  L.floorY - Math.round((4 + 1.6 * Math.sin(x * 0.2 - t * 3) + 0.9 * Math.sin(x * 0.07 + t)) * L.scale)
826
827// ── Behaviour ──────────────────────────────────────────────────────────────
828
829export type At = { x: number; y: number }
830
831export function spray(sim: Sim, n: number, at: At, glyphs: readonly string[], colors: readonly string[], spread = 6, g = 0) {
832  for (let i = 0; i < n; i++) {
833    sim.particles.push({
834      x: at.x + rand(-spread, spread),
835      y: at.y + rand(-2, 2),
836      vx: rand(-8, 8),
837      vy: rand(-14, -4),
838      g,
839      life: rand(0.6, 1.3),
840      glyph: pick(glyphs),
841      color: pick(colors),
842    })
843  }
844}
845
846export function bits(sim: Sim, n: number, at: At, arts: readonly Art[], colors: readonly string[], speed = 14, g = 0, life = 1) {
847  for (let i = 0; i < n; i++) {
848    const a = rand(0, Math.PI * 2)
849    const v = rand(speed * 0.4, speed)
850    sim.particles.push({ x: at.x, y: at.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.3, g, life: rand(life * 0.6, life), art: pick(arts), color: pick(colors) })
851  }
852}
853
854export function float(sim: Sim, at: At, text: string, color: string) {
855  sim.particles.push({ x: at.x - text.length / 2, y: at.y, vx: 0, vy: -7, g: 0, life: 1.9, glyph: text, isFloat: true, color })
856}
857
858export function jump(sim: Sim, height: number, vx = 0) {
859  sim.vy = -Math.sqrt(2 * GRAVITY * height)
860  sim.vx = vx
861  sim.isGrounded = false
862}
863
864export function enterMood(sim: Sim, next: Shown, L: Layout) {
865  const mood = next.mood
866  const head = { x: sim.x + L.w / 2, y: sim.y - 2 }
867  sim.shown = next
868  sim.mood = mood
869  sim.moodT = 0
870  sim.pause = 0
871  sim.emit = 0
872  sim.beat = -1
873  sim.isArrived = false
874  sim.isPlanted = false
875  sim.anchorX = sim.x
876  sim.variant = Math.floor(Math.random() * 1000)
877  sim.fidget = null
878
879  if (mood === 'oops') {
880    sim.flash = 0.7
881    bits(sim, 4, head, [ART.smoke], ['#8A8580'], 10, -8, 1.2)
882  }
883  if (mood === 'happy') {
884    for (let i = 0; i < 36; i++) {
885      sim.particles.push({
886        x: rand(0, L.cols),
887        y: rand(-6, 2),
888        vx: rand(-5, 5),
889        vy: rand(0, 6),
890        g: 16,
891        life: rand(2, 3.2),
892        art: pick([['x'], ['xx'], ['x', 'x']]),
893        color: pick(CONFETTI),
894      })
895    }
896  }
897  if (mood === 'installing') sim.targetX = sim.x < L.maxX / 2 ? L.maxX : 0
898  if (STATIONS.includes(mood)) {
899    const room = 18 * L.scale
900    sim.targetX = rand(0, L.maxX)
901    if (L.cols - (sim.targetX + L.w) < room && sim.targetX < room) sim.targetX = L.maxX / 2
902  }
903  if (FACES_SIDE.includes(mood)) sim.dir = sim.x + L.w / 2 < L.cols / 2 ? 1 : -1
904  if (mood === 'debugging' && !sim.critter) {
905    const fromLeft = sim.x > L.cols / 2
906    sim.critter = { x: fromLeft ? 2 : L.cols - 8, vx: (fromLeft ? 1 : -1) * 10, t: 0 }
907  }
908}
909
910export function react(sim: Sim, r: Reaction, L: Layout) {
911  sim.react = { kind: r.kind, t: 0 }
912  const S = L.scale
913  const head = { x: sim.x + L.w / 2, y: sim.y - 4 * S }
914  switch (r.kind) {
915    case 'pass':
916      float(sim, head, r.text, PAL.G)
917      bits(sim, 8, head, [ART.sparkle, ['Y'], ['W']], ['#FFE38A'], 16, 6, 1.1)
918      break
919    case 'fail':
920      float(sim, head, r.text, PAL.r)
921      bits(sim, 5, head, [ART.smoke], ['#8A8580'], 8, -6, 1.3)
922      sim.flash = Math.max(sim.flash, 0.4)
923      break
924    case 'edit':
925      float(sim, { x: head.x - (r.extra ? 2 : 0), y: head.y }, r.text, PAL.G)
926      if (r.extra) float(sim, { x: head.x + 3, y: head.y }, r.extra, PAL.r)
927      bits(sim, 4, head, [['Y'], ['t']], [sim.shown.tint || PAL.y], 10, 4, 0.8)
928      break
929    case 'found':
930      float(sim, head, r.text, PAL.Y)
931      spray(sim, 1, { x: head.x, y: head.y - 4 }, ['!'], [PAL.Y], 0)
932      break
933    case 'none':
934      float(sim, head, r.text, MUTED)
935      break
936    case 'hello':
937      float(sim, head, r.text, PAL.p)
938      bits(sim, 3, head, [ART.heart], [PAL.p], 8, -4, 1.4)
939      break
940    case 'denied':
941      float(sim, head, r.text, MUTED)
942      break
943    case 'phew':
944      float(sim, head, r.text, PAL.B)
945      for (let i = 0; i < 3; i++) sim.particles.push({ ...place(sim, L, i % 2 ? 2 : 21, 1), vx: rand(-4, 4), vy: rand(-6, 0), g: 30, life: 0.9, art: ['B', 'B'], color: PAL.B })
946      break
947    case 'combo':
948      float(sim, head, r.text, PAL.Y)
949      bits(sim, 14, { x: head.x, y: sim.y + 6 * S }, [ART.sparkle, ['Y'], ['W']], ['#FFE38A'], 26, 0, 0.8)
950      break
951    case 'noted':
952      float(sim, head, r.text, PAL.p)
953      bits(sim, 4, head, [ART.heart], [PAL.p], 8, -6, 1.6)
954      break
955    case 'streak':
956      float(sim, head, r.text, PAL.O)
957      for (let i = 0; i < 18; i++) {
958        sim.particles.push({ x: sim.x + rand(2, L.w - 2), y: sim.y + rand(6, 12) * S, vx: rand(-3, 3), vy: rand(-26, -10), g: 0, life: rand(0.5, 1.1), art: pick([['x'], ['x', 'x']]), color: pick([PAL.Y, PAL.O, PAL.r]) })
959      }
960      break
961  }
962}
963
964export function chooseTarget(sim: Sim, L: Layout) {
965  const { maxX } = L
966  switch (ACTS[sim.mood].move) {
967    case 'pace':
968      sim.targetX = rand(maxX * 0.2, maxX * 0.8)
969      sim.pause = rand(0.5, 1.2)
970      break
971    case 'scan':
972      sim.targetX = clamp(sim.x + rand(-12, 12) * L.scale, 0, maxX)
973      sim.pause = rand(0.8, 1.8)
974      break
975    case 'carry':
976      sim.targetX = sim.targetX > maxX / 2 ? 0 : maxX
977      sim.pause = 0.3
978      break
979    case 'sweep':
980      sim.targetX = clamp(sim.anchorX + (sim.targetX > sim.anchorX ? -4 : 4) * L.scale, 0, maxX)
981      sim.pause = 0.05
982      break
983    case 'surf':
984      sim.targetX = rand(0, maxX)
985      sim.pause = 0.1
986      break
987    default:
988      sim.targetX = rand(0, maxX)
989      sim.pause = sim.mood === 'sad' ? rand(2.5, 4) : rand(1, 3)
990  }
991}
992
993export function isWalking(sim: Sim) {
994  const move = ACTS[sim.mood].move
995  const walks =
996    (move === 'wander' && !sim.fidget) ||
997    move === 'pace' ||
998    move === 'scan' ||
999    move === 'carry' ||
1000    move === 'sweep' ||
1001    move === 'chase' ||
1002    (move === 'station' && !sim.isArrived)
1003  return walks && sim.isGrounded && Math.abs(sim.targetX - sim.x) >= 0.6
1004}
1005
1006// Idle isn't just standing around.
1007export const FIDGETS: readonly Fidget[] = ['look', 'wave', 'stretch', 'whistle', 'sit', 'dance', 'juggle', 'ball']
1008
1009export function idleFace(sim: Sim): Face {
1010  const night = isNight(sim)
1011  if (sim.butterfly) {
1012    const dx = sim.butterfly.x > sim.x + 9 ? 1 : -1
1013    return face('wide', 'o', { look: { dx, dy: -1 }, blush: true })
1014  }
1015  switch (sim.fidget) {
1016    case 'juggle':
1017      return face('open', 'o', { look: { dx: 0, dy: -1 } })
1018    case 'ball':
1019      return face('happy', 'grin', { look: { dx: sim.ball && sim.ball.x > sim.x + 12 ? 1 : -1, dy: 1 } })
1020    case 'look':
1021      return face('open', 'smile', { blush: true })
1022    case 'wave':
1023      return face('happy', 'grin', { blush: true })
1024    case 'stretch':
1025      return face('closed', night || sim.fidgetT > 0.5 ? 'yawn' : 'o')
1026    case 'whistle':
1027      return face('closed', 'small')
1028    case 'sit':
1029      return face('open', 'smile', { look: { dx: glance(sim, 1.6), dy: 0 } })
1030    case 'dance':
1031      return face('happy', 'grin')
1032  }
1033  return face(night ? 'half' : 'open', 'none', { look: walkLook(sim) })
1034}
1035
1036export function idleArms(sim: Sim): Arms {
1037  switch (sim.fidget) {
1038    case 'juggle':
1039      return Math.floor(sim.t * 6) % 2 ? { front: 'raise', back: 'out' } : { front: 'out', back: 'raise' }
1040    case 'wave':
1041      return { front: Math.floor(sim.t * 5) % 2 ? 'up' : 'out', back: 'out' }
1042    case 'stretch':
1043      return ARMS_UP
1044    case 'dance':
1045      return Math.floor(sim.t * 3) % 2 ? { front: 'up', back: 'down' } : { front: 'down', back: 'up' }
1046    case 'sit':
1047      return { front: 'down', back: 'down' }
1048  }
1049  return ARMS_OUT
1050}
1051
1052export function stepIdle(sim: Sim, L: Layout) {
1053  const S = L.scale
1054  if (sim.fidget) {
1055    sim.fidgetT += DT
1056    if (sim.fidget === 'dance' && sim.isGrounded && Math.floor(sim.fidgetT * 3) !== Math.floor((sim.fidgetT - DT) * 3)) {
1057      jump(sim, 2 * S)
1058      sim.dir = (sim.dir * -1) as -1 | 1
1059    }
1060    if (sim.fidget === 'whistle' && Math.random() < 0.06) {
1061      sim.particles.push({ ...anchor(sim, L, 14, 2), vx: sim.dir * 6, vy: -8, g: 0, life: 1.4, glyph: pick(['♪', '♫']), color: PAL.v })
1062    }
1063    if (sim.fidget === 'ball' && !sim.ball && sim.fidgetT < DT * 1.5) {
1064      const fromLeft = sim.x > L.cols / 2
1065      sim.ball = { x: fromLeft ? -4 : L.cols, vx: (fromLeft ? 1 : -1) * 26, y: 0, vy: 0 }
1066    }
1067    if (sim.fidgetT > (sim.fidget === 'sit' ? 5 : sim.fidget === 'juggle' || sim.fidget === 'ball' ? 3.5 : 2.2)) {
1068      sim.fidget = null
1069      chooseTarget(sim, L)
1070    }
1071    return
1072  }
1073  const dx = sim.targetX - sim.x
1074  if (Math.abs(dx) < 0.6) {
1075    sim.pause -= DT
1076    if (sim.pause > 0) return
1077    const roll = Math.random()
1078    if (roll < 0.45) chooseTarget(sim, L)
1079    else if (roll < 0.55 && !sim.butterfly) {
1080      const fromLeft = Math.random() < 0.5
1081      sim.butterfly = { x: fromLeft ? -4 : L.cols + 2, y: L.groundY - 6 * S, vx: (fromLeft ? 1 : -1) * 7 * S, t: 0 }
1082    } else {
1083      sim.fidget = pick(FIDGETS)
1084      sim.fidgetT = 0
1085    }
1086    return
1087  }
1088  sim.dir = dx < 0 ? -1 : 1
1089  const move = Math.min(Math.abs(dx), (sim.butterfly ? 12 : 7) * S * DT)
1090  sim.x += sim.dir * move
1091  sim.stride += move
1092}
1093
1094// The bug runs from Clawd; the ball rolls, gets kicked, and rolls away.
1095export function stepPlay(sim: Sim, L: Layout) {
1096  const S = L.scale
1097  const mid = sim.x + L.w / 2
1098  const c = sim.critter
1099  if (c) {
1100    c.t += DT
1101    if (Math.abs(c.x + 2 - mid) < 16 * S) c.vx = Math.sign(c.x + 2 - mid || 1) * 24
1102    else if (Math.random() < 0.03) c.vx = rand(-12, 12)
1103    c.x += c.vx * DT
1104    if (c.x < 0 || c.x > L.cols - 5) {
1105      c.x = clamp(c.x, 0, L.cols - 5)
1106      c.vx *= -1
1107    }
1108    if (sim.mood !== 'debugging') sim.critter = null
1109  }
1110  const b = sim.ball
1111  if (b) {
1112    b.x += b.vx * DT
1113    b.vy += 120 * DT
1114    b.y = Math.min(0, b.y + b.vy * DT)
1115    if (b.y === 0) b.vy = 0
1116    b.vx *= 0.995
1117    const isNear = Math.abs(b.x + 2 - mid) < 12 * S && b.y === 0
1118    if (isNear && Math.sign(b.vx) === Math.sign(mid - b.x - 2)) {
1119      b.vx = -b.vx * 1.3
1120      b.vy = -40
1121      sim.dir = b.vx > 0 ? -1 : 1
1122      bits(sim, 3, { x: b.x + 2, y: L.floorY - 2 }, [['s']], [MUTED], 8, 20, 0.4)
1123    }
1124    if (b.x < -10 || b.x > L.cols + 6) sim.ball = null
1125  }
1126}
1127
1128export function stepButterfly(sim: Sim, L: Layout) {
1129  const b = sim.butterfly
1130  if (!b) return
1131  b.t += DT
1132  b.x += b.vx * DT
1133  b.y = L.groundY - 8 * L.scale + Math.sin(b.t * 3) * 4 * L.scale - b.t * 0.6
1134  if (sim.mood === 'idle' && !sim.fidget) sim.targetX = clamp(b.x - L.w / 2, 0, L.maxX)
1135  if (b.x < -8 || b.x > L.cols + 4) sim.butterfly = null
1136}
1137
1138export function stepMinis(sim: Sim, L: Layout) {
1139  const want = sim.props.helpers ?? 0
1140  const staying = sim.minis.filter(m => !m.isLeaving)
1141  if (staying.length < want) {
1142    const fromLeft = Math.random() < 0.5
1143    sim.minis.push({
1144      x: fromLeft ? -10 : L.cols,
1145      dir: fromLeft ? 1 : -1,
1146      targetX: rand(0, L.cols - 10),
1147      stride: 0,
1148      isLeaving: false,
1149      hat: Math.floor(Math.random() * ART.miniHats.length),
1150      tint: pick(CONFETTI),
1151    })
1152  } else if (staying.length > want) {
1153    const m = staying[0]
1154    m.isLeaving = true
1155    m.targetX = m.x < L.cols / 2 ? -12 : L.cols + 2
1156  }
1157  for (const m of sim.minis) {
1158    const dx = m.targetX - m.x
1159    if (Math.abs(dx) < 0.6) {
1160      if (!m.isLeaving && Math.random() < 0.04) m.targetX = rand(0, L.cols - 10)
1161      continue
1162    }
1163    m.dir = dx < 0 ? -1 : 1
1164    const move = Math.min(Math.abs(dx), 24 * L.scale * DT)
1165    m.x += m.dir * move
1166    m.stride += move
1167  }
1168  sim.minis = sim.minis.filter(m => !(m.isLeaving && (m.x < -11 || m.x > L.cols + 1)))
1169}
1170
1171export function emit(sim: Sim, L: Layout) {
1172  const S = L.scale
1173  const tint = sim.shown.tint || PAL.y
1174  sim.emit = 0.5
1175
1176  switch (sim.mood) {
1177    case 'sleepy':
1178      sim.particles.push({ ...anchor(sim, L, 15, 0), vx: 5, vy: -5, g: 0, life: 2.2, glyph: pick(['z', 'Z']), color: PAL.v })
1179      sim.emit = 1.1
1180      break
1181    case 'writing':
1182      if (sim.isArrived) spray(sim, 1, anchor(sim, L, 9, 3), ['{', '}', ';', '<', '>', '/', '=', '(', ')'], [tint, shade(tint, 0.4), MUTED], 7)
1183      sim.emit = 0.2
1184      break
1185    case 'terminal':
1186      if (sim.isArrived && Math.random() < 0.3) spray(sim, 1, anchor(sim, L, 27, -2), ['$', '>', '_'], [PAL.G], 2)
1187      sim.emit = 0.3
1188      break
1189    case 'reading':
1190      if (Math.random() < 0.3) spray(sim, 1, anchor(sim, L, 24, -3), ['·'], [PAL.Y], 2)
1191      sim.emit = 0.6
1192      break
1193    case 'testing':
1194      bits(sim, 1, anchor(sim, L, 20, -3), [['J'], ['J', 'J']], [shade(LIQUIDS[sim.variant % LIQUIDS.length], 0.3)], 3, -10, 1)
1195      sim.emit = 0.2
1196      break
1197    case 'shipping': {
1198      if (!sim.isArrived) break
1199      const lift = rocketLift(sim)
1200      const at = anchor(sim, L, 24, 10 - lift / S)
hooks/clawd.tsx 122 lines
1import type { ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
2
3import {
4  ART,
5  BODY,
6  CAPTION_ROWS,
7  GRAVITY,
8  PAL,
9  TICK,
10  W0,
11  bits,
12  clamp,
13  layout as layoutOf,
14  newSim,
15  rasterize,
16  step,
17} from './engine'
18import type { Props, State } from './engine'
19
20const layout = (surface: ClientSurface<State>) => layoutOf(surface.columns, surface.rows)
21
22// ── The module ─────────────────────────────────────────────────────────────
23
24function onPointer(surface: ClientSurface<State>, e: ClientPointerEvent) {
25  const st = surface.state
26  if (!st) return
27  const sim = st.sim
28  const L = layout(surface)
29  const x = e.fine?.x ?? e.x + 0.5
30  const y = ((e.fine?.y ?? e.y + 0.5) - CAPTION_ROWS) * 2
31
32  if (e.type === 'down' && e.button === 'left') {
33    const isHit = x >= sim.x && x <= sim.x + L.w && y >= sim.y - 1 && y <= sim.y + L.h + 1
34    if (!isHit) return
35    sim.isHeld = true
36    sim.hasDragged = false
37    sim.isGrounded = false
38    sim.grabX = x - sim.x
39    sim.grabY = y - sim.y
40    sim.prevX = sim.x
41    sim.prevY = sim.y
42  } else if (e.type === 'move' && sim.isHeld) {
43    sim.hasDragged = true
44    sim.x = clamp(x - sim.grabX, 0, L.maxX)
45    sim.y = clamp(y - sim.grabY, -L.h / 2, L.groundY)
46  } else if ((e.type === 'up' || e.type === 'leave') && sim.isHeld) {
47    sim.isHeld = false
48    sim.isArrived = false
49    if (!sim.hasDragged) {
50      // A pat: a happy hop, heart eyes and hearts.
51      sim.vx = 0
52      sim.vy = -Math.sqrt(2 * GRAVITY * 5 * L.scale)
53      sim.react = { kind: 'pet', t: 0 }
54      bits(sim, 4, { x: sim.x + L.w / 2, y: sim.y - 2 }, [ART.heart], [PAL.p], 10, -6, 1.4)
55    } else {
56      sim.vx = clamp(sim.vx, -160, 160)
57      sim.vy = clamp(sim.vy, -220, 220)
58    }
59    sim.targetX = sim.x
60  } else {
61    return
62  }
63  surface.setState({ sim, frame: st.frame + 1 })
64}
65
66const Clawd: ClientModule<Props, State> = (props, surface) => {
67  const { Box, Text } = surface.elements
68
69  if (!surface.state) {
70    const sim = newSim(props)
71    surface.every(TICK, () => {
72      const st = surface.state
73      if (!st) return
74      const L = layout(surface)
75      if (L.cols >= W0 && L.cellRows >= 4) step(st.sim, L)
76      surface.setState({ sim: st.sim, frame: st.frame + 1 })
77    })
78    surface.onPointer(e => onPointer(surface, e))
79    surface.setState({ sim, frame: 0 })
80    return <Text dimColor>…</Text>
81  }
82
83  const sim = surface.state.sim
84  sim.props = props
85  const L = layout(surface)
86
87  if (L.cols < W0 || L.cellRows < 4) {
88    return <Text color={BODY}>▐▛███▜▌ {sim.shown.label}</Text>
89  }
90
91  const rows = rasterize(sim, L)
92  const accent = sim.shown.tint || BODY
93
94  return (
95    <Box flexDirection="column">
96      <Box flexDirection="row">
97        <Text color={accent}>{'● '}</Text>
98        <Text bold color={BODY}>
99          {sim.shown.label}
100        </Text>
101        {sim.shown.detail !== '' && (
102          <Text dimColor wrap="truncate-end">
103            {'  ' + sim.shown.detail}
104          </Text>
105        )}
106      </Box>
107      <Text dimColor>{sim.isHeld ? 'wheee!' : ' '}</Text>
108      {rows.map(segs => (
109        <Box flexDirection="row">
110          {segs.map(s => (
111            <Text color={s.fg} backgroundColor={s.bg}>
112              {s.text}
113            </Text>
114          ))}
115        </Box>
116      ))}
117    </Box>
118  )
119}
120
121export default Clawd
122
types/index.d.ts 72 lines
1// Everything Clawd can be up to. The hooks pick one from what Claude is
2// doing; the Client draws its scene and moves the way it says.
3export type Mood =
4  | 'idle'
5  | 'sleepy'
6  | 'listening'
7  | 'thinking'
8  | 'pondering'
9  | 'talking'
10  | 'reading'
11  | 'searching'
12  | 'writing'
13  | 'terminal'
14  | 'testing'
15  | 'building'
16  | 'installing'
17  | 'shipping'
18  | 'committing'
19  | 'cleaning'
20  | 'planning'
21  | 'asking'
22  | 'waiting'
23  | 'timing'
24  | 'delegating'
25  | 'surfing'
26  | 'plugging'
27  | 'snapping'
28  | 'casting'
29  | 'compacting'
30  | 'debugging'
31  | 'polishing'
32  | 'branching'
33  | 'querying'
34  | 'serving'
35  | 'benchmarking'
36  | 'designing'
37  | 'exploring'
38  | 'mailing'
39  | 'remembering'
40  | 'phoning'
41  | 'happy'
42  | 'oops'
43  | 'sad'
44
45// A moment's reaction to a result, drawn over whatever Clawd is doing.
46export type ReactKind = 'pass' | 'fail' | 'edit' | 'found' | 'none' | 'hello' | 'denied' | 'phew' | 'combo' | 'noted' | 'streak'
47
48export type Reaction = { kind: ReactKind; text: string; extra?: string; seq: number }
49
50// Where Clawd lives: the stage above the prompt, a side pane, or nowhere.
51export type Place = 'stage' | 'pane' | 'hidden'
52
53export type Buddy = {
54  mood: Mood
55  detail: string
56  since: number
57  seq: number
58  helpers: number
59  tint: string
60  hour: number
61  isBig: boolean
62  month: number
63  day: number
64  react: Reaction
65}
66
67declare module 'claude-code' {
68  interface PluginState {
69    'clawdhouse': { buddy: Buddy; frame: number; place: Place }
70  }
71}
72