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.

Small mods for Claude Code (terminal and desktop), written as plugins of function hooks that hot-reload in a session.
| Folder | What 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 light | dark | auto 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. |
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>".
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>.
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" }
Pokepal/frames, Pokepal/anim and Pokepal/sounds hold art and audio from the Pokémon games. They are here for personal use only
tools/spine-render and the spine-webgl 4.0 runtime. Render with premultiplied alpha off, or the pieces get bright outlines.hooks/register.tsx 790 lines1import 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}
790types/index.d.ts 48 lines1export 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