SLOPSHOPPER

mango

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

newbandguardcommandtoastprompt
v0.1.3MITupdated 2026-10-16malinfossum/mango-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mango
› fix the failing auth test and add an audit log call ╭────────────────────────────╮ │ mango │ ⏺ Read(src/auth.ts) │ Mango: context passed 20 % │ ⎿ 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 › /mango ⎿ mango: Mango purrs. \/_\/ ( >.< ) ! Mango purrs. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
\/_\/ ( >.< ) ! Mango purrs.
README

<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="">

<img src="assets/icons/cat.svg" width="22" height="22" alt=""> Meet Mango

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/icons/moon.svg" width="22" height="22" alt=""> Her moods

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

<img src="assets/icons/home.svg" width="22" height="22" alt=""> Bring her home

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.

<img src="assets/icons/paw.svg" width="22" height="22" alt=""> Talk to her

CommandWhat happens
/mango or /mango petYou pet her. She purrs.
/mango cardYour story together: sessions, pets, errors survived, context
/mango mute / unmuteShe stays, but keeps her thoughts to herself
/mango still / moveShe sits perfectly still (calmer for screen readers and reduced motion)
/mango off / onShe hides, or comes back. She remembers this between sessions.

<img src="assets/icons/leaf.svg" width="22" height="22" alt=""> Gentle by design

  • She never phones home. No network, no model calls, no telemetry, no dependencies.
  • She dresses for the room. In the terminal she is plain ASCII, so she fits any terminal. In the desktop Code tab she is a little drawing of the real Mango.
  • She is alive on desktop. She breathes, blinks, twitches an ear and swishes her tail, and each mood moves in its own way. Rest your pointer on her and she perks up. She holds still when your system asks for reduced motion.
  • She speaks in your colours. Her remarks use your theme's colours, so they stay readable in light and dark.
  • She makes room. On a narrow or short band she curls up into a single line.
  • She works everywhere. The terminal and the desktop Code tab alike.

<img src="assets/icons/wrench.svg" width="22" height="22" alt=""> For tinkerers

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="">

<img src="assets/icons/heart.svg" width="22" height="22" alt=""> Why she exists

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.

Source 4 files
hooks/register.tsx 332 lines
1import { 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}
332
hooks/cat.ts 134 lines
1import 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}
134
hooks/drawing.ts 226 lines
1import 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>`
226
types/index.d.ts 21 lines
1export 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