SLOPSHOPPER

clawd

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…

newbandguardcommandprompttimer
★ 1v1.0.0MITupdated 2026-10-05yushiran/cc-clawd/plugins/clawd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /clawd ⎿ clawd: Clawd is idle · renderer raster on terminal ⎿ clawd: frames: 0 computed/s, 0 sent/s (a frame is sent only when it changed) ⎿ clawd: signals: none ⎿ clawd: rows from: nobody ⎿ clawd: pets: clawd, slime ▣ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ ⟨Claude Code's own drawing⟩
README

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

Clawd's twelve states

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

中文说明

Install

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

What it does

WhenThe pet
A permission prompt waits for youholds up a ticket with a blinking ? (highest priority)
Claude is workingtypes on a laptop whose screen shows what the tool does: >ls_ for Bash, [==] for Read, +-~ for Edit
Nothing is happeningsips coffee, looks around, stretches, blinks
Quiet for 20 minutes, or 3 minutes after midnightsleeps under the moon
A mod or program sends a signalwhatever 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:

  • pet: clawd, slime, or a pet of your own.
  • renderer: auto picks one per surface; see below.
  • fps: 30, 60 or 120 frames per second.

How it draws

SurfaceRendererWhat you get
Terminal (most)rasterCharacter pixels: quadrant blocks give each cell 2×2 pixels, repainted in place by $.ui.blit
kitty, GhosttyimageReal pixels through the terminal's graphics protocol. Detected automatically, and the result is remembered per terminal
Desktop appclientA surface module that animates on the app's own frame clock and reacts to the pointer
VS Code, mobiletextThe 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.

For mod authors: $.clawd

cc-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
}
MethodWhat 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.

For any program: signal files

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.

Make your own pet

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:

The slime, a data pack

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.

Develop

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:

PathWhat it is
plugins/clawd/engine/pixel.jsCanvas and encoders (Raster cells, text spans, RGBA)
plugins/clawd/engine/fx.jsProps and effects
plugins/clawd/engine/pack.jsPack compiler and stage
plugins/clawd/engine/brain.jsWhat the pet does: priorities, signals, emotes, sleep
plugins/clawd/hooks/register.jsThe host: Claude Code events, $.clawd, the band, renderers
plugins/clawd/hooks/client.jsThe 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.

License

CC BY-NC-SA 4.0 for the code. Clawd's design belongs to Anthropic.

Source 11 files
hooks/register.js 492 lines
1// 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}
492
engine/brain.js 144 lines
1// 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}
144
engine/pack.js 161 lines
1// 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}
161
engine/pixel.js 264 lines
1// 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}
264
engine/fx.js 199 lines
1// 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
199
hooks/client.js 85 lines
1// 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}
85
pets/index.js 8 lines
1// 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]
8
engine/font.js 31 lines
1// 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}
31
pets/clawd.js 124 lines
1// 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}
124
pets/slime.js 97 lines
1// 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}
97
types/index.d.ts 139 lines
1// 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