SLOPSHOPPER

prompt-mascot

A little Claude that stands above the prompt: thinks, types on a laptop while tools run, and reacts to each reply.

newbandguardcommandmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-mascot
› 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 › /mascot ⎿ prompt-mascot: Mascot hidden. Run /mascot to bring it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Prompt Mascot

A little Claude that stands above the prompt in Claude Code. It thinks while the model thinks, pulls out a laptop while tools run, and reacts to each reply: happy, excited, curious, worried, sad or surprised. Left alone it settles down and eventually falls asleep.

In the desktop app it's an animated pixel-art figure with a speech bubble; in the terminal it's a small face with a status line.

Unofficial fan project. Not made, endorsed or supported by Anthropic. Claude is a trademark of Anthropic.

Install

In Claude Code:

/plugin marketplace add MinhTam2773/prompt-mascot
/plugin install prompt-mascot@prompt-mascot

Start a new session and the mascot appears above the prompt.

Requirements

Prompt Mascot uses Claude Code's hooks-module plugin format, which needs a recent Claude Code. It's tested on 2.1.288; older builds (for example 2.1.97) reject the plugin. Run claude --version to check, and update if the install fails.

Commands

CommandWhat it does
/mascotShow or hide the mascot
/mascot show / /mascot hideShow or hide explicitly
/mascot demoPlay every emotion in turn (about 38 seconds)
/mascot stopEnd the demo early

How it picks a mood

After each reply, the mascot sends the start and end of the reply (up to about 1,200 characters) to Claude's small, fast model to read its tone. That's one small extra model call per reply. If the call fails or takes longer than 6 seconds, it falls back to matching keywords in the reply. A reply ending in a question makes it curious; an interrupted turn surprises it.

It doesn't read or write your files, run commands, or make network requests of its own.

Sharing the box with your own mods

The mascot lives in the box above the prompt (the AbovePrompt band), and other mods can draw there too. You don't have to choose between them:

  • The mascot keeps your mod. It lets the other mods in the band draw first, then adds itself on its own row underneath what they drew.
  • Your mod should keep the mascot. If your AbovePrompt hook returns its own tree without calling next(e), everything beneath it in the chain is dropped, and that can include the mascot. Call next(e) and put its result in your tree:
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
  const below = await next(e) // the mascot, and any other mods in the band
  const { Box, Text } = $.ui.resolve(e)

  return (
    <Box flexDirection="column">
      <Text>Your mod's row</Text>
      {below}
    </Box>
  )
})

Keep below inside a plain column like this one. What next(e) returns can be the engine's own drawing, which Claude Code refuses to place inside a Box that sets sizing props such as width.

Development

The plugin lives in plugins/prompt-mascot. To try local changes, load it straight from disk:

claude --plugin-dir plugins/prompt-mascot

Validate and run the tests:

claude plugin validate plugins/prompt-mascot
claude plugin test plugins/prompt-mascot

License

MIT

Source 4 files
hooks/register.tsx 244 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { MascotMood, MascotState } from '../types'
5import { drawMascot, FACES, HEIGHT, WIDTH } from './art'
6import { lineFor, moodForAnswer, stateForTool } from './mood'
7
8const current = atom({ plugin: 'prompt-mascot', key: 'current' } as const, { mood: 'idle', line: '' })
9const isHidden = atom({ plugin: 'prompt-mascot', key: 'isHidden' } as const, false)
10
11const IDLE: MascotState = { mood: 'idle', line: '' }
12const SLEEPY: MascotState = { mood: 'sleepy', line: '' }
13const WRITING: MascotState = { mood: 'typing', line: 'Writing a reply' }
14
15// How long a reaction stays up before it settles back to resting,
16// how long it rests before it falls asleep, and how often that is checked.
17const REACTION_MS = 2 * 60_000
18
19// The moods the small model chooses between when it reads a reply's tone,
20// and how long to wait for it before falling back to counting words. Curious
21// is not among them: it comes from a question mark in the reply's last
22// paragraph, because the model also read a request ("tell me what you see")
23// as curious.
24const MOODS_TO_READ = ['happy', 'excited', 'worried', 'sad'] as const
25const READ_TIMEOUT_MS = 6000
26const SLEEP_MS = 10 * 60_000
27const TICK_MS = 15_000
28
29// Every write redraws the picture and restarts its animations,
30// so a hook writes only when the mood or the bubble really changes.
31const same = (a: MascotState, b: MascotState) => a.mood === b.mood && a.line === b.line
32
33// The next state, remembering the mood it leaves so the picture can play
34// the transition between the two.
35const moveTo = (next: MascotState) => (now: MascotState): MascotState => ({ ...next, from: now.mood })
36
37// `/mascot demo`: every emotion in turn, each held long enough for its
38// transition and a little of its loop, the bubble naming it.
39const DEMO: ReadonlyArray<{ state: MascotState; ms: number }> = [
40  { state: { mood: 'thinking', line: 'Thinking' }, ms: 4000 },
41  { state: { mood: 'typing', line: 'Typing' }, ms: 5000 },
42  { state: { mood: 'happy', line: 'Happy' }, ms: 4000 },
43  { state: { mood: 'excited', line: 'Excited' }, ms: 3500 },
44  { state: { mood: 'curious', line: 'Curious' }, ms: 4000 },
45  { state: { mood: 'worried', line: 'Worried' }, ms: 4000 },
46  { state: { mood: 'sad', line: 'Sad' }, ms: 4500 },
47  { state: { mood: 'surprised', line: 'Surprised' }, ms: 4000 },
48  { state: { mood: 'sleepy', line: 'Sleepy' }, ms: 5000 },
49  { state: { mood: 'idle', line: '' }, ms: 0 },
50]
51
52export const register: Register = on => {
53  // When the current reaction settles to resting, and when resting turns to
54  // sleep, in milliseconds since the epoch; null when nothing is pending.
55  let settleAt: number | null = null
56  let sleepAt: number | null = null
57  let tick: Timer | undefined
58  let demo: Timer | undefined
59
60  on('session.start', async ($, e, next) => {
61    await $.command.register({
62      name: 'mascot',
63      description: 'Show or hide the Claude mascot (demo: play every emotion, stop: end the demo)',
64    })
65
66    const now = await $.clock.now()
67    const state = await read($, current)
68    if (state.mood === 'idle') sleepAt = now + SLEEP_MS
69    else if (state.mood !== 'thinking' && state.mood !== 'typing') settleAt = now + REACTION_MS
70
71    tick?.cancel()
72    tick = $.clock.every(TICK_MS, () => {
73      void (async () => {
74        const at = await $.clock.now()
75        if (settleAt !== null && at >= settleAt) {
76          settleAt = null
77          sleepAt = at + SLEEP_MS
78          await update($, current, moveTo(IDLE))
79        } else if (sleepAt !== null && at >= sleepAt) {
80          sleepAt = null
81          await update($, current, moveTo(SLEEPY))
82        }
83      })()
84    })
85
86    return next(e)
87  })
88
89  on('command.run', { command: 'mascot' }, async ($, e) => {
90    const arg = e.args.trim().toLowerCase()
91
92    if (arg === 'demo') {
93      demo?.cancel()
94      settleAt = null
95      sleepAt = null
96      await update($, isHidden, () => false)
97      const play = (i: number) => {
98        const step = DEMO[i]
99        if (step === undefined) return
100        void update($, current, moveTo(step.state))
101        if (i + 1 < DEMO.length) demo = $.clock.after(step.ms, () => play(i + 1))
102      }
103      play(0)
104      const seconds = Math.round(DEMO.reduce((sum, step) => sum + step.ms, 0) / 1000)
105      return { text: `Playing every emotion in turn, about ${seconds} seconds. /mascot stop ends it early.` }
106    }
107
108    if (arg === 'stop') {
109      demo?.cancel()
110      await update($, current, moveTo(IDLE))
111      return { text: 'Demo stopped.' }
112    }
113
114    const hide = arg === 'hide' ? true : arg === 'show' ? false : !(await read($, isHidden))
115    await update($, isHidden, () => hide)
116
117    return { text: hide ? 'Mascot hidden. Run /mascot to bring it back.' : 'Mascot is back.' }
118  })
119
120  // The turn now running: a mood read for an older turn arrives too late to show.
121  let latestTurn = ''
122
123  on('turn.start', async ($, e, next) => {
124    latestTurn = e.turnId
125    demo?.cancel() // real work wins over the demo
126    settleAt = null
127    sleepAt = null
128    await update($, current, moveTo({ mood: 'thinking', line: lineFor('thinking') }))
129
130    return next(e)
131  })
132
133  // While the reply's text streams in, it is writing: out comes the laptop.
134  // Only the first visible piece of each model request switches it, and the
135  // switch runs beside the stream so no piece of text waits on it. Thinking
136  // pieces leave it thinking; a subagent's steps are not the reply.
137  on('turn.step', async function* ($, e, next) {
138    if (e.agentId !== undefined) return yield* next(e)
139    let writing = false
140    for await (const chunk of next(e)) {
141      if (!writing && chunk.kind === 'text' && chunk.text.trim() !== '') {
142        writing = true
143        void (async () => {
144          if (same(await read($, current), WRITING)) return
145          await update($, current, moveTo(WRITING))
146        })()
147      }
148      yield chunk
149    }
150  })
151
152  on('tool.call', async ($, e, next) => {
153    const doing = stateForTool(String(e.tool))
154    if (!same(await read($, current), doing)) await update($, current, moveTo(doing))
155
156    return next(e)
157  })
158
159  on('turn.complete', async ($, e, next) => {
160    // A subagent's turn ending is not the reply the person reads.
161    if (e.agentId !== undefined) return next(e)
162
163    const result = await next(e)
164    const turn = e.turnId
165    const keywords = moodForAnswer(e.answer, e.reason)
166
167    // Read the reply's tone with the small fast model, in the background so the
168    // turn never waits on it. Counting words reads the topic instead: a cheerful
169    // reply about a bug came out "sad". Interrupted and failed turns, and empty
170    // replies, need no reading; a failed or slow reading falls back to the words.
171    void (async () => {
172      let mood: MascotMood = keywords
173      if (e.reason === 'answer' && e.answer.trim() !== '' && keywords !== 'curious') {
174        const text = e.answer.length > 1200 ? `${e.answer.slice(0, 600)}\n…\n${e.answer.slice(-600)}` : e.answer
175        const reading = $.model
176          .classify(
177            `The mood this reply from Claude, an AI assistant, conveys by its tone, not by the topic it discusses.\n\n${text}`,
178            MOODS_TO_READ,
179          )
180          .catch(() => undefined)
181        const timeout = $.clock.sleep(READ_TIMEOUT_MS).then(() => undefined)
182        const label = await Promise.race([reading, timeout])
183        if (label !== undefined && (MOODS_TO_READ as readonly string[]).includes(label)) {
184          mood = label as MascotMood
185        }
186      }
187      if (latestTurn !== turn) return // a new turn started meanwhile
188      await update($, current, moveTo({ mood, line: lineFor(mood) }))
189      settleAt = (await $.clock.now()) + REACTION_MS
190    })()
191
192    return result
193  })
194
195  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196    // Other mods share this band (next-steps puts its suggestions here), so
197    // let them draw first and add the mascot on its own row after what they drew.
198    // What they drew may be the engine's own node, which the engine refuses
199    // under a Box with sizing props, so it goes into a plain column untouched.
200    const below = await next(e)
201    if (e.props.hasSurvey || (await read($, isHidden))) return below
202
203    let state = await read($, current)
204    // A turn is running but no turn.start reached us (say, right after a reload).
205    if (e.props.isWorking && (state.mood === 'idle' || state.mood === 'sleepy')) {
206      state = { mood: 'thinking', line: lineFor('thinking', 0) }
207    }
208
209    if (e.surface === 'desktop') {
210      const { Box, Svg } = $.ui.resolve(e)
211      const alt = `Claude mascot, ${state.mood}${state.line ? `: ${state.line}` : ''}`
212
213      // Drawn as a plain image, not an interactive frame: a frame loads each new
214      // picture into an empty document and shows nothing for a moment, while an
215      // image keeps the old picture up until the new one is ready. SMIL still
216      // plays in an image. The key keeps the row the same element when other
217      // mods change what is drawn above it.
218      return (
219        <Box flexDirection="column">
220          {below}
221          <Box key="prompt-mascot" flexDirection="row" justifyContent="flex-end" width="100%">
222            <Svg source={drawMascot(state)} alt={alt} width={WIDTH} height={HEIGHT} />
223          </Box>
224        </Box>
225      )
226    }
227
228    if (e.surface !== 'terminal') return below
229    const { Box, Text } = $.ui.resolve(e)
230
231    return (
232      <Box flexDirection="column">
233        {below}
234        <Box flexDirection="row" justifyContent="flex-end" width="100%">
235          <Text color="#D97757" bold>
236            {FACES[state.mood]}
237          </Text>
238          {state.line !== '' && <Text dimColor> {state.line}</Text>}
239        </Box>
240      </Box>
241    )
242  })
243}
244
hooks/art.ts 818 lines
1import type { MascotMood, MascotState } from '../types'
2
3// The pixel critter, animated the way a sprite is: every mood is a list of
4// hand-built poses, each held for a number of frames, played at FPS frames a
5// second. Nothing glides; a pose snaps to the next, as in the agent-view video.
6
7const BODY = '#D97757'
8const SHADE = '#C15F3C' // an arm crossing in front of the body
9const INK = '#1F1A17'
10const GOLD = '#F2B84B'
11const BLUE = '#7EC4F2'
12const PINK = '#F27A93'
13const RED = '#E5484D'
14const GREY = '#9AA0B4'
15const SIDE = '#C4664A' // the body in shadow: its far side, its right edge and bottom, the legs
16const LIGHT = '#E8977C' // a highlight where the light (top left) catches the body
17const SCREEN = '#A3A3AB'
18const KEYBOARD = '#8A8A92'
19const LIT = '#B9CBDA' // the screen's face, flickering between these two
20const DIM = '#8EA9BF'
21const MUG = '#F4EDE4'
22const MUG_SHADE = '#CDBFB1'
23const STEAM = '#F6EFE8'
24const COFFEE = '#6E4330'
25const MONO = "ui-monospace, 'Cascadia Mono', Consolas, Menlo, monospace"
26
27export const WIDTH = 252
28export const HEIGHT = 72
29
30// One sprite pixel is U by U CSS pixels. Grid column 0, row 0 sits at
31// (OX, OY); the feet end on row 7, at the bottom of the picture. Rows above 0
32// are headroom for jumps and for things floating over its head.
33const U = 5
34const OX = 168
35const OY = 30
36// Frames a second. Holds in the timelines below are written in tenths of a
37// second (frames at 10 fps) and converted as they play: a one-frame moment
38// (a keystroke, a take-off) stays one frame, and a long hold keeps its length.
39const FPS = 12
40const AUTHORED_FPS = 10
41const BUBBLE_RIGHT = OX - 12
42
43type Cell = readonly [number, number]
44
45type Body = 'normal' | 'bob' | 'squash' | 'stretch' | 'tipped'
46type Eyes = 'open' | 'blink' | 'dot' | 'happy' | 'closed' | 'wide'
47// 'tap', 'hover' and 'reach' are the side view's typing arms.
48type Arm = 'down' | 'up' | 'out' | 'low' | 'chin' | 'tap' | 'hover' | 'reach'
49type Legs = 'stand' | 'stepA' | 'stepB' | 'splay' | 'lift'
50
51/** Something drawn near the critter: a heart, a "?", a falling tear. */
52type Prop = { cells: readonly Cell[]; color: string; c: number; r: number; p: number }
53
54type Pose = {
55  body: Body
56  y: number // whole-body hop, in sprite pixels (negative is up)
57  lean: number // top of the body shifted sideways, legs planted
58  eyes: Eyes
59  gx: number // where the eyes look
60  gy: number
61  tilt: number // right eye raised (-1) or lowered (+1): a head tilt
62  armL: Arm
63  armR: Arm
64  legs: Legs
65  side: boolean // turned to face left, at the laptop
66  lid: 'none' | 'closed' | 'half' | 'open' // the laptop, in the side view
67  screen: number // laptop screen brightness step
68  mug: 'none' | 'low' | 'mid' | 'sip' | 'gulp' // a coffee mug in the right hand: held in front, halfway up, at the mouth, or tipped further for a gulp
69  props: readonly Prop[]
70}
71
72const BASE: Pose = {
73  body: 'normal',
74  y: 0,
75  lean: 0,
76  eyes: 'open',
77  gx: 0,
78  gy: 0,
79  tilt: 0,
80  armL: 'down',
81  armR: 'down',
82  legs: 'stand',
83  side: false,
84  lid: 'open',
85  screen: 0,
86  mug: 'none',
87  props: [],
88}
89
90const pose = (over: Partial<Pose>): Pose => ({ ...BASE, ...over })
91
92// ---- small pixel pictures for props ----
93
94function pattern(rows: readonly string[]): Cell[] {
95  const cells: Cell[] = []
96  rows.forEach((row, r) => [...row].forEach((ch, c) => ch === 'X' && cells.push([c, r])))
97  return cells
98}
99
100const HEART = pattern(['.X.X.', 'XXXXX', '.XXX.', '..X..'])
101const SPARKLE = pattern(['.X.', 'XXX', '.X.'])
102const QUESTION = pattern(['XXX', '..X', '.XX', '...', '.X.'])
103const BANG = pattern(['X', 'X', 'X', '.', 'X'])
104const ZED = pattern(['XXXX', '..X.', '.X..', 'XXXX'])
105const DOT: Cell[] = [[0, 0]]
106const DROP: Cell[] = [[0, 0], [0, 1]]
107const PUFF = pattern(['.X', 'XX'])
108const WISP = pattern(['.X', 'X.', '.X'])
109
110// ---- drawing one frame ----
111
112function bodyBox(body: Body) {
113  switch (body) {
114    case 'bob':
115      return { l: 2, r: 11, t: 1, b: 5 } // breathing out: one pixel shorter
116    case 'squash':
117      return { l: 1, r: 12, t: 2, b: 5 } // crouch or landing: low and wide
118    case 'stretch':
119      return { l: 3, r: 10, t: -2, b: 5 } // take-off or a big stretch: tall and thin
120    default:
121      return { l: 2, r: 11, t: 0, b: 5 }
122  }
123}
124
125function armCells(arm: Arm, edge: number, side: -1 | 1, a: number): { body: Cell[]; front: Cell[] } {
126  const out = (k: number) => edge + side * k
127  const inward = (k: number) => edge - side * k
128  switch (arm) {
129    case 'up':
130      // Angled up and away from the head, so it reads as an arm, not a horn.
131      return { body: [[out(1), a], [out(1), a + 1], [out(2), a - 1], [out(2), a], [out(3), a - 2]], front: [] }
132    case 'out':
133      return { body: [[out(1), a], [out(2), a], [out(3), a], [out(1), a + 1]], front: [] }
134    case 'low':
135      return { body: [[out(1), a + 1], [out(1), a + 2]], front: [] }
136    case 'chin':
137      return { body: [[out(1), a + 1]], front: [[inward(1), a + 2], [inward(2), a + 2], [inward(3), a + 1]] }
138    default:
139      return { body: [[out(1), a], [out(2), a], [out(1), a + 1], [out(2), a + 1]], front: [] }
140  }
141}
142
143function eyeCells(p: Pose, l: number, r: number, t: number): Cell[] {
144  const eL = l + 2 + p.gx
145  const eR = r - 2 + p.gx
146  const row = t + 1 + p.gy
147  const rowR = row + p.tilt
148  switch (p.eyes) {
149    case 'blink':
150    case 'dot':
151      return [[eL, row + 1], [eR, rowR + 1]]
152    case 'happy':
153      return [[eL - 1, row + 1], [eL, row], [eL + 1, row + 1], [eR - 1, rowR + 1], [eR, rowR], [eR + 1, rowR + 1]]
154    case 'closed':
155      return [[eL - 1, row + 1], [eL, row + 1], [eR, rowR + 1], [eR + 1, rowR + 1]]
156    case 'wide':
157      return [[eL - 1, row], [eL, row], [eL - 1, row + 1], [eL, row + 1], [eR, rowR], [eR + 1, rowR], [eR, rowR + 1], [eR + 1, rowR + 1]]
158    default:
159      return [[eL, row], [eL, row + 1], [eR, rowR], [eR, rowR + 1]]
160  }
161}
162
163function legCells(legs: Legs): Cell[] {
164  const top: Cell[] = [[3, 6], [5, 6], [8, 6], [10, 6]]
165  const feet: Record<Legs, number[]> = {
166    stand: [3, 5, 8, 10],
167    stepA: [2, 5, 7, 10],
168    stepB: [3, 6, 8, 11],
169    splay: [2, 4, 9, 11],
170    lift: [3, 5, 8],
171  }
172  return [...top, ...feet[legs].map(c => [c, 7] as Cell)]
173}
174
175/** Sprite pixels by "col,row". */
176type Pixels = Map<string, { c: number; r: number; color: string }>
177
178/** A grid to paint on. A later `put` on the same cell wins, so parts go back to front. */
179function canvas() {
180  const cells: Pixels = new Map()
181  const put = (list: readonly Cell[], color: string, dx = 0, dy = 0) => {
182    for (const [c, r] of list) cells.set(`${c + dx},${r + dy}`, { c: c + dx, r: r + dy, color })
183  }
184  return { cells, put }
185}
186
187/** One path per colour, in `order`. */
188function paint(cells: Pixels, order: readonly string[], under = ''): string {
189  const byColor = new Map<string, string>()
190  for (const { c, r, color } of cells.values()) {
191    byColor.set(color, (byColor.get(color) ?? '') + `M${OX + c * U} ${OY + r * U}h${U}v${U}h-${U}z`)
192  }
193  let out = under
194  for (const color of order) {
195    const d = byColor.get(color)
196    if (d !== undefined) out += `<path d="${d}" fill="${color}"/>`
197  }
198  return out
199}
200
201function propsSvg(props: readonly Prop[]): string {
202  return props
203    .map(prop => {
204      const d = prop.cells
205        .map(([c, r]) => `M${(OX + prop.c * U + c * prop.p).toFixed(1)} ${(OY + prop.r * U + r * prop.p).toFixed(1)}h${prop.p}v${prop.p}h-${prop.p}z`)
206        .join('')
207      return `<path d="${d}" fill="${prop.color}"/>`
208    })
209    .join('')
210}
211
212/**
213 * One frame, in three layers that animate separately: the figure (body, legs,
214 * arms, shadow, laptop), the eyes on top of it, and the props around it. A
215 * blink then changes only the eyes layer instead of redrawing the critter.
216 */
217type Layers = { figure: string; eyes: string; props: string }
218const LAYERS = ['figure', 'eyes', 'props'] as const
219
220/** A frame's pixels before they become SVG. */
221type Drawing = { figure: Pixels; eyes: Pixels; order: readonly string[]; under: string; props: readonly Prop[] }
222
223function frame(p: Pose): Layers {
224  const d = draw(p)
225  return { figure: paint(d.figure, d.order, d.under), eyes: paint(d.eyes, [INK]), props: propsSvg(d.props) }
226}
227
228function draw(p: Pose): Drawing {
229  if (p.side) return drawSide(p)
230  const fig = canvas()
231  const eyes = canvas()
232  const { l, r, t, b } = bodyBox(p.body)
233  const top = p.y
234  const lean = p.lean
235  const a = t + 2 // the row the arms hang from
236  const left = armCells(p.armL, l, -1, a)
237  // Holding the mug, the right hand is drawn with the mug instead.
238  const m = p.mug === 'none' ? null : t + { low: 3, mid: 2, sip: 0, gulp: -1 }[p.mug] // the mug's top row
239  const right = m === null ? armCells(p.armR, r, 1, a) : { body: [], front: [] }
240
241  // Lit from the top left, like the side view: the legs sit in the body's
242  // shadow, the right column and bottom row are darker, and the top-left
243  // corner catches a highlight.
244  // Tipped back to drink, as in the coffee reference: the top steps up toward
245  // the right, where the face is, one row every few columns, and a row more on
246  // a gulp. The mug sits against the face inside that raised part.
247  const raised = p.mug === 'gulp' ? t - 2 : t - 1
248  const topOf = (c: number) => (p.body !== 'tipped' ? t : c <= l + 2 ? t + 1 : c <= l + 5 ? t : raised)
249  const columns = block(l, 0, r, 0).map(([c]) => c)
250  fig.put(legCells(p.legs), SIDE, 0, top)
251  fig.put(columns.flatMap(c => block(c, topOf(c), c, b)), BODY, lean, top)
252  fig.put([...block(r, topOf(r), r, b), ...block(l, b, r, b)], SIDE, lean, top)
253  fig.put([[l, topOf(l)], [l + 1, topOf(l + 1)]], LIGHT, lean, top)
254  fig.put([...left.body, ...right.body], BODY, lean, top)
255  fig.put([...left.front, ...right.front], SHADE, lean, top)
256  // Tipped back, both eyes sit left of the mug, closed, each the same
257  // distance below its part of the sloping top.
258  const tippedEyes: Cell[] = [[l + 1, t + 3], [l + 2, t + 3], [l + 4, t + 2], [l + 5, t + 2]]
259  eyes.put(p.body === 'tipped' ? tippedEyes : eyeCells(p, l, r, t), INK, lean, top)
260  if (m !== null && p.body === 'tipped') {
261    // Drinking: the mug tilted against the face, its bottom up and to the
262    // right, the rim at the bottom left where the mouth is; the handle on the
263    // body's edge and the hand just outside it.
264    const x = r - 3
265    fig.put([[x + 1, m], [x + 2, m], [x, m + 1], [x + 1, m + 1], [x + 2, m + 1], [x, m + 2], [x + 1, m + 2]], MUG, lean, top)
266    fig.put([[x + 2, m], [x + 2, m + 1], [r, m + 1]], MUG_SHADE, lean, top)
267    fig.put([...block(r + 1, m + 1, r + 1, a + 1), [r + 1, m + 2]], BODY, lean, top)
268    for (const [c, row] of block(x, m, x + 2, m + 2)) eyes.cells.delete(`${c + lean},${row + top}`)
269  } else if (m !== null) {
270    // A mug three pixels square in front of the right side of the body, its
271    // right column in shade, the handle sticking out, the hand holding it.
272    fig.put(block(r - 2, m, r, m + 2), MUG, lean, top)
273    fig.put([...block(r, m, r, m + 2), [r + 1, m + 1]], MUG_SHADE, lean, top)
274    // The arm runs up the side from the shoulder to the hand.
275    fig.put([...block(r + 1, Math.min(m + 2, a + 1), r + 1, a + 1), [r + 2, m + 1], [r + 2, m + 2]], BODY, lean, top)
276    // Held low it is seen from a little above: the coffee shows at the top.
277    if (p.mug === 'low') fig.put([[r - 2, m], [r - 1, m]], COFFEE, lean, top)
278    // The mug hides whatever part of the right eye is behind it.
279    for (const [c, row] of block(r - 2, m, r, m + 2)) eyes.cells.delete(`${c + lean},${row + top}`)
280  }
281  return { figure: fig.cells, eyes: eyes.cells, order: [SIDE, BODY, LIGHT, SHADE, MUG, MUG_SHADE, COFFEE], under: shadow(p.y, 7), props: p.props }
282}
283
284/**
285 * Turned three-quarters to the left, typing on a laptop on the ground, as in
286 * the reference: body in columns 5-12 with the far side (11-12) darker, eyes
287 * near the front edge, the near arm reaching forward at mid height, the far
288 * hand (darker) down on the keys, the screen tilted open toward it.
289 * `armL` is the near arm (tap or hover), `armR` the far hand (tap, hover or
290 * reach, one key further on).
291 */
292function drawSide(p: Pose): Drawing {
293  const { cells, put } = canvas()
294  const eyes = canvas()
295  const t = p.body === 'bob' ? 1 : 0
296
297  // The laptop: open, the screen a diagonal lit on the side facing the
298  // critter; half open, a shorter diagonal; closed, a flat slab; or not there.
299  let laptop = ''
300  if (p.lid === 'open') {
301    put([[1, 7], [0, 6], [-1, 5]], SCREEN)
302    put([[1, 6], [0, 5]], p.screen ? LIT : DIM)
303  } else if (p.lid === 'half') {
304    put([[1, 7], [0, 7], [-1, 6]], SCREEN)
305  } else if (p.lid === 'closed') {
306    laptop += `<rect x="${OX + U}" y="${OY + 8 * U - 4}" width="${5 * U}" height="2" fill="${SCREEN}"/>`
307  }
308  if (p.lid !== 'none') laptop += `<rect x="${OX + U}" y="${OY + 8 * U - 2}" width="${5 * U}" height="2" fill="${KEYBOARD}"/>`
309
310  put([[5, 6], [5, 7], [7, 6], [7, 7], [10, 6], [10, 7]], BODY)
311  put([[12, 6], [12, 7]], SIDE)
312  put(block(5, t, 10, 5), BODY)
313  put(block(11, t, 12, 5), SIDE)
314
315  const ex = 5 + p.gx
316  const ex2 = 9 + p.gx
317  eyes.put(p.eyes === 'blink' ? [[ex, t + 2], [ex2, t + 2]] : [[ex, t + 1], [ex, t + 2], [ex2, t + 1], [ex2, t + 2]], INK)
318
319  const far: Record<string, Cell[]> = {
320    tap: [[3, 5], [4, 5], [3, 6], [4, 6]],
321    hover: [[3, 4], [4, 4], [3, 5], [4, 5]],
322    reach: [[2, 5], [3, 5], [2, 6], [3, 6]],
323  }
324  put(far[p.armR] ?? far.tap ?? [], SIDE)
325  put(p.armL === 'tap' ? [[4, t + 3], [4, t + 4]] : [[4, t + 2], [4, t + 3]], BODY)
326
327  return { figure: cells, eyes: eyes.cells, order: [SIDE, BODY, SCREEN, LIT, DIM], under: shadow(0, 8.5) + laptop, props: p.props }
328}
329
330function block(c0: number, r0: number, c1: number, r1: number): Cell[] {
331  const out: Cell[] = []
332  for (let row = r0; row <= r1; row++) for (let c = c0; c <= c1; c++) out.push([c, row])
333  return out
334}
335
336/** A soft shadow on the ground, centred on `center`, shrinking while in the air. */
337function shadow(y: number, center: number): string {
338  const width = Math.max(4, 9 + y) * U
339  return `<rect x="${OX + center * U - width / 2}" y="${OY + 8 * U - 2}" width="${width}" height="2" fill="#000" opacity="0.18"/>`
340}
341
342// ---- turning a timeline into an animation ----
343
344type Beat = readonly [Pose, number]
345type Extras = (tick: number) => Prop[]
346
347/**
348 * Plays `intro` once (a transition into this mood), then plays `loop` over
349 * and over: each pose held for its number of frames. `extras` adds props per
350 * frame of the loop (things that float or fall). Each layer runs on its own:
351 * every distinct drawing of a layer appears once and is switched on and off
352 * in its slots.
353 */
354function animate(loop: readonly Beat[], extras?: Extras, intro: readonly Beat[] = []): string {
355  const cache = new Map<string, Layers>()
356  const expand = (beats: readonly Beat[], add?: Extras) => {
357    const ticks: Layers[] = []
358    for (const [p, held] of beats) {
359      const n = Math.max(1, Math.round((held * FPS) / AUTHORED_FPS))
360      for (let i = 0; i < n; i++) {
361        // Props step on the authored clock, so they drift at the speed they were written for.
362        const props = [...p.props, ...(add?.(Math.floor((ticks.length * AUTHORED_FPS) / FPS)) ?? [])]
363        const key = JSON.stringify({ ...p, props })
364        let drawn = cache.get(key)
365        if (drawn === undefined) {
366          drawn = frame({ ...p, props })
367          cache.set(key, drawn)
368        }
369        ticks.push(drawn)
370      }
371    }
372    return ticks
373  }
374  const first = expand(intro)
375  const repeat = expand(loop, extras)
376  const timing = { introDur: (first.length / FPS).toFixed(2), loopDur: (repeat.length / FPS).toFixed(2) }
377  return LAYERS.map(layer => track(layer, first.map(t => t[layer]), repeat.map(t => t[layer]), timing)).join('')
378}
379
380/**
381 * One layer's drawings over time: `first` plays once, then `repeat` loops.
382 * An empty drawing ('') is no group at all: at those moments nothing of the
383 * layer is switched on.
384 */
385function track(
386  layer: string,
387  first: readonly string[],
388  repeat: readonly string[],
389  { introDur, loopDur }: { introDur: string; loopDur: string },
390): string {
391  const all = [...first, ...repeat]
392  const unique = [...new Set(all)].filter(f => f !== '')
393  if (unique.length === 0) return ''
394  if (unique.length === 1 && all.every(f => f === unique[0])) return `<g data-l="${layer}">${unique[0]}</g>`
395
396  // A drawing's on/off switches over one run of ticks, as values and keyTimes.
397  const switches = (ticks: readonly string[], f: string) => {
398    const values: string[] = []
399    const times: string[] = []
400    let last = ''
401    ticks.forEach((t, i) => {
402      const v = t === f ? '1' : '0'
403      if (v !== last) {
404        values.push(v)
405        times.push((i / ticks.length).toFixed(4))
406        last = v
407      }
408    })
409    return `values="${values.join(';')}" keyTimes="${times.join(';')}"`
410  }
411  return unique
412    .map(f => {
413      const shownFirst = (first.length > 0 ? first[0] : repeat[0]) === f
414      const once =
415        first.length > 0 && first.includes(f)
416          ? `<animate attributeName="opacity" calcMode="discrete" ${switches(first, f)} dur="${introDur}s"/>`
417          : ''
418      const always = `<animate attributeName="opacity" calcMode="discrete" ${switches(repeat, f)} begin="${introDur}s" dur="${loopDur}s" repeatCount="indefinite"/>`
419      return `<g data-l="${layer}" opacity="${shownFirst ? 1 : 0}">${f}${once}${always}</g>`
420    })
421    .join('')
422}
423
424const prop = (cells: readonly Cell[], color: string, c: number, r: number, p = 2): Prop => ({ cells, color, c, r, p })
425
426// ---- the moods ----
427
428const STAND = pose({})
429const with_ = (p: Pose, over: Partial<Pose>) => ({ ...p, ...over })
430
431/**
432 * A breath: hold, then one sprite pixel down, then back. Whole pixels only:
433 * half-transparent in-between frames read as a faint ghost trailing the body
434 * on this 5-pixel grid, and made the movement look like slow motion.
435 */
436function breath(p: Pose, rest: number, out: number): Beat[] {
437  const down = with_(p, { body: 'bob' })
438  return [[p, rest + 1], [down, out + 1]]
439}
440
441/** Steam off the mug: two wisps rising and swaying, `k` the step (0-2). */
442function steam(k: number, m = 3): Prop[] {
443  return [0, 1].map(i => {
444    const rise = (k + i * 1.5) % 3
445    return prop(WISP, STEAM, 9.4 + i * 1.2 + (k % 2) * 0.4, m - 2.2 - rise * 1.2, 2.5)
446  })
447}
448
449/** A coffee break: picks up the mug, two sips, then puts it back down out of sight. */
450function coffee(): Beat[] {
451  const cup = (mug: Pose['mug'], over: Partial<Pose> = {}) => pose({ mug, ...over })
452  const drink = (mug: 'sip' | 'gulp', over: Partial<Pose> = {}) => cup(mug, { body: 'tipped', eyes: 'closed', ...over })
453  const sip = (gulps: number): Beat[] => [
454    [cup('low', { body: 'bob', eyes: 'closed' }), 1], // a little dip before lifting
455    [cup('mid', { eyes: 'closed', gx: -1 }), 1], // eyes to the left, clear of the mug on its way up
456    [drink('sip'), 3], // tips back, the mug at its mouth
457    // Each gulp tips the head and the mug back one row further, then down again.
458    ...Array.from({ length: gulps }, (_, i): Beat[] => [[drink('gulp', i === gulps - 1 ? { legs: 'lift' } : {}), 3], [drink('sip'), 2]]).flat(),
459    [drink('sip'), 2],
460    [cup('mid', { eyes: 'happy', gx: -1 }), 1],
461  ]
462  return [
463    [pose({ armR: 'out', gx: 1 }), 2], // reaches to the side for it
464    [cup('low', { gx: 1 }), 2], // brings it in and looks at it
465    [cup('low', { gx: 1, props: steam(0) }), 3],
466    [cup('low', { props: steam(1) }), 3],
467    [cup('low', { props: steam(2) }), 2],
468    ...sip(2),
469    [cup('low', { eyes: 'happy', props: steam(0) }), 4], // contented
470    [cup('low', { eyes: 'happy', body: 'bob', props: steam(1) }), 4], // a happy sigh
471    [cup('low', { eyes: 'happy', props: steam(2) }), 3],
472    [cup('low', { eyes: 'blink', props: steam(0) }), 1],
473    [cup('low', { props: steam(1) }), 3],
474    ...sip(1),
475    [cup('low', { eyes: 'happy', props: steam(2) }), 5],
476    [cup('low', { eyes: 'happy', body: 'bob', props: steam(0) }), 3],
477    [pose({ armR: 'out', eyes: 'happy' }), 2], // puts it back down
478    [STAND, 3],
479  ]
480}
481
482function idle(intro: readonly Beat[]): string {
483  const lookL = with_(STAND, { gx: -1 })
484  const lookR = with_(STAND, { gx: 1 })
485  const reach = pose({ body: 'stretch', armL: 'up', armR: 'up', eyes: 'closed' })
486  return animate([
487    ...breath(STAND, 8, 4), [with_(STAND, { eyes: 'blink' }), 1], ...breath(STAND, 5, 4),
488    ...glance(STAND, -1), ...breath(lookL, 6, 3), ...glance(lookL, 0), [STAND, 2],
489    ...glance(STAND, 1), ...breath(lookR, 5, 3), ...glance(lookR, 0),
490    ...coffee(),
491    // a little foot tap
492    [with_(STAND, { legs: 'lift' }), 2], [STAND, 2], [with_(STAND, { legs: 'lift' }), 2], [STAND, 4],
493    ...breath(STAND, 5, 4), [with_(STAND, { eyes: 'blink' }), 1], [STAND, 2], [with_(STAND, { eyes: 'blink' }), 1],
494    // a big stretch, arms up, eased in and out
495    [with_(STAND, { body: 'squash' }), 2],
496    [reach, 3], [with_(reach, { y: -1 }), 4],
497    [pose({ armL: 'out', armR: 'out' }), 3], [STAND, 8],
498  ], undefined, intro)
499}
500
501function sleepy(intro: readonly Beat[]): string {
502  const z = pose({ eyes: 'closed', armL: 'low', armR: 'low' })
503  const zb = with_(z, { body: 'bob' })
504  return animate(
505    [
506      ...breath(z, 10, 8), ...breath(z, 8, 6),
507      [with_(zb, { gy: 1 }), 6], [with_(z, { body: 'squash' }), 2], [with_(z, { body: 'bob' }), 1], [z, 4],
508    ],
509    tick =>
510      // One "z" at a time drifts up and out, growing as it goes.
511      [0, 1].flatMap(k => {
512        const phase = (tick + k * 12) % 24
513        const step = Math.floor(phase / 3)
514        return phase < 18 ? [prop(ZED, GREY, 11.6 + step * 0.35, -step * 0.9, 1 + step * 0.25)] : []
515      }),
516    intro,
517  )
518}
519
520function thinking(intro: readonly Beat[]): string {
521  const think = pose({ gx: 1, gy: -1, armR: 'chin' })
522  const lift = with_(think, { legs: 'lift' })
523  const dots = (n: number) =>
524    [[12.2, 0.2, 2], [13.2, -1.2, 3], [12.6, -3, 4]].slice(0, n).map(([c, r, p]) => prop(DOT, '#E9DED3', c ?? 0, r ?? 0, p ?? 2))
525  return animate([
526    [with_(think, { props: dots(1) }), 4], [with_(think, { props: dots(2) }), 4], [with_(think, { props: dots(3) }), 6],
527    [with_(lift, { props: dots(3) }), 2], [with_(think, { props: dots(3) }), 2], [with_(lift, { props: dots(3) }), 2],
528    [with_(think, { props: dots(3) }), 4], [with_(think, { gx: 0, props: dots(3) }), 6], [with_(think, { gx: 0, eyes: 'blink' }), 1],
529    [with_(think, { gx: 0 }), 3], ...breath(think, 1, 3),
530  ], undefined, intro)
531}
532
533function typing(intro: readonly Beat[]): string {
534  // Each letter is one keystroke frame: A is the reference pose (far hand on
535  // the keys), B both arms lifted, C the far hand one key further on.
536  const strokes: Record<string, Partial<Pose>> = {
537    A: { armL: 'hover', armR: 'tap' },
538    B: { armL: 'tap', armR: 'hover' },
539    C: { armL: 'hover', armR: 'reach' },
540  }
541  const key = (ch: string, over: Partial<Pose> = {}) => pose({ side: true, ...strokes[ch], ...over })
542  // The body breathes under the keystrokes, one pixel down and back.
543  const breathing: Partial<Pose>[] = [{}, {}, {}, { body: 'bob' }, { body: 'bob' }, { body: 'bob' }]
544  const burst = (rhythm: string, from: number): Beat[] =>
545    [...rhythm].map((ch, i) => [key(ch, { screen: (from + i) % 2, ...breathing[(from + i) % 6] }), 1])
546
547  return animate(
548    [
549      ...burst('ABABACABAB', 0),
550      [key('A'), 3], // a pause to read
551      ...burst('BABCABAB', 1),
552      [key('A', { gx: 1 }), 5], // a glance back at you
553      [key('A', { gx: 1, eyes: 'blink' }), 1],
554      [key('A', { gx: 1 }), 2],
555      ...burst('ABACABABAB', 0),
556      [key('A', { eyes: 'blink' }), 1],
557      [key('A', { body: 'bob' }), 4],
558    ],
559    // Little bits of code drifting up off the screen.
560    tick =>
561      [0, 1].flatMap(k => {
562        const phase = (tick + k * 5) % 10
563        const colors = ['#7FD1AE', '#9FD3FF', GOLD]
564        return phase < 7 ? [prop(DOT, colors[(Math.floor(tick / 10) + k) % 3] ?? GOLD, -1.2 + k * 0.9, 4.2 - phase * 0.7, 2)] : []
565      }),
566    intro,
567  )
568}
569
570/**
571 * A jump on an eased arc: quick off the ground, a beat over the top, quick
572 * back down. Whole pixels and only five frames in the air, so it stays snappy.
573 */
574function hop(face: Partial<Pose>, height: number, arms: Arm, crouch = 2): Beat[] {
575  const arc = [0.6, 0.9, 1, 0.9, 0.6].map(s => -Math.round(height * s))
576  const air = (y: number) => pose({ ...face, armL: arms, armR: arms, y })
577  const squash = pose({ ...face, body: 'squash' })
578  return [
579    [squash, crouch], // anticipation, held
580    [pose({ ...face, body: 'stretch', armL: arms, armR: arms, y: -1 }), 1], // take-off, stretched tall
581    ...arc.map(y => [air(y), 1] as Beat),
582    [with_(squash, { legs: 'splay', props: dust(0) }), 1], // landing, dust kicks out
583    [with_(squash, { legs: 'splay', props: dust(1) }), 1],
584    [pose({ ...face, body: 'bob' }), 1], // rising out of the crouch in whole pixels
585    [pose({ ...face, y: -1 }), 1], // overshoot: a little bounce
586    [pose(face), 1], // settles
587  ]
588}
589
590/** Two puffs of dust kicking out from the feet, a frame apart (secondary animation). */
591function dust(step: number, from = 2, to = 11): Prop[] {
592  const spread = step === 0 ? 0.6 : 1.6
593  const p = step === 0 ? 2 : 1.5
594  return [prop(PUFF, '#B9AEA4', from - spread, 7.1 - step * 0.4, p), prop(PUFF, '#B9AEA4', to + spread, 7.1 - step * 0.4, p)]
595}
596
597/** A glance that swings one pixel past where it is going, then settles there. */
598const glance = (p: Pose, gx: number): Beat[] => [[with_(p, { gx: gx + Math.sign(gx - p.gx) }), 1]]
599
600function happy(intro: readonly Beat[]): string {
601  const face = { eyes: 'happy' as const }
602  return animate(
603    [[pose(face), 4], ...hop(face, 3, 'up'), [pose(face), 4], [pose({ ...face, body: 'bob' }), 4], [pose(face), 6]],
604    tick => {
605      const phase = tick % 20
606      return phase < 12 ? [prop(HEART, PINK, 11.5, -1 - Math.floor(phase / 3))] : []
607    },
608    intro,
609  )
610}
611
612function excited(intro: readonly Beat[]): string {
613  const face = { eyes: 'happy' as const }
614  return animate(
615    [...hop(face, 5, 'up', 3), [pose({ ...face, armL: 'out', armR: 'out' }), 1], ...hop(face, 5, 'up', 3), [pose(face), 2]],
616    tick =>
617      [[-1.5, 1], [12.8, -1.5], [4, -4.5], [9.5, -4]].flatMap(([c, r], k) =>
618        (tick + k * 2) % 6 < 3 ? [prop(SPARKLE, GOLD, c ?? 0, r ?? 0)] : [],
619      ),
620    intro,
621  )
622}
623
624function curious(intro: readonly Beat[]): string {
625  const a = pose({ tilt: -1, gx: 1 })
626  const b = pose({ tilt: 1, gx: -1 })
627  return animate(
628    [
629      [a, 10], [with_(a, { eyes: 'blink' }), 1], [a, 5], [with_(a, { body: 'bob' }), 3], [a, 3],
630      [b, 10], [with_(b, { body: 'bob' }), 3], [b, 4], [with_(b, { legs: 'lift' }), 2], [b, 3],
631    ],
632    tick => [prop(QUESTION, SHADE, 12.4, -5 - (Math.floor(tick / 4) % 2) * 0.4, 3)],
633    intro,
634  )
635}
636
637function worried(intro: readonly Beat[]): string {
638  const w = pose({ armL: 'low', armR: 'low' })
639  return animate(
640    [
641      [with_(w, { gx: -1 }), 5], [with_(w, { gx: -1, body: 'bob' }), 2], [with_(w, { gx: 1 }), 5], [w, 2],
642      // a nervous shuffle
643      [with_(w, { legs: 'stepA', lean: -1 }), 2], [with_(w, { legs: 'stepB', lean: 1 }), 2], [with_(w, { legs: 'stepA', lean: -1 }), 2], [with_(w, { legs: 'stepB', lean: 1 }), 2],
644      [with_(w, { eyes: 'blink' }), 1], [w, 3],
645    ],
646    tick => {
647      const phase = tick % 11
648      return phase < 8 ? [prop(DROP, BLUE, 12.2, -0.6 + phase * 0.4, 3)] : []
649    },
650    intro,
651  )
652}
653
654function sad(intro: readonly Beat[]): string {
655  const d = pose({ body: 'bob', eyes: 'dot', gy: 1, armL: 'low', armR: 'low' })
656  return animate(
657    [[d, 14], [with_(d, { body: 'squash' }), 4], [d, 10], [with_(d, { eyes: 'closed', gy: 0 }), 2], [d, 10]],
658    tick => {
659      const phase = tick % 12
660      return phase < 8 ? [prop(DROP, BLUE, 4.1, 4 + phase * 0.45, 3)] : []
661    },
662    intro,
663  )
664}
665
666function surprised(intro: readonly Beat[]): string {
667  const face = { eyes: 'wide' as const }
668  // The jump back is its entrance (see ENTER); the loop is the wide-eyed after.
669  return animate(
670    [
671      [pose({ ...face, armL: 'out', armR: 'out' }), 8],
672      [pose(face), 10], [pose({ eyes: 'blink' }), 1], [pose(face), 6], [pose({ ...face, body: 'bob' }), 4], [pose(face), 6],
673    ],
674    tick => (tick % 8 < 6 ? [prop(BANG, RED, 13, -5, 3)] : []),
675    intro,
676  )
677}
678
679// ---- transitions ----
680
681const side = (over: Partial<Pose>) => pose({ side: true, armL: 'hover', armR: 'tap', ...over })
682
683/** How each mood winds back down to plain standing. */
684const EXIT: Partial<Record<MascotMood, readonly Beat[]>> = {
685  typing: [
686    [side({ armR: 'hover' }), 2], // hands off the keys
687    [side({ lid: 'half', armR: 'hover' }), 2], // folds the screen down
688    [side({ lid: 'closed', armL: 'tap', armR: 'tap' }), 2],
689    [side({ lid: 'none', body: 'bob', armL: 'tap', armR: 'tap' }), 2], // tucks it away
690    [side({ lid: 'none' }), 1],
691    [pose({ gx: -1 }), 2], // turns back to face you
692    [pose({ eyes: 'blink' }), 1],
693    [STAND, 1],
694  ],
695  thinking: [[pose({ gx: 1, gy: -1, armR: 'low' }), 2], [pose({ eyes: 'blink' }), 1], [STAND, 1]],
696  happy: [[pose({ eyes: 'happy', body: 'bob' }), 2], [STAND, 1]],
697  excited: [[pose({ eyes: 'happy', body: 'squash', legs: 'splay' }), 2], [pose({ eyes: 'happy', body: 'bob' }), 2], [STAND, 1]],
698  curious: [[pose({ eyes: 'blink' }), 1], [STAND, 1]],
699  worried: [[pose({ eyes: 'blink', armL: 'low', armR: 'low' }), 1], [pose({ body: 'bob' }), 3], [STAND, 1]], // a sigh of relief
700  sad: [[pose({ body: 'bob', eyes: 'blink', armL: 'low', armR: 'low' }), 3], [pose({ eyes: 'blink' }), 1], [STAND, 2]], // a sniff, then straightens up
701  surprised: [[pose({ eyes: 'wide', body: 'bob' }), 2], [pose({ eyes: 'blink' }), 1], [pose({ body: 'squash' }), 1], [STAND, 1]],
702  sleepy: [
703    [pose({ eyes: 'closed', body: 'bob' }), 2], [pose({ eyes: 'blink' }), 2], // wakes up
704    [pose({ body: 'squash' }), 1], [pose({ body: 'stretch', armL: 'up', armR: 'up', eyes: 'closed' }), 4], // a big stretch
705    [pose({ eyes: 'blink' }), 1], [STAND, 1],
706  ],
707}
708
709/** How each mood starts from plain standing, before its loop. */
710const ENTER: Partial<Record<MascotMood, readonly Beat[]>> = {
711  typing: [
712    [pose({ gx: -1 }), 2], // looks to its left
713    [side({ lid: 'none' }), 2], // turns
714    [side({ lid: 'none', body: 'bob', armL: 'tap', armR: 'tap' }), 2], // reaches down
715    [side({ lid: 'closed', armL: 'tap', armR: 'tap', props: dust(0, 0.4, 5.6) }), 1], // sets the laptop down: a puff of dust
716    [side({ lid: 'closed', armL: 'tap', armR: 'tap', props: dust(1, 0.4, 5.6) }), 1],
717    [side({ lid: 'half', armR: 'hover' }), 2], // opens it
718    [side({ armR: 'hover', screen: 1 }), 1], // the screen flickers on
719    [side({ armR: 'hover' }), 1],
720    [side({ armR: 'hover', screen: 1 }), 2],
721  ],
722  thinking: [[pose({ gy: -1 }), 2], [pose({ gx: 1, gy: -1, armR: 'low' }), 2]],
723  happy: [[pose({ eyes: 'blink' }), 1], [pose({ eyes: 'happy' }), 2], ...hop({ eyes: 'happy' }, 3, 'up')],
724  excited: [[pose({ eyes: 'wide' }), 2], [pose({ eyes: 'happy', body: 'squash' }), 2]],
725  curious: [[pose({ gx: 1 }), 2], [pose({ gx: 1, tilt: -1, body: 'bob' }), 1]],
726  worried: [[pose({ eyes: 'wide' }), 2], [pose({ gx: -1, armL: 'low', armR: 'low' }), 2]],
727  sad: [[pose({ eyes: 'blink' }), 1], [pose({ eyes: 'dot', gy: 1 }), 3], [pose({ eyes: 'dot', gy: 1, armL: 'low', armR: 'low' }), 2]],
728  surprised: hop({ eyes: 'wide' }, 4, 'out', 1), // a flinch, not a wind-up: surprise is sudden
729  sleepy: [
730    [pose({ body: 'stretch', armL: 'up', armR: 'up', eyes: 'closed' }), 4], // a yawn
731    [pose({ eyes: 'closed' }), 3],
732    [pose({ eyes: 'closed', body: 'bob', armL: 'low', armR: 'low' }), 3], // slumps
733  ],
734}
735
736/** The one-off frames between two moods: the old one's exit, then the new one's entrance. */
737function transition(from: MascotMood | undefined, to: MascotMood): readonly Beat[] {
738  if (from === undefined || from === to) return []
739  return [...(EXIT[from] ?? []), ...(ENTER[to] ?? [])]
740}
741
742const MOODS: Record<MascotMood, (intro: readonly Beat[]) => string> = {
743  idle,
744  sleepy,
745  thinking,
746  typing,
747  happy,
748  excited,
749  curious,
750  worried,
751  sad,
752  surprised,
753}
754
755// The frames for each move between moods never change, so each is drawn once per load.
756const drawn = new Map<string, string>()
757const sprite = (mood: MascotMood, from: MascotMood | undefined) => {
758  const key = `${from ?? ''}>${mood}`
759  let svg = drawn.get(key)
760  if (svg === undefined) {
761    svg = MOODS[mood](transition(from, mood))
762    drawn.set(key, svg)
763  }
764  return svg
765}
766
767// ---- speech bubble ----
768
769const escape = (text: string) =>
770  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
771
772function bubble(state: MascotState): string {
773  if (state.line === '') return ''
774  const dots = state.mood === 'thinking'
775  const textWidth = state.line.length * 6.6
776  const width = Math.min(BUBBLE_RIGHT - 2, Math.round(textWidth + 18 + (dots ? 16 : 0)))
777  const left = BUBBLE_RIGHT - width
778  const dotMarks = dots
779    ? [0, 1, 2]
780        .map(
781          i =>
782            `<rect x="${left + 9 + textWidth + 4 + i * 5}" y="40" width="3" height="3" fill="#3D2B22" opacity="0.2"><animate attributeName="opacity" values="0.2;1;0.2" dur="1.2s" begin="${i * 0.2}s" calcMode="discrete" repeatCount="indefinite"/></rect>`,
783        )
784        .join('')
785    : ''
786  // It pops in with a pixel bounce: 3 px low, 1 px high, then in place.
787  return `<g><animateTransform attributeName="transform" type="translate" values="0 3;0 -1;0 0" keyTimes="0;0.4;0.8" dur="0.25s" calcMode="discrete" fill="freeze"/>
788<rect x="${left}" y="29" width="${width}" height="20" fill="#FFFBF6" stroke="#C9B3A3" stroke-width="1.5"/>
789<rect x="${BUBBLE_RIGHT}" y="36" width="4" height="4" fill="#C9B3A3"/><rect x="${BUBBLE_RIGHT + 4}" y="39" width="3" height="3" fill="#C9B3A3"/>
790<text x="${left + 9}" y="43" font-size="11" font-family="${MONO}" fill="#3D2B22">${escape(state.line)}</text>${dotMarks}</g>`
791}
792
793/** The whole picture for one state, as an SVG document. */
794export function drawMascot(state: MascotState): string {
795  // The picture is shown as an image now, which has no backdrop. The style line
796  // stays for the case of a frame, whose backdrop turns solid white when its
797  // colour scheme differs from the page around it.
798  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}" shape-rendering="crispEdges">
799<style>:root{color-scheme:light dark;background:transparent}</style>
800${bubble(state)}
801${sprite(state.mood, state.from)}
802</svg>`
803}
804
805/** The terminal has no pictures: a little text face instead. */
806export const FACES: Record<MascotMood, string> = {
807  idle: '▐▛▘▝▜▌',
808  sleepy: '▐▀▀▀▀▌ zZ',
809  thinking: '▐▛▘▝▜▌…',
810  typing: '▐▛▘▝▜▌⌨',
811  happy: '▐^▀▀^▌',
812  excited: '\\▐^▀▀^▌/',
813  curious: '▐▛▘ ▖▌?',
814  worried: '▐▛▘▝▜▌;',
815  sad: '▐▖▀▀▗▌',
816  surprised: '▐█▀▀█▌!',
817}
818
hooks/mood.ts 79 lines
1import type { MascotMood, MascotState } from '../types'
2
3export type TurnEnd = 'answer' | 'aborted' | 'refusal' | 'error'
4
5// Words that mean something went wrong, and words that mean it went well.
6// Counted across the whole reply; whichever side has more wins.
7const BAD =
8  /\b(sorry|unfortunately|failed|failing|fails|couldn'?t|could not|can'?t|cannot|unable to|broken|doesn'?t work|didn'?t work|not working|errored|crash(ed|es)?|denied|blocked)\b/g
9const GOOD =
10  /\b(done|works|worked|working now|passed|passes|passing|succeeded|successfully|fixed|installed|ready|all set|good news|completed?|shipped|deployed|published)\b/g
11// A clear win gets the bigger celebration.
12const GREAT =
13  /(🎉|\ball (\d+ )?tests? pass|\btests? (all )?pass(ed)?\b|\bit works\b|\bworks now\b|\bnailed\b)/
14
15const count = (text: string, pattern: RegExp) => (text.match(pattern) ?? []).length
16
17/** Picks the face for a finished reply. */
18export function moodForAnswer(answer: string, reason: TurnEnd): MascotMood {
19  if (reason === 'aborted') return 'surprised'
20  if (reason === 'error' || reason === 'refusal') return 'sad'
21
22  const text = answer.toLowerCase().trim()
23  if (text === '') return 'happy'
24
25  const bad = count(text, BAD)
26  const good = count(text, GOOD)
27  if (bad > good) return bad >= 3 ? 'sad' : 'worried'
28
29  // A reply whose last paragraph asks something is waiting on the person.
30  const ending = text.split(/\n\s*\n/).pop() ?? ''
31  if (ending.includes('?')) return 'curious'
32
33  if (GREAT.test(text)) return 'excited'
34  return 'happy'
35}
36
37const LINES: Record<MascotMood, readonly string[]> = {
38  idle: [''],
39  sleepy: [''],
40  thinking: ['Thinking', 'Hmm, let me see', 'Mulling it over'],
41  typing: ['Working on it'],
42  happy: ['Done!', 'All set!', 'Here you go!'],
43  excited: ['It works!', 'Nailed it!', 'Woohoo!'],
44  curious: ['Your call?', 'Over to you!', 'What do you think?'],
45  worried: ['Hit a snag…', 'Hmm, not quite', 'A bit stuck…'],
46  sad: ['Sorry about that', "That didn't work"],
47  surprised: ['Oh! Stopped.', 'Whoa, paused!'],
48}
49
50/** One of the bubble lines for a mood; `pick` in [0, 1) chooses which. */
51export function lineFor(mood: MascotMood, pick = Math.random()): string {
52  const list = LINES[mood]
53  return list[Math.floor(pick * list.length)] ?? list[0] ?? ''
54}
55
56const TOOL_LINES: Record<string, string> = {
57  Bash: 'Running a command',
58  PowerShell: 'Running a command',
59  Read: 'Reading files',
60  Grep: 'Searching the code',
61  Glob: 'Looking for files',
62  Edit: 'Writing code',
63  Write: 'Writing a file',
64  NotebookEdit: 'Editing a notebook',
65  WebFetch: 'Reading a web page',
66  WebSearch: 'Searching the web',
67  Agent: 'Asking a helper',
68  Skill: 'Loading a skill',
69  ToolSearch: 'Finding tools',
70  TodoWrite: 'Making a to-do list',
71}
72
73/** What the mascot does while a tool runs. */
74export function stateForTool(tool: string): MascotState {
75  if (tool === 'AskUserQuestion') return { mood: 'curious', line: 'Got a question!' }
76  const line = TOOL_LINES[tool] ?? (tool.startsWith('mcp__') ? 'Using a connector' : 'Working on it')
77  return { mood: 'typing', line }
78}
79
types/index.d.ts 24 lines
1export type MascotMood =
2  | 'idle'
3  | 'sleepy'
4  | 'thinking'
5  | 'typing'
6  | 'happy'
7  | 'excited'
8  | 'curious'
9  | 'worried'
10  | 'sad'
11  | 'surprised'
12
13/**
14 * What the mascot is doing now, the words in its speech bubble ('' for none),
15 * and the mood it came from, which picks the transition it plays first.
16 */
17export type MascotState = { mood: MascotMood; line: string; from?: MascotMood }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'prompt-mascot': { current: MascotState; isHidden: boolean }
22  }
23}
24