VS Code Pets for Claude Code: a cat, dog, crab, fox, rubber duck or Clawd that lives above your prompt, chases balls, cheers when a turn ends and naps when…

VS Code Pets, but for Claude Code: little pets that live above your prompt, wander around, chase the ball you throw, and keep an eye on Claude.
/\_/\ ▐▛███▜▌ (\/)(o,o)(\/) [ ball ]
( ^.^ )♥ ● ▝▜█████▛▘ // \\
~ (")(") ▘▘ ▝▝
You start with Clawd. Add more with /pet:
| Command | Does |
|---|---|
/pet add cat, /pet add dog Biscuit, /pet fox | adopt a pet, optionally with a name |
/pet ball (or the band's ball button, hotkey b) | throw a ball; everyone chases it |
/pet list | who lives here |
/pet rename Luna Nova | rename a pet |
/pet remove Luna | say goodbye |
/pet | open the pets in a side panel (Esc or its close button closes it) |
/pet hide, /pet show | put the band and panel away, and bring them back |
The desktop app draws nothing above the prompt; open the pets there with /pet. The panel never opens by itself.
Pets: clawd, cat, dog, crab (Ferris), fox, duck (a rubber duck, naturally), rabbit, snake. Up to 8 at once. Your pets are remembered across sessions.
●), then show a ♥.zZ) when you've been away a while, and wake up when you type.In the config menu (or pluginConfigs["claude-pets"].options in settings.json):
| Option | Default |
|---|---|
sleepAfterMinutes | 5; minutes without a prompt before the pets nap; 0 keeps them awake |
reactions | true; off and they ignore Claude |
Claude Code with function-hook plugins (mods). Any OS, terminal or desktop.
hooks/register.tsx 227 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pet } from '../types'
5import {
6 HELP,
7 MAX_PETS,
8 TICK_MS,
9 addPet,
10 defaultPets,
11 describePets,
12 drawScene,
13 emptyScene,
14 findPet,
15 parseCommand,
16 react,
17 step,
18 throwBall,
19 wake,
20} from './pets'
21
22const PLUGIN = 'claude-pets'
23const BALL_BUTTON = 9
24const PANE = 'claude-pets'
25
26const petsAtom = atom({ plugin: 'claude-pets', key: 'pets' } as const, [])
27const sceneAtom = atom({ plugin: 'claude-pets', key: 'scene' } as const, emptyScene(1))
28const hiddenAtom = atom({ plugin: 'claude-pets', key: 'isHidden' } as const, false)
29const paneOpenAtom = atom({ plugin: 'claude-pets', key: 'isPaneOpen' } as const, false)
30
31/** What this load of the module holds; a reload starts it over. */
32const live = {
33 /** The playground's width, as the band last measured it. */
34 width: 60,
35 sleepAfter: 0,
36 reactions: true,
37}
38
39async function savePets($: EngineInterface, pets: Pet[]): Promise<void> {
40 await update($, petsAtom, () => pets)
41 await $.store.set('pets', pets)
42}
43
44async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
45 await update($, hiddenAtom, () => isHidden)
46 await $.store.set('isHidden', isHidden)
47 if (isHidden) await closePane($)
48}
49
50/** Opens the playground as a side panel: the desktop app draws no strip above the prompt. */
51async function openPane($: EngineInterface): Promise<void> {
52 await $.ui.open({ id: PANE, title: 'Pets', focus: true, closeOnEscape: true, rows: 8 })
53 // The panel stands in for the strip above the prompt while it is open.
54 await update($, paneOpenAtom, () => true)
55}
56
57async function closePane($: EngineInterface): Promise<void> {
58 await $.ui.close({ id: PANE })
59 await update($, paneOpenAtom, () => false)
60}
61
62async function tick($: EngineInterface): Promise<void> {
63 if (await read($, hiddenAtom)) return
64 const pets = await read($, petsAtom)
65 if (pets.length === 0) return
66 await update($, sceneAtom, scene => step(scene, pets, live.width, { sleepAfter: live.sleepAfter }))
67}
68
69async function run($: EngineInterface, args: string): Promise<string> {
70 const command = parseCommand(args)
71 const pets = await read($, petsAtom)
72 switch (command.kind) {
73 case 'help':
74 // A bare /pet opens the playground's panel.
75 if (args.trim() === '') {
76 await setHidden($, false)
77 await openPane($)
78 }
79 return `${describePets(pets)}\n\n${HELP}`
80 case 'list':
81 return describePets(pets)
82 case 'add': {
83 if (pets.length >= MAX_PETS) return `The playground is full (${MAX_PETS} pets). Say goodbye to one first: /pet remove <name>.`
84 const now = addPet(pets, command.species, command.name)
85 await savePets($, now)
86 await setHidden($, false)
87 const added = now[now.length - 1]
88 return added === undefined ? 'Added.' : `${added.name} the ${added.species} joined the playground.`
89 }
90 case 'remove': {
91 const pet = findPet(pets, command.who)
92 if (pet === undefined) return `No pet called "${command.who}". ${describePets(pets)}`
93 await savePets($, pets.filter(one => one.id !== pet.id))
94 return `Bye, ${pet.name}!`
95 }
96 case 'rename': {
97 const pet = findPet(pets, command.who)
98 if (pet === undefined) return `No pet called "${command.who}". ${describePets(pets)}`
99 await savePets($, pets.map(one => (one.id === pet.id ? { ...one, name: command.name } : one)))
100 return `${pet.name} is now called ${command.name}.`
101 }
102 case 'ball':
103 if (pets.length === 0) return 'Nobody to play with. Add a pet first: /pet add cat'
104 await setHidden($, false)
105 await update($, sceneAtom, scene => throwBall(scene, live.width))
106 return 'Fetch! ●'
107 case 'hide':
108 await setHidden($, true)
109 return 'The pets are napping out of sight. /pet show brings them back.'
110 case 'show':
111 await setHidden($, false)
112 await openPane($)
113 return pets.length === 0 ? 'No pets yet. Add one: /pet add cat' : 'Here they are!'
114 case 'error':
115 return command.text
116 }
117}
118
119export const register: Register = (on, options) => {
120 const minutes = typeof options.sleepAfterMinutes === 'number' ? Math.max(0, options.sleepAfterMinutes) : 5
121 live.sleepAfter = Math.round((minutes * 60_000) / TICK_MS)
122 live.reactions = options.reactions !== false
123
124 on('session.start', async ($, e, next) => {
125 const started = await next(e)
126 await $.command.register({
127 name: 'pet',
128 description: 'Your pets above the prompt: /pet add cat, /pet ball, /pet list, /pet remove <name>',
129 argumentHint: '[add <clawd|cat|dog|crab|fox|duck|rabbit|snake> [name] | ball | list | rename | remove | hide | show]',
130 immediate: true,
131 })
132 const stored = await $.store.get('pets')
133 await update($, petsAtom, () => (Array.isArray(stored) ? (stored as Pet[]) : defaultPets()))
134 const isHidden = (await $.store.get('isHidden')) === true
135 await update($, hiddenAtom, () => isHidden)
136 const seed = (await $.clock.now()) % 100_000
137 // A reload keeps the scene running; a new session starts a fresh one.
138 await update($, sceneAtom, scene => (scene.tick > 0 ? scene : emptyScene(seed + 7)))
139 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
140 // A load starts with the panel closed, even one left open before a reload;
141 // /pet or /pet show opens it.
142 await closePane($)
143
144 return started
145 })
146
147 on('ui.close', async ($, e, next) => {
148 const closed = await next(e)
149 if (e.id === PANE) await update($, paneOpenAtom, () => false)
150 return closed
151 })
152
153 on('command.run', { command: 'pet' }, async ($, e) => ({ text: await run($, e.args) }))
154
155 on('prompt.submit', async ($, e, next) => {
156 await update($, sceneAtom, wake)
157 return next(e)
158 })
159
160 on('turn.complete', async ($, e, next) => {
161 const done = await next(e)
162 await update($, sceneAtom, scene => (live.reactions ? react(wake(scene), 'happy') : wake(scene)))
163 return done
164 })
165
166 on('tool.call', async ($, e, next) => {
167 const ran = await next(e)
168 if (live.reactions && 'isError' in ran && ran.isError === true) await update($, sceneAtom, scene => react(scene, 'sad'))
169 return ran
170 })
171
172 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
173 if (e.props.hasSurvey || (await read($, hiddenAtom)) || (await read($, paneOpenAtom))) return next(e)
174 const pets = await read($, petsAtom)
175 if (pets.length === 0) return next(e)
176 const scene = await read($, sceneAtom)
177 live.width = Math.max(20, e.props.bodyColumns - BALL_BUTTON)
178 const rows = drawScene(scene, pets, live.width)
179 const below = await next(e)
180 const { Box, Button, Text } = $.ui.resolve(e)
181
182 return (
183 <Box flexDirection="column">
184 <Box flexDirection="row" alignItems="flex-end">
185 <Box flexDirection="column">
186 {rows.map(runs => (
187 <Text wrap="truncate">
188 {runs.map(one => (one.color === undefined ? one.text : <Text color={one.color}>{one.text}</Text>))}
189 </Text>
190 ))}
191 </Box>
192 <Button
193 key="ball"
194 label="ball"
195 hotkey="b"
196 onPress={() => update($, sceneAtom, s => throwBall(wake(s), live.width))}
197 />
198 </Box>
199 {below}
200 </Box>
201 )
202 })
203
204 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
205 const { Box, Button, Text } = $.ui.resolve(e)
206 const pets = await read($, petsAtom)
207 if (pets.length === 0) return <Text dimColor>No pets yet. Add one: /pet add cat</Text>
208 const scene = await read($, sceneAtom)
209 live.width = Math.max(20, e.props.bodyColumns)
210 const rows = drawScene(scene, pets, live.width)
211
212 return (
213 <Box flexDirection="column">
214 {rows.map(runs => (
215 <Text wrap="truncate">
216 {runs.map(one => (one.color === undefined ? one.text : <Text color={one.color}>{one.text}</Text>))}
217 </Text>
218 ))}
219 <Box flexDirection="row">
220 <Button key="ball" label="ball" hotkey="b" onPress={() => update($, sceneAtom, s => throwBall(wake(s), live.width))} />
221 <Button key="close" label="close" onPress={() => closePane($)} />
222 </Box>
223 </Box>
224 )
225 })
226}
227hooks/pets.ts 464 lines1import type { Actor, Mood, Pet, Scene, Species } from '../types'
2
3export const SPECIES: readonly Species[] = ['clawd', 'cat', 'dog', 'crab', 'fox', 'duck', 'rabbit', 'snake']
4export const MAX_PETS = 8
5/** Milliseconds per tick of the playground. */
6export const TICK_MS = 250
7
8type Face = 'normal' | 'blink' | 'happy' | 'sad' | 'sleep' | 'alert'
9
10type Sprite = {
11 /** Two walk frames, facing right, rows top to bottom; `{f}` is the face. */
12 frames: [string[], string[]]
13 faces: Record<Face, string>
14 color: string
15}
16
17const EYES: Record<Face, string> = {
18 normal: 'o.o',
19 blink: '-.-',
20 happy: '^.^',
21 sad: ';.;',
22 sleep: '-.-',
23 alert: 'O.O',
24}
25const EYE: Record<Face, string> = { normal: 'o', blink: '-', happy: '^', sad: ';', sleep: '-', alert: 'O' }
26
27const SPRITES: Record<Species, Sprite> = {
28 clawd: {
29 frames: [
30 [' ▐▛███▜▌', '▝▜█████▛▘', ' ▘▘ ▝▝'],
31 [' ▐▛███▜▌', '▝▜█████▛▘', ' ▝▝ ▘▘'],
32 ],
33 faces: EYES,
34 color: '#D97757',
35 },
36 cat: {
37 frames: [
38 [' /\\_/\\', ' ( {f} )', '~ (")(")'],
39 [' /\\_/\\', ' ( {f} )', '_ (") (")'],
40 ],
41 faces: EYES,
42 color: '#E8B04A',
43 },
44 dog: {
45 frames: [
46 [' ___', ' U( {f} )U', '~ (_)(_)'],
47 [' ___', ' U( {f} )U', '/ (_) (_)'],
48 ],
49 faces: EYES,
50 color: '#B5835A',
51 },
52 crab: {
53 frames: [
54 ['(\\/)({f})(\\/)', ' // \\\\'],
55 ['(\\/)({f})(\\/)', ' \\\\ //'],
56 ],
57 faces: { normal: 'o,o', blink: '-,-', happy: '^,^', sad: ';,;', sleep: '-,-', alert: 'O,O' },
58 color: '#E5533D',
59 },
60 fox: {
61 frames: [
62 [' /\\ /\\', ' \\ {f} /', '~~ \\_v_/'],
63 [' /\\ /\\', ' \\ {f} /', '~ \\_v_/'],
64 ],
65 faces: EYES,
66 color: '#EE7E2F',
67 },
68 duck: {
69 frames: [
70 [' __', '_( {f}>', '\\___)'],
71 [' __', '_( {f}>', '\\__/)'],
72 ],
73 faces: EYE,
74 color: '#F2C94C',
75 },
76 rabbit: {
77 frames: [
78 [' (\\(\\', ' ( {f})', 'o(")(")'],
79 [' (\\(\\', ' ( {f})', 'o (")(")'],
80 ],
81 faces: EYES,
82 color: '#D8D8D8',
83 },
84 snake: {
85 frames: [['_/\\_/\\_({f}>'], ['\\_/\\_/\\({f}>']],
86 faces: EYE,
87 color: '#6CC24A',
88 },
89}
90
91const NAMES: Record<Species, string[]> = {
92 clawd: ['Clawd', 'Clawdette', 'Clawdius'],
93 cat: ['Whiskers', 'Luna', 'Miso', 'Mittens'],
94 dog: ['Biscuit', 'Rex', 'Bolt', 'Pixel'],
95 crab: ['Ferris', 'Pinchy', 'Clacks'],
96 fox: ['Rusty', 'Kit', 'Ember'],
97 duck: ['Rubber Duck', 'Quackers', 'Debug'],
98 rabbit: ['Clover', 'Bun', 'Hops'],
99 snake: ['Monty', 'Sly', 'Py'],
100}
101
102const MIRROR: Record<string, string> = {
103 '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<',
104 '/': '\\', '\\': '/', '▐': '▌', '▌': '▐', '▛': '▜', '▜': '▛', '▝': '▘', '▘': '▝',
105}
106
107const cells = (text: string): string[] => Array.from(text)
108
109/** The rows of a pet as drawn: padded to one width, mirrored when it faces left. */
110export function spriteRows(species: Species, frame: number, face: Face, dir: 1 | -1): string[] {
111 const sprite = SPRITES[species]
112 const rows = sprite.frames[frame % 2 === 0 ? 0 : 1].map(row => row.replace('{f}', sprite.faces[face]))
113 const width = spriteWidth(species)
114 const padded = rows.map(row => row + ' '.repeat(width - cells(row).length))
115 if (dir === 1) return padded
116
117 return padded.map(row => cells(row).reverse().map(c => MIRROR[c] ?? c).join(''))
118}
119
120/** The widest row over both frames, so a pet never jitters between them. */
121export function spriteWidth(species: Species): number {
122 const sprite = SPRITES[species]
123 let width = 0
124 for (const frame of sprite.frames) {
125 for (const row of frame) width = Math.max(width, cells(row.replace('{f}', sprite.faces.normal)).length)
126 }
127
128 return width
129}
130
131export const colorOf = (species: Species): string => SPRITES[species].color
132
133function faceOf(actor: Actor, tick: number): Face {
134 switch (actor.mood) {
135 case 'sleep':
136 return 'sleep'
137 case 'happy':
138 return 'happy'
139 case 'sad':
140 return 'sad'
141 case 'chase':
142 return 'alert'
143 default:
144 return (tick + actor.id.length * 7) % 23 === 0 ? 'blink' : 'normal'
145 }
146}
147
148// ---------------------------------------------------------------- randomness
149
150/** mulberry32: a small seeded generator, so a step is a pure function of its scene. */
151function nextRandom(seed: number): [number, number] {
152 const s = (seed + 0x6d2b79f5) | 0
153 let t = s
154 t = Math.imul(t ^ (t >>> 15), t | 1)
155 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
156
157 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, s]
158}
159
160class Dice {
161 constructor(public seed: number) {}
162 next(): number {
163 const [value, seed] = nextRandom(this.seed)
164 this.seed = seed
165 return value
166 }
167 int(min: number, max: number): number {
168 return min + Math.floor(this.next() * (max - min + 1))
169 }
170}
171
172// ---------------------------------------------------------------- the pets themselves
173
174export function emptyScene(seed: number): Scene {
175 return { tick: 0, seed: seed | 0, lastActive: 0, actors: [], ball: null }
176}
177
178export function defaultPets(): Pet[] {
179 return [{ id: 'clawd-1', name: 'Clawd', species: 'clawd' }]
180}
181
182/** A name for a new pet of this kind that none of the others has. */
183export function nameFor(species: Species, pets: readonly Pet[]): string {
184 const taken = new Set(pets.map(pet => pet.name.toLowerCase()))
185 const free = NAMES[species].find(name => !taken.has(name.toLowerCase()))
186 if (free !== undefined) return free
187 const base = NAMES[species][0] ?? species
188 let n = 2
189 while (taken.has(`${base} ${n}`.toLowerCase())) n += 1
190
191 return `${base} ${n}`
192}
193
194export function addPet(pets: readonly Pet[], species: Species, name?: string): Pet[] {
195 let n = 1
196 while (pets.some(pet => pet.id === `${species}-${n}`)) n += 1
197 const given = name?.trim()
198
199 return [...pets, { id: `${species}-${n}`, name: given !== undefined && given !== '' ? given : nameFor(species, pets), species }]
200}
201
202/** The pet a name, id or species points at: exact name first, then id, then the last of a species. */
203export function findPet(pets: readonly Pet[], who: string): Pet | undefined {
204 const want = who.trim().toLowerCase()
205 return (
206 pets.find(pet => pet.name.toLowerCase() === want) ??
207 pets.find(pet => pet.id === want) ??
208 [...pets].reverse().find(pet => pet.species === want)
209 )
210}
211
212function newActor(pet: Pet, width: number, dice: Dice): Actor {
213 const room = Math.max(0, width - spriteWidth(pet.species))
214 return { id: pet.id, x: dice.int(0, room), dir: dice.next() < 0.5 ? 1 : -1, mood: 'walk', ticks: dice.int(8, 24), frame: 0, bubble: '' }
215}
216
217function nextMood(actor: Actor, isDrowsy: boolean, dice: Dice): Actor {
218 if (isDrowsy) return { ...actor, mood: 'sleep', ticks: dice.int(40, 80), bubble: 'z' }
219 const roll = dice.next()
220 if (roll < 0.5) return { ...actor, mood: 'walk', ticks: dice.int(10, 40), bubble: '' }
221 if (roll < 0.7) return { ...actor, mood: 'sit', ticks: dice.int(10, 30), bubble: '' }
222 if (roll < 0.82) return { ...actor, mood: 'run', ticks: dice.int(6, 16), bubble: '' }
223 return { ...actor, mood: 'walk', dir: actor.dir === 1 ? -1 : 1, ticks: dice.int(8, 24), bubble: '' }
224}
225
226/** Moves by `by` cells, turning around at either edge. */
227function walk(actor: Actor, by: number, room: number): Actor {
228 let x = actor.x + actor.dir * by
229 let dir = actor.dir
230 if (x <= 0) {
231 x = 0
232 dir = 1
233 } else if (x >= room) {
234 x = room
235 dir = -1
236 }
237
238 return { ...actor, x, dir, frame: actor.frame + 1 }
239}
240
241export type StepOptions = {
242 /** Ticks without a prompt or turn before pets curl up and sleep; 0 never. */
243 sleepAfter: number
244}
245
246/** One tick of the playground, `width` cells wide. */
247export function step(scene: Scene, pets: readonly Pet[], width: number, options: StepOptions): Scene {
248 const dice = new Dice(scene.seed)
249 const tick = scene.tick + 1
250 const isDrowsy = options.sleepAfter > 0 && tick - scene.lastActive > options.sleepAfter
251
252 // Keep one actor per pet, in the pets' order.
253 const actors = pets.map(pet => scene.actors.find(actor => actor.id === pet.id) ?? newActor(pet, width, dice))
254
255 let ball = scene.ball
256 if (ball !== null) {
257 let x = ball.x + ball.vx
258 let vx = ball.vx * 0.85
259 if (x < 0 || x > width - 1) {
260 x = Math.max(0, Math.min(width - 1, x))
261 vx = -vx
262 }
263 ball = { x, vx: Math.abs(vx) < 0.15 ? 0 : vx }
264 }
265
266 const moved = actors.map((actor, i) => {
267 const pet = pets[i]
268 if (pet === undefined) return actor
269 const w = spriteWidth(pet.species)
270 const room = Math.max(0, width - w)
271 let next: Actor = { ...actor, x: Math.min(actor.x, room), ticks: actor.ticks - 1 }
272
273 const isBusy = next.mood === 'happy' || next.mood === 'sad'
274 if (ball !== null && !isBusy && next.mood !== 'sleep') {
275 const middle = next.x + Math.floor(w / 2)
276 const target = Math.round(ball.x)
277 if (Math.abs(middle - target) <= 1) {
278 ball = null
279 return { ...next, mood: 'happy' as Mood, ticks: 12, bubble: '♥', frame: 0 }
280 }
281 next = { ...next, mood: 'chase', dir: target > middle ? 1 : -1, bubble: '!' }
282 return walk(next, 1, room)
283 }
284 if (next.mood === 'chase') next = { ...next, ticks: 0 }
285
286 if (next.ticks <= 0) next = nextMood(next, isDrowsy, dice)
287 if (next.mood === 'sleep' && !isDrowsy) next = nextMood(next, false, dice)
288
289 switch (next.mood) {
290 case 'walk':
291 return tick % 2 === 0 ? walk(next, 1, room) : next
292 case 'run':
293 return walk(next, 1, room)
294 case 'sleep':
295 return { ...next, frame: 0, bubble: Math.floor(tick / 6) % 2 === 0 ? 'z' : 'zZ' }
296 case 'happy':
297 return { ...next, frame: tick % 4 < 2 ? 0 : 1 }
298 default:
299 return next
300 }
301 })
302
303 return { tick, seed: dice.seed, lastActive: scene.lastActive, actors: moved, ball }
304}
305
306/** Throws a ball from one edge or the other; the pets chase it. */
307export function throwBall(scene: Scene, width: number): Scene {
308 const dice = new Dice(scene.seed)
309 const fromLeft = dice.next() < 0.5
310 const speed = 2 + dice.next() * 1.5
311
312 return {
313 ...scene,
314 seed: dice.seed,
315 lastActive: scene.tick,
316 ball: { x: fromLeft ? 0 : Math.max(0, width - 1), vx: fromLeft ? speed : -speed },
317 actors: scene.actors.map(actor => (actor.mood === 'sleep' ? { ...actor, mood: 'sit', ticks: 0, bubble: '' } : actor)),
318 }
319}
320
321/** Something happened: sleeping pets wake up and the idle clock starts over. */
322export function wake(scene: Scene): Scene {
323 return {
324 ...scene,
325 lastActive: scene.tick,
326 actors: scene.actors.map(actor => (actor.mood === 'sleep' ? { ...actor, mood: 'sit', ticks: 4, bubble: '' } : actor)),
327 }
328}
329
330/** One pet reacts: `happy` with a heart (a turn finished), `sad` with a `!` (a tool failed). */
331export function react(scene: Scene, mood: 'happy' | 'sad'): Scene {
332 const dice = new Dice(scene.seed)
333 const awake = scene.actors.filter(actor => actor.mood !== 'happy' && actor.mood !== 'sad' && actor.mood !== 'chase')
334 if (awake.length === 0) return { ...scene, seed: dice.seed }
335 const chosen = awake[dice.int(0, awake.length - 1)]
336
337 return {
338 ...scene,
339 seed: dice.seed,
340 actors: scene.actors.map(actor =>
341 actor === chosen ? { ...actor, mood, ticks: mood === 'happy' ? 12 : 10, frame: 0, bubble: mood === 'happy' ? '♥' : '!' } : actor,
342 ),
343 }
344}
345
346// ---------------------------------------------------------------- drawing
347
348export type Run = { text: string; color?: string }
349
350/** The playground as rows of colored runs, `width` cells wide. */
351export function drawScene(scene: Scene, pets: readonly Pet[], width: number): Run[][] {
352 const tall = Math.max(2, ...pets.map(pet => SPRITES[pet.species].frames[0].length))
353 const grid: { ch: string; color?: string }[][] = Array.from({ length: tall }, () =>
354 Array.from({ length: width }, () => ({ ch: ' ' })),
355 )
356 const put = (row: number, col: number, ch: string, color?: string): void => {
357 const line = grid[row]
358 if (line === undefined || col < 0 || col >= width || ch === ' ') return
359 line[col] = { ch, color }
360 }
361
362 if (scene.ball !== null) put(tall - 1, Math.round(scene.ball.x), '●', '#C6E03A')
363
364 scene.actors.forEach(actor => {
365 const pet = pets.find(one => one.id === actor.id)
366 if (pet === undefined) return
367 const rows = spriteRows(pet.species, actor.frame, faceOf(actor, scene.tick), actor.dir)
368 const w = spriteWidth(pet.species)
369 const x = Math.max(0, Math.min(actor.x, width - w))
370 const top = tall - rows.length
371 const color = colorOf(pet.species)
372 rows.forEach((row, r) => cells(row).forEach((ch, c) => put(top + r, x + c, ch, color)))
373 if (actor.bubble !== '') {
374 const bubble = cells(actor.bubble)
375 const at = actor.dir === 1 ? x + w : x - bubble.length
376 bubble.forEach((ch, i) => put(top, at + i, ch, actor.mood === 'sad' ? '#E5533D' : '#F28CB1'))
377 }
378 })
379
380 return grid.map(line => {
381 const runs: Run[] = []
382 for (const cell of line) {
383 const last = runs[runs.length - 1]
384 if (last !== undefined && last.color === cell.color) last.text += cell.ch
385 else runs.push(cell.color === undefined ? { text: cell.ch } : { text: cell.ch, color: cell.color })
386 }
387 return runs
388 })
389}
390
391// ---------------------------------------------------------------- the /pet command
392
393export type PetCommand =
394 | { kind: 'add'; species: Species; name?: string }
395 | { kind: 'remove'; who: string }
396 | { kind: 'rename'; who: string; name: string }
397 | { kind: 'ball' }
398 | { kind: 'list' }
399 | { kind: 'hide' }
400 | { kind: 'show' }
401 | { kind: 'help' }
402 | { kind: 'error'; text: string }
403
404const SPECIES_ALIASES: Record<string, Species> = { claude: 'clawd', kitten: 'cat', puppy: 'dog', ferris: 'crab', bunny: 'rabbit' }
405
406export function speciesOf(word: string): Species | undefined {
407 const lower = word.trim().toLowerCase().replace(/s$/, '')
408 return SPECIES.find(one => one === lower) ?? SPECIES_ALIASES[lower] ?? SPECIES.find(one => one === word.trim().toLowerCase())
409}
410
411export function parseCommand(args: string): PetCommand {
412 const [first = '', ...rest] = args.trim().split(/\s+/)
413 const word = first.toLowerCase()
414 const tail = rest.join(' ').trim()
415 switch (word) {
416 case '':
417 case 'help':
418 return { kind: 'help' }
419 case 'add':
420 case 'new':
421 case 'spawn': {
422 const [kind = '', ...name] = rest
423 const species = speciesOf(kind)
424 if (species === undefined) return { kind: 'error', text: `Pick a kind of pet: ${SPECIES.join(', ')}.` }
425 const given = name.join(' ').trim()
426 return given === '' ? { kind: 'add', species } : { kind: 'add', species, name: given }
427 }
428 case 'remove':
429 case 'rm':
430 case 'bye':
431 return tail === '' ? { kind: 'error', text: 'Say which pet: /pet remove <name>.' } : { kind: 'remove', who: tail }
432 case 'rename': {
433 const [who = '', ...name] = rest
434 const given = name.join(' ').trim()
435 return who === '' || given === '' ? { kind: 'error', text: 'Say which pet and its new name: /pet rename <pet> <name>.' } : { kind: 'rename', who, name: given }
436 }
437 case 'ball':
438 case 'throw':
439 return { kind: 'ball' }
440 case 'list':
441 case 'ls':
442 return { kind: 'list' }
443 case 'hide':
444 return { kind: 'hide' }
445 case 'show':
446 return { kind: 'show' }
447 default: {
448 // `/pet cat` adds a cat.
449 const species = speciesOf(first)
450 if (species !== undefined) return tail === '' ? { kind: 'add', species } : { kind: 'add', species, name: tail }
451 return { kind: 'error', text: `Unknown "${first}". ${HELP}` }
452 }
453 }
454}
455
456export const HELP = `Try /pet add <${SPECIES.join(' | ')}> [name], /pet ball, /pet list, /pet rename <pet> <name>, /pet remove <pet>, /pet hide, /pet show.`
457
458const ICON: Record<Species, string> = { clawd: '✻', cat: '🐈', dog: '🐕', crab: '🦀', fox: '🦊', duck: '🦆', rabbit: '🐇', snake: '🐍' }
459
460export function describePets(pets: readonly Pet[]): string {
461 if (pets.length === 0) return 'No pets yet. Add one: /pet add cat'
462 return pets.map(pet => `${ICON[pet.species]} ${pet.name} (${pet.species})`).join('\n')
463}
464types/index.d.ts 59 lines1/** The kinds of pet the mod draws. */
2export type Species = 'clawd' | 'cat' | 'dog' | 'crab' | 'fox' | 'duck' | 'rabbit' | 'snake'
3
4/** A pet you keep; kept across sessions in `$.store`. */
5export type Pet = {
6 id: string
7 name: string
8 species: Species
9}
10
11/** What a pet is doing right now. */
12export type Mood = 'walk' | 'run' | 'sit' | 'sleep' | 'chase' | 'happy' | 'sad'
13
14/** A pet on the playground: where it is and what it does. */
15export type Actor = {
16 /** The `Pet` it draws. */
17 id: string
18 /** Its left edge, in cells. */
19 x: number
20 /** 1 faces right, -1 faces left. */
21 dir: 1 | -1
22 mood: Mood
23 /** Ticks left in this mood. */
24 ticks: number
25 /** Which walk frame shows: 0 or 1. */
26 frame: number
27 /** A word or glyph beside its head (`♥`, `z`, `!`), or empty. */
28 bubble: string
29}
30
31/** A ball rolling on the playground. */
32export type Ball = {
33 x: number
34 /** Cells per tick; slows as it rolls. */
35 vx: number
36}
37
38export type Scene = {
39 tick: number
40 /** The random generator's state, so each step is reproducible. */
41 seed: number
42 /** The tick of the last prompt or turn; pets fall asleep a while after it. */
43 lastActive: number
44 actors: Actor[]
45 ball: Ball | null
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'claude-pets': {
51 pets: Pet[]
52 scene: Scene
53 isHidden: boolean
54 /** The side panel is open; the strip above the prompt steps aside for it. */
55 isPaneOpen: boolean
56 }
57 }
58}
59