A fluffy black cat above the prompt, in memory of Mango.

<img src="assets/banner.svg" alt="Mango, a long-haired black cat with a white ruff, white paws and amber eyes, lies in a loaf on a blue knitted cushion in front of a glowing orange moon." width="100%">
<b>A fluffy black cat who lives above your Claude Code prompt.</b><br> <i>In loving memory of my Mango, the most amazing cat who ever lived.</i> ❤️
<img src="assets/icons/stripe.svg" width="100%" height="8" alt="">
Mango sits in the little band above the prompt and keeps you company while you work. She blinks, flicks her tail and kneads while Claude thinks. She naps when you go quiet, flattens her ears at a failed command, purrs at a green test run and gets a little worried when your context window fills up.
/\_/\
( o.o ) ~ Mango blinks slowly at you.
She is small, she is soft, and she is always there.
<img src="assets/moods.svg" alt="Six mood cards. Awake: just being here. Working: kneads while Claude works. Proud: purrs at green test runs. Sleeping: naps when you go quiet. Ruffled: after a failed command. Worried: when context passes 20 percent." width="100%">
Every mood comes with something to say, like "Mango knocks the error off the table." or "Mango dreams of warm laptops." Pet her and she leans into your hand.
You need Claude Code 2.1.287 or newer. Type this at the prompt in a terminal session:
/plugin install mango --marketplace malinfossum/mango-buddy
Say y to add the marketplace, then pick user scope so she follows you into every project.
| Command | What happens |
|---|---|
/mango or /mango pet | You pet her. She purrs. |
/mango card | Your story together: sessions, pets, errors survived, context |
/mango mute / unmute | She stays, but keeps her thoughts to herself |
/mango still / move | She sits perfectly still (calmer for screen readers and reduced motion) |
/mango off / on | She hides, or comes back. She remembers this between sessions. |
She is a Claude Code mod (function hooks) written in TypeScript.
claude plugin validate .
claude plugin test .
The .claude-plugin/types/ folder that tsconfig.json extends is laid by the engine after the first claude plugin validate . or load, so run that before tsc.
Contributions are welcome and come in under this repo's MIT licence.
<img src="assets/icons/stripe.svg" width="100%" height="8" alt="">
Mango was my cat. A fluffy black cat, the most amazing cat who ever lived, and I miss her every single day. I built this so a small part of her can sit with me while I code, and maybe keep you company too.
hooks/register.tsx 332 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3import type { Mood } from '../types'
4import { ART_WIDTH, BUDGET_PERCENT, faceFor, frameFor, GAP, isTestCommand, PROUD_TICKS, remarkFor, resolveMood, RUFFLED_TICKS, TICK_MS, toneFor } from './cat'
5import { altFor, DRAWING_HEIGHT, DRAWING_WIDTH, drawingFor } from './drawing'
6
7type EngineType = EngineInterface
8
9export const tick = atom({ plugin: 'mango', key: 'tick' } as const, 0)
10export const mood = atom({ plugin: 'mango', key: 'mood' } as const, 'awake' as Mood)
11export const moodUntil = atom({ plugin: 'mango', key: 'moodUntil' } as const, 0)
12export const lastActivityTick = atom({ plugin: 'mango', key: 'lastActivityTick' } as const, 0)
13export const contextPercent = atom({ plugin: 'mango', key: 'contextPercent' } as const, 0)
14export const remark = atom({ plugin: 'mango', key: 'remark' } as const, null as string | null)
15export const isSessionCounted = atom({ plugin: 'mango', key: 'isSessionCounted' } as const, false)
16export const wasOverBudget = atom({ plugin: 'mango', key: 'wasOverBudget' } as const, false)
17export const isOff = atom({ plugin: 'mango', key: 'isOff' } as const, false)
18export const isMuted = atom({ plugin: 'mango', key: 'isMuted' } as const, false)
19export const isStill = atom({ plugin: 'mango', key: 'isStill' } as const, false)
20// The mood the picture surfaces draw. They never read the tick, which the clock writes
21// every period: each redraw rebuilds the picture's frame, and that shows as a blink.
22export const shownMood = atom({ plugin: 'mango', key: 'shownMood' } as const, 'awake' as Mood)
23
24const FACE_WIDTH = 5
25
26const flag = (x: unknown): boolean => x === true
27
28// The clock's own count. In still mode the tick atom is only written on a mood
29// change, so time is kept here and the atom follows it.
30let clockTick = 0
31
32// The running clock, kept so a repeated session.start stops it before starting another.
33let clock: Timer | undefined
34
35// The last line the timed-mood and sleep logic wrote to `remark`, kept so expiry and
36// waking clear only that line and never a later purr, yawn or worried remark.
37let timedRemark: string | null = null
38let sleepRemark: string | null = null
39
40// The mood the clock saw on its previous period, to spot the move into sleeping.
41let lastMood: Mood = 'awake'
42
43const snapshot = async ($: EngineType, t: number) => ({
44 mood: await read($, mood),
45 moodUntil: await read($, moodUntil),
46 tick: t,
47 lastActivityTick: await read($, lastActivityTick),
48 contextPercent: await read($, contextPercent),
49})
50
51// Clears the sleeping remark if it is still the one showing. Waking calls this.
52const clearSleepRemark = async ($: EngineType) => {
53 if (sleepRemark === null) return
54 const line = sleepRemark
55 sleepRemark = null
56 if ((await read($, remark)) === line) await update($, remark, () => null)
57}
58
59// Writes the mood only when it changes, so the picture is redrawn only then.
60const showMood = async ($: EngineType, m: Mood) => {
61 if ((await read($, shownMood)) !== m) await update($, shownMood, () => m)
62}
63
64// Resolves the mood now. Every write outside the clock that can change it calls this.
65const syncShownMood = async ($: EngineType) => showMood($, resolveMood(await snapshot($, clockTick)))
66
67export const markActivity = async ($: EngineType) => {
68 const t = clockTick
69 await update($, lastActivityTick, () => t)
70 await clearSleepRemark($)
71 await syncShownMood($)
72}
73
74// Reads the count, adds one and writes it back at the moment of change.
75const bump = async ($: EngineType, key: string): Promise<number> => {
76 const value = (Number(await $.store.get(key)) || 0) + 1
77 await $.store.set(key, value)
78 return value
79}
80
81const HELP = 'Mango knows: pet, mute, unmute, still, move, off, on, card.'
82
83export const runCommand = async ($: EngineType, args: string): Promise<string> => {
84 const word = args.trim().toLowerCase().split(/\s+/)[0] ?? ''
85 switch (word) {
86 case '':
87 case 'pet':
88 await bump($, 'pets')
89 await update($, remark, () => remarkFor('purr', clockTick))
90 return 'Mango purrs.'
91 case 'mute':
92 await $.store.set('isMuted', true)
93 await update($, isMuted, () => true)
94 return 'Mango is quiet now.'
95 case 'unmute':
96 await $.store.set('isMuted', false)
97 await update($, isMuted, () => false)
98 return 'Mango may speak again.'
99 case 'still':
100 await $.store.set('isStill', true)
101 await update($, isStill, () => true)
102 return 'Mango sits still.'
103 case 'move':
104 await $.store.set('isStill', false)
105 await update($, isStill, () => false)
106 return 'Mango moves again.'
107 case 'off':
108 await $.store.set('isOff', true)
109 await update($, isOff, () => true)
110 return 'Mango is away.'
111 case 'on':
112 await $.store.set('isOff', false)
113 await update($, isOff, () => false)
114 return 'Mango is back.'
115 case 'card': {
116 const n = async (k: string) => Number(await $.store.get(k)) || 0
117 return `Mango · sessions ${await n('sessions')} · pets ${await n('pets')} · errors survived ${await n('errorsSurvived')} · context ${await read($, contextPercent)} %`
118 }
119 default:
120 return HELP
121 }
122}
123
124const setTimedMood = async ($: EngineType, m: 'ruffled' | 'proud', ticks: number) => {
125 const t = clockTick
126 await update($, mood, () => m)
127 await update($, moodUntil, () => t + ticks)
128 const line = remarkFor(m, t)
129 timedRemark = line
130 await update($, remark, () => line)
131 await syncShownMood($)
132}
133
134// One period of the clock. Normal mode writes the tick every period. Still mode writes
135// it only when the resolved mood differs from the one the stored tick would draw.
136const advanceClock = async ($: EngineType) => {
137 clockTick += 1
138 const t = clockTick
139 let s = await snapshot($, t)
140 if ((s.mood === 'ruffled' || s.mood === 'proud') && t >= s.moodUntil) {
141 // The timed mood is over: fall back to awake and drop its remark, once.
142 await update($, mood, () => 'awake')
143 if (timedRemark !== null && (await read($, remark)) === timedRemark) await update($, remark, () => null)
144 timedRemark = null
145 s = { ...s, mood: 'awake' }
146 }
147 const now = resolveMood(s)
148 await showMood($, now)
149 if (await read($, isStill)) {
150 const stored = await read($, tick)
151 if (now !== resolveMood({ ...s, tick: stored })) await update($, tick, () => t)
152 } else {
153 await update($, tick, () => t)
154 }
155 if (now === 'sleeping') {
156 if (lastMood !== 'sleeping') {
157 const line = remarkFor('sleeping', t)
158 sleepRemark = line
159 await update($, remark, () => line)
160 }
161 } else {
162 await clearSleepRemark($)
163 }
164 lastMood = now
165}
166
167// Reads the context figure the way turn.complete does. Undefined when usage has no figure.
168const readContextPercent = async ($: EngineType): Promise<number | undefined> => {
169 const { context } = await $.session.usage()
170 const percent =
171 context.percent ??
172 (context.window > 0 && context.tokens !== undefined ? (context.tokens / context.window) * 100 : undefined)
173 return percent === undefined ? undefined : Math.round(percent)
174}
175
176export const register: Register = on => {
177 on('session.start', async ($, e, next) => {
178 // The clock and the command come first: a failing store read must not leave Mango
179 // frozen or /mango unregistered. The clock goes first of all, so a failed
180 // registration still leaves her moving.
181 clockTick = await read($, tick)
182 clock?.cancel()
183 clock = $.clock.every(TICK_MS, async () => {
184 try {
185 await advanceClock($)
186 } catch {
187 // The engine asks the next period either way and never awaits this promise, so
188 // the catch only keeps a failed read or write from becoming an unhandled rejection.
189 }
190 })
191 await $.command.register({
192 name: 'mango',
193 description: 'Pet Mango, or tell her to be still, quiet or away.',
194 argumentHint: 'pet | mute | unmute | still | move | off | on | card',
195 })
196
197 const storedOff = flag(await $.store.get('isOff'))
198 const storedMuted = flag(await $.store.get('isMuted'))
199 const storedStill = flag(await $.store.get('isStill'))
200 await update($, isOff, () => storedOff)
201 await update($, isMuted, () => storedMuted)
202 await update($, isStill, () => storedStill)
203
204 if (!(await read($, isSessionCounted))) {
205 await bump($, 'sessions')
206 await update($, isSessionCounted, () => true)
207 }
208 try {
209 // A resumed session may already be well into its context: show the real figure, no toast.
210 const rounded = await readContextPercent($)
211 if (rounded !== undefined) await update($, contextPercent, () => rounded)
212 await syncShownMood($)
213 } catch {
214 // usage unavailable: keep the default
215 }
216 return next(e)
217 }).catch(($, e, next) => next(e))
218
219 on('command.run', { command: 'mango' }, async ($, e) => ({ text: await runCommand($, e.args) }))
220 .catch(() => ({ text: 'Mango could not do that.' }))
221
222 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
223 await markActivity($)
224 const isTest = isTestCommand(e.command)
225 const ran = await next(e)
226 if (ran.deny !== undefined) return ran
227 if (ran.isError === true) {
228 await setTimedMood($, 'ruffled', RUFFLED_TICKS)
229 await bump($, 'errorsSurvived')
230 } else if (isTest) {
231 await setTimedMood($, 'proud', PROUD_TICKS)
232 }
233 return ran
234 }).catch(($, e, next) => next(e))
235
236 on('turn.complete', async ($, e, next) => {
237 await markActivity($)
238 try {
239 const rounded = await readContextPercent($)
240 if (rounded !== undefined) {
241 await update($, contextPercent, () => rounded)
242 const over = rounded >= BUDGET_PERCENT
243 const was = await read($, wasOverBudget)
244 if (over && !was) {
245 // Fire and forget: a failed toast fails in its own dispatch, never here.
246 $.ui.toast(`Mango: context passed ${BUDGET_PERCENT} %`)
247 await update($, remark, () => remarkFor('worried', clockTick))
248 }
249 if (!over && was) await update($, remark, () => null)
250 await update($, wasOverBudget, () => over)
251 await syncShownMood($)
252 }
253 } catch {
254 // usage unavailable: keep the previous figure
255 }
256 if (!e.isAborted && e.durationMs > 120000) {
257 await update($, remark, () => remarkFor('yawn', clockTick))
258 }
259 return next(e)
260 }).catch(($, e, next) => next(e))
261
262 on('prompt.submit', async ($, e, next) => {
263 await markActivity($)
264 await update($, remark, () => null)
265 return next(e)
266 }).catch(($, e, next) => next(e))
267
268 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
269 if (e.props.hasSurvey || (await read($, isOff))) return next(e)
270 const { Box, Text } = $.ui.resolve(e)
271 const isTerminal = e.surface === 'terminal'
272 // Only the terminal reads the tick: its frames change with it. The picture surfaces
273 // read the shown mood, so an idle clock period never redraws them.
274 const t = isTerminal ? await read($, tick) : 0
275 const shown = isTerminal ? resolveMood(await snapshot($, t)) : await read($, shownMood)
276 const still = await read($, isStill)
277 // In still mode the tick can sit on a blink frame (0 % 8), which would draw an awake
278 // cat with closed eyes. Nudge by two so the tail and the z keep their parity.
279 const drawTick = still && t % 8 === 0 ? t + 2 : t
280 const said = await read($, remark)
281 const muted = await read($, isMuted)
282 const pose = e.props.isWorking ? 'working' : shown
283 const isShort = e.props.maxRows < 3
284 // The face is 5 columns wide, the two-row art ART_WIDTH. A band narrower than
285 // the art draws the art with an empty line, never a negative slice.
286 const room = Math.max(0, e.props.bodyColumns - (isShort ? FACE_WIDTH : ART_WIDTH) - GAP)
287 const text = (said ?? `context ${await read($, contextPercent)} %`).slice(0, room)
288 // A remark takes the mood's theme colour; the context figure stays dim.
289 const tone = said === null ? {} : { color: toneFor(pose) }
290 const speech = muted ? null : (
291 <>
292 <Text>{' '.repeat(GAP)}</Text>
293 {/* Text has no `key` prop, so the key lives on the Box around it. */}
294 <Box key="line"><Text dimColor={said === null} {...tone}>{text}</Text></Box>
295 </>
296 )
297 if (!isTerminal) {
298 // Surfaces that draw an Svg get the picture; a short band gets it at half size. The
299 // markup follows the pose, never the tick, so her animations run on between periods.
300 // An interactive frame is what lets CSS motion and hover play; still mode is an image.
301 const { Svg } = $.ui.resolve(e)
302 const scale = isShort ? 0.5 : 1
303 return (
304 <Box alignItems="center">
305 <Box key="cat">
306 <Svg source={drawingFor(pose, !still)} alt={altFor(pose)} width={DRAWING_WIDTH * scale} height={DRAWING_HEIGHT * scale} isInteractive={!still} />
307 </Box>
308 {speech}
309 </Box>
310 )
311 }
312 if (isShort) {
313 return (
314 <Box>
315 <Box key="face"><Text>{faceFor(pose, drawTick)}</Text></Box>
316 {speech}
317 </Box>
318 )
319 }
320 const [row1, row2] = frameFor(pose, drawTick)
321 return (
322 <Box flexDirection="column">
323 <Box key="row1"><Text>{row1}</Text></Box>
324 <Box>
325 <Box key="row2"><Text>{row2}</Text></Box>
326 {speech}
327 </Box>
328 </Box>
329 )
330 }).catch(($, e, next) => next(e))
331}
332hooks/cat.ts 134 lines1import type { Mood } from '../types'
2
3export type Pose = Mood | 'working'
4
5export const TICK_MS = 750
6export const SLEEP_AFTER_TICKS = 160
7export const RUFFLED_TICKS = 8
8export const PROUD_TICKS = 12
9export const BUDGET_PERCENT = 20
10export const ART_WIDTH = 10
11export const GAP = 2
12
13const EARS = ' /\\_/\\'
14
15const pad = (s: string): string => (s + ' ').slice(0, ART_WIDTH)
16
17export const frameFor = (pose: Pose, tick: number): [string, string] => {
18 switch (pose) {
19 case 'awake': {
20 const eyes = tick % 8 === 0 ? '( -.- )' : '( o.o )'
21 const tail = tick % 2 === 1 ? ' ~' : ' '
22 return [pad(EARS), pad(eyes + tail)]
23 }
24 case 'sleeping':
25 return [pad(EARS), pad('( -.- )' + (tick % 2 === 0 ? ' z' : 'zz'))]
26 case 'ruffled':
27 return [pad(' \\/_\\/'), pad('( >.< ) !')]
28 case 'proud':
29 return [pad(EARS), pad('( ^.^ ) *')]
30 case 'worried':
31 return [pad(EARS), pad('( O.O )')]
32 case 'working':
33 return [pad(EARS), pad(tick % 2 === 0 ? '( o.o )nn' : '( o.o ) nn')]
34 }
35}
36
37export const faceFor = (pose: Pose, tick: number): string => {
38 switch (pose) {
39 case 'awake':
40 return tick % 8 === 0 ? '(-.-)' : '(o.o)'
41 case 'sleeping':
42 return '(-.-)'
43 case 'ruffled':
44 return '(>.<)'
45 case 'proud':
46 return '(^.^)'
47 case 'worried':
48 return '(O.O)'
49 case 'working':
50 return '(o.o)'
51 }
52}
53
54// The theme colour a remark is drawn in, by the pose Mango shows. Theme keys follow the
55// person's theme, so these stay readable in light and dark.
56export const toneFor = (pose: Pose): string => {
57 switch (pose) {
58 case 'proud':
59 return 'success'
60 case 'ruffled':
61 return 'error'
62 case 'worried':
63 return 'warning'
64 case 'sleeping':
65 return 'suggestion'
66 case 'awake':
67 case 'working':
68 return 'claude'
69 }
70}
71
72// Awake has no lines: an awake cat shows the context figure instead.
73type Voice = Exclude<Mood, 'awake'> | 'purr' | 'yawn'
74
75const REMARKS: Record<Voice, readonly string[]> = {
76 sleeping: [
77 'Mango is asleep. Type gently.', 'Mango dreams of warm laptops.', 'Mango curls up on the prompt.', 'Mango is off duty.',
78 'Mango snores, very softly.', 'Mango tucks her white paws under.', 'Mango twitches. She is chasing a bug in her dreams.',
79 'Mango is a loaf now.', 'Mango keeps one ear on the keyboard.', 'Mango sleeps on the warm side of the screen.',
80 ],
81 ruffled: [
82 'Mango did not like that one.', 'Mango flattens her ears.', 'Mango knocks the error off the table.', 'Mango stares at the stack trace.', 'Mango will allow one retry.',
83 'Mango fluffs up her ruff.', 'Mango hisses at the exit code.', 'Mango swats at the red text.',
84 'Mango turns her back on the terminal.', 'Mango says read it again, slowly.', 'Mango blames the semicolon.',
85 ],
86 proud: [
87 'Mango purrs at the green run.', 'Mango knew you would get it.', 'Mango stretches. Tests pass.', 'Mango sits taller now.', 'Mango is pleased.',
88 'Mango slow-blinks at you.', 'Mango kneads in celebration.', 'Mango brings you a green tick.',
89 'Mango flicks her tail. Well done.', 'Mango approves this commit.', 'Mango puffs out her white ruff.',
90 ],
91 worried: [
92 'Mango eyes the context meter.', 'Mango thinks this is a lot of context.', 'Mango suggests saving and clearing soon.', 'Mango paces. The window is filling.',
93 'Mango counts the tokens twice.', 'Mango says write it down before you forget.', 'Mango wants a fresh session soon.',
94 'Mango sits on the memory file to keep it safe.', 'Mango taps the save key with one paw.', 'Mango thinks a recap would fit nicely here.',
95 ],
96 purr: [
97 'Mango purrs.', 'Mango leans into your hand.', 'Mango closes her eyes and rumbles.', 'Mango headbutts the cursor.',
98 'Mango rolls over. The belly is a trap.', 'Mango kneads your sleeve.', 'Mango slow-blinks. That means love.',
99 'Mango tucks her head under your chin.', 'Mango purrs louder than the fans.', 'Mango lets you stroke her ruff.',
100 ],
101 yawn: [
102 'Mango yawns. That was a long one.', 'Mango checks the clock. Still here.', 'Mango stretches after the long turn.', 'Mango wonders if you need a break too.',
103 'Mango yawns and shows every tooth.', 'Mango stretched twice while that ran.', 'Mango thinks water and a stretch would help.',
104 'Mango opens one eye. Is it done?', 'Mango groomed both paws while she waited.', 'Mango has counted every star on the ceiling.',
105 ],
106}
107
108export const remarkFor = (mood: Voice, tick: number): string => {
109 const lines = REMARKS[mood]
110 return lines[Math.abs(tick) % lines.length] ?? ''
111}
112
113// A runner counts only at command position: the start of a line, or after &&, ;, |, ( or a
114// backtick, with optional VAR=value prefixes. So `npm install -D vitest` and
115// `cat jest.config.js` do not.
116const TEST_COMMAND =
117 /(^|&&|;|\||\n|\(|`)\s*(\w+=\S*\s+)*((npm|pnpm|yarn|bun)\s+(run\s+)?test|((npx|bunx|bun|npm\s+exec(\s+--)?|pnpm(\s+(exec|dlx))?|yarn(\s+(exec|dlx))?)\s+)?(vitest|jest)|dotnet\s+test)\b/
118
119export const isTestCommand = (command: string): boolean => TEST_COMMAND.test(command.trim())
120
121export const resolveMood = (s: {
122 mood: Mood
123 moodUntil: number
124 tick: number
125 lastActivityTick: number
126 contextPercent: number
127}): Mood => {
128 const isTimed = s.mood === 'ruffled' || s.mood === 'proud'
129 if (isTimed && s.tick < s.moodUntil) return s.mood
130 if (s.contextPercent >= BUDGET_PERCENT) return 'worried'
131 if (s.tick - s.lastActivityTick >= SLEEP_AFTER_TICKS) return 'sleeping'
132 return 'awake'
133}
134hooks/drawing.ts 226 lines1import type { Pose } from './cat'
2
3// Mango as a picture for the surfaces that draw an Svg (desktop, editor, mobile). The
4// terminal keeps the ASCII frames. Drawn on a 120 x 96 grid and shown at half that, so
5// strokes stay crisp. She matches the README banner: long black coat, white ruff and
6// paws, white whiskers, amber eyes.
7//
8// She moves by CSS inside the SVG, so the browser animates her smoothly and the markup
9// changes only when her pose does. A frame that is redrawn restarts every animation, so
10// nothing here may depend on the clock tick. Still mode and reduced motion draw her
11// without motion.
12
13export const DRAWING_WIDTH = 60
14export const DRAWING_HEIGHT = 48
15
16const COAT = '#1a1217'
17const RIM = '#4a3a36'
18const EAR = '#3a2219'
19const WHITE = '#f4efe6'
20const AMBER = '#f2a92e'
21const PUPIL = '#0d0a10'
22const NOSE = '#e58a9a'
23const CLOSED = '#8a6f60'
24const HEART = '#e5484d'
25
26// Accent marks share the README mood card colours.
27const SLEEP_MARK = '#b9a6ff'
28const PROUD_MARK = '#ffc24d'
29const RUFFLED_MARK = '#ff8a80'
30const WORRIED_MARK = '#8ac7ff'
31
32const DEFS = `<defs><radialGradient id="coat" cx="0.32" cy="0.28" r="0.85"><stop offset="0" stop-color="#3b2a26"/><stop offset="0.45" stop-color="#1a1418"/><stop offset="1" stop-color="#0d0a10"/></radialGradient></defs>`
33
34// A raised tail for a proud cat, a low curl for every other mood.
35const tail = (isRaised: boolean): string => {
36 const d = isRaised ? 'M 78 82 C 100 82 112 70 108 54 C 106 46 100 44 96 46' : 'M 78 82 C 102 86 114 76 112 62 C 111 56 106 54 102 56'
37 return `<g class="tail"><path d="${d}" stroke="${RIM}" stroke-width="13.4" stroke-linecap="round" fill="none" opacity="0.7"/><path d="${d}" stroke="${COAT}" stroke-width="11" stroke-linecap="round" fill="none"/><path d="${d}" stroke="#3b2a26" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.6"/></g>`
38}
39
40const body = `<ellipse class="body" cx="52" cy="76" rx="32" ry="18" fill="url(#coat)" stroke="${RIM}" stroke-width="1.2"/>`
41
42const paws = `<ellipse class="paw-l" cx="42" cy="90" rx="8" ry="4.5" fill="${WHITE}"/><ellipse class="paw-r" cx="62" cy="90" rx="8" ry="4.5" fill="${WHITE}"/>`
43
44const ears = (isFlat: boolean): string => {
45 const [l, r, li, ri] = isFlat
46 ? ['M 30 32 L 18 18 L 42 20 Z', 'M 74 32 L 86 18 L 62 20 Z', 'M 30 27 L 23 20 L 37 21 Z', 'M 74 27 L 81 20 L 67 21 Z']
47 : ['M 28 34 L 30 6 L 48 20 Z', 'M 76 34 L 74 6 L 56 20 Z', 'M 32 28 L 33 13 L 43 21 Z', 'M 72 28 L 71 13 L 61 21 Z']
48 const ear = (side: string, outer: string, inner: string) =>
49 `<g class="perk-${side}"><g class="ear-${side}"><path d="${outer}" fill="${COAT}" stroke="${RIM}" stroke-width="1.2" stroke-linejoin="round"/><path d="${inner}" fill="${EAR}"/></g></g>`
50 return ear('l', l, li) + ear('r', r, ri)
51}
52
53// Long fur: the head is a circle with tufts at the cheeks.
54const head = `<path d="M 28 46 L 20 50 L 28 54 L 24 60 L 34 60 Z M 76 46 L 84 50 L 76 54 L 80 60 L 70 60 Z" fill="${COAT}"/><circle cx="52" cy="42" r="25" fill="url(#coat)" stroke="${RIM}" stroke-width="1.2"/>`
55
56const ruff = `<path d="M 36 62 L 32 70 L 40 68 L 38 77 L 46 72 L 48 80 L 52 73 L 56 80 L 58 72 L 66 77 L 64 68 L 72 70 L 68 62 Q 52 70 36 62 Z" fill="${WHITE}"/>`
57
58const face = `<path d="M 49 49 L 55 49 L 52 52.5 Z" fill="${NOSE}"/><path d="M 52 52.5 Q 50 55 47.5 54 M 52 52.5 Q 54 55 56.5 54" stroke="${CLOSED}" stroke-width="1" fill="none" stroke-linecap="round"/><g stroke="${WHITE}" stroke-width="0.9" stroke-linecap="round" opacity="0.8"><path d="M 44 51 L 22 47"/><path d="M 44 53 L 21 54"/><path d="M 60 51 L 82 47"/><path d="M 60 53 L 83 54"/></g>`
59
60type Eyes = 'open' | 'closed' | 'happy' | 'squint' | 'wide'
61
62const eyeShapes = (kind: Eyes): string => {
63 const pair = (draw: (cx: number) => string) => draw(42) + draw(62)
64 switch (kind) {
65 case 'open':
66 return pair(cx => `<ellipse cx="${cx}" cy="40" rx="6.5" ry="5.5" fill="${AMBER}"/><g class="pupil"><ellipse cx="${cx}" cy="40" rx="1.6" ry="4.6" fill="${PUPIL}"/><circle cx="${cx + 2}" cy="38" r="1.2" fill="${WHITE}"/></g>`)
67 case 'wide':
68 return pair(cx => `<circle cx="${cx}" cy="40" r="7" fill="${AMBER}"/><g class="pupil"><circle cx="${cx}" cy="40" r="2.2" fill="${PUPIL}"/><circle cx="${cx + 2.4}" cy="37.6" r="1.3" fill="${WHITE}"/></g>`)
69 case 'closed':
70 return pair(cx => `<path d="M ${cx - 6} 40 Q ${cx} 45 ${cx + 6} 40" stroke="${CLOSED}" stroke-width="2.2" fill="none" stroke-linecap="round"/>`)
71 case 'happy':
72 return pair(cx => `<path d="M ${cx - 6} 43 Q ${cx} 35 ${cx + 6} 43" stroke="${AMBER}" stroke-width="2.6" fill="none" stroke-linecap="round"/>`)
73 case 'squint':
74 return `<path d="M 36 36 L 47 40 L 36 44" stroke="${AMBER}" stroke-width="2.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M 68 36 L 57 40 L 68 44" stroke="${AMBER}" stroke-width="2.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
75 }
76}
77
78// Two groups: the outer one widens on hover, the inner one blinks.
79const eyes = (kind: Eyes): string => `<g class="look"><g class="eyes">${eyeShapes(kind)}</g></g>`
80
81const zMark = (x: number, y: number, s: number, cls = ''): string =>
82 `<path${cls === '' ? '' : ` class="${cls}"`} d="M ${x} ${y} h ${s} l ${-s} ${s * 1.1} h ${s}" stroke="${SLEEP_MARK}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
83
84const mark = (pose: Pose, isAnimated: boolean): string => {
85 switch (pose) {
86 case 'sleeping':
87 // Moving, three small z drift up from the same spot in turn; still, two sit apart.
88 return isAnimated ? zMark(84, 24, 6, 'z z1') + zMark(84, 24, 6, 'z z2') + zMark(84, 24, 6, 'z z3') : zMark(84, 26, 6) + zMark(98, 10, 8)
89 case 'proud':
90 return `<path class="mark" d="M 100 14 l 3 8 l 8 3 l -8 3 l -3 8 l -3 -8 l -8 -3 l 8 -3 Z" fill="${PROUD_MARK}"/>`
91 case 'ruffled':
92 return `<g class="mark"><path d="M 102 8 L 102 24" stroke="${RUFFLED_MARK}" stroke-width="3.4" stroke-linecap="round"/><circle cx="102" cy="31" r="2" fill="${RUFFLED_MARK}"/></g>`
93 case 'worried':
94 return `<path class="mark" d="M 82 18 Q 78 25 82 28 Q 86 25 82 18 Z" fill="${WORRIED_MARK}"/>`
95 default:
96 return ''
97 }
98}
99
100// Shown only while a pointer rests on her. Hidden by its own attribute, so a picture
101// without the style sheet never shows it.
102const heart = `<path class="heart" opacity="0" d="M 96 14 C 96 10 91 9 90 13 C 89 9 84 10 84 14 C 84 18 90 21 90 23 C 90 21 96 18 96 14 Z" fill="${HEART}"/>`
103
104const eyesFor = (pose: Pose): Eyes => {
105 switch (pose) {
106 case 'awake':
107 case 'working':
108 return 'open'
109 case 'sleeping':
110 return 'closed'
111 case 'ruffled':
112 return 'squint'
113 case 'proud':
114 return 'happy'
115 case 'worried':
116 return 'wide'
117 }
118}
119
120// Keyframes every pose may use. Transform origins are in the 120 x 96 grid.
121const KEYFRAMES = [
122 '@keyframes breathe{0%,100%{transform:scaleY(1)}50%{transform:scaleY(1.035)}}',
123 '@keyframes deep{0%,100%{transform:scaleY(1)}50%{transform:scaleY(1.06)}}',
124 '@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-0.8px)}}',
125 '@keyframes sink{0%,100%{transform:translateY(0)}50%{transform:translateY(1.2px)}}',
126 '@keyframes blink{0%,44%,48%,86%,89%,92%,100%{transform:scaleY(1)}46%,87.5%,90.5%{transform:scaleY(0.1)}}',
127 '@keyframes sway{0%,100%{transform:rotate(-4deg)}50%{transform:rotate(7deg)}}',
128 '@keyframes flick{0%,100%{transform:rotate(0)}25%{transform:rotate(16deg)}55%{transform:rotate(-8deg)}}',
129 '@keyframes shiver{0%,100%{transform:rotate(-1.5deg)}50%{transform:rotate(1.5deg)}}',
130 '@keyframes twitch-l{0%,58%,66%,100%{transform:rotate(0)}61%{transform:rotate(-14deg)}}',
131 '@keyframes twitch-r{0%,22%,30%,100%{transform:rotate(0)}25%{transform:rotate(14deg)}}',
132 '@keyframes knead{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}',
133 '@keyframes drift{0%{opacity:0;transform:translate(0,3px)}25%{opacity:1}100%{opacity:0;transform:translate(14px,-18px)}}',
134 '@keyframes twinkle{0%,100%{opacity:0.6;transform:scale(0.75) rotate(0)}50%{opacity:1;transform:scale(1.15) rotate(25deg)}}',
135 '@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.25)}}',
136 '@keyframes drip{0%{opacity:0;transform:translateY(-2px)}20%{opacity:1}100%{opacity:0;transform:translateY(7px)}}',
137 '@keyframes glance{0%,20%,90%,100%{transform:translateX(0)}30%,50%{transform:translateX(-2px)}60%,80%{transform:translateX(2px)}}',
138 '@keyframes love{0%{opacity:0;transform:translateY(4px) scale(0.6)}25%{opacity:1;transform:translateY(0) scale(1)}100%{opacity:0;transform:translateY(-8px) scale(1)}}',
139].join('')
140
141const ORIGINS = [
142 '.body{transform-origin:52px 94px}',
143 '.tail{transform-origin:78px 82px}',
144 '.eyes,.look{transform-origin:52px 40px}',
145 '.ear-l,.perk-l{transform-origin:38px 30px}',
146 '.ear-r,.perk-r{transform-origin:66px 30px}',
147 '.mark{transform-origin:103px 22px}',
148 '.heart{transform-origin:90px 16px}',
149 '.perk-l,.perk-r,.look{transition:transform 0.25s ease-out}',
150].join('')
151
152// A pointer on her perks her ears, widens her eyes and floats a heart.
153const HOVER = 'svg:hover .perk-l{transform:rotate(6deg) translateY(-1px)}svg:hover .perk-r{transform:rotate(-6deg) translateY(-1px)}svg:hover .look{transform:scale(1.08)}svg:hover .heart{animation:love 1.8s ease-out infinite}'
154
155// Without motion the three z would sit on one spot, so they spread out as a still picture.
156const REDUCED = '@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}.z2{transform:translate(7px,-8px)}.z3{transform:translate(14px,-16px)}}'
157
158const motionFor = (pose: Pose): string => {
159 const breath = (name: string, seconds: number) => `.body{animation:${name} ${seconds}s ease-in-out infinite}.head{animation:${name === 'deep' ? 'sink' : 'bob'} ${seconds}s ease-in-out infinite}`
160 const tailMoves = (name: string, seconds: number, timing = 'ease-in-out') => `.tail{animation:${name} ${seconds}s ${timing} infinite}`
161 const blink = '.eyes{animation:blink 6.8s linear infinite}'
162 const earTwitch = '.ear-l{animation:twitch-l 9s ease-in-out infinite}.ear-r{animation:twitch-r 11s ease-in-out infinite}'
163 switch (pose) {
164 case 'awake':
165 return breath('breathe', 3.2) + tailMoves('sway', 4.4) + blink + earTwitch
166 case 'working':
167 return (
168 breath('breathe', 3.2) + tailMoves('sway', 2.6) + blink + earTwitch +
169 '.paw-l{animation:knead 0.9s ease-in-out infinite}.paw-r{animation:knead 0.9s ease-in-out -0.45s infinite}'
170 )
171 case 'sleeping':
172 // Asleep she keeps one ear on the keyboard.
173 return (
174 breath('deep', 5) + '.ear-r{animation:twitch-r 13s ease-in-out infinite}' +
175 '.z{animation:drift 3.6s ease-out infinite}.z2{animation-delay:-1.2s}.z3{animation-delay:-2.4s}'
176 )
177 case 'proud':
178 return breath('breathe', 3.2) + tailMoves('sway', 3.6) + '.mark{animation:twinkle 1.6s ease-in-out infinite}'
179 case 'ruffled':
180 return breath('breathe', 1.6) + tailMoves('flick', 0.9) + '.mark{animation:pulse 0.6s ease-in-out infinite}'
181 case 'worried':
182 return (
183 breath('breathe', 2.2) + tailMoves('shiver', 1.2) + blink +
184 '.pupil{animation:glance 4s ease-in-out infinite}.mark{animation:drip 2.4s ease-in infinite}'
185 )
186 }
187}
188
189const styleFor = (pose: Pose): string => `<style>${KEYFRAMES}${ORIGINS}${motionFor(pose)}${HOVER}${REDUCED}</style>`
190
191// What a reader that cannot see the picture is told: the same mood the ASCII shows.
192export const altFor = (pose: Pose): string => {
193 switch (pose) {
194 case 'awake':
195 return 'Mango, a black cat, is awake.'
196 case 'sleeping':
197 return 'Mango, a black cat, is asleep.'
198 case 'ruffled':
199 return 'Mango, a black cat, is ruffled.'
200 case 'proud':
201 return 'Mango, a black cat, is proud.'
202 case 'worried':
203 return 'Mango, a black cat, is worried.'
204 case 'working':
205 return 'Mango, a black cat, kneads while Claude works.'
206 }
207}
208
209export const drawingFor = (pose: Pose, isAnimated: boolean): string =>
210 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 96" width="120" height="96">` +
211 DEFS +
212 (isAnimated ? styleFor(pose) : '') +
213 tail(pose === 'proud') +
214 body +
215 paws +
216 `<g class="head">` +
217 ears(pose === 'ruffled') +
218 head +
219 ruff +
220 eyes(eyesFor(pose)) +
221 face +
222 `</g>` +
223 mark(pose, isAnimated) +
224 heart +
225 `</svg>`
226types/index.d.ts 21 lines1export type Mood = 'awake' | 'sleeping' | 'ruffled' | 'proud' | 'worried'
2
3declare module 'claude-code' {
4 interface PluginState {
5 mango: {
6 tick: number
7 mood: Mood
8 moodUntil: number
9 lastActivityTick: number
10 contextPercent: number
11 remark: string | null
12 isSessionCounted: boolean
13 wasOverBudget: boolean
14 isOff: boolean
15 isMuted: boolean
16 isStill: boolean
17 shownMood: Mood
18 }
19 }
20}
21