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

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.
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.
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.
| Command | What it does |
|---|---|
/mascot | Show or hide the mascot |
/mascot show / /mascot hide | Show or hide explicitly |
/mascot demo | Play every emotion in turn (about 38 seconds) |
/mascot stop | End the demo early |
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.
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:
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.
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
hooks/register.tsx 244 lines1import { 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}
244hooks/art.ts 818 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
818hooks/mood.ts 79 lines1import 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}
79types/index.d.ts 24 lines1export 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