diff --git a/ai-stuff/claude/README.md b/ai-stuff/claude/README.md index e543d9dc..88f31b40 100644 --- a/ai-stuff/claude/README.md +++ b/ai-stuff/claude/README.md @@ -23,14 +23,15 @@ ai-stuff/claude/ │ worktree-cleanup skill helpers moved to ai-stuff/_shared/scripts/ — │ shared across tools) ├── mods/ # Local plugin marketplace `dotfiles-mods` for Claude Code mods -│ └── abra/ # Pixel Abra in the band above the prompt (see its README) +│ └── pokemon/ # Pixel Pokémon in the band above the prompt (see its README) ├── settings.json # Hooks, permissions, statusline, plugins (→ ~/.claude/settings.json) └── README.md ``` `settings.json` declares `mods/` as the `dotfiles-mods` marketplace and enables -its plugins. Claude Code caches an installed plugin by version, so bump a mod's -`version` and run `claude plugin update @dotfiles-mods` after editing it. +its plugins, and `make claude-mods` installs every mod under `mods/`. A directory +marketplace loads each mod in place from the repo, so edits apply on +`/reload-plugins` without a version bump. `make claude` also installs (sources live elsewhere): diff --git a/ai-stuff/claude/mods/.claude-plugin/marketplace.json b/ai-stuff/claude/mods/.claude-plugin/marketplace.json index ad96d4e5..d814bfd0 100644 --- a/ai-stuff/claude/mods/.claude-plugin/marketplace.json +++ b/ai-stuff/claude/mods/.claude-plugin/marketplace.json @@ -6,9 +6,9 @@ }, "plugins": [ { - "name": "abra", - "source": "./abra", - "description": "A pixel Abra that lives at the right edge of the band above the prompt" + "name": "pokemon", + "source": "./pokemon", + "description": "A pixel Pokémon that lives at the right edge of the band above the prompt" } ] } diff --git a/ai-stuff/claude/mods/abra/.claude-plugin/plugin.json b/ai-stuff/claude/mods/abra/.claude-plugin/plugin.json deleted file mode 100644 index 2c2fb73a..00000000 --- a/ai-stuff/claude/mods/abra/.claude-plugin/plugin.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "abra", - "version": "0.3.0", - "description": "A pixel Abra that lives at the right edge of the band above the prompt", - "author": { "name": "deniz gökçin" } -} diff --git a/ai-stuff/claude/mods/abra/README.md b/ai-stuff/claude/mods/abra/README.md deleted file mode 100644 index d5f29294..00000000 --- a/ai-stuff/claude/mods/abra/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# abra - -A Claude Code mod that draws a pixel Abra at the right edge of the band above -the prompt. It bobs in place while you're idle and paces back and forth while -Claude works. - -## Requirements - -- Claude Code v2.1.287 or later -- The terminal app. The Desktop app has no `Raster`, so the mod draws nothing there. -- A truecolor terminal (iTerm2, Ghostty, kitty, WezTerm) - -## Usage - -| Command | Effect | -| --- | --- | -| `/abra` | Toggle between default and shiny, saved across sessions | -| `/abra shiny`, `/abra default` | Pick a variant | -| Ctrl+X Ctrl+A | Collapse or expand the band (Claude Code's own binding) | - -## Layout - -| Path | Contents | -| --- | --- | -| `hooks/register.js` | Band renderer, animation timer, `/abra` command | -| `hooks/frames.js` | Generated pixel frames. Don't edit by hand. | -| `sprites/*.gif` | Source GIFs, 32x32 at 2 frames each | -| `scripts/build-frames.mjs` | Regenerates `frames.js` from the GIFs with ffmpeg | -| `tests/abra.test.ts` | `claude plugin test` suite | - -## Development - -```bash -node scripts/build-frames.mjs # after changing sprites/ -claude plugin validate . -claude plugin test -claude --plugin-dir . # live-reloading session -``` - -The sprite is 19x19 pixels. Each terminal cell holds two vertical pixels with -`▀`/`▄` half blocks, so Abra takes 19 columns by 10 rows inside a 40 column strip. - -## Credits - -Sprites come from [jakobhoeg/vscode-pokemon](https://github.com/jakobhoeg/vscode-pokemon/tree/main/media/gen1/abra). -Pokémon sprites are © The Pokémon Company / Nintendo / Game Freak, used here -for a personal, non-commercial fan project. diff --git a/ai-stuff/claude/mods/abra/hooks/frames.js b/ai-stuff/claude/mods/abra/hooks/frames.js deleted file mode 100644 index ce252649..00000000 --- a/ai-stuff/claude/mods/abra/hooks/frames.js +++ /dev/null @@ -1,205 +0,0 @@ -// Generated by scripts/build-frames.mjs from sprites/*.gif. Do not edit by hand. -// Each frame is rows of palette letters; '.' is transparent. - -export const SPRITE = { - "width": 19, - "height": 19, - "variants": { - "default": { - "palette": [ - "#000000", - "#483818", - "#b89828", - "#f8d860", - "#907860", - "#786038" - ], - "idle": [ - [ - "...................", - "...................", - "........aaaaa......", - "....bbbacdddcabbb..", - "....bddcdddddcddb..", - "....bcdddddddddcb..", - ".....bdddddddddb...", - "....bcdddddddddcb..", - "....acbadddddabca..", - "....acddadddaddca..", - ".....aadddddddaa...", - "....beeaadddaaeeb..", - "....affbcaaacbffa..", - ".....aaccbfbccaa...", - "...bbdcaccbccacdbb.", - "...bdccaddaddaccdb.", - "....bbccaaaaaccbb..", - "......aa.....aa....", - "..................." - ], - [ - "...................", - "........aaaaa......", - "....bbbacdddcabbb..", - "....bddcdddddcddb..", - "....bcdddddddddcb..", - ".....bdddddddddb...", - "....bcdddddddddcb..", - "....acbadddddabca..", - "....acddadddaddca..", - ".....aadddddddaa...", - "....beeaadddaaeeb..", - "....affbcaaacbffa..", - ".....aaccbfbccaa...", - "...bbdcaccbccacdbb.", - "...bdccaddaddaccdb.", - "....bbccaaaaaccbb..", - "......aa.....aa....", - "...................", - "..................." - ] - ], - "walk": [ - [ - "...................", - ".........bb........", - "........bccbba.....", - "........aabcddba...", - ".......addcbdddda..", - ".......accddddddda.", - "........accddddddb.", - ".......bcbdddddddda", - ".......acddddbadddb", - ".......adddddddaddb", - ".bb.....accdddddbb.", - "bcb....afaaaaaaa...", - "bccbbb.afffab......", - "bccfccbcaaacbbb....", - ".bffcccaffbacaaa...", - "..bbcccabbccacda...", - "....babbaccccdb....", - ".........aabccb....", - "............bb....." - ], - [ - ".........bb........", - "........bccbba.....", - "........aabcddba...", - ".......addcbdddda..", - ".......accddddddda.", - "........accddddddb.", - ".......bcbdddddddda", - "..bb...acddddbadddb", - ".bcb...adddddddaddb", - ".bcb....accdddddbb.", - ".bcfb..afaaaaaaa...", - ".affcbbafffab......", - "..bcccccaaacbbb....", - "..accccaffbacaaa...", - "...abccabbccacda...", - ".....abbaccccdb....", - ".........aabccb....", - "............bb.....", - "..................." - ] - ] - }, - "shiny": { - "palette": [ - "#000000", - "#604820", - "#d8b870", - "#f8e0a0", - "#a89080", - "#a08048" - ], - "idle": [ - [ - "...................", - "...................", - "........aaaaa......", - "....bbbacdddcabbb..", - "....bddcdddddcddb..", - "....bcdddddddddcb..", - ".....bdddddddddb...", - "....bcdddddddddcb..", - "....acbadddddabca..", - "....acddadddaddca..", - ".....aadddddddaa...", - "....beeaadddaaeeb..", - "....affbcaaacbffa..", - ".....aaccbfbccaa...", - "...bbdcaccbccacdbb.", - "...bdccaddaddaccdb.", - "....bbccaaaaaccbb..", - "......aa.....aa....", - "..................." - ], - [ - "...................", - "........aaaaa......", - "....bbbacdddcabbb..", - "....bddcdddddcddb..", - "....bcdddddddddcb..", - ".....bdddddddddb...", - "....bcdddddddddcb..", - "....acbadddddabca..", - "....acddadddaddca..", - ".....aadddddddaa...", - "....beeaadddaaeeb..", - "....affbcaaacbffa..", - ".....aaccbfbccaa...", - "...bbdcaccbccacdbb.", - "...bdccaddaddaccdb.", - "....bbccaaaaaccbb..", - "......aa.....aa....", - "...................", - "..................." - ] - ], - "walk": [ - [ - "...................", - ".........bb........", - "........bccbba.....", - "........aabcddba...", - ".......addcbdddda..", - ".......accddddddda.", - "........accddddddb.", - ".......bcbdddddddda", - ".......acddddbadddb", - ".......adddddddaddb", - ".bb.....accdddddbb.", - "bcb....afaaaaaaa...", - "bccbbb.afffab......", - "bccfccbcaaacbbb....", - ".bffcccaffbacaaa...", - "..bbcccabbccacda...", - "....babbaccccdb....", - ".........aabccb....", - "............bb....." - ], - [ - ".........bb........", - "........bccbba.....", - "........aabcddba...", - ".......addcbdddda..", - ".......accddddddda.", - "........accddddddb.", - ".......bcbdddddddda", - "..bb...acddddbadddb", - ".bcb...adddddddaddb", - ".bcb....accdddddbb.", - ".bcfb..afaaaaaaa...", - ".affcbbafffab......", - "..bcccccaaacbbb....", - "..accccaffbacaaa...", - "...abccabbccacda...", - ".....abbaccccdb....", - ".........aabccb....", - "............bb.....", - "..................." - ] - ] - } - } -} diff --git a/ai-stuff/claude/mods/abra/hooks/register.js b/ai-stuff/claude/mods/abra/hooks/register.js deleted file mode 100644 index 54094f66..00000000 --- a/ai-stuff/claude/mods/abra/hooks/register.js +++ /dev/null @@ -1,116 +0,0 @@ -import { SPRITE } from './frames.js' - -// Abra lives at the right edge of the band above the prompt. It bobs in place -// while you're idle and paces back and forth while Claude works. - -const TICK_MS = 150 -const FRAME_TICKS = 2 -const STRIP_COLUMNS = 40 -const DEFAULT_COLOR = 0x01000000 -const UPPER_HALF = 0x2580 -const LOWER_HALF = 0x2584 -const SPACE = 0x20 - -let variant = 'default' -let working = false -let bandId = null -let columns = STRIP_COLUMNS -let tick = 0 -let x = STRIP_COLUMNS - SPRITE.width -let facing = 'left' - -const rows = Math.ceil(SPRITE.height / 2) - -// Palette letter to 0xRRGGBB, per variant -const COLORS = Object.fromEntries( - Object.entries(SPRITE.variants).map(([name, sheet]) => [ - name, - Object.fromEntries(sheet.palette.map((hex, i) => [String.fromCharCode(97 + i), parseInt(hex.slice(1), 16)])), - ]), -) - -// Look up a pixel's color in a frame, or null where it's transparent -function pixelsOf(frame, colors, flip) { - return (px, py) => { - const row = frame[py] - if (!row) return null - return colors[row[flip ? SPRITE.width - 1 - px : px]] ?? null - } -} - -// Pack the current frame into Raster cells, two pixels per cell with half blocks -function cellsNow() { - const sheet = SPRITE.variants[variant] - const anim = working ? sheet.walk : sheet.idle - const frame = anim[Math.floor(tick / FRAME_TICKS) % anim.length] - const pixel = pixelsOf(frame, COLORS[variant], working && facing === 'left') - const words = new Uint32Array(columns * rows * 3) - for (let cy = 0; cy < rows; cy++) { - for (let cx = 0; cx < columns; cx++) { - const px = cx - x - const top = px >= 0 && px < SPRITE.width ? pixel(px, cy * 2) : null - const bottom = px >= 0 && px < SPRITE.width ? pixel(px, cy * 2 + 1) : null - const i = (cy * columns + cx) * 3 - if (top !== null) words.set([UPPER_HALF, top, bottom ?? DEFAULT_COLOR], i) - else if (bottom !== null) words.set([LOWER_HALF, bottom, DEFAULT_COLOR], i) - else words.set([SPACE, DEFAULT_COLOR, DEFAULT_COLOR], i) - } - } - return new Uint8Array(words.buffer).toBase64() -} - -// Move one column per tick while working, turning at the strip's edges -function step() { - tick += 1 - if (!working) return - const maxX = columns - SPRITE.width - if (facing === 'left' && x <= 0) facing = 'right' - else if (facing === 'right' && x >= maxX) facing = 'left' - x = Math.max(0, Math.min(maxX, x + (facing === 'left' ? -1 : 1))) -} - -export function register(on) { - on('session.start', async ($, e, next) => { - await $.command.register({ - name: 'abra', - description: 'Switch Abra between default and shiny', - argumentHint: '[default|shiny]', - immediate: true, - }) - const saved = await $.store.get('variant') - if (saved in SPRITE.variants) variant = saved - $.clock.every(TICK_MS, () => { - step() - if (bandId !== null) $.ui.blit({ requestId: bandId, key: 'abra', columns, rows, cells: cellsNow() }) - }) - return next(e) - }) - - on('command.run', { command: 'abra' }, async ($, e) => { - const asked = e.args.trim() - variant = asked in SPRITE.variants ? asked : variant === 'shiny' ? 'default' : 'shiny' - await $.store.set('variant', variant) - $.ui.invalidate('ui.render') - return { text: 'Abra is now ' + variant + '.' } - }) - - on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { - if (e.surface !== 'terminal' || e.props.hasSurvey) { - bandId = null - return next(e) - } - const { Box, Raster } = $.ui.resolve(e) - working = e.props.isWorking - bandId = e.requestId - columns = Math.max(SPRITE.width, Math.min(STRIP_COLUMNS, e.props.bodyColumns)) - x = Math.max(0, Math.min(columns - SPRITE.width, x)) - - const abra = Raster({ key: 'abra', columns, rows, cells: cellsNow() }) - const theirs = await next(e) - return Box({ - flexDirection: 'row', - justifyContent: theirs ? 'space-between' : 'flex-end', - children: theirs ? [Box({ flexGrow: 1, children: [theirs] }), abra] : [abra], - }) - }) -} diff --git a/ai-stuff/claude/mods/abra/scripts/build-frames.mjs b/ai-stuff/claude/mods/abra/scripts/build-frames.mjs deleted file mode 100644 index 2cc26f57..00000000 --- a/ai-stuff/claude/mods/abra/scripts/build-frames.mjs +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node - -// Decodes sprites/*.gif with ffmpeg and writes hooks/frames.js. -// Each frame becomes rows of palette letters, cropped to the box every frame fits in. -// Run from anywhere: node scripts/build-frames.mjs - -import { execFileSync } from 'node:child_process' -import { writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const SIZE = 32 -const VARIANTS = ['default', 'shiny'] -const ANIMS = ['idle', 'walk'] -const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' - -function decode(file) { - const raw = execFileSync('ffmpeg', [ - '-v', 'error', '-i', file, '-fps_mode', 'passthrough', - '-f', 'rawvideo', '-pix_fmt', 'rgba', '-', - ]) - const frameBytes = SIZE * SIZE * 4 - const frames = [] - for (let off = 0; off < raw.length; off += frameBytes) { - const px = [] - for (let i = 0; i < SIZE * SIZE; i++) { - const p = off + i * 4 - px.push(raw[p + 3] > 127 ? (raw[p] << 16) | (raw[p + 1] << 8) | raw[p + 2] : null) - } - frames.push(px) - } - return frames -} - -const sheets = {} -for (const v of VARIANTS) { - sheets[v] = {} - for (const a of ANIMS) sheets[v][a] = decode(join(ROOT, 'sprites', `${v}_${a}_8fps.gif`)) -} - -let minX = SIZE, minY = SIZE, maxX = -1, maxY = -1 -for (const v of VARIANTS) for (const a of ANIMS) for (const px of sheets[v][a]) { - px.forEach((c, i) => { - if (c === null) return - const x = i % SIZE, y = Math.floor(i / SIZE) - minX = Math.min(minX, x); maxX = Math.max(maxX, x) - minY = Math.min(minY, y); maxY = Math.max(maxY, y) - }) -} - -const out = { width: maxX - minX + 1, height: maxY - minY + 1, variants: {} } -for (const v of VARIANTS) { - const palette = [] - const letter = (c) => { - if (c === null) return '.' - let i = palette.indexOf(c) - if (i < 0) i = palette.push(c) - 1 - return LETTERS[i] - } - const anims = {} - for (const a of ANIMS) { - anims[a] = sheets[v][a].map((px) => { - const rows = [] - for (let y = minY; y <= maxY; y++) { - let row = '' - for (let x = minX; x <= maxX; x++) row += letter(px[y * SIZE + x]) - rows.push(row) - } - return rows - }) - } - out.variants[v] = { palette: palette.map((c) => '#' + c.toString(16).padStart(6, '0')), ...anims } -} - -const body = `// Generated by scripts/build-frames.mjs from sprites/*.gif. Do not edit by hand. -// Each frame is rows of palette letters; '.' is transparent. - -export const SPRITE = ${JSON.stringify(out, null, 2)} -` -writeFileSync(join(ROOT, 'hooks', 'frames.js'), body) -console.log(`wrote hooks/frames.js: ${out.width}x${out.height}px, ${ANIMS.map((a) => `${a} ${out.variants.default[a].length}f`).join(', ')}`) diff --git a/ai-stuff/claude/mods/abra/tests/abra.test.ts b/ai-stuff/claude/mods/abra/tests/abra.test.ts deleted file mode 100644 index d1fc7f4c..00000000 --- a/ai-stuff/claude/mods/abra/tests/abra.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { expect, mock, test } from 'claude-code/testing' - -const BAND = { - plugin: 'abra', - component: 'AbovePrompt', - requestId: 'band', - viewport: { columns: 120, rows: 40 }, - props: { - hasSurvey: false, - isWorking: false, - maxRows: 20, - bodyColumns: 120, - scroll: { offset: 0, bodyRows: 20 }, - view: {}, - }, -} as const - -const THEIRS = { type: 'Text', props: {}, children: ['drawn by Claude Code'] } - -function codePoints(cells: string): number[] { - const words = new Uint32Array(Uint8Array.fromBase64(cells).buffer) - return Array.from(words).filter((_, i) => i % 3 === 0) -} - -test('draws Abra as a half-block raster in the terminal band', async ($, on) => { - on('ui.render', () => THEIRS) - const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) - const raster = await ui.find({ key: 'abra' }) - expect(raster).toBeDefined() - expect(raster.props.columns).toBe(40) - expect(raster.props.rows).toBe(10) - expect(codePoints(raster.props.cells)).toContain(0x2580) -}) - -test('leaves the band alone on desktop and during a survey', async ($, on) => { - on('ui.render', () => THEIRS) - const desktop = await $.ui.mount({ ...BAND, surface: 'desktop' }) - expect(await desktop.find({ key: 'abra' })).toBeUndefined() - await desktop.unmount() - const survey = await $.ui.mount({ ...BAND, surface: 'terminal', props: { ...BAND.props, hasSurvey: true } }) - expect(await survey.find({ key: 'abra' })).toBeUndefined() -}) - -test('paces while Claude works', async ($, on) => { - const clock = mock.clock(on) - const blits: string[] = [] - on('ui.render', () => THEIRS) - on('ui.blit', ($, e) => { - blits.push(e.cells) - return { value: {} } - }) - on('session.start', () => ({ cwd: '/work' })) - on('command.register', () => ({ value: undefined })) - on('store.get', () => ({ value: undefined })) - - await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) - await $.ui.mount({ ...BAND, surface: 'terminal', props: { ...BAND.props, isWorking: true } }) - await clock.advance(150 * 6) - expect(blits.length).toBe(6) - expect(new Set(blits).size).toBe(6) -}) - -test('/abra toggles shiny and saves it', async ($, on) => { - const saved = new Map() - on('store.set', ($, e) => { - saved.set(e.key, e.value) - return { value: undefined } - }) - const first = await $.command.run({ command: 'abra', args: '' }) - expect(first.text).toBe('Abra is now shiny.') - const second = await $.command.run({ command: 'abra', args: 'default' }) - expect(second.text).toBe('Abra is now default.') - expect(saved.get('variant')).toBe('default') -}) diff --git a/ai-stuff/claude/mods/pokemon/.claude-plugin/plugin.json b/ai-stuff/claude/mods/pokemon/.claude-plugin/plugin.json new file mode 100644 index 00000000..92e83ff4 --- /dev/null +++ b/ai-stuff/claude/mods/pokemon/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "pokemon", + "version": "0.5.0", + "description": "A pixel Pokémon that lives at the right edge of the band above the prompt", + "author": { "name": "deniz gökçin" } +} diff --git a/ai-stuff/claude/mods/pokemon/README.md b/ai-stuff/claude/mods/pokemon/README.md new file mode 100644 index 00000000..19a74340 --- /dev/null +++ b/ai-stuff/claude/mods/pokemon/README.md @@ -0,0 +1,63 @@ +# pokemon + +A Claude Code mod that draws one pixel Pokémon at the right edge of the band +above the prompt. It paces back and forth while Claude works, walks home to the +right edge when the turn ends, and bobs in place there while you're idle. + +## Requirements + +- Claude Code v2.1.287 or later +- The terminal app. The Desktop app has no `Raster`, so the mod draws nothing there. +- A truecolor terminal (iTerm2, Ghostty, kitty, WezTerm) + +## Usage + +| Command | Effect | +| --- | --- | +| `/pokemon` | Show which mon and variant are active, and the options | +| `/pokemon abra`, `/pokemon bulbasaur`, `/pokemon charmander` | Pick a mon, saved across sessions | +| `/pokemon shiny`, `/pokemon default` | Pick a variant, saved across sessions | +| Ctrl+X Ctrl+A | Collapse or expand the band (Claude Code's own binding) | + +## Mons + +| Mon | Pixels | Band rows | Frames (default) | +| --- | --- | --- | --- | +| abra | 19x19 | 10 | 2 idle, 2 walk at 300 ms | +| bulbasaur | 20x17 | 9 | 6 idle, 6 walk at 100 ms | +| charmander | 19x17 | 9 | 2 idle, 2 walk at 300 ms | + +## Add a mon + +1. Copy the four GIFs from `media/gen//` in [jakobhoeg/vscode-pokemon](https://github.com/jakobhoeg/vscode-pokemon/tree/main/media) into `sprites//`. + The files are `default_idle_8fps.gif`, `default_walk_8fps.gif`, `shiny_idle_8fps.gif`, and `shiny_walk_8fps.gif`. +2. Run `node scripts/build-frames.mjs`. +3. Run `/reload-plugins`. The mod loads in place from the repo, so no version bump or reinstall is needed. + +## Layout + +| Path | Contents | +| --- | --- | +| `hooks/register.js` | Band renderer, animation timer, `/pokemon` command | +| `hooks/frames.js` | Generated pixel frames. Don't edit by hand. | +| `sprites//*.gif` | Source GIFs, 32x32 | +| `scripts/build-frames.mjs` | Regenerates `frames.js` from the GIFs with ffmpeg | +| `tests/pokemon.test.ts` | `claude plugin test` suite | + +## Development + +```bash +node scripts/build-frames.mjs # after changing sprites/ +claude plugin validate . +claude plugin test +claude --plugin-dir . # live-reloading session +``` + +Each terminal cell holds two vertical pixels with `▀`/`▄` half blocks. The +sprite paces inside a 40 column strip. + +## Credits + +Sprites come from [jakobhoeg/vscode-pokemon](https://github.com/jakobhoeg/vscode-pokemon/tree/main/media). +Pokémon sprites are © The Pokémon Company / Nintendo / Game Freak, used here +for a personal, non-commercial fan project. diff --git a/ai-stuff/claude/mods/pokemon/hooks/frames.js b/ai-stuff/claude/mods/pokemon/hooks/frames.js new file mode 100644 index 00000000..b5b9c73a --- /dev/null +++ b/ai-stuff/claude/mods/pokemon/hooks/frames.js @@ -0,0 +1,843 @@ +// Generated by scripts/build-frames.mjs from sprites//*.gif. Do not edit by hand. +// Each frame is its duration and rows of palette letters; '.' is transparent. + +export const SPRITES = { + "abra": { + "width": 19, + "height": 19, + "variants": { + "default": { + "palette": [ + "#000000", + "#483818", + "#b89828", + "#f8d860", + "#907860", + "#786038" + ], + "idle": [ + { + "ms": 300, + "rows": [ + "...................", + "...................", + "........aaaaa......", + "....bbbacdddcabbb..", + "....bddcdddddcddb..", + "....bcdddddddddcb..", + ".....bdddddddddb...", + "....bcdddddddddcb..", + "....acbadddddabca..", + "....acddadddaddca..", + ".....aadddddddaa...", + "....beeaadddaaeeb..", + "....affbcaaacbffa..", + ".....aaccbfbccaa...", + "...bbdcaccbccacdbb.", + "...bdccaddaddaccdb.", + "....bbccaaaaaccbb..", + "......aa.....aa....", + "..................." + ] + }, + { + "ms": 300, + "rows": [ + "...................", + "........aaaaa......", + "....bbbacdddcabbb..", + "....bddcdddddcddb..", + "....bcdddddddddcb..", + ".....bdddddddddb...", + "....bcdddddddddcb..", + "....acbadddddabca..", + "....acddadddaddca..", + ".....aadddddddaa...", + "....beeaadddaaeeb..", + "....affbcaaacbffa..", + ".....aaccbfbccaa...", + "...bbdcaccbccacdbb.", + "...bdccaddaddaccdb.", + "....bbccaaaaaccbb..", + "......aa.....aa....", + "...................", + "..................." + ] + } + ], + "walk": [ + { + "ms": 300, + "rows": [ + "...................", + ".........bb........", + "........bccbba.....", + "........aabcddba...", + ".......addcbdddda..", + ".......accddddddda.", + "........accddddddb.", + ".......bcbdddddddda", + ".......acddddbadddb", + ".......adddddddaddb", + ".bb.....accdddddbb.", + "bcb....afaaaaaaa...", + "bccbbb.afffab......", + "bccfccbcaaacbbb....", + ".bffcccaffbacaaa...", + "..bbcccabbccacda...", + "....babbaccccdb....", + ".........aabccb....", + "............bb....." + ] + }, + { + "ms": 300, + "rows": [ + ".........bb........", + "........bccbba.....", + "........aabcddba...", + ".......addcbdddda..", + ".......accddddddda.", + "........accddddddb.", + ".......bcbdddddddda", + "..bb...acddddbadddb", + ".bcb...adddddddaddb", + ".bcb....accdddddbb.", + ".bcfb..afaaaaaaa...", + ".affcbbafffab......", + "..bcccccaaacbbb....", + "..accccaffbacaaa...", + "...abccabbccacda...", + ".....abbaccccdb....", + ".........aabccb....", + "............bb.....", + "..................." + ] + } + ] + }, + "shiny": { + "palette": [ + "#000000", + "#604820", + "#d8b870", + "#f8e0a0", + "#a89080", + "#a08048" + ], + "idle": [ + { + "ms": 300, + "rows": [ + "...................", + "...................", + "........aaaaa......", + "....bbbacdddcabbb..", + "....bddcdddddcddb..", + "....bcdddddddddcb..", + ".....bdddddddddb...", + "....bcdddddddddcb..", + "....acbadddddabca..", + "....acddadddaddca..", + ".....aadddddddaa...", + "....beeaadddaaeeb..", + "....affbcaaacbffa..", + ".....aaccbfbccaa...", + "...bbdcaccbccacdbb.", + "...bdccaddaddaccdb.", + "....bbccaaaaaccbb..", + "......aa.....aa....", + "..................." + ] + }, + { + "ms": 300, + "rows": [ + "...................", + "........aaaaa......", + "....bbbacdddcabbb..", + "....bddcdddddcddb..", + "....bcdddddddddcb..", + ".....bdddddddddb...", + "....bcdddddddddcb..", + "....acbadddddabca..", + "....acddadddaddca..", + ".....aadddddddaa...", + "....beeaadddaaeeb..", + "....affbcaaacbffa..", + ".....aaccbfbccaa...", + "...bbdcaccbccacdbb.", + "...bdccaddaddaccdb.", + "....bbccaaaaaccbb..", + "......aa.....aa....", + "...................", + "..................." + ] + } + ], + "walk": [ + { + "ms": 300, + "rows": [ + "...................", + ".........bb........", + "........bccbba.....", + "........aabcddba...", + ".......addcbdddda..", + ".......accddddddda.", + "........accddddddb.", + ".......bcbdddddddda", + ".......acddddbadddb", + ".......adddddddaddb", + ".bb.....accdddddbb.", + "bcb....afaaaaaaa...", + "bccbbb.afffab......", + "bccfccbcaaacbbb....", + ".bffcccaffbacaaa...", + "..bbcccabbccacda...", + "....babbaccccdb....", + ".........aabccb....", + "............bb....." + ] + }, + { + "ms": 300, + "rows": [ + ".........bb........", + "........bccbba.....", + "........aabcddba...", + ".......addcbdddda..", + ".......accddddddda.", + "........accddddddb.", + ".......bcbdddddddda", + "..bb...acddddbadddb", + ".bcb...adddddddaddb", + ".bcb....accdddddbb.", + ".bcfb..afaaaaaaa...", + ".affcbbafffab......", + "..bcccccaaacbbb....", + "..accccaffbacaaa...", + "...abccabbccacda...", + ".....abbaccccdb....", + ".........aabccb....", + "............bb.....", + "..................." + ] + } + ] + } + } + }, + "bulbasaur": { + "width": 20, + "height": 17, + "variants": { + "default": { + "palette": [ + "#406030", + "#60a810", + "#000000", + "#88e020", + "#404040", + "#68d078", + "#409850", + "#e8e8f8", + "#681018", + "#c8c8c8", + "#b83018" + ], + "idle": [ + { + "ms": 100, + "rows": [ + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + "..ecfgeeeeegfcge....", + ".egcfffffgfffcge....", + ".egefffggffffea.....", + ".ecgfffggffffgc.....", + "..cghigfffgihgc.....", + "..cgjkfffffkjgc.....", + "..ecggfffffggca.....", + "..cgccggfggccga.....", + "...ccacccccaggc.....", + "............cc......", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + "..ecfgeeeeegfcge....", + ".egcfffffgfffcge....", + ".egefffggffffea.....", + ".ecgfffggffffgc.....", + "..cghigfffgihgc.....", + "..cgjkfffffkjgc.....", + "..ecggfffffggca.....", + "..cgccggfggccga.....", + "...ccacccccaggc.....", + "............cc......", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + "..ecfgeeeeegfcge....", + ".egcfffffgfffcge....", + ".egefffggffffea.....", + ".ecgfffggffffgc.....", + "..cghigfffgihgc.....", + "..cgjkfffffkjgc.....", + "..ecggfffffggca.....", + "..cgccggfggccga.....", + "...ccacccccaggc.....", + "............cc......", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + "....................", + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + ".egcfgeeeeegfce.....", + ".egcfffffgfffcge....", + "..aefffggffffege....", + "..cgfffggffffgce....", + "..cghigfffgihgc.....", + "..cgjkfffffkjgc.....", + "..acggfffffggce.....", + "..agccggfggccgc.....", + "..cggacccccacc......", + "...cc..............." + ] + }, + { + "ms": 100, + "rows": [ + "....................", + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + ".egcfgeeeeegfce.....", + ".egcfffffgfffcge....", + "..aefffggffffege....", + "..cgfffggffffgce....", + "..cghigfffgihgc.....", + "..cgjkfffffkjgc.....", + "..acggfffffggce.....", + "..agccggfggccgc.....", + "..cggacccccacc......", + "...cc..............." + ] + }, + { + "ms": 100, + "rows": [ + "....................", + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + ".egcfgeeeeegfce.....", + ".egcfffffgfffcge....", + "..aefffggffffege....", + "..cgfffggffffgce....", + "..cghigfffgihgc.....", + "..cgjkfffffkjgc.....", + "..acggfffffggce.....", + "..agccggfggccgc.....", + "..cggacccccacc......", + "...cc..............." + ] + } + ], + "walk": [ + { + "ms": 100, + "rows": [ + "......aaaa..........", + "...ccadddba.aaa.....", + "..cbbdddbbbccgfaa...", + "..cbbbbbbecgfffgga..", + "..abddbbeffffffggfa.", + ".abbddbbegffffffffa.", + ".abbbbbbeggffffffgfa", + ".abbbbbbcgfffffffffa", + "..abbbbcffgfffhifffa", + "...ccccfffafffjkgfga", + "....cgfffffafffffac.", + "....cggfffggaaccc...", + "...cggggccgggaa.....", + "...cggccaaacggc.....", + "....cc......cc......", + "....................", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + "......aaaa..........", + "...ccadddba.aaa.....", + "..cbbdddbbbccgfaa...", + "..cbbbbbbecgfffgga..", + "..abddbbeffffffggfa.", + ".abbddbbegffffffffa.", + ".abbbbbbeggffffffgfa", + ".abbbbbbcgfffffffffa", + "..abbbbcffgfffhifffa", + "...ccccfffafffjkgfga", + "....cgfffffafffffac.", + "....cggfffggaaccc...", + "...cggggccgggaa.....", + "...cggccaaacggc.....", + "....cc......cc......", + "....................", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + "......aaaa..........", + "...ccadddba.aaa.....", + "..cbbdddbbbccgfaa...", + "..cbbbbbbecgfffgga..", + "..abddbbeffffffggfa.", + ".abbddbbegffffffffa.", + ".abbbbbbeggffffffgfa", + ".abbbbbbcgfffffffffa", + "..abbbbcffgfffhifffa", + "...ccccfffafffjkgfga", + "....cgfffffafffffac.", + "....cggfffggaaccc...", + "...cggggccgggaa.....", + "...cggccaaacggc.....", + "....cc......cc......", + "....................", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + "....................", + "......aaaa..........", + "...ccadddba.aaa.....", + "..cbbdddbbbccgfaa...", + "..cbbbbbbecgfffgga..", + "..abddbbeffffffggfa.", + ".abbddbbegffffffffa.", + ".abbbbbbeggffffffgfa", + ".abbbbbbcgfffffffffa", + "..abbbbcffgfffhifffa", + "...ccccfffafffjkgfga", + "....cgfffffafffffac.", + "...cagggffggaaccc...", + "...cacggagggcga.....", + "....ccggccggca......", + "......cc..cc........", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + "....................", + "......aaaa..........", + "...ccadddba.aaa.....", + "..cbbdddbbbccgfaa...", + "..cbbbbbbecgfffgga..", + "..abddbbeffffffggfa.", + ".abbddbbegffffffffa.", + ".abbbbbbeggffffffgfa", + ".abbbbbbcgfffffffffa", + "..abbbbcffgfffhifffa", + "...ccccfffafffjkgfga", + "....cgfffffafffffac.", + "...cagggffggaaccc...", + "...cacggagggcga.....", + "....ccggccggca......", + "......cc..cc........", + "...................." + ] + }, + { + "ms": 100, + "rows": [ + "....................", + "......aaaa..........", + "...ccadddba.aaa.....", + "..cbbdddbbbccgfaa...", + "..cbbbbbbecgfffgga..", + "..abddbbeffffffggfa.", + ".abbddbbegffffffffa.", + ".abbbbbbeggffffffgfa", + ".abbbbbbcgfffffffffa", + "..abbbbcffgfffhifffa", + "...ccccfffafffjkgfga", + "....cgfffffafffffac.", + "...cagggffggaaccc...", + "...cacggagggcga.....", + "....ccggccggca......", + "......cc..cc........", + "...................." + ] + } + ] + }, + "shiny": { + "palette": [ + "#406030", + "#207840", + "#000000", + "#50a058", + "#404040", + "#709830", + "#a0d038", + "#e8e8f8", + "#681018", + "#d8d8f8", + "#b83018" + ], + "idle": [ + { + "ms": 300, + "rows": [ + "....................", + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + ".efcgfeeeeefgce.....", + ".efcgggggfgggcfe....", + "..aegggffggggefe....", + "..cfgggffggggfce....", + "..cfhifgggfihfc.....", + "..cfjkgggggkjfc.....", + "..acffgggggffce.....", + "..afccffgffccfc.....", + "..cffacccccacc......", + "...cc..............." + ] + }, + { + "ms": 300, + "rows": [ + ".......aaa..........", + "......ababa.........", + "....ccbdbbbcc.......", + "...cbddddbbdbc......", + "..cbbbdddbddbbc.....", + "..cbeabbbbbaebc.....", + "..ecgfeeeeefgcfe....", + ".efcgggggfgggcfe....", + ".efegggffggggea.....", + ".ecfgggffggggfc.....", + "..cfhifgggfihfc.....", + "..cfjkgggggkjfc.....", + "..ecffgggggffca.....", + "..cfccffgffccfa.....", + "...ccacccccaffc.....", + "............cc......", + "...................." + ] + } + ], + "walk": [ + { + "ms": 300, + "rows": [ + "....................", + ".....aaaa...........", + "..ccadddba.aaa......", + ".cbbdddbbbccfgaa....", + ".cbbbbbbecfgggffa...", + ".abddbbeggggggffga..", + "abbddbbefgggggggga..", + "abbbbbbeffggggggfga.", + "abbbbbbcfggggggggga.", + ".abbbbcggfggghiggga.", + "..ccccgggagggjkfgfa.", + "...cfgggggagggggac..", + "..cafffggffaaccc....", + "..cacffafffcfa......", + "...ccffccffca.......", + ".....cc..cc.........", + "...................." + ] + }, + { + "ms": 300, + "rows": [ + ".....aaaa...........", + "..ccadddba.aaa......", + ".cbbdddbbbccfgaa....", + ".cbbbbbbecfgggffa...", + ".abddbbeggggggffga..", + "abbddbbefgggggggga..", + "abbbbbbeffggggggfga.", + "abbbbbbcfggggggggga.", + ".abbbbcggfggghiggga.", + "..ccccgggagggjkfgfa.", + "...cfgggggagggggac..", + "...cffgggffaaccc....", + "..cffffccfffaa......", + "..cffccaaacffc......", + "...cc......cc.......", + "....................", + "...................." + ] + } + ] + } + } + }, + "charmander": { + "width": 19, + "height": 17, + "variants": { + "default": { + "palette": [ + "#604020", + "#f88838", + "#b85820", + "#e8e8f8", + "#284858", + "#000000", + "#d8a830", + "#f0d070", + "#e02828" + ], + "idle": [ + { + "ms": 300, + "rows": [ + "...................", + ".....aaaaa.........", + "....abbbbba........", + "...acbbbbbca.......", + "...abbbbbbba.......", + "...abcbbbcba.......", + "..acdbbbbbdca......", + "..acebbbbbeca......", + "..fcebbbbbecf......", + "...fccbbbccf.......", + "...afcccccfa.......", + "...fcfffffcf.......", + "...fcaghgacf.......", + "..acffhhhffca......", + "..accghhhgcca......", + "..accfaaafff.......", + "...ff.............." + ] + }, + { + "ms": 300, + "rows": [ + ".....aaaaa.........", + "....abbbbba........", + "...acbbbbbca.......", + "...abbbbbbba.......", + "...abcbbbcba.......", + "..acdbbbbbdca......", + "..acebbbbbeca......", + "..fcebbbbbecf......", + "...fccbbbccf.......", + "...afcccccfa.......", + "...fcfffffcf.......", + "...fcaghgacf.......", + "..acffhhhffca......", + "..accghhhgcca......", + "...fffaaafcca......", + "..........ff.......", + "..................." + ] + } + ], + "walk": [ + { + "ms": 300, + "rows": [ + "...................", + "...........aaaa....", + ".i........abbbba...", + ".ii......abbbbbba..", + ".hi......abbbbbbc..", + "iiii....abbbbbbcba.", + "ihhi....abbbbbdcba.", + ".ihi....abbbbbebbba", + ".ii....abbbbbbebbbf", + ".fca...abbbcabbbcf.", + ".fccaaabbbcbcaffa..", + "..fccbbbbcabbca....", + "..fccccbbbcffff....", + "...ffcccbbccga.....", + ".....ffacccca......", + ".......fccaa.......", + "........fff........" + ] + }, + { + "ms": 300, + "rows": [ + "...........aaaa....", + "...i......abbbba...", + "...i.....abbbbbba..", + "..ii.....abbbbbbc..", + "..iii...abbbbbbcba.", + ".iihi...abbbbbdcba.", + ".ihhi...abbbbbebbba", + ".ahi...abbbbbbebbbf", + ".fca...abbbcabbbcf.", + ".fccaaabbbcbcaffa..", + "..fccbbbbbabbca....", + "..fccccbbbbffff....", + "...ffccccbbcga.....", + ".....ffaacccca.....", + "........afcca......", + "..........fff......", + "..................." + ] + } + ] + }, + "shiny": { + "palette": [ + "#604020", + "#f8c038", + "#f88838", + "#e8e8f8", + "#284858", + "#000000", + "#d8a830", + "#f0d070", + "#e02828" + ], + "idle": [ + { + "ms": 300, + "rows": [ + "...................", + ".....aaaaa.........", + "....abbbbba........", + "...acbbbbbca.......", + "...abbbbbbba.......", + "...abcbbbcba.......", + "..acdbbbbbdca......", + "..acebbbbbeca......", + "..fcebbbbbecf......", + "...fccbbbccf.......", + "...afcccccfa.......", + "...fcfffffcf.......", + "...fcaghgacf.......", + "..acffhhhffca......", + "..accghhhgcca......", + "..accfaaafff.......", + "...ff.............." + ] + }, + { + "ms": 300, + "rows": [ + ".....aaaaa.........", + "....abbbbba........", + "...acbbbbbca.......", + "...abbbbbbba.......", + "...abcbbbcba.......", + "..acdbbbbbdca......", + "..acebbbbbeca......", + "..fcebbbbbecf......", + "...fccbbbccf.......", + "...afcccccfa.......", + "...fcfffffcf.......", + "...fcaghgacf.......", + "..acffhhhffca......", + "..accghhhgcca......", + "...fffaaafcca......", + "..........ff.......", + "..................." + ] + } + ], + "walk": [ + { + "ms": 300, + "rows": [ + "...................", + "...........aaaa....", + ".i........abbbba...", + ".ii......abbbbbba..", + ".hi......abbbbbbc..", + "iiii....abbbbbbcba.", + "ihhi....abbbbbdcba.", + ".ihi....abbbbbebbba", + ".ii....abbbbbbebbbf", + ".fca...abbbcabbbcf.", + ".fccaaabbbcbcaffa..", + "..fccbbbbcabbca....", + "..fccccbbbcffff....", + "...ffcccbbccga.....", + ".....ffacccca......", + ".......fccaa.......", + "........fff........" + ] + }, + { + "ms": 300, + "rows": [ + "...........aaaa....", + "...i......abbbba...", + "...i.....abbbbbba..", + "..ii.....abbbbbbc..", + "..iii...abbbbbbcba.", + ".iihi...abbbbbdcba.", + ".ihhi...abbbbbebbba", + ".ahi...abbbbbbebbbf", + ".fca...abbbcabbbcf.", + ".fccaaabbbcbcaffa..", + "..fccbbbbbabbca....", + "..fccccbbbbffff....", + "...ffccccbbcga.....", + ".....ffaacccca.....", + "........afcca......", + "..........fff......", + "..................." + ] + } + ] + } + } + } +} diff --git a/ai-stuff/claude/mods/abra/hooks/hooks.json b/ai-stuff/claude/mods/pokemon/hooks/hooks.json similarity index 100% rename from ai-stuff/claude/mods/abra/hooks/hooks.json rename to ai-stuff/claude/mods/pokemon/hooks/hooks.json diff --git a/ai-stuff/claude/mods/pokemon/hooks/register.js b/ai-stuff/claude/mods/pokemon/hooks/register.js new file mode 100644 index 00000000..b937ad0f --- /dev/null +++ b/ai-stuff/claude/mods/pokemon/hooks/register.js @@ -0,0 +1,169 @@ +import { SPRITES } from './frames.js' + +// One pixel Pokémon lives at the right edge of the band above the prompt. It +// paces back and forth while Claude works, walks home to the right edge when +// the turn ends, and bobs in place there while you're idle. + +const TICK_MS = 50 +const MOVE_TICKS = 3 +const STRIP_COLUMNS = 40 +const DEFAULT_COLOR = 0x01000000 +const UPPER_HALF = 0x2580 +const LOWER_HALF = 0x2584 +const SPACE = 0x20 +const MONS = Object.keys(SPRITES) +const VARIANTS = ['default', 'shiny'] + +let mon = MONS.includes('abra') ? 'abra' : MONS[0] +let variant = 'default' +let working = false +let bandId = null +let columns = STRIP_COLUMNS +let tick = 0 +let x = STRIP_COLUMNS +let facing = 'left' + +// Palette letter to 0xRRGGBB, per mon and variant +const COLORS = Object.fromEntries( + MONS.map((name) => [ + name, + Object.fromEntries( + VARIANTS.map((v) => [ + v, + Object.fromEntries( + SPRITES[name].variants[v].palette.map((hex, i) => [String.fromCharCode(97 + i), parseInt(hex.slice(1), 16)]), + ), + ]), + ), + ]), +) + +const rowsOf = (name) => Math.ceil(SPRITES[name].height / 2) + +// The frame showing after elapsed milliseconds, looping over each frame's own duration +function frameAt(anim, elapsed) { + const total = anim.reduce((sum, f) => sum + f.ms, 0) + let t = elapsed % total + for (const f of anim) { + if (t < f.ms) return f.rows + t -= f.ms + } + return anim[0].rows +} + +// Pack the current frame into Raster cells, two pixels per cell with half blocks +function cellsNow() { + const sprite = SPRITES[mon] + const sheet = sprite.variants[variant] + const walking = working || !isHome() + const frame = frameAt(walking ? sheet.walk : sheet.idle, tick * TICK_MS) + const colors = COLORS[mon][variant] + const flip = walking && facing === 'left' + const pixel = (px, py) => { + if (px < 0 || px >= sprite.width) return null + const row = frame[py] + if (!row) return null + return colors[row[flip ? sprite.width - 1 - px : px]] ?? null + } + + const rows = rowsOf(mon) + const words = new Uint32Array(columns * rows * 3) + for (let cy = 0; cy < rows; cy++) { + for (let cx = 0; cx < columns; cx++) { + const top = pixel(cx - x, cy * 2) + const bottom = pixel(cx - x, cy * 2 + 1) + const i = (cy * columns + cx) * 3 + if (top !== null) words.set([UPPER_HALF, top, bottom ?? DEFAULT_COLOR], i) + else if (bottom !== null) words.set([LOWER_HALF, bottom, DEFAULT_COLOR], i) + else words.set([SPACE, DEFAULT_COLOR, DEFAULT_COLOR], i) + } + } + return new Uint8Array(words.buffer).toBase64() +} + +// The right edge of the strip, where the sprite idles +const homeX = () => columns - SPRITES[mon].width +const isHome = () => x >= homeX() + +// Keep the sprite inside the strip +function clampX() { + x = Math.max(0, Math.min(homeX(), x)) +} + +// Advance the clock and move one column every few ticks: pacing while +// working, heading right while idle until the sprite is home +function step() { + tick += 1 + if (tick % MOVE_TICKS !== 0) return + if (!working) { + if (isHome()) return + facing = 'right' + } else if (facing === 'left' && x <= 0) facing = 'right' + else if (facing === 'right' && isHome()) facing = 'left' + x += facing === 'left' ? -1 : 1 + clampX() +} + +export function register(on) { + on('session.start', async ($, e, next) => { + await $.command.register({ + name: 'pokemon', + description: 'Pick the Pokémon above the prompt, or switch it to shiny', + argumentHint: '[' + [...MONS, ...VARIANTS].join('|') + ']', + immediate: true, + }) + const savedMon = await $.store.get('mon') + if (MONS.includes(savedMon)) mon = savedMon + const savedVariant = await $.store.get('variant') + if (VARIANTS.includes(savedVariant)) variant = savedVariant + $.clock.every(TICK_MS, () => { + step() + if (bandId !== null) { + $.ui.blit({ requestId: bandId, key: 'pokemon', columns, rows: rowsOf(mon), cells: cellsNow() }) + } + }) + return next(e) + }) + + on('command.run', { command: 'pokemon' }, async ($, e) => { + const asked = e.args.trim().toLowerCase() + if (MONS.includes(asked)) { + const wasHome = isHome() + mon = asked + if (wasHome) x = homeX() + clampX() + await $.store.set('mon', mon) + } else if (VARIANTS.includes(asked)) { + variant = asked + await $.store.set('variant', variant) + } else if (asked) { + return { text: 'Unknown option "' + asked + '". Try one of: ' + [...MONS, ...VARIANTS].join(', ') + '.' } + } else { + return { text: 'Showing ' + variant + ' ' + mon + '. Options: ' + [...MONS, ...VARIANTS].join(', ') + '.' } + } + $.ui.invalidate('ui.render') + return { text: 'Now showing ' + variant + ' ' + mon + '.' } + }) + + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + if (e.surface !== 'terminal' || e.props.hasSurvey) { + bandId = null + return next(e) + } + const { Box, Raster } = $.ui.resolve(e) + const wasHome = isHome() + working = e.props.isWorking + bandId = e.requestId + columns = Math.max(SPRITES[mon].width, Math.min(STRIP_COLUMNS, e.props.bodyColumns)) + if (wasHome) x = homeX() + clampX() + + const sprite = Raster({ key: 'pokemon', columns, rows: rowsOf(mon), cells: cellsNow() }) + const theirs = await next(e) + return Box({ + flexDirection: 'row', + justifyContent: theirs ? 'space-between' : 'flex-end', + children: theirs ? [Box({ flexGrow: 1, children: [theirs] }), sprite] : [sprite], + }) + }) +} diff --git a/ai-stuff/claude/mods/pokemon/scripts/build-frames.mjs b/ai-stuff/claude/mods/pokemon/scripts/build-frames.mjs new file mode 100644 index 00000000..5fdb2462 --- /dev/null +++ b/ai-stuff/claude/mods/pokemon/scripts/build-frames.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +// Decodes sprites//*.gif with ffmpeg and writes hooks/frames.js. +// Each frame becomes rows of palette letters plus its duration, cropped per mon +// to the box every one of its frames fits in. +// Add a mon by dropping its four GIFs from jakobhoeg/vscode-pokemon into sprites//. +// Run from anywhere: node scripts/build-frames.mjs + +import { execFileSync } from 'node:child_process' +import { readdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SIZE = 32 +const VARIANTS = ['default', 'shiny'] +const ANIMS = ['idle', 'walk'] +const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + +function decode(file) { + const raw = execFileSync('ffmpeg', [ + '-v', 'error', '-i', file, '-fps_mode', 'passthrough', + '-f', 'rawvideo', '-pix_fmt', 'rgba', '-', + ]) + const durations = execFileSync('ffprobe', [ + '-v', 'error', '-show_entries', 'frame=duration_time', '-of', 'csv=p=0', file, + ]).toString().trim().split('\n').map((s) => Math.round(Number(s) * 1000) || 100) + + const frameBytes = SIZE * SIZE * 4 + const frames = [] + for (let off = 0, k = 0; off < raw.length; off += frameBytes, k++) { + const px = [] + for (let i = 0; i < SIZE * SIZE; i++) { + const p = off + i * 4 + px.push(raw[p + 3] > 127 ? (raw[p] << 16) | (raw[p + 1] << 8) | raw[p + 2] : null) + } + frames.push({ ms: durations[k] ?? 100, px }) + } + return frames +} + +function buildMon(mon) { + const sheets = {} + for (const v of VARIANTS) { + sheets[v] = {} + for (const a of ANIMS) sheets[v][a] = decode(join(ROOT, 'sprites', mon, `${v}_${a}_8fps.gif`)) + } + + let minX = SIZE, minY = SIZE, maxX = -1, maxY = -1 + for (const v of VARIANTS) for (const a of ANIMS) for (const { px } of sheets[v][a]) { + px.forEach((c, i) => { + if (c === null) return + const x = i % SIZE, y = Math.floor(i / SIZE) + minX = Math.min(minX, x); maxX = Math.max(maxX, x) + minY = Math.min(minY, y); maxY = Math.max(maxY, y) + }) + } + + const out = { width: maxX - minX + 1, height: maxY - minY + 1, variants: {} } + for (const v of VARIANTS) { + const palette = [] + const letter = (c) => { + if (c === null) return '.' + let i = palette.indexOf(c) + if (i < 0) i = palette.push(c) - 1 + return LETTERS[i] + } + const anims = {} + for (const a of ANIMS) { + anims[a] = sheets[v][a].map(({ ms, px }) => { + const rows = [] + for (let y = minY; y <= maxY; y++) { + let row = '' + for (let x = minX; x <= maxX; x++) row += letter(px[y * SIZE + x]) + rows.push(row) + } + return { ms, rows } + }) + } + out.variants[v] = { palette: palette.map((c) => '#' + c.toString(16).padStart(6, '0')), ...anims } + } + return out +} + +const mons = readdirSync(join(ROOT, 'sprites'), { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort() + +const sprites = Object.fromEntries(mons.map((mon) => [mon, buildMon(mon)])) + +const body = `// Generated by scripts/build-frames.mjs from sprites//*.gif. Do not edit by hand. +// Each frame is its duration and rows of palette letters; '.' is transparent. + +export const SPRITES = ${JSON.stringify(sprites, null, 2)} +` +writeFileSync(join(ROOT, 'hooks', 'frames.js'), body) +for (const [mon, s] of Object.entries(sprites)) { + const counts = ANIMS.map((a) => `${a} ${s.variants.default[a].length}f`).join(', ') + console.log(`${mon}: ${s.width}x${s.height}px, ${counts}`) +} diff --git a/ai-stuff/claude/mods/abra/sprites/default_idle_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/abra/default_idle_8fps.gif similarity index 100% rename from ai-stuff/claude/mods/abra/sprites/default_idle_8fps.gif rename to ai-stuff/claude/mods/pokemon/sprites/abra/default_idle_8fps.gif diff --git a/ai-stuff/claude/mods/abra/sprites/default_walk_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/abra/default_walk_8fps.gif similarity index 100% rename from ai-stuff/claude/mods/abra/sprites/default_walk_8fps.gif rename to ai-stuff/claude/mods/pokemon/sprites/abra/default_walk_8fps.gif diff --git a/ai-stuff/claude/mods/abra/sprites/shiny_idle_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/abra/shiny_idle_8fps.gif similarity index 100% rename from ai-stuff/claude/mods/abra/sprites/shiny_idle_8fps.gif rename to ai-stuff/claude/mods/pokemon/sprites/abra/shiny_idle_8fps.gif diff --git a/ai-stuff/claude/mods/abra/sprites/shiny_walk_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/abra/shiny_walk_8fps.gif similarity index 100% rename from ai-stuff/claude/mods/abra/sprites/shiny_walk_8fps.gif rename to ai-stuff/claude/mods/pokemon/sprites/abra/shiny_walk_8fps.gif diff --git a/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/default_idle_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/default_idle_8fps.gif new file mode 100644 index 00000000..440f81e0 Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/default_idle_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/default_walk_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/default_walk_8fps.gif new file mode 100644 index 00000000..837e93e3 Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/default_walk_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/shiny_idle_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/shiny_idle_8fps.gif new file mode 100644 index 00000000..8993e61f Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/shiny_idle_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/shiny_walk_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/shiny_walk_8fps.gif new file mode 100644 index 00000000..4b239408 Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/bulbasaur/shiny_walk_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/sprites/charmander/default_idle_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/charmander/default_idle_8fps.gif new file mode 100644 index 00000000..1c21410a Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/charmander/default_idle_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/sprites/charmander/default_walk_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/charmander/default_walk_8fps.gif new file mode 100644 index 00000000..ff15b6d0 Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/charmander/default_walk_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/sprites/charmander/shiny_idle_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/charmander/shiny_idle_8fps.gif new file mode 100644 index 00000000..3d924b06 Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/charmander/shiny_idle_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/sprites/charmander/shiny_walk_8fps.gif b/ai-stuff/claude/mods/pokemon/sprites/charmander/shiny_walk_8fps.gif new file mode 100644 index 00000000..d3d38568 Binary files /dev/null and b/ai-stuff/claude/mods/pokemon/sprites/charmander/shiny_walk_8fps.gif differ diff --git a/ai-stuff/claude/mods/pokemon/tests/pokemon.test.ts b/ai-stuff/claude/mods/pokemon/tests/pokemon.test.ts new file mode 100644 index 00000000..ecebe7a2 --- /dev/null +++ b/ai-stuff/claude/mods/pokemon/tests/pokemon.test.ts @@ -0,0 +1,131 @@ +import { expect, mock, test } from 'claude-code/testing' + +const BAND = { + plugin: 'pokemon', + component: 'AbovePrompt', + requestId: 'band', + viewport: { columns: 120, rows: 40 }, + props: { + hasSurvey: false, + isWorking: false, + maxRows: 20, + bodyColumns: 120, + scroll: { offset: 0, bodyRows: 20 }, + view: {}, + }, +} as const + +const THEIRS = { type: 'Text', props: {}, children: ['drawn by Claude Code'] } + +function codePoints(cells: string): number[] { + const words = new Uint32Array(Uint8Array.fromBase64(cells).buffer) + return Array.from(words).filter((_, i) => i % 3 === 0) +} + +test('draws Abra by default as a half-block raster in the terminal band', async ($, on) => { + on('ui.render', () => THEIRS) + const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) + const raster = await ui.find({ key: 'pokemon' }) + expect(raster).toBeDefined() + expect(raster.props.columns).toBe(40) + expect(raster.props.rows).toBe(10) + expect(codePoints(raster.props.cells)).toContain(0x2580) +}) + +test('leaves the band alone on desktop and during a survey', async ($, on) => { + on('ui.render', () => THEIRS) + const desktop = await $.ui.mount({ ...BAND, surface: 'desktop' }) + expect(await desktop.find({ key: 'pokemon' })).toBeUndefined() + await desktop.unmount() + const survey = await $.ui.mount({ ...BAND, surface: 'terminal', props: { ...BAND.props, hasSurvey: true } }) + expect(await survey.find({ key: 'pokemon' })).toBeUndefined() +}) + +test('paces while Claude works', async ($, on) => { + const clock = mock.clock(on) + const blits: string[] = [] + on('ui.render', () => THEIRS) + on('ui.blit', ($, e) => { + blits.push(e.cells) + return { value: {} } + }) + on('session.start', () => ({ cwd: '/work' })) + on('command.register', () => ({ value: undefined })) + on('store.get', () => ({ value: undefined })) + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + await $.ui.mount({ ...BAND, surface: 'terminal', props: { ...BAND.props, isWorking: true } }) + await clock.advance(50 * 30) + expect(blits.length).toBe(30) + expect(new Set(blits).size).toBeGreaterThan(5) +}) + +test('/pokemon switches the mon and the variant, and saves both', async ($, on) => { + const saved = new Map() + on('ui.render', () => THEIRS) + on('store.set', ($, e) => { + saved.set(e.key, e.value) + return { value: undefined } + }) + + const mon = await $.command.run({ command: 'pokemon', args: 'bulbasaur' }) + expect(mon.text).toBe('Now showing default bulbasaur.') + const shiny = await $.command.run({ command: 'pokemon', args: 'shiny' }) + expect(shiny.text).toBe('Now showing shiny bulbasaur.') + expect(saved.get('mon')).toBe('bulbasaur') + expect(saved.get('variant')).toBe('shiny') + + const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) + expect((await ui.find({ key: 'pokemon' })).props.rows).toBe(9) +}) + +test('/pokemon rejects an unknown name', async ($) => { + const answer = await $.command.run({ command: 'pokemon', args: 'mewtwo' }) + expect(answer.text).toMatch(/^Unknown option "mewtwo"/) +}) + +test('/pokemon charmander draws Charmander', async ($, on) => { + on('ui.render', () => THEIRS) + on('store.set', () => ({ value: undefined })) + const answer = await $.command.run({ command: 'pokemon', args: 'charmander' }) + expect(answer.text).toBe('Now showing default charmander.') + const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) + expect((await ui.find({ key: 'pokemon' })).props.rows).toBe(9) +}) + +// The rightmost column that holds part of the sprite +function rightEdge(cells: string, columns: number): number { + const points = codePoints(cells) + let edge = -1 + points.forEach((cp, i) => { + if (cp !== 0x20) edge = Math.max(edge, i % columns) + }) + return edge +} + +test('walks back to the right edge after a turn and idles there', async ($, on) => { + const clock = mock.clock(on) + const blits: string[] = [] + on('ui.render', () => THEIRS) + on('ui.blit', ($, e) => { + blits.push(e.cells) + return { value: {} } + }) + on('session.start', () => ({ cwd: '/work' })) + on('command.register', () => ({ value: undefined })) + on('store.get', () => ({ value: undefined })) + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + + const idle = await $.ui.mount({ ...BAND, surface: 'terminal' }) + const home = rightEdge((await idle.find({ key: 'pokemon' })).props.cells, 40) + await idle.unmount() + + const busy = await $.ui.mount({ ...BAND, surface: 'terminal', props: { ...BAND.props, isWorking: true } }) + await clock.advance(150 * 10) + expect(rightEdge(blits[blits.length - 1], 40)).toBeLessThan(home) + await busy.unmount() + + await $.ui.mount({ ...BAND, surface: 'terminal' }) + await clock.advance(150 * 40) + expect(rightEdge(blits[blits.length - 1], 40)).toBe(home) +}) diff --git a/ai-stuff/claude/settings.json b/ai-stuff/claude/settings.json index b31d00fd..592ff5e4 100644 --- a/ai-stuff/claude/settings.json +++ b/ai-stuff/claude/settings.json @@ -261,7 +261,6 @@ "command": "~/.claude/scripts/subagent-statusline.sh" }, "enabledPlugins": { - "abra@dotfiles-mods": true, "context7@claude-plugins-official": false, "datadog@treatwell": true, "feature-dev@claude-plugins-official": false, @@ -271,7 +270,8 @@ "pyright-lsp@claude-plugins-official": false, "ralph-loop@claude-plugins-official": false, "skill-creator@claude-plugins-official": false, - "typescript-lsp@claude-plugins-official": false + "typescript-lsp@claude-plugins-official": false, + "pokemon@dotfiles-mods": true }, "extraKnownMarketplaces": { "agent-plugins-for-aws": { diff --git a/makefiles/claude.mk b/makefiles/claude.mk index f5b4cce7..7d0fbe52 100644 --- a/makefiles/claude.mk +++ b/makefiles/claude.mk @@ -4,7 +4,7 @@ # configs/templates live at ~/.config/ai-shared (make ai-shared) — # agents reference them there, so nothing tool-specific holds content. # This file only handles what is Claude Code-specific: agents, hook scripts, -# settings, and keybindings. +# settings, keybindings, and mods. CLAUDE_HOME := ${HOME}/.claude @@ -12,6 +12,9 @@ CLAUDE_AGENTS := $(notdir $(wildcard $(DOTFILES)/ai-stuff/agents/*.md)) CLAUDE_OUTPUT_STYLES := $(notdir $(wildcard $(DOTFILES)/ai-stuff/output-styles/*.md)) +CLAUDE_MODS_DIR := $(DOTFILES)/ai-stuff/claude/mods +CLAUDE_MODS := $(patsubst $(CLAUDE_MODS_DIR)/%/.claude-plugin/plugin.json,%,$(wildcard $(CLAUDE_MODS_DIR)/*/.claude-plugin/plugin.json)) + # Pre-ai-shared layout installed by the old claude.mk — pruned on install. CLAUDE_LEGACY := ${CLAUDE_HOME}/personas ${CLAUDE_HOME}/config ${CLAUDE_HOME}/templates \ $(addprefix ${CLAUDE_HOME}/agents/,gitboi.md jiragirl.md mega-dev.md) @@ -28,7 +31,7 @@ claude-cli: ## Install Claude Code with Anthropic's recommended native installer echo "Claude Code already installed at $(HOME)/.local/bin/claude"; \ fi -claude-dotfiles: claude-dirs claude-agents claude-output-styles claude-scripts claude-settings claude-keybindings ai-shared ai-claude ## Install Claude Code configuration only +claude-dotfiles: claude-dirs claude-agents claude-output-styles claude-scripts claude-settings claude-keybindings claude-mods ai-shared ai-claude ## Install Claude Code configuration only $(call pretty_print, "Pruning legacy ~/.claude symlinks...") @rm -rf $(CLAUDE_LEGACY) @@ -67,8 +70,16 @@ claude-keybindings: claude-dirs ## Symlink Claude Code keybindings.json $(call pretty_print, "Installing Claude Code keybindings...") $(call symlink,ai-stuff/claude/keybindings.json,${CLAUDE_HOME}/keybindings.json) +# Mods load in place from the dotfiles-mods directory marketplace, so edits +# apply on /reload-plugins. This registers the marketplace and installs each mod. +claude-mods: claude-settings ## Install Claude Code mods from ai-stuff/claude/mods + $(call pretty_print, "Installing Claude Code mods...") + @claude plugin marketplace update dotfiles-mods >/dev/null 2>&1 || claude plugin marketplace add "$(CLAUDE_MODS_DIR)" + @for m in $(CLAUDE_MODS); do claude plugin install "$$m@dotfiles-mods"; done + claude-clean: ai-clean-claude ## Remove Claude Code symlinks $(call pretty_print, "Removing Claude Code symlinks...") + @for m in $(CLAUDE_MODS); do claude plugin uninstall "$$m@dotfiles-mods" >/dev/null 2>&1 || true; done @for a in $(CLAUDE_AGENTS); do rm -f "${CLAUDE_HOME}/agents/$$a"; done @for s in $(CLAUDE_OUTPUT_STYLES); do rm -f "${CLAUDE_HOME}/output-styles/$$s"; done $(call remove_file,${CLAUDE_HOME}/scripts) @@ -76,4 +87,4 @@ claude-clean: ai-clean-claude ## Remove Claude Code symlinks $(call remove_file,${CLAUDE_HOME}/keybindings.json) @rm -rf $(CLAUDE_LEGACY) -.PHONY: claude claude-cli claude-dotfiles claude-dirs claude-agents claude-output-styles claude-scripts claude-settings claude-keybindings claude-clean +.PHONY: claude claude-cli claude-dotfiles claude-dirs claude-agents claude-output-styles claude-scripts claude-settings claude-keybindings claude-mods claude-clean