A bunny above the prompt that reacts to what the session is doing.

A small bunny above the Claude Code prompt. It reacts to the session:
| Event | Mood |
|---|---|
| You send a prompt | thinking |
| A tool runs | working, with a caption per tool |
| A tool call fails | oops |
| A turn completes | happy |
| 60 seconds idle | asleep |
/bunny hides or shows it, and /bunny chibi, sit or loaf picks a style, which is remembered between sessions. While subagents run, a small helper for each shows what it is doing.
At the prompt of a Claude Code session in a terminal:
/plugin install bunny --marketplace lillycham/claudepet
Or try it for one session from a clone of this repository:
claude --plugin-dir plugins/bunnyhooks/register.tsx 479 lines1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import type { Feeling, Helper, Mood, StyleName } from '../types'
4
5const TICK_MS = 400
6const HAPPY_MS = 6000
7const OOPS_MS = 5000
8const SLEEP_AFTER_MS = 60000
9const HELPER_DONE_MS = 4000
10const HELPER_STALE_MS = 180000
11const MAX_HELPERS = 4
12
13// Text bunny, used where there is no Raster (the desktop app). Two frames per mood.
14const FACES: Record<Mood, [string, string]> = {
15 idle: [`(='.'=)`, `(-'.'-)`],
16 sleep: [`(-.-)`, `(-_-)`],
17 think: [`(='.'=)`, `(=°.°=)`],
18 work: [`(>'.'>)`, `(<'.'<)`],
19 oops: [`(;'.';)`, `(T'.'T)`],
20 happy: [`(^'.'^)`, `(^o.o^)`],
21}
22const EARS: Record<Mood, [string, string]> = {
23 idle: [`(\\_/)`, `(\\_/)`],
24 sleep: [`(\\_/)`, `(\\_/)`],
25 think: [`(\\_/)`, `(/_/)`],
26 work: [`(\\_/)`, `(\\_/)`],
27 oops: [`(\\_/)`, `(\\_/)`],
28 happy: [`(\\_/)`, `(\\_/)`],
29}
30const COLORS: Record<Mood, string | undefined> = {
31 idle: undefined,
32 sleep: undefined,
33 think: 'yellow',
34 work: 'cyan',
35 oops: 'red',
36 happy: 'green',
37}
38
39// Raster bunny: pixel art, two pixels per terminal row (half blocks).
40// Legend: . empty, L outline, W fur, G shade, P inner ear, C cheek, N nose,
41// K eye, B tear, Y sparkle. Row counts are odd: the canvas adds one spare row for the hop.
42type Spot = [row: number, col: number]
43type Style = {
44 rows: string[]
45 eyes: Spot[]
46 smile: Spot[]
47 frown: Spot[]
48 sparks: [Spot[], Spot[]]
49}
50
51const STYLES: Record<StyleName, Style> = {
52 chibi: {
53 rows: [
54 '...LL...LL...',
55 '..LWWL.LWWL..',
56 '..LWPL.LPWL..',
57 '..LWPWLWPWL..',
58 '.LWWWWWWWWWL.',
59 'LWWWWWWWWWWWL',
60 'LWWKWWWWWKWWL',
61 'LWCWWWNWWWCWL',
62 '.LWWWWWWWWWL.',
63 '..LLLWWWLLL..',
64 '..LWWLWLWWL..',
65 '..LGWWWWWGL..',
66 '...LLL.LLL...',
67 ],
68 eyes: [[6, 3], [6, 9]],
69 smile: [[8, 5], [8, 7], [9, 6]],
70 frown: [[8, 6]],
71 sparks: [[[0, 0], [2, 12]], [[2, 0], [0, 12]]],
72 },
73 sit: {
74 rows: [
75 '........LL.LL...',
76 '.......LWWLWWL..',
77 '.......LWPLWPL..',
78 '.......LWWLWWWL.',
79 '.......LWWWWWWWL',
80 '....LLLLWWWWWWWL',
81 '..LLWWWLWWKWWKWL',
82 '.LWWWWWLWWWWWWWN',
83 '.LLWWWWWLWWWWWL.',
84 'LWWLWWWWWLLLLL..',
85 'LWWLWWWWWWWWL...',
86 '.LLGGWWWWWLWWL..',
87 '..LLLLLLLLLLLL..',
88 ],
89 eyes: [[6, 10], [6, 13]],
90 smile: [],
91 frown: [],
92 sparks: [[[0, 2], [1, 15]], [[3, 2], [0, 15]]],
93 },
94 loaf: {
95 rows: [
96 '..........LLLL..',
97 '....LLLLLLWWWWL.',
98 '..LLWPPPPLWWWWWL',
99 '.LWWLLLLLWWKWKWL',
100 'LWWWWWWWWWWWWWWN',
101 'LWWWWWWWWWWWWWWL',
102 'LWWWWWWWWWWWWWL.',
103 '.LGGWWWWWWWWGGL.',
104 '..LLLLLLLLLLLL..',
105 ],
106 eyes: [[3, 11], [3, 13]],
107 smile: [],
108 frown: [],
109 sparks: [[[0, 2], [1, 0]], [[1, 1], [0, 3]]],
110 },
111}
112
113const PALETTE: Record<string, number> = {
114 L: 0x8a8398,
115 W: 0xf4f0f2,
116 G: 0xc9c2cf,
117 P: 0xf4a6c0,
118 N: 0xe87ba0,
119 C: 0xf8c0d0,
120 K: 0x2b2433,
121 B: 0x6cb4ff,
122 Y: 0xffd84d,
123}
124const DEFAULT_COLOR = 0x01000000
125
126// Draws one mood and frame onto a copy of the style's pixels. Returns the grid
127// and whether the sprite lifts one pixel (the bounce of working and happy).
128const paint = (style: Style, mood: Mood, n: number): { grid: string[][]; isUp: boolean } => {
129 const grid = style.rows.map(row => row.split(''))
130 const put = (spots: Spot[], ch: string) => {
131 for (const [r, c] of spots) if (grid[r]?.[c] !== undefined) grid[r]![c] = ch
132 }
133 // Moves each eye by (dr, dc) onto fur or cheek, and gives it back where it was.
134 const lookAt = (dr: number, dc: number) => {
135 for (const [r, c] of style.eyes) {
136 const target = grid[r + dr]?.[c + dc]
137 if (target === 'W' || target === 'C') {
138 grid[r]![c] = 'W'
139 grid[r + dr]![c + dc] = 'K'
140 }
141 }
142 }
143 const odd = n % 2 === 1
144
145 if (mood === 'sleep' || (mood === 'idle' && n % 12 === 11)) put(style.eyes, 'L')
146 if (mood === 'think') lookAt(-1, odd ? -1 : 0)
147 if (mood === 'work') lookAt(0, odd ? 1 : -1)
148 if (mood === 'oops') {
149 for (const [r, c] of style.eyes) {
150 const dr = odd ? 2 : 1
151 if (grid[r + dr]?.[c] === 'W' || grid[r + dr]?.[c] === 'C') grid[r + dr]![c] = 'B'
152 }
153 put(style.frown, 'K')
154 }
155 if (mood === 'happy') {
156 put(style.smile, 'K')
157 put(style.sparks[odd ? 1 : 0], 'Y')
158 }
159
160 return { grid, isUp: odd && (mood === 'work' || mood === 'happy') }
161}
162
163// Packs the sprite as `Raster` cells: one `▀`/`▄` per two pixels, row-major.
164const spriteCells = (style: Style, mood: Mood, n: number): string => {
165 const { grid, isUp } = paint(style, mood, n)
166 const width = grid[0]!.length
167 // The sprite has an odd number of rows, so the canvas has one spare pixel row
168 // above it at rest. A hop moves the sprite into it.
169 const height = grid.length + 1
170 const shift = isUp ? 0 : 1
171 const pixel = (y: number, x: number): number | undefined => {
172 const ch = grid[y - shift]?.[x]
173 return ch === undefined || ch === '.' ? undefined : PALETTE[ch]
174 }
175 const words = new Uint32Array(width * (height / 2) * 3)
176 let i = 0
177 for (let r = 0; r < height / 2; r++) {
178 for (let c = 0; c < width; c++) {
179 const up = pixel(2 * r, c)
180 const down = pixel(2 * r + 1, c)
181 // Two equal pixels are a full block with the default background: a cell
182 // with a coloured background paints a grey bar to its left in the terminal.
183 const [glyph, fg, bg] =
184 up === undefined && down === undefined
185 ? [0x20, DEFAULT_COLOR, DEFAULT_COLOR]
186 : up === undefined
187 ? [0x2584, down!, DEFAULT_COLOR]
188 : up === down
189 ? [0x2588, up, DEFAULT_COLOR]
190 : [0x2580, up, down ?? DEFAULT_COLOR]
191 words[i++] = glyph!
192 words[i++] = fg!
193 words[i++] = bg!
194 }
195 }
196 return new Uint8Array(words.buffer).toBase64()
197}
198
199const decay = (f: Feeling, now: number): Feeling => {
200 const age = now - f.since
201 const isDone =
202 (f.mood === 'happy' && age > HAPPY_MS) || (f.mood === 'oops' && age > OOPS_MS)
203 if (isDone) return { mood: 'idle', label: '', since: now }
204 if (f.mood === 'idle' && age > SLEEP_AFTER_MS) return { mood: 'sleep', label: '', since: now }
205 return f
206}
207
208const shorten = (text: string, max = 48): string => {
209 const one = text.replace(/\s+/g, ' ').trim()
210 return one.length > max ? `${one.slice(0, max - 1)}…` : one
211}
212
213// A small text bunny for each running subagent: ears, then a face for its mood.
214const helperFace = (mood: Mood, odd: boolean): string => {
215 if (mood === 'happy') return odd ? '(^o^)' : '(^.^)'
216 if (mood === 'oops') return odd ? '(T.T)' : '(;.;)'
217 return odd ? '(>.>)' : '(<.<)'
218}
219const EARS_HELPER = '(\\_/)'
220const HELPER_COLORS: Partial<Record<Mood, string>> = { work: 'cyan', happy: 'green', oops: 'red' }
221
222const baseName = (path: string): string => path.split('/').pop() ?? path
223
224// What a tool call is doing, in a few words, from the tool's own arguments.
225const describeTool = (tool: string, input: Record<string, unknown>): string => {
226 const str = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
227 switch (tool) {
228 case 'Read':
229 return `reading ${baseName(str('file_path'))}`
230 case 'Edit':
231 case 'Write':
232 case 'NotebookEdit':
233 return `editing ${baseName(str('file_path'))}`
234 case 'Bash':
235 return shorten(`$ ${str('description') || str('command')}`)
236 case 'Grep':
237 return shorten(`searching for ${str('pattern')}`)
238 case 'Glob':
239 return shorten(`finding ${str('pattern')}`)
240 case 'Task':
241 case 'Agent':
242 return shorten(`delegating: ${str('description')}`)
243 case 'WebFetch':
244 return shorten(`fetching ${str('url')}`)
245 case 'WebSearch':
246 return shorten(`searching the web: ${str('query')}`)
247 default:
248 return `using ${tool}`
249 }
250}
251
252const upsert = (list: Helper[], helper: Helper): Helper[] =>
253 [...list.filter(one => one.id !== helper.id), helper].slice(-8)
254
255// Subagents run at the same time, so a change retries when another one wrote first.
256async function patchHelpers($: Engine, change: (list: Helper[]) => Helper[]): Promise<void> {
257 for (let attempt = 0; attempt < 5; attempt++) {
258 const held = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
259 const done = await $.state.set(
260 { plugin: 'bunny', key: 'helpers' } as const,
261 change(held.value ?? []),
262 { ifVersion: held.version },
263 )
264 if (done.isSet) return
265 }
266}
267
268export const register: Register = on => {
269 let frameNo = 0
270
271 on('session.start', async ($, e, next) => {
272 await $.command.register({
273 name: 'bunny',
274 description: 'Show or hide the bunny, or pick its style: /bunny [chibi|sit|loaf]',
275 })
276
277 // The style is kept between sessions.
278 const saved = await $.store.get('style')
279 if (saved === 'chibi' || saved === 'sit' || saved === 'loaf') {
280 await $.state.set({ plugin: 'bunny', key: 'style' } as const, saved)
281 }
282
283 $.clock.every(TICK_MS, async () => {
284 const now = await $.clock.now()
285 const held = await $.state.get({ plugin: 'bunny', key: 'feeling' } as const)
286 if (held.value !== undefined) {
287 const decayed = decay(held.value, now)
288 if (decayed !== held.value) {
289 await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, decayed)
290 }
291 }
292 const helpers = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
293 const list = helpers.value ?? []
294 const fresh = list.filter(h => now - h.since < (h.mood === 'happy' ? HELPER_DONE_MS : HELPER_STALE_MS))
295 if (fresh.length !== list.length) {
296 await patchHelpers($, current => current.filter(h => fresh.some(one => one.id === h.id)))
297 }
298 frameNo = (frameNo + 1) % 1000
299 await $.state.set({ plugin: 'bunny', key: 'frame' } as const, frameNo)
300 })
301
302 return next(e)
303 })
304
305 on('command.run', { command: 'bunny' }, async ($, e) => {
306 const arg = e.args.trim()
307 const held = await $.state.get({ plugin: 'bunny', key: 'isHidden' } as const)
308 const hidden = held.value === true
309
310 if (arg === 'chibi' || arg === 'sit' || arg === 'loaf') {
311 await $.state.set({ plugin: 'bunny', key: 'style' } as const, arg)
312 await $.store.set('style', arg)
313 await $.state.set({ plugin: 'bunny', key: 'isHidden' } as const, false)
314
315 return { text: `Bunny style: ${arg} :3 (remembered)` }
316 }
317 if (arg !== '') {
318 return { text: 'Usage: /bunny [chibi|sit|loaf]. With no argument it shows or hides the bunny.' }
319 }
320
321 await $.state.set({ plugin: 'bunny', key: 'isHidden' } as const, !hidden)
322
323 return { text: hidden ? 'Bunny is back :3' : 'Bunny is hiding.' }
324 })
325
326 on('prompt.submit', async ($, e, next) => {
327 await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, { mood: 'think', label: 'twitching ears', since: await $.clock.now() })
328
329 return next(e)
330 })
331
332 on('agent.spawn', async ($, e, next) => {
333 const started = await next(e)
334 const id = started.agentId
335
336 if (started.deny === undefined && id !== undefined) {
337 const now = await $.clock.now()
338 const name = e.name ?? e.description
339 await patchHelpers($, list =>
340 upsert(list, { id, name, mood: 'work', label: shorten(e.description), since: now }),
341 )
342 }
343
344 return started
345 })
346
347 on('tool.call', async ($, e, next) => {
348 const now = await $.clock.now()
349 const label = describeTool(e.tool, e as unknown as Record<string, unknown>)
350 const id = e.agentId
351
352 if (id !== undefined) {
353 // A subagent's call moves its own bunny, not the main one.
354 await patchHelpers($, list =>
355 upsert(list, { id, name: list.find(h => h.id === id)?.name ?? 'helper', mood: 'work', label, since: now }),
356 )
357 const ran = await next(e)
358 if (ran.deny === undefined && ran.isError === true) {
359 const failedAt = await $.clock.now()
360 await patchHelpers($, list =>
361 list.map(h => (h.id === id ? { ...h, mood: 'oops', label: `${e.tool} failed`, since: failedAt } : h)),
362 )
363 }
364
365 return ran
366 }
367
368 await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, { mood: 'work', label, since: now })
369 const ran = await next(e)
370
371 if (ran.deny === undefined && ran.isError === true) {
372 await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, { mood: 'oops', label: `${e.tool} failed`, since: await $.clock.now() })
373 }
374
375 return ran
376 })
377
378 on('turn.complete', async ($, e, next) => {
379 const now = await $.clock.now()
380 const id = e.agentId
381
382 if (id !== undefined) {
383 const mood: Mood = e.reason === 'answer' ? 'happy' : 'oops'
384 await patchHelpers($, list => list.map(h => (h.id === id ? { ...h, mood, label: 'done', since: now } : h)))
385
386 return next(e)
387 }
388
389 const seconds = Math.round(e.durationMs / 1000)
390 const feeling: Feeling =
391 e.reason === 'answer'
392 ? { mood: 'happy', label: `all done in ${seconds}s!`, since: now }
393 : e.reason === 'aborted'
394 ? { mood: 'idle', label: '', since: now }
395 : { mood: 'oops', label: 'something went wrong', since: now }
396 await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, feeling)
397
398 return next(e)
399 })
400
401 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402 const hid = await $.state.get({ plugin: 'bunny', key: 'isHidden' } as const)
403 if (e.props.hasSurvey || hid.value === true) {
404 return next(e)
405 }
406
407 const { Box, Text } = $.ui.resolve(e)
408 const feeling = await $.state.get({ plugin: 'bunny', key: 'feeling' } as const)
409 const frame = await $.state.get({ plugin: 'bunny', key: 'frame' } as const)
410 const p: Feeling = feeling.value ?? { mood: 'idle', label: '', since: 0 }
411 const f = (frame.value ?? 0) % 2
412 if (e.surface === 'terminal') {
413 const { Raster } = $.ui.resolve(e)
414 const styled = await $.state.get({ plugin: 'bunny', key: 'style' } as const)
415 const name: StyleName = styled.value ?? 'chibi'
416 const style = STYLES[name]
417 const n = frame.value ?? 0
418 const zzz = p.mood === 'sleep' ? (n % 2 ? 'z' : 'Z') : ''
419 const crew = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
420 const helpers = crew.value ?? []
421 const shown = helpers.slice(-MAX_HELPERS)
422
423 return (
424 <Box flexDirection="row">
425 <Raster
426 key="bunny"
427 columns={style.rows[0]!.length}
428 rows={(style.rows.length + 1) / 2}
429 cells={spriteCells(style, p.mood, n)}
430 />
431 <Box flexDirection="column">
432 <Text dimColor> {zzz}{p.label}</Text>
433 {shown.length > 0 && (
434 <Box flexDirection="row">
435 <Text> </Text>
436 {shown.map((helper, i) => {
437 const odd = (n + i) % 2 === 1
438 const hop = odd && helper.mood !== 'oops' ? ' ' : ''
439 const color = HELPER_COLORS[helper.mood]
440
441 return (
442 <Box key={`helper-${helper.id}`} flexDirection="row">
443 <Text> </Text>
444 <Box flexDirection="column">
445 <Text color={color}>{hop}{EARS_HELPER}</Text>
446 <Text color={color}>{hop}{helperFace(helper.mood, odd)}</Text>
447 <Text dimColor>{helper.name.length > 7 ? `${helper.name.slice(0, 6)}…` : helper.name}</Text>
448 </Box>
449 </Box>
450 )
451 })}
452 {helpers.length > shown.length && <Text dimColor> +{helpers.length - shown.length}</Text>}
453 </Box>
454 )}
455 </Box>
456 </Box>
457 )
458 }
459
460 const crew = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
461 const extra = (crew.value ?? []).length > 0 ? ` (+${(crew.value ?? []).length} helpers)` : ''
462 const hop = p.mood === 'work' || p.mood === 'happy' ? (f ? ' ' : '') : ''
463 const color = COLORS[p.mood]
464 const zzz = p.mood === 'sleep' ? (f ? ' z' : ' Z') : ''
465 const bottom = f ? ` (")_(")` : `(")_(")`
466
467 return (
468 <Box flexDirection="row">
469 <Box flexDirection="column">
470 <Text color={color}>{hop}{EARS[p.mood][f]}</Text>
471 <Text color={color}>{hop}{FACES[p.mood][f]}{zzz}</Text>
472 <Text color={color}>{hop}{bottom}</Text>
473 </Box>
474 <Text dimColor> {p.label}{extra}</Text>
475 </Box>
476 )
477 })
478}
479types/index.d.ts 11 lines1export type Mood = 'idle' | 'sleep' | 'think' | 'work' | 'oops' | 'happy'
2export type StyleName = 'chibi' | 'sit' | 'loaf'
3export type Helper = { id: string; name: string; mood: Mood; label: string; since: number }
4export type Feeling = { mood: Mood; label: string; since: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 bunny: { feeling: Feeling; frame: number; isHidden: boolean; style: StyleName; helpers: Helper[] }
9 }
10}
11