SLOPSHOPPER

token-monster

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

newpanebandguardcommandtoast
★ 1v0.4.2MITupdated 2026-10-04ugglr/claude-token-monster
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-monster
│ ┃ Token Monster ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /token-monster │ ┃ ⎿ token-monster: Token Monster is hungry. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ nom nom │ ┃ fed 0m ago │ ┃ belly ██████████░░░░░░░░░░ 49% 97k/200k │ ┃ session ██████░░░░░░░░░░░░░░ 31% NaNm │ ┃ Lv 1 ──────────────────── 20k to Lv 2 (o)(o) nom nom ████░░░░ 49% session 31% /token-monster ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(o)(o) nom nom ████░░░░ 49% session 31% /token-monster
Pane · Token Monster
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ nom nom fed 0m ago belly ██████████░░░░░░░░░░ 49% 97k/200k session ██████░░░░░░░░░░░░░░ 31% NaNm Lv 1 ──────────────────── 20k to Lv 2 pantry full. feast time! m: Monster c: Color p: Pet d: Diet: free context s: Sound: off ctrl+x tab or a click gives me the keys
README

Claude Token Monster

Dad approved

The cast: eating, on fire, super mode, a frenzy, asleep, petted, levelling up, hatching, crying, a K.O., watching you type, and a burp

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

Install

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.

Use

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.

Read the monster

  • Size: how full the context is. The bar below splits it by category, as /context does.
  • Face: its mood. Stuffed past 75%, dizzy past 90% of the point where Claude Code auto-compacts (time to /compact). Sad and then grey if nothing feeds it.
  • Glow: your session and weekly limits, green to amber to pulsing red.
  • Chewing: how fast tokens stream right now, colored by tool.
  • On fire: tool calls landing back to back. Three or more end the turn in a K.O.
  • Gagging: one tool result over 20k tokens. Its color goes sickly with a green tinge, it clutches its throat and coughs, and the readout names the call (and, for the main conversation, points you at the diet, d).
  • Fed up: the same tool call (the same tool with the same input) failed twice in a row, with no success between. A refusal does not count. It crosses its arms under a throbbing anger mark for a few seconds. It only shows; Claude is never told.
  • Super mode: subagents running. Each one shows up as a little helper that feeds it. With the rate pegged too, it goes into a full frenzy.
  • Calling you: Claude is waiting on you, at a permission dialog or a question. It waves both hands and hops, a ! blinks beside its head, and the readout names the call (or says how many wait); with sound on, one soft chime. It stops once the call runs or is refused; nothing says a dialog was answered, so an approved web or MCP call keeps it calling until it returns.
  • Cold leftovers: a frosted bowl with a snowflake beside it. The prompt cache has lapsed since Claude's last request (after 5 minutes, or an hour once it sees your session keeps it that long), so the readout estimates how many tokens your next prompt re-reads uncached.
  • Levels: it grows with every token it eats, across sessions, and earns a bow tie, a propeller cap, a crown, a cape and a halo. Each session starts with an egg.

It also sleeps when nothing happens, waves when you come back, and does little antics while it waits.

Put it on a diet

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.

What it sees

Mods are not sandboxed, so read the code before you install any mod. This one reads:

  • the model's response stream (text, thinking, tool arguments), your prompts and each tool result, for the main conversation and subagents, measured for length and not kept
  • the token and cache counts of the latest main conversation response, kept as one record with when its request was sent (cleared on /clear, /compact and resume), to tell when the prompt cache goes cold
  • every argument of each tool call, to tell calls apart (a failed call is remembered as a short hash), and each result's text, to see whether the call was refused
  • each tool call's name and its file path, description, command, URL, search pattern or prompt, shown in the readout and the diet
  • the status line's context, limit and cost figures, and the /context breakdown with the auto-compact point (a local estimate, no request)
  • the running subagents, and the COLORTERM variable to pick colors
  • when a permission dialog or a question opens for you, with the tool and its label
  • the conversation's tool results when you open the diet, and the conversation on a diet /compact

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

Notes

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

Source 7 files
hooks/register.tsx 1241 lines
1import { 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 lines
1import { 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}
295
hooks/format.ts 22 lines
1// 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}`
22
hooks/grow.ts 30 lines
1// 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}
30
hooks/paint.ts 3041 lines
1import 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  }
1200
hooks/sound.tsx 86 lines
1import { 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}
86
types/index.d.ts 52 lines
1// 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