SLOPSHOPPER

claude-pets

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…

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-pets
│ ┃ Pets ✕ › fix the failing auth test and add an audit log call │ ┃ … │ ┃ … ⏺ Read(src/auth.ts) │ ┃ … ⎿ Read 6 lines │ ┃ [ ball ][ close ] ⏺ 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 │ │ › /pet │ ⎿ claude-pets: ✻ Clawd (clawd) │ ⎿ claude-pets: │ ⎿ claude-pets: Try /pet add <clawd | cat | dog | crab | fox | duck │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pets
[ ball ][ close ]
README

Claude Pets 🐾

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 ]
 ( ^.^ )♥          ●            ▝▜█████▛▘                     //   \\
~ (")(")                          ▘▘ ▝▝

Use

You start with Clawd. Add more with /pet:

CommandDoes
/pet add cat, /pet add dog Biscuit, /pet foxadopt a pet, optionally with a name
/pet ball (or the band's ball button, hotkey b)throw a ball; everyone chases it
/pet listwho lives here
/pet rename Luna Novarename a pet
/pet remove Lunasay goodbye
/petopen the pets in a side panel (Esc or its close button closes it)
/pet hide, /pet showput 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.

What they do

  • Walk, run, sit and turn around on their own.
  • Chase and catch the ball (●), then show a ♥.
  • React to Claude: a pet cheers (♥) when a turn finishes, and looks worried (!) when a tool call fails.
  • Fall asleep (zZ) when you've been away a while, and wake up when you type.

Settings

In the config menu (or pluginConfigs["claude-pets"].options in settings.json):

OptionDefault
sleepAfterMinutes5; minutes without a prompt before the pets nap; 0 keeps them awake
reactionstrue; off and they ignore Claude

Requirements

Claude Code with function-hook plugins (mods). Any OS, terminal or desktop.

Roadmap

  • More pets (VS Code Pets has a zoo: chicken, panda, turtle, rocky…) and colors
  • Pixel-art sprites in terminals that support images
  • Pets saying hello to each other
Source 3 files
hooks/register.tsx 227 lines
1import { 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}
227
hooks/pets.ts 464 lines
1import 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}
464
types/index.d.ts 59 lines
1/** 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