SLOPSHOPPER

pokewait

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

newbandpromptprocessnetwork
v0.4.1no licenseupdated 2026-10-08stef-bulteel/pokewait
A shopper browsing a rack in a slop shop
Source 3 files
hooks/register.tsx 274 lines
1import { 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}
274
hooks/png.ts 209 lines
1// 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}
209
types/index.d.ts 20 lines
1export 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