Catch wild creatures above the Claude Code prompt: where your prompt leads (water, grass, fire, bugs, plans, builds) decides what appears. Pick a partner that…

cc-pokedex: wild creatures above the Claude Code prompt. Where your prompt leads decides what shows up.

Prompt about the sea and something from the water may appear. Ask Claude to fix a crash and a bug type may crawl out. Ask it to think through an architecture and a psychic type drifts in. Throw balls to catch it, right above the prompt, while Claude keeps working. Nothing here asks the model anything: encounters, throws and the dex cost zero tokens.
Catch one and it becomes your partner. It draws under the logo at startup, its name takes over the spinner (Pikachu is sauteing... (12s)) and the turn footer (Pikachu baked for 3s), and it sits above the prompt with its level. It gains xp when Claude finishes a turn, edits a file, passes tests or commits, and it evolves at the levels you would expect.
Built on Claude Code function hooks ("Claude Mods"), early access: TypeScript that runs inside Claude Code's own process. The API can change between releases.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.claude -p, the desktop app or mobile.~/.claude/settings.json: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
git clone https://github.com/deonmenezes/claude-mods-pokedex
claude --plugin-dir ./claude-mods-pokedex
Or install it as a marketplace plugin (the repo is its own marketplace):
claude plugin marketplace add deonmenezes/claude-mods-pokedex
claude plugin install cc-pokedex@cc-pokedex
throw ball button, or run /dex throw. Four balls per encounter./dex wild water forces an encounter so you can try it straight away. /dex wild pikachu forces a species.| Command | What it does |
|---|---|
/dex | Dex count, partner and level, what is wild right now |
/dex list | Everything caught, with rarity stars and counts |
/dex partner <name> | Make a caught species your partner |
/dex throw | Throw a ball (or click the board, or press Space after clicking it) |
/dex run | Leave the encounter |
/dex lure <name> | Set a lure: your next prompt that leads into that species' habitat brings it out |
/dex show / /dex hide | Show or hide the partner strip above the prompt |
/dex wild [kind or name] | Force an encounter, for trying it out |
/dex release | Drop the partner |
Every form of /dex runs straight away, even while Claude is working.
| Words in your prompt | Habitat | Examples |
|---|---|---|
| water, sea, river, fish, boat, rain, stream | water | Squirtle, Magikarp, Psyduck, Lapras |
| land, grass, forest, tree, garden, plant | grass | Bulbasaur, Oddish, Bellsprout, Tangela |
| fire, hot, burn, flame, lava, cook | fire | Charmander, Vulpix, Growlithe, Moltres |
| electric, power, battery, thunder, charge | electric | Pikachu, Magnemite, Voltorb, Zapdos |
| rock, stone, mountain, cave, sand, dig | rock | Geodude, Onix, Diglett, Sandshrew |
| ice, snow, cold, freeze, winter | ice | Shellder, Jynx, Articuno |
| sky, fly, bird, wind, cloud | flying | Pidgey, Spearow, Zubat |
| bug, fix, crash, error, broken, failing | bug | Caterpie, Weedle, Scyther |
| delete, remove, kill, legacy, deprecated | ghost | Gastly, Haunter, Gengar |
| think, plan, design, research, explain | psychic | Abra, Drowzee, Mewtwo |
| build, compile, deploy, docker, ship, test | fighting | Machop, Machoke, Hitmonchan |
| anything else | the road | Rattata, Meowth, Eevee, Snorlax, Ditto |
A named habitat meets something 40% of the time, the road 10%. Rarity weights the pick and the catch: common ★ 65%, uncommon ★★ 45%, rare ★★★ 28%, legendary ★★★★ 10% per ball. Mew only answers a prompt that says both please and thank you.
Level n needs 2·n² xp. A finished turn gives 8, an edit 1, a passing test run 10, a commit 15, a catch 20. Charmander becomes Charmeleon at 16 and Charizard at 36; the other chains follow the games. An evolution also fills the dex entry of the new form.
The partner and the dex live in the plugin's store, so every session shares them.
Function hooks can redraw a fixed list of components: the user and assistant messages, tool rows, the spinner, the turn footer, the notice lines under the logo, the prompt hint, the band above the prompt and panes. The logo is not on that list yet, so the partner draws beside the first notice line under it instead. If a Logo render site lands, swapping the sprite in is a one-hook change.
bun test # rules: habitats, picks, throws, levels, evolutions
claude plugin validate .claude-plugin/plugin.json # lists hooked events, $ calls and surface modules
bunx -p typescript tsc -p . # types against .claude/types (from /plugin-types)
Two rules for board files: never name a local variable h, and write Client module paths as string literals.
A fan project. The creature names belong to Nintendo, Game Freak and Creatures Inc.; the sprites are original terminal art, and nothing here is affiliated with or endorsed by them.
hooks/register.tsx 328 lines1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3import { HABITATS, KIND_COLOR, KIND_ICON, RARITY_STARS, SPECIES, byName, type Kind } from './dex/data.ts'
4import {
5 BALLS_PER_ENCOUNTER, dexSize, gainXp, habitatOf, isPolite, levelOf, pickWild, record,
6 rollEncounter, throwBall, totalSpecies, xpEventFor, xpForLevel, type Dex, type Partner, type XpEvent,
7} from './dex/logic.ts'
8
9// cc-pokedex: wild ones appear above the prompt depending on where your prompt leads (water words,
10// grass words, a bug to fix, a plan to think through...). Throw balls to catch them, keep a dex in
11// $.store, and pick a partner: it draws under the logo, beside the spinner, and above the prompt,
12// and levels up and evolves as Claude works. Nothing here asks the model anything.
13//
14// Two rules for the boards in ./boards: never name a local `h` (JSX compiles to calls of `h`), and
15// `Client` module paths must be string literals (the engine reads them off the source).
16
17type Phase = 'wild' | 'throwing' | 'caught' | 'fled'
18type Encounter = {
19 name: string
20 kind: Kind
21 where: string
22 ballsLeft: number
23 phase: Phase
24 /** bumped on every throw; the board animates when it sees a new one */
25 throwSeq: number
26 shakes: number
27 /** decided at the throw, revealed once the animation has played */
28 pending?: boolean
29}
30
31let dex: Dex = {}
32let partner: Partner | undefined
33let showPartner = true
34let encounter: Encounter | undefined
35let firstNotice: string | undefined
36// a lure: the next prompt that leads into this species' habitat brings it out
37let lure: string | undefined
38
39const isDex = (v: unknown): v is Dex => typeof v === 'object' && v !== null && !Array.isArray(v)
40const isPartner = (v: unknown): v is Partner => typeof v === 'object' && v !== null && typeof (v as Partner).xp === 'number' && typeof (v as Partner).name === 'string'
41
42const stars = (name: string) => RARITY_STARS[byName(name)?.rarity ?? 'common']
43const icon = (name: string) => KIND_ICON[byName(name)?.kind ?? 'normal']
44
45async function save($: EngineInterface, key: string, value: unknown) {
46 await $.store.set(key, value).catch(err => $.ui.log(`cc-pokedex: store write failed: ${err}`))
47}
48
49// one throw: decide now, animate on the board, reveal when the animation has played
50async function throwNow($: EngineInterface): Promise<string> {
51 if (!encounter || encounter.phase !== 'wild') return encounter?.phase === 'throwing' ? 'the ball is still in the air' : 'nothing to throw at · a wild one appears when you prompt'
52 const sp = byName(encounter.name)
53 if (!sp) return 'nothing to throw at'
54 const { caught, shakes } = throwBall(sp.rarity, Math.random)
55 encounter = { ...encounter, phase: 'throwing', throwSeq: encounter.throwSeq + 1, shakes, ballsLeft: encounter.ballsLeft - 1, pending: caught }
56 $.ui.invalidate('ui.render')
57 // the board's clock: four frames of flight, three per shake, at 200 ms, then a beat
58 const ms = 800 + shakes * 600 + 400
59 $.clock.after(ms, () => { void reveal($) })
60 return `threw a ball at ${sp.name}...`
61}
62
63async function reveal($: EngineInterface) {
64 if (!encounter || encounter.phase !== 'throwing') return
65 const name = encounter.name
66 if (encounter.pending) {
67 encounter = { ...encounter, phase: 'caught' }
68 const now = await $.clock.now()
69 const { dex: next, isNew } = record(dex, name, now)
70 dex = next
71 await save($, 'dex', dex)
72 $.ui.toast(`${icon(name)} Gotcha! ${name} was caught${isNew ? ` · new dex entry (${dexSize(dex)}/${totalSpecies})` : ''}`)
73 if (!partner) {
74 partner = { name, caughtAs: name, xp: 0, since: now }
75 await save($, 'partner', partner)
76 $.ui.toast(`${name} is now your partner · /dex partner <name> switches`)
77 } else {
78 await feedPartner($, 'catch')
79 }
80 $.ui.invalidate('ui.render')
81 $.clock.after(4000, () => { if (encounter?.phase === 'caught') { encounter = undefined; $.ui.invalidate('ui.render') } })
82 } else if (encounter.ballsLeft <= 0) {
83 encounter = { ...encounter, phase: 'fled' }
84 $.ui.toast(`${name} broke free and ran away`)
85 $.ui.invalidate('ui.render')
86 $.clock.after(3000, () => { if (encounter?.phase === 'fled') { encounter = undefined; $.ui.invalidate('ui.render') } })
87 } else {
88 encounter = { ...encounter, phase: 'wild' }
89 $.ui.invalidate('ui.render')
90 }
91}
92
93async function feedPartner($: EngineInterface, event: XpEvent) {
94 if (!partner) return
95 const { partner: fed, leveled, evolvedFrom } = gainXp(partner, event)
96 partner = fed
97 await save($, 'partner', partner)
98 if (evolvedFrom) {
99 $.ui.toast(`✨ What? ${evolvedFrom} is evolving... it became ${fed.name}!`)
100 const now = await $.clock.now()
101 const { dex: next } = record(dex, fed.name, now)
102 dex = next
103 await save($, 'dex', dex)
104 } else if (leveled) {
105 $.ui.toast(`${icon(fed.name)} ${fed.name} grew to level ${levelOf(fed.xp)}`)
106 }
107 if (leveled || evolvedFrom) $.ui.invalidate('ui.render')
108}
109
110export const register: Register = on => {
111 on('session.start', async ($, e, next) => {
112 const r = await next(e)
113 const stored = (key: string) => $.store.get(key).catch(err => { $.ui.log(`cc-pokedex: store read failed: ${err}`); return undefined })
114 const savedDex = await stored('dex')
115 if (isDex(savedDex)) dex = savedDex
116 const savedPartner = await stored('partner')
117 if (isPartner(savedPartner)) partner = savedPartner
118 showPartner = (await stored('show')) !== false
119 await $.command.register({
120 name: 'dex',
121 description: 'Pokedex: status, list, partner <name>, throw, run, lure <name>, show, hide (cc-pokedex)',
122 argumentHint: '[list | partner <name> | throw | run | lure <name> | show | hide]',
123 immediate: true,
124 }).catch(err => $.ui.log(`cc-pokedex: /dex not registered: ${err}`))
125 return r
126 })
127
128 // where the prompt leads decides what may show up; the prompt itself passes through untouched
129 on('prompt.submit', async ($, e, next) => {
130 try {
131 const text = e.text.trim()
132 if (e.origin.kind === 'composer' && !text.startsWith('/') && text.length > 0 && (!encounter || encounter.phase === 'fled' || encounter.phase === 'caught')) {
133 const { kind, where } = habitatOf(text)
134 const lured = lure ? byName(lure) : undefined
135 if (lured && lured.kind === kind) {
136 lure = undefined
137 encounter = { name: lured.name, kind: lured.kind, where, ballsLeft: BALLS_PER_ENCOUNTER, phase: 'wild', throwSeq: 0, shakes: 0 }
138 $.ui.toast(`${KIND_ICON[lured.kind]} The lure worked! A wild ${lured.name} appeared ${where}!`)
139 $.ui.invalidate('ui.render')
140 } else if (rollEncounter(kind, Math.random)) {
141 const sp = pickWild(kind, Math.random, isPolite(text))
142 encounter = { name: sp.name, kind: sp.kind, where, ballsLeft: BALLS_PER_ENCOUNTER, phase: 'wild', throwSeq: 0, shakes: 0 }
143 $.ui.toast(`${KIND_ICON[sp.kind]} A wild ${sp.name} appeared ${where}! Click it or /dex throw`)
144 $.ui.invalidate('ui.render')
145 }
146 }
147 } catch (err) {
148 $.ui.log(`cc-pokedex: encounter roll failed: ${err}`)
149 }
150 return next(e)
151 })
152
153 on('command.run', { command: 'dex' }, async ($, e, next) => {
154 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
155 const arg = rest.join(' ')
156 const v = verb.toLowerCase()
157 const status = () => {
158 const p = partner
159 const me = p ? `partner ${icon(p.name)} ${p.name} · level ${levelOf(p.xp)} · ${p.xp} xp (next at ${xpForLevel(levelOf(p.xp) + 1)})` : 'no partner yet · catch one and it joins you'
160 const wild = encounter && encounter.phase !== 'caught' && encounter.phase !== 'fled' ? `wild ${encounter.name} ${encounter.where} · ${encounter.ballsLeft} balls left · /dex throw or click it` : 'nothing wild right now · prompt about water, grass, fire, bugs, plans, builds...'
161 return `dex ${dexSize(dex)}/${totalSpecies} · ${me}\n${wild}`
162 }
163 if (v === '' || v === 'status') return { text: status() }
164 if (v === 'list') {
165 const rows = SPECIES.filter(sp => dex[sp.name]).map(sp => {
166 const d = dex[sp.name]!
167 return `${KIND_ICON[sp.kind]} ${sp.name.padEnd(12)} ${RARITY_STARS[sp.rarity].padEnd(4)} ${sp.kind.padEnd(9)} x${d.count}${partner?.name === sp.name ? ' · partner' : ''}`
168 })
169 return { text: rows.length ? [`caught ${rows.length}/${totalSpecies}`, ...rows].join('\n') : 'nothing caught yet · a wild one appears when your prompt leads somewhere' }
170 }
171 if (v === 'partner') {
172 if (!arg) return { text: partner ? `partner is ${partner.name} · /dex partner <name> switches to another you caught` : 'no partner yet' }
173 const sp = byName(arg)
174 if (!sp) return { text: `no species called "${arg}"` }
175 if (!dex[sp.name]) return { text: `you have not caught ${sp.name} yet` }
176 const now = await $.clock.now()
177 partner = { name: sp.name, caughtAs: sp.name, xp: 0, since: now }
178 await save($, 'partner', partner)
179 firstNotice = undefined
180 $.ui.invalidate('ui.render')
181 return { text: `${KIND_ICON[sp.kind]} ${sp.name} is now your partner` }
182 }
183 if (v === 'throw') return { text: await throwNow($) }
184 if (v === 'run') {
185 if (!encounter || encounter.phase === 'caught' || encounter.phase === 'fled') return { text: 'nothing to run from' }
186 const name = encounter.name
187 encounter = undefined
188 $.ui.invalidate('ui.render')
189 return { text: `got away from ${name}` }
190 }
191 if (v === 'show' || v === 'hide') {
192 showPartner = v === 'show'
193 await save($, 'show', showPartner)
194 $.ui.invalidate('ui.render')
195 return { text: showPartner ? 'partner shown above the prompt' : 'partner hidden above the prompt (still by the spinner)' }
196 }
197 if (v === 'wild') {
198 // for trying it out: force an encounter, optionally of a kind or a species
199 const sp = byName(arg) ?? pickWild((Object.keys(KIND_COLOR) as Kind[]).includes(arg as Kind) ? (arg as Kind) : 'normal', Math.random, true)
200 const where = habitatOf(arg).where
201 encounter = { name: sp.name, kind: sp.kind, where, ballsLeft: BALLS_PER_ENCOUNTER, phase: 'wild', throwSeq: 0, shakes: 0 }
202 $.ui.invalidate('ui.render')
203 return { text: `${KIND_ICON[sp.kind]} A wild ${sp.name} appeared ${where}!` }
204 }
205 if (v === 'lure') {
206 const sp = byName(arg)
207 if (!sp) return { text: arg ? `no species called "${arg}"` : 'lure what? /dex lure pikachu' }
208 lure = sp.name
209 const where = HABITATS.find(h => h.kind === sp.kind)?.where ?? 'on the road'
210 return { text: `${KIND_ICON[sp.kind]} lure set for ${sp.name} · your next prompt that leads ${where} brings it out` }
211 }
212 if (v === 'release') {
213 if (!partner) return { text: 'no partner to release' }
214 const name = partner.name
215 partner = undefined
216 await save($, 'partner', undefined)
217 $.ui.invalidate('ui.render')
218 return { text: `${name} went back to the wild` }
219 }
220 return { text: 'dex · list · partner <name> · throw · run · show · hide · lure <name> · wild [kind|name] · release' }
221 })
222
223 on('turn.complete', async ($, e, next) => {
224 const r = await next(e)
225 await feedPartner($, 'turn')
226 return r
227 })
228
229 on('tool.call', async ($, e, next) => {
230 const r = await next(e)
231 const command = (e as { command?: unknown }).command
232 const event = xpEventFor(e.tool, typeof command === 'string' ? command : undefined, 'deny' in r ? undefined : !r.isError)
233 if (event) await feedPartner($, event)
234 return r
235 })
236
237 // the encounter board posts a click; the partner board posts nothing
238 on('ui.message', async ($, e, next) => {
239 const data = e.data as { throw?: unknown; run?: unknown } | null
240 if (data?.throw) await throwNow($)
241 else if (data?.run) { encounter = undefined; $.ui.invalidate('ui.render') }
242 else return next(e)
243 return {}
244 })
245
246 // above the prompt: the wild one while there is one, else the partner strip
247 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
248 if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
249 const { Box, Button, Client, Text } = await $.ui.resolve(e)
250 const cols = e.props.bodyColumns ?? e.viewport?.columns ?? 80
251 if (encounter) {
252 const sp = byName(encounter.name)
253 if (!sp) return next(e)
254 const props = {
255 name: sp.name, art: sp.art, color: KIND_COLOR[sp.kind], where: encounter.where, stars: RARITY_STARS[sp.rarity],
256 phase: encounter.phase, throwSeq: encounter.throwSeq, shakes: encounter.shakes, ballsLeft: encounter.ballsLeft,
257 caught: encounter.phase === 'caught',
258 }
259 const canThrow = encounter.phase === 'wild'
260 return (
261 <Box flexDirection="column">
262 <Box flexDirection="row" columnGap={1}>
263 {canThrow ? <Button key="dex:throw" label="throw ball" onPress={() => { void throwNow($) }} /> : null}
264 {canThrow ? <Button key="dex:run" label="run" onPress={() => { encounter = undefined; $.ui.invalidate('ui.render') }} /> : null}
265 </Box>
266 <Client key="dex:encounter" module="./boards/encounter.tsx" width={cols} height={4} props={props} />
267 {await next(e)}
268 </Box>
269 )
270 }
271 if (partner && showPartner) {
272 const sp = byName(partner.name)
273 if (!sp) return next(e)
274 const props = {
275 name: sp.name, art: sp.art, color: KIND_COLOR[sp.kind], kind: sp.kind, level: levelOf(partner.xp), xp: partner.xp,
276 next: xpForLevel(levelOf(partner.xp) + 1), caught: dexSize(dex), total: totalSpecies, working: e.props.isWorking,
277 }
278 return (
279 <Box flexDirection="column">
280 <Client key="dex:partner" module="./boards/partner.tsx" width={cols} height={3} props={props} />
281 {await next(e)}
282 </Box>
283 )
284 }
285 return next(e)
286 })
287
288 // the spinner's word while Claude works: "Pikachu is sauteing... (12s)"
289 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
290 if (!partner || e.surface !== 'terminal') return next(e)
291 const word = e.props.word
292 return next({ ...e, props: { ...e.props, word: `${partner.name} is ${word.charAt(0).toLowerCase()}${word.slice(1)}` } })
293 })
294
295 // the line that closes a turn: "Pikachu baked for 3s"
296 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
297 if (!partner || e.surface !== 'terminal') return next(e)
298 const word = e.props.word
299 return next({ ...e, props: { ...e.props, word: `${partner.name} ${word.charAt(0).toLowerCase()}${word.slice(1)}` } })
300 })
301
302 // under the logo: the partner beside the first notice line. The logo itself has no render site
303 // yet, so this is the nearest a plugin gets to changing the mascot.
304 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
305 if (!partner || e.surface !== 'terminal') return next(e)
306 if (firstNotice === undefined) firstNotice = e.requestId
307 if (firstNotice !== e.requestId) return next(e)
308 const sp = byName(partner.name)
309 if (!sp) return next(e)
310 const { Box, Text } = await $.ui.resolve(e)
311 const color = KIND_COLOR[sp.kind]
312 return (
313 <Box flexDirection="column">
314 <Box flexDirection="row" columnGap={2}>
315 <Box flexDirection="column">
316 {sp.art.map(line => <Text color={color}>{line}</Text>)}
317 </Box>
318 <Box flexDirection="column" justifyContent="center">
319 <Text bold color={color}>{`${sp.name}`}<Text dimColor>{` · level ${levelOf(partner.xp)} · your partner`}</Text></Text>
320 <Text dimColor>{`dex ${dexSize(dex)}/${totalSpecies} · /dex`}</Text>
321 </Box>
322 </Box>
323 {await next(e)}
324 </Box>
325 )
326 })
327}
328hooks/dex/data.ts 159 lines1// The species table and the habitats. Pure data: no engine imports, so `bun test` loads it bare.
2//
3// Every species has a three-line sprite at most 11 cells wide, drawn in its type's colour. Art is
4// original terminal art in the style of the games, not ripped sprites.
5
6export type Kind =
7 | 'water' | 'grass' | 'fire' | 'electric' | 'rock' | 'ice' | 'flying'
8 | 'bug' | 'ghost' | 'psychic' | 'fighting' | 'normal'
9export type Rarity = 'common' | 'uncommon' | 'rare' | 'legendary'
10
11export type Species = {
12 name: string
13 kind: Kind
14 rarity: Rarity
15 art: [string, string, string]
16 /** the next form and the partner level that reaches it */
17 evolves?: { to: string; at: number }
18}
19
20export const KIND_COLOR: Record<Kind, string> = {
21 water: '#4aa8ff',
22 grass: '#5fd35f',
23 fire: '#ff6a3d',
24 electric: '#ffd83d',
25 rock: '#c9a468',
26 ice: '#8fe3ff',
27 flying: '#b9a7ff',
28 bug: '#a3c832',
29 ghost: '#9b6bff',
30 psychic: '#ff6bc4',
31 fighting: '#d9743a',
32 normal: '#c8c0b0',
33}
34
35export const KIND_ICON: Record<Kind, string> = {
36 water: '💧', grass: '🌿', fire: '🔥', electric: '⚡', rock: '🪨', ice: '❄', flying: '🌪',
37 bug: '🐛', ghost: '👻', psychic: '🔮', fighting: '👊', normal: '⭐',
38}
39
40// Where a prompt leads. Each habitat is a kind and the words that take you there; a prompt scores
41// one per word it matches, and the highest habitat wins (earlier wins a tie). No match: normal.
42export const HABITATS: ReadonlyArray<{ kind: Kind; words: RegExp; where: string }> = [
43 { kind: 'water', where: 'by the water', words: /\b(water|sea|ocean|river|lake|swim|fish|boat|beach|wave|rain|pool|surf|stream|shore|island|dive)\b/i },
44 { kind: 'grass', where: 'in the tall grass', words: /\b(land|grass|forest|tree|garden|plant|field|leaf|leaves|park|meadow|jungle|hike|flower|seed|grow)\b/i },
45 { kind: 'fire', where: 'near the volcano', words: /\b(fire|hot|burn|flame|lava|heat|volcano|cook|spicy|smoke|torch|ember)\b/i },
46 { kind: 'electric', where: 'at the power plant', words: /\b(electric|lightning|power|battery|thunder|volt|charge|energy|spark|storm|current)\b/i },
47 { kind: 'rock', where: 'in the cave', words: /\b(rock|stone|mountain|cave|desert|sand|dig|mine|dirt|earth|tunnel|boulder|cliff)\b/i },
48 { kind: 'ice', where: 'on the glacier', words: /\b(ice|snow|cold|freeze|frozen|winter|frost|chill|blizzard|glacier)\b/i },
49 { kind: 'flying', where: 'up in the sky', words: /\b(sky|fly|bird|wind|cloud|air|plane|wing|feather|soar)\b/i },
50 { kind: 'bug', where: 'in the bug-ridden code', words: /\b(bug|bugs|fix|crash|crashes|error|errors|broken|fails?|failing|exception|stack ?trace)\b/i },
51 { kind: 'ghost', where: 'in the haunted repo', words: /\b(delete|remove|kill|dead|haunt|ghost|night|dark|legacy|deprecated|zombie|orphan)\b/i },
52 { kind: 'psychic', where: 'in deep thought', words: /\b(think|plan|design|architecture|research|brainstorm|idea|ideas|why|explain|strategy|analy[sz]e)\b/i },
53 { kind: 'fighting', where: 'at the build gym', words: /\b(build|compile|deploy|docker|ship|release|binary|ci|pipeline|package|bundle|test|tests)\b/i },
54]
55
56export const RARITY_WEIGHT: Record<Rarity, number> = { common: 60, uncommon: 28, rare: 10, legendary: 2 }
57export const CATCH_RATE: Record<Rarity, number> = { common: 0.65, uncommon: 0.45, rare: 0.28, legendary: 0.1 }
58export const RARITY_STARS: Record<Rarity, string> = { common: '★', uncommon: '★★', rare: '★★★', legendary: '★★★★' }
59
60const s = (name: string, kind: Kind, rarity: Rarity, art: [string, string, string], evolves?: { to: string; at: number }): Species =>
61 ({ name, kind, rarity, art, evolves })
62
63export const SPECIES: readonly Species[] = [
64 // water
65 s('Squirtle', 'water', 'uncommon', [' ,-(o o) ', ' (_)\\_/ ) ', ' ~~ ~~ '], { to: 'Wartortle', at: 16 }),
66 s('Wartortle', 'water', 'rare', [' ~,-(o o) ', ' (@)\\_/ )~ ', ' ~~~~~~ '], { to: 'Blastoise', at: 36 }),
67 s('Blastoise', 'water', 'rare', [' ||,-(o o) ', ' (##)\\_/ ) ', ' ||~~~~~~ ']),
68 s('Magikarp', 'water', 'common', [' ><(o)> ', ' <( ~~ ) ', ' splash '], { to: 'Gyarados', at: 20 }),
69 s('Gyarados', 'water', 'rare', [' /\\/\\(O O)<', ' \\/\\/\\_/\\/ ', ' ~~~~~~~~ ']),
70 s('Psyduck', 'water', 'common', [' (o o)? ', ' ( D ) ', ' /| |\\ '], { to: 'Golduck', at: 33 }),
71 s('Golduck', 'water', 'rare', [' <(o o) ', ' ( D ) ', ' ~| |~ ']),
72 s('Seel', 'water', 'common', [' (o o)_ ', ' ( . ) ) ', ' ~~~~~~ '], { to: 'Dewgong', at: 34 }),
73 s('Dewgong', 'ice', 'uncommon', [' /(o o)__ ', ' \\( . ) ) ', ' ~~~~~~~ ']),
74 s('Lapras', 'water', 'rare', [' (o o) ', ' ( ~ )__ ', ' ~(_/##\\_)~']),
75 // grass
76 s('Bulbasaur', 'grass', 'uncommon', [' @(o o) ', ' (\\ w ) ', ' \\| |/ '], { to: 'Ivysaur', at: 16 }),
77 s('Ivysaur', 'grass', 'rare', [' \\@/(o o) ', ' (\\ w ) ', ' \\| |/ '], { to: 'Venusaur', at: 36 }),
78 s('Venusaur', 'grass', 'rare', ['\\\\@@//(o o)', ' (\\ w ) ', ' \\| |/ ']),
79 s('Oddish', 'grass', 'common', [' \\|/\\|/ ', ' (o o) ', ' \\_/ '], { to: 'Gloom', at: 21 }),
80 s('Gloom', 'grass', 'uncommon', [' \\\\|// ', ' (- -) ', ' (___)~ ']),
81 s('Bellsprout', 'grass', 'common', [' (o o)> ', ' \\|/ ', ' __|__ '], { to: 'Weepinbell', at: 21 }),
82 s('Weepinbell', 'grass', 'uncommon', [' (o o)==> ', ' ( ) ', ' \\___/ ']),
83 s('Tangela', 'grass', 'uncommon', [' @@@@@@@@ ', ' @@(o o)@@ ', ' @@@@@@@@ ']),
84 // fire
85 s('Charmander', 'fire', 'uncommon', [' (o o) ', ' ( w )~) ', ' /| |\\* '], { to: 'Charmeleon', at: 16 }),
86 s('Charmeleon', 'fire', 'rare', [' ^(o o) ', ' ( w )~~) ', ' /| |\\**'], { to: 'Charizard', at: 36 }),
87 s('Charizard', 'fire', 'rare', ['\\\\^(O O) ', '//( VV )~~)', ' /| |\\*']),
88 s('Vulpix', 'fire', 'common', [' ~~(o o) ', ' ( w ) ', ' /| |\\~~~'], { to: 'Ninetales', at: 30 }),
89 s('Ninetales', 'fire', 'rare', ['~~~(o o) ', ' ~( w )~ ', ' /| |\\~~']),
90 s('Growlithe', 'fire', 'common', [' ^(o o)^ ', ' ( w ) ', ' /| |\\~ '], { to: 'Arcanine', at: 30 }),
91 s('Arcanine', 'fire', 'rare', ['^^(O O)^^ ', '~( W )~', ' /| |\\ ']),
92 s('Ponyta', 'fire', 'common', [' **(o o) ', ' *( w ) ', ' /| |\\ '], { to: 'Rapidash', at: 40 }),
93 s('Rapidash', 'fire', 'rare', ['*^*(o o) ', '**( w ) ', ' /| |\\ ']),
94 s('Moltres', 'fire', 'legendary', ['***(o o)***', ' *( V )* ', ' **\\_/** ']),
95 // electric
96 s('Pikachu', 'electric', 'uncommon', [' /\\_/\\ ', ' ( o.o ) ', ' (>w<)< '], { to: 'Raichu', at: 22 }),
97 s('Raichu', 'electric', 'rare', [' /)_(\\ ', ' (o.o) ', ' (>w<)<~~ ']),
98 s('Magnemite', 'electric', 'common', [' |( O )| ', ' |( === )| ', ' + - ']),
99 s('Voltorb', 'electric', 'common', [' .-===-. ', ' ( o o ) ', ' \'-___-\' '], { to: 'Electrode', at: 30 }),
100 s('Electrode', 'electric', 'uncommon', [' .-___-. ', ' ( ^ ^ ) ', ' \'-===-\' ']),
101 s('Jolteon', 'electric', 'rare', [' \\(o o)/ ', ' ( w ) ', ' </| |\\> ']),
102 s('Zapdos', 'electric', 'legendary', ['//\\(o o)/\\\\', ' \\( ==V== )', ' \\/\\/\\/ ']),
103 // rock and ground
104 s('Geodude', 'rock', 'common', [' (o o) ', ' 0=( ___ )=0', ' '], { to: 'Graveler', at: 25 }),
105 s('Graveler', 'rock', 'uncommon', ['0=(o o)=0', '0=( ___ )=0', ' / \\ ']),
106 s('Onix', 'rock', 'uncommon', [' (O O) ', ' ( VV )O ', ' O O O ']),
107 s('Diglett', 'rock', 'common', [' (o o) ', ' ( . ) ', ' ~~~~~~~~~ '], { to: 'Dugtrio', at: 26 }),
108 s('Dugtrio', 'rock', 'uncommon', ['(oo)(oo)(oo)', '( .)( .)( .)', ' ~~~~~~~~~ ']),
109 s('Sandshrew', 'rock', 'common', [' #(o o) ', ' ##( w ) ', ' #/| |\\ '], { to: 'Sandslash', at: 22 }),
110 s('Sandslash', 'rock', 'uncommon', [' ^^^(o o) ', ' ^^( w ) ', ' ^/| |\\ ']),
111 // ice
112 s('Jynx', 'ice', 'uncommon', [' ~~~(o o)~~', ' ( o ) ', ' \\_____/ ']),
113 s('Shellder', 'ice', 'common', [' /~~~~~\\ ', ' ( o o ) ', ' \\_____/ ']),
114 s('Articuno', 'ice', 'legendary', ['~~\\(o o)/~~', ' ~( ===V )~', ' \\/ \\/ ']),
115 // flying
116 s('Pidgey', 'flying', 'common', [' ((o o)> ', ' (( )) ', ' ^ ^ '], { to: 'Pidgeotto', at: 18 }),
117 s('Pidgeotto', 'flying', 'uncommon', [' ~((o o)> ', ' ((( ))) ', ' ^ ^ ']),
118 s('Spearow', 'flying', 'common', [' ((o o)>> ', ' (( )) ', ' ^ ^ '], { to: 'Fearow', at: 20 }),
119 s('Fearow', 'flying', 'uncommon', [' ((o o)>>>', ' ((( _ ))) ', ' ^ ^ ']),
120 s('Zubat', 'flying', 'common', [' /\\ /\\ ', ' / ( ) \\', ' v v '], { to: 'Golbat', at: 22 }),
121 s('Golbat', 'flying', 'uncommon', ['/\\ /\\', '\\ (o vv o) /', ' \\___/ ']),
122 s('Butterfree', 'bug', 'uncommon', [' /\\(o o)/\\ ', '( ( ) )', ' \\/ \\/ ']),
123 // bug
124 s('Caterpie', 'bug', 'common', [' (o o) ', ' ( )()()', ' ~~~~~~~ '], { to: 'Metapod', at: 7 }),
125 s('Metapod', 'bug', 'common', [' /( )\\ ', ' ( o o ) ', ' \\___/ '], { to: 'Butterfree', at: 10 }),
126 s('Weedle', 'bug', 'common', [' ^(o o) ', ' ( )()()', ' ~~~~~~~ '], { to: 'Kakuna', at: 7 }),
127 s('Kakuna', 'bug', 'common', [' /( )\\ ', ' ( - - ) ', ' \\___/ '], { to: 'Beedrill', at: 10 }),
128 s('Beedrill', 'bug', 'uncommon', ['>=(o o)=< ', ' ( )> ', ' \\|/ ']),
129 s('Scyther', 'bug', 'rare', ['/=(o o)=\\ ', ' ( ) ', ' _/| |\\_ ']),
130 // ghost
131 s('Gastly', 'ghost', 'common', [' . (o o) . ', '. ( ~ ) .', ' . \'-\' . '], { to: 'Haunter', at: 25 }),
132 s('Haunter', 'ghost', 'uncommon', [' (o o) ', ' \\( ~~~ )/ ', ' \'---\' '], { to: 'Gengar', at: 40 }),
133 s('Gengar', 'ghost', 'rare', [' ^(o o)^ ', ' ( \\___/ ) ', ' \\_____/ ']),
134 // psychic
135 s('Abra', 'psychic', 'common', [' (- -) ', ' -( w )- ', ' /| |\\ '], { to: 'Kadabra', at: 16 }),
136 s('Kadabra', 'psychic', 'uncommon', [' (o o) ', ' -( w )-/ ', ' /| |\\ ']),
137 s('Drowzee', 'psychic', 'common', [' (- -) ', ' ( o~) ', ' /| |\\ '], { to: 'Hypno', at: 26 }),
138 s('Hypno', 'psychic', 'uncommon', [' (o o) ', ' ( o~)-o ', ' /| |\\ ']),
139 s('Mewtwo', 'psychic', 'legendary', [' (o o) ', ' ( V ) ', ' /| |\\~']),
140 s('Mew', 'psychic', 'legendary', [' (o o) ', ' ( w )~ ', ' \\_/ ']),
141 // fighting
142 s('Machop', 'fighting', 'common', [' (o o) ', ' o( w )o ', ' /| |\\ '], { to: 'Machoke', at: 28 }),
143 s('Machoke', 'fighting', 'uncommon', [' (o o) ', 'O( W )O', ' /| |\\ ']),
144 s('Hitmonchan', 'fighting', 'rare', [' (o o) ', ' 0( - )0 ', ' /| |\\ ']),
145 // normal
146 s('Rattata', 'normal', 'common', [' (o o) ', ' <( w ) ', ' /| |\\~ '], { to: 'Raticate', at: 20 }),
147 s('Raticate', 'normal', 'uncommon', [' (o o) ', ' <( W )> ', ' /| |\\~~']),
148 s('Meowth', 'normal', 'common', [' /\\_$_/\\ ', ' ( o.o ) ', ' (>w<) '], { to: 'Persian', at: 28 }),
149 s('Persian', 'normal', 'uncommon', [' /\\_@_/\\ ', ' ( -.- ) ', ' (>w<)~ ']),
150 s('Eevee', 'normal', 'uncommon', [' /\\ /\\ ', ' ( o.o ) ', ' (>w<)~~ '], { to: 'Jolteon', at: 20 }),
151 s('Snorlax', 'normal', 'rare', [' (- -) ', ' ( ___ ) ', ' (_______) ']),
152 s('Ditto', 'normal', 'uncommon', [' .-----. ', ' ( o o ) ', ' \'--w--\' ']),
153]
154
155export const byName = (name: string): Species | undefined => {
156 const lower = name.toLowerCase()
157 return SPECIES.find(sp => sp.name.toLowerCase() === lower)
158}
159hooks/dex/logic.ts 118 lines1// The game's rules, pure: which habitat a prompt leads to, whether a wild one appears, whether a
2// ball catches it, and how a partner levels and evolves. Randomness comes in as an `rng` in
3// [0, 1), so tests can pin it. ../register.tsx wires these to the engine.
4
5import { CATCH_RATE, HABITATS, RARITY_WEIGHT, SPECIES, byName, type Kind, type Rarity, type Species } from './data.ts'
6
7export type Rng = () => number
8
9/** The habitat a prompt leads to: the kind with the most matching words, else normal. */
10export function habitatOf(text: string): { kind: Kind; where: string } {
11 let best: { kind: Kind; where: string; score: number } | undefined
12 for (const h of HABITATS) {
13 const re = new RegExp(h.words.source, 'gi')
14 const score = (text.match(re) ?? []).length
15 if (score > 0 && (!best || score > best.score)) best = { kind: h.kind, where: h.where, score }
16 }
17 return best ? { kind: best.kind, where: best.where } : { kind: 'normal', where: 'on the road' }
18}
19
20// how often a prompt in a habitat meets something: named habitats often, the road rarely
21export const ENCOUNTER_RATE: Record<'habitat' | 'road', number> = { habitat: 0.4, road: 0.1 }
22
23/** Whether a wild one shows up for this prompt. Mew only answers manners. */
24export function rollEncounter(kind: Kind, rng: Rng): boolean {
25 return rng() < (kind === 'normal' ? ENCOUNTER_RATE.road : ENCOUNTER_RATE.habitat)
26}
27
28const isMythical = (sp: Species) => sp.name === 'Mew'
29export const isPolite = (text: string) => /\bplease\b/i.test(text) && /\bthank(s| you)\b/i.test(text)
30
31/** Picks a wild species of a kind, weighted by rarity. Evolved forms roam too, at their rarity. */
32export function pickWild(kind: Kind, rng: Rng, polite = false): Species {
33 const pool = SPECIES.filter(sp => sp.kind === kind && (!isMythical(sp) || polite))
34 const candidates = pool.length ? pool : SPECIES.filter(sp => sp.kind === 'normal')
35 const total = candidates.reduce((sum, sp) => sum + RARITY_WEIGHT[sp.rarity], 0)
36 let roll = rng() * total
37 for (const sp of candidates) {
38 roll -= RARITY_WEIGHT[sp.rarity]
39 if (roll < 0) return sp
40 }
41 return candidates[candidates.length - 1]!
42}
43
44export const BALLS_PER_ENCOUNTER = 4
45
46/** One throw: caught or broke free, and how many times the ball shook (0-3) before the answer. */
47export function throwBall(rarity: Rarity, rng: Rng): { caught: boolean; shakes: number } {
48 const caught = rng() < CATCH_RATE[rarity]
49 if (caught) return { caught, shakes: 3 }
50 // a miss shakes fewer times the rarer the target, so the animation tells you how close it was
51 const closeness = rng()
52 const shakes = closeness < 0.4 ? 0 : closeness < 0.75 ? 1 : 2
53 return { caught: false, shakes }
54}
55
56// ── the partner ───────────────────────────────────────────────────────────────
57
58export type Partner = {
59 /** the species name as it stands (evolution rewrites it) */
60 name: string
61 /** the species it was caught as */
62 caughtAs: string
63 xp: number
64 since: number
65}
66
67export type XpEvent = 'turn' | 'edit' | 'test-pass' | 'commit' | 'catch'
68export const XP: Record<XpEvent, number> = { turn: 8, edit: 1, 'test-pass': 10, commit: 15, catch: 20 }
69
70/** level n needs 2·n² xp: 5 at 50, 10 at 200, 16 at 512, 36 at 2592 */
71export const levelOf = (xp: number) => Math.max(1, Math.floor(Math.sqrt(xp / 2)))
72export const xpForLevel = (level: number) => 2 * level * level
73
74/** Gives xp; when the level crosses an evolution, the partner becomes the next form. */
75export function gainXp(p: Partner, event: XpEvent): { partner: Partner; leveled: boolean; evolvedFrom?: string } {
76 const before = levelOf(p.xp)
77 const xp = p.xp + XP[event]
78 const after = levelOf(xp)
79 let name = p.name
80 let evolvedFrom: string | undefined
81 // one evolution per gain; a chain climbs one step per level crossing
82 const sp = byName(name)
83 if (sp?.evolves && after >= sp.evolves.at && before < sp.evolves.at) {
84 evolvedFrom = name
85 name = sp.evolves.to
86 }
87 return { partner: { ...p, xp, name }, leveled: after > before, evolvedFrom }
88}
89
90/** What a finished tool call is worth to the partner; undefined when nothing. */
91const TEST_COMMAND = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|mocha)\b|\b(pytest|jest|vitest|rspec|phpunit|mocha)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmake\s+test\b/
92export function xpEventFor(tool: string, command: string | undefined, ok: boolean | undefined): XpEvent | undefined {
93 if (!ok) return undefined
94 if (tool === 'Bash' && command) {
95 if (TEST_COMMAND.test(command)) return 'test-pass'
96 if (/\bgit\s+commit\b/.test(command) && !/--dry-run\b/.test(command)) return 'commit'
97 return undefined
98 }
99 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'edit'
100 return undefined
101}
102
103// ── the dex ───────────────────────────────────────────────────────────────────
104
105/** Caught species by name: how many, and when the first one was caught. */
106export type Dex = Record<string, { count: number; first: number }>
107
108export function record(dex: Dex, name: string, now: number): { dex: Dex; isNew: boolean } {
109 const had = dex[name]
110 return {
111 dex: { ...dex, [name]: { count: (had?.count ?? 0) + 1, first: had?.first ?? now } },
112 isNew: !had,
113 }
114}
115
116export const dexSize = (dex: Dex) => Object.keys(dex).length
117export const totalSpecies = SPECIES.length
118hooks/boards/encounter.tsx 102 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3
4// The wild one, drawn above the prompt. The hooks module owns the encounter (it decides each throw
5// and reveals the answer once this animation has played); this board draws the creature, the ball
6// in flight and its shakes, and posts a click as a throw. Space or Enter throws once the board has
7// the keyboard (click it first); `r` runs.
8
9type Props = {
10 name: string
11 art: [string, string, string]
12 color: string
13 where: string
14 stars: string
15 phase: 'wild' | 'throwing' | 'caught' | 'fled'
16 throwSeq: number
17 shakes: number
18 ballsLeft: number
19 caught: boolean
20} | undefined
21
22type State = { frame: number; seenSeq: number; animFrame: number }
23
24const FLIGHT = 4
25const PER_SHAKE = 3
26const BALL = ['(o)', '(O)', '(o)']
27const FLIGHT_PATH = [' ', ' .', ' . ', '. ']
28
29export default function EncounterBoard(props: Props, surface: ClientSurface<State>) {
30 const { Box, Text } = surface.elements
31
32 if (surface.state === undefined) {
33 surface.setState({ frame: 0, seenSeq: props?.throwSeq ?? 0, animFrame: -1 })
34 surface.every(200, () => {
35 const s = surface.state
36 if (s) surface.setState({ ...s, frame: s.frame + 1, animFrame: s.animFrame >= 0 ? s.animFrame + 1 : -1 })
37 })
38 surface.onPointer(ev => {
39 if (ev.type === 'down') surface.post({ throw: true })
40 })
41 surface.onKey(ev => {
42 if (ev.key === ' ' || ev.key === 'return') surface.post({ throw: true })
43 else if (ev.key === 'r') surface.post({ run: true })
44 })
45 }
46
47 const s = surface.state
48 if (!props || !s) return <Text dimColor>a wild one is loading...</Text>
49
50 // a new throw starts the animation from the first frame
51 if (props.throwSeq !== s.seenSeq) surface.setState({ ...s, seenSeq: props.throwSeq, animFrame: 0 })
52 const anim = props.throwSeq !== s.seenSeq ? 0 : s.animFrame
53 const inFlight = props.phase === 'throwing' && anim >= 0 && anim < FLIGHT
54 const shaking = props.phase === 'throwing' && anim >= FLIGHT
55 const shakeIndex = shaking ? Math.floor((anim - FLIGHT) / PER_SHAKE) : -1
56 const shakesDone = Math.min(props.shakes, Math.max(0, shakeIndex + (shaking && (anim - FLIGHT) % PER_SHAKE === PER_SHAKE - 1 ? 1 : 0)))
57
58 // the creature bobs while wild, hides in the ball while it shakes, and blinks now and then
59 const bob = props.phase === 'wild' && s.frame % 6 < 3 ? ' ' : ''
60 const blink = s.frame % 25 === 0
61 const art = props.art.map(line => (blink ? line.replace(/o/g, '-') : line))
62 const hidden = shaking || props.phase === 'caught'
63
64 let ball: string
65 let line: string
66 if (props.phase === 'caught') {
67 ball = ' (*) '
68 line = `Gotcha! ${props.name} was caught!`
69 } else if (props.phase === 'fled') {
70 ball = ' '
71 line = `Oh no! ${props.name} broke free and ran away.`
72 } else if (inFlight) {
73 ball = FLIGHT_PATH[anim] + BALL[anim % 3]
74 line = 'you threw a ball...'
75 } else if (shaking) {
76 const wobble = (anim - FLIGHT) % PER_SHAKE
77 ball = wobble === 0 ? ' (o) ' : wobble === 1 ? '(o) ' : ' (o)'
78 line = `${'shake '.repeat(shakesDone)}${shakesDone < props.shakes || props.shakes < 3 ? '...' : ''}`
79 } else {
80 ball = ' '
81 line = `${props.ballsLeft} ball${props.ballsLeft === 1 ? '' : 's'} left · click the board or press Space to throw · r runs`
82 }
83
84 const color = props.color
85 return (
86 <Box flexDirection="column">
87 <Text bold color={color}>{`A wild ${props.name} appeared ${props.where}! `}<Text color="yellow">{props.stars}</Text></Text>
88 <Box flexDirection="row" columnGap={2}>
89 <Box flexDirection="column" width={16}>
90 {hidden
91 ? [<Text> </Text>, <Text color="red">{` ${ball}`}</Text>, <Text> </Text>]
92 : art.map(l => <Text color={color}>{`${bob}${l}`}</Text>)}
93 </Box>
94 <Box flexDirection="column" justifyContent="flex-end">
95 {inFlight ? <Text color="red">{ball}</Text> : <Text> </Text>}
96 <Text dimColor={!props.caught} color={props.caught ? 'greenBright' : undefined} bold={props.caught}>{line}</Text>
97 </Box>
98 </Box>
99 </Box>
100 )
101}
102hooks/boards/partner.tsx 65 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3
4// The partner strip above the prompt: the sprite, bobbing while Claude works, blinking now and
5// then, with its level and the dex count beside it. Draws only; the hooks module owns the partner.
6
7type Props = {
8 name: string
9 art: [string, string, string]
10 color: string
11 kind: string
12 level: number
13 xp: number
14 next: number
15 caught: number
16 total: number
17 working: boolean
18} | undefined
19
20type State = { frame: number; petted: number }
21
22const bar = (xp: number, next: number, level: number) => {
23 const from = 2 * level * level
24 const span = Math.max(1, next - from)
25 const filled = Math.round(Math.max(0, Math.min(1, (xp - from) / span)) * 12)
26 return '█'.repeat(filled) + '░'.repeat(12 - filled)
27}
28
29export default function PartnerBoard(props: Props, surface: ClientSurface<State>) {
30 const { Box, Text } = surface.elements
31
32 if (surface.state === undefined) {
33 surface.setState({ frame: 0, petted: 0 })
34 surface.every(250, () => {
35 const s = surface.state
36 if (s) surface.setState({ frame: s.frame + 1, petted: Math.max(0, s.petted - 1) })
37 })
38 surface.onPointer(ev => {
39 const s = surface.state
40 if (s && ev.type === 'down') surface.setState({ ...s, petted: 6 })
41 })
42 }
43
44 const s = surface.state
45 if (!props || !s) return <Text dimColor>partner loading...</Text>
46
47 const bob = props.working && s.frame % 4 < 2 ? ' ' : ''
48 const blink = s.frame % 20 === 0
49 const art = props.art.map(line => (blink ? line.replace(/o/g, '-') : line))
50 const hearts = s.petted > 0 ? ' ♥ ♥' : ''
51
52 return (
53 <Box flexDirection="row" columnGap={2}>
54 <Box flexDirection="column" width={14}>
55 {art.map(line => <Text color={props.color}>{`${bob}${line}`}</Text>)}
56 </Box>
57 <Box flexDirection="column">
58 <Text bold color={props.color}>{props.name}<Text dimColor>{` · ${props.kind} · level ${props.level}`}</Text><Text color="magentaBright">{hearts}</Text></Text>
59 <Text dimColor>{'xp '}<Text color={props.color}>{bar(props.xp, props.next, props.level)}</Text>{` ${props.xp}/${props.next}`}</Text>
60 <Text dimColor>{`dex ${props.caught}/${props.total} · ${props.working ? 'working with Claude' : 'waiting for a prompt'} · /dex hide`}</Text>
61 </Box>
62 </Box>
63 )
64}
65