A pixel-art monster in a side pane that eats tokens and shows context, limits and activity at a glance


A pixel-art virtual pet that lives in a side pane of Claude Code and eats your tokens. One glance tells you how full your context is, how close you are to your limits, and what Claude is doing right now. See it live (the monsters on the site are real, type to feed them).
Inside Claude Code:
/plugin marketplace add ugglr/claude-token-monster
/plugin install token-monster@token-monster
Start a new session and the monster moves in. Needs Claude Code 2.1.287 or later; looks best in a terminal with 24-bit color.
Update: claude plugin marketplace update token-monster, then claude plugin update token-monster@token-monster. Remove: claude plugin uninstall token-monster@token-monster.
The pane opens by itself in a terminal 144 columns wide or more; at any width, run /token-monster. Focus it with ctrl+x tab, then:
m swap monster, c swap color, p pet, d diet, s sound on or off, ctrl+x x close.
Or by name: /token-monster slime green, /token-monster diet, /token-monster sound on. Monsters: cookie, slime, ghost, gremlin, crab.
/context does./compact). Sad and then grey if nothing feeds it.d).It also sleeps when nothing happens, waves when you come back, and does little antics while it waits.
Today you can only /clear or /compact the whole conversation. Press d to list the biggest tool results in your context, pick some (or all of one kind, like every Read), and press e. Your next /compact replaces just those with a short note and leaves everything else as it was.
Mods are not sandboxed, so read the code before you install any mod. This one reads:
/clear, /compact and resume), to tell when the prompt cache goes cold/context breakdown with the auto-compact point (a local estimate, no request)COLORTERM variable to pick colors/compactIt stores your monster and color, the tokens it has eaten, whether sound is on, and whether you've seen the sound tip. Sound is off by default: one soft chime when Claude starts waiting on you, played through afplay on macOS. No network calls.
Token counts are estimates (about four characters a token); the context and limit figures are Claude Code's own. A diet meal rebuilds the messages that held eaten results from their text, and skips results that sit next to an image or document. Built with Claude Code and reviewed by Dad. Develop with claude plugin validate . and claude plugin test ..
Cookie Monster is a trademark of Sesame Workshop, Tamagotchi of Bandai, Dragon Ball and Super Saiyan of their owners, Street Fighter of Capcom. Named only as inspiration; not affiliated.
MIT
hooks/register.tsx 1241 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, SessionContextUsage, Timer } from 'claude-code'
3
4import type { Belly, Limit, Look, Slice } from '../types'
5import { OLD_DIET, label, registerDiet } from './diet'
6import { PANE, PANE_OPEN, isDietWord, isSoundWord, kilo, tokens } from './format'
7import { levelOf, xpFor } from './grow'
8import {
9 AMBER,
10 BURP,
11 BURST,
12 COMBO_MS,
13 EGG,
14 MINUTE,
15 PALETTE,
16 PROMPT,
17 RED,
18 SAD,
19 STARVING,
20 TEXT,
21 THINKING,
22 WARDROBE,
23 choke,
24 cold,
25 createScene,
26 encode,
27 fedUp,
28 finishTurn,
29 hatch,
30 hit,
31 levelUp,
32 lively,
33 paint,
34 perk,
35 pet,
36 serve,
37 settle,
38 startTurn,
39 step,
40 toolColor,
41 typed,
42 wait,
43} from './paint'
44import type { Fuss, Scene } from './paint'
45import { registerSound } from './sound'
46
47const FPS = 10
48const BAR = 20
49
50const art = (s: string) => s.split('\n').slice(1, -1)
51
52// The drawing for surfaces without a Raster (desktop, IDE): E marks an eye.
53const ASCII: Record<string, { open: string[]; closed: string[] }> = {
54 cookie: {
55 open: art(String.raw`
56 .-. .-.
57 ( E )( E )
58/ \
59| .----. |
60| | | |
61| '----' |
62 '--------'
63`),
64 closed: art(String.raw`
65 .-. .-.
66 ( E )( E )
67/ \
68| |
69| ------ |
70| |
71 '--------'
72`),
73 },
74 slime: {
75 open: art(String.raw`
76 .~~~~.
77 ( E E )
78 | /\ |
79 | \/ |
80 ~~~~~~~~~~~~
81`),
82 closed: art(String.raw`
83 .~~~~.
84 ( E E )
85 | -- |
86 | |
87 ~~~~~~~~~~~~
88`),
89 },
90 ghost: {
91 open: art(String.raw`
92 .-----.
93 / E E \
94 | (_) |
95 | |
96 '^v^v^v^v^'
97`),
98 closed: art(String.raw`
99 .-----.
100 / E E \
101 | --- |
102 | |
103 '^v^v^v^v^'
104`),
105 },
106 gremlin: {
107 open: art(String.raw`
108 /\ /\
109 / '---' \
110 | E E |
111 | /vvv\ |
112 | \^^^/ |
113 '-------'
114`),
115 closed: art(String.raw`
116 /\ /\
117 / '---' \
118 | E E |
119 | vvvvv |
120 | |
121 '-------'
122`),
123 },
124 // After the CrabStack crab: (\/) (°,,°) (\/).
125 crab: {
126 open: art(String.raw`
127 (\/) (\/)
128 \ E E /
129 \( /\ )/
130 //(____)\\
131 // / \ \\
132`),
133 closed: art(String.raw`
134 (\/) (\/)
135 \ E E /
136 \( ,, )/
137 //(____)\\
138 // / \ \\
139`),
140 },
141}
142const NAMES = Object.keys(ASCII)
143const COLORS = Object.keys(PALETTE)
144const WINDOWS: Record<string, string> = { five_hour: 'session', seven_day: 'weekly', spend_limit: 'spend' }
145
146const belly = atom({ plugin: 'token-monster', key: 'belly' } as const, null)
147const look = atom({ plugin: 'token-monster', key: 'look' } as const, { monster: 'cookie', color: 'blue' })
148const now = atom({ plugin: 'token-monster', key: 'now' } as const, 0)
149const pantry = atom({ plugin: 'token-monster', key: 'pantry' } as const, [])
150const doing = atom({ plugin: 'token-monster', key: 'doing' } as const, '')
151// The diet's own values, read here for the readout; the state scan wants each atom in its file.
152const armed = atom({ plugin: 'token-monster', key: 'armed' } as const, [])
153const serving = atom({ plugin: 'token-monster', key: 'serving' } as const, 0)
154// The super mode level, 0 to 3: subagents running, plus one for three tools at once.
155const level = atom({ plugin: 'token-monster', key: 'level' } as const, 0)
156// The running combo for the readout: set at each hit, cleared when the chain's window
157// lapses or the turn ends. The sprite draws its own from the scene.
158const combo = atom({ plugin: 'token-monster', key: 'combo' } as const, 0)
159// The context window by category, for the stacked belly bar.
160const slices = atom({ plugin: 'token-monster', key: 'slices' } as const, [])
161// Which view the pane shows; the diet module draws 'diet' (see hooks/diet.tsx).
162const view = atom({ plugin: 'token-monster', key: 'view' } as const, 'monster')
163// What it says back for a few seconds when you pet it or come back, over its mood line.
164const chat = atom({ plugin: 'token-monster', key: 'chat' } as const, null)
165// Lifetime tokens eaten, as banked in the store; when this session started (ms), set
166// once a session, so a hot reload finds it and skips the egg; and whether it is hatching.
167const xp = atom({ plugin: 'token-monster', key: 'xp' } as const, 0)
168const born = atom({ plugin: 'token-monster', key: 'born' } as const, null)
169const egg = atom({ plugin: 'token-monster', key: 'hatching' } as const, false)
170// Whether the sound is on, for the s button's label; hooks/sound.tsx keeps it.
171const sound = atom({ plugin: 'token-monster', key: 'sound' } as const, false)
172// The prompt cache as the main loop's last response left it.
173const cache = atom({ plugin: 'token-monster', key: 'cache' } as const, null)
174// The call Claude waits on the person for, as the readout names it, how many when more than one wait, or null.
175const waiting = atom({ plugin: 'token-monster', key: 'waiting' } as const, null)
176
177// What the sprite animates from. The readout draws from the atoms above, save the
178// helper count, which only ever changes together with `level`.
179const scene: Scene = createScene({ monster: 'cookie', color: 'blue' })
180
181const after = (list: string[], at: string) => list[(list.indexOf(at) + 1) % list.length] ?? at
182const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
183const span = (ms: number) => {
184 const minutes = Math.floor(ms / MINUTE)
185 const hours = Math.floor(minutes / 60)
186
187 return hours >= 24 ? `${Math.floor(hours / 24)}d${hours % 24}h` : hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
188}
189const bar = (percent: number, width: number) => {
190 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
191
192 return ['█'.repeat(filled), '░'.repeat(width - filled)] as const
193}
194const barColor = (percent: number) => (percent >= RED ? 'red' : percent >= AMBER ? 'yellow' : 'green')
195
196// What a limit is called in the readout and the band.
197const limitName = (kind: string) => WINDOWS[kind] ?? kind.slice(0, 7)
198
199const onFire = (hits: number) => (hits >= 2 ? ` on fire x${hits}` : '')
200
201// What it says, for the pane and the band alike: super mode, unless the belly is
202// about to burst, whose line is the /compact warning.
203const voice = (full: Belly, at: number, power: number, helpers: number) =>
204 power === 0 || full.fill >= BURST
205 ? feeling(full, at)
206 : {
207 eye: 'O',
208 say: [
209 '',
210 helpers === 0 ? 'SUPER MODE! three tools at once' : `SUPER MODE! me and ${helpers} helper${helpers === 1 ? '' : 's'}`,
211 'SUPER MODE 2!! power rising',
212 'SUPER MODE 3!!! power maxed out',
213 ][power]!,
214 }
215
216// Its moods: a full belly beats everything, then hunger, then a burp, then how full it is.
217// They go by `fill`, the share of the auto-compact point: that is when the context
218// actually gets summarized, usually well before the window is full.
219const feeling = ({ fill: percent, fedAt, burpAt }: Belly, at: number) => {
220 const idle = at - fedAt
221
222 if (percent >= BURST) return { eye: '@', say: 'me gonna burst! /compact' }
223 if (idle >= STARVING) return { eye: '-', say: 'me starving... feed me tokens' }
224 if (idle >= SAD) return { eye: 'T', say: 'me sad. no tokens :(' }
225 if (burpAt !== null && at - burpAt < BURP) return { eye: '^', say: '*burp* me feel lighter' }
226 if (percent >= 75) return { eye: 'x', say: 'me so full...' }
227 if (percent >= 50) return { eye: 'O', say: 'om nom nom nom' }
228 if (percent >= 25) return { eye: 'o', say: 'nom nom' }
229 return { eye: 'o', say: 'ME WANT TOKENS!' }
230}
231
232const larder = (limits: readonly Limit[]) => {
233 const most = Math.max(0, ...limits.map(limit => limit.percentUsed))
234
235 if (limits.length === 0) return undefined
236 if (most >= RED) return 'pantry almost empty! me ration'
237 if (most >= AMBER) return 'pantry getting low...'
238 return 'pantry full. feast time!'
239}
240
241// The time, kept for the readout, and the local hour, which sets the sky.
242const clocked = async ($: EngineInterface) => {
243 const at = await $.clock.now()
244 const when = new Date(at)
245
246 scene.hour = when.getHours() + when.getMinutes() / 60
247 scene.at = await update($, now, () => at)
248
249 return at
250}
251
252// The window by category and the auto-compact point, from a local estimate
253// (`summary` sends no request): when the window changes (a new model), and at
254// most every 20 seconds as the context grows, which also picks up auto-compact
255// turned on or off in /config.
256let gauged: { window: number; at: number | null; readAt: number } = { window: 0, at: null, readAt: -Infinity }
257
258const gauge = async ($: EngineInterface, window: number, now: number) => {
259 if (gauged.window !== window || now - gauged.readAt > 20_000) {
260 try {
261 const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
262
263 gauged = {
264 window,
265 at: breakdown?.isAutoCompactEnabled === true ? (breakdown.autoCompactThreshold ?? null) : null,
266 readAt: now,
267 }
268
269 if (breakdown !== undefined) {
270 const rows: Slice[] = breakdown.categories
271 .filter(row => row.kind !== 'deferred' && row.tokens > 0)
272 .map(row => ({ name: row.name, tokens: row.tokens, kind: row.kind }))
273
274 await update($, slices, () => rows)
275 }
276 } catch {
277 // Unknown for now; the moods go by the window until a reading comes.
278 }
279 }
280
281 return gauged.window === window ? gauged.at : null
282}
283
284// One color per category, by what it is; anything new gets a neutral grey.
285const SLICE_COLORS: [RegExp, string, string][] = [
286 [/messages/i, '#ffd166', 'messages'],
287 [/system prompt/i, '#b59cff', 'system'],
288 [/mcp/i, '#22c7d6', 'mcp'],
289 [/tools/i, '#5cc8ff', 'tools'],
290 [/agent/i, '#ff6fd8', 'agents'],
291 [/memory/i, '#ffa94d', 'memory'],
292 [/skill/i, '#5cff8a', 'skills'],
293 [/command/i, '#c77dff', 'commands'],
294 [/buffer|reserve/i, '#6b6f8a', 'reserve'],
295]
296
297const sliceLook = (name: string) => {
298 const found = SLICE_COLORS.find(([match]) => match.test(name))
299
300 return found === undefined ? { color: '#9aa4c7', short: name.toLowerCase().split(' ')[0]! } : { color: found[1], short: found[2] }
301}
302
303// The belly bar split by category: whole cells by largest remainder, so the
304// parts always add up to the bar.
305const stack = (rows: readonly Slice[], window: number, width: number) => {
306 // The categories are estimates and can add up to more than the window: then they
307 // share the bar rather than run past it.
308 const sum = rows.filter(row => row.kind !== 'free').reduce((total, row) => total + row.tokens, 0)
309 const scale = (Math.min(1, window / Math.max(1, sum)) * width) / window
310 const parts = rows.filter(row => row.kind !== 'free').map(row => ({ row, exact: row.tokens * scale }))
311 const cells = parts.map(part => Math.floor(part.exact))
312 const room = Math.min(width, Math.round(parts.reduce((sum, part) => sum + part.exact, 0)))
313 const order = parts.map((part, i) => [part.exact - Math.floor(part.exact), i] as const).sort((a, b) => b[0] - a[0])
314
315 for (let k = 0; cells.reduce((a, b) => a + b, 0) < room && k < order.length; k++) cells[order[k]![1]]! += 1
316
317 const used = parts.flatMap((part, i) => (cells[i]! > 0 ? [{ ...sliceLook(part.row.name), kind: part.row.kind, cells: cells[i]! }] : []))
318 // The reserve sits at the far end of the bar, where auto-compact begins.
319 const reserve = used.filter(part => part.kind === 'buffer')
320 const content = used.filter(part => part.kind !== 'buffer')
321 const free = width - used.reduce((sum, part) => sum + part.cells, 0)
322
323 return { content, reserve, free: Math.max(0, free) }
324}
325
326const share = (tokens: number, window: number, compactAt: number | null) =>
327 Math.round((tokens / (compactAt ?? window)) * 100)
328
329const feed = async ($: EngineInterface, context: SessionContextUsage) => {
330 const at = await clocked($)
331 const compactAt = await gauge($, context.window, at)
332 scene.belly = await update($, belly, last => {
333 // Right after a compaction the window reports no fill: wait for a real one
334 // rather than count the whole compacted context as a meal.
335 if (context.tokens === undefined) {
336 return last === null
337 ? { percent: 0, tokens: 0, window: context.window, ate: 0, fedAt: at, burpAt: null, known: false, compactAt, fill: 0 }
338 : { ...last, window: context.window, ate: 0, known: false, compactAt }
339 }
340
341 const tokens = context.tokens
342 const ate = last?.known ? tokens - last.tokens : 0
343
344 return {
345 percent: context.percent ?? Math.round((tokens / context.window) * 100),
346 tokens,
347 window: context.window,
348 ate,
349 fedAt: last === null || ate > 0 ? at : last.fedAt,
350 burpAt: ate < 0 ? at : (last?.burpAt ?? null),
351 known: true,
352 compactAt,
353 fill: share(tokens, context.window, compactAt),
354 }
355 })
356}
357
358const burp = async ($: EngineInterface, tokensAfter: number | undefined) => {
359 const at = await clocked($)
360 scene.belly = await update($, belly, last =>
361 last === null
362 ? null
363 : tokensAfter === undefined
364 ? { ...last, ate: 0, known: false, burpAt: at }
365 : {
366 ...last,
367 tokens: tokensAfter,
368 percent: Math.round((tokensAfter / last.window) * 100),
369 fill: share(tokensAfter, last.window, last.compactAt),
370 ate: 0,
371 known: true,
372 burpAt: at,
373 },
374 )
375}
376
377const stock = async ($: EngineInterface, limits: Limit[]) => {
378 scene.pantry = await update($, pantry, () => limits)
379}
380
381const tick = async ($: EngineInterface) => {
382 await clocked($)
383 await rally($, true)
384}
385
386const act = async ($: EngineInterface, text: string) => {
387 await update($, doing, () => text)
388}
389
390// Agent tool calls in flight: a foreground subagent runs inside its call.
391let inflight = 0
392
393// The level last written, so an unchanged level never redraws the readout. -1 forces
394// the first write: the atom survives a hot reload, this variable does not.
395let written = -1
396
397// Background agents outlive their tool call, so minions come from the agent list too.
398// Never throws: a failed list or write leaves the last known level until the next try.
399const rally = async ($: EngineInterface, isListed = false) => {
400 try {
401 if (isListed) {
402 const running = (await $.agent.list()).filter(agent => agent.status === 'running').length
403
404 scene.minions = Math.max(inflight, running)
405 } else {
406 scene.minions = Math.max(inflight, scene.minions)
407 }
408
409 scene.level = Math.min(3, scene.minions + (scene.tools.size >= 3 ? 1 : 0))
410
411 if (scene.level !== written) written = await update($, level, () => scene.level)
412 } catch {
413 // Unknown this time; the next poll tries again.
414 }
415}
416
417// The prompt cache lapses 5 minutes after a request, or an hour on some sessions;
418// the API does not say which. 5 minutes, until a response after a longer gap still
419// reads most of its prompt from the cache: then an hour, until one reads less than
420// half (it lapsed but for a shared prefix, or the model changed).
421const CACHE_SHORT = 5 * MINUTE
422const CACHE_LONG = 60 * MINUTE
423
424// Wakes the clock the moment the cache lapses, so the bowl shows on time.
425let chill: Timer | undefined
426
427// A main loop response came, for a request sent at `sent`: the cache holds its prompt from then.
428const warm = async ($: EngineInterface, sent: number, usage: ModelUsage) => {
429 const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
430 const last = await read($, cache)
431 const isRead = usage.cache_read_input_tokens > prompt / 2
432 const isLong = isRead && last !== null && (last.ttl === CACHE_LONG || sent - last.at > CACHE_SHORT)
433 const kept = { at: sent, tokens: prompt + usage.output_tokens, ttl: isLong ? CACHE_LONG : CACHE_SHORT }
434
435 scene.cache = await update($, cache, () => kept)
436 chill?.cancel()
437 chill = $.clock.after(Math.max(0, sent + kept.ttl - (await clocked($))) + 1, () => void clocked($))
438}
439
440// A new conversation (/clear) or a compacted one: nothing of it is cached yet to go cold.
441const forget = async ($: EngineInterface) => {
442 chill?.cancel()
443 scene.cache = await update($, cache, () => null)
444 await clocked($)
445}
446
447// A single tool result this big, in tokens, makes it gag: rare in normal use.
448const HUGE = 20_000
449
450// It gags on `call`'s result, and names it with the way out: the diet.
451// The diet lists only the main conversation's results, so a subagent's gag names no way out.
452const gag = async ($: EngineInterface, call: Call, size: number) => {
453 const tail = ` ~${kilo(size)}!${call.loop === '' ? ' d: diet' : ''}`
454
455 choke(scene)
456 await reply($, 'x', `*gag* ${call.label.slice(0, 42 - tail.length)}${tail}`)
457}
458
459// Every tool call in flight, by its id: its tool, label and arguments, its loop ('' for
460// the main one, else the subagent's id), whether the person was asked, and whether the
461// auto mode classifier refused it.
462type Call = { tool: string; label: string; input: Record<string, unknown>; loop: string; asked: boolean; refused: boolean }
463
464const calls = new Map<string, Call>()
465
466// Whether a dialog's arguments are this call's.
467const same = (args: unknown, call: Call) =>
468 Object.entries((args ?? {}) as Record<string, unknown>).every(([key, value]) => JSON.stringify(value) === JSON.stringify(call.input[key]))
469
470// A call as fed up counts it: its tool and all its arguments, never its label, kept as
471// a short hash rather than the arguments themselves.
472const identity = ({ tool, input }: Call) => {
473 const { tool_use_id, agentId, consent, ...args } = input
474 let h = 5381
475
476 for (const char of `${tool} ${JSON.stringify(args)}`) h = (Math.imul(h, 33) ^ char.charCodeAt(0)) >>> 0
477
478 return h.toString(36)
479}
480
481// The engine's own words when a call is refused: by the person at its dialog (the main
482// loop's or a subagent's), a deny rule, the classifier or a safety check. No field says
483// so; the result's text is the firmest sign there is.
484const REFUSED = /^(The user doesn't want to|Permission (to use|for this) )/
485
486// The last call that failed in each loop: the same one failing again, with no success
487// between, makes it fed up. It only shows; the model is never told.
488const failed = new Map<string, string>()
489
490const tally = async ($: EngineInterface, call: Call, ran: { text?: string; isError?: boolean; deny?: unknown }) => {
491 // A refusal, at the dialog, by the classifier or by a hook, is someone's say: it
492 // neither counts as a failure nor clears one.
493 if (call.refused || ran.deny !== undefined || REFUSED.test(ran.text ?? '')) return
494 if (ran.isError !== true) {
495 failed.delete(call.loop)
496 return
497 }
498
499 if (failed.get(call.loop) === identity(call)) {
500 fedUp(scene)
501 await reply($, '-', `ugh. ${call.label.slice(0, 30)} failed again`)
502 }
503 failed.set(call.loop, identity(call))
504}
505
506// What a finished call fed it: a meal, maybe a gag, and a tally of failures.
507const eat = async ($: EngineInterface, call: Call, ran: { text?: string; isError?: boolean; deny?: unknown }) => {
508 const size = tokens(ran.text)
509
510 serve(scene, size, toolColor(call.tool))
511 if (size >= HUGE) await gag($, call, size)
512 await tally($, call, ran)
513}
514
515// The calls waiting on the person, oldest first, by id: a permission dialog or a
516// question each, the main loop's or a subagent's. The order dialogs queue in is the
517// engine's, so the readout names a call only when it is the one waiting.
518let asks: string[] = []
519
520const show = async ($: EngineInterface) => {
521 wait(scene, asks.length > 0)
522 await update($, waiting, () =>
523 asks.length === 0 ? null : asks.length === 1 ? (calls.get(asks[0]!)?.label ?? null) : `${asks.length} calls`,
524 )
525}
526
527const ask = async ($: EngineInterface, id: string) => {
528 asks = [...asks.filter(one => one !== id), id]
529 await show($)
530}
531
532// These calls are no longer waiting: answered, running, or ended.
533const answered = async ($: EngineInterface, ids: readonly string[]) => {
534 if (!asks.some(id => ids.includes(id))) return
535
536 asks = asks.filter(id => !ids.includes(id))
537 await show($)
538}
539
540// The first sign a call asked about runs: the person said yes.
541const runs = ($: EngineInterface, id: string) => answered($, [id])
542
543const CALLING = { eye: 'O', say: 'psst! me waiting for you' }
544const waitLine = (call: string) => `> waiting for you: ${call}`
545
546// Its answer to a pet, by how it took it; each pet says the next line.
547const FUSSES: Record<Fuss, { eye: string; lines: string[] }> = {
548 purr: { eye: '^', lines: ['hehe, that tickles', '*purr*', 'more pets pls'] },
549 wiggle: { eye: '^', lines: ['*purrrr* me like you', 'hehe! again!', 'best human'] },
550 spin: { eye: '^', lines: ['wheee! me LOVE you', '*happy spin*', 'best human EVER'] },
551 stir: { eye: '-', lines: ['mmm... *purr*... zzz', '*smiles in its sleep*'] },
552 plead: { eye: 'o', lines: ['pets nice... but me so hungry', 'feed me tokens? pleeease'] },
553}
554const SAYING = 4000
555
556let petted = 0
557let hush: Timer | undefined
558
559const reply = async ($: EngineInterface, eye: string, words: string) => {
560 await update($, chat, () => ({ eye, say: words }))
561 hush?.cancel()
562 hush = $.clock.after(SAYING, () => void update($, chat, () => null))
563}
564
565const stroke = async ($: EngineInterface) => {
566 const { eye, lines } = FUSSES[pet(scene, await $.clock.now())]
567
568 petted += 1
569 await reply($, eye, lines[petted % lines.length]!)
570}
571
572// The scene's tokens already in the store. They are banked every BANK_MS, and at
573// once when a main turn ends.
574const BANK_MS = 30_000
575let banked = 0
576
577const bank = async ($: EngineInterface) => {
578 // Claimed before any await, so the timer and another caller never bank the same tokens.
579 const fresh = scene.eaten - banked
580
581 if (fresh <= 0) return
582
583 banked = scene.eaten
584
585 let total: number
586
587 try {
588 // Read back first: another session may have banked its own meals meanwhile.
589 total = (Number(await $.store.get('xp')) || 0) + fresh
590 await $.store.set('xp', total)
591 } catch {
592 // Not banked; the next try takes these tokens too.
593 banked -= fresh
594 return
595 }
596
597 const was = scene.rank
598
599 await update($, xp, () => total)
600 levelUp(scene, levelOf(total))
601
602 if (was > 0 && scene.rank > was) {
603 const gift = WARDROBE.find(one => one.level === scene.rank)
604
605 $.ui.toast(`Token Monster grew to Lv ${scene.rank}${gift === undefined ? '!' : ` and got ${gift.part}!`}`)
606 }
607}
608
609// Its level from the store, and a new session (no birthday yet) hatches once the pane shows.
610const wake = async ($: EngineInterface) => {
611 try {
612 const total = Number(await $.store.get('xp')) || 0
613
614 await update($, xp, () => total)
615 // Where it stands, not a level-up.
616 scene.rank = levelOf(total)
617 } catch {
618 // No store: it stays the level it shows.
619 }
620
621 if ((await read($, born)) === null) {
622 const at = await $.clock.now()
623
624 await update($, born, () => at)
625 scene.eggDue = at
626 }
627}
628
629const restyle = async ($: EngineInterface, change: (current: Look) => Look) => {
630 scene.look = await update($, look, change)
631 await $.store.set('look', scene.look)
632}
633
634// The sprite: painted on every render, then blitted in place by the loop.
635let loop: Timer | undefined
636let lapse: Timer | undefined
637let pulse: Timer | undefined
638let saver: Timer | undefined
639let canvas = { columns: 0, rows: 0 }
640// Whether the terminal shows only 256 colors, so the sprite picks them itself.
641let is256 = false
642let painting = false
643
644const stop = () => {
645 loop?.cancel()
646 loop = undefined
647}
648
649const frame = async ($: EngineInterface) => {
650 if (canvas.columns === 0) return
651
652 step(scene, canvas.columns, canvas.rows * 2)
653
654 // The session's first paint hatches the egg; the readout follows it in and out.
655 if (scene.eggDue !== null && hatch(scene, await $.clock.now())) void update($, egg, () => true)
656 if (scene.tick - scene.eggAt === EGG) void update($, egg, () => false)
657
658 if (scene.tick % 20 === 0) void rally($, true)
659
660 // Idle, a third of the frames is plenty for blinking and breathing.
661 if (painting || (!lively(scene) && scene.tick % 3 !== 0)) return
662
663 painting = true
664
665 try {
666 const done = await $.ui.blit({
667 requestId: PANE,
668 key: 'sprite',
669 cells: encode(paint(scene, canvas.columns, canvas.rows * 2), canvas.columns, canvas.rows, is256),
670 })
671
672 if (done.deny !== undefined) stop()
673 } catch {
674 // Nothing to paint on any more; the next render starts the loop again.
675 stop()
676 } finally {
677 painting = false
678 }
679}
680
681export const register: Register = on => {
682 on('session.start', async ($, e, next) => {
683 const saved = (await $.store.get('look')) as Partial<Look> | undefined
684
685 if (saved?.monster && NAMES.includes(saved.monster) && saved.color && COLORS.includes(saved.color)) {
686 await update($, look, () => ({ monster: saved.monster as string, color: saved.color as string }))
687 }
688
689 scene.look = await read($, look)
690 scene.cache = await read($, cache)
691
692 // 24-bit color announces itself in COLORTERM; Apple Terminal, for one, does not have it.
693 const depth = (await $.env.get('COLORTERM')) ?? ''
694
695 is256 = !['truecolor', '24bit'].includes(depth.toLowerCase())
696
697 const usage = await $.session.usage()
698
699 await feed($, usage.context)
700 await stock($, usage.rateLimits)
701 pulse?.cancel()
702 pulse = $.clock.every(MINUTE, () => void tick($))
703 await wake($)
704 // A hot reload drops the timers that would clear these: start them clear.
705 await update($, egg, () => false)
706 await update($, chat, () => null)
707 await update($, combo, () => 0)
708 await update($, waiting, () => null)
709 saver?.cancel()
710 saver = $.clock.every(BANK_MS, () => void bank($))
711 await $.command.register({
712 name: 'token-monster',
713 description: 'Open the Token Monster pane, swap its monster and color, put it on a diet, or turn sound on or off',
714 argumentHint: `[${NAMES.join('|')}] [color] | diet | eat | sound on|off`,
715 })
716 await update($, view, () => 'monster')
717 await $.ui.close({ id: OLD_DIET })
718 // Unplaced (a narrow terminal), the pane waits and the band above the prompt shows instead.
719 void $.ui.open(PANE_OPEN)
720
721 return next(e)
722 })
723
724 on('command.run', { command: 'token-monster' }, async ($, e, next) => {
725 const words = e.args.toLowerCase().split(/\s+/).filter(Boolean)
726
727 if (isDietWord(e.args) || isSoundWord(e.args)) return next(e)
728
729 const unknown = words.filter(word => !NAMES.includes(word) && !COLORS.includes(word))
730
731 if (unknown.length > 0) {
732 return {
733 text: `Me not know ${unknown.join(', ')}. Monsters: ${NAMES.join(', ')}. Colors: ${COLORS.join(', ')}. Or: diet (or eat), sound on|off.`,
734 }
735 }
736
737 await restyle($, current => ({
738 monster: words.find(word => NAMES.includes(word)) ?? current.monster,
739 color: words.find(word => COLORS.includes(word)) ?? current.color,
740 }))
741 await update($, view, () => 'monster')
742 await $.ui.open({ ...PANE_OPEN, focus: true })
743
744 return { text: 'Token Monster is hungry.' }
745 })
746
747 on('session.measure', async ($, e, next) => {
748 if (e.changed.includes('context')) await feed($, e.context)
749 if (e.changed.includes('rateLimits')) await stock($, e.rateLimits)
750
751 return next(e)
752 })
753
754 // Outermost on compaction, so it sees the diet's answer as well as core's.
755 on('session.compact', async ($, e, next) => {
756 const done = await next(e)
757
758 // A hook that fails after next() has its answer dropped and core summarizes instead,
759 // which would turn a diet into a full compaction: the burp may fail, the meal may not.
760 try {
761 if (done.skip === undefined && e.agentId === undefined && e.trigger !== 'precompute') {
762 await burp($, done.tokensAfter)
763 await forget($)
764 }
765 } catch {}
766
767 return done
768 })
769
770 registerDiet(on)
771 registerSound(on, scene)
772
773 on('prompt.edit', async ($, e, next) => {
774 scene.typedMs = await $.clock.now()
775 // Back after a long quiet: it waves hello before it rubs its hands.
776 if (typed(scene)) await reply($, '^', 'oh hi! you back!')
777 serve(scene, 2, PROMPT, false)
778
779 return next(e)
780 })
781
782 on('prompt.submit', ($, e, next) => {
783 serve(scene, tokens(e.text), PROMPT)
784 perk(scene)
785
786 return next(e)
787 })
788
789 // The model's stream, main and subagents alike: every piece is tokens, and the
790 // monster eats them as they come. It only watches; every chunk goes on unchanged.
791 on('turn.step', async function* ($, e, next) {
792 const stream = next(e)
793 const names = new Map<number, string>()
794 // The main loop's requests are timed for the prompt cache.
795 const sent = e.agentId === undefined ? await $.clock.now() : null
796
797 for await (const chunk of stream) {
798 if (chunk.kind === 'text') serve(scene, tokens(chunk.text), TEXT)
799 if (chunk.kind === 'thinking') serve(scene, tokens(chunk.text), THINKING)
800 if (chunk.kind === 'tool') names.set(chunk.index, chunk.name)
801 if (chunk.kind === 'input') serve(scene, tokens(chunk.json), toolColor(names.get(chunk.index)))
802 if (chunk.kind === 'stop' && sent !== null && chunk.usage !== null) await warm($, sent, chunk.usage)
803
804 yield chunk
805 }
806 })
807
808 // turn.start carries no agent id: a turn starting while the main one runs is a
809 // subagent's, and must not reset the main turn's combo.
810 on('turn.start', async ($, e, next) => {
811 if (!scene.busy) {
812 startTurn(scene)
813 await update($, combo, () => 0)
814 await act($, 'thinking...')
815 }
816
817 await rally($, true)
818
819 return next(e)
820 })
821
822 // Subagents' turns complete too, carrying their agentId: only the main turn ends the meal.
823 on('turn.complete', async ($, e, next) => {
824 if (e.agentId === undefined) {
825 finishTurn(scene, e.isAborted)
826 await update($, combo, () => 0)
827 await act($, '')
828 await bank($)
829 }
830
831 // A loop's turn ended: none of its calls waits any more.
832 await answered($, asks.filter(id => calls.get(id)?.loop === (e.agentId ?? '')))
833 await rally($, true)
834
835 return next(e)
836 })
837
838 // A dialog up for the person: the ask path raises this hook once the mode's own
839 // decider (the auto mode classifier) has not settled it. It carries no call id, so
840 // it goes to the call in flight in its loop with the same tool and arguments. A
841 // settings hook that answered it asks no one.
842 on('classic.PermissionRequest', async ($, e, next) => {
843 const done = await next(e)
844 const found = [...calls].find(
845 ([, call]) => !call.asked && call.tool === e.tool_name && call.loop === (e.agent_id ?? '') && same(e.tool_input, call),
846 )
847
848 if (done.decision === undefined && found !== undefined) {
849 found[1].asked = true
850 await ask($, found[0])
851 }
852
853 return done
854 })
855
856 // The auto mode classifier refused a call.
857 on('classic.PermissionDenied', async ($, e, next) => {
858 const call = calls.get(e.tool_use_id)
859
860 if (call !== undefined) call.refused = true
861
862 return next(e)
863 })
864
865 // No event says a dialog was answered. Its call running says yes: a subagent being
866 // spawned, or the run-in-background hint a long command shows. A no ends the call.
867 on('agent.spawn', async ($, e, next) => {
868 await runs($, e.tool_use_id)
869
870 return next(e)
871 })
872
873 // Drawing may not write state: the wait ends on the next tick of the clock.
874 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
875 if (asks.includes(e.props.tool_use_id)) $.clock.after(0, () => void runs($, e.props.tool_use_id))
876
877 return next(e)
878 })
879
880 // A new conversation, or another resumed: its calls, waits, failures and cache start over.
881 on('classic.SessionStart', async ($, e, next) => {
882 if (e.source === 'clear' || e.source === 'resume') {
883 calls.clear()
884 asks = []
885 failed.clear()
886 await show($)
887 await forget($)
888 }
889
890 return next(e)
891 })
892
893 // Every tool result is a meal; the main loop's calls chain into combos and power up.
894 on('tool.call', async ($, e, next) => {
895 const id = e.tool_use_id
896 const isMain = e.agentId === undefined
897 const isAgent = isMain && e.tool === 'Agent'
898 const call: Call = {
899 tool: e.tool,
900 label: `${e.tool} ${label(e as unknown as Record<string, unknown>)}`.trim(),
901 input: e as unknown as Record<string, unknown>,
902 loop: e.agentId ?? '',
903 asked: false,
904 refused: false,
905 }
906
907 calls.set(id, call)
908
909 // Everything counted up is inside the try, so the finally always counts it down.
910 try {
911 // A question's whole run is the wait for the person's answer.
912 if (e.tool === 'AskUserQuestion') await ask($, id)
913 if (isMain) {
914 scene.tools.set(id, e.tool)
915 if (isAgent) inflight += 1
916 await rally($)
917 await act($, call.label)
918 }
919
920 const ran = await next(e)
921
922 await answered($, [id])
923 if (isMain) hit(scene, ran.isError === true, await $.clock.now())
924 await eat($, call, ran)
925 if (isMain) {
926 await update($, combo, () => scene.combo)
927 lapse?.cancel()
928 lapse = $.clock.after(COMBO_MS, () => void update($, combo, () => 0))
929 }
930
931 return ran
932 } finally {
933 await answered($, [id])
934 calls.delete(id)
935 if (isMain) {
936 scene.tools.delete(id)
937 if (isAgent) inflight -= 1
938 await rally($, isAgent)
939 await act($, [...scene.tools.values()].at(-1) ?? (scene.busy ? 'thinking...' : ''))
940 }
941 }
942 })
943
944 on('ui.close', ($, e, next) => {
945 if (e.id === PANE) {
946 stop()
947 // The band above the prompt takes over once the pane is gone.
948 $.ui.invalidate('ui.render')
949 }
950
951 return next(e)
952 })
953
954 // Where the pane does not show (a narrow terminal, or closed), one line above the
955 // prompt carries the readout. The band is shared: only one mod draws it at a time.
956 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
957 if (e.props.hasSurvey) return next(e)
958
959 const pane = (await $.ui.panes()).find(one => one.id === PANE)
960
961 if (pane?.isShown === true && pane.isPlaced) return next(e)
962
963 const { Box, Text } = $.ui.resolve(e)
964 const at = await read($, now)
965 const full = await read($, belly)
966
967 if (full === null) return next(e)
968
969 const limits = await read($, pantry)
970 const activity = await read($, doing)
971 const power = await read($, level)
972 const hits = await read($, combo)
973 const asked = await read($, waiting)
974 const tint = hex(PALETTE[(await read($, look)).color] ?? 0x3d7bff)
975 const { eye, say } = asked !== null ? CALLING : voice(full, at, power, scene.minions)
976 const [used, left] = bar(full.percent, 8)
977 const pieces = [
978 ...limits.map(limit => ` ${limitName(limit.kind)} ${Math.round(limit.percentUsed)}%`),
979 ' /token-monster',
980 ]
981 const head = `(${eye})(${eye}) ${asked !== null ? waitLine(asked) : activity !== '' ? `> ${activity}${onFire(hits)}` : say}`
982 const meter = ` ${used}${left} ${full.percent}%`
983 const room = e.props.bodyColumns - 2
984 // What fits: the face and words first, cut short if they must, then the gauges.
985 const words = head.slice(0, Math.max(10, room - meter.length))
986 const tail = pieces.filter((_, index) => words.length + meter.length + pieces.slice(0, index + 1).join('').length <= room)
987
988 return (
989 <Box paddingX={1}>
990 <Text color={tint} bold>
991 {words}
992 </Text>
993 <Text color={barColor(full.percent)}>{` ${used}`}</Text>
994 <Text dimColor>{left}</Text>
995 <Text>{` ${full.percent}%`}</Text>
996 <Text dimColor>{tail.join('')}</Text>
997 </Box>
998 )
999 })
1000
1001 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1002 const elements = $.ui.resolve(e)
1003 const { Box, Button, Text } = elements
1004 const at = await read($, now)
1005 const full = (await read($, belly)) ?? { percent: 0, tokens: 0, window: 0, ate: 0, fedAt: at, burpAt: null, known: false, compactAt: null, fill: 0 }
1006 const limits = await read($, pantry)
1007 const activity = await read($, doing)
1008 const dieting = (await read($, armed)).length
1009 const plate = await read($, serving)
1010 const { monster, color } = await read($, look)
1011 const power = await read($, level)
1012 const helpers = scene.minions
1013 const hits = await read($, combo)
1014 const eaten = await read($, xp)
1015 const rank = levelOf(eaten)
1016 const isLoud = await read($, sound)
1017 const asked = await read($, waiting)
1018 const kept = cold(scene) ? scene.cache : null
1019 // Waiting on the person outranks everything: that is what needs them now.
1020 // About to burst outranks super mode: that line is the /compact warning.
1021 // What it said back to a pet or a hello shows for a moment, over its mood; hatching, it cracks.
1022 const { eye, say } =
1023 asked !== null
1024 ? CALLING
1025 : ((await read($, chat)) ?? ((await read($, egg)) ? { eye: 'o', say: '*crack* ... *crack*' } : voice(full, at, power, helpers)))
1026 const chain = onFire(hits)
1027 const remark = larder(limits)
1028 const tint = hex(PALETTE[color] ?? 0x3d7bff)
1029 const pie = await read($, slices)
1030 // Biggest first; free space is the empty part of the bar, not a legend entry.
1031 // Categories sharing a color merge (two MCP rows are one "mcp"); the five biggest
1032 // show, then the reserve.
1033 const merged = new Map<string, { color: string; short: string; kind: string; tokens: number }>()
1034
1035 for (const row of pie.filter(one => one.kind !== 'free')) {
1036 const look = sliceLook(row.name)
1037 const known = merged.get(look.short)
1038
1039 merged.set(look.short, { ...look, kind: row.kind, tokens: (known?.tokens ?? 0) + row.tokens })
1040 }
1041
1042 const parts = [...merged.values()]
1043 const legend = [
1044 ...parts.filter(part => part.kind !== 'buffer').sort((a, b) => b.tokens - a.tokens).slice(0, 5),
1045 ...parts.filter(part => part.kind === 'buffer'),
1046 ]
1047 const entry = (part: (typeof legend)[number]) => `■ ${part.short} ${kilo(part.tokens)} `
1048 const dietLine = dieting > 0 ? `eating ~${kilo(plate)} tokens from the context on your next /compact` : ''
1049 // The readout's height at `columns` wide, line by line, so the sprite takes only
1050 // what is left and the buttons never fall off the bottom of the pane.
1051 const lines = (columns: number) => {
1052 let legendLines = legend.length > 0 ? 1 : 0
1053
1054 for (let used = 0, i = 0; i < legend.length; i++) {
1055 const w = entry(legend[i]!).length
1056
1057 if (used + w > columns && used > 0) {
1058 legendLines += 1
1059 used = 0
1060 }
1061 used += w
1062 }
1063
1064 // What it says can wrap: a gag or a fed-up line runs past a narrow readout.
1065 return (
1066 2 +
1067 Math.ceil(say.length / columns) +
1068 legendLines +
1069 limits.length +
1070 1 +
1071 (remark === undefined ? 0 : 1) +
1072 Math.ceil(dietLine.length / columns) +
1073 2 +
1074 (e.props.isFocused ? 0 : 1)
1075 )
1076 }
1077 // Stacked, the sprite takes up to 64 columns and the readout stays a 48 column block
1078 // under it. A pane too short for that (fewer than 10 sprite rows) and wide enough
1079 // for both puts the sprite beside the readout instead, as tall as it, twice as wide.
1080 const { bodyColumns, scroll } = e.props
1081 const wide = Math.max(16, Math.min(64, bodyColumns))
1082 const beside = Math.min(48, bodyColumns - 1 - 2 * Math.min(scroll.bodyRows, lines(Math.min(48, wide))))
1083 const isBeside =
1084 e.surface === 'terminal' && 'Raster' in elements && scroll.bodyRows - lines(Math.min(48, wide)) - 1 < 10 && beside >= 40
1085 const columns = isBeside ? beside : Math.min(48, wide)
1086 const readoutLines = lines(columns)
1087 const width = Math.max(6, Math.min(BAR, columns - 23))
1088 const grown = Math.round(((eaten - xpFor(rank)) / (xpFor(rank + 1) - xpFor(rank))) * width)
1089 const row = (name: string, percent: number, tail: string) => {
1090 const [used, left] = bar(percent, width)
1091
1092 return (
1093 <Box>
1094 <Text dimColor>{name.padEnd(8)}</Text>
1095 <Text color={barColor(percent)}>{used}</Text>
1096 <Text dimColor>{left}</Text>
1097 <Text>
1098 {` ${Math.round(percent)}%`.padStart(5)} {tail}
1099 </Text>
1100 </Box>
1101 )
1102 }
1103 const layers = stack(pie, full.window || 1, width)
1104 let sprite
1105
1106 // Raster draws on the terminal only; elsewhere it is an empty fragment.
1107 if (e.surface === 'terminal' && 'Raster' in elements) {
1108 const { Raster } = elements
1109
1110 // After a hot reload the scene starts over: take the level from the banked tokens.
1111 if (scene.rank === 0) levelUp(scene, rank)
1112 // Mid-resize the pane can report no rows at all; a Raster under one cell is refused.
1113 const rows = isBeside ? Math.max(1, Math.min(scroll.bodyRows, readoutLines)) : Math.max(6, Math.min(22, scroll.bodyRows - readoutLines - 1))
1114 const across = isBeside ? Math.max(1, Math.min(2 * rows, bodyColumns - 1 - columns)) : wide
1115
1116 canvas = { columns: across, rows }
1117 if (loop === undefined) {
1118 settle(scene)
1119 loop = $.clock.every(1000 / FPS, () => void frame($))
1120 }
1121 sprite = <Raster key="sprite" columns={across} rows={rows} cells={encode(paint(scene, across, rows * 2), across, rows, is256)} />
1122 } else {
1123 const drawing = ASCII[monster] ?? ASCII.cookie!
1124
1125 sprite = (
1126 <Box flexDirection="column">
1127 {(activity === '' ? drawing.closed : drawing.open).map(line => (
1128 <Text color={tint}>{line.replaceAll('E', eye)}</Text>
1129 ))}
1130 </Box>
1131 )
1132 }
1133
1134 const readout = (
1135 <Box flexDirection="column" width={columns}>
1136 <Text color={tint} bold>
1137 {say}
1138 </Text>
1139 <Text dimColor wrap="truncate-end">
1140 {asked !== null
1141 ? waitLine(asked)
1142 : activity !== ''
1143 ? `> ${activity}${chain}`
1144 : kept !== null
1145 ? `cold cache: next prompt re-reads ~${kilo(kept.tokens)} uncached`
1146 : full.ate > 0
1147 ? `last bite +${kilo(full.ate)}, fed ${span(at - full.fedAt)} ago`
1148 : `fed ${span(at - full.fedAt)} ago`}
1149 </Text>
1150 {pie.length === 0 ? (
1151 row('belly', full.percent, `${kilo(full.tokens)}/${kilo(full.window)}`)
1152 ) : (
1153 <Box>
1154 <Text dimColor>{'belly'.padEnd(8)}</Text>
1155 {layers.content.map(part => (
1156 <Text color={part.color}>{'█'.repeat(part.cells)}</Text>
1157 ))}
1158 <Text dimColor>{'░'.repeat(layers.free)}</Text>
1159 {layers.reserve.map(part => (
1160 <Text color={part.color}>{'▒'.repeat(part.cells)}</Text>
1161 ))}
1162 <Text>
1163 {` ${Math.round(full.percent)}%`.padStart(5)} {`${kilo(full.tokens)}/${kilo(full.window)}`}
1164 </Text>
1165 </Box>
1166 )}
1167 {pie.length > 0 && (
1168 <Box flexWrap="wrap" width={columns}>
1169 {legend.map(part => (
1170 <Text>
1171 <Text color={part.color}>{part.kind === 'buffer' ? '▒' : '■'}</Text>
1172 <Text dimColor>{entry(part).slice(1)}</Text>
1173 </Text>
1174 ))}
1175 </Box>
1176 )}
1177 {limits.map(limit =>
1178 row(
1179 limitName(limit.kind),
1180 limit.percentUsed,
1181 limit.resetsAt === undefined ? '' : span(Math.max(0, Date.parse(limit.resetsAt) - at)),
1182 ),
1183 )}
1184 {/* Its level and how far to the next, on a thin bar in its own color. */}
1185 <Box>
1186 <Text dimColor>{`Lv ${rank}`.padEnd(8)}</Text>
1187 <Text color={tint}>{'━'.repeat(grown)}</Text>
1188 <Text dimColor>{'─'.repeat(width - grown)}</Text>
1189 <Text dimColor>{` ${kilo(xpFor(rank + 1) - eaten)} to Lv ${rank + 1}`}</Text>
1190 </Box>
1191 {remark !== undefined && <Text dimColor>{remark}</Text>}
1192 {dieting > 0 && <Text color="yellow">{dietLine}</Text>}
1193 {/* Plain buttons show their key: `m: Monster`. The keys work while the pane holds the keyboard. */}
1194 <Box>
1195 <Button
1196 key="monster"
1197 label="Monster"
1198 hotkey="m"
1199 plain
1200 onPress={() => restyle($, current => ({ ...current, monster: after(NAMES, current.monster) }))}hooks/diet.tsx 295 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, SessionMessage } from 'claude-code'
3
4import type { Dish } from '../types'
5import { PANE, PANE_OPEN, isDietWord, kilo, tokens } from './format'
6
7// The diet: picked tool results replaced by a stub, so every tool call keeps its
8// result and the conversation stays valid. Roughly four characters to a token.
9//
10// Eat only arms it. A plugin's own $.session.compact() skips that plugin's hooks,
11// so the diet could not answer it and core would summarize everything instead.
12// The person's next /compact is answered here, with the armed results eaten.
13// The diet is a view inside the monster's pane, never a pane of its own: a mod
14// cannot switch the person back to another tab, so a second pane traps them.
15// Its hooks sit outside the monster's on the same pane and pass when `view` is
16// not 'diet'. Earlier versions opened a pane under this id; session.start closes it.
17export const OLD_DIET = 'token-monster-diet'
18
19// Nine rows, so each has a digit to press; the whole menu, for eating by kind, up to a bound.
20const COURSES = 9
21const MENU = 400
22// Keys for eating by kind: a for every tool result, then one per tool, biggest first.
23// Clear of the diet's own keys (1-9, e, r, q, x).
24const KINDS = ['a', 'b', 'f', 'g', 'h', 'j']
25const EATEN = '[Token Monster ate this '
26// What a row says about the call: a Bash description reads better than its command.
27const LABELS = ['file_path', 'description', 'url', 'pattern', 'query', 'command', 'prompt']
28
29const menu = atom({ plugin: 'token-monster', key: 'menu' } as const, [])
30const picked = atom({ plugin: 'token-monster', key: 'picked' } as const, [])
31const armed = atom({ plugin: 'token-monster', key: 'armed' } as const, [])
32// The armed results' tokens, roughly: what the next /compact takes out of the context.
33const serving = atom({ plugin: 'token-monster', key: 'serving' } as const, 0)
34const view = atom({ plugin: 'token-monster', key: 'view' } as const, 'monster')
35// The monster's own reading of the context, for the before and after.
36const belly = atom({ plugin: 'token-monster', key: 'belly' } as const, null)
37
38type Block = { type: string; [field: string]: unknown }
39
40export const label = (input: Record<string, unknown>) =>
41 String(LABELS.map(name => input[name]).find(value => typeof value === 'string') ?? '')
42
43// mcp__claude_ai_brainlink__get_started reads as brainlink.get_started.
44const short = (tool: string) =>
45 tool.startsWith('mcp__') ? tool.split('__').slice(1).join('.').replace(/^claude_ai_/, '') : tool
46
47const stub = (tool: string, text: string) =>
48 `${EATEN}${tool} result (~${kilo(tokens(text))} tokens) to free context. Run the tool again if you need it.]`
49
50// A message holding an eaten result is rebuilt from its text, which would drop an
51// image or a document beside it: the results of such a message are never offered.
52export const withMedia = (messages: readonly { content: readonly Block[] }[]) => {
53 const ids = new Set<string>()
54
55 for (const { content } of messages) {
56 const results = content.filter(block => block.type === 'tool_result')
57 const hasMedia =
58 content.some(block => block.type !== 'text' && block.type !== 'tool_result') ||
59 results.some(block => Array.isArray(block.content) && block.content.some((inner: Block) => inner.type !== 'text'))
60
61 if (hasMedia) for (const block of results) ids.add(String(block.tool_use_id))
62 }
63
64 return ids
65}
66
67export const stubbed = (messages: readonly SessionMessage[], ids: ReadonlySet<string>): SessionMessage[] => {
68 const tools = new Map(messages.flatMap(message => message.toolUses.map(use => [use.tool_use_id, use.tool])))
69
70 return messages.map(message =>
71 message.toolResults?.some(result => ids.has(result.tool_use_id))
72 ? {
73 role: message.role,
74 text: message.text,
75 toolUses: message.toolUses,
76 toolResults: message.toolResults.map(result =>
77 ids.has(result.tool_use_id)
78 ? {
79 tool_use_id: result.tool_use_id,
80 isError: result.isError,
81 text: stub(tools.get(result.tool_use_id) ?? 'tool', result.text),
82 }
83 : result,
84 ),
85 }
86 : message,
87 )
88}
89
90const fill = async ($: EngineInterface) => {
91 const unsafe = withMedia(await $.session.messages({ as: 'api' }))
92 const dishes: Dish[] = []
93
94 for (const message of await $.session.messages()) {
95 for (const use of message.toolUses) {
96 if (use.text === undefined || use.text.startsWith(EATEN) || unsafe.has(use.tool_use_id)) continue
97
98 dishes.push({ id: use.tool_use_id, tool: short(use.tool), label: label(use.input), tokens: tokens(use.text) })
99 }
100 }
101
102 await update($, menu, () => dishes.sort((a, b) => b.tokens - a.tokens).slice(0, MENU))
103 await update($, picked, () => [])
104}
105
106const showDiet = async ($: EngineInterface) => {
107 await fill($)
108 await update($, view, () => 'diet')
109}
110
111const hideDiet = async ($: EngineInterface) => {
112 await update($, view, () => 'monster')
113}
114
115// Picks every one of `group`, or unpicks them all when they already are.
116const pickAll = (group: readonly string[]) => (ids: string[]) =>
117 group.every(id => ids.includes(id)) ? ids.filter(id => !group.includes(id)) : [...new Set([...ids, ...group])]
118
119const pick = (id: string) => (ids: string[]) => (ids.includes(id) ? ids.filter(one => one !== id) : [...ids, id])
120
121const arm = async ($: EngineInterface) => {
122 const chosen = await read($, picked)
123
124 if (chosen.length === 0) return
125
126 const tokens = (await read($, menu)).filter(dish => chosen.includes(dish.id)).reduce((sum, dish) => sum + dish.tokens, 0)
127
128 await update($, armed, () => chosen)
129 await update($, serving, () => tokens)
130
131 const { isFilled } = await $.prompt.fill({ text: '/compact' })
132
133 // The next step is the prompt, and the monster's readout says it is armed.
134 await hideDiet($)
135
136 $.ui.toast(`${isFilled ? 'Press Enter on /compact' : 'Run /compact'} and me eat ~${kilo(tokens)} tokens from the context.`)
137}
138
139const disarm = async ($: EngineInterface) => {
140 await update($, armed, () => [])
141 await update($, serving, () => 0)
142}
143
144export const registerDiet = (on: On) => {
145 // Only the person's /compact eats: an automatic one needs the room a summary makes.
146 on('session.compact', { trigger: 'manual' }, async ($, e, next) => {
147 // Whatever the /compact does, the pane goes back to the monster to watch it.
148 if (e.agentId === undefined) await hideDiet($)
149
150 const ids = await read($, armed)
151
152 if (ids.length === 0 || e.agentId !== undefined) return next(e)
153
154 await disarm($)
155 await update($, picked, () => [])
156
157 if (!Array.isArray(e.messages) || e.messages.length === 0) return { skip: 'me could not see the conversation' }
158
159 // Results an earlier compaction or /clear already took are gone: with none of the
160 // armed ones left, this is an ordinary /compact.
161 const present = new Set(e.messages.flatMap(message => message.toolResults ?? []).map(result => result.tool_use_id))
162 const meal = new Set(ids.filter(id => present.has(id)))
163
164 if (meal.size === 0) return next(e)
165
166 await update($, menu, dishes => dishes.filter(dish => !meal.has(dish.id)))
167
168 const eaten = e.messages
169 .flatMap(message => message.toolResults ?? [])
170 .filter(result => meal.has(result.tool_use_id))
171 .reduce((sum, result) => sum + tokens(result.text), 0)
172
173 $.ui.toast(`Me eating ~${kilo(eaten)} tokens from the context. *burp*`)
174
175 return { messages: stubbed(e.messages, meal) }
176 })
177
178 // `diet` is this module's word; the monster's command passes it down here.
179 on('command.run', { command: 'token-monster' }, async ($, e, next) => {
180 if (!isDietWord(e.args)) return next(e)
181
182 await showDiet($)
183 await $.ui.open({ ...PANE_OPEN, focus: true })
184
185 return { text: 'Pick what Token Monster eats.' }
186 })
187
188 // The monster's Diet button: this module alone switches the view to the diet.
189 on('ui.press', { plugin: 'token-monster', element: 'diet' }, async ($, e) => {
190 await showDiet($)
191
192 return { element: e.element }
193 })
194
195 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
196 if ((await read($, view)) !== 'diet') return next(e)
197
198 const { Box, Button, Text } = $.ui.resolve(e)
199 const { bodyColumns, isFocused } = e.props
200 const dishes = await read($, menu)
201 const ids = await read($, picked)
202 const ready = await read($, armed)
203 const plate = await read($, serving)
204 const room = Math.max(12, bodyColumns - 2)
205 const chosen = dishes.filter(dish => ids.includes(dish.id))
206 const saving = chosen.reduce((sum, dish) => sum + dish.tokens, 0)
207 const full = await read($, belly)
208 const now =
209 full === null || !full.known ? 'your context' : `${full.percent}% ${kilo(full.tokens)}/${kilo(full.window)}`
210 const after =
211 full === null || !full.known || saving === 0
212 ? ''
213 : ` -> ~${Math.round((Math.max(0, full.tokens - saving) / full.window) * 100)}% after`
214 const edible = dishes.reduce((sum, dish) => sum + dish.tokens, 0)
215 const byTool = new Map<string, string[]>()
216
217 for (const dish of dishes) byTool.set(dish.tool, [...(byTool.get(dish.tool) ?? []), dish.id])
218
219 const weigh = (group: readonly string[]) => dishes.filter(dish => group.includes(dish.id)).reduce((sum, dish) => sum + dish.tokens, 0)
220 const kinds = [
221 { name: 'all tool results', ids: dishes.map(dish => dish.id) },
222 ...[...byTool].map(([tool, group]) => ({ name: tool, ids: group })).sort((a, b) => weigh(b.ids) - weigh(a.ids)),
223 ].slice(0, KINDS.length)
224
225 return (
226 <Box flexDirection="column">
227 <Text bold>Free up this conversation's context</Text>
228 <Text dimColor>
229 Tool results are the part of the context me can eat: ~{kilo(edible)} right now. The system prompt, tools,
230 memory and skills belong to Claude Code.
231 </Text>
232 <Text>
233 context {now}
234 {after}
235 </Text>
236 {!isFocused && <Text dimColor>ctrl+x tab or a click gives me the keys</Text>}
237 {ready.length > 0 && (
238 <Box>
239 <Text color="yellow">Armed: eating ~{kilo(plate)} tokens from the context on your next /compact </Text>
240 <Button key="cancel" label="Cancel" hotkey="x" plain onPress={() => disarm($)} />
241 </Box>
242 )}
243 {dishes.length === 0 && <Text dimColor>No tool results to eat yet.</Text>}
244 {dishes.length > 0 && (
245 <Box flexWrap="wrap" width={room}>
246 <Text dimColor>by kind </Text>
247 {kinds.map((kind, index) => (
248 <Box marginRight={2}>
249 <Button
250 key={`kind-${index}`}
251 plain
252 hotkey={KINDS[index]}
253 label={`${kind.ids.every(id => ids.includes(id)) ? '[x]' : '[ ]'} ${kind.name} ~${kilo(weigh(kind.ids))}`}
254 onPress={() => update($, picked, pickAll(kind.ids))}
255 />
256 </Box>
257 ))}
258 </Box>
259 )}
260 {dishes.length > 0 && <Text dimColor>biggest</Text>}
261 {dishes.slice(0, COURSES).map((dish, index) => {
262 const size = ` ~${kilo(dish.tokens)}`
263 const name = `${ids.includes(dish.id) ? '[x]' : '[ ]'} ${dish.tool} ${dish.label}`
264 const width = room - size.length - 3
265
266 return (
267 <Button
268 key={`dish-${index}`}
269 plain
270 hotkey={String(index + 1)}
271 label={`${name.slice(0, width).padEnd(width)}${size}`}
272 onPress={() => update($, picked, pick(dish.id))}
273 />
274 )
275 })}
276 <Text> </Text>
277 <Box>
278 <Button
279 key="eat"
280 label={chosen.length === 0 ? 'Eat' : `Eat ${chosen.length} (frees ~${kilo(saving)})`}
281 hotkey="e"
282 plain
283 onPress={() => arm($)}
284 />
285 <Text> </Text>
286 <Button key="refresh" label="Refresh" hotkey="r" plain onPress={() => fill($)} />
287 <Text> </Text>
288 <Button key="back" label="Back" hotkey="q" plain onPress={() => hideDiet($)} />
289 </Box>
290 <Text dimColor>Eat puts /compact in your prompt; Enter removes them from the context, each left as a short note.</Text>
291 </Box>
292 )
293 })
294}
295hooks/format.ts 22 lines1// What both modules share: the pane, the words, and the token arithmetic.
2export const PANE = 'token-monster'
3export const PANE_OPEN = { id: PANE, title: 'Token Monster', columns: 46 }
4
5// `/token-monster diet` or `/token-monster eat`, with anything after it.
6export const isDietWord = (args: string) => ['diet', 'eat'].includes(args.trim().toLowerCase().split(/\s+/)[0] ?? '')
7
8// `/token-monster sound`, with on or off or nothing after it.
9export const isSoundWord = (args: string) => args.trim().toLowerCase().split(/\s+/)[0] === 'sound'
10
11// Rough tokens in a piece of text, about four characters each.
12export const tokens = (text: string | undefined) => Math.ceil((text?.length ?? 0) / 4)
13
14export const kilo = (n: number) =>
15 n >= 1_000_000
16 ? `${(n / 1_000_000).toFixed(1)}M`
17 : n >= 10_000
18 ? `${Math.round(n / 1000)}k`
19 : n >= 1000
20 ? `${(n / 1000).toFixed(1)}k`
21 : `${n}`
22hooks/grow.ts 30 lines1// It grows up with you. Every token it eats (what heats it up: the stream, tool
2// results, prompts) is banked as lifetime experience in $.store, across sessions,
3// and its level follows from that. The hooks that bank it are in register.tsx.
4
5// The step from level n to n + 1 takes STEP * n^1.6 tokens: level 2 at 20k, level 3
6// at about 80k (an hour or so of work), level 10 at 2.7M, level 25 at about 30M.
7const STEP = 20_000
8
9const cost = (level: number) => Math.round(STEP * level ** 1.6)
10
11// The tokens it takes to reach `level`.
12export const xpFor = (level: number) => {
13 let total = 0
14
15 for (let n = 1; n < level; n++) total += cost(n)
16
17 return total
18}
19
20export const levelOf = (tokens: number) => {
21 let [level, need] = [1, cost(1)]
22
23 while (tokens >= need) {
24 level += 1
25 need += cost(level)
26 }
27
28 return level
29}
30hooks/paint.ts 3041 lines1import type { Belly, Cache, Limit, Look } from '../types'
2
3// Everything the sprite reads. The gauges (belly, pantry, look, hour) come from
4// the hooks; serve(), hit(), perk() and the turn calls record what happened, and
5// step() turns it into motion one frame at a time, only as fast as tokens arrive.
6export type Scene = {
7 tick: number
8 at: number
9 hour: number
10 belly: Belly | null
11 look: Look
12 pantry: Limit[]
13 busy: boolean
14 tools: Map<string, string>
15 minions: number
16 level: number
17 lastLevel: number
18 typedAt: number
19 errorAt: number
20 activeAt: number
21 perkAt: number
22 cheerAt: number
23 flashAt: number
24 arrived: number
25 rate: number
26 heat: number
27 servings: Serving[]
28 motes: Mote[]
29 bits: Bit[]
30 chew: number
31 bob: number
32 breathe: number
33 blinkAt: number
34 gaze: Point
35 gazeTo: Point
36 gazeUntil: number
37 googly: Point
38 googlyV: Point
39 squash: number
40 squashV: number
41 lid: number
42 open: number
43 smile: number
44 blush: number
45 power: number
46 // Full tilt: the token rate pegged, with subagents on top. 0 to 1, eased.
47 frenzy: number
48 // When the person last typed, in $.clock ms (typedAt counts frames, which stop with the pane).
49 typedMs: number
50 combo: number
51 best: number
52 comboAt: number
53 lastHitAt: number
54 finish: { text: string; at: number } | null
55 // Petting: when, how it took it, and an affection that builds and fades over minutes.
56 petAt: number
57 fuss: Fuss
58 affection: number
59 lovedAt: number
60 // Typing after a long quiet: it waves hello first.
61 greetAt: number
62 // The idle antic running, the frame the next may start (-1 until it is calm), the last one.
63 antic: { kind: Antic; at: number; len: number; seed: number } | null
64 anticAt: number
65 lastAntic: Antic | null
66 // How far it has stepped from the middle, chasing something.
67 shift: number
68 // Growing up: tokens eaten since this scene began (the hooks bank them for good),
69 // the lifetime level (0 until known), when it last went up, and the egg a new
70 // session hatches from: `eggDue` holds the session's start until the pane shows.
71 eaten: number
72 rank: number
73 rankUpAt: number
74 eggDue: number | null
75 eggAt: number
76 // Claude is waiting on the person (a permission dialog or a question): it calls
77 // them, from the frame `waitAt`.
78 waiting: boolean
79 waitAt: number
80 // The prompt cache, to see when it has gone cold.
81 cache: Cache | null
82 // When it last gagged on a huge tool result.
83 gagAt: number
84 // When the same call failing again made it fed up.
85 fedUpAt: number
86}
87
88type Point = { x: number; y: number }
89type Serving = { tokens: number; color: number }
90type Mote = { x: number; y: number; px: number; py: number; color: number }
91type Bit = {
92 kind: 'crumb' | 'spark' | 'confetti' | 'z' | 'puff' | 'tear' | 'drool' | 'firefly' | 'ember' | 'heart' | 'shell'
93 x: number
94 y: number
95 vx: number
96 vy: number
97 life: number
98 max: number
99 color: number
100}
101
102export const MINUTE = 60_000
103export const SAD = 15 * MINUTE
104export const STARVING = 60 * MINUTE
105export const BURP = MINUTE
106// One pair of limit thresholds for the bars, the glow, the sweat and the remarks.
107export const AMBER = 50
108export const RED = 80
109// The context fill, in percent, where the monster is about to burst: time to /compact.
110export const BURST = 90
111// Tool calls closer together than this chain into a combo, and the fire burns this many frames after the last.
112export const COMBO_MS = 4000
113const COMBO_FRAMES = 40
114// Frames (at 10 a second) of nothing happening before it yawns, then dozes off.
115const DOZE = 1800
116const YAWN = 40
117const CHEER = 26
118// Frames it gags on a huge tool result.
119const GAG = 24
120// Frames it stays fed up with a call that keeps failing.
121const FED_UP = 50
122const HOP = 12
123const PERK = 18
124// A level-up, in frames: crouch, a spinning jump, the landing, then a pose.
125export const LEVEL_UP = 50
126// The egg wobbles and cracks this many frames, bursts, and the shell is gone by EGG.
127export const EGG_CRACK = 28
128export const EGG = 42
129// A pane first painted later than this after the session started skips the egg.
130export const HATCH_WINDOW = 20_000
131
132export const PALETTE: Record<string, number> = {
133 blue: 0x3d7bff,
134 cyan: 0x22c7d6,
135 green: 0x46d160,
136 yellow: 0xf2c230,
137 magenta: 0xd25cf0,
138 red: 0xf0503c,
139 white: 0xdfe6f0,
140 // CrabStack's amber.
141 amber: 0xffb000,
142}
143
144const WHITE = 0xffffff
145const BLACK = 0x000000
146const INK = 0x1a0f24
147const MOUTH = 0x2a0410
148const TONGUE = 0xe8607e
149const BLUSH = 0xff7aa8
150const GOLD = 0xffd23f
151const GOLD_LIGHT = 0xfff3a0
152const GOLD_DARK = 0x8a5a00
153const RAINBOW = [0xff5a5f, 0xffd23f, 0x5cff8a, 0x5cc8ff, 0xc77dff, 0xffffff]
154const TEAL = 0x2fe0c8
155const BOLT = 0xcff6ff
156export const TEXT = 0xffd166
157export const THINKING = 0xb59cff
158export const PROMPT = 0x9ad7ff
159const TOOL_COLORS: [RegExp, number][] = [
160 [/^Bash/, 0x5cff8a],
161 [/^(Read|Grep|Glob|LS)/, 0x5cc8ff],
162 [/^(Edit|Write|MultiEdit|NotebookEdit)/, 0xffa94d],
163 [/^Web/, 0xc77dff],
164 [/^Agent/, 0xff6fd8],
165 [/^mcp__/, 0xffe066],
166]
167
168export const toolColor = (tool: string | undefined) =>
169 tool === undefined ? TEXT : (TOOL_COLORS.find(([match]) => match.test(tool))?.[1] ?? TEXT)
170
171const mix = (a: number, b: number, t: number) => {
172 const k = Math.max(0, Math.min(1, t))
173 const channel = (shift: number) => {
174 const from = (a >> shift) & 255
175
176 return Math.round(from + (((b >> shift) & 255) - from) * k) << shift
177 }
178
179 return channel(16) | channel(8) | channel(0)
180}
181
182// A color as hue (degrees), saturation and lightness (0 to 1), and back.
183const toHsl = (color: number) => {
184 const [r, g, b] = [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255]
185 const [hi, lo] = [Math.max(r, g, b), Math.min(r, g, b)]
186 const l = (hi + lo) / 2
187 const d = hi - lo
188
189 if (d === 0) return [0, 0, l] as const
190
191 const h = hi === r ? (g - b) / d + (g < b ? 6 : 0) : hi === g ? (b - r) / d + 2 : (r - g) / d + 4
192
193 return [h * 60, l > 0.5 ? d / (2 - hi - lo) : d / (hi + lo), l] as const
194}
195
196const fromHsl = (h: number, s: number, l: number) => {
197 const a = s * Math.min(l, 1 - l)
198 const channel = (n: number) => {
199 const k = (n + h / 30) % 12
200
201 return Math.round(255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))))
202 }
203
204 return (channel(0) << 16) | (channel(8) << 8) | channel(4)
205}
206
207// Turns hue `h` by up to `by` degrees toward `to`, the short way round.
208const toward = (h: number, to: number, by: number) => {
209 const d = ((to - h + 540) % 360) - 180
210
211 return (h + Math.sign(d) * Math.min(Math.abs(d), by) + 360) % 360
212}
213
214// `color` shaded `step` bands lighter, or darker below 0, as handheld-era sprites do:
215// shadows turn toward blue and light toward yellow, rather than mixing in black and
216// white, which greys colors out. Warm colors take their light as yellow more than as
217// white, so a red's highlight stays a hot red-orange instead of going pink.
218const ramp = (color: number, step: number) => {
219 const [h, s, l] = toHsl(color)
220
221 if (s < 0.08) return fromHsl(h, s, clamp(l + step * 0.16, 0.04, 0.97))
222 if (step < 0) return fromHsl(toward(h, 250, -step * 14), clamp(s - step * 0.06), clamp(l + step * 0.13, 0.04, 0.96))
223
224 const warm = h < 70 || h > 320
225
226 return warm
227 ? fromHsl(toward(h, 55, step * 12), clamp(s + step * 0.08), clamp(l + step * 0.1, 0.04, 0.96))
228 : fromHsl(toward(h, 55, step * 10), clamp(s - step * 0.04), clamp(l + step * 0.13, 0.04, 0.96))
229}
230
231// A fully saturated color going round the hue wheel, for the frenzy.
232const rainbow = (turn: number) => {
233 const h = ((turn % 1) + 1) % 1 * 6
234 const x = 1 - Math.abs((h % 2) - 1)
235 const [r, g, b] = h < 1 ? [1, x, 0] : h < 2 ? [x, 1, 0] : h < 3 ? [0, 1, x] : h < 4 ? [0, x, 1] : h < 5 ? [x, 0, 1] : [1, 0, x]
236
237 return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255)
238}
239
240// A repeatable 0..1 from integers, so paint() flickers without moving the scene.
241const hash = (a: number, b = 0) => {
242 let h = (a * 374761393 + b * 668265263) | 0
243
244 h = Math.imul(h ^ (h >>> 13), 1274126177)
245
246 return ((h ^ (h >>> 16)) >>> 0) / 2 ** 32
247}
248
249let seed = 7
250const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32
251const ease = (from: number, to: number, k: number) => from + (to - from) * k
252const clamp = (v: number, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v))
253
254export const createScene = (look: Look): Scene => ({
255 tick: 0,
256 at: 0,
257 hour: 22,
258 belly: null,
259 look,
260 pantry: [],
261 busy: false,
262 tools: new Map(),
263 minions: 0,
264 level: 0,
265 lastLevel: 0,
266 typedAt: -100,
267 errorAt: -100,
268 activeAt: 0,
269 perkAt: -100,
270 cheerAt: -100,
271 flashAt: -100,
272 arrived: 0,
273 rate: 0,
274 heat: 0,
275 servings: [],
276 motes: [],
277 bits: [],
278 chew: 0,
279 bob: 0,
280 breathe: 0,
281 blinkAt: -100,
282 gaze: { x: 0, y: 0 },
283 gazeTo: { x: 0, y: 0 },
284 gazeUntil: 0,
285 googly: { x: 0, y: 0 },
286 googlyV: { x: 0, y: 0 },
287 squash: 0,
288 squashV: 0,
289 lid: 0,
290 open: 0,
291 smile: 0.3,
292 blush: 0,
293 power: 0,
294 frenzy: 0,
295 typedMs: -Infinity,
296 combo: 0,
297 best: 0,
298 comboAt: -100,
299 lastHitAt: -Infinity,
300 finish: null,
301 petAt: -100,
302 fuss: 'purr',
303 affection: 0,
304 lovedAt: 0,
305 greetAt: -100,
306 antic: null,
307 anticAt: -1,
308 lastAntic: null,
309 shift: 0,
310 eaten: 0,
311 rank: 0,
312 rankUpAt: -1000,
313 eggDue: null,
314 eggAt: -1000,
315 waiting: false,
316 waitAt: -1000,
317 cache: null,
318 gagAt: -100,
319 fedUpAt: -100,
320})
321
322// Tokens arriving: they heat the monster up and fly into its mouth. Typing only
323// drools; it is not tokens spent. Prompt-colored servings rise from the prompt.
324export const serve = (s: Scene, tokens: number, color: number, heats = true) => {
325 if (tokens <= 0) return
326 if (heats) {
327 s.arrived += tokens
328 s.eaten += tokens
329 }
330
331 s.activeAt = s.tick
332 s.servings.push({ tokens: Math.min(tokens, 3000), color })
333 s.servings = s.servings.slice(-24)
334}
335
336// You sent a prompt: it perks up and looks forward to it.
337export const perk = (s: Scene) => {
338 s.perkAt = s.tick
339 s.activeAt = s.tick
340}
341
342// A main-loop tool call landed at `at` ($.clock milliseconds): it chains if close to the last.
343export const hit = (s: Scene, isError: boolean, at: number) => {
344 s.combo = at - s.lastHitAt > COMBO_MS ? 1 : s.combo + 1
345 s.best = Math.max(s.best, s.combo)
346 s.lastHitAt = at
347 s.comboAt = s.tick
348 s.activeAt = s.tick
349 // Every hit stokes the fire: a jolt now, a flare of embers on the next frame.
350 s.squashV += 0.05
351
352 if (isError) {
353 s.errorAt = s.tick
354 s.finish = { text: 'COUNTER', at: s.tick }
355 }
356}
357
358export const startTurn = (s: Scene) => {
359 s.busy = true
360 s.combo = 0
361 s.best = 0
362 s.activeAt = s.tick
363}
364
365// Every finished turn is a little celebration; one that landed a combo of three
366// or more ends in a K.O., however long the answer took.
367export const finishTurn = (s: Scene, isAborted = false) => {
368 // An interrupted turn gets neither: no K.O., no cheer.
369 if (!isAborted) {
370 if (s.best >= 3) s.finish = { text: 'K.O.', at: s.tick }
371 s.cheerAt = s.tick
372 }
373
374 s.busy = false
375 s.tools.clear()
376 s.combo = 0
377 s.best = 0
378 s.activeAt = s.tick
379}
380
381// Asleep: three quiet minutes with nothing flowing, and well fed. A hungry monster
382// stays up, so the sprite and a pet agree on this one rule.
383const asleep = (s: Scene) =>
384 !s.busy && s.heat <= 0.02 && s.tick - s.activeAt > DOZE && (s.belly === null || s.at - s.belly.fedAt < SAD)
385
386// It grew a level: the big moment. A first reading of the level is no moment.
387export const levelUp = (s: Scene, rank: number) => {
388 if (s.rank > 0 && rank > s.rank) {
389 s.rankUpAt = s.tick
390 s.activeAt = s.tick
391 }
392
393 s.rank = Math.max(s.rank, rank)
394}
395
396// The pane is painting: a session's first look at the monster starts with the egg,
397// unless the pane came too long after the start (`at` and `eggDue` in ms). Once only.
398export const hatch = (s: Scene, at: number) => {
399 const due = s.eggDue
400
401 s.eggDue = null
402 if (due === null || at - due >= HATCH_WINDOW) return false
403
404 s.eggAt = s.tick
405 s.activeAt = s.tick
406
407 return true
408}
409
410// A tool result too big to swallow: it gags.
411export const choke = (s: Scene) => {
412 s.gagAt = s.tick
413 s.activeAt = s.tick
414}
415
416// The same call failed again: it is fed up for a few seconds.
417export const fedUp = (s: Scene) => {
418 s.fedUpAt = s.tick
419 s.activeAt = s.tick
420}
421
422// The prompt cache has lapsed since the last request, and no turn runs: the next prompt re-reads it all.
423export const cold = (s: Scene) => !s.busy && s.cache !== null && s.at - s.cache.at > s.cache.ttl
424
425// Claude started or stopped waiting on the person.
426export const wait = (s: Scene, isWaiting: boolean) => {
427 if (isWaiting && !s.waiting) s.waitAt = s.tick
428 s.waiting = isWaiting
429 s.activeAt = s.tick
430}
431
432export const hatching = (s: Scene) => s.tick - s.eggAt >= 0 && s.tick - s.eggAt < EGG
433
434// What piled up while nothing was drawing is not a meal to replay.
435export const settle = (s: Scene) => {
436 s.arrived = 0
437 s.rate = 0
438 s.heat = 0
439 s.servings = []
440 s.motes = []
441 s.bits = []
442 s.antic = null
443}
444
445const comboShown = (s: Scene) => s.combo >= 2 && s.tick - s.comboAt <= COMBO_FRAMES
446
447// Whether anything moves beyond breathing and blinking, so a frame is worth painting.
448export const lively = (s: Scene) =>
449 s.busy ||
450 // A few seconds into a wait the call calms to the idle rate.
451 (s.waiting && s.tick - s.waitAt < 30) ||
452 s.heat > 0.02 ||
453 s.power > 0.02 ||
454 s.frenzy > 0.02 ||
455 s.motes.length > 0 ||
456 s.servings.length > 0 ||
457 s.bits.some(bit => bit.kind !== 'z' && bit.kind !== 'firefly') ||
458 s.tick - s.typedAt < 15 ||
459 s.tick - s.cheerAt < CHEER ||
460 s.tick - s.perkAt < PERK ||
461 Math.abs(s.squashV) > 0.01 ||
462 comboShown(s) ||
463 (s.finish !== null && s.tick - s.finish.at < 18) ||
464 s.antic !== null ||
465 s.tick - s.gagAt < GAG ||
466 s.tick - s.fedUpAt < FED_UP ||
467 s.tick - s.petAt < PET ||
468 s.tick - s.greetAt < GREET ||
469 Math.abs(s.shift) > 0.05 ||
470 s.tick - s.rankUpAt < LEVEL_UP ||
471 hatching(s)
472
473// What a frame reads off the scene: the monster's place and size, and how it feels.
474const shape = (s: Scene, width: number, height: number) => {
475 const t = s.tick
476 const full = (s.belly?.percent ?? 0) / 100
477 const fill = s.belly?.fill ?? 0
478 const idle = s.belly === null ? 0 : s.at - s.belly.fedAt
479 const starving = idle >= STARVING
480 const monster = s.look.monster
481 const eating = s.heat > 0.02
482 const quiet = t - s.activeAt
483 const hungry = idle >= SAD
484 const sleeping = asleep(s)
485 const yawning = !s.busy && !eating && quiet > DOZE - YAWN && !sleeping && !hungry
486 const floor = height - 4
487 // Powering up, it tightens to make room for the hair.
488 const tight = 1 - s.power * 0.18
489 const r0 = Math.min(width * 0.5, height * 0.62) * 0.5
490 const wobble = monster === 'slime' ? 1 + 0.05 * Math.sin(s.breathe * 2.2) : 1
491 const breath = 1 + Math.sin(s.breathe) * (sleeping ? 0.05 : 0.025)
492 // The crab's shell is wide and low.
493 const [wide, low] = monster === 'crab' ? [1.18, 0.7] : [1, 1]
494 const rx0 = Math.min(width * 0.38, r0 * (0.78 + 0.5 * full) * (starving ? 0.85 : 1) * wobble * wide) * tight
495 const ry0 = Math.min(height * 0.3, r0 * (0.82 + 0.3 * full) * low) * tight * breath
496 const move = motion(s, r0)
497 // A level-up jumps high and spins twice in the air: seen edge-on, it is thin.
498 const grown = t - s.rankUpAt
499 const leveling = grown >= 0 && grown < LEVEL_UP
500 const flight = (grown - 4) / 14
501 const flying = flight > 0 && flight < 1
502 const spin = flying ? Math.cos(flight * Math.PI * 4) : 1
503 // Out of the egg it pops up from small.
504 const egg = t - s.eggAt
505 const pop = egg >= EGG_CRACK && egg < EGG_CRACK + 6 ? 0.55 + 0.45 * ((egg - EGG_CRACK) / 6) : 1
506 const rx = rx0 * (1 + s.squash * 0.7) * pop * Math.max(0.14, Math.abs(spin)) * move.sx
507 const ry = ry0 * (1 - s.squash) * pop * move.sy
508 const hopP = (t - s.cheerAt) / HOP
509 // Calling you, it jumps every second and a half.
510 const callP = ((t - s.waitAt) % 15) / 8
511 const hop =
512 (hopP >= 0 && hopP < 1 ? Math.sin(Math.PI * hopP) * r0 * 0.45 : 0) +
513 (flying ? Math.sin(Math.PI * flight) * r0 * 1.5 : 0) +
514 (s.waiting && callP < 1 ? Math.sin(Math.PI * callP) * r0 * 0.3 : 0)
515 const lift =
516 (monster === 'ghost' ? 3 + Math.sin(s.breathe * 1.3) * 1.4 : 0) +
517 (eating ? Math.abs(Math.sin(s.bob)) * (0.3 + s.heat * 1.8) : 0) +
518 s.power * 1.5 +
519 hop +
520 move.lift
521 const shake =
522 s.heat > 0.7 || s.level >= 2 || t - s.errorAt < 6 || t - s.gagAt < GAG ? Math.round((hash(t, 1) - 0.5) * 2 * (1 + s.frenzy * 2)) : 0
523 // A crab never stands still: it shuffles a step to the side and back.
524 const shuffle = monster === 'crab' ? Math.round(Math.sin(s.breathe * 0.8) * 2) : 0
525 const cx = width / 2 + shake + shuffle + Math.round(s.shift + move.dx)
526 const feet = monster === 'ghost' || monster === 'slime' ? 0 : r0 * (monster === 'crab' ? 0.4 : 0.18)
527 // However it floats, the head stays on screen.
528 const cy = floor - feet - ry - Math.min(lift, Math.max(0, floor - feet - 2 * ry - 4))
529
530 return {
531 t,
532 monster,
533 floor,
534 r0,
535 rx,
536 ry,
537 lift,
538 cx,
539 cy,
540 feet,
541 eating,
542 starving,
543 sleeping,
544 yawning,
545 heat: s.heat,
546 power: s.power,
547 // How hard the combo burns, 0 to 1, dying down once the chain breaks.
548 blaze: comboShown(s) ? (0.3 + 0.7 * clamp((s.combo - 2) / 6)) * clamp((COMBO_FRAMES - (t - s.comboAt)) / 12) : 0,
549 flare: t - s.comboAt < 4 ? 1 - (t - s.comboAt) / 4 : 0,
550 level: s.level,
551 wave: s.breathe,
552 hour: s.hour,
553 sad: idle >= SAD,
554 burping: s.belly?.burpAt != null && s.at - s.belly.burpAt < BURP,
555 bursting: fill >= BURST,
556 stuffed: fill >= 75,
557 pressure: Math.max(0, ...s.pantry.map(limit => limit.percentUsed)),
558 typing: t - s.typedAt < 15,
559 perking: t - s.perkAt < PERK,
560 cheering: t - s.cheerAt < CHEER,
561 thinking: s.busy && !eating,
562 angry: t - s.errorAt < 20,
563 gagging: t - s.gagAt < GAG,
564 fedUp: t - s.fedUpAt < FED_UP,
565 blink: t - s.blinkAt < 3 && t - s.typedAt >= 15,
566 // Gagging, it turns a little green.
567 body: starving
568 ? mix(PALETTE[s.look.color] ?? 0x3d7bff, 0x8a8f99, 0.55)
569 : t - s.gagAt < GAG
570 ? mix(PALETTE[s.look.color] ?? 0x3d7bff, 0x9bd44a, 0.4)
571 : mix(PALETTE[s.look.color] ?? 0x3d7bff, rainbow(t * 0.08), s.frenzy * 0.4),
572 frenzy: s.frenzy,
573 mouth: { x: cx, y: cy + ry * (monster === 'slime' ? 0.3 : monster === 'crab' ? 0.05 : 0.34) },
574 // Where it stands at rest, and the middle of its body there, for the antics' props.
575 home: width / 2,
576 rest: floor - feet - ry,
577 // Turned away, by an antic or mid level-up spin: no face to draw.
578 back: move.back || spin < 0.3,
579 petting: t - s.petAt < PET,
580 calling: s.waiting,
581 cold: cold(s),
582 greeting: t - s.greetAt < GREET,
583 leveling,
584 grown,
585 egg,
586 rank: s.rank,
587 width,
588 // Red and magenta monsters wear their reds in blue and teal, so they show.
589 reddish: s.look.color === 'red' || s.look.color === 'magenta',
590 }
591}
592
593type Shape = ReturnType<typeof shape>
594
595type Eyes = 'open' | 'happy' | 'closed' | 'dizzy' | 'squeeze' | 'plead'
596type Brows = 'none' | 'fierce' | 'sad'
597
598// How it looks right now: eyes, brows, and the targets the face eases toward.
599const mood = (s: Scene, f: Shape) => {
600 const chomp = Math.abs(Math.sin(s.chew))
601 const set = (eyes: Eyes, brows: Brows, lid: number, open: number, smile: number, blush: number) => ({
602 eyes,
603 brows,
604 lid,
605 open,
606 smile,
607 blush,
608 })
609
610 if (f.leveling) return set('happy', 'none', 0, 0.85, 1, 1)
611 if (f.calling) return set('open', 'none', 0, 0.55, 0.8, 0.5)
612 if (f.gagging) return set('squeeze', 'sad', 0, 0.8 + 0.2 * Math.sin(f.t * 1.3), -0.6, 0)
613 if (f.bursting && f.power <= 0.5) return set('dizzy', 'sad', 0, 0.35 + 0.15 * Math.sin(f.t * 0.3), -0.3, 0.3)
614 if (f.angry && f.t - s.errorAt < 10) return set('squeeze', 'sad', 0, 0.15, -0.8, 0)
615 if (f.fedUp) return set('open', 'fierce', 0.55, 0, -0.45, 0)
616 if (f.petting) {
617 if (s.fuss === 'stir') return set('closed', 'none', 1, 0.1, 0.9, 1)
618 if (s.fuss === 'plead') return set('plead', 'sad', 0, 0, -0.35, 0.6)
619
620 return set('happy', 'none', 0, f.eating ? 0.3 + 0.6 * chomp : s.fuss === 'spin' ? 0.45 : 0, 1, 1)
621 }
622 if (f.greeting) return set('happy', 'none', 0, 0.45, 1, 0.5)
623 if (f.burping) return set('happy', 'none', 0, 1, 0.6, 0.6)
624 if (f.cheering) return set('happy', 'none', 0, 0.75, 1, 1)
625 if (f.frenzy > 0.55) return set('dizzy', 'fierce', 0, 0.55 + 0.45 * chomp, 0.9, 1)
626 if (f.power > 0.5) return set('open', 'fierce', 0.1, f.eating ? 0.3 + 0.6 * chomp : 0.5, 0.3, 0.2)
627 if (f.blaze > 0.3) return set('open', 'fierce', 0.15, f.eating ? 0.3 + 0.6 * chomp : 0.25, 0.6, 0.4)
628 if (s.antic !== null) {
629 const [eyes, brows, lid, open, smile, blush] = feel(s, f, s.antic)
630
631 return set(eyes, brows, lid, open, smile, blush)
632 }
633 if (f.sleeping) return set('closed', 'none', 1, 0.12 + 0.06 * Math.sin(f.wave), 0.1, 0.35)
634 if (f.yawning) return set('closed', 'none', 1, 1, 0, 0)
635 if (f.eating) return set('open', 'none', 0.1 + f.heat * 0.35, (0.25 + 0.75 * f.heat) * chomp, 0.6, 0.2 + f.heat * 0.7)
636 if (f.typing || f.perking) return set('open', 'none', 0, 0.3, 0.4, 0.3)
637 if (f.thinking) return set('open', 'none', 0.25, 0, -0.1, 0)
638 if (f.starving) return set('open', 'sad', 0.55, 0, -1, 0)
639 if (f.sad) return set('open', 'sad', 0.3, 0, -0.7, 0)
640 if (f.stuffed) return set('open', 'none', 0.45, 0, 0.8, 0.6)
641 return set('open', 'none', 0.05, 0, 0.35, 0.15)
642}
643
644// Life: petting, the idle antics and the greeting. Each is a few seconds of
645// motion read off the scene, so paint() stays pure and step() moves it on.
646
647export type Fuss = 'purr' | 'wiggle' | 'spin' | 'stir' | 'plead'
648export type Antic = 'stretch' | 'scratch' | 'look' | 'wave' | 'chase' | 'juggle' | 'hop' | 'peek' | 'star'
649
650// Frames each antic runs (1.5 to 4 seconds), and how often it comes up.
651const ANTICS: Record<Antic, { len: number; weight: number }> = {
652 stretch: { len: 32, weight: 3 },
653 scratch: { len: 30, weight: 3 },
654 look: { len: 40, weight: 3 },
655 wave: { len: 24, weight: 2 },
656 chase: { len: 40, weight: 3 },
657 juggle: { len: 40, weight: 2 },
658 hop: { len: 18, weight: 2 },
659 peek: { len: 34, weight: 2 },
660 star: { len: 38, weight: 2 },
661}
662const PET = 22
663const GREET = 24
664// Two minutes of frames: typing after that long a quiet earns a hello.
665const AWAY = 1200
666// 15 to 45 seconds of calm between antics.
667const LULL = 150
668const LULL_MORE = 300
669const HEART = 0xff4f7b
670
671// How fond of you it is right now: each pet adds one, and it halves every two minutes.
672export const fondness = (s: Scene, at: number) => s.affection * 0.5 ** (Math.max(0, at - s.lovedAt) / (2 * MINUTE))
673
674// You petted it, at `at` ($.clock milliseconds). Asleep, it stirs and smiles without
675// waking; starving, it pleads; otherwise the fonder it is, the bigger the fuss.
676export const pet = (s: Scene, at: number): Fuss => {
677 const dozing = asleep(s)
678 const starving = s.belly !== null && at - s.belly.fedAt >= STARVING
679
680 s.affection = Math.min(6, fondness(s, at) + 1)
681 s.lovedAt = at
682 s.petAt = s.tick
683 s.fuss = dozing ? 'stir' : starving ? 'plead' : s.affection > 3.5 ? 'spin' : s.affection > 1.5 ? 'wiggle' : 'purr'
684 s.antic = null
685 if (!dozing) s.activeAt = s.tick
686
687 return s.fuss
688}
689
690// A key pressed in the prompt. After a long quiet it waves hello first; says whether it does.
691export const typed = (s: Scene) => {
692 const away = s.tick - s.activeAt >= AWAY && s.tick - s.typedAt >= AWAY
693
694 if (away) s.greetAt = s.tick
695 s.typedAt = s.tick
696
697 return away
698}
699
700// Whether nothing at all is going on, so an antic may run.
701const calm = (s: Scene, f: Shape) =>
702 !s.busy &&
703 !s.waiting &&
704 !f.eating &&
705 !f.typing &&
706 !f.perking &&
707 !f.cheering &&
708 !f.sleeping &&
709 !f.yawning &&
710 !f.starving &&
711 !f.angry &&
712 !f.gagging &&
713 !f.fedUp &&
714 !f.petting &&
715 !f.greeting &&
716 s.power < 0.05 &&
717 s.servings.length === 0 &&
718 s.motes.length === 0 &&
719 !comboShown(s)
720
721// A weighted pick, never the one just done; the falling star only after dark,
722// the side scratch only for a monster with arms.
723const choose = (s: Scene, f: Shape): Antic => {
724 const night = daylight(s.hour) < 0.4
725 const options = (Object.keys(ANTICS) as Antic[]).filter(
726 kind => kind !== s.lastAntic && (kind !== 'star' || night) && (kind !== 'scratch' || f.monster !== 'ghost'),
727 )
728 let roll = random() * options.reduce((sum, kind) => sum + ANTICS[kind].weight, 0)
729
730 return options.find(kind => (roll -= ANTICS[kind].weight) < 0) ?? options[0]!
731}
732
733const progress = (s: Scene) => (s.antic === null ? 0 : (s.tick - s.antic.at) / s.antic.len)
734// Up over the first `rise` of the way, held, down over the last `fall`.
735const swell = (p: number, rise: number, fall: number) => clamp(Math.min(p / rise, (1 - p) / fall))
736
737// How the body moves: a step aside, a lift, a stretch, and for a spin whether its back is turned.
738const motion = (s: Scene, r0: number) => {
739 const t = s.tick
740 const m = { dx: 0, lift: 0, sx: 1, sy: 1, back: false }
741 const ghost = s.look.monster === 'ghost'
742
743 if (t - s.petAt < PET) {
744 const p = (t - s.petAt) / PET
745
746 if (s.fuss === 'purr') m.dx = Math.sin(t * 1.5) * 1.2 * (1 - p)
747 if (s.fuss === 'wiggle') {
748 m.dx = Math.sin(t * 1.5) * 2 * (1 - p)
749 m.lift = Math.abs(Math.sin(p * Math.PI * 2)) * r0 * 0.18
750 }
751 if (s.fuss === 'spin') {
752 const q = clamp(p / 0.6)
753 const turn = Math.cos(q * Math.PI * 2)
754
755 m.sx = Math.max(0.25, Math.abs(turn))
756 m.back = turn < 0
757 m.lift = Math.sin(Math.PI * q) * r0 * 0.4
758 }
759 if (s.fuss === 'stir') m.dx = p < 0.6 ? Math.sin(t * 0.9) * 0.8 : 0
760 if (s.fuss === 'plead') m.lift = Math.abs(Math.sin(t * 0.45)) * 0.8
761
762 return m
763 }
764
765 if (t - s.greetAt < GREET) {
766 const p = (t - s.greetAt) / GREET
767
768 m.lift = p < 0.4 ? Math.sin((Math.PI * p) / 0.4) * r0 * 0.25 : 0
769 if (ghost) m.dx = Math.sin(t * 0.9) * 2
770
771 return m
772 }
773
774 const a = s.antic
775
776 if (a === null) return m
777
778 const p = progress(s)
779
780 if (a.kind === 'stretch') {
781 const k = swell(p, 0.25, 0.25)
782
783 m.sy = 1 + 0.16 * k
784 m.sx = 1 - 0.08 * k
785 // A satisfied shake to finish.
786 if (p > 0.8) m.dx = Math.sin(t * 2.2) * 0.8
787 }
788 if (a.kind === 'look') {
789 m.dx = p < 0.1 ? 0 : p < 0.32 ? -1 : p < 0.56 ? 1 : 0
790 // The shrug: shoulders up and down.
791 m.lift = p >= 0.62 ? Math.sin(clamp((p - 0.62) / 0.3) * Math.PI) * 1.6 : 0
792 }
793 // No arms to wave with: the ghost rocks side to side instead.
794 if (a.kind === 'wave' && ghost) {
795 m.dx = Math.sin(t * 0.9) * 2
796 m.lift = Math.abs(Math.sin(t * 0.45)) * 1.5
797 }
798 // Leaning into the itch.
799 if (a.kind === 'scratch') m.dx = swell(p, 0.15, 0.15)
800 if (a.kind === 'chase') {
801 m.lift =
802 p < 0.56
803 ? Math.abs(Math.sin(p * Math.PI * 6)) * r0 * 0.12
804 : p < 0.74
805 ? Math.sin(((p - 0.56) / 0.18) * Math.PI) * r0 * 0.3
806 : 0
807 }
808 if (a.kind === 'hop') m.lift = Math.abs(Math.sin(p * Math.PI * 3)) * r0 * 0.3
809 if (a.kind === 'peek') {
810 const k = swell(p, 0.15, 0.2)
811
812 m.sy = 1 - 0.07 * k
813 m.sx = 1 + 0.04 * k
814 if (p >= 0.8) m.lift = Math.abs(Math.sin(((p - 0.8) / 0.2) * Math.PI * 2)) * 1.2
815 }
816 if (a.kind === 'star' && hash(a.at, 4) < 0.7 && p > 0.62 && p < 0.86) m.lift = Math.sin(((p - 0.62) / 0.24) * Math.PI) * r0 * 0.25
817 if (a.kind === 'juggle' && p > 0.9) m.lift = Math.sin(((p - 0.9) / 0.1) * Math.PI) * 1.2
818
819 return m
820}
821
822// The thing an antic plays with, where it is at `p`: the firefly or butterfly, the
823// falling star, the token. Null when there is none (yet, or any more).
824const prop = (s: Scene, f: Shape, p = progress(s)) => {
825 const a = s.antic
826
827 if (a === null) return null
828
829 const side = a.seed < 0.5 ? -1 : 1
830
831 if (a.kind === 'chase') {
832 const away = clamp((p - 0.66) / 0.34)
833 const y0 = f.rest - f.ry * 0.9
834
835 return {
836 x: f.home + side * f.rx * 1.3 * Math.cos(Math.PI * 2 * 1.15 * Math.min(p, 0.66)) + side * away * f.rx * 2.5,
837 y: y0 + Math.sin(Math.PI * 2 * 2.3 * p) * f.ry * 0.3 - away * away * (y0 + 6),
838 }
839 }
840
841 if (a.kind === 'star') {
842 const { aim, caught, start } = falling(s, f)
843 const land = caught ? aim : aim - side * (f.rx + 3)
844 const q = clamp((p - 0.12) / (caught ? 0.48 : 0.6))
845
846 if ((caught && p >= 0.6) || p >= 0.72) return null
847
848 return {
849 x: ease(start, land, q),
850 y: ease(2, caught ? f.rest + f.ry * 0.34 : f.floor - 1, q ** 1.4),
851 }
852 }
853
854 if (a.kind === 'juggle') {
855 const head = { x: f.cx, y: f.cy - f.ry - 1.5 }
856 const [left, right] =
857 f.monster === 'ghost' ? [head, head] : [-1, 1].map(side => ({ x: f.cx + side * f.rx * 0.95, y: f.cy + f.ry * 0.15 }))
858 const arc = (from: Point, to: Point, u: number, height: number) => ({
859 x: ease(from.x, to.x, u),
860 y: ease(from.y, to.y, u) - 4 * height * u * (1 - u),
861 })
862
863 // Three throws hand to hand (bounces on the head, for the ghost), then a high one into the mouth.
864 if (p < 0.72) {
865 const q = (p / 0.72) * 3
866 const k = Math.floor(q)
867
868 return arc(k % 2 === 0 ? left! : right!, k % 2 === 0 ? right! : left!, q - k, f.monster === 'ghost' ? 5 : f.ry * 1.3 + 2)
869 }
870
871 return p < 0.9 ? arc(right!, f.mouth, (p - 0.72) / 0.18, f.ry * 1.4 + 4) : null
872 }
873
874 return null
875}
876
877// Where the star falls from, where it is aimed, and whether the monster gets it.
878const falling = (s: Scene, f: Shape) => {
879 const a = s.antic!
880 const side = a.seed < 0.5 ? -1 : 1
881 const aim = f.home + (hash(a.at, 3) - 0.5) * f.rx * 1.2
882
883 return { aim, caught: hash(a.at, 4) < 0.7, start: aim + side * f.home * 0.7 }
884}
885
886type Feel = [Eyes, Brows, number, number, number, number]
887
888// The face for each antic, as targets for mood(): eyes, brows, lid, mouth open, smile, blush.
889const feel = (s: Scene, f: Shape, a: NonNullable<Scene['antic']>): Feel => {
890 const p = progress(s)
891
892 if (a.kind === 'stretch') return p < 0.78 ? ['closed', 'none', 1, 0.55, 0.3, 0.2] : ['happy', 'none', 0, 0, 0.8, 0.4]
893 if (a.kind === 'scratch') return ['open', 'none', 0.5, 0, 0.7, 0.3]
894 if (a.kind === 'look') return p < 0.6 ? ['open', 'none', 0, 0, 0.1, 0] : ['open', 'sad', 0, 0, -0.05, 0]
895 if (a.kind === 'wave' || a.kind === 'hop') return ['happy', 'none', 0, 0.35, 1, 0.5]
896 if (a.kind === 'chase') {
897 if (p < 0.6) return ['open', 'none', 0, 0.2, 0.6, 0.3]
898 if (p < 0.74) return ['open', 'fierce', 0, 0.5, 0.4, 0.3]
899 return p < 0.88 ? ['open', 'none', 0, 0.3, 0.3, 0.2] : ['happy', 'none', 0, 0, 0.9, 0.4]
900 }
901 if (a.kind === 'juggle') {
902 if (p < 0.78) return ['open', 'none', 0, 0.1, 0.7, 0.3]
903 if (p < 0.9) return ['open', 'none', 0, 0.8, 0.5, 0.3]
904 return ['happy', 'none', 0, Math.abs(Math.sin(f.t * 0.8)) * 0.4, 1, 0.7]
905 }
906 if (a.kind === 'peek') return p < 0.8 ? ['open', 'none', 0, 0.15, 0, 0] : ['happy', 'none', 0, 0, 0.8, 0.3]
907
908 const { caught } = falling(s, f)
909
910 if (p < 0.12) return ['open', 'none', 0, 0, 0.4, 0]
911 if (p < 0.35) return ['open', 'none', 0, 0.2, 0.5, 0.2]
912 if (p < 0.6) return ['open', 'none', 0, 0.9, 0.4, 0.3]
913 if (caught) return ['happy', 'none', 0, 0, 1, 1]
914 return p < 0.72 ? ['open', 'none', 0, 0.5, 0.2, 0] : ['open', 'sad', 0.2, 0, -0.6, 0]
915}
916
917// Where the eyes look: up at you to plead, after the critter, the star or the
918// token, around the garden, or down at the readout under the picture.
919const glance = (s: Scene, f: Shape): Point | null => {
920 if (f.calling) return { x: 0, y: 0 }
921 if (f.petting) return s.fuss === 'plead' ? { x: 0, y: -0.7 } : null
922 if (f.greeting) return { x: 0, y: 0 }
923
924 const a = s.antic
925
926 if (a === null) return null
927
928 const p = progress(s)
929 const thing = prop(s, f)
930
931 if (thing !== null) {
932 const [dx, dy] = [thing.x - f.cx, thing.y - (f.cy - f.ry * 0.4)]
933 const d = Math.max(1, Math.hypot(dx, dy))
934
935 return { x: dx / d, y: dy / d }
936 }
937
938 if (a.kind === 'look') return p < 0.1 ? { x: 0, y: 0 } : p < 0.32 ? { x: -0.95, y: 0.1 } : p < 0.56 ? { x: 0.95, y: 0.1 } : { x: 0, y: 0 }
939 if (a.kind === 'scratch') return { x: 0.8, y: 0.6 }
940 if (a.kind === 'peek') return p < 0.8 ? { x: Math.sin(p * Math.PI * 5) * 0.8, y: 1 } : { x: 0, y: 0.3 }
941 if (a.kind === 'chase') return { x: (a.seed < 0.5 ? -1 : 1) * 0.6, y: -0.9 }
942 if (a.kind === 'star') return falling(s, f).caught ? { x: 0, y: 0 } : { x: (a.seed < 0.5 ? 1 : -1) * 0.7, y: 0.9 }
943
944 return null
945}
946
947// Where a hand goes for a pet, the greeting or an antic, or null to leave it to the usual poses.
948const pose = (s: Scene, f: Shape, side: number, ax: number, ay: number, length: number, rest: Point): Point | null => {
949 const { t } = f
950 const up = { x: ax + side * length * 0.6, y: ay - length * 1.1 }
951 const wave = { x: ax + side * length * 0.55 + Math.sin(t * 1.1) * length * 0.45, y: ay - length * 1.05 }
952
953 if (f.petting) {
954 const p = (t - s.petAt) / PET
955
956 // Hands clasped under the chin to beg; out wide to spin; on its blushing cheeks to purr.
957 if (s.fuss === 'plead') return { x: f.mouth.x + side * 1.5, y: f.mouth.y + 2.5 }
958 if (s.fuss === 'stir') return null
959 if (s.fuss === 'spin') return p < 0.6 ? { x: ax + side * length * 0.9, y: ay - length * 0.2 } : up
960
961 return { x: f.cx + side * f.rx * 0.8, y: f.cy + f.ry * 0.2 }
962 }
963
964 if (f.greeting) return side === -1 ? wave : null
965
966 const a = s.antic
967
968 if (a === null) return null
969
970 const p = progress(s)
971
972 if (a.kind === 'stretch') {
973 const k = swell(p, 0.25, 0.25)
974
975 return { x: ax + side * length * (0.6 - 0.25 * k), y: ay - length * (0.2 + 1.2 * k) }
976 }
977 if (a.kind === 'scratch') {
978 return side === 1 ? { x: f.cx + f.rx * 0.78, y: f.cy + f.ry * 0.2 + Math.sin(t * 1.6) * 2 } : null
979 }
980 if (a.kind === 'look') return p >= 0.6 ? { x: ax + side * length * 0.95, y: ay - length * 0.15 } : null
981 if (a.kind === 'wave') return side === -1 ? wave : null
982 if (a.kind === 'hop') return { x: ax + side * length * 0.8, y: ay - length * 0.6 }
983 if (a.kind === 'peek') return { x: f.cx + side * f.rx * 0.5, y: f.cy + f.ry * 0.8 }
984
985 if (a.kind === 'chase') {
986 const clap = prop(s, f, 0.66)!
987 const k = p < 0.54 ? 0 : p < 0.64 ? (p - 0.54) / 0.1 : p < 0.78 ? 1 : 1 - (p - 0.78) / 0.12
988
989 return k <= 0 ? null : { x: ease(rest.x, clap.x + side * 1.3, k), y: ease(rest.y, clap.y, k) }
990 }
991
992 if (a.kind === 'juggle') {
993 const thing = prop(s, f)
994 const base = { x: f.cx + side * f.rx * 0.95, y: f.cy + f.ry * 0.15 }
995 const near = thing === null ? 0 : clamp(1 - Math.hypot(thing.x - base.x, thing.y - base.y) / 5)
996
997 return p < 0.92 ? { x: base.x, y: base.y + 1 - near * 2 } : { x: f.cx + side * f.rx * 0.35, y: f.cy + f.ry * 0.55 }
998 }
999
1000 // The star: reach up for it; caught, pat the belly.
1001 if (p < 0.3) return null
1002 if (p < 0.62) return up
1003 if (falling(s, f).caught) return { x: f.cx + side * f.rx * 0.35, y: f.cy + f.ry * (0.55 + (t % 6 < 3 ? 0.05 : 0)) }
1004
1005 return null
1006}
1007
1008// One frame of life: start, run and stop the antics, step toward what it chases,
1009// and send up the hearts, sparkles and crumbs.
1010const live = (s: Scene, f: Shape) => {
1011 const a = s.antic
1012
1013 if (!calm(s, f)) {
1014 s.antic = null
1015 s.anticAt = -1
1016 } else if (a !== null && s.tick - a.at >= a.len) {
1017 s.antic = null
1018 s.anticAt = s.tick + LULL + random() * LULL_MORE
1019 } else if (a === null && s.anticAt < 0) {
1020 s.anticAt = s.tick + LULL + random() * LULL_MORE
1021 } else if (a === null && s.tick >= s.anticAt && s.tick - s.activeAt + 40 < DOZE - YAWN) {
1022 const kind = choose(s, f)
1023
1024 s.antic = { kind, at: s.tick, len: ANTICS[kind].len, seed: random() }
1025 s.lastAntic = kind
1026 }
1027
1028 const now = s.antic
1029 const p = progress(s)
1030 const room = Math.max(0, f.home - f.rx - 2)
1031 let target = 0
1032
1033 if (now?.kind === 'chase' && p < 0.62) target = ((prop(s, f)?.x ?? f.home) - f.home) * 0.45
1034 if (now?.kind === 'star' && p > 0.1 && p < 0.85) target = falling(s, f).aim - f.home
1035
1036 s.shift = ease(s.shift, clamp(target, -room, room), 0.15)
1037 if (target === 0 && Math.abs(s.shift) < 0.05) s.shift = 0
1038
1039 const sparkle = (x: number, y: number, n: number, color: number) => {
1040 for (let i = 0; i < n; i++) {
1041 const angle = (i / n) * Math.PI * 2
1042
1043 s.bits.push({ kind: 'spark', x, y, vx: Math.cos(angle) * 0.45, vy: Math.sin(angle) * 0.45 - 0.1, life: 16, max: 16, color })
1044 }
1045 }
1046 const heart = (x: number, y: number, vx: number, vy: number, life = 28) =>
1047 s.bits.push({ kind: 'heart', x, y, vx, vy, life, max: life, color: HEART })
1048
1049 if (now !== null) {
1050 const e = s.tick - now.at
1051 const at = (q: number) => e === Math.round(now.len * q)
1052
1053 if (now.kind === 'star' && falling(s, f).caught && at(0.6)) {
1054 sparkle(f.mouth.x, f.mouth.y - 1, 10, GOLD_LIGHT)
1055 s.squashV += 0.1
1056 }
1057 if (now.kind === 'star' && !falling(s, f).caught && at(0.72)) {
1058 s.bits.push({ kind: 'puff', x: f.home + (falling(s, f).aim - f.home) - (now.seed < 0.5 ? -1 : 1) * (f.rx + 3), y: f.floor - 2, vx: 0, vy: -0.2, life: 18, max: 18, color: 0xfff1b0 })
1059 }
1060 if (now.kind === 'chase' && at(0.66)) {
1061 const clap = prop(s, f, 0.66)!
1062
1063 sparkle(clap.x, clap.y, 4, WHITE)
1064 }
1065 if (now.kind === 'juggle' && at(0.9)) {
1066 s.squashV += 0.08
1067 for (let i = 0; i < 5; i++) {
1068 s.bits.push({ kind: 'crumb', x: f.mouth.x, y: f.mouth.y, vx: (random() - 0.5) * 1.2, vy: -0.4 - random() * 0.6, life: 22, max: 22, color: GOLD })
1069 }
1070 }
1071 if (now.kind === 'hop' && (at(1 / 3) || at(2 / 3))) s.squashV += 0.1
1072 if (now.kind === 'scratch' && e % 7 === 3 && f.monster !== 'ghost') {
1073 s.bits.push({ kind: 'crumb', x: f.cx + f.rx * 0.95, y: f.cy + f.ry * 0.3, vx: 0.3 + random() * 0.4, vy: -0.5, life: 16, max: 16, color: mix(f.body, WHITE, 0.45) })
1074 }
1075 }
1076
1077 // Petting: hearts float up, more the fonder it is, and a ring of them for a spin.
1078 const since = s.tick - s.petAt
1079
1080 if (since >= 1 && since < PET) {
1081 const top = f.cy - f.ry * (f.monster === 'cookie' ? 1.3 : 1) - 2
1082 const above = () => [f.cx + (random() - 0.5) * f.rx * 1.2, top] as const
1083
1084 if ((s.fuss === 'purr' || s.fuss === 'wiggle') && (since === 1 || since % 7 === 0)) heart(...above(), (random() - 0.5) * 0.3, -0.3)
1085 if (s.fuss === 'wiggle' && since === 1) heart(...above(), (random() - 0.5) * 0.5, -0.4)
1086 if (s.fuss === 'stir' && since === 4) heart(f.cx - f.rx * 0.5, top, -0.1, -0.2, 22)
1087 if (s.fuss === 'spin' && since === Math.round(PET * 0.6)) {
1088 s.squashV += 0.12
1089 for (let i = 0; i < 6; i++) {
1090 const angle = (i / 6) * Math.PI * 2 - Math.PI / 2
1091
1092 heart(f.cx + Math.cos(angle) * (f.rx + 3), f.cy + Math.sin(angle) * (f.ry + 2), Math.cos(angle) * 1.1, Math.sin(angle) * 0.7)
1093 }
1094 }
1095 if (s.fuss === 'plead' && since === 8 && f.monster !== 'slime') {
1096 s.bits.push({ kind: 'tear', x: f.cx - f.rx * 0.45, y: f.cy - f.ry * 0.1, vx: 0, vy: 0.2, life: 16, max: 16, color: 0x6ec6ff })
1097 }
1098 }
1099}
1100
1101// What the antics play with, drawn over the monster: the critter, the star, the
1102// token, a question mark over a shrug, and the glow of a star it swallowed.
1103const antics = (c: Canvas, s: Scene, f: Shape) => {
1104 const a = s.antic
1105
1106 if (a === null) return
1107
1108 const p = progress(s)
1109 const thing = prop(s, f)
1110
1111 if (a.kind === 'look' && p >= 0.62 && p < 0.97) write(c, '?', f.cx + f.rx * 0.75, f.cy - f.ry - 5, 1, WHITE, 0xc8d0e0)
1112
1113 if (a.kind === 'star' && thing === null && falling(s, f).caught && p >= 0.6) {
1114 const fade = 1 - (p - 0.6) / 0.4
1115
1116 for (let i = 0; i < 5; i++) {
1117 const angle = (i / 5) * Math.PI * 2 + f.t * 0.15
1118
1119 if (hash(i, f.t >> 1) < 0.75) c.add(f.cx + Math.cos(angle) * (f.rx + 2), f.cy + Math.sin(angle) * (f.ry + 2), GOLD_LIGHT, fade)
1120 }
1121 }
1122
1123 if (thing === null) return
1124
1125 const { x, y } = thing
1126
1127 if (a.kind === 'chase' && daylight(s.hour) < 0.4) {
1128 const glow = 0.6 + 0.4 * Math.sin(f.t * 0.5)
1129
1130 for (let dy = -2; dy <= 2; dy++) {
1131 for (let dx = -2; dx <= 2; dx++) {
1132 const d = Math.hypot(dx, dy)
1133
1134 if (d <= 2.3) c.add(x + dx, y + dy, 0xd8ff7a, (d < 1.2 ? 0.6 : 0.25) * glow)
1135 }
1136 }
1137 c.put(x, y, 0xf4ffc8)
1138 } else if (a.kind === 'chase') {
1139 // A butterfly: wings open and shut every other frame.
1140 const open = (f.t >> 1) % 2 === 0
1141 const wings: [number, number][] = open ? [[-2, -1], [-1, -1], [-1, 0], [1, -1], [2, -1], [1, 0]] : [[-1, -1], [1, -1]]
1142
1143 for (const [dx, dy] of wings) c.put(x + dx, y + dy, Math.abs(dx) === 2 ? 0xffe066 : 0xff8a3d)
1144 c.put(x, y - 1, INK)
1145 c.put(x, y, INK)
1146 } else if (a.kind === 'star') {
1147 if (p < 0.12) {
1148 // It appears as a twinkle before it falls.
1149 const r = 1 + Math.round((p / 0.12) * 1.5)
1150
1151 for (let k = -r; k <= r; k++) {
1152 c.add(x + k, y, 0xfff6c0, 1 - Math.abs(k) / (r + 1))
1153 c.add(x, y + k, 0xfff6c0, 1 - Math.abs(k) / (r + 1))
1154 }
1155 } else {
1156 const before = prop(s, f, p - 0.06) ?? thing
1157 const [dx, dy] = [x - before.x, y - before.y]
1158
1159 for (let k = 1; k <= 5; k++) c.add(x - (dx * k) / 3, y - (dy * k) / 3, 0xffe9a0, 0.7 - k * 0.12)
1160 for (const [ox, oy] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) c.put(x + ox, y + oy, GOLD_LIGHT)
1161 c.put(x, y, WHITE)
1162 }
1163 } else if (a.kind === 'juggle') {
1164 // A spinning gold token.
1165 const w = Math.max(0.5, Math.abs(Math.cos(f.t * 0.6)) * 1.6)
1166
1167 for (let dy = -2; dy <= 2; dy++) {
1168 for (let dx = -2; dx <= 2; dx++) {
1169 const d = (dx / w) ** 2 + (dy / 1.7) ** 2
1170
1171 if (d <= 1) c.put(x + dx, y + dy, d > 0.55 ? GOLD_DARK : dx < 0 && dy < 0 ? GOLD_LIGHT : GOLD)
1172 }
1173 }
1174 }
1175}
1176
1177// The fewest pixels tall the art needs to show the whole monster, its hat included.
1178const TALL = 28
1179
1180// The canvas the scene lives on: the one asked for, or, too short for the art (a wide,
1181// short pane), one TALL pixels high and as much wider, which paint() shrinks to fit.
1182const fit = (width: number, height: number) =>
1183 height >= TALL ? ([width, height] as const) : ([Math.round((width * TALL) / height), TALL] as const)
1184
1185// Moves the scene on one frame of a `columns` by `rows` pixel canvas. Idle, only breath,
1186// blinks and glances; the chew, the bounce, the arms and the motes run as fast as tokens arrive.
1187export const step = (s: Scene, columns: number, rows: number) => {
1188 const [width, height] = fit(columns, rows)
1189
1190 s.tick += 1
1191 s.rate = s.rate * 0.85 + s.arrived * 0.15
1192 s.arrived = 0
1193 s.heat = 1 - Math.exp(-s.rate / 8)
1194 s.breathe += s.tick - s.activeAt > DOZE ? 0.02 : 0.035
1195
1196 if (s.heat > 0.02) {
1197 s.chew += 0.15 + s.heat * 0.9
1198 s.bob += 0.12 + s.heat * 0.7
1199 }
1200hooks/sound.tsx 86 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { isSoundWord } from './format'
5import type { Scene } from './paint'
6
7// One sound, off until the person turns it on with s or `/token-monster sound on`: a
8// soft two-note chime, once, when Claude starts waiting on them. The clip is the
9// plugin's own sounds/call.wav, made by scripts/make-sounds.py. $.audio.play plays it
10// with afplay on macOS; other terminals stay silent.
11//
12// It keeps no rules of its own: it chimes when the monster starts calling (the
13// `waiting` atom), so the two can never disagree. Never while the person types.
14
15const sound = atom({ plugin: 'token-monster', key: 'sound' } as const, false)
16
17const CALL = 'sounds/call.wav'
18const GAIN = 0.3
19// Typed this recently, in ms, the person is typing and the monster keeps quiet.
20const TYPING = 2000
21
22const LINE = 'Usage: /token-monster sound on|off'
23
24const isOn = ($: EngineInterface) => read($, sound)
25
26// Starts the clip and returns without waiting for it to play or end.
27const chime = async ($: EngineInterface, scene: Scene) => {
28 if (!(await isOn($)) || (await $.clock.now()) - scene.typedMs < TYPING) return
29
30 void $.audio.play({ asset: CALL }, { gain: GAIN }).catch(() => {})
31}
32
33const turnOn = async ($: EngineInterface, value: boolean) => {
34 await update($, sound, () => value)
35 await $.store.set('sound', value)
36
37 if (value && (await $.store.get('soundHeard')) !== true) {
38 await $.store.set('soundHeard', true)
39 $.ui.toast('Sound on: me chime softly once when Claude waits on you. macOS only. Press s or /token-monster sound off to stop.')
40 }
41}
42
43// `scene` is the monster's own (register.tsx): plain data may cross an import, $ may not.
44export const registerSound = (on: On, scene: Scene) => {
45 // A `claude -p` run never loads the choice, so it stays silent.
46 on('session.start', { isInteractive: true }, async ($, e, next) => {
47 const done = await next(e)
48
49 const value = (await $.store.get('sound')) === true
50
51 await update($, sound, () => value)
52
53 return done
54 })
55
56 on('command.run', { command: 'token-monster' }, async ($, e, next) => {
57 if (!isSoundWord(e.args)) return next(e)
58
59 const word = e.args.trim().toLowerCase().split(/\s+/)[1]
60
61 if (word !== undefined && word !== 'on' && word !== 'off') return { text: LINE }
62
63 const value = word === undefined ? !(await isOn($)) : word === 'on'
64
65 await turnOn($, value)
66
67 return { text: value ? 'Sound on. Me chime when Claude waits on you (macOS).' : 'Sound off. Me call you quietly.' }
68 })
69
70 // The monster's s button: the press lands here, as $ cannot cross into this module.
71 on('ui.press', { plugin: 'token-monster', element: 'sound' }, async ($, e) => {
72 await turnOn($, !(await isOn($)))
73
74 return { element: e.element }
75 })
76
77 // Claude started waiting on the person: one chime, and none for a second call while it waits.
78 on('state.set', { plugin: 'token-monster', key: 'waiting' }, async ($, e, next) => {
79 const done = await next(e)
80
81 if (e.value != null && e.previous == null) await chime($, scene)
82
83 return done
84 })
85}
86types/index.d.ts 52 lines1// The contract must be self-contained (no imports), so Limit mirrors the
2// engine's SessionRateLimit here rather than importing it.
3export type Belly = {
4 percent: number
5 tokens: number
6 window: number
7 ate: number
8 fedAt: number
9 burpAt: number | null
10 known: boolean
11 // Where auto-compaction runs, in tokens, or null when it is off or unknown;
12 // `fill` is the percent of that point used (of the window without one).
13 compactAt: number | null
14 fill: number
15}
16export type Look = { monster: string; color: string }
17export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
18// One category of the context window, as /context lists it: `used`, `free`, or the
19// auto-compact `buffer`.
20export type Slice = { name: string; tokens: number; kind: string }
21// The prompt cache as the main loop's last response left it: when it came (ms), the
22// tokens the next request re-sends, and how long the cache keeps them (ms).
23export type Cache = { at: number; tokens: number; ttl: number }
24export type Dish = { id: string; tool: string; label: string; tokens: number }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'token-monster': {
29 belly: Belly | null
30 look: Look
31 now: number
32 pantry: Limit[]
33 doing: string
34 menu: Dish[]
35 picked: string[]
36 armed: string[]
37 serving: number
38 view: string
39 slices: Slice[]
40 level: number
41 combo: number
42 chat: { eye: string; say: string } | null
43 xp: number
44 born: number | null
45 hatching: boolean
46 sound: boolean
47 waiting: string | null
48 cache: Cache | null
49 }
50 }
51}
52