A pixel pet in the band above the prompt: Clawd (the Claude Code crab) or any pet you define. Reacts to what Claude is doing; any mod can drive it with $.clawd…

Pixel pets for Claude Code. Clawd, the Claude Code crab, lives at the left of the band above your prompt, animates at 60 Hz, and knows what Claude is doing: it types on a laptop while Claude works, holds up a ticket when a permission prompt is waiting for you, sips coffee when nothing is happening, and falls asleep late at night. Other mods can tell it what to do, give it rows to show beside it, or give it a different body.

<sub>One row per state, top to bottom: idle, calm, happy, ecstatic, cheer, worried, sad, sleep, alert, needs, think, dizzy. Rendered with tools/preview.mjs.</sub>
Clawd is Anthropic's Claude Code mascot. This is fan art and an unofficial project, not affiliated with or endorsed by Anthropic.
claude plugin marketplace add yushiran/cc-clawd
claude plugin install clawd@cc-clawd
Then run /reload-plugins (or restart Claude Code). Requires a Claude Code build with function-hook mods (2.1.289 or later).
| When | The pet |
|---|---|
| A permission prompt waits for you | holds up a ticket with a blinking ? (highest priority) |
| Claude is working | types on a laptop whose screen shows what the tool does: >ls_ for Bash, [==] for Read, +-~ for Edit |
| Nothing is happening | sips coffee, looks around, stretches, blinks |
| Quiet for 20 minutes, or 3 minutes after midnight | sleeps under the moon |
| A mod or program sends a signal | whatever the signal says: a monitor of your numbers, confetti, rain, a bell |
You click it (desktop app, or renderer: client) | a heart, a jump, a sparkle; its eyes follow the pointer |
/clawd shows what it is doing and why, and the frame rate. /clawd demo plays every state. /clawd pets lists the pets, and /clawd pet slime switches to one. /clawd bench measures each terminal renderer for four seconds.
Settings are under /config → clawd:
clawd, slime, or a pet of your own.auto picks one per surface; see below.| Surface | Renderer | What you get |
|---|---|---|
| Terminal (most) | raster | Character pixels: quadrant blocks give each cell 2×2 pixels, repainted in place by $.ui.blit |
| kitty, Ghostty | image | Real pixels through the terminal's graphics protocol. Detected automatically, and the result is remembered per terminal |
| Desktop app | client | A surface module that animates on the app's own frame clock and reacts to the pointer |
| VS Code, mobile | text | The current frame as coloured text, redrawn when the pet's state changes |
A frame is computed every 1000/fps ms and sent only when it changed. Computing one takes about 10 µs, under 0.1% of a 60 Hz frame. While Claude works, about 35 of the 60 frames each second actually change. docs/ENGINE.md has the measurements and the best practices behind them.
$.clawdcc-clawd adds $.clawd to every plugin's $, with no dependency to declare. Call it inside try/catch, and draw your own UI when it throws (the pet is not installed):
try {
const { columns } = await $.clawd.layout() // lay your row out to this width
await $.clawd.slot({ source: 'build', rows: [[['build ', { dim: true }], ['passing', { color: 'green' }]]], action: { label: 'log', command: 'build log' } })
await $.clawd.signal({ source: 'build', scene: 'confetti', say: 'all green', priority: 80, ttlMs: 20000 })
await $.clawd.emote({ emote: 'heart' })
} catch {
// no pet: draw the band yourself
}
| Method | What it does |
|---|---|
signal({ source, scene, mood?, bars?, tool?, say?, priority?, ttlMs? }) | Puts the pet in a scene. The highest priority wins, ties go to the newest, and scene: null withdraws the signal |
emote({ emote, say? }) | A one-shot reaction over whatever it is doing: heart, sparkle, surprise, question, jump, nod, shake |
slot({ source, rows, order?, action? }) | Your rows in the band, to the right of the pet, with an optional button that runs a slash command |
pack(def) | Registers a pet (a data pack) for the session |
layout() / state() / pets() | Width for your rows, what the pet is doing, which pets exist |
The full reference is docs/API.md, the types are in plugins/clawd/types/index.d.ts, and examples/focus-timer is a complete guest mod in about 50 lines.
Scripts, cron jobs and training runs on a cluster can signal the pet without being a mod. Write a JSON file to ~/.local/share/clawd/signals/:
d=~/.local/share/clawd/signals; mkdir -p $d
printf '{"scene":"confetti","say":"training finished","priority":85,"ttlMs":30000}' > $d/.train.tmp && mv $d/.train.tmp $d/train.json
The pet reads the folder every second. Deleting the file withdraws the signal.
A pet is a pack: plain JSON that holds a palette, character-art sprites and, for each state, a loop of frames plus props from the shared effects. Put it in ~/.config/clawd/pets/<id>.json and run /clawd pet <id>.
{
"format": "cc-clawd/pack@1", "id": "blob", "cols": 17, "rows": 3, "mode": "quad",
"palette": { "#": "#7bd88f" },
"at": [1, 1],
"sprites": { "a": ["..####..", ".##..##.", "########"], "b": ["........", "..####..", "########"] },
"states": { "idle": { "frames": [["a", 900], ["b", 200]], "fx": [["coffee", 18, 0]] } }
}
States the pack lacks fall back toward idle. The built-in slime (examples/pets/my-slime.json) is a full example:

Preview a pack in a plain terminal, render a contact sheet, or check it:
node tools/preview.mjs examples/pets/my-slime.json
node tools/preview.mjs examples/pets/my-slime.json --png sheet.png
node tools/preview.mjs --check my-pet.json
docs/PACKS.md is the format: modes, sprites, frames, the effects and their sizes, fallbacks, and how to make a built-in.
claude plugin validate plugins/clawd
claude plugin test plugins/clawd # the engine, the brain, and the host end to end on every surface
node tools/preview.mjs # every state of Clawd in this terminal
The layout:
| Path | What it is |
|---|---|
plugins/clawd/engine/pixel.js | Canvas and encoders (Raster cells, text spans, RGBA) |
plugins/clawd/engine/fx.js | Props and effects |
plugins/clawd/engine/pack.js | Pack compiler and stage |
plugins/clawd/engine/brain.js | What the pet does: priorities, signals, emotes, sleep |
plugins/clawd/hooks/register.js | The host: Claude Code events, $.clawd, the band, renderers |
plugins/clawd/hooks/client.js | The surface-side renderer |
plugins/clawd/pets/ | Built-in pets |
The engine is pure JavaScript with no dependencies, so the same files run in a hooks module, a client module and Node.
CC BY-NC-SA 4.0 for the code. Clawd's design belongs to Anthropic.
hooks/register.js 492 lines1// register.js: the host. Wires Claude Code to the brain (engine/brain.js) and a stage (engine/pack.js), publishes
2// $.clawd to every plugin, and draws the band above the prompt: the pet on the left, other mods' rows on the right.
3//
4// Renderers, picked per surface (userConfig `renderer`, default auto):
5// raster terminal: character pixels in a Raster, a frame computed every 1000/fps ms and blitted when it changed
6// image terminal that draws pictures (kitty, Ghostty): the same frames as real pixels in an Image
7// client desktop app (and the terminal on request): client.js animates on the surface's own clock, follows the
8// pointer and reacts to clicks; no hook round trip per frame
9// text VS Code, mobile: the current frame as coloured text, redrawn when the pet's state changes
10//
11// Validator rules this file keeps: `$` is never stored (only called, or passed to top-level functions); other
12// plugins reach $.clawd only by calling it inside try/catch; engine.create spreads `built`.
13
14import { createBrain } from '../engine/brain.js'
15import { BUILTINS, compile, createStage, STATES } from '../engine/pack.js'
16import { base64, cells, hex, DEF } from '../engine/pixel.js'
17import { EMOTES } from '../engine/fx.js'
18
19const API = 1
20const GAP = 2
21const POKES = ['heart', 'jump', 'sparkle', 'nod']
22const SIGNAL_README = `cc-clawd signal files: put <name>.json here and the pet reads it within a second.
23{
24 "scene": "confetti", // idle monitor notes confetti rain night bell ticket laptop dizzy; null withdraws
25 "say": "training finished", // optional: replaces the text of the band's bottom row
26 "mood": 1.5, // optional, -2..2: the pose for monitor
27 "bars": [0.5, -1.2, 0.3], // optional: the monitor's three bars (percent)
28 "priority": 80, // 0-99, higher wins; Claude working is 50, a permission prompt 100
29 "ttlMs": 20000 // how long after the file's modification time it lasts; default 15 minutes
30}
31Delete the file to withdraw it. Write a temporary file and rename it, so a half-written file is never read.
32`
33
34let opts = {}
35const brain = createBrain()
36const packs = new Map(BUILTINS) // id → compiled pack
37const packErrors = {} // file or id → errors
38let petOverride = '' // /clawd pet, when the setting could not be written
39let rendererOverride = '' // /clawd bench
40let stage = null
41let surface = 'terminal'
42let lastCols = 120
43let lastRenderer = 'raster'
44let home = ''
45let termKey = ''
46let imageLikely = false
47let imageOK = 'unknown' // can this terminal draw an Image: yes | no | unknown
48let clientFault = false
49const band = { id: '', kind: '', live: false, denied: 0 }
50let busy = false
51let lastWords = null
52let lastPickKey = ''
53let polling = false
54let bench = null // a /clawd bench in progress
55let lastBench = null // its results
56const fileMtime = new Map()
57// The animation clock: the host's time at the last resync plus the larger of ticks × interval and wall time since.
58// No $.clock.now() per frame; in production wall time leads (timers fire late, never early), under the test kit's
59// clock the ticks do.
60const clk = { host: Date.now(), wall: Date.now(), ticks: 0, dt: 16 }
61const stat = { startedAt: '', registered: [], errors: [], frames: 0, blits: 0, skipped: 0, denies: 0, lastDeny: '', fps: 0, framesPerSec: 0, pokes: 0, client: null, theirs: '' }
62let fpsMark = { t: 0, frames: 0, blits: 0 }
63
64const now = () => clk.host + Math.max(clk.ticks * clk.dt, Date.now() - clk.wall)
65const msg = (err) => String(err && err.message ? err.message : err).slice(0, 240)
66const pickKey = (v) => v.state + '|' + v.by + '|' + (v.say || '') + '|' + (v.emote ? v.emote.kind + v.emote.t0 : '')
67
68function activePack() {
69 return packs.get(petOverride) || packs.get(String(opts.pet || '')) || packs.get('clawd')
70}
71function ensureStage() {
72 const want = activePack()
73 if (!stage || stage.pack !== want) { stage = createStage(want); lastWords = null }
74 return stage
75}
76
77function registerPack(def, source) {
78 const { pack, errors } = compile(def, source)
79 const id = def && typeof def.id === 'string' ? def.id : '?'
80 if (!pack) { packErrors[id] = errors; return { ok: false, id, errors } }
81 if (BUILTINS.has(pack.id)) { packErrors[id] = ['the id "' + pack.id + '" belongs to a built-in pet']; return { ok: false, id, errors: packErrors[id] } }
82 packs.set(pack.id, pack)
83 delete packErrors[id]
84 return { ok: true, id: pack.id, errors: [] }
85}
86
87// ---------- renderers ----------
88function chooseRenderer(s, els) {
89 const pref = rendererOverride || String(opts.renderer || 'auto')
90 if (s === 'terminal') {
91 if (pref === 'text') return 'text'
92 if (pref === 'client' && !clientFault) return 'client'
93 if (pref === 'image' && imageOK !== 'no') return 'image'
94 if (pref === 'auto' && (imageOK === 'yes' || (imageOK === 'unknown' && imageLikely))) return 'image'
95 return 'raster'
96 }
97 if (s === 'desktop' && 'Client' in els && !clientFault && pref !== 'text') return 'client'
98 return 'text'
99}
100
101function textArt(els, C) {
102 const { Box, Text } = els
103 return Box({
104 flexDirection: 'column', width: C.cols,
105 children: C.spans().map((row) => Text({ children: row.map(([s, fg, bg]) => Text({ color: fg === DEF ? undefined : hex(fg), backgroundColor: bg === DEF ? undefined : hex(bg), children: [s] })) })),
106 })
107}
108
109function clientProps(v, t) {
110 const pack = ensureStage().pack
111 return {
112 v: API, pack: pack.source === 'builtin' ? pack.id : pack.def, fps: Number(opts.fps) || 60,
113 state: v.state, info: v.info || {}, emote: v.emote ? { kind: v.emote.kind, age: Math.max(0, t - v.emote.t0) } : null,
114 }
115}
116
117function setImage($, v) {
118 imageOK = v
119 if (termKey) $.store.set('image:' + termKey, v).catch(() => {})
120 if (v === 'no') { band.live = false; $.ui.invalidate('ui.render') }
121}
122
123// Redraw the band only when what it says changes; the pixels move by blit.
124function follow($, v) {
125 const key = pickKey(v)
126 if (key !== lastPickKey) { lastPickKey = key; $.ui.invalidate('ui.render') }
127}
128
129// ---------- the frame loop: one frame per tick, blitted only when it changed, one blit in flight ----------
130function animate($) {
131 clk.ticks += 1
132 if (brain.takeChanged()) $.ui.invalidate('ui.render')
133 if (!stage) return
134 const t = now()
135 const v = brain.pick(t)
136 follow($, v)
137 if (!band.live || busy) return
138 const C = stage.draw(t, v)
139 stat.frames += 1
140 const wd = C.words()
141 if (lastWords && lastWords.length === wd.length) {
142 let same = true
143 for (let i = 0; i < wd.length; i++) if (wd[i] !== lastWords[i]) { same = false; break }
144 if (same) { stat.skipped += 1; return }
145 }
146 lastWords = Uint32Array.from(wd)
147 let args
148 if (band.kind === 'image') {
149 const img = C.rgba()
150 args = { requestId: band.id, key: 'pet', source: { rgba: base64(img.data), width: img.width, height: img.height } }
151 } else {
152 args = { requestId: band.id, key: 'pet', cells: C.cells() }
153 }
154 const kind = band.kind
155 busy = true
156 $.ui.blit(args).then((r) => {
157 busy = false
158 if (r && r.deny) {
159 stat.denies += 1; stat.lastDeny = String(r.deny).slice(0, 160); lastWords = null
160 if (kind === 'image' && imageOK !== 'yes') { setImage($, 'no'); return }
161 if (++band.denied >= 5) band.live = false
162 } else {
163 band.denied = 0; stat.blits += 1
164 if (kind === 'image' && imageOK !== 'yes') setImage($, 'yes')
165 }
166 }, (err) => { busy = false; band.live = false; lastWords = null; stat.lastDeny = msg(err) })
167}
168
169async function resync($) {
170 try { const h = await $.clock.now(); clk.host = h; clk.wall = Date.now(); clk.ticks = 0 } catch (err) { /* keep extrapolating */ }
171}
172
173// ---------- every second: resync, the last second's rates, expiries, signal files, the benchmark ----------
174async function second($) {
175 await resync($)
176 const t = now()
177 const dt = (t - fpsMark.t) / 1000
178 if (fpsMark.t && dt >= 0.5) {
179 stat.fps = Math.round(((stat.blits - fpsMark.blits) / dt) * 10) / 10
180 stat.framesPerSec = Math.round(((stat.frames - fpsMark.frames) / dt) * 10) / 10
181 }
182 if (!fpsMark.t || dt >= 0.5) fpsMark = { t, frames: stat.frames, blits: stat.blits }
183 brain.sweep(t)
184 if (bench) stepBench($, t)
185 await pollFiles($)
186}
187
188async function pollFiles($) {
189 if (!home || polling) return
190 polling = true
191 const dir = home + '/.local/share/clawd/signals'
192 try {
193 const list = await $.fs.list(dir)
194 const seen = new Set()
195 for (const ent of list) {
196 if (ent.kind !== 'file' || !ent.name.endsWith('.json')) continue
197 const source = 'file:' + ent.name.slice(0, -5)
198 seen.add(source)
199 if (fileMtime.get(source) === ent.mtimeMs) continue
200 try {
201 const j = JSON.parse(await $.fs.read(dir + '/' + ent.name))
202 fileMtime.set(source, ent.mtimeMs)
203 const r = brain.signal({ ...j, source }, ent.mtimeMs)
204 if (!r.ok) stat.errors = stat.errors.slice(-4).concat([ent.name + ': ' + r.error])
205 } catch (err) {
206 fileMtime.delete(source) // half-written or not JSON: try again next second
207 }
208 }
209 for (const source of [...fileMtime.keys()]) {
210 if (!seen.has(source)) { fileMtime.delete(source); brain.signal({ source, scene: null }, now()) }
211 }
212 } catch (err) {
213 // no signals directory: nobody sends file signals
214 }
215 polling = false
216}
217
218async function loadUserPacks($) {
219 if (!home) return
220 const dir = home + '/.config/clawd/pets'
221 let list = []
222 try { list = await $.fs.list(dir) } catch (err) { return }
223 for (const ent of list) {
224 if (ent.kind !== 'file' || !ent.name.endsWith('.json')) continue
225 try {
226 const r = registerPack(JSON.parse(await $.fs.read(dir + '/' + ent.name)), 'file')
227 if (!r.ok) packErrors[ent.name] = r.errors
228 } catch (err) {
229 packErrors[ent.name] = ['not JSON: ' + msg(err)]
230 }
231 }
232}
233
234// ---------- /clawd bench: each terminal renderer for four seconds, then back ----------
235function stepBench($, t) {
236 if (t - bench.at < 4000) return
237 const phase = bench.order[bench.i]
238 const secs = (t - bench.at) / 1000
239 const rate = (n) => Math.round((n / secs) * 10) / 10
240 const r = phase === 'client'
241 ? { framesPerSec: stat.client ? stat.client.computed : 0, sentPerSec: stat.client ? stat.client.fps : 0 }
242 : { framesPerSec: rate(stat.frames - bench.frames), sentPerSec: rate(stat.blits - bench.blits) }
243 if (phase === 'image' && imageOK === 'no') r.note = 'this terminal does not draw images'
244 bench.results[phase] = r
245 bench.i += 1
246 if (bench.i < bench.order.length) {
247 rendererOverride = bench.order[bench.i]
248 bench.at = t; bench.frames = stat.frames; bench.blits = stat.blits
249 if (rendererOverride === 'client') stat.client = null
250 } else {
251 lastBench = { results: bench.results, at: new Date(t).toISOString() }
252 bench = null; rendererOverride = ''
253 writeStatus($)
254 }
255 $.ui.invalidate('ui.render')
256}
257
258// ---------- self-check: ~/.local/share/clawd/status.json, every five seconds ----------
259async function writeStatus($) {
260 if (!home) return
261 const t = now()
262 const v = brain.pick(t)
263 try {
264 await $.fs.write(home + '/.local/share/clawd/status.json', JSON.stringify({
265 at: new Date(t).toISOString(), api: API, pet: stage ? stage.pack.id : '', renderer: lastRenderer, surface, columns: lastCols, imageOK,
266 state: v.state, by: v.by, band: { kind: band.kind, live: band.live, denied: band.denied },
267 signals: [...brain.signals.values()].map((s) => ({ source: s.source, scene: s.scene, priority: s.priority, expiresInS: Math.round((s.until - t) / 1000) })),
268 slots: brain.rows().map(([source, s]) => ({ source, rows: s.rows.length, action: s.action ? s.action.command : null })),
269 packs: [...packs.values()].map((p) => p.id + ' (' + p.source + ')'), packErrors, bench: lastBench, ...stat,
270 }, null, 1))
271 } catch (err) {
272 // cannot write: nothing to do
273 }
274}
275
276// ---------- the band's right column: other mods' rows; a line of speech replaces the bottom row's text ----------
277function runCommand($, command) {
278 $.command.run({ command, args: '' }).catch(() => {})
279}
280
281function rightColumn($, els, width, say) {
282 const { Box, Text, Button } = els
283 const lines = []
284 for (const [source, s] of brain.rows()) s.rows.forEach((spans, ri) => lines.push({ source, spans, action: ri === s.rows.length - 1 ? s.action : null }))
285 if (say) {
286 const spans = [[say, { color: 'cyan' }]]
287 if (lines.length) lines[lines.length - 1] = { ...lines[lines.length - 1], spans }
288 else lines.push({ source: '', spans, action: null })
289 }
290 return lines.map((l) => {
291 const children = [Text({ wrap: 'truncate-end', children: l.spans.map((x) => Text({ color: x[1].color, dimColor: x[1].dim, bold: x[1].bold, children: [x[0]] })) })]
292 if (l.action) {
293 const used = l.spans.reduce((n, x) => n + cells(x[0]), 0)
294 const command = l.action.command
295 children.push(Text({ children: [' '.repeat(Math.max(1, width - used - cells(l.action.label)))] }))
296 children.push(Button({ key: 'act-' + l.source, plain: true, dimColor: true, label: l.action.label, onPress: () => runCommand($, command) }))
297 }
298 return Box({ children })
299 })
300}
301
302function statusText() {
303 const t = now()
304 const v = brain.pick(t)
305 const pack = ensureStage().pack
306 const lines = [
307 pack.name + ' is ' + v.state + (v.by === 'activity' ? '' : ' (' + v.by + ')') + ' · renderer ' + lastRenderer + ' on ' + surface + (imageOK !== 'unknown' ? ' · images: ' + imageOK : '') + (stat.pokes ? ' · pokes ' + stat.pokes : ''),
308 'frames: ' + stat.framesPerSec + ' computed/s, ' + stat.fps + ' sent/s (a frame is sent only when it changed)' + (stat.client ? ' · client: ' + stat.client.computed + ' computed/s, ' + stat.client.fps + ' changed/s' : ''),
309 'signals: ' + ([...brain.signals.values()].map((s) => s.source + '=' + s.scene + '@' + s.priority).join(', ') || 'none'),
310 'rows from: ' + (brain.rows().map(([s]) => s).join(', ') || 'nobody'),
311 'pets: ' + [...packs.values()].map((p) => p.id).join(', ') + (Object.keys(packErrors).length ? ' · pack errors: ' + Object.keys(packErrors).join(', ') : ''),
312 ]
313 if (lastBench) lines.push('bench ' + lastBench.at.slice(11, 19) + ': ' + Object.entries(lastBench.results).map(([k, r]) => k + ' ' + r.framesPerSec + ' computed/s, ' + r.sentPerSec + ' sent/s' + (r.note ? ' (' + r.note + ')' : '')).join(' · '))
314 return lines.join('\n')
315}
316
317export function register(on, options) {
318 opts = options || {}
319 clk.dt = Math.max(8, Math.round(1000 / (Number(opts.fps) || 60)))
320
321 on('engine.create', async ($, e, next) => {
322 const built = await next(e)
323 return {
324 ...built,
325 clawd: {
326 signal: async (a) => brain.signal(a, now()),
327 emote: async (a) => brain.emote(a, now()),
328 slot: async (a) => brain.slot(a),
329 pack: async (def) => registerPack(def, 'plugin'),
330 layout: async () => {
331 const pack = ensureStage().pack
332 return { api: API, columns: Math.max(10, lastCols - pack.cols - GAP - 1), petColumns: pack.cols + GAP, surface, renderer: lastRenderer, pet: pack.id }
333 },
334 state: async () => { const v = brain.pick(now()); return { state: v.state, by: v.by, pet: ensureStage().pack.id } },
335 pets: async () => [...packs.values()].map((p) => ({ id: p.id, name: p.name, source: p.source, cols: p.cols, rows: p.rows, mode: p.mode })),
336 },
337 }
338 })
339
340 on('session.start', async ($, e, next) => {
341 // Timers first: whatever fails below, the pet still moves.
342 $.clock.every(clk.dt, () => animate($))
343 $.clock.every(1000, () => { second($) })
344 $.clock.every(5000, () => { writeStatus($); $.ui.invalidate('ui.render') })
345 await resync($)
346 brain.act.lastActive = now()
347 stat.startedAt = new Date(now()).toISOString()
348 try {
349 home = (await $.env.get('HOME')) || ''
350 const tp = (await $.env.get('TERM_PROGRAM')) || '', term = (await $.env.get('TERM')) || ''
351 termKey = (tp || term).slice(0, 40)
352 imageLikely = /kitty|ghostty/i.test(tp + ' ' + term) || !!(await $.env.get('KITTY_WINDOW_ID')) || !!(await $.env.get('GHOSTTY_RESOURCES_DIR'))
353 const known = termKey ? await $.store.get('image:' + termKey) : undefined
354 if (known === 'yes' || known === 'no') imageOK = known
355 } catch (err) {
356 stat.errors.push('environment: ' + msg(err))
357 }
358 try {
359 await $.command.register({ name: 'clawd', description: 'The pet: status; demo, bench, pets, pet <id>, renderer <auto|raster|client|image|text>' })
360 stat.registered.push('/clawd')
361 } catch (err) {
362 stat.errors.push('/clawd: ' + msg(err))
363 }
364 try {
365 if (home && !(await $.fs.exists(home + '/.local/share/clawd/signals/README.txt'))) await $.fs.write(home + '/.local/share/clawd/signals/README.txt', SIGNAL_README)
366 } catch (err) {
367 // no signals directory: the band works without it
368 }
369 await loadUserPacks($)
370 ensureStage()
371 return next(e)
372 })
373
374 on('tool.call', async ($, e, next) => {
375 brain.activity.tool(e.tool, now())
376 try {
377 return await next(e)
378 } finally {
379 brain.activity.toolDone(e.tool) // a permission prompt names the tool, not the call
380 }
381 })
382 on('classic.PermissionRequest', async ($, e, next) => {
383 brain.activity.permission(e.tool_name, now())
384 return next(e)
385 })
386 on('turn.start', async ($, e, next) => {
387 brain.activity.turnStart(now()) // a subagent's run raises none
388 return next(e)
389 })
390 on('turn.complete', async ($, e, next) => {
391 if (!e.agentId) brain.activity.turnEnd(now())
392 return next(e)
393 })
394 on('prompt.submit', async ($, e, next) => {
395 brain.activity.prompt(now())
396 return next(e)
397 })
398
399 // The client renderer's posts: a click on the pet, and its frame counts.
400 on('ui.message', async ($, e, next) => {
401 const d = e.data && typeof e.data === 'object' ? e.data : {}
402 if (d.stats && typeof d.stats === 'object') stat.client = { computed: Number(d.stats.computed) || 0, fps: Number(d.stats.fps) || 0 }
403 if (d.poke) {
404 stat.pokes += 1
405 const t = now()
406 brain.emote({ source: 'pointer', emote: POKES[(stat.pokes - 1) % POKES.length] }, t)
407 return { props: clientProps(brain.pick(t), t) }
408 }
409 return next(e)
410 })
411 on('ui.fault', async ($, e, next) => {
412 clientFault = true
413 stat.errors = stat.errors.slice(-4).concat(['client failed on ' + e.surface + ' (' + (e.phase || '') + ')'])
414 $.ui.invalidate('ui.render')
415 return next(e)
416 })
417
418 on('command.run', { command: 'clawd' }, async ($, e) => {
419 const [cmd, arg] = String(e.args || '').trim().split(/\s+/)
420 if (cmd === 'demo') { brain.demo(now()); return { text: 'Each of the ' + STATES.length + ' states for 2.5 s: ' + STATES.join(' → ') } }
421 if (cmd === 'pets') {
422 return { text: [...packs.values()].map((p) => p.id + ' · ' + p.name + ' (' + p.source + ', ' + p.cols + '×' + p.rows + ' ' + p.mode + ')' + (p.description ? ': ' + p.description : '')).join('\n') + (Object.keys(packErrors).length ? '\nrefused: ' + Object.entries(packErrors).map(([k, v]) => k + ': ' + v.join('; ')).join(' · ') : '') }
423 }
424 if (cmd === 'pet') {
425 if (!arg || !packs.has(arg)) return { text: 'Pets: ' + [...packs.keys()].join(', ') }
426 try {
427 const r = await $.config.set({ key: 'clawd.pet', value: arg })
428 if (r && r.deny) throw new Error(r.deny)
429 return { text: 'Pet: ' + arg + ' (saved in /config)' }
430 } catch (err) {
431 petOverride = arg; ensureStage(); $.ui.invalidate('ui.render')
432 return { text: 'Pet: ' + arg + ' for this session (the setting could not be written: ' + msg(err) + ')' }
433 }
434 }
435 if (cmd === 'renderer') {
436 if (!['auto', 'raster', 'client', 'image', 'text'].includes(arg)) return { text: 'Renderers: auto, raster, client, image, text' }
437 try {
438 const r = await $.config.set({ key: 'clawd.renderer', value: arg })
439 if (r && r.deny) throw new Error(r.deny)
440 return { text: 'Renderer: ' + arg + ' (saved in /config)' }
441 } catch (err) {
442 rendererOverride = arg; $.ui.invalidate('ui.render')
443 return { text: 'Renderer: ' + arg + ' for this session (' + msg(err) + ')' }
444 }
445 }
446 if (cmd === 'bench') {
447 if (surface !== 'terminal') return { text: 'The bench compares the terminal renderers; this band is on ' + surface + '.' }
448 const order = ['raster', 'client'].concat(imageOK === 'no' ? [] : ['image'])
449 bench = { order, i: 0, at: now(), frames: stat.frames, blits: stat.blits, results: {} }
450 rendererOverride = order[0]; $.ui.invalidate('ui.render')
451 return { text: 'Benchmarking ' + order.join(', ') + ', four seconds each; /clawd shows the result in ' + order.length * 4 + ' s.' }
452 }
453 return { text: statusText() }
454 })
455
456 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
457 if (e.props.hasSurvey) { band.live = false; return next(e) }
458 const theirs = await next(e)
459 stat.theirs = theirs && typeof theirs === 'object' ? String(theirs.type || 'tree') : String(theirs)
460 const els = $.ui.resolve(e)
461 const { Box, Text } = els
462 surface = e.surface
463 lastCols = e.props.bodyColumns || lastCols
464 const t = now()
465 brain.activity.working(!!e.props.isWorking, t)
466 const pack = ensureStage().pack
467 const v = brain.pick(t)
468 lastPickKey = pickKey(v)
469 const kind = chooseRenderer(e.surface, els)
470 lastRenderer = kind
471 band.live = false
472 let art
473 if (kind === 'raster' || kind === 'image') {
474 band.id = e.requestId; band.kind = kind; band.live = true; band.denied = 0; lastWords = null
475 const C = stage.draw(t, v)
476 if (kind === 'image') {
477 const img = C.rgba()
478 art = els.Image({ key: 'pet', source: { rgba: base64(img.data), width: img.width, height: img.height }, columns: pack.cols, rows: pack.rows, alt: ' ' })
479 } else {
480 art = els.Raster({ key: 'pet', columns: pack.cols, rows: pack.rows, cells: C.cells() })
481 }
482 } else if (kind === 'client') {
483 art = els.Client({ key: 'pet', module: './client.js', props: clientProps(v, t), width: pack.cols, height: pack.rows })
484 } else {
485 art = textArt(els, stage.draw(t, v))
486 }
487 const right = rightColumn($, els, Math.max(10, lastCols - pack.cols - GAP - 1), v.say)
488 if (theirs !== null && theirs !== undefined) right.push(theirs)
489 return Box({ flexDirection: 'row', children: [art, Text({ children: [' '.repeat(GAP)] }), Box({ flexDirection: 'column', children: right })] })
490 })
491}
492engine/brain.js 144 lines1// brain.js: what the pet does now. Pure state, no `$`, no clock: every call takes the time.
2// Inputs: what Claude is doing (activity), signals from mods and programs, emotes, slots (other mods' band rows).
3// Output: pick(now) → { state, info, say, by, emote }.
4// Priority: a permission prompt waiting on you (100) > signals by their priority (0–99; ties: the newest) >
5// Claude working (50) > nothing (idle, or asleep after a long quiet or late at night).
6
7import { STATES } from './pack.js'
8import { EMOTES, EMOTE_MS } from './fx.js'
9
10export const SCENES = ['idle', 'monitor', 'notes', 'confetti', 'rain', 'night', 'bell', 'ticket', 'laptop', 'dizzy']
11const SCENE_STATE = { idle: 'idle', notes: 'happy', confetti: 'cheer', rain: 'sad', night: 'sleep', bell: 'alert', ticket: 'needs', laptop: 'think', dizzy: 'dizzy' }
12
13const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
14const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))
15
16/** −2…2 → the monitor's mood. */
17export function moodState(m) {
18 if (m >= 1.2) return 'ecstatic'
19 if (m >= 0.35) return 'happy'
20 if (m <= -1.2) return 'sad'
21 if (m <= -0.35) return 'worried'
22 return 'calm'
23}
24
25function normSpan(sp) {
26 if (!Array.isArray(sp) || typeof sp[0] !== 'string') return null
27 const st = sp[1] && typeof sp[1] === 'object' ? sp[1] : {}
28 return [sp[0].slice(0, 400), { color: typeof st.color === 'string' ? st.color : undefined, dim: st.dim === true, bold: st.bold === true }]
29}
30
31/**
32 * options: { sleepAfterMs = 20 min, nightSleepAfterMs = 3 min, nightUntilHour = 7, hourOf = (ms) => local hour }
33 */
34export function createBrain(options) {
35 const o = { sleepAfterMs: 20 * 60e3, nightSleepAfterMs: 3 * 60e3, nightUntilHour: 7, hourOf: (ms) => new Date(ms).getHours(), ...(options || {}) }
36 const signals = new Map() // source → signal
37 const slots = new Map() // source → { rows, order, action, key }
38 let emote = null // the latest: { kind, t0, until, say, source }
39 let demoAt = 0
40 let changed = false // something the band draws changed (rows, a line of speech)
41 const act = { working: false, doing: null, doingAt: 0, needs: 0, needsTool: '', lastActive: 0 }
42
43 const brain = {
44 act,
45 signals,
46 slots,
47 /** True once after anything the band's text depends on changed. */
48 takeChanged() { const c = changed; changed = false; return c },
49
50 signal(a, at) {
51 if (!a || typeof a.source !== 'string' || !a.source) return { ok: false, error: 'source: a non-empty string' }
52 const source = a.source.slice(0, 64)
53 if (a.scene === null) { if (signals.delete(source)) changed = true; return { ok: true } }
54 if (!SCENES.includes(a.scene)) return { ok: false, error: 'scene: one of ' + SCENES.join(', ') + ', or null' }
55 const rec = {
56 source, scene: a.scene, mood: num(a.mood, 0),
57 bars: Array.isArray(a.bars) ? a.bars.slice(0, 3).map(Number).filter(Number.isFinite) : null,
58 tool: typeof a.tool === 'string' ? a.tool.slice(0, 32) : null,
59 say: typeof a.say === 'string' && a.say ? a.say.slice(0, 200) : null,
60 priority: clamp(Math.round(num(a.priority, 30)), 0, 99),
61 until: at + clamp(num(a.ttlMs, 15 * 60e3), 1000, 24 * 3600e3),
62 at,
63 }
64 // The same source repeating the same scene (a renewal, new bar heights) keeps its time, so two sources of one
65 // priority do not take turns winning each time one of them re-sends.
66 const old = signals.get(source)
67 const same = old && old.until > at && old.scene === rec.scene && old.say === rec.say && old.priority === rec.priority
68 if (same) rec.at = old.at
69 signals.set(source, rec)
70 if (!same) changed = true
71 return { ok: true }
72 },
73
74 emote(a, at) {
75 if (!a || !EMOTES.includes(a.emote)) return { ok: false, error: 'emote: one of ' + EMOTES.join(', ') }
76 emote = { kind: a.emote, t0: at, until: at + EMOTE_MS, say: typeof a.say === 'string' && a.say ? a.say.slice(0, 200) : null, source: typeof a.source === 'string' ? a.source.slice(0, 64) : '' }
77 changed = true
78 return { ok: true }
79 },
80
81 slot(a) {
82 if (!a || typeof a.source !== 'string' || !a.source) return { ok: false, error: 'source: a non-empty string' }
83 const source = a.source.slice(0, 64)
84 if (a.rows === null || a.rows === undefined) { if (slots.delete(source)) changed = true; return { ok: true } }
85 if (!Array.isArray(a.rows)) return { ok: false, error: 'rows: an array of rows, or null' }
86 const rows = a.rows.slice(0, 6).map((r) => (Array.isArray(r) ? r.map(normSpan).filter(Boolean) : []))
87 const action = a.action && typeof a.action.label === 'string' && typeof a.action.command === 'string'
88 ? { label: a.action.label.slice(0, 16), command: a.action.command.replace(/^\//, '').slice(0, 64) } : null
89 const rec = { rows, order: clamp(num(a.order, 50), 0, 999), action }
90 const key = JSON.stringify(rec)
91 const old = slots.get(source)
92 if (!old || old.key !== key) { slots.set(source, Object.assign(rec, { key })); changed = true }
93 return { ok: true }
94 },
95
96 /** The slots in drawing order. */
97 rows() { return [...slots.entries()].sort((a, b) => a[1].order - b[1].order || (a[0] < b[0] ? -1 : 1)) },
98
99 /** Drops what expired. */
100 sweep(now) {
101 for (const [k, s] of signals) if (s.until <= now) { signals.delete(k); changed = true }
102 if (emote && emote.until <= now) { if (emote.say) changed = true; emote = null }
103 },
104
105 demo(at) { demoAt = at; changed = true },
106
107 activity: {
108 tool(name, at) { act.doing = name; act.doingAt = at; act.lastActive = at },
109 toolDone(name) { if (act.needs && act.needsTool === name) { act.needs = 0; changed = true } },
110 permission(toolName, at) { act.needs = at || 1; act.needsTool = toolName || ''; act.lastActive = at; changed = true },
111 turnStart(at) { act.working = true; act.lastActive = at },
112 turnEnd(at) { act.working = false; act.doing = null; act.needs = 0; act.lastActive = at; changed = true },
113 prompt(at) { act.lastActive = at },
114 working(v, at) { if (v && !act.working) act.lastActive = at; act.working = !!v },
115 },
116
117 pick(now) {
118 const em = emote && now < emote.until ? { kind: emote.kind, t0: emote.t0 } : null
119 const emSay = emote && now < emote.until ? emote.say : null
120 if (demoAt) {
121 const k = Math.floor((now - demoAt) / 2500)
122 if (k >= 0 && k < STATES.length) return { state: STATES[k], info: { bars: [1.1, -0.6, 0.4], tool: 'Bash' }, say: 'demo: ' + STATES[k], by: 'demo', emote: em }
123 demoAt = 0
124 }
125 let best = null
126 const consider = (c) => { if (!best || c.pr > best.pr || (c.pr === best.pr && c.at > best.at)) best = c }
127 if (act.needs && now - act.needs < 10 * 60e3) consider({ state: 'needs', info: {}, pr: 100, at: act.needs, by: 'activity', say: null })
128 if (act.working) consider({ state: 'think', info: { tool: act.doing && now - act.doingAt < 60e3 ? act.doing : null }, pr: 50, at: act.doingAt, by: 'activity', say: null })
129 for (const s of signals.values()) {
130 if (s.until <= now) continue
131 const state = s.scene === 'monitor' ? moodState(s.mood) : SCENE_STATE[s.scene] || 'idle'
132 consider({ state, info: { bars: s.bars, tool: s.tool }, pr: s.priority, at: s.at, by: s.source, say: s.say })
133 }
134 if (!best) {
135 const quiet = now - act.lastActive
136 const sleepy = quiet > o.sleepAfterMs || (o.hourOf(now) < o.nightUntilHour && quiet > o.nightSleepAfterMs)
137 best = { state: sleepy ? 'sleep' : 'idle', info: {}, pr: 0, at: 0, by: 'activity', say: null }
138 }
139 return { state: best.state, info: best.info, say: emSay || best.say, by: best.by, emote: em }
140 },
141 }
142 return brain
143}
144engine/pack.js 161 lines1// pack.js: pet packs. A pack is a pet: its size, its pixel mode and how it looks in each state.
2// code pack { ..., render(C, t, state, info) } ships inside the plugin (pets/), can do anything
3// data pack { ..., palette, sprites, states } plain JSON: a file in ~/.config/clawd/pets/, or another
4// plugin's $.clawd.pack(); compiled here, never run as code
5// Both compile to one shape (Pack below), which a stage draws, one frame per call. docs/PACKS.md is the format.
6
7import { createCanvas, color, sprite as toSprite } from './pixel.js'
8import { FX, EMOTE_FX, EMOTE_MS, emoteOffset } from './fx.js'
9import BUILTIN from '../pets/index.js'
10
11export const FORMAT = 'cc-clawd/pack@1'
12export const STATES = ['idle', 'calm', 'happy', 'ecstatic', 'cheer', 'worried', 'sad', 'sleep', 'alert', 'needs', 'think', 'dizzy']
13/** Where a state a pack lacks falls back to, step by step, ending at idle. */
14export const FALLBACK = { calm: 'idle', happy: 'calm', ecstatic: 'happy', cheer: 'happy', worried: 'calm', sad: 'worried', sleep: 'idle', alert: 'idle', needs: 'alert', think: 'idle', dizzy: 'worried' }
15const FX_NAMES = new Set([...Object.keys(FX), 'desk']) // desk = monitor when there are bars, coffee otherwise
16const ID = /^[a-z0-9][a-z0-9-]{0,31}$/
17
18const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
19const pair = (v, d) => (Array.isArray(v) && isNum(v[0]) && isNum(v[1]) ? [Math.round(v[0]), Math.round(v[1])] : d)
20
21/** The state a pack draws for `state`: itself if the pack has it, else down the fallback chain, else idle. */
22export function resolveState(pack, state) {
23 let s = state
24 for (let i = 0; i < 12 && s && !pack.has(s); i++) s = (pack.fallback && pack.fallback[s]) || FALLBACK[s]
25 return s && pack.has(s) ? s : 'idle'
26}
27
28function common(def, errors) {
29 if (!def || typeof def !== 'object') { errors.push('a pack is an object'); return null }
30 if (def.format !== FORMAT) errors.push('format must be "' + FORMAT + '"')
31 if (typeof def.id !== 'string' || !ID.test(def.id)) errors.push('id: 1–32 of a–z, 0–9, "-", starting with a letter or digit')
32 const cols = def.cols, rows = def.rows
33 if (!Number.isInteger(cols) || cols < 4 || cols > 40) errors.push('cols: a whole number from 4 to 40')
34 if (!Number.isInteger(rows) || rows < 1 || rows > 4) errors.push('rows: a whole number from 1 to 4')
35 const mode = def.mode || 'quad'
36 if (mode !== 'quad' && mode !== 'half') errors.push('mode: "quad" or "half"')
37 const fallback = {}
38 if (def.fallback != null) {
39 if (typeof def.fallback !== 'object') errors.push('fallback: an object of state → state')
40 else for (const [k, v] of Object.entries(def.fallback)) {
41 if (!STATES.includes(k) || !STATES.includes(v)) errors.push('fallback: ' + k + ' → ' + v + ' names a state that does not exist')
42 else fallback[k] = v
43 }
44 }
45 return {
46 id: String(def.id), name: typeof def.name === 'string' ? def.name.slice(0, 40) : String(def.id),
47 author: typeof def.author === 'string' ? def.author.slice(0, 80) : '',
48 description: typeof def.description === 'string' ? def.description.slice(0, 200) : '',
49 cols, rows, mode, fallback, anchor: pair(def.anchor, [Math.max(0, (cols * (mode === 'half' ? 1 : 2)) >> 1), 0]),
50 look: pair(def.look, null), // where the eyes are (pixels): the client turns them to the pointer
51 }
52}
53
54function compileData(def, errors) {
55 const base = common(def, errors)
56 if (!base) return null
57 const w = base.cols * (base.mode === 'half' ? 1 : 2), h = base.rows * 2
58 const palette = {}
59 if (!def.palette || typeof def.palette !== 'object') errors.push('palette: an object of character → colour')
60 else for (const [ch, v] of Object.entries(def.palette)) {
61 if (ch.length !== 1 || ch === '.' || ch === ' ') { errors.push('palette: keys are single characters other than "." and " "'); continue }
62 try { palette[ch] = color(v) } catch (err) { errors.push('palette "' + ch + '": ' + err.message) }
63 }
64 const sprites = {}
65 if (!def.sprites || typeof def.sprites !== 'object') errors.push('sprites: an object of name → rows of characters')
66 else for (const [name, lines] of Object.entries(def.sprites)) {
67 if (!Array.isArray(lines) || !lines.every((l) => typeof l === 'string')) { errors.push('sprite ' + name + ': rows of characters'); continue }
68 if (lines.length > h || lines.some((l) => l.length > w)) errors.push('sprite ' + name + ': larger than the canvas (' + w + '×' + h + ' pixels)')
69 try { sprites[name] = toSprite(lines, palette) } catch (err) { errors.push('sprite ' + name + ': ' + err.message.replace('sprite: ', '')) }
70 }
71 const at = pair(def.at, [0, 0])
72 const states = {}
73 if (!def.states || typeof def.states !== 'object') errors.push('states: an object of state → { frames, fx? }')
74 else for (const [state, spec] of Object.entries(def.states)) {
75 if (!STATES.includes(state)) { errors.push('states: "' + state + '" is not one of ' + STATES.join(', ')); continue }
76 const frames = []
77 for (const f of (spec && spec.frames) || []) {
78 if (!Array.isArray(f) || typeof f[0] !== 'string' || !isNum(f[1]) || f[1] <= 0 || f[1] > 60000) { errors.push(state + ': a frame is [sprite, ms (1–60000), dx?, dy?]'); continue }
79 if (!sprites[f[0]]) { errors.push(state + ': no sprite "' + f[0] + '"'); continue }
80 frames.push({ sprite: sprites[f[0]], ms: f[1], dx: isNum(f[2]) ? Math.round(f[2]) : 0, dy: isNum(f[3]) ? Math.round(f[3]) : 0 })
81 }
82 if (!frames.length) { errors.push(state + ': needs at least one frame'); continue }
83 const fx = []
84 for (const e of (spec && spec.fx) || []) {
85 if (!Array.isArray(e) || !FX_NAMES.has(e[0]) || !isNum(e[1]) || !isNum(e[2])) { errors.push(state + ': an effect is [name, x, y, options?], name one of ' + [...FX_NAMES].join(', ')); continue }
86 fx.push({ name: e[0], x: Math.round(e[1]), y: Math.round(e[2]), opts: e[3] && typeof e[3] === 'object' ? e[3] : {} })
87 }
88 states[state] = { frames, fx, total: frames.reduce((n, f) => n + f.ms, 0) }
89 }
90 if (!states.idle && !errors.length) errors.push('states: "idle" is required (every other state falls back to it)')
91 return {
92 ...base,
93 has: (s) => !!states[s],
94 render(C, t, state, info) {
95 const st = states[state] || states.idle
96 let k = t % st.total, f = st.frames[0]
97 for (const fr of st.frames) { if (k < fr.ms) { f = fr; break } k -= fr.ms }
98 for (const e of st.fx) {
99 const name = e.name === 'desk' ? (info.bars && info.bars.length ? 'monitor' : 'coffee') : e.name
100 FX[name](C, t, e.x, e.y, { ...e.opts, bars: info.bars, tool: info.tool })
101 }
102 C.stamp(f.sprite, at[0] + f.dx + (info.dx || 0), at[1] + f.dy + (info.dy || 0), 5)
103 },
104 }
105}
106
107function compileCode(def, errors) {
108 const base = common(def, errors)
109 if (!base) return null
110 const has = new Set(Array.isArray(def.states) ? def.states.filter((s) => STATES.includes(s)) : ['idle'])
111 if (!has.has('idle')) errors.push('states: "idle" is required')
112 return { ...base, has: (s) => has.has(s), render: (C, t, state, info) => def.render(C, t, state, info) }
113}
114
115/**
116 * A pack definition → { pack, errors }. A definition with a render function is a code pack (built-ins only:
117 * functions do not cross plugins or files); anything else is a data pack. `pack` is null when there are errors.
118 * The compiled pack keeps its definition as `def`, so a data pack can be handed to a Client as plain props.
119 */
120export function compile(def, source) {
121 const errors = []
122 let pack = null
123 try {
124 pack = def && typeof def.render === 'function' ? compileCode(def, errors) : compileData(def, errors)
125 } catch (err) {
126 errors.push(String(err && err.message ? err.message : err))
127 }
128 if (errors.length || !pack) return { pack: null, errors: errors.slice(0, 12) }
129 return { pack: { ...pack, source: source || 'builtin', def: typeof def.render === 'function' ? null : def }, errors: [] }
130}
131
132/** The built-in packs, compiled once. */
133export const BUILTINS = new Map()
134for (const def of BUILTIN) {
135 const { pack, errors } = compile(def, 'builtin')
136 if (pack) BUILTINS.set(pack.id, pack)
137 else throw new Error('built-in pack ' + def.id + ': ' + errors.join('; '))
138}
139
140/**
141 * A stage draws one pack: draw(t, view) clears its canvas, has the pack draw `view.state` (resolved through the
142 * fallbacks) with `view.info`, then lays an active emote over it. view: { state, info?, emote?: { kind, t0 } }.
143 * Read the frame off `stage.canvas`: cells() for a Raster, spans() for text, rgba() for an Image.
144 */
145export function createStage(pack) {
146 const C = createCanvas(pack.cols, pack.rows, pack.mode)
147 return {
148 pack,
149 canvas: C,
150 draw(t, view) {
151 C.clear()
152 const info = { ...(view.info || {}) }
153 const em = view.emote && t - view.emote.t0 >= 0 && t - view.emote.t0 < EMOTE_MS ? view.emote : null
154 if (em) { const o = emoteOffset(em.kind, t - em.t0); info.dx = o.dx; info.dy = o.dy }
155 pack.render(C, t, resolveState(pack, view.state), info)
156 if (em && EMOTE_FX[em.kind]) FX[EMOTE_FX[em.kind]](C, t - em.t0, pack.anchor[0], pack.anchor[1])
157 return C
158 },
159 }
160}
161engine/pixel.js 264 lines1// pixel.js: a small pixel engine for terminal cells.
2// Pure, dependency-free and `$`-free: the same file runs in a Claude Code hooks module, in a Client
3// surface module and in Node (tools/preview.mjs).
4//
5// A canvas is a grid of character cells. Each cell holds 2×2 pixels in "quad" mode (quadrant blocks ▖▗▘▝▚▞▙▛▜▟)
6// or 1×2 pixels in "half" mode (half blocks ▀▄). Pixels carry a colour and a layer (z); glyphs (text, bar levels)
7// can be put in whole cells over the pixels. A frame is then encoded for whichever backend draws it:
8// cells() a Raster's `cells`: base64 of little-endian u32 [codePoint, fg, bg] triplets terminal
9// spans() per-row runs of [text, fg, bg] Text, Client
10// rgba() RGBA bytes, cw×ch image pixels per cell Image (kitty, Ghostty)
11//
12// Measured on Claude Code 2.1.289 (docs/ENGINE.md): a Raster cell must be one printable, width-1 BMP character;
13// the terminal paints colours at 4 bits per channel and keeps 1024 (fg, bg) pairs at once, so cells() quantizes;
14// a cell has one foreground and one background, so when a cell's pixels disagree the highest layer wins there.
15// rgba() has no such limits: every pixel keeps its own colour.
16
17import { glyphBits } from './font.js'
18
19export const PIXEL_VERSION = '2.0.0'
20export const DEF = 0x01000000 // "the terminal's own colour"
21
22const QUAD = [' ', '▗', '▖', '▄', '▝', '▐', '▞', '▟', '▘', '▚', '▌', '▙', '▀', '▜', '▛', '█'].map((c) => c.codePointAt(0))
23const UPPER = 0x2580, LOWER = 0x2584, FULL = 0x2588, SPACE = 0x20
24/** Eighth-height bars ▁…█, for level meters. */
25export const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'].map((c) => c.codePointAt(0))
26
27/** 0xRRGGBB → 4 bits per channel, what the terminal actually paints. DEF passes through. */
28export function q4(c) {
29 if (c === DEF) return DEF
30 const r = Math.round(((c >> 16) & 255) / 17) * 17
31 const g = Math.round(((c >> 8) & 255) / 17) * 17
32 const b = Math.round((c & 255) / 17) * 17
33 return (r << 16) | (g << 8) | b
34}
35
36/** Linear blend of two 0xRRGGBB colours, k in [0, 1]. */
37export function mix(a, b, k) {
38 k = k < 0 ? 0 : k > 1 ? 1 : k
39 const r = ((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * k
40 const g = ((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * k
41 const bl = (a & 255) + ((b & 255) - (a & 255)) * k
42 return (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(bl)
43}
44
45/** 0xRRGGBB → '#rrggbb'. */
46export const hex = (c) => '#' + (c & 0xffffff).toString(16).padStart(6, '0')
47
48/** '#rgb', '#rrggbb' or a number → 0xRRGGBB; throws on anything else. */
49export function color(v) {
50 if (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 0xffffff) return v
51 if (typeof v === 'string') {
52 const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v.trim())
53 if (m) {
54 const s = m[1].length === 3 ? m[1].split('').map((ch) => ch + ch).join('') : m[1]
55 return parseInt(s, 16)
56 }
57 }
58 throw new Error('not a colour: ' + JSON.stringify(v))
59}
60
61const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
62/** Standard padded base64 (the engine's toBase64 when it has one). */
63export function base64(u8) {
64 if (typeof u8.toBase64 === 'function') return u8.toBase64()
65 let s = ''
66 for (let i = 0; i < u8.length; i += 3) {
67 const n = (u8[i] << 16) | ((u8[i + 1] || 0) << 8) | (u8[i + 2] || 0)
68 s += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < u8.length ? B64[(n >> 6) & 63] : '=') + (i + 2 < u8.length ? B64[n & 63] : '=')
69 }
70 return s
71}
72
73/**
74 * Character-art sprite: one string per row; '.' or ' ' is transparent, any other character is looked up in
75 * `palette` (a 0xRRGGBB number or '#rrggbb'). Returns [dx, dy, colour] triplets for canvas.stamp().
76 */
77export function sprite(lines, palette) {
78 const out = []
79 lines.forEach((line, dy) => {
80 for (let dx = 0; dx < line.length; dx++) {
81 const ch = line[dx]
82 if (ch === '.' || ch === ' ') continue
83 if (palette[ch] === undefined) throw new Error('sprite: the palette has no "' + ch + '"')
84 out.push([dx, dy, color(palette[ch])])
85 }
86 })
87 return out
88}
89
90/** How many terminal cells a string takes (CJK and full-width are 2). */
91export function cells(s) {
92 let n = 0
93 for (const ch of String(s)) {
94 const c = ch.codePointAt(0)
95 n += (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
96 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) ? 2 : 1
97 }
98 return n
99}
100
101/**
102 * A canvas of `cols`×`rows` cells: (2·cols)×(2·rows) pixels in 'quad' mode, cols×(2·rows) in 'half' mode.
103 * Buffers are allocated once and reused every frame.
104 * px(x, y, colour, z) one pixel; a higher z draws over a lower one (default 1)
105 * rect(x0, y0, x1, y1, c, z)
106 * bg(x0, y0, x1, y1, c) background colour behind a cell's pixels (per pixel; a cell takes its first)
107 * glyph(cx, cy, ch, c) a character in a whole cell, over the pixels (text, BARS levels)
108 * stamp(sprite, x, y, z)
109 * cellX(x), cellY(y) which cell a pixel coordinate falls in
110 * mem a scratch object effects may keep state in (one per canvas)
111 */
112export function createCanvas(cols, rows, mode = 'quad') {
113 if (mode !== 'quad' && mode !== 'half') throw new Error('mode must be quad or half')
114 const sx = mode === 'half' ? 1 : 2, sy = 2
115 const w = cols * sx, h = rows * sy
116 const fgC = new Uint32Array(w * h), fgZ = new Uint8Array(w * h)
117 const bgC = new Uint32Array(w * h), bgOn = new Uint8Array(w * h)
118 const gCode = new Uint32Array(cols * rows), gFg = new Uint32Array(cols * rows)
119 const words = new Uint32Array(cols * rows * 3)
120 const bytes = new Uint8Array(words.buffer)
121 const ids = [0, 0, 0, 0]
122 let fresh = false
123
124 function encodeQuad() {
125 for (let cy = 0; cy < rows; cy++) {
126 for (let cx = 0; cx < cols; cx++) {
127 const ci = cy * cols + cx, o = ci * 3
128 ids[0] = (cy * 2) * w + cx * 2; ids[1] = ids[0] + 1; ids[2] = ids[0] + w; ids[3] = ids[2] + 1
129 let bg = DEF
130 for (let k = 0; k < 4; k++) if (bgOn[ids[k]]) { bg = q4(bgC[ids[k]]); break }
131 if (gCode[ci]) { words[o] = gCode[ci]; words[o + 1] = q4(gFg[ci]); words[o + 2] = bg; continue }
132 let top = 0, col = 0
133 for (let k = 0; k < 4; k++) if (fgZ[ids[k]] > top) { top = fgZ[ids[k]]; col = fgC[ids[k]] }
134 let bits = 0
135 if (top) for (let k = 0; k < 4; k++) if (fgZ[ids[k]] && fgC[ids[k]] === col) bits |= 8 >> k
136 words[o] = QUAD[bits]
137 words[o + 1] = bits ? q4(col) : DEF
138 words[o + 2] = bg
139 }
140 }
141 }
142
143 function encodeHalf() {
144 for (let cy = 0; cy < rows; cy++) {
145 for (let cx = 0; cx < cols; cx++) {
146 const ci = cy * cols + cx, o = ci * 3
147 const t = (cy * 2) * w + cx, b = t + w
148 const bgT = bgOn[t] ? q4(bgC[t]) : DEF, bgB = bgOn[b] ? q4(bgC[b]) : DEF
149 if (gCode[ci]) { words[o] = gCode[ci]; words[o + 1] = q4(gFg[ci]); words[o + 2] = bgT !== DEF ? bgT : bgB; continue }
150 const tz = fgZ[t], bz = fgZ[b]
151 if (!tz && !bz) { words[o] = SPACE; words[o + 1] = DEF; words[o + 2] = bgT !== DEF ? bgT : bgB }
152 else if (tz && !bz) { words[o] = UPPER; words[o + 1] = q4(fgC[t]); words[o + 2] = bgB }
153 else if (!tz && bz) { words[o] = LOWER; words[o + 1] = q4(fgC[b]); words[o + 2] = bgT }
154 else {
155 const a = q4(fgC[t]), d = q4(fgC[b])
156 if (a === d) { words[o] = FULL; words[o + 1] = a; words[o + 2] = DEF }
157 else { words[o] = UPPER; words[o + 1] = a; words[o + 2] = d }
158 }
159 }
160 }
161 }
162
163 const c = {
164 cols, rows, mode, w, h, sx, sy,
165 mem: {},
166 cellX: (x) => Math.floor(x / sx),
167 cellY: (y) => Math.floor(y / sy),
168 clear() { fgZ.fill(0); bgOn.fill(0); gCode.fill(0); fresh = false },
169 px(x, y, col, z) {
170 x = Math.round(x); y = Math.round(y)
171 if (x < 0 || y < 0 || x >= w || y >= h) return
172 const i = y * w + x, zz = z || 1
173 if (zz >= fgZ[i]) { fgC[i] = col; fgZ[i] = zz; fresh = false }
174 },
175 rect(x0, y0, x1, y1, col, z) { for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) c.px(x, y, col, z) },
176 bg(x0, y0, x1, y1, col) {
177 for (let y = Math.max(0, y0); y <= Math.min(h - 1, y1); y++) {
178 for (let x = Math.max(0, x0); x <= Math.min(w - 1, x1); x++) { bgC[y * w + x] = col; bgOn[y * w + x] = 1 }
179 }
180 fresh = false
181 },
182 glyph(cx, cy, ch, col) {
183 if (cx < 0 || cy < 0 || cx >= cols || cy >= rows) return
184 gCode[cy * cols + cx] = typeof ch === 'number' ? ch : ch.codePointAt(0)
185 gFg[cy * cols + cx] = col
186 fresh = false
187 },
188 stamp(spr, x, y, z) { for (let k = 0; k < spr.length; k++) c.px(x + spr[k][0], y + spr[k][1], spr[k][2], z) },
189
190 /** The frame as [codePoint, fg, bg] words (quantized). Reused buffer: copy it to keep it. */
191 words() {
192 if (!fresh) { if (mode === 'half') encodeHalf(); else encodeQuad(); fresh = true }
193 return words
194 },
195 /** A Raster's `cells`. */
196 cells() { return base64(new Uint8Array(c.words().buffer)) },
197 /** Per-row runs [text, fg, bg] of equal colours; a blank cell's foreground counts as DEF. */
198 spans() {
199 const wd = c.words(), out = []
200 for (let cy = 0; cy < rows; cy++) {
201 const row = []
202 let text = '', fg = -1, bg = -1
203 for (let cx = 0; cx < cols; cx++) {
204 const o = (cy * cols + cx) * 3
205 const cp = wd[o], f = cp === SPACE ? DEF : wd[o + 1], b = wd[o + 2]
206 if (f !== fg || b !== bg) { if (text) row.push([text, fg, bg]); text = ''; fg = f; bg = b }
207 text += String.fromCodePoint(cp)
208 }
209 if (text) row.push([text, fg, bg])
210 out.push(row)
211 }
212 return out
213 },
214 /** Each row's characters (tests, debugging). */
215 text() {
216 const wd = c.words(), out = []
217 for (let cy = 0; cy < rows; cy++) {
218 let s = ''
219 for (let cx = 0; cx < cols; cx++) s += String.fromCodePoint(wd[(cy * cols + cx) * 3])
220 out.push(s)
221 }
222 return out
223 },
224 /**
225 * The frame as RGBA bytes, cw×ch image pixels per cell (default 8×16, a typical cell), unquantized: every
226 * pixel its own colour, glyphs drawn from a small pixel font, transparency where nothing is drawn.
227 */
228 rgba(cw = 8, ch = 16) {
229 const W = cols * cw, H = rows * ch
230 const out = new Uint8Array(W * H * 4)
231 const put = (x, y, col) => { const i = (y * W + x) * 4; out[i] = (col >> 16) & 255; out[i + 1] = (col >> 8) & 255; out[i + 2] = col & 255; out[i + 3] = 255 }
232 const box = (x0, y0, bw, bh, col) => { for (let y = y0; y < y0 + bh; y++) for (let x = x0; x < x0 + bw; x++) put(x, y, col) }
233 const pw = cw / sx, ph = ch / sy
234 for (let cy = 0; cy < rows; cy++) {
235 for (let cx = 0; cx < cols; cx++) {
236 const ci = cy * cols + cx
237 const X = cx * cw, Y = cy * ch
238 if (gCode[ci]) {
239 const p0 = (cy * sy) * w + cx * sx
240 if (bgOn[p0]) box(X, Y, cw, ch, bgC[p0])
241 const code = gCode[ci], col = gFg[ci]
242 if (code >= 0x2581 && code <= 0x2588) { const n = Math.round(((code - 0x2580) / 8) * ch); box(X, Y + ch - n, cw, n, col); continue }
243 const bits = glyphBits(code)
244 const s = Math.max(1, Math.floor(Math.min(cw / 4, ch / 7)))
245 const gx = X + Math.floor((cw - 3 * s) / 2), gy = Y + Math.floor((ch - 5 * s) / 2)
246 for (let r = 0; r < 5; r++) for (let q = 0; q < 3; q++) if (bits[r] & (4 >> q)) box(gx + q * s, gy + r * s, s, s, col)
247 continue
248 }
249 for (let py = 0; py < sy; py++) {
250 for (let px = 0; px < sx; px++) {
251 const i = (cy * sy + py) * w + cx * sx + px
252 const col = fgZ[i] ? fgC[i] : bgOn[i] ? bgC[i] : -1
253 if (col >= 0) box(Math.round(X + px * pw), Math.round(Y + py * ph), Math.round(pw), Math.round(ph), col)
254 }
255 }
256 }
257 }
258 return { data: out, width: W, height: H }
259 },
260 }
261 c.encode = c.cells // v1 name
262 return c
263}
264engine/fx.js 199 lines1// fx.js: props and effects any pet can place, each a pure function of time.
2// Every effect draws relative to an origin (x, y) in pixels, the top-left of its region, so a pet of any size puts
3// it where it fits; glyph positions follow from the canvas's cellX/cellY, so quad and half canvases both work.
4// Natural sizes (pixels, quad mode) are in FX_SIZES; data packs (pack.js) name effects from FX.
5// fx(C, t, x, y, o) C a canvas (pixel.js), t milliseconds, o the effect's options
6
7import { mix, sprite, BARS } from './pixel.js'
8
9export const PAL = {
10 orange: 0xd97757, orangeHi: 0xeb8a69, orangeDim: 0x9c5a44, gray: 0x8b929c, grayDark: 0x4a4f57, screen: 0x1b2420, glow: 0x2f4a3c,
11 green: 0x5cc27a, red: 0xe5605a, yellow: 0xf2c14e, cyan: 0x5cc8e8, blue: 0x6f9cf5, pink: 0xf28bb3, purple: 0xb48ee0,
12 white: 0xeeeeee, moon: 0xf3e3a1, paper: 0xf1ead8, ink: 0x3a3a3a, cloud: 0x9aa3ad, mug: 0xd9d4cc, coffee: 0x6b4a2f,
13}
14
15const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
16
17// A screen's sweeping glow: five cells from x+4, pixel rows y+2..y+3.
18function glow(C, t, x, y, period) {
19 const s = ((t / period) % 1) * 7 - 1
20 const c0 = C.cellX(x + 4)
21 for (let k = 0; k < 5; k++) {
22 const p = (c0 + k) * C.sx
23 C.bg(p, y + 2, p + C.sx - 1, y + 3, mix(PAL.screen, PAL.glow, Math.max(0, 1 - Math.abs(k - s) / 1.2) * 0.9))
24 }
25}
26
27/** A monitor whose three bars show o.bars (percent; ±2 fills a bar; green up, red down), eased over ~200 ms. */
28function monitor(C, t, x, y, o) {
29 const g = PAL.gray
30 C.rect(x + 3, y + 1, x + 14, y + 1, g, 2); C.rect(x + 3, y + 4, x + 14, y + 4, g, 2)
31 for (const r of [2, 3]) { C.px(x + 3, y + r, g, 2); C.px(x + 14, y + r, g, 2) }
32 C.px(x + 8, y + 5, g, 2); C.px(x + 9, y + 5, g, 2)
33 glow(C, t, x, y, 2600)
34 const m = C.mem.monitor || (C.mem.monitor = { shown: [0, 0, 0], t: -1 })
35 const dt = m.t < 0 ? 16.7 : clamp(t - m.t, 0, 500)
36 m.t = t
37 const k = 1 - Math.exp(-dt / 200)
38 const bars = (o && o.bars) || []
39 ;[x + 4, x + 8, x + 12].forEach((bx, i) => {
40 const v = bars[i] || 0
41 m.shown[i] += (v - m.shown[i]) * k
42 const s = m.shown[i]
43 const wiggle = Math.sin(t / 230 + i * 1.9) > 0.92 ? 1 : 0
44 const lvl = clamp(Math.round((Math.abs(s) / 2) * 7) + wiggle, 0, 7)
45 C.glyph(C.cellX(bx), C.cellY(y + 2), BARS[lvl], Math.abs(s) < 0.03 ? PAL.grayDark : s > 0 ? PAL.green : PAL.red)
46 })
47 C.glyph(C.cellX(x + 10), C.cellY(y + 2), '.', mix(PAL.screen, PAL.cyan, (Math.sin(t / 160) + 1) / 2))
48}
49
50const MUG = sprite(['mmmmm.', 'mcccmh', 'mmmmmh', '.mmm..'], { m: PAL.mug, c: PAL.coffee, h: PAL.mug })
51/** A steaming mug. */
52function coffee(C, t, x, y) {
53 C.stamp(MUG, x + 6, y + 2, 2)
54 for (let i = 0; i < 2; i++) {
55 const k = ((t / 1500) + i / 2) % 1
56 C.px(x + 7 + i * 2 + Math.round(Math.sin(t / 300 + i * 2) * 0.6), y + 1 - k * 2.2, mix(PAL.gray, PAL.grayDark, k), 2)
57 }
58}
59
60/** Falling confetti over a w×h region (default 16×6), o.n pieces. */
61function confetti(C, t, x, y, o) {
62 const n = (o && o.n) || 12, w = (o && o.w) || 16, h = (o && o.h) || 6
63 const colors = [PAL.yellow, PAL.pink, PAL.cyan, PAL.green, PAL.purple, PAL.white]
64 for (let i = 0; i < n; i++) {
65 const speed = 2.6 + (i % 5) * 0.8
66 C.px(x + ((i * 7) % w) + Math.sin(t / 280 + i) * 0.7, y + (((t / 1000) * speed + i * 0.83) % (h + 1)) - 1, colors[i % colors.length], 3)
67 }
68}
69
70/** A cloud with rain. */
71function rain(C, t, x, y) {
72 C.rect(x + 4, y + 1, x + 13, y + 1, PAL.cloud, 2)
73 for (const dx of [5, 6, 7, 9, 10, 11, 12]) C.px(x + dx, y, PAL.cloud, 2)
74 for (let i = 0; i < 7; i++) C.px(x + 4 + ((i * 3) % 10), y + 2 + (((t / 1000) * 7 + i * 0.57) % 4), PAL.blue, 3)
75}
76
77/** Moon, twinkling stars, and z's drifting up from cell (o.zx, o.zy), default the region's first cell, row +2. */
78function night(C, t, x, y, o) {
79 for (const [dy, dx] of [[0, 12], [0, 13], [1, 11], [2, 11], [3, 12], [3, 13]]) C.px(x + dx, y + dy, PAL.moon, 2)
80 for (const [dx, dy, k] of [[5, 0, 0], [8, 3, 1.7], [15, 1, 3.1], [3, 4, 4.4]]) C.px(x + dx, y + dy, mix(PAL.grayDark, PAL.white, (Math.sin(t / 520 + k) + 1) / 2), 2)
81 zzz(C, t, x, y, o)
82}
83
84/** z's drifting up and to the right, fading. */
85function zzz(C, t, x, y, o) {
86 const zx = o && o.zx != null ? o.zx : C.cellX(x), zy = o && o.zy != null ? o.zy : C.cellY(y) + 2
87 for (let i = 0; i < 3; i++) {
88 const k = ((t / 2600) + i / 3) % 1
89 C.glyph(zx + Math.floor(k * 3.2), zy - Math.floor(k * 3), k < 0.5 ? 'z' : 'Z', mix(PAL.gray, PAL.grayDark, k))
90 }
91}
92
93const BELL = sprite(['..yy..', '.yyyy.', '.yyyy.', 'yyyyyy'], { y: PAL.yellow })
94/** A ringing bell; '!' blinks at cell (o.bx, o.by), default the region's first cell. */
95function bell(C, t, x, y, o) {
96 const s = Math.round(Math.sin((t / 1000) * Math.PI * 2 * 2.2) * 1.2)
97 C.stamp(BELL, x + 6 + s, y, 3)
98 if (Math.floor(t / 90) % 2) for (const [dx, dy] of [[2, 1], [3, 1], [2, 2]]) C.px(x + 6 + s + dx, y + dy, 0xfff1b8, 4)
99 C.px(x + 9 - s, y + 4, PAL.orangeDim, 3)
100 if (s !== 0) { C.px(x + (s > 0 ? 13 : 4), y + 1, PAL.gray, 2); C.px(x + (s > 0 ? 14 : 3), y + 2, PAL.gray, 2) }
101 if (Math.floor(t / 250) % 2 === 0) C.glyph(o && o.bx != null ? o.bx : C.cellX(x), o && o.by != null ? o.by : C.cellY(y), '!', PAL.yellow)
102}
103
104/** A ticket with a blinking '?': waiting for the person. */
105function ticket(C, t, x, y) {
106 for (let r = 0; r <= 5; r++) {
107 for (let px = x + 4; px <= x + 13; px++) {
108 if ((r === 0 || r === 5) && (px === x + 4 || px === x + 13)) continue
109 C.px(px, y + r, mix(PAL.paper, 0xfffaf0, (Math.sin(t / 300 + px * 0.4) + 1) / 4), 2)
110 }
111 }
112 C.glyph(C.cellX(x + 8), C.cellY(y + 2), '?', mix(PAL.paper, PAL.red, (Math.sin(t / 180) + 1) / 2))
113 C.bg(x + 8, y + 2, x + 9, y + 3, PAL.paper)
114}
115
116export const SCREEN = { Read: '[==]', Grep: '?...', Glob: '?...', Edit: '+-~ ', Write: '+-~ ', NotebookEdit: '+-~ ', Bash: '>ls_', WebSearch: '@...', WebFetch: '@...', Agent: '&...', Task: '&...' }
117/** A laptop whose screen types what o.tool is doing. */
118function laptop(C, t, x, y, o) {
119 const g = PAL.gray
120 C.rect(x + 4, y + 1, x + 13, y + 1, g, 2); C.px(x + 3, y + 1, g, 2); C.px(x + 14, y + 1, g, 2)
121 for (const r of [2, 3]) { C.px(x + 3, y + r, g, 2); C.px(x + 14, y + r, g, 2) }
122 C.rect(x + 1, y + 4, x + 15, y + 4, g, 2)
123 glow(C, t, x, y, 1800)
124 const text = SCREEN[o && o.tool] || '... '
125 const n = 1 + (Math.floor(t / 170) % 5)
126 for (let i = 0; i < 4; i++) {
127 let ch = i < n ? text[i] : ' '
128 if (i === n && Math.floor(t / 350) % 2 === 0) ch = '_'
129 if (ch !== ' ') C.glyph(C.cellX(x + 4) + i, C.cellY(y + 2), ch, i === 0 ? PAL.cyan : PAL.green)
130 }
131}
132
133/** Stars circling, with an 'x' in the middle: dizzy. */
134function orbit(C, t, x, y) {
135 for (let i = 0; i < 3; i++) {
136 const a = (t / 1100) * Math.PI * 2 + (i * Math.PI * 2) / 3
137 C.px(x + 8.5 + Math.cos(a) * 5, y + 2.5 + Math.sin(a) * 2.4, PAL.yellow, 3)
138 }
139 C.glyph(C.cellX(x + 8), C.cellY(y + 2), 'x', PAL.red)
140}
141
142/** A falling drop of sweat. */
143function sweat(C, t, x, y) {
144 const k = (t % 1400) / 1400
145 if (k < 0.85) C.px(x, y + 1 + k * 4.5, PAL.cyan, 3)
146}
147
148/** Two notes floating up. */
149function notes(C, t, x, y) {
150 for (let i = 0; i < 2; i++) {
151 const k = ((t / 1800) + i / 2) % 1
152 const px = x + i * 3 + Math.round(Math.sin(k * 6) * 0.6), py = y + 5 - k * 6
153 C.px(px, py, PAL.green, 3); C.px(px + 1, py - 1, PAL.green, 3); C.px(px + 1, py - 2, PAL.green, 3)
154 }
155}
156
157// ---------- emote overlays: t is the time since the emote began ----------
158const HEART = sprite(['.r.r.', 'rrrrr', '.rrr.', '..r..'], { r: PAL.pink })
159/** A heart rising from (x, y), fading out after a second. */
160function heart(C, t, x, y) {
161 if (t > 1300) return
162 C.stamp(HEART, x - 2, y - Math.round((t / 1300) * 3), 6)
163}
164/** Three twinkles around (x, y). */
165function sparkle(C, t, x, y) {
166 for (const [dx, dy, k] of [[-3, 0, 0], [2, -1, 1.3], [4, 2, 2.6]]) if (Math.sin(t / 90 + k) > 0) C.px(x + dx, y + dy, PAL.yellow, 6)
167}
168/** A blinking '!' in the cell over (x, y). */
169function bang(C, t, x, y) {
170 if (Math.floor(t / 160) % 2 === 0) C.glyph(C.cellX(x), C.cellY(y), '!', PAL.yellow)
171}
172/** A '?' in the cell over (x, y). */
173function question(C, t, x, y) {
174 C.glyph(C.cellX(x), C.cellY(y), '?', PAL.cyan)
175}
176
177export const FX = { monitor, coffee, confetti, rain, night, zzz, bell, ticket, laptop, orbit, sweat, notes, heart, sparkle, bang, question }
178
179/** Each effect's natural size in pixels (quad mode), for placing it. */
180export const FX_SIZES = {
181 monitor: [16, 6], coffee: [16, 6], confetti: [16, 6], rain: [16, 6], night: [16, 6], zzz: [8, 6], bell: [16, 6], ticket: [16, 6],
182 laptop: [16, 6], orbit: [16, 6], sweat: [1, 6], notes: [5, 6], heart: [5, 4], sparkle: [8, 4], bang: [2, 2], question: [2, 2],
183}
184
185/** Body motion of the emotes that move the pet: an offset {dx, dy} at time t since the emote began. */
186export function emoteOffset(kind, t) {
187 switch (kind) {
188 case 'jump': return { dx: 0, dy: t < 700 && (t % 350) < 160 ? -1 : 0 }
189 case 'nod': return { dx: 0, dy: (t % 450) > 120 && (t % 450) < 260 && t < 900 ? 1 : 0 }
190 case 'shake': return { dx: t < 600 ? (Math.floor(t / 60) % 2 ? 1 : -1) : 0, dy: 0 }
191 default: return { dx: 0, dy: 0 }
192 }
193}
194
195/** The overlay an emote draws over the pet, at the pack's anchor. */
196export const EMOTE_FX = { heart: 'heart', sparkle: 'sparkle', surprise: 'bang', question: 'question', jump: null, nod: null, shake: null }
197export const EMOTES = Object.keys(EMOTE_FX)
198export const EMOTE_MS = 1300
199hooks/client.js 85 lines1// client.js: the Client renderer, a surface module. It runs on the drawing surface itself (the terminal's render
2// loop, or the desktop app), draws the pet from its props on the surface's own frame clock (no frame goes through
3// the hooks module), and turns the pointer into reactions: the eyes follow it; a click posts a poke, which the host
4// answers with an emote in the next props.
5// Props (from register.js): { v, pack: built-in id or a data pack, fps, state, info, emote: { kind, age } | null }.
6
7import { BUILTINS, compile, createStage } from '../engine/pack.js'
8import { hex, DEF } from '../engine/pixel.js'
9
10const runtime = new WeakMap() // surface → this instance's clock, pointer and last frame
11const stages = new Map() // pack key → stage, shared by instances
12
13function stageFor(pack) {
14 const key = typeof pack === 'string' ? pack : JSON.stringify(pack)
15 let st = stages.get(key)
16 if (!st) {
17 const p = typeof pack === 'string' ? BUILTINS.get(pack) : compile(pack, 'plugin').pack
18 st = createStage(p || BUILTINS.get('clawd'))
19 stages.set(key, st)
20 }
21 return st
22}
23
24// The instance's time: wall time where the surface keeps it, its own ticks under a test's frame clock.
25const localNow = (R) => R.start + Math.max(R.ticks * R.dt, Date.now() - R.wall)
26
27/** Draws a frame into R; true when it differs from the last one. */
28function draw(R) {
29 const p = R.props || {}
30 const st = stageFor(p.pack || 'clawd')
31 const t = localNow(R)
32 const key = p.emote ? p.emote.kind + ':' + p.emote.age : ''
33 if (key !== R.emoteKey) { R.emoteKey = key; R.emote = p.emote ? { kind: p.emote.kind, t0: t - p.emote.age } : null }
34 const C = st.draw(t, { state: p.state || 'idle', info: { ...(p.info || {}), look: R.look }, emote: R.emote })
35 R.frames += 1
36 const wd = C.words()
37 if (R.last && R.last.length === wd.length) {
38 let same = true
39 for (let i = 0; i < wd.length; i++) if (wd[i] !== R.last[i]) { same = false; break }
40 if (same) return false
41 }
42 R.last = Uint32Array.from(wd)
43 R.rows = C.spans()
44 R.changed += 1
45 return true
46}
47
48function tick(surface, R) {
49 R.ticks += 1
50 if (draw(R)) { R.n += 1; surface.setState({ n: R.n }) }
51 const t = localNow(R), el = t - R.markAt
52 if (el >= 2000) {
53 surface.post({ stats: { computed: Math.round((R.frames * 10000) / el) / 10, fps: Math.round((R.changed * 10000) / el) / 10 } })
54 R.markAt = t; R.frames = 0; R.changed = 0
55 }
56}
57
58function pointer(surface, R, ev) {
59 if (ev.type === 'down') { surface.post({ poke: { x: ev.x, y: ev.y } }); return }
60 if (ev.type === 'leave') { R.look = null; return }
61 const pk = stageFor((R.props && R.props.pack) || 'clawd').pack
62 const eye = pk.look ? pk.look[0] / (pk.mode === 'half' ? 1 : 2) : pk.cols / 4 // the eyes' column, in cells
63 const x = ev.fine ? ev.fine.x : ev.x + 0.5, y = ev.fine ? ev.fine.y : ev.y + 0.5
64 R.look = y < 0.6 ? 'up' : x < eye - 1.5 ? 'left' : x > eye + 1.5 ? 'right' : 'front'
65}
66
67export default function Pet(props, surface) {
68 const { Box, Text } = surface.elements
69 let R = runtime.get(surface)
70 if (!R) {
71 const dt = Math.max(8, Math.round(1000 / (Number(props && props.fps) || 60)))
72 const wall = Date.now()
73 R = { props, dt, ticks: 0, start: wall, wall, look: null, emote: null, emoteKey: '', last: null, rows: null, frames: 0, changed: 0, markAt: wall, n: 0 }
74 runtime.set(surface, R)
75 surface.every(dt, () => tick(surface, R))
76 surface.onPointer((ev) => pointer(surface, R, ev))
77 }
78 R.props = props
79 if (!R.rows) draw(R)
80 return Box({
81 flexDirection: 'column',
82 children: R.rows.map((row) => Text({ children: row.map(([s, fg, bg]) => Text({ color: fg === DEF ? undefined : hex(fg), backgroundColor: bg === DEF ? undefined : hex(bg), children: [s] })) })),
83 })
84}
85pets/index.js 8 lines1// The pets that ship with cc-clawd. A new built-in is one file here plus one line below; a pet of your own
2// needs no change to the plugin (docs/PACKS.md).
3
4import clawd from './clawd.js'
5import slime from './slime.js'
6
7export default [clawd, slime]
8engine/font.js 31 lines1// font.js: a 3×5 pixel font for the glyphs effects put in cells, used where a frame is drawn as real pixels
2// (pixel.js rgba(), the Image backend). In the Raster and text backends a glyph is the terminal's own character.
3// Each glyph is five rows of three bits, the left column the high bit.
4
5const F = {
6 ' ': [0, 0, 0, 0, 0], '!': [2, 2, 2, 0, 2], '?': [7, 1, 2, 0, 2], '.': [0, 0, 0, 0, 2], ',': [0, 0, 0, 2, 4],
7 ':': [0, 2, 0, 2, 0], '-': [0, 0, 7, 0, 0], '_': [0, 0, 0, 0, 7], '=': [0, 7, 0, 7, 0], '+': [0, 2, 7, 2, 0],
8 '~': [0, 0, 3, 6, 0], '*': [5, 2, 7, 2, 5], '#': [5, 7, 5, 7, 5], '^': [2, 5, 0, 0, 0], '/': [1, 1, 2, 4, 4],
9 '\\': [4, 4, 2, 1, 1], '<': [1, 2, 4, 2, 1], '>': [4, 2, 1, 2, 4], '[': [6, 4, 4, 4, 6], ']': [3, 1, 1, 1, 3],
10 '(': [2, 4, 4, 4, 2], ')': [2, 1, 1, 1, 2], '@': [7, 5, 7, 4, 7], '&': [2, 5, 2, 5, 3], '%': [5, 1, 2, 4, 5],
11 '0': [7, 5, 5, 5, 7], '1': [2, 6, 2, 2, 7], '2': [7, 1, 7, 4, 7], '3': [7, 1, 3, 1, 7], '4': [5, 5, 7, 1, 1],
12 '5': [7, 4, 7, 1, 7], '6': [7, 4, 7, 5, 7], '7': [7, 1, 1, 1, 1], '8': [7, 5, 7, 5, 7], '9': [7, 5, 7, 1, 7],
13 a: [0, 6, 1, 7, 7], b: [4, 4, 7, 5, 7], c: [0, 7, 4, 4, 7], d: [1, 1, 7, 5, 7], e: [0, 7, 7, 4, 7],
14 f: [3, 4, 6, 4, 4], g: [0, 7, 5, 7, 1], h: [4, 4, 7, 5, 5], i: [2, 0, 2, 2, 2], j: [1, 0, 1, 5, 7],
15 k: [4, 5, 6, 5, 5], l: [2, 2, 2, 2, 3], m: [0, 5, 7, 5, 5], n: [0, 6, 5, 5, 5], o: [0, 2, 5, 5, 2],
16 p: [0, 7, 5, 7, 4], q: [0, 7, 5, 7, 1], r: [0, 7, 4, 4, 4], s: [0, 3, 4, 1, 6], t: [4, 7, 4, 4, 3],
17 u: [0, 5, 5, 5, 7], v: [0, 5, 5, 5, 2], w: [0, 5, 5, 7, 5], x: [0, 5, 2, 5, 0], y: [0, 5, 7, 1, 7], z: [0, 7, 2, 4, 7],
18 A: [2, 5, 7, 5, 5], B: [6, 5, 6, 5, 6], C: [7, 4, 4, 4, 7], D: [6, 5, 5, 5, 6], E: [7, 4, 6, 4, 7],
19 F: [7, 4, 6, 4, 4], G: [7, 4, 5, 5, 7], H: [5, 5, 7, 5, 5], I: [7, 2, 2, 2, 7], J: [1, 1, 1, 5, 7],
20 K: [5, 5, 6, 5, 5], L: [4, 4, 4, 4, 7], M: [5, 7, 7, 5, 5], N: [6, 5, 5, 5, 5], O: [7, 5, 5, 5, 7],
21 P: [7, 5, 7, 4, 4], Q: [7, 5, 5, 7, 1], R: [7, 5, 6, 5, 5], S: [7, 4, 7, 1, 7], T: [7, 2, 2, 2, 2],
22 U: [5, 5, 5, 5, 7], V: [5, 5, 5, 5, 2], W: [5, 5, 7, 7, 5], X: [5, 5, 2, 5, 5], Y: [5, 5, 2, 2, 2], Z: [7, 1, 2, 4, 7],
23}
24const BOX = [7, 5, 5, 5, 7]
25const CODES = new Map(Object.entries(F).map(([ch, bits]) => [ch.codePointAt(0), bits]))
26
27/** The 3×5 bitmap of a code point; an outlined box for one the font lacks. */
28export function glyphBits(code) {
29 return CODES.get(code) || BOX
30}
31pets/clawd.js 124 lines1// clawd.js: Clawd, the Claude Code crab, as a code pack. Fan art: Clawd is Anthropic's mascot; this project is not
2// affiliated with Anthropic.
3// 17 cells × 3 rows in quad mode = 34 × 6 pixels: the crab on the left (pixels 0–17, the official block art redrawn
4// pixel by pixel, its eyes holes in the body) and its props on the right (pixels 18–33, effects from fx.js).
5
6import { mix } from '../engine/pixel.js'
7import { FX, PAL } from '../engine/fx.js'
8
9const PROPS = 18 // where the props' region starts (pixels)
10
11// Eyes (holes in the body), arms (rest / up / down) and legs (stand / step / tuck)
12const EYES = {
13 front: [[1, 5], [1, 12]], right: [[1, 6], [1, 13]], left: [[1, 4], [1, 11]], up: [[0, 5], [0, 12]],
14 wide: [[0, 5], [1, 5], [0, 12], [1, 12]], shut: [],
15}
16const LOOKING = new Set(['idle', 'calm', 'happy', 'worried', 'think', 'needs', 'sad'])
17
18function crab(C, x0, y0, o) {
19 const holes = new Set((o.eyes || EYES.front).map(([r, c]) => r * 100 + c))
20 const at = (r, c) => { if (!holes.has(r * 100 + c)) C.px(x0 + c, y0 + r, o.color, 5) }
21 for (let c = 3; c <= 14; c++) { at(0, c); at(1, c); at(2, c); at(3, c) }
22 const arm = (side, pose) => {
23 const cs = side === 'L' ? [1, 2] : [15, 16]
24 if (pose === 'up') { cs.forEach((c) => at(1, c)); at(0, side === 'L' ? 1 : 16) }
25 else if (pose === 'down') cs.forEach((c) => at(3, c))
26 else cs.forEach((c) => at(2, c))
27 }
28 arm('L', o.armL); arm('R', o.armR)
29 for (const c of o.legs === 'step' ? [5, 7, 10, 12] : o.legs === 'tuck' ? [] : [4, 6, 11, 13]) at(4, c)
30}
31
32export default {
33 format: 'cc-clawd/pack@1',
34 id: 'clawd',
35 name: 'Clawd',
36 author: 'cc-clawd (fan art of the Claude Code mascot)',
37 description: 'The Claude Code crab at its desk: coffee when idle, a laptop while Claude works, a monitor of your numbers',
38 cols: 17, rows: 3, mode: 'quad',
39 anchor: [19, 0],
40 look: [8, 1],
41 states: ['idle', 'calm', 'happy', 'ecstatic', 'cheer', 'worried', 'sad', 'sleep', 'alert', 'needs', 'think', 'dizzy'],
42
43 /** info: { bars?, tool?, dx?, dy? (emote motion), look?: 'left' | 'right' | 'up' | 'front' (the pointer) } */
44 render(C, t, state, info) {
45 const bars = Array.isArray(info.bars) && info.bars.length ? info.bars : null
46 const pose = { eyes: EYES.right, armL: 'rest', armR: 'rest', legs: 'stand', color: 0 }
47 let x0 = 0, y0 = 1
48 const hop = (period, air) => ((t % period) / period < air ? 0 : 1)
49 const desk = () => (bars ? FX.monitor(C, t, PROPS, 0, { bars }) : FX.coffee(C, t, PROPS, 0))
50 switch (state) {
51 case 'idle': { // looks around, shuffles, stretches
52 const c = t % 12000
53 pose.eyes = c < 3000 ? EYES.front : c < 4200 ? EYES.left : c < 7000 ? EYES.front : c < 8200 ? EYES.right : EYES.front
54 if (c > 9000 && c < 9800) pose.legs = Math.floor(t / 140) % 2 ? 'step' : 'stand'
55 if (c > 10400 && c < 11200) pose.armR = 'up'
56 FX.coffee(C, t, PROPS, 0)
57 break
58 }
59 case 'calm': {
60 const c = t % 9000
61 pose.eyes = c > 6200 && c < 7300 ? EYES.front : EYES.right
62 if (c > 3000 && c < 3700) pose.legs = Math.floor(t / 140) % 2 ? 'step' : 'stand'
63 desk()
64 break
65 }
66 case 'happy':
67 pose.eyes = EYES.up; y0 = hop(700, 0.3)
68 pose.armL = Math.floor(t / 350) % 2 ? 'up' : 'rest'; pose.armR = Math.floor(t / 350) % 2 ? 'rest' : 'up'
69 desk(); FX.notes(C, t, PROPS, 0)
70 break
71 case 'ecstatic':
72 pose.eyes = EYES.up; y0 = hop(420, 0.45); pose.armL = 'up'; pose.armR = 'up'
73 pose.legs = y0 === 0 ? 'tuck' : 'stand'; FX.confetti(C, t, PROPS, 0, { n: 12 })
74 break
75 case 'cheer':
76 pose.eyes = EYES.up; y0 = hop(380, 0.45)
77 pose.armL = Math.floor(t / 190) % 2 ? 'up' : 'rest'; pose.armR = Math.floor(t / 190) % 2 ? 'rest' : 'up'
78 pose.legs = y0 === 0 ? 'tuck' : 'stand'; FX.confetti(C, t, PROPS, 0, { n: 14 })
79 if (Math.floor(t / 300) % 2 === 0) C.glyph(C.cellX(PROPS), 0, '!', PAL.yellow)
80 break
81 case 'worried':
82 pose.eyes = EYES.front
83 if (t % 2400 < 360) x0 = Math.floor(t / 60) % 2
84 desk(); FX.sweat(C, t, PROPS, 0)
85 break
86 case 'sad':
87 pose.eyes = EYES.front; pose.armL = 'down'; pose.armR = 'down'; FX.rain(C, t, PROPS, 0)
88 break
89 case 'sleep':
90 pose.eyes = EYES.shut; pose.legs = 'tuck'; pose.color = PAL.orangeDim
91 pose.armL = t % 4000 < 2000 ? 'rest' : 'down'; pose.armR = pose.armL
92 FX.night(C, t, PROPS, 0)
93 break
94 case 'alert':
95 pose.eyes = EYES.wide; y0 = hop(320, 0.4); pose.armL = 'up'; pose.armR = Math.floor(t / 160) % 2 ? 'up' : 'rest'
96 FX.bell(C, t, PROPS, 0)
97 break
98 case 'needs':
99 pose.eyes = Math.floor(t / 700) % 2 ? EYES.left : EYES.right; pose.armR = 'up'
100 FX.ticket(C, t, PROPS, 0)
101 break
102 case 'think':
103 pose.eyes = t % 3000 < 400 ? EYES.up : EYES.right
104 pose.armL = Math.floor(t / 120) % 2 ? 'down' : 'rest'; pose.armR = Math.floor(t / 120) % 2 ? 'rest' : 'down'
105 FX.laptop(C, t, PROPS, 0, { tool: info.tool })
106 break
107 case 'dizzy': {
108 const k = Math.floor(t / 110) % 4
109 pose.eyes = [[[0, 5], [1, 12]], [[1, 6], [0, 13]], [[1, 5], [0, 12]], [[0, 4], [1, 11]]][k]
110 x0 = Math.floor(t / 260) % 2
111 FX.orbit(C, t, PROPS, 0)
112 break
113 }
114 default:
115 FX.coffee(C, t, PROPS, 0)
116 }
117 const blink = t % 4200 < 130 && !['sleep', 'dizzy', 'cheer', 'alert', 'ecstatic'].includes(state)
118 if (blink) pose.eyes = EYES.shut
119 else if (info.look && EYES[info.look] && LOOKING.has(state)) pose.eyes = EYES[info.look]
120 if (!pose.color) pose.color = mix(PAL.orange, PAL.orangeHi, ((Math.sin(t / 650) + 1) / 2) * 0.6) // breathing
121 crab(C, x0 + (info.dx || 0), y0 + (info.dy || 0), pose)
122 },
123}
124pets/slime.js 97 lines1// slime.js: a data pack, the format anyone can write (docs/PACKS.md). No code: sprites are character art,
2// each state a loop of frames, props come from the shared effects. The same object, as JSON, can live in
3// ~/.config/clawd/pets/<id>.json or be registered by another plugin with $.clawd.pack().
4// One colour plus holes, like Clawd: in quad mode a cell holds two colours, so single-colour art stays crisp.
5
6export default {
7 format: 'cc-clawd/pack@1',
8 id: 'slime',
9 name: 'Slime',
10 author: 'cc-clawd',
11 description: 'A green slime that bounces when things go well and melts when they do not',
12 cols: 17, rows: 3, mode: 'quad',
13 palette: { '#': '#7bd88f' },
14 at: [1, 1],
15 anchor: [17, 0],
16 look: [7, 2],
17 sprites: {
18 rest: [
19 '....######....',
20 '..##########..',
21 '.###.####.###.',
22 '##############',
23 '##############',
24 ],
25 blink: [
26 '....######....',
27 '..##########..',
28 '.############.',
29 '##############',
30 '##############',
31 ],
32 squish: [
33 '..............',
34 '...########...',
35 '.####.##.####.',
36 '##############',
37 '##############',
38 ],
39 stretch: [
40 '.....####.....',
41 '...########...',
42 '...#.####.#...',
43 '..##########..',
44 '...########...',
45 ],
46 up: [
47 '....######....',
48 '..##.####.##..',
49 '.############.',
50 '##############',
51 '##############',
52 ],
53 low: [
54 '..............',
55 '..............',
56 '...########...',
57 '.####.##.####.',
58 '##############',
59 ],
60 flat: [
61 '..............',
62 '..............',
63 '...########...',
64 '.############.',
65 '##############',
66 ],
67 dizzyA: [
68 '....######....',
69 '..##########..',
70 '.##.#####.###.',
71 '##############',
72 '##############',
73 ],
74 dizzyB: [
75 '....######....',
76 '..##########..',
77 '.####.####.##.',
78 '##############',
79 '##############',
80 ],
81 },
82 states: {
83 idle: { frames: [['rest', 2200], ['blink', 130], ['rest', 1400], ['squish', 220], ['rest', 900]], fx: [['coffee', 18, 0]] },
84 calm: { frames: [['rest', 2600], ['blink', 130]], fx: [['desk', 18, 0]] },
85 happy: { frames: [['squish', 180], ['stretch', 200], ['up', 260]], fx: [['desk', 18, 0], ['notes', 15, 0]] },
86 ecstatic: { frames: [['squish', 120], ['stretch', 160, 0, -1], ['up', 140]], fx: [['confetti', 18, 0, { n: 12 }]] },
87 cheer: { frames: [['squish', 110], ['stretch', 150, 0, -1], ['up', 130]], fx: [['confetti', 18, 0, { n: 14 }], ['bang', 18, 0]] },
88 worried: { frames: [['rest', 900], ['rest', 120, 1, 0], ['rest', 120, -1, 0]], fx: [['desk', 18, 0], ['sweat', 16, 0]] },
89 sad: { frames: [['low', 1800], ['flat', 600]], fx: [['rain', 18, 0]] },
90 sleep: { frames: [['flat', 2000], ['low', 2000]], fx: [['night', 18, 0]] },
91 alert: { frames: [['stretch', 160], ['rest', 160]], fx: [['bell', 18, 0]] },
92 needs: { frames: [['up', 700], ['rest', 700]], fx: [['ticket', 18, 0]] },
93 think: { frames: [['rest', 240], ['squish', 120], ['rest', 240], ['blink', 110]], fx: [['laptop', 18, 0]] },
94 dizzy: { frames: [['dizzyA', 140], ['dizzyB', 140]], fx: [['orbit', 18, 0]] },
95 },
96}
97types/index.d.ts 139 lines1// The $.clawd contract, api 1. cc-clawd adds `clawd` to every plugin's `$` (engine.create): no dependency needed.
2// Each method is one event dispatch with one plain-data argument. Without cc-clawd installed `$.clawd` is absent and a
3// call throws, so call it inside try/catch and draw your own band when it fails:
4//
5// try { await $.clawd.slot({ source: 'mymod', rows }) } catch { /* no pet: draw the rows yourself */ }
6//
7// docs/API.md has the long form, docs/PACKS.md the pack format.
8
9/** What the pet can be doing. A pack draws each (or falls back toward idle: ecstatic → happy → calm → idle ...). */
10export type ClawdStateName = 'idle' | 'calm' | 'happy' | 'ecstatic' | 'cheer' | 'worried' | 'sad' | 'sleep' | 'alert' | 'needs' | 'think' | 'dizzy'
11
12/**
13 * A scene: what a signal asks the pet to do, mapped to a state. monitor takes `mood` (≥1.2 ecstatic, ≥0.35 happy,
14 * ≤−0.35 worried, ≤−1.2 sad, else calm) and draws `bars`; laptop types what `tool` does; the rest are fixed:
15 * notes happy, confetti cheer, rain sad, night sleep, bell alert, ticket needs, dizzy dizzy, idle idle.
16 */
17export type ClawdScene = 'idle' | 'monitor' | 'notes' | 'confetti' | 'rain' | 'night' | 'bell' | 'ticket' | 'laptop' | 'dizzy'
18
19/** A one-shot reaction over whatever the pet is doing, about 1.3 s. */
20export type ClawdEmote = 'heart' | 'sparkle' | 'surprise' | 'question' | 'jump' | 'nod' | 'shake'
21
22export type ClawdSignal = {
23 /** Who sends it; a new signal from the same source replaces its last. */
24 source: string
25 /** null withdraws this source's signal. */
26 scene: ClawdScene | null
27 /** −2…2: the pose for `monitor`. */
28 mood?: number
29 /** Up to three values (percent; ±2 fills a bar) for the monitor. */
30 bars?: number[]
31 /** For `laptop`: the tool on the screen (Read, Edit, Bash, …). */
32 tool?: string
33 /** A line of speech: replaces the text of the band's bottom row until the signal ends (that row's button stays). */
34 say?: string
35 /** 0–99, higher wins; ties go to the newest. The pet itself: a permission prompt 100, Claude working 50, idle 0.
36 * Suggested: lasting states (markets, a training run) 30–45, one-off events 70–90. Default 30. */
37 priority?: number
38 /** How long it lasts, default 15 minutes, at most 24 hours. */
39 ttlMs?: number
40}
41
42export type ClawdEmoteArgs = {
43 source?: string
44 emote: ClawdEmote
45 /** Said while the emote plays. */
46 say?: string
47}
48
49/** A run of text and its style: [text, { color?, dim?, bold? }]. color: a terminal colour name or #rrggbb. */
50export type ClawdSpan = [string, { color?: string; dim?: boolean; bold?: boolean }]
51
52export type ClawdSlot = {
53 /** Whose rows; the same source again replaces them. */
54 source: string
55 /** Rows of spans, at most six; null withdraws them. Lay them out to `layout().columns`. */
56 rows: ClawdSpan[][] | null
57 /** Top to bottom among several mods' rows, smaller first; default 50. */
58 order?: number
59 /** A button at the right end of the last row: pressing it runs /<command>. */
60 action?: { label: string; command: string }
61}
62
63export type ClawdLayout = {
64 /** The contract's version; this file describes 1. */
65 api: 1
66 /** Cells a slot row may take: the band's width less the pet and its gap. */
67 columns: number
68 /** Cells the pet and its gap take. */
69 petColumns: number
70 /** Where the band was last drawn: terminal, desktop, vscode or mobile. */
71 surface: string
72 /** How the pet is drawn there: raster, image, client or text. */
73 renderer: string
74 /** The active pet's id. */
75 pet: string
76}
77
78export type ClawdState = {
79 state: ClawdStateName
80 /** Who put it there: `activity` (the pet itself), `demo`, or a signal's source. */
81 by: string
82 pet: string
83}
84
85export type ClawdPetInfo = { id: string; name: string; source: 'builtin' | 'file' | 'plugin'; cols: number; rows: number; mode: 'quad' | 'half' }
86
87/**
88 * A data pack: a pet as plain JSON (docs/PACKS.md). Sprites are rows of characters ('.' or ' ' transparent, others
89 * looked up in `palette`); each state loops its frames and places effects; states it lacks fall back toward idle.
90 */
91export type ClawdPackDef = {
92 format: 'cc-clawd/pack@1'
93 /** 1–32 of a–z, 0–9, '-'. */
94 id: string
95 name?: string
96 author?: string
97 description?: string
98 /** Cells: 4–40 across, 1–4 down. */
99 cols: number
100 rows: number
101 /** quad: 2×2 pixels a cell, two colours a cell (crisp single-colour art); half: 1×2, every pixel its own colour. */
102 mode?: 'quad' | 'half'
103 palette: Record<string, string>
104 sprites: Record<string, string[]>
105 /** Where sprites are stamped, pixels. */
106 at?: [number, number]
107 /** Where emotes appear, pixels. */
108 anchor?: [number, number]
109 /** Where the eyes are, pixels (the client turns code packs' eyes toward the pointer). */
110 look?: [number, number]
111 states: Partial<Record<ClawdStateName, {
112 /** [sprite, ms, dx?, dy?], looped. */
113 frames: [string, number, number?, number?][]
114 /** [effect, x, y, options?]: monitor, coffee, desk (monitor with bars, else coffee), laptop, confetti, rain, night,
115 * zzz, bell, ticket, orbit, sweat, notes, heart, sparkle, bang, question. */
116 fx?: [string, number, number, Record<string, unknown>?][]
117 }>>
118 fallback?: Partial<Record<ClawdStateName, ClawdStateName>>
119}
120
121export type ClawdResult = { ok: boolean; error?: string }
122
123export type Clawd = {
124 signal: (args: ClawdSignal) => Promise<ClawdResult>
125 emote: (args: ClawdEmoteArgs) => Promise<ClawdResult>
126 slot: (args: ClawdSlot) => Promise<ClawdResult>
127 /** Registers a data pack for this session; `/clawd pet <id>` or the `pet` setting picks it. */
128 pack: (def: ClawdPackDef) => Promise<{ ok: boolean; id: string; errors: string[] }>
129 layout: () => Promise<ClawdLayout>
130 state: () => Promise<ClawdState>
131 pets: () => Promise<ClawdPetInfo[]>
132}
133
134declare module 'claude-code' {
135 interface EngineInterface {
136 clawd: Clawd
137 }
138}
139