SLOPSHOPPER

code-pet

A tiny ASCII pet above your prompt (=^.^=) that cheers when tests pass, worries when commands fail, naps when you are idle, and levels up as you work.

newbandguardcommandtoaststatus
v0.1.0MITupdated 2026-10-04Alyan-khattak/Claude-Code-Mods/code-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-pet
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /pet ⎿ code-pet: (^) Mochi purrs happily. ⟨Claude Code's own drawing⟩ (^) <3 Mochi: purrs happily <3 Lv 1 [#....] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ (^) <3 Mochi: purrs happily <3 Lv 1 [#....]
Source 3 files
hooks/register.tsx 293 lines
1// Code Pet: a tiny pet above your prompt. It watches you code, cheers
2// when tests pass, worries when commands fail, naps when you are idle, and
3// earns XP as you work, hatching from an egg and growing over the weeks.
4//
5// /pet pats it; /pet name <name>, /pet species <cat|dog|bunny|bear|fox|panda|frog|chick>,
6// /pet stats. CODE_PET=status shows it in the status line, CODE_PET=off hides it.
7// It is plain ASCII by default (=^.^=); CODE_PET_STYLE=emoji uses emoji instead.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { PetProfile } from '../types'
13import { animate, blink, day, DEFAULT_PROFILE, doingFor, face, isTestCommand, level, MARK, MOODS, passedCount, shown, SPECIES, stage, xpBar } from './pet'
14import type { Style } from './pet'
15
16const profile = atom({ plugin: 'code-pet', key: 'profile' } as const, null)
17const flash = atom({ plugin: 'code-pet', key: 'flash' } as const, null)
18const now = atom({ plugin: 'code-pet', key: 'now' } as const, 0)
19const lastActivity = atom({ plugin: 'code-pet', key: 'lastActivity' } as const, 0)
20const isWorking = atom({ plugin: 'code-pet', key: 'isWorking' } as const, false)
21const doing = atom({ plugin: 'code-pet', key: 'doing' } as const, '')
22const doingMood = atom({ plugin: 'code-pet', key: 'doingMood' } as const, 'working')
23const failStreak = atom({ plugin: 'code-pet', key: 'failStreak' } as const, 0)
24const contextPercent = atom({ plugin: 'code-pet', key: 'contextPercent' } as const, 0)
25const limitPercent = atom({ plugin: 'code-pet', key: 'limitPercent' } as const, 0)
26
27const SOUNDS: Record<string, string> = {
28  cat: 'purrs happily',
29  dog: 'wags its tail',
30  bunny: 'does a happy hop',
31  bear: 'gives you a hug',
32  fox: 'yips with joy',
33  panda: 'munches happily',
34  frog: 'ribbits',
35  chick: 'cheeps',
36}
37
38type Display = 'band' | 'status' | 'off'
39let display: Display = 'band'
40let style: Style = 'ascii'
41
42export const register: Register = on => {
43  on('session.start', async ($, e, next) => {
44    const result = await next(e)
45    const mode = ((await $.env.get('CODE_PET')) ?? 'band').toLowerCase()
46    display = mode === 'status' || mode === 'off' ? mode : 'band'
47    style = ((await $.env.get('CODE_PET_STYLE')) ?? '').toLowerCase() === 'emoji' ? 'emoji' : 'ascii'
48    const t = await $.clock.now()
49    const saved = await $.store.get('profile')
50    const loaded: PetProfile = saved && typeof saved === 'object' ? { ...DEFAULT_PROFILE(t), ...(saved as PetProfile) } : DEFAULT_PROFILE(t)
51    await update($, profile, () => loaded)
52    if (saved === undefined || saved === null) await $.store.set('profile', loaded)
53    await update($, now, () => t)
54    await update($, lastActivity, () => t)
55    try {
56      await $.command.register({
57        name: 'pet',
58        description: 'Pat your Code Pet (or: /pet name <name>, /pet species <kind>, /pet stats)',
59        argumentHint: '[name <name> | species <kind> | stats]',
60        immediate: true,
61      })
62    } catch (error) {
63      $.ui.log(`code-pet: could not register /pet (${String(error)})`, { to: 'debug' })
64    }
65    // One tick a second: the animation frame, idle naps, and the status line.
66    $.clock.every(1_000, () => tick($))
67    return result
68  })
69
70  on('turn.start', async ($, e, next) => {
71    const t = await $.clock.now()
72    await update($, isWorking, () => true)
73    await update($, doing, () => 'on it!')
74    await update($, doingMood, () => 'working')
75    await update($, lastActivity, () => t)
76    return next(e)
77  })
78
79  on('tool.call', async ($, e, next) => {
80    const tool = String(e.tool)
81    const input = e as unknown as Record<string, unknown>
82    const what = doingFor(tool, input)
83    await update($, doing, () => what.say)
84    await update($, doingMood, () => what.mood)
85    const started = await $.clock.now()
86    await update($, lastActivity, () => started)
87
88    const command = tool === 'Bash' && typeof input.command === 'string' ? input.command : undefined
89    const ran = await next(e)
90    if (command === undefined || ran.deny !== undefined) return ran
91
92    const ok = ran.isError !== true
93    const output = typeof ran.text === 'string' ? ran.text : ''
94    const t = await $.clock.now()
95    if (isTestCommand(command)) {
96      if (ok) {
97        const count = passedCount(output)
98        await gainXp($, 10, p => ({
99          ...p,
100          streak: p.streak + 1,
101          best: Math.max(p.best, p.streak + 1),
102          today: { date: day(t), tests: (p.today.date === day(t) ? p.today.tests : 0) + (count ?? 0) },
103        }))
104        await update($, failStreak, () => 0)
105        const p = await read($, profile)
106        const streak = p !== null && p.streak >= 3 ? ` ${MARK.streak![style]}${p.streak} in a row` : ''
107        await say($, 'happy', `${count !== undefined ? `${count} tests passed!` : 'tests passed!'} +10 XP${streak}`, 8_000)
108      } else {
109        await update($, profile, p => (p === null ? p : { ...p, streak: 0 }))
110        await saveProfile($)
111        const fails = (await read($, failStreak)) + 1
112        await update($, failStreak, () => fails)
113        await say($, fails >= 3 ? 'sad' : 'worried', fails >= 3 ? 'tests keep failing... we got this' : 'tests failed, let us fix them', 8_000)
114      }
115    } else if (!ok) {
116      const fails = (await read($, failStreak)) + 1
117      await update($, failStreak, () => fails)
118      const cmd = command.trim().split(/\s+/).slice(0, 3).join(' ')
119      await say($, fails >= 3 ? 'sad' : 'worried', `uh oh, \`${cmd.length > 30 ? cmd.slice(0, 27) + '...' : cmd}\` failed`, 8_000)
120    }
121    return ran
122  })
123
124  on('turn.complete', async ($, e, next) => {
125    const result = await next(e)
126    if (e.agentId !== undefined) return result
127    const t = await $.clock.now()
128    await update($, isWorking, () => false)
129    await update($, lastActivity, () => t)
130    if (!e.isAborted && e.reason === 'answer') {
131      await gainXp($, 5, p => p)
132      if (e.durationMs >= 60_000) await say($, 'done', `done! that took ${minutes(e.durationMs)}`, 6_000)
133    }
134    try {
135      const usage = await $.session.usage()
136      await update($, contextPercent, () => usage.context.percent ?? 0)
137      await update($, limitPercent, () => Math.max(0, ...usage.rateLimits.map(r => r.percentUsed)))
138    } catch {
139      // no readings: keep the old ones
140    }
141    return result
142  })
143
144  on('command.run', { command: 'pet' }, async ($, e) => {
145    const args = e.args.trim()
146    const p = await read($, profile)
147    if (p === null) return { text: 'Your pet is still waking up.' }
148
149    const named = /^name\s+(.+)$/i.exec(args)
150    if (named) {
151      const name = named[1]!.trim().slice(0, 20)
152      await update($, profile, q => (q === null ? q : { ...q, name }))
153      await saveProfile($)
154      await say($, 'petted', `my name is ${name}!`, 5_000)
155      return { text: `Your pet is now called ${name}.` }
156    }
157
158    const kind = /^species\s+(\w+)$/i.exec(args)
159    if (kind) {
160      const species = kind[1]!.toLowerCase()
161      if (!(species in SPECIES)) return { text: `Pick one of: ${Object.keys(SPECIES).join(', ')}` }
162      await update($, profile, q => (q === null ? q : { ...q, species }))
163      await saveProfile($)
164      return { text: `${p.name} is now a ${species} ${face({ ...p, species, xp: Math.max(p.xp, 25) }, style)} (eggs hatch at level 2).` }
165    }
166
167    if (/^stats$/i.test(args)) {
168      const t = await $.clock.now()
169      const tests = p.today.date === day(t) ? p.today.tests : 0
170      const days = Math.max(0, Math.floor((t - p.born) / 86_400_000))
171      return {
172        text: [
173          `${face(p, style)} ${p.name} the ${p.species}: level ${level(p.xp)} (${stage(p.xp)}), ${p.xp} XP`,
174          `Green test streak: ${p.streak} (best ${p.best}) | tests passed today: ${tests}`,
175          `Pats: ${p.pets} | friends for ${days} ${days === 1 ? 'day' : 'days'}`,
176        ].join('\n'),
177      }
178    }
179
180    await update($, profile, q => (q === null ? q : { ...q, pets: q.pets + 1 }))
181    await saveProfile($)
182    const sound = SOUNDS[p.species] ?? 'is happy'
183    await say($, 'petted', `${sound} ${MARK.heart![style]}`, 5_000)
184    return { text: `${face(p, style, 'petted')} ${p.name} ${sound}.` }
185  })
186
187  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
188    const below = await next(e)
189    if (display !== 'band' || e.props.hasSurvey) return below
190    const line = await current($)
191    if (line === null) return below
192    const { Box, Text } = $.ui.resolve(e)
193    const roomy = e.props.bodyColumns >= 70
194    return (
195      <Box flexDirection="column">
196        {below}
197        <Box key="code-pet" flexDirection="row" paddingX={1} justifyContent="space-between" width={e.props.bodyColumns}>
198          <Text wrap="truncate-end">
199            {line.face} {line.icon === '' ? '' : `${line.icon} `}
200            <Text bold>{line.name}</Text>
201            <Text color={line.color}>: {line.say}</Text>
202          </Text>
203          {roomy && (
204            <Text dimColor>
205              {' '}
206              Lv {line.level} {line.bar}
207              {line.streak >= 3 ? ` ${MARK.streak![style]}${line.streak}` : ''}
208            </Text>
209          )}
210        </Box>
211      </Box>
212    )
213  })
214}
215
216type Line = { face: string; icon: string; name: string; say: string; color: string; level: number; bar: string; streak: number }
217
218/** Everything the pet shows right now, from its state. */
219async function current($: EngineInterface): Promise<Line | null> {
220  const p = await read($, profile)
221  if (p === null) return null
222  const t = await read($, now)
223  const s = shown({
224    flash: await read($, flash),
225    now: t,
226    lastActivity: await read($, lastActivity),
227    isWorking: await read($, isWorking),
228    doing: await read($, doing),
229    doingMood: await read($, doingMood),
230    failStreak: await read($, failStreak),
231    contextPercent: await read($, contextPercent),
232    limitPercent: await read($, limitPercent),
233    hour: new Date(t).getHours(),
234    name: p.name,
235  })
236  const frame = Math.floor(t / 1000)
237  return {
238    face: face(p, style, blink(s.mood, frame, style)),
239    icon: animate(s.mood, frame, style),
240    name: p.name,
241    say: s.say,
242    color: MOODS[s.mood]?.color ?? 'white',
243    level: level(p.xp),
244    bar: xpBar(p.xp, style),
245    streak: p.streak,
246  }
247}
248
249async function tick($: EngineInterface) {
250  const t = await $.clock.now()
251  await update($, now, () => t)
252  if (display === 'status') {
253    const line = await current($)
254    if (line !== null) $.ui.status(`${line.face} ${line.icon === '' ? '' : `${line.icon} `}${line.name}: ${line.say}`)
255  }
256}
257
258/** A mood for a few seconds. A fresh level-up message is never overwritten. */
259async function say($: EngineInterface, mood: string, text: string, ms: number) {
260  const t = await $.clock.now()
261  const old = await read($, flash)
262  if (old !== null && old.mood === 'levelup' && old.until > t) return
263  await update($, flash, () => ({ mood, say: text, until: t + ms }))
264  await update($, now, () => t)
265}
266
267async function gainXp($: EngineInterface, amount: number, change: (p: PetProfile) => PetProfile) {
268  const before = await read($, profile)
269  if (before === null) return
270  const after = change({ ...before, xp: before.xp + amount })
271  await update($, profile, () => after)
272  await saveProfile($)
273  const from = level(before.xp)
274  const to = level(after.xp)
275  if (to > from) {
276    const t = await $.clock.now()
277    const hatched = from === 1
278    const text = hatched ? `${after.name} hatched!${MARK.hatch![style]} level ${to}` : `${after.name} grew to level ${to}!${to === 10 ? MARK.crown![style] : ''}`
279    await update($, flash, () => ({ mood: 'levelup', say: text, until: t + 8_000 }))
280    $.ui.toast(`Code Pet: ${text}`)
281  }
282}
283
284async function saveProfile($: EngineInterface) {
285  const p = await read($, profile)
286  if (p !== null) await $.store.set('profile', p)
287}
288
289function minutes(ms: number): string {
290  const m = Math.round(ms / 60_000)
291  return m < 1 ? `${Math.round(ms / 1000)}s` : `${m}m`
292}
293
hooks/pet.ts 210 lines
1// Code Pet's personality as plain functions: species, moods, levels, and
2// how to tell a test run from any other command. Nothing here touches $.
3//
4// Two looks: "ascii" (the default: plain ASCII, lines up in every terminal
5// and font) and "emoji" (CODE_PET_STYLE=emoji).
6
7import type { PetFlash, PetProfile } from '../types'
8
9export type Style = 'ascii' | 'emoji'
10
11export const SPECIES: Record<string, string> = {
12  cat: '🐱',
13  dog: '🐶',
14  bunny: '🐰',
15  bear: '🐻',
16  fox: '🦊',
17  panda: '🐼',
18  frog: '🐸',
19  chick: '🐥',
20}
21
22/** ASCII bodies: what goes left and right of the face. `=^.^=` is a cat, `U^.^U` a dog. */
23const ASCII_BODY: Record<string, [string, string]> = {
24  cat: ['=', '='],
25  dog: ['U', 'U'],
26  bunny: ['(\\', '/)'],
27  bear: ['(', ')'],
28  fox: ['<', '>'],
29  panda: ['[', ']'],
30  frog: ['@', '@'],
31  chick: ['{', '}'],
32}
33
34export const DEFAULT_PROFILE = (now: number): PetProfile => ({
35  name: 'Mochi',
36  species: 'cat',
37  xp: 0,
38  born: now,
39  streak: 0,
40  best: 0,
41  today: { date: day(now), tests: 0 },
42  pets: 0,
43})
44
45/** Per mood: the ASCII face (eyes and mouth), the icon beside it in each style, and the line's color. */
46export const MOODS: Record<string, { eyes: string; ascii: string; emoji: string; color: string }> = {
47  watching: { eyes: '^.^', ascii: '', emoji: '👀', color: 'white' },
48  working: { eyes: 'o_o', ascii: '...', emoji: '💻', color: 'cyan' },
49  building: { eyes: '>_<', ascii: '[#]', emoji: '🔨', color: 'cyan' },
50  reading: { eyes: 'o.o', ascii: '[=]', emoji: '📖', color: 'cyan' },
51  running: { eyes: '*_*', ascii: '[>]', emoji: '⚙️', color: 'cyan' },
52  happy: { eyes: '^o^', ascii: '\\o/', emoji: '🎉', color: 'green' },
53  done: { eyes: '^-^', ascii: '*', emoji: '✨', color: 'green' },
54  worried: { eyes: 'O.O', ascii: '!', emoji: '😰', color: 'yellow' },
55  sad: { eyes: 'T_T', ascii: ';(', emoji: '😢', color: 'red' },
56  crowded: { eyes: '@_@', ascii: '!!', emoji: '😵', color: 'magenta' },
57  tired: { eyes: '=_=', ascii: 'zz', emoji: '🥱', color: 'magenta' },
58  idle: { eyes: '-_-', ascii: '...', emoji: '💭', color: 'gray' },
59  sleeping: { eyes: '-.-', ascii: 'zZ', emoji: '💤', color: 'gray' },
60  late: { eyes: 'u_u', ascii: 'C', emoji: '🌙', color: 'blue' },
61  petted: { eyes: '^w^', ascii: '<3', emoji: '💕', color: 'magenta' },
62  levelup: { eyes: '*o*', ascii: '**', emoji: '⭐', color: 'yellow' },
63}
64
65// ---------- levels ----------
66
67/** Level from XP: 25 XP to level 2, then each level costs a little more. */
68export function level(xp: number): number {
69  return Math.floor(Math.sqrt(Math.max(0, xp) / 25)) + 1
70}
71
72/** XP where the current level starts and where the next one begins. */
73export function levelRange(xp: number): { from: number; to: number } {
74  const l = level(xp)
75  return { from: 25 * (l - 1) ** 2, to: 25 * l ** 2 }
76}
77
78/**
79 * The pet as it looks at its level, in its mood: an egg first, then the
80 * species, then a legend. ASCII: `(^)` egg, `=^o^=` cat, `*=^o^=*` legend.
81 */
82export function face(profile: PetProfile, style: Style = 'ascii', mood = 'watching'): string {
83  const l = level(profile.xp)
84  if (style === 'emoji') {
85    const animal = SPECIES[profile.species] ?? SPECIES.cat!
86    if (l <= 1) return '🥚'
87    return l >= 10 ? `${animal}👑` : animal
88  }
89  const eyes = MOODS[mood]?.eyes ?? '^.^'
90  if (l <= 1) return `(${eyes.charAt(0)})`
91  const [left, right] = ASCII_BODY[profile.species] ?? ASCII_BODY.cat!
92  const body = `${left}${eyes}${right}`
93  return l >= 10 ? `*${body}*` : body
94}
95
96export function stage(xp: number): string {
97  const l = level(xp)
98  return l <= 1 ? 'egg' : l <= 4 ? 'baby' : l <= 9 ? 'grown' : 'legendary'
99}
100
101export function xpBar(xp: number, style: Style = 'ascii', cells = 5): string {
102  const { from, to } = levelRange(xp)
103  const filled = Math.round(((xp - from) / Math.max(1, to - from)) * cells)
104  return style === 'emoji' ? '█'.repeat(filled) + '░'.repeat(cells - filled) : `[${'#'.repeat(filled)}${'.'.repeat(cells - filled)}]`
105}
106
107/** Little marks that differ by style. */
108export const MARK: Record<string, Record<Style, string>> = {
109  heart: { ascii: '<3', emoji: '♥' },
110  streak: { ascii: 'x', emoji: '🔥' },
111  hatch: { ascii: '', emoji: ' 🐣' },
112  crown: { ascii: ' legendary!', emoji: ' 👑 legendary!' },
113}
114
115// ---------- reading what happened ----------
116
117const TEST_RUNNER =
118  /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|mocha)\b|\b(pytest|vitest|jest|mocha|phpunit|rspec)\b|\bpython3?\s+-m\s+(pytest|unittest)\b|\b(go|cargo|deno|dart|flutter|mvn|swift)\s+test\b|\bgradlew?\s+test\b|\bdotnet\s+test\b|\bmix\s+test\b/
119
120export function isTestCommand(command: string): boolean {
121  return TEST_RUNNER.test(command)
122}
123
124/** How many tests passed, when the runner's output says so. */
125export function passedCount(output: string): number | undefined {
126  const m = /(\d+)\s+(?:tests?\s+)?(?:passed|passing)\b/i.exec(output) ?? /\bTests?:\s+(\d+)\s+passed/i.exec(output) ?? /\bok\s+\((\d+)\s+tests?\)/i.exec(output)
127  return m ? Number(m[1]) : undefined
128}
129
130/** A short "what I'm doing" line from a tool call. */
131export function doingFor(tool: string, input: Record<string, unknown>): { mood: string; say: string } {
132  const file = (p: unknown) => (typeof p === 'string' ? p.replace(/\\/g, '/').split('/').pop() ?? p : '')
133  if (tool === 'Read') return { mood: 'reading', say: `reading ${file(input.file_path)}` }
134  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
135    return { mood: 'building', say: `building ${file(input.file_path ?? input.notebook_path)}` }
136  }
137  if (tool === 'Bash' && typeof input.command === 'string') {
138    const cmd = input.command.trim().split(/\s+/).slice(0, 3).join(' ')
139    return { mood: 'running', say: `running ${cmd.length > 28 ? cmd.slice(0, 25) + '...' : cmd}` }
140  }
141  if (tool === 'Grep' || tool === 'Glob') return { mood: 'reading', say: 'searching the code' }
142  if (tool === 'WebSearch' || tool === 'WebFetch') return { mood: 'reading', say: 'looking things up' }
143  if (tool === 'Agent') return { mood: 'working', say: 'calling a friend' }
144  return { mood: 'working', say: 'on it!' }
145}
146
147// ---------- what the pet shows right now ----------
148
149export type Shown = { mood: string; say: string }
150
151type Base = {
152  flash: PetFlash | null
153  now: number
154  lastActivity: number
155  isWorking: boolean
156  doing: string
157  doingMood?: string
158  failStreak: number
159  contextPercent: number
160  limitPercent: number
161  hour: number
162  name: string
163}
164
165export function shown(b: Base): Shown {
166  if (b.flash && b.flash.until > b.now) return { mood: b.flash.mood, say: b.flash.say }
167  if (b.isWorking) return { mood: b.doingMood ?? 'working', say: b.doing || 'on it!' }
168  const idleMin = (b.now - b.lastActivity) / 60_000
169  if (idleMin >= 15) return { mood: 'sleeping', say: `${b.name} is asleep` }
170  if (b.hour >= 1 && b.hour < 5) return { mood: 'late', say: "it's late... go to sleep?" }
171  if (b.limitPercent >= 90) return { mood: 'tired', say: "I'm tired... your limit is almost used" }
172  if (b.contextPercent >= 75) return { mood: 'crowded', say: "it's crowded in here... /compact?" }
173  if (b.failStreak >= 3) return { mood: 'sad', say: 'rough day... want to take a break?' }
174  if (idleMin >= 5) return { mood: 'idle', say: '...' }
175  return { mood: 'watching', say: `${b.name} is watching you code` }
176}
177
178/** A tiny animation: the icon beside the pet changes every second for some moods. */
179export function animate(mood: string, frame: number, style: Style = 'ascii'): string {
180  if (style === 'emoji') {
181    const icon = MOODS[mood]?.emoji ?? '👀'
182    if (mood === 'sleeping') return ['💤', '  ', '💤', 'z '][frame % 4]!
183    if (mood === 'happy' || mood === 'levelup' || mood === 'petted') return frame % 2 === 0 ? icon : icon === '🎉' ? '🥳' : icon === '💕' ? '💖' : '🌟'
184    if (mood === 'watching') return frame % 6 === 5 ? '😑' : icon
185    if (mood === 'building' || mood === 'running' || mood === 'working') return frame % 2 === 0 ? icon : '💦'
186    return icon
187  }
188  const icon = MOODS[mood]?.ascii ?? ''
189  if (mood === 'sleeping') return ['z', 'zZ', 'zZz', 'zZ'][frame % 4]!
190  if (mood === 'working' || mood === 'idle') return ['.', '..', '...'][frame % 3]!
191  if (mood === 'happy') return frame % 2 === 0 ? '\\o/' : '_o_'
192  if (mood === 'petted') return frame % 2 === 0 ? '<3' : '<3 <3'
193  if (mood === 'levelup') return frame % 2 === 0 ? '**' : '*'
194  return icon
195}
196
197/** In ASCII style the eyes blink now and then while watching. */
198export function blink(mood: string, frame: number, style: Style): string {
199  return style === 'ascii' && mood === 'watching' && frame % 6 === 5 ? 'idle' : mood
200}
201
202// ---------- helpers ----------
203
204const pad = (n: number) => String(n).padStart(2, '0')
205
206export function day(ms: number): string {
207  const d = new Date(ms)
208  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
209}
210
types/index.d.ts 39 lines
1/** The pet itself: saved across sessions in the plugin store. */
2export type PetProfile = {
3  name: string
4  species: string
5  xp: number
6  /** When the pet hatched, ms since the epoch. */
7  born: number
8  /** Green test runs in a row, and the best run ever. */
9  streak: number
10  best: number
11  /** Tests passed today, by local date. */
12  today: { date: string; tests: number }
13  pets: number
14}
15
16/** A mood that lasts a few seconds (tests passed, a failure, a pat), then the pet goes back to its base mood. */
17export type PetFlash = { mood: string; say: string; until: number }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'code-pet': {
22      profile: PetProfile | null
23      flash: PetFlash | null
24      /** Ticks every second: animation frame and idle checks. */
25      now: number
26      lastActivity: number
27      isWorking: boolean
28      /** What the pet says while Claude works: "reading app.js", "running npm test". */
29      doing: string
30      /** The mood that goes with it: reading, building, running, working. */
31      doingMood: string
32      failStreak: number
33      /** Context window and plan-limit fill, from the latest response. */
34      contextPercent: number
35      limitPercent: number
36    }
37  }
38}
39