SLOPSHOPPER

pokepal

A Pokémon pal for your session (Fuecoco, Mimikyu, Pumpkaboo, Drifloon): a side pane and a mini strip above the prompt, animated from Pokémon Sleep.

newpanebandguardcommandprompt
★ 1v0.2.2no licenseupdated 2026-10-22FutureProg/claude-plugins/Pokepal
A shopper browsing a rack in a slop shop
README

Claude Plugins

Small mods for Claude Code (terminal and desktop), written as plugins of function hooks that hot-reload in a session.

FolderWhat it is
Pokepal/A buddy for your session, with four to pick from: Fuecoco, Mimikyu, Pumpkaboo and Drifloon (/pokepal for a random one, /pokepal go <name or national dex number> for a specific one; /pokepal open-panel shows the side pane with buttons, which stays closed by default; /pokepal sleep / wake put him to sleep or wake him; `/pokepal bg lightdarkauto sets the strip's colour on desktop (auto follows the theme setting); /pokepal off turns him off for good, /pokepal on` back on). A side pane plus a mini version above the prompt. Moods follow the session (walks while Claude works, cheers when a turn is done, dozes off when you're away), with voice clips.
Pokeball/A Pokéball above the prompt that rocks while Claude works and clicks shut when a turn finishes.
tools/spine-render/The scripts used to turn Pokémon Sleep Spine animations into frames (see below): renderall.html renders every animation of the listed Pokémon, process.py crops and packs them, mkapng.py makes the desktop animations.

Installing as a plugin

This repo is a plugin marketplace. From a terminal session:

/plugin marketplace add FutureProg/claude-plugins
/plugin install pokepal@nicks-plugins
/plugin install pokeball@nicks-plugins

Or from a shell: claude plugin marketplace add FutureProg/claude-plugins, then claude plugin install pokepal@nicks-plugins. The repo is private, so you need GitHub access set up on your machine. To try it from a local checkout, use claude plugin marketplace add "<path to this repo>".

Loading them (development)

In a terminal session:

claude --plugin-dir "/Users/nick/Dev/Claude Plugins/Pokepal"

In the desktop app, load the plugin-authoring skill once per session, then link the folders into that session's hot-reload folder:

ln -s "/Users/nick/Dev/Claude Plugins/Pokepal"  ~/.claude/dev-mods/<session-id>/pokepal
ln -s "/Users/nick/Dev/Claude Plugins/Pokeball" ~/.claude/dev-mods/<session-id>/pokeball

The desktop's hot-reload watcher misses edits made through symlinks, so use real copies and keep them in sync after each edit:

tools/sync-dev.sh [session-id]   # copies Pokepal/ (and Pokeball/ if it is already there) into the newest ~/.claude/dev-mods/<session-id>/

Check a mod with claude plugin validate <folder>.

Loading it in every session

Put the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (colon-separated for several folders). Every new session, terminal or desktop app, then loads it:

"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/Users/nick/Dev/Claude Plugins/Pokepal" }

Notes on the assets (private repo)

Pokepal/frames, Pokepal/anim and Pokepal/sounds hold art and audio from the Pokémon games. They are here for personal use only

  • Animation frames were rendered from the Spine 4.0.63 skeletons for Pokémon Sleep (as shown on the RaenonX Pokémon Sleep Wiki) with tools/spine-render and the spine-webgl 4.0 runtime. Render with premultiplied alpha off, or the pieces get bright outlines.
  • Voice clips are the Scarlet/Violet sounds (Pumpkaboo's from Z-A), from a personal collection.
  • Desktop pictures are palette APNGs, because the desktop's SVG source is capped at 131,072 characters.

Sources

Poképal

Source 2 files
hooks/register.tsx 790 lines
1import type { Register } from 'claude-code'
2
3import type { Anim } from '../types'
4
5const PANE = 'pokepal'
6const TICK_MS = 50
7// desktop only: the strip above the prompt is filled with the page's own background, so it looks transparent
8// (the app draws a grey frame around it that this covers); the terminal's strip has no fill at all.
9// Light or dark, from the theme setting, or /pokepal bg light|dark|auto to force one
10const BAND_BG: Record<string, string> = { light: '#fcfcfb', dark: '#151515' }
11
12// the buddies: each has the same set of animations (rendered frames and ms per frame); Mimikyu also has a hurt pose and a disguise
13const BUDDIES: Record<string, { name: string; dex: number; anims: Record<string, { frames: number; ms: number }> }> = {
14  fuecoco: {
15    name: 'Fuecoco',
16    dex: 909,
17    anims: {
18      standing: { frames: 71, ms: 50 },
19      moving: { frames: 26, ms: 50 },
20      idle: { frames: 59, ms: 100 },
21      idlehappy: { frames: 59, ms: 100 },
22      idledrowse: { frames: 59, ms: 100 },
23      idlesleep: { frames: 59, ms: 100 },
24      sleep1: { frames: 111, ms: 200 },
25      sleep2: { frames: 125, ms: 200 },
26      sleep3: { frames: 145, ms: 200 },
27    },
28  },
29  mimikyu: {
30    name: 'Mimikyu',
31    dex: 778,
32    anims: {
33      standing: { frames: 80, ms: 50 },
34      moving: { frames: 29, ms: 50 },
35      idle: { frames: 53, ms: 100 },
36      idlehappy: { frames: 53, ms: 100 },
37      idledrowse: { frames: 67, ms: 100 },
38      idlesleep: { frames: 67, ms: 100 },
39      sleep1: { frames: 136, ms: 200 },
40      sleep2: { frames: 134, ms: 200 },
41      sleep3: { frames: 139, ms: 200 },
42      damage: { frames: 37, ms: 50 },
43      disguise: { frames: 41, ms: 50 },
44      h2idle: { frames: 53, ms: 100 },
45      h2idlehappy: { frames: 53, ms: 100 },
46      h2idledrowse: { frames: 67, ms: 100 },
47      h2idlesleep: { frames: 67, ms: 100 },
48    },
49  },
50  pumpkaboo: {
51    name: 'Pumpkaboo',
52    dex: 710,
53    anims: {
54      standing: { frames: 80, ms: 50 },
55      moving: { frames: 47, ms: 50 },
56      idle: { frames: 67, ms: 100 },
57      idlehappy: { frames: 67, ms: 100 },
58      idledrowse: { frames: 67, ms: 100 },
59      idlesleep: { frames: 67, ms: 100 },
60      sleep1: { frames: 135, ms: 200 },
61      sleep2: { frames: 117, ms: 200 },
62      sleep3: { frames: 130, ms: 200 },
63    },
64  },
65  drifloon: {
66    name: 'Drifloon',
67    dex: 425,
68    anims: {
69      standing: { frames: 85, ms: 50 },
70      moving: { frames: 50, ms: 50 },
71      idle: { frames: 60, ms: 100 },
72      idlehappy: { frames: 60, ms: 100 },
73      idledrowse: { frames: 60, ms: 100 },
74      idlesleep: { frames: 60, ms: 100 },
75      sleep1: { frames: 141, ms: 200 },
76      sleep2: { frames: 131, ms: 200 },
77      sleep3: { frames: 155, ms: 200 },
78    },
79  },
80}
81
82const LABELS: Record<Anim, string> = {
83  standing: 'Happy Loop',
84  happy1: 'Happy!',
85  moving: 'Moving',
86  idle: 'Idle',
87  idlehappy: 'Glad',
88  idledrowse: 'Drowsy',
89  idlesleep: 'Dozing',
90  sleep1: '★1 Sleep',
91  sleep2: '★2 Sleep',
92  sleep3: '★3 Sleep',
93  damage: 'Hurt',
94  disguise: 'Disguise',
95  h2idle: 'Busted',
96  h2idlehappy: 'Busted: Glad',
97  h2idledrowse: 'Busted: Sad',
98  h2idlesleep: 'Busted: Dozing',
99}
100
101// what to play for an animation a buddy does not have: the closest one it does
102const FALLBACK: Partial<Record<Anim, Anim>> = { damage: 'idle', disguise: 'idle' }
103
104// one animation of one buddy: how many frames, how long each shows, which frame files, and whether it plays once
105function specOf(who: string, anim: Anim) {
106  const anims = (BUDDIES[who] ?? BUDDIES.fuecoco).anims
107  const base: Anim = anim === 'happy1' ? 'standing' : anim
108  const have = anims[base] ? base : (FALLBACK[base] ?? 'idle')
109  const data = anims[have]
110
111  return { frames: data.frames, ms: data.ms, files: have, file: anim === 'happy1' ? 'happy1' : have, once: anim === 'happy1' || anim === 'disguise', label: LABELS[anim] }
112}
113
114// moods follow the session; ticks are 50 ms, so 20 = 1 s
115const MOODS = {
116  working: { anim: 'moving', text: 'working' },
117  tired: { anim: 'idledrowse', text: 'worn out' },
118  happy: { anim: 'happy1', text: 'happy!', next: 'glad', after: 72 },
119  glad: { anim: 'idlehappy', text: 'glad', next: 'calm', after: 120 },
120  calm: { anim: 'idle', text: 'relaxing', next: 'drowsy', after: 1200 },
121  drowsy: { anim: 'idledrowse', text: 'drowsy', next: 'dozing', after: 1200 },
122  dozing: { anim: 'idlesleep', text: 'dozing off', next: 'asleep1', after: 2400 },
123  asleep1: { anim: 'sleep1', text: 'asleep', next: 'asleep2', after: 12000 },
124  asleep2: { anim: 'sleep2', text: 'sound asleep', next: 'asleep3', after: 12000 },
125  asleep3: { anim: 'sleep3', text: 'dreaming' },
126  alert: { anim: 'idle', text: 'waiting for you' },
127  flustered: { anim: 'idle', text: 'uh-oh', next: 'calm', after: 100 },
128  step: { anim: 'idle', text: 'step done, more to go', next: 'drowsy', after: 1200 },
129  oops: { anim: 'idlehappy', text: 'did I do something?', next: 'calm', after: 120 },
130  stuck: { anim: 'idledrowse', text: 'hit a wall', next: 'busted', after: 42 },
131  busted: { anim: 'idledrowse', text: 'hit a wall', next: 'dozing', after: 1200 },
132} as const
133
134type Mood = keyof typeof MOODS
135
136// ask a small model how the turn went (costs a few tokens per turn); false uses the word-spotting guess alone
137const USE_MODEL_JUDGE = true
138
139const TIRED_AFTER = 2400 // two minutes of one turn
140const STARTLE_TICKS = 60 // three seconds
141
142const frames: Record<string, string[]> = {}
143const apngs: Record<string, string> = {}
144let tick = 0
145let mood: Mood = 'calm'
146let moodSince = 0
147let isAuto = true
148let startleAt = 0
149let buddy = 'fuecoco'
150let busted = false // Mimikyu's disguise is broken (phase 2) until your next prompt
151const haveSounds: Record<string, Set<string>> = {}
152
153// while his disguise is broken, a buddy that has the phase-2 poses rests in them instead
154const BUSTED: Partial<Record<Mood, Anim>> = {
155  busted: 'h2idledrowse',
156  tired: 'h2idledrowse',
157  drowsy: 'h2idledrowse',
158  calm: 'h2idle',
159  step: 'h2idle',
160  alert: 'h2idle',
161  flustered: 'h2idle',
162  glad: 'h2idlehappy',
163  oops: 'h2idlehappy',
164  dozing: 'h2idlesleep',
165  asleep1: 'h2idlesleep',
166  asleep2: 'h2idlesleep',
167  asleep3: 'h2idlesleep',
168}
169
170// the animation a mood plays; a failed tool shows the hurt pose, and a turn that hit a wall the broken disguise, when the buddy has them
171function moodAnim(m: Mood): Anim {
172  if (busted && BUDDIES[buddy]?.anims.h2idle && BUSTED[m]) return BUSTED[m] as Anim
173  if (m === 'flustered' && BUDDIES[buddy]?.anims.damage) return 'damage'
174  if (m === 'stuck' && BUDDIES[buddy]?.anims.disguise) return 'disguise'
175
176  return MOODS[m].anim
177}
178
179async function show($: any, anim: Anim) {
180  await $.state.set({ plugin: 'pokepal', key: 'anim' } as const, anim)
181  await $.state.set({ plugin: 'pokepal', key: 'since' } as const, tick)
182}
183
184async function setMood($: any, next: Mood) {
185  if (next === mood) return
186  mood = next
187  moodSince = tick
188  if (next === 'stuck' && BUDDIES[buddy]?.anims.h2idle) busted = true
189  await $.state.set({ plugin: 'pokepal', key: 'status' } as const, MOODS[next].text)
190
191  if (isAuto) await show($, moodAnim(next))
192}
193
194function startle($: any, mark: string) {
195  startleAt = tick
196
197  return $.state.set({ plugin: 'pokepal', key: 'startle' } as const, { at: tick, mark })
198}
199
200async function pick($: any, anim: Anim) {
201  isAuto = false
202  await $.state.set({ plugin: 'pokepal', key: 'auto' } as const, false)
203  await show($, anim)
204}
205
206async function follow($: any) {
207  isAuto = true
208  await $.state.set({ plugin: 'pokepal', key: 'auto' } as const, true)
209  await show($, moodAnim(mood))
210}
211
212// on or off for good (remembered across sessions); off hides the strip, closes the panel and keeps him quiet
213async function setEnabled($: any, isOn: boolean) {
214  await $.state.set({ plugin: 'pokepal', key: 'enabled' } as const, isOn)
215  await $.store.set('enabled', isOn)
216
217  if (!isOn) {
218    await $.ui.close({ id: PANE })
219    await $.state.set({ plugin: 'pokepal', key: 'paneUp' } as const, false)
220  }
221}
222
223async function choose($: any, who: string) {
224  if (!BUDDIES[who]) return
225  buddy = who
226  busted = false
227  await $.state.set({ plugin: 'pokepal', key: 'buddy' } as const, who)
228  await show($, isAuto ? moodAnim(mood) : (await $.state.get({ plugin: 'pokepal', key: 'anim' } as const)).value ?? 'idle')
229}
230
231type Verdict = 'done' | 'progress' | 'stuck'
232
233// the free guess: look for words that sound like a blocker, a question, or a finished job
234function guess(text: string): Verdict {
235  const t = text.toLowerCase()
236  const tail = t.slice(-400)
237
238  if (/\?\s*$/.test(t.trim())) return 'progress'
239  if (/(couldn't|could not|unable to|wasn't able|can't|cannot|failed|doesn't work|didn't work|blocked|gave up|give up|not possible)/.test(tail)) return 'stuck'
240  if (/(done|fixed|built|works|working|passes|passed|validated|ready|complete|finished|saved|created)/.test(tail)) return 'done'
241
242  return 'progress'
243}
244
245async function judge($: any, answer: string, reason: string): Promise<Verdict> {
246  if (reason === 'aborted') return 'progress'
247  if (reason !== 'answer') return 'stuck'
248
249  const text = answer.trim()
250  if (text === '') return 'progress'
251  if (!USE_MODEL_JUDGE) return guess(text)
252
253  const r = await $.model.complete({
254    model: 'haiku',
255    effort: 'low',
256    maxTokens: 8,
257    timeoutMs: 8000,
258    prompt:
259      "Label the final message of an AI coding assistant's turn. Reply with exactly one word.\n" +
260      'done = it accomplished what the user asked, or a clear goal.\n' +
261      'progress = it finished a step fine but more steps remain, or it is asking the user something.\n' +
262      'stuck = it could not accomplish the task, hit a blocker, is giving up, or has to change approach.\n\n' +
263      `Message:\n<<<\n${text.slice(-1500)}\n>>>`,
264  })
265
266  if (r.isAnswered) {
267    const word = r.text.trim().toLowerCase()
268    if (word.startsWith('done')) return 'done'
269    if (word.startsWith('stuck')) return 'stuck'
270    if (word.startsWith('progress')) return 'progress'
271  }
272
273  return guess(text)
274}
275
276// voice clips live in sounds/<buddy>/ (afplay plays wav); the pane's test button steps through the standard names
277const SOUNDS = ['index', 'glad1', 'glad2', 'glad3', 'sad', 'ex1', 'ex2', 'ex3', 'ex4', 'anger', 'atk', 'spatk', 'env'] as const
278const GLAD = ['glad1', 'glad2', 'glad3'] as const
279const NEUTRAL = ['ex1', 'ex3', 'ex4'] as const
280const FRIENDLY = ['index', 'env', 'glad1', 'glad2', 'glad3', 'ex1', 'ex3', 'ex4'] as const
281let nextSound = 0
282let failures = 0 // tool failures this turn: they sound only when the turn ends
283let lastChimeAt = -10000
284
285// which clips a buddy has (read once); a buddy with no clips is simply quiet
286async function soundsOf($: any, who: string) {
287  if (!haveSounds[who]) {
288    try {
289      const files = await $.fs.list(`${$.plugin.root}/sounds/${who}`)
290      haveSounds[who] = new Set(files.filter((f: any) => f.kind === 'file' && /\.wav$/i.test(f.name)).map((f: any) => f.name.replace(/\.wav$/i, '')))
291    } catch {
292      haveSounds[who] = new Set()
293    }
294  }
295
296  return haveSounds[who]
297}
298
299// play one clip unless muted, and remember which, for the pane to show; a missing clip falls back to his plain cry
300async function cry($: any, name: string) {
301  const { value: isOn = true } = await $.state.get({ plugin: 'pokepal', key: 'sound' } as const)
302  const { value: isEnabled = true } = await $.state.get({ plugin: 'pokepal', key: 'enabled' } as const)
303  if (!isOn || !isEnabled) return
304
305  const have = await soundsOf($, buddy)
306  const clip = have.has(name) ? name : have.has('index') ? 'index' : null
307  if (clip === null) return
308
309  await $.state.set({ plugin: 'pokepal', key: 'lastSound' } as const, clip)
310
311  try {
312    await $.audio.play({ asset: `sounds/${buddy}/${clip}.wav` })
313  } catch {
314    // the player is missing or the file is: stay quiet
315  }
316}
317
318function cheer($: any) {
319  return cry($, GLAD[Math.floor(Math.random() * GLAD.length)])
320}
321
322// a neutral "hey" for questions and permissions; at most one every ten seconds so it never pesters
323function chime($: any) {
324  if (tick - lastChimeAt < 200) return Promise.resolve()
325  lastChimeAt = tick
326
327  return cry($, NEUTRAL[Math.floor(Math.random() * NEUTRAL.length)])
328}
329
330// a poke wakes him if he is sleepy or idle (not while he is busy or waiting for you), with a random friendly sound
331async function poke($: any) {
332  if (mood !== 'working' && mood !== 'tired' && mood !== 'alert') {
333    if (mood === 'calm' || mood === 'drowsy' || mood === 'dozing' || mood === 'asleep1' || mood === 'asleep2' || mood === 'asleep3' || mood === 'step' || mood === 'stuck') {
334      await setMood($, 'glad')
335    } else if (!isAuto) {
336      await show($, moodAnim('glad'))
337    }
338  }
339
340  await cry($, FRIENDLY[Math.floor(Math.random() * FRIENDLY.length)])
341}
342
343function testSound($: any) {
344  const name = SOUNDS[nextSound % SOUNDS.length]
345  nextSound += 1
346
347  return cry($, name)
348}
349
350// Fuecoco's picture for a render event: the pane's big one, or the mini one above the prompt
351async function draw($: any, e: any, isMini: boolean) {
352  const { value: anim = 'idle' } = await $.state.get({ plugin: 'pokepal', key: 'anim' } as const)
353  const { value: scare = null } = await $.state.get({ plugin: 'pokepal', key: 'startle' } as const)
354  const { value: who = 'fuecoco' } = await $.state.get({ plugin: 'pokepal', key: 'buddy' } as const)
355  const spec = specOf(who, anim as Anim)
356  const cacheKey = `${who}/${spec.file}`
357
358  if (e.surface === 'terminal') {
359    const { Box, Text, Image } = $.ui.resolve(e)
360
361    frames[cacheKey] ??= await Promise.all(
362      Array.from({ length: spec.frames }, async (_, i) =>
363        (await $.fs.read(`${$.plugin.root}/frames/${who}/${spec.files}-${String(i).padStart(3, '0')}.png`, { as: 'bytes' })).base64,
364      ),
365    )
366
367    // only the terminal reads the tick: a redraw per tick would make desktop's picture blink
368    const { value: now = 0 } = await $.state.get({ plugin: 'pokepal', key: 'tick' } as const)
369    const { value: since = 0 } = await $.state.get({ plugin: 'pokepal', key: 'since' } as const)
370    const step = Math.max(0, Math.floor((now - since) / (spec.ms / TICK_MS)))
371    const png = frames[cacheKey][spec.once ? Math.min(step, spec.frames - 1) : step % spec.frames]
372    const picture = (
373      <Image key="buddy" source={{ png }} columns={isMini ? 6 : 14} rows={isMini ? 3 : 7} alt={BUDDIES[who]?.name ?? 'Fuecoco'} />
374    )
375
376    if (isMini) return picture
377
378    // the mark sits on its own row above his head (blank when there is none, so nothing jumps)
379    const isMarked = scare !== null && now - scare.at < STARTLE_TICKS
380    return (
381      <Box flexDirection="column">
382        <Text color={scare !== null && scare.mark === '?' ? 'blue' : 'red'} bold>
383          {'         '}
384          {isMarked && scare !== null ? scare.mark : ' '}
385        </Text>
386        <Box>
387          <Text>{'  '}</Text>
388          {picture}
389        </Box>
390      </Box>
391    )
392  }
393
394  // desktop, the phone and the editor all draw the animated picture as an SVG
395  if (e.surface === 'desktop' || e.surface === 'mobile' || e.surface === 'vscode') {
396    const { Svg } = $.ui.resolve(e)
397
398    apngs[cacheKey] ??= (await $.fs.read(`${$.plugin.root}/anim/${who}/${spec.file}.png`, { as: 'bytes' })).base64
399
400    // an animated PNG (APNG) plays itself inside the SVG; the SVG source is capped at 131072 characters
401    const uri = `data:image/png;base64,${apngs[cacheKey]}`
402    // a bubble that pops in over his head: red for '!', blue for '?'
403    const bubble =
404      scare !== null
405        ? `<g transform="translate(80 30)"><g><animateTransform attributeName="transform" type="scale" values="0;1.35;1" keyTimes="0;0.6;1" dur="0.35s" fill="freeze"/>` +
406          `<circle r="17" fill="${scare.mark === '?' ? '#2f6fe0' : '#e03030'}" stroke="#ffffff" stroke-width="3"/>` +
407          `<text y="9" font-size="26" font-weight="bold" text-anchor="middle" fill="#ffffff" font-family="Arial,Helvetica,sans-serif">${scare.mark}</text></g></g>`
408        : ''
409    // a play-once animation restarts only if its source changes, so stamp it with when it started
410    const { value: startedAt = 0 } =
411      spec.once ? await $.state.get({ plugin: 'pokepal', key: 'since' } as const) : { value: 0 }
412    const source =
413      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128"><!--${startedAt}--><g>` +
414      `<image href="${uri}" x="0" y="0" width="128" height="128"/></g>${bubble}</svg>`
415
416    return <Svg source={source} alt={BUDDIES[who]?.name ?? 'Fuecoco'} width={isMini ? 56 : 128} height={isMini ? 56 : 128} />
417  }
418
419  return null
420}
421
422async function settle($: any, e: any) {
423  // while he is off there is nothing to react with, and no reason to spend a model call judging the turn
424  const { value: isEnabled = true } = await $.state.get({ plugin: 'pokepal', key: 'enabled' } as const)
425  if (!isEnabled) return
426
427  // you interrupted: he perks up with a question mark
428  if (e.isAborted === true || e.reason === 'aborted') {
429    failures = 0
430    await setMood($, 'oops')
431    await startle($, '?')
432    void chime($)
433
434    return
435  }
436
437  const verdict = await judge($, e.answer, e.reason)
438
439  await setMood($, verdict === 'done' ? 'happy' : verdict === 'stuck' ? 'stuck' : 'step')
440
441  // failures speak up only now, at the end of the turn
442  if (verdict === 'done') void cheer($)
443  else if (verdict === 'stuck') void cry($, 'sad')
444  else if (failures > 0) void cry($, 'ex2')
445
446  failures = 0
447}
448
449// a buddy's key from what was typed: its name, or its national dex number (909 or #909)
450function whoIs(typed: string): string | undefined {
451  if (BUDDIES[typed]) return typed
452  const dex = Number(typed.replace(/^#/, ''))
453
454  return Object.keys(BUDDIES).find(k => BUDDIES[k].dex === dex)
455}
456
457const HELP = [
458  `Not sure what that means. Here is what /pokepal takes:`,
459  '  /pokepal                      a random buddy',
460  `  /pokepal go <name or dex no.> pick one: ${Object.keys(BUDDIES).map(k => `${k} (${BUDDIES[k].dex})`).join(', ')}; just go: a random one`,
461  '  /pokepal sleep | wake         put him to sleep or wake him',
462  '  /pokepal open-panel           show the side panel (close-panel hides it)',
463  '  /pokepal bg light|dark|auto   strip colour on desktop (auto follows the theme)',
464  '  /pokepal off | on             turn him off for good, or back on',
465].join('\n')
466
467// which fill the strip gets: the saved override, else the app's theme setting (anything with "dark" in it), else light
468async function applyBg($: any) {
469  const saved = await $.store.get('bg')
470  let bg = saved === 'light' || saved === 'dark' ? saved : 'light'
471
472  if (bg !== saved) {
473    try {
474      const theme = (await $.config.list()).find((row: any) => row.key === 'theme')
475      if (typeof theme?.value === 'string' && theme.value.includes('dark')) bg = 'dark'
476    } catch {
477      // no theme to read: stay light
478    }
479  }
480
481  await $.state.set({ plugin: 'pokepal', key: 'bg' } as const, bg)
482
483  return bg
484}
485
486export const register: Register = on => {
487  on('session.start', async ($, e, next) => {
488    await $.command.register({
489      name: 'pokepal',
490      description: 'A random Pokémon pal; or /pokepal go <name or national dex number>; open-panel / close-panel for the side panel; sleep / wake; bg light|dark|auto for the strip colour; off / on to disable or enable',
491    })
492    const saved = await $.store.get('enabled')
493    await $.state.set({ plugin: 'pokepal', key: 'enabled' } as const, saved !== false)
494    await applyBg($)
495    // the side panel stays closed unless asked for; after a reload it may already be up
496    const panes = await $.ui.panes()
497    await $.state.set({ plugin: 'pokepal', key: 'paneUp' } as const, panes.some(p => p.id === PANE && p.isPlaced))
498    await $.state.set({ plugin: 'pokepal', key: 'sound' } as const, true)
499    const { value: savedBuddy = 'fuecoco' } = await $.state.get({ plugin: 'pokepal', key: 'buddy' } as const)
500    buddy = BUDDIES[savedBuddy] ? savedBuddy : 'fuecoco'
501    await $.state.set({ plugin: 'pokepal', key: 'buddy' } as const, buddy)
502    await $.state.set({ plugin: 'pokepal', key: 'status' } as const, MOODS[mood].text)
503    await $.state.set({ plugin: 'pokepal', key: 'auto' } as const, true)
504    await show($, moodAnim(mood))
505
506    // one shared clock: the terminal's frame swap, and the mood timeline
507    $.clock.every(TICK_MS, () => {
508      tick += 1
509      void $.state.set({ plugin: 'pokepal', key: 'tick' } as const, tick)
510
511      // a startle lasts three seconds, then the mark goes away
512      if (startleAt > 0 && tick - startleAt >= STARTLE_TICKS) {
513        startleAt = 0
514        void $.state.set({ plugin: 'pokepal', key: 'startle' } as const, null)
515      }
516
517      const spec = MOODS[mood]
518      if ('next' in spec && tick - moodSince >= spec.after) void setMood($, spec.next)
519      else if (mood === 'working' && tick - moodSince >= TIRED_AFTER) void setMood($, 'tired')
520    })
521
522    return next(e)
523  })
524
525  on('command.run', { command: 'pokepal' }, async ($, e) => {
526    const arg = e.args.trim().toLowerCase()
527
528    if (arg === 'off' || arg === 'disable' || arg === 'hide') {
529      await setEnabled($, false)
530
531      return {}
532    }
533
534    // the strip's fill on desktop: bg light | dark | auto (auto follows the theme setting)
535    if (arg === 'bg' || arg.startsWith('bg ')) {
536      const choice = arg.slice(2).trim()
537      if (choice !== 'light' && choice !== 'dark' && choice !== 'auto') return { text: 'Usage: /pokepal bg light|dark|auto' }
538      await $.store.set('bg', choice)
539      const bg = await applyBg($)
540
541      return { text: `Strip background: ${choice === 'auto' ? `auto (using ${bg})` : choice}` }
542    }
543
544    // anything else turns him back on
545    await setEnabled($, true)
546
547    if (arg === 'on' || arg === 'enable') return {}
548
549    // straight to sleep (he wakes at your next prompt, or with /pokepal wake)
550    if (arg === 'sleep') {
551      await follow($)
552      await setMood($, 'asleep1')
553
554      return {}
555    }
556
557    if (arg === 'wake') {
558      await follow($)
559      await poke($)
560
561      return {}
562    }
563
564    if (arg === 'open-panel' || arg === 'panel' || arg === 'open') {
565      const opened = await $.ui.open({ id: PANE, title: 'PokéPal' })
566      await $.state.set({ plugin: 'pokepal', key: 'paneUp' } as const, opened.isPlaced === true)
567
568      return {}
569    }
570
571    if (arg === 'close-panel' || arg === 'close') {
572      await $.ui.close({ id: PANE })
573      await $.state.set({ plugin: 'pokepal', key: 'paneUp' } as const, false)
574
575      return {}
576    }
577
578    // go <name or national dex number> picks a buddy; plain go, like no argument, picks a random one.
579    // A bare name (the old form) still works, but is not advertised.
580    const isGo = arg === 'go' || arg.startsWith('go ')
581    const typed = isGo ? arg.slice(2).trim() : arg
582
583    if (typed !== '') {
584      const who = isGo ? whoIs(typed) : BUDDIES[typed] ? typed : undefined
585      if (!who) {
586        return { text: isGo ? `No buddy called "${typed}". Pick one of: ${Object.keys(BUDDIES).map(k => `${k} (${BUDDIES[k].dex})`).join(', ')}` : HELP }
587      }
588      await choose($, who)
589
590      return {}
591    }
592
593    // a random pal, never the one already out
594    const others = Object.keys(BUDDIES).filter(k => k !== buddy)
595    await choose($, others[Math.floor(Math.random() * others.length)])
596
597    return {}
598  })
599
600  // the mini Fuecoco above the prompt steps aside while the pane is open, and returns when it closes
601  on('ui.close', { id: PANE }, async ($, e, next) => {
602    const closed = await next(e)
603    await $.state.set({ plugin: 'pokepal', key: 'paneUp' } as const, false)
604
605    return closed
606  })
607
608  on('prompt.submit', async ($, e, next) => {
609    failures = 0
610    busted = false
611    if (mood === 'dozing' || mood === 'asleep1' || mood === 'asleep2' || mood === 'asleep3') void cry($, 'env')
612    await setMood($, 'working')
613
614    return next(e)
615  })
616
617  on('turn.complete', async ($, e, next) => {
618    const ran = await next(e)
619
620    // a subagent's turn is not the main one
621    if (e.agentId === undefined) await settle($, e)
622
623    return ran
624  })
625
626  // a failed tool, or a question for you, stops him in his tracks; the next tool call sets him walking again
627  on('tool.call', async ($, e, next) => {
628    if (mood === 'alert' || mood === 'flustered') await setMood($, 'working')
629
630    if (e.tool === 'AskUserQuestion') {
631      await setMood($, 'alert')
632      await startle($, '?')
633      void chime($)
634      const answered = await next(e)
635      await setMood($, 'working')
636
637      return answered
638    }
639
640    const ran = await next(e)
641
642    if (ran.deny === undefined && ran.isError === true) {
643      failures += 1
644      await setMood($, 'flustered')
645      await startle($, '!')
646    }
647
648    return ran
649  })
650
651  on('classic.PermissionRequest', async ($, e, next) => {
652    await setMood($, 'alert')
653    await startle($, '?')
654    void chime($)
655
656    return next(e)
657  })
658
659  // the mini Fuecoco above the prompt, only while the pane is not open
660  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
661    const below = await next(e)
662    if (e.props.hasSurvey) return below
663
664    const { value: isEnabled = true } = await $.state.get({ plugin: 'pokepal', key: 'enabled' } as const)
665    if (!isEnabled) return below
666
667    const { value: isPaneUp = false } = await $.state.get({ plugin: 'pokepal', key: 'paneUp' } as const)
668    if (isPaneUp) return below
669
670    const picture = await draw($, e, true)
671    if (picture === null) return below
672
673    const { value: bgName = 'light' } = await $.state.get({ plugin: 'pokepal', key: 'bg' } as const)
674
675    const { value: scare = null } = await $.state.get({ plugin: 'pokepal', key: 'startle' } as const)
676    const { Box, Text } = $.ui.resolve(e)
677    // on desktop the app draws a grey frame around the strip: stretch past each side to cover it.
678    // Negative margins pull the box outward (and shrink what the bar reserves); the padding gives that room back
679    // and sits his picture low, so his feet are on the bottom edge. A taller tree needs more rows from the band
680    // or it scrolls, so with fewer rows the stretch is trimmed: first the extra top bleed, then all vertical bleed.
681    const wide = (e.props.bodyColumns ?? 80) + 3
682    const rows = e.props.maxRows ?? 99
683    const fit =
684      e.surface !== 'desktop'
685        ? { width: '100%' }
686        : rows >= 7
687          ? { marginX: -1, marginTop: -2, marginBottom: -1, paddingTop: 3, paddingBottom: 0, width: wide }
688          : rows >= 6
689            ? { marginX: -1, marginTop: -1, marginBottom: -1, paddingTop: 2, paddingBottom: 0, width: wide }
690            : { marginX: -1, width: wide }
691
692    // the terminal draws no fill; on desktop the page-coloured box also covers the app's grey frame (see `fit`)
693    const mark =
694      e.surface === 'terminal' && scare !== null ? (
695        <Text color={scare.mark === '?' ? 'blue' : 'red'} bold>
696          {` ${scare.mark}`}
697        </Text>
698      ) : null
699    const mine =
700      e.surface === 'desktop' ? (
701        <Box alignItems="center" justifyContent="center" backgroundColor={BAND_BG[bgName] ?? BAND_BG.light} {...fit}>
702          {picture}
703        </Box>
704      ) : (
705        <Box alignItems="center" justifyContent="center" width="100%">
706          {picture}
707          {mark}
708        </Box>
709      )
710
711    return below ? (
712      <Box flexDirection="column">
713        {below}
714        {mine}
715      </Box>
716    ) : (
717      mine
718    )
719  })
720
721  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
722    const { value: anim = 'idle' } = await $.state.get({ plugin: 'pokepal', key: 'anim' } as const)
723    const { value: isAutoNow = true } = await $.state.get({ plugin: 'pokepal', key: 'auto' } as const)
724    const { value: scare = null } = await $.state.get({ plugin: 'pokepal', key: 'startle' } as const)
725    const { value: status = '' } = await $.state.get({ plugin: 'pokepal', key: 'status' } as const)
726    const { value: isSoundOn = true } = await $.state.get({ plugin: 'pokepal', key: 'sound' } as const)
727    const { value: lastSound = '' } = await $.state.get({ plugin: 'pokepal', key: 'lastSound' } as const)
728    const { value: who = 'fuecoco' } = await $.state.get({ plugin: 'pokepal', key: 'buddy' } as const)
729    const spec = specOf(who, anim)
730
731    const picture = await draw($, e, false)
732    const { Box, Text, Button } = $.ui.resolve(e)
733    const mark = scare !== null ? ` ${scare.mark}` : ''
734    const tag = (a: Anim) => (
735      <Button key={a} label={LABELS[a]} onPress={() => pick($, a)} />
736    )
737
738    return (
739      <Box flexDirection="column">
740        {picture}
741        <Box>
742          {Object.keys(BUDDIES).map(k => (
743            <Button key={`buddy-${k}`} label={k === who ? `${BUDDIES[k].name} ✓` : BUDDIES[k].name} onPress={() => choose($, k)} />
744          ))}
745        </Box>
746        <Text dimColor>
747          {BUDDIES[who]?.name ?? 'Fuecoco'} · {isAutoNow ? status : spec.label}
748          {mark}
749          {lastSound !== '' ? `  ♪ ${lastSound}` : ''}
750        </Text>
751        <Box>
752          <Button key="poke" label="Poke" onPress={() => poke($)} />
753          <Button key="auto" label={isAutoNow ? 'Auto ✓' : 'Auto'} onPress={() => follow($)} />
754          <Button
755            key="sound"
756            label={isSoundOn ? 'Sound ✓' : 'Sound'}
757            onPress={() => $.state.set({ plugin: 'pokepal', key: 'sound' } as const, !isSoundOn)}
758          />
759          <Button key="cry" label="Cry" onPress={() => cry($, 'index')} />
760          <Button key="test" label="♪ Next" onPress={() => testSound($)} />
761        </Box>
762        <Box>
763          {tag('standing')}
764          {tag('happy1')}
765          {tag('moving')}
766        </Box>
767        <Box>
768          {tag('idle')}
769          {tag('idlehappy')}
770          {tag('idledrowse')}
771          {tag('idlesleep')}
772        </Box>
773        <Box>
774          {tag('sleep1')}
775          {tag('sleep2')}
776          {tag('sleep3')}
777        </Box>
778        {BUDDIES[who]?.anims.damage ? (
779          <Box>
780            {tag('damage')}
781            {tag('disguise')}
782            {BUDDIES[who]?.anims.h2idle ? tag('h2idle') : null}
783            {BUDDIES[who]?.anims.h2idle ? tag('h2idledrowse') : null}
784          </Box>
785        ) : null}
786      </Box>
787    )
788  })
789}
790
types/index.d.ts 48 lines
1export type Anim =
2  | 'standing'
3  | 'happy1'
4  | 'moving'
5  | 'idle'
6  | 'idlehappy'
7  | 'idledrowse'
8  | 'idlesleep'
9  | 'sleep1'
10  | 'sleep2'
11  | 'sleep3'
12  | 'damage'
13  | 'disguise'
14  | 'h2idle'
15  | 'h2idlehappy'
16  | 'h2idledrowse'
17  | 'h2idlesleep'
18
19export type Startle = { at: number; mark: string }
20
21declare module 'claude-code' {
22  interface PluginState {
23    pokepal: {
24      anim: Anim
25      /** the tick the current animation started on (so each one plays from its first frame) */
26      since: number
27      tick: number
28      /** true while Fuecoco follows the session; false after a button press */
29      auto: boolean
30      startle: Startle | null
31      /** what the mood line says ("working", "happy!", ...) */
32      status: string
33      /** whether the side pane is open, so the band above the prompt knows to step aside */
34      paneUp: boolean
35      /** false after /pokepal off: no strip, no panel, no sound */
36      enabled: boolean
37      /** which buddy is out: fuecoco, mimikyu, pumpkaboo or drifloon */
38      buddy: string
39      /** the desktop strip's fill: light or dark */
40      bg: string
41      /** whether his cry plays */
42      sound: boolean
43      /** the last sound that played, shown in the pane */
44      lastSound: string
45    }
46  }
47}
48