All 1025 Pokémon, each in its own generation's sprite art from PokeAPI, walk across the top of the band above the prompt while Claude is working

hooks/register.tsx 274 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Team, Walker } from '../types'
5import { measurePng } from './png'
6
7const team = atom({ plugin: 'pokewait', key: 'team' } as const, [] as Team)
8
9// Every Pokémon, #1–1025, drawn as the games of its own generation drew it,
10// from PokeAPI's sprite repo. `frame` is that generation's sprite frame in
11// pixels, what a Pokémon's size is measured against (see spriteHeight).
12type Source = { last: number; path: (id: number) => string; frame: number }
13export const SOURCES: Source[] = [
14 { last: 151, path: id => `versions/generation-i/red-blue/transparent/${id}.png`, frame: 56 },
15 { last: 251, path: id => `versions/generation-ii/crystal/transparent/${id}.png`, frame: 56 },
16 { last: 386, path: id => `versions/generation-iii/emerald/${id}.png`, frame: 64 },
17 { last: 493, path: id => `versions/generation-iv/platinum/${id}.png`, frame: 80 },
18 { last: 649, path: id => `versions/generation-v/black-white/${id}.png`, frame: 96 },
19 // Gens 6–9 had no pixel sprites of their own; PokeAPI's default ones are.
20 { last: 1025, path: id => `${id}.png`, frame: 96 },
21]
22export const MAX_ID = SOURCES[SOURCES.length - 1]!.last
23
24export function sourceOf(id: number): Source {
25 return SOURCES.find(s => id <= s.last) ?? SOURCES[SOURCES.length - 1]!
26}
27
28const SPRITES = 'PokeAPI/sprites/master/sprites/pokemon'
29// The file itself, for curl: no rate limit.
30export const rawUrl = (id: number) => `https://raw.githubusercontent.com/${SPRITES}/${sourceOf(id).path(id)}`
31// The same file as base64 JSON, for when curl is missing: 60 an hour.
32export const apiUrl = (id: number) =>
33 `https://api.github.com/repos/PokeAPI/sprites/contents/sprites/pokemon/${sourceOf(id).path(id)}`
34
35const TEAM_SIZE = 8
36export const BAND_HEIGHT = 30
37// The smallest Pokémon is drawn this share of the band's height, the biggest
38// all of it: big ones stand taller, small ones stay easy to see.
39const MIN_SHARE = 0.65
40// The canvas is wider than any band and cropped, never scaled, to the width
41// the surface gives the image: see bandSvg.
42const CANVAS_WIDTH = 4000
43
44const px = (n: number) => Math.round(n * 100) / 100
45
46// How tall a sprite is drawn, from how much of its generation's frame it fills.
47export function spriteHeight(id: number, boxHeight: number): number {
48 const fill = Math.min(1, boxHeight / sourceOf(id).frame)
49 return px(BAND_HEIGHT * (MIN_SHARE + (1 - MIN_SHARE) * fill))
50}
51
52// One walker crosses the whole band and back, standing on the ground line.
53// The front sprites face left, so it is mirrored on the way right. `100vw`
54// inside the image is the width it is drawn at, so the walk ends at the
55// band's right edge whatever its width.
56export function walkerSvg(w: Walker, index: number): string {
57 const [bx, by, bw, bh] = w.box
58 const height = spriteHeight(w.id, bh)
59 const width = px((bw * height) / bh)
60 const timing = `${w.seconds}s linear -${w.offset}s infinite`
61 return (
62 `<style>@keyframes walk${index}{0%,100%{transform:translateX(0)}50%{transform:translateX(calc(100vw - ${width}px))}}</style>` +
63 `<g style="animation:walk${index} ${timing}">` +
64 `<g class="face" style="animation:face ${timing}">` +
65 `<g class="bob" style="animation-delay:-${(w.offset % 0.4).toFixed(2)}s">` +
66 `<svg x="0" y="${px(BAND_HEIGHT - height)}" width="${width}" height="${height}" viewBox="${bx} ${by} ${bw} ${bh}">` +
67 `<image width="${w.width}" height="${w.height}" href="data:image/png;base64,${w.png}"/>` +
68 `</svg></g></g></g>`
69 )
70}
71
72// An Svg drawn as an image with no size of its own is 300px wide, so the
73// canvas is CANVAS_WIDTH wide: the surface draws it as wide as the band allows
74// at the height given, and `slice` crops the rest instead of shrinking it.
75export function bandSvg(walkers: Team): string {
76 return (
77 `<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS_WIDTH}" height="${BAND_HEIGHT}" viewBox="0 0 ${CANVAS_WIDTH} ${BAND_HEIGHT}" preserveAspectRatio="xMinYMax slice">` +
78 `<style>` +
79 `@keyframes face{0%,49.99%{transform:scaleX(-1)}50%,100%{transform:scaleX(1)}}` +
80 `@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}` +
81 `.face{transform-box:fill-box;transform-origin:center}` +
82 `.bob{animation:bob 0.4s linear infinite}` +
83 `</style>` +
84 walkers.map(walkerSvg).join('') +
85 `</svg>`
86 )
87}
88
89// A small seeded generator: picks stay reproducible in tests.
90export function rng(seed: number): () => number {
91 let s = seed >>> 0 || 1
92 return () => {
93 s = (s * 1664525 + 1013904223) >>> 0
94 return s / 2 ** 32
95 }
96}
97
98export function pickIds(rand: () => number, n = TEAM_SIZE): number[] {
99 const ids = new Set<number>()
100 while (ids.size < n) ids.add(1 + Math.floor(rand() * MAX_ID))
101 return [...ids]
102}
103
104// Spread walkers along the path so they don't start in a pile.
105export function pace(rand: () => number, index: number, size: number): { seconds: number; offset: number } {
106 const seconds = Math.round(20 + rand() * 16)
107 const offset = Math.round(((index + rand() * 0.5) / size) * seconds * 10) / 10
108 return { seconds, offset }
109}
110
111const fromBase64 = (b64: string): Uint8Array =>
112 (Uint8Array as unknown as { fromBase64: (s: string) => Uint8Array }).fromBase64(b64)
113
114type $ = EngineInterface
115type Sprite = Omit<Walker, 'seconds' | 'offset'>
116
117// Sprites live on disk, one PNG each, so the cache can hold all 1025.
118async function cacheDir($: $): Promise<string | null> {
119 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
120 return home ? `${home}/.claude/pokewait-cache` : null
121}
122
123async function readBase64($: $, file: string): Promise<string | null> {
124 try {
125 return (await $.fs.read(file, { as: 'bytes' })).base64
126 } catch {
127 return null
128 }
129}
130
131// The cached PNG, else one curl downloads into the cache, else the GitHub API's copy.
132async function fetchPng($: $, id: number, dir: string | null): Promise<string | null> {
133 const file = dir && `${dir}/${id}.png`
134 if (file) {
135 const hit = await readBase64($, file)
136 if (hit) return hit
137 try {
138 const { exitCode } = await $.process.run(['curl', '-sfL', '--create-dirs', '-o', file, rawUrl(id)], {
139 timeoutMs: 20_000,
140 })
141 if (exitCode === 0) {
142 const got = await readBase64($, file)
143 if (got) return got
144 }
145 } catch {
146 // No curl: fall through to the API.
147 }
148 }
149 try {
150 const res = await $.http.fetch(apiUrl(id), { headers: { Accept: 'application/vnd.github+json' } })
151 if (!res.ok) return null
152 return String((JSON.parse(res.text) as { content?: string }).content ?? '').replace(/\s/g, '') || null
153 } catch {
154 return null
155 }
156}
157
158// The PNG and where its pixels lie, measured once it arrives.
159async function loadSprite($: $, id: number, dir: string | null): Promise<Sprite | null> {
160 const png = await fetchPng($, id, dir)
161 if (!png) return null
162 try {
163 const m = measurePng(fromBase64(png))
164 return m ? { id, png, width: m.width, height: m.height, box: m.box } : null
165 } catch {
166 return null
167 }
168}
169
170// Offline or rate-limited: any sprite already in the cache.
171async function anyCached($: $, dir: string | null, rand: () => number, skip: Set<number>): Promise<Sprite | null> {
172 if (!dir) return null
173 try {
174 const ids = (await $.fs.list(dir))
175 .map(e => Number(/^(\d+)\.png$/.exec(e.name)?.[1]))
176 .filter(id => id > 0 && !skip.has(id))
177 for (let tries = 0; tries < 3 && ids.length > 0; tries++) {
178 const [id] = ids.splice(Math.floor(rand() * ids.length), 1)
179 const sprite = await loadSprite($, id!, dir)
180 if (sprite) return sprite
181 }
182 } catch {
183 // No cache yet.
184 }
185 return null
186}
187
188export async function newTeam($: $, seed: number): Promise<Team> {
189 const rand = rng(seed)
190 const dir = await cacheDir($)
191 const ids = pickIds(rand)
192 const found = await Promise.all(ids.map(id => loadSprite($, id, dir)))
193 const picked: Sprite[] = []
194 const used = new Set<number>()
195 for (const sprite of found) {
196 const s = sprite && !used.has(sprite.id) ? sprite : await anyCached($, dir, rand, used)
197 if (s) {
198 used.add(s.id)
199 picked.push(s)
200 }
201 }
202 return picked.map((s, i) => ({ ...s, ...pace(rand, i, picked.length) }))
203}
204
205// Earlier versions cached sprites in the store, up to 3 MB of them.
206async function dropOldCache($: $): Promise<void> {
207 for (const key of await $.store.keys()) {
208 if (/^(sprite|gen1):/.test(key) || key === 'index' || key === 'cached') await $.store.delete(key)
209 }
210}
211
212let refreshing = false
213
214// Fetch the next party in the background; the band shows it once it lands.
215async function refresh($: $): Promise<void> {
216 if (refreshing) return
217 refreshing = true
218 try {
219 const party = await newTeam($, await $.clock.now())
220 if (party.length > 0) await update($, team, () => party)
221 } finally {
222 refreshing = false
223 }
224}
225
226export const register: Register = on => {
227 refreshing = false
228
229 on('session.start', async ($, e, next) => {
230 const result = await next(e)
231 await dropOldCache($)
232 const current = await read($, team)
233 if (current.length === 0 || !current[0]?.box) void refresh($)
234 return result
235 })
236
237 // A fresh party for every prompt.
238 on('prompt.submit', async ($, e, next) => {
239 void refresh($)
240 return next(e)
241 })
242
243 // Draw whatever the plugins beneath draw (the usage band, say) first, then
244 // stack the Pokémon on top of it, so they sit above every other band mod.
245 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
246 const below = await next(e)
247 if (!e.props.isWorking || e.props.hasSurvey) return below
248
249 const els = $.ui.resolve(e)
250 if (!('Svg' in els)) return below
251 const { Box, Svg } = els
252
253 // A party from an earlier version has no box; wait for a new one.
254 const walkers = (await read($, team)).filter(w => w.png && w.box)
255 if (walkers.length === 0) return below
256
257 // A growing box in a row is what stretches an Svg across the band.
258 return (
259 <Box flexDirection="column" width="100%">
260 <Box flexDirection="row" width="100%">
261 <Box flexGrow={1} flexShrink={1} minWidth={8}>
262 <Svg
263 source={bandSvg(walkers)}
264 alt={`Pokémon #${walkers.map(w => w.id).join(', #')} walking while Claude works`}
265 height={BAND_HEIGHT}
266 />
267 </Box>
268 </Box>
269 {below}
270 </Box>
271 )
272 })
273}
274hooks/png.ts 209 lines1// Just enough PNG to find where a sprite's visible pixels lie: an inflate
2// (the plugin environment has no DecompressionStream) and the five filters.
3
4type Tree = { counts: Uint16Array; symbols: Uint16Array }
5
6function buildTree(lengths: ArrayLike<number>, n: number): Tree {
7 const counts = new Uint16Array(16)
8 const symbols = new Uint16Array(n)
9 const offs = new Uint16Array(16)
10 for (let i = 0; i < n; i++) counts[lengths[i]!]!++
11 counts[0] = 0
12 for (let i = 1; i < 16; i++) offs[i] = offs[i - 1]! + counts[i - 1]!
13 for (let i = 0; i < n; i++) if (lengths[i]) symbols[offs[lengths[i]!]!++] = i
14 return { counts, symbols }
15}
16
17const LBASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
18const LBITS = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
19const DBASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
20const DBITS = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
21const CLC_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
22
23const FIXED_LIT = buildTree(Array.from({ length: 288 }, (_, i) => (i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8)), 288)
24const FIXED_DIST = buildTree(Array.from({ length: 30 }, () => 5), 30)
25
26// zlib stream (2-byte header, deflate blocks) to bytes.
27export function inflate(data: Uint8Array): Uint8Array {
28 let pos = 2
29 let tag = 0
30 let bitcount = 0
31 const out: number[] = []
32
33 const bit = () => {
34 if (bitcount === 0) {
35 tag = data[pos++] ?? 0
36 bitcount = 8
37 }
38 const b = tag & 1
39 tag >>>= 1
40 bitcount--
41 return b
42 }
43 const bits = (n: number, base: number) => {
44 let v = 0
45 for (let i = 0; i < n; i++) v |= bit() << i
46 return base + v
47 }
48 const sym = (t: Tree) => {
49 let sum = 0
50 let cur = 0
51 let len = 0
52 do {
53 cur = 2 * cur + bit()
54 len++
55 sum += t.counts[len]!
56 cur -= t.counts[len]!
57 } while (cur >= 0 && len < 15)
58 return t.symbols[sum + cur]!
59 }
60
61 for (let final = 0; !final; ) {
62 final = bit()
63 const type = bits(2, 0)
64 if (type === 0) {
65 bitcount = 0
66 const len = data[pos]! | (data[pos + 1]! << 8)
67 pos += 4
68 for (let i = 0; i < len; i++) out.push(data[pos++]!)
69 continue
70 }
71 let lit = FIXED_LIT
72 let dist = FIXED_DIST
73 if (type === 2) {
74 const hlit = bits(5, 257)
75 const hdist = bits(5, 1)
76 const hclen = bits(4, 4)
77 const clens = new Uint8Array(19)
78 for (let i = 0; i < hclen; i++) clens[CLC_ORDER[i]!] = bits(3, 0)
79 const clTree = buildTree(clens, 19)
80 const lens = new Uint8Array(hlit + hdist)
81 for (let n = 0; n < hlit + hdist; ) {
82 const s = sym(clTree)
83 if (s < 16) lens[n++] = s
84 else {
85 const [value, count] =
86 s === 16 ? [lens[n - 1] ?? 0, bits(2, 3)] : s === 17 ? [0, bits(3, 3)] : [0, bits(7, 11)]
87 for (let i = 0; i < count; i++) lens[n++] = value
88 }
89 }
90 lit = buildTree(lens.subarray(0, hlit), hlit)
91 dist = buildTree(lens.subarray(hlit), hdist)
92 } else if (type !== 1) {
93 throw new Error('bad deflate block')
94 }
95 for (;;) {
96 const s = sym(lit)
97 if (s === 256) break
98 if (s < 256) {
99 out.push(s)
100 continue
101 }
102 const length = bits(LBITS[s - 257]!, LBASE[s - 257]!)
103 const d = sym(dist)
104 const back = bits(DBITS[d]!, DBASE[d]!)
105 const from = out.length - back
106 for (let i = 0; i < length; i++) out.push(out[from + i]!)
107 }
108 }
109 return Uint8Array.from(out)
110}
111
112export type SpriteBox = { width: number; height: number; box: [number, number, number, number] }
113
114const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }
115
116// The image's size and the box around its visible pixels, or null when the
117// bytes are no PNG this can read (an interlaced one draws whole).
118export function measurePng(bytes: Uint8Array): SpriteBox | null {
119 if (bytes[0] !== 0x89 || bytes[1] !== 0x50) return null
120 const u32 = (p: number) => ((bytes[p]! << 24) | (bytes[p + 1]! << 16) | (bytes[p + 2]! << 8) | bytes[p + 3]!) >>> 0
121 let p = 8
122 let width = 0
123 let height = 0
124 let depth = 8
125 let ct = 6
126 let interlace = 0
127 let trns: Uint8Array | null = null
128 const idat: Uint8Array[] = []
129 while (p + 8 <= bytes.length) {
130 const len = u32(p)
131 const type = String.fromCharCode(bytes[p + 4]!, bytes[p + 5]!, bytes[p + 6]!, bytes[p + 7]!)
132 const data = bytes.subarray(p + 8, p + 8 + len)
133 if (type === 'IHDR') {
134 width = u32(p + 8)
135 height = u32(p + 12)
136 depth = data[8]!
137 ct = data[9]!
138 interlace = data[12]!
139 } else if (type === 'tRNS') trns = data
140 else if (type === 'IDAT') idat.push(data)
141 else if (type === 'IEND') break
142 p += 12 + len
143 }
144 const channels = CHANNELS[ct]
145 if (!width || !height || !channels) return null
146 const whole: SpriteBox = { width, height, box: [0, 0, width, height] }
147 if (interlace) return whole
148
149 const joined = new Uint8Array(idat.reduce((n, c) => n + c.length, 0))
150 let o = 0
151 for (const c of idat) {
152 joined.set(c, o)
153 o += c.length
154 }
155 const raw = inflate(joined)
156 const bpp = Math.max(1, (depth * channels) >> 3)
157 const stride = Math.ceil((width * depth * channels) / 8)
158 const sample = (line: Uint8Array, i: number) =>
159 depth === 16 ? line[i * 2]! : depth === 8 ? line[i]! : (line[(i * depth) >> 3]! >> (8 - depth - ((i * depth) & 7))) & ((1 << depth) - 1)
160
161 let x0 = width
162 let y0 = height
163 let x1 = -1
164 let y1 = -1
165 let prev = new Uint8Array(stride)
166 let r = 0
167 for (let y = 0; y < height; y++) {
168 const filter = raw[r++]!
169 const line = raw.slice(r, r + stride)
170 r += stride
171 for (let i = 0; i < stride; i++) {
172 const a = i >= bpp ? line[i - bpp]! : 0
173 const b = prev[i]!
174 const c = i >= bpp ? prev[i - bpp]! : 0
175 let v = line[i]!
176 if (filter === 1) v += a
177 else if (filter === 2) v += b
178 else if (filter === 3) v += (a + b) >> 1
179 else if (filter === 4) {
180 const pp = a + b - c
181 const pa = Math.abs(pp - a)
182 const pb = Math.abs(pp - b)
183 const pc = Math.abs(pp - c)
184 v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
185 }
186 line[i] = v & 255
187 }
188 prev = line
189 for (let x = 0; x < width; x++) {
190 let isVisible = true
191 if (ct === 6 || ct === 4) isVisible = sample(line, x * channels + channels - 1) > 0
192 else if (ct === 3) {
193 const idx = sample(line, x)
194 isVisible = !trns || idx >= trns.length || trns[idx]! > 0
195 } else if (trns && depth === 8) {
196 // A single transparent colour: grey (ct 0) or RGB (ct 2), 16-bit values.
197 isVisible = [...Array(channels)].some((_, k) => line[x * channels + k] !== trns![k * 2 + 1])
198 }
199 if (isVisible) {
200 if (x < x0) x0 = x
201 if (y < y0) y0 = y
202 if (x > x1) x1 = x
203 if (y > y1) y1 = y
204 }
205 }
206 }
207 return x1 < 0 ? null : { width, height, box: [x0, y0, x1 - x0 + 1, y1 - y0 + 1] }
208}
209types/index.d.ts 20 lines1export type Walker = {
2 id: number
3 /** The sprite's PNG, base64. */
4 png: string
5 /** The PNG's own size in pixels. */
6 width: number
7 height: number
8 /** Where its visible pixels lie: x, y, width, height. */
9 box: [number, number, number, number]
10 seconds: number
11 offset: number
12}
13export type Team = Walker[]
14
15declare module 'claude-code' {
16 interface PluginState {
17 pokewait: { team: Team }
18 }
19}
20