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


▶ 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.
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.
| Option | Default | What it does |
|---|---|---|
name | friend | What Clawd calls you in greetings and cheers. |
Pass --config name=YourName when installing from the shell, or change it later from the /plugin menu.
| Command | What it does |
|---|---|
/clawd | Show Clawd (or list what he can do) |
/clawd stage | Put him above the prompt |
/clawd pane | Move him into a side pane |
/clawd hide | Give 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.
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.
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.
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.
.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
hooks/register.tsx 691 lines1import { 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}
691hooks/engine.ts 2444 lines1// 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 lines1import 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
122types/index.d.ts 72 lines1// 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