SLOPSHOPPER

pixel-pet

A pixel pet above the prompt that acts out each tool call, plus an HP/MP/ST HUD below it

newbandspinnerguardtoaststatus
★ 10v0.2.0MITupdated 2026-10-03Namenomeaning/pixel-pet/plugins/pixel-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-pet
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ pixel-pet: pixel-pet: Error: ENOENT: no such file /plugins/pixel-pet/assets/slime.json
README

<img src="docs/images/slime.gif" width="160" alt="The slime breathes, blinks, jumps, and cheers">

<h1 align="center">pixel-pet</h1>

A pixel pet for the Claude Code terminal. It acts out what Claude is doing,<br> and a game-style HUD below the prompt keeps your context and rate limits in view.

<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-5aa9ff"></a> <img alt="Claude Code 2.1.287 or later" src="https://img.shields.io/badge/Claude_Code-2.1.287+-ffe25a">

<img src="docs/images/demo.gif" width="806" alt="A session in the terminal: the slime reads, searches, sends out a mini for a subagent, fetches a page, edits, fails a test and fixes it, and cheers when the turn ends">

Install

You need Claude Code v2.1.287 or later (claude --version).

claude plugin marketplace add Namenomeaning/pixel-pet
claude plugin install pixel-pet@pixel-pet

Start a new session, or run /reload-plugins in an open one. The slime appears above the prompt.

To uninstall, run claude plugin uninstall pixel-pet@pixel-pet.

What the pet does

WhenThe pet
Claude is idleBreathes, blinks, and looks around. Falls asleep after the Sleep after time, a minute by default.
A turn startsJumps
A tool call endsRuns back and forth for 4 seconds
Claude thinks longerLooks around, with ? and dots
ReadReads a book: reading app.ts
Grep, GlobSweeps a magnifier: hunting for "useState"
Edit, MultiEdit, Write, NotebookEdit, TodoWriteWrites with a pen: editing app.ts
Bash, and any tool not listedTypes in a small terminal: $ npm test
WebFetch, WebSearchSpins a globe: fetching docs.anthropic.com
A subagent startsSmiles. A mini joins the trail behind the pet and stays until that subagent finishes. It leaves with a sparkle, or grey if the subagent failed.
A tool call failsx x eyes and a sweat drop
A turn endsCheers

The HUD

A pixel window below the prompt holds up to three bars:

  • ♥ HP is the context window left. It turns yellow at 50 % or less, red at 25 % or less, and shows /compact under 10 %.
  • ✦ MP is the 5-hour rate limit left, with the time to its reset, such as reset in 4h26m.
  • ◆ ST is the 7-day rate limit left, with the time to its reset, such as reset in 3d4h.

MP and ST turn red under 15 %. Each shows on Pro and Max plans once a response has reported its limit.

While the pet idles or thinks, its face follows the HUD: it sweats at 50 % HP or less, gets x x eyes at 25 % HP or less, and looks tired under 20 % MP or ST.

Make it yours

<img src="docs/images/pets.gif" width="440" alt="The default slime beside a rubber duck made with the pixel-pet skill">

Ask Claude in any session, or run /pixel-pet:pixel-pet. You can change:

  • the pet: "set up my pet as a rubber duck", "make the slime purple";
  • its props and effects: "a UFO tablet when it reads", "a radar dish for web searches", "no question mark when it thinks";
  • its minis: "a tiny saucer for each subagent";
  • the text: "alien status lines", "make the bash line red";
  • the HUD: "a green frame", "rename HP to FUEL", "hide ST";
  • the scene: "a moon surface with rocks to jump over", "grass and flowers behind the pet";
  • the settings below, such as speed and sleep.

Claude opens a preview in your browser first: every motion, face, status line, and HUD look, and the pet running through its scene, on a dark or a light background. Say what to change, and Claude redraws it. When you approve, the change shows above the prompt at once and stays for later sessions. To undo, ask for the slime back.

All of it lives in one theme, a JSON file documented in FORMAT.md. alien.json uses every field. To share a theme, ask Claude to save it as a file. To use someone else's, ask Claude to load their file.

Settings

In a session, run /plugin configure pixel-pet@pixel-pet.

SettingDefaultWhat it does
SpeednormalHow quickly the pet runs and animates: slow, normal, or fast.
Sleep after (seconds)60Idle time before the pet falls asleep. 0 keeps it awake.
HUDonThe HP, MP, and ST bars below the prompt.
Status lineonThe text beside the pet.
Name files and commandsonThe status line names the file, pattern, command, host, or search query a tool works on. Turn it off to share your screen.
Subagent minisonA mini behind the pet for each running subagent.

From a shell, pipe the values as JSON, then restart Claude Code:

echo '{"speed": "fast", "sleepAfter": "300"}' | claude plugin configure pixel-pet@pixel-pet --values-stdin

claude plugin configure pixel-pet@pixel-pet with no input lists the settings and which you have set. The install's "6 userConfig options not yet set" needs nothing from you: each setting has a default.

Update

claude plugin marketplace update pixel-pet
claude plugin update pixel-pet@pixel-pet

Then start a new session. Your pet and your settings carry over. A setting the new version doesn't know takes its default, and a pet drawn for an older version still draws.

Where it shows

The pet draws with colored text cells, so it works in any terminal with 24-bit color, without kitty graphics. The Desktop app's Code tab shows it as SVG. The VS Code chat panel, claude -p, and cloud sessions don't show it. The HUD shows only in the terminal.

Privacy and security

A mod runs inside Claude Code with your permissions. pixel-pet:

  • reads only its own theme files, polls session usage and the list of subagents, and draws;
  • registers three tools for changing the theme: get_theme returns the theme on screen, preview_theme writes a preview page to the path Claude gives it, and set_theme changes the theme and keeps it in the mod's own store;
  • makes no network requests, starts no processes, and reads no environment variables.

Its tool.call hook sees each tool's input and keeps only a short target for the status line. The status line shows the first line of a Bash command, cut to 24 characters; the Name files and commands setting turns that off.

To check this yourself, clone the repo and run claude plugin validate plugins/pixel-pet. Its calls: line lists everything the mod asks Claude Code to do.

To report a vulnerability, open a private security advisory.

Develop

.claude-plugin/marketplace.json   the repo is a marketplace with one plugin
plugins/pixel-pet/                the plugin: a Claude Code mod
  .claude-plugin/plugin.json      the plugin's name, version, and settings
  hooks/hooks.json                points Claude Code at register.tsx
  hooks/register.tsx              wires Claude Code's events to the modules below, and serves the tools
  hooks/anim.ts                   decides what the pet does on each tick, and when it leaps an obstacle
  hooks/pixels.ts                 draws a frame: body, eyes, props, effects, and minis
  hooks/theme.ts                  reads a theme and makes its pet's frames for every motion
  hooks/scene.ts                  lays out a theme's scene and draws the band with it
  hooks/preview.ts                writes the preview: every motion, face, scene, and frame of a pet
  hooks/status.ts                 the status line beside the pet
  hooks/hud.ts                    the HP, MP, and ST bars in their window
  hooks/minis.ts                  tracks a mini per subagent
  hooks/settings.ts               reads the settings
  hooks/*.test.ts                 the tests, one file per module
  types/index.d.ts                the mod's state, as Claude Code keeps it
  tsconfig.json                   type-checks the mod
  assets/slime.json               the default pet
  assets/duck.json                an example pet that faces to one side
  assets/alien.json               an example pet that uses every field
  skills/pixel-pet/               the skill that draws a pet with you, and the pet format
tools/preview/build.mjs           writes the preview of a theme file
tools/demo/record.mjs             records docs/images/demo.gif
docs/images/                      the images in this README

Load your working copy for one session with claude --plugin-dir ./plugins/pixel-pet. Saving a file reloads the mod. Before you open a pull request, run:

claude plugin validate . --strict
claude plugin validate plugins/pixel-pet --strict
claude plugin test plugins/pixel-pet

plugins/pixel-pet/tsconfig.json extends plugins/pixel-pet/.claude-plugin/types/tsconfig.json, which Claude Code writes the first time it loads the mod. After one --plugin-dir session, tsc -p plugins/pixel-pet type-checks the mod. The tests don't need it.

To see every motion, face, status line, HUD look, and frame of a pet, run node tools/preview/build.mjs [theme file] (Node 22.18 or later) and open tools/preview/preview.html. It writes the same preview preview_theme does, for the slime when you give no file, and prints the pet's resting frame and notes on anything it repaired.

To record docs/images/demo.gif again after a change to the band or the HUD, run node tools/demo/record.mjs (Node 22.18 or later). It plays a scripted session through the mod's modules and needs Google Chrome (or its path in CHROME) and ffmpeg.

Installed copies update only when version in plugins/pixel-pet/.claude-plugin/plugin.json changes, so bump it in every release. Contributor rules are in CLAUDE.md.

License

MIT

Author

Made by halluqinate. Say hi on X, Instagram, TikTok, or LinkedIn.

Source 11 files
hooks/register.tsx 405 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Anim, Mode } from '../types'
5import { TICK_MS, fail, leapClipMs, step } from './anim'
6import { BAR_W, HUD_WINDOW_W, frameColor, hudFrom, hudRows, mood, windowEdges } from './hud'
7import type { Hud } from './hud'
8import { minisOnScreen, reconcile } from './minis'
9import type { Mini } from './minis'
10import { animate, readTheme, restingFrame } from './theme'
11import { previewPage } from './preview'
12import { readSettings } from './settings'
13import { BODY_W, FACES, HEIGHT, MAX_MINIS, compose, crop, encodeCells, encodeSvg, trailWidth } from './pixels'
14import type { Body } from './pixels'
15import { GROUND_H, drawBand, layScene, obstacleSpans } from './scene'
16import type { SceneLayout } from './scene'
17import { lineColor, lineWidth, statusLine, targetOf, toolMode } from './status'
18import type { ToolMode } from './status'
19
20const ROWS = 10 // a cell is two pixels tall, so the frames are 20 px high
21const GROUND_ROWS = GROUND_H / 2
22const STATUS_ROOM = 20 // columns kept free beside a running pet for its status line
23const USAGE_EVERY_BEATS = 20
24const AGENTS_EVERY_BEATS = 5
25const SLOW_BEATS: Partial<Record<Mode, number>> = { idle: 2, sleep: 4 } // ticks per redraw while nothing moves fast
26
27const THEME_KEY = 'theme' // in $.store: the theme set_theme last took
28const OWN_TOOLS = 'mcp__pixel-pet__'
29// Literals, so `claude plugin validate` can read the hooks' matchers.
30const SET_THEME = 'mcp__pixel-pet__set_theme'
31const PREVIEW_THEME = 'mcp__pixel-pet__preview_theme'
32const GET_THEME = 'mcp__pixel-pet__get_theme'
33
34const anim = atom({ plugin: 'pixel-pet', key: 'anim' } as const, {
35  mode: 'idle',
36  since: 0,
37  x: 0,
38  dir: 1,
39  tick: 0,
40  target: '',
41  working: false,
42} as Anim)
43
44/** The HUD from fresh usage, or `last` when the usage call fails. */
45async function usageOr($: EngineInterface, now: number, last: Hud | undefined) {
46  try {
47    return hudFrom(await $.session.usage(), now)
48  } catch {
49    return last
50  }
51}
52
53/** The minis after a fresh look at the session's agents, or `last` when the list call fails. */
54async function minisOr($: EngineInterface, now: number, last: Mini[]) {
55  try {
56    return reconcile(last, await $.agent.list(), now)
57  } catch {
58    return last
59  }
60}
61
62/** What preview_theme and set_theme tell Claude about a theme's pet: the clips and faces made, the resting frame, and readTheme's notes. */
63function themeReport(pet: Body, notes: string[]) {
64  const made = `${Object.keys(pet.clips).join(', ')}, and ${FACES.length} faces`
65  const noted = notes.length > 0 ? `\n\nNotes, to act on or leave as drawn:\n- ${notes.join('\n- ')}` : ''
66
67  return `The mod made every clip and face from the sprite: ${made}.\n\nResting frame (@ is a pupil, * a cheek):\n${restingFrame(pet)}${noted}`
68}
69
70/** The default slime theme, from the plugin's own file. */
71async function slimeBody($: EngineInterface) {
72  const read = readTheme(JSON.parse(await $.fs.read(`${$.plugin.root}/assets/slime.json`)))
73  if (read.errors) {
74    throw new Error(`assets/slime.json: ${read.errors.join(' ')}`)
75  }
76
77  return animate(read.theme)
78}
79
80/** The theme set_theme kept in an earlier session, else the slime. A kept theme this version cannot read gives way to the slime, with a toast. */
81async function keptBody($: EngineInterface) {
82  const kept = await $.store.get(THEME_KEY)
83  if (kept !== undefined) {
84    const read = readTheme(kept)
85    if (!read.errors) {
86      return animate(read.theme)
87    }
88    $.ui.toast(`pixel-pet: your theme no longer reads (${read.errors[0]}). Showing the slime.`)
89  }
90
91  return slimeBody($)
92}
93
94export const register: Register = (on, options) => {
95  const settings = readSettings(options)
96  let isWorking = false
97  let lastToolAt = 0
98  let activeTools = 0
99  let activeMode: ToolMode = 'bash'
100  let activeTarget = ''
101  let bodyColumns = 80
102  let beat = 0
103  let showsError = false
104  let body: Body | undefined
105  let hud: Hud | undefined
106  let minis: Mini[] = []
107  let previewed: unknown // the last theme preview_theme drew, for set_theme to apply without resending it
108  let layout: SceneLayout | undefined // the scene of `layoutOf` on a band `bandWidth()` wide
109  let layoutOf: Body | undefined
110
111  // The band leaves the last column free, so a full row never wraps.
112  const bandWidth = () => Math.max(BODY_W, bodyColumns - 1)
113  /** The layout of the pet's scene on the band as wide as it is now, or undefined for a pet with no scene. */
114  const sceneLayout = (pet: Body) => {
115    if (!pet.scene) {
116      return undefined
117    }
118    if (layout?.width !== bandWidth() || layoutOf !== pet) {
119      layout = layScene(pet.scene, bandWidth())
120      layoutOf = pet
121    }
122    return layout
123  }
124
125  on('session.start', async ($, e, next) => {
126    const now = await $.clock.now()
127    await update($, anim, () => ({ mode: 'idle', since: now, x: 0, dir: 1, tick: 0, target: '', working: false }))
128    hud = await usageOr($, now, undefined)
129    try {
130      await $.tool.register({
131        name: 'preview_theme',
132        description:
133          'Writes the preview of a pixel-pet theme to `path`: an HTML page with every motion, face, status line, and HUD look of its pet, and the pet running through its scene. It does not change what is on screen. `theme` is in the format the `pixel-pet:pixel-pet` skill describes. Returns the resting frame and notes on anything repaired.',
134        inputSchema: {
135          type: 'object',
136          properties: {
137            theme: { type: 'object', description: 'The theme as a JSON object, in the format FORMAT.md documents.' },
138            path: { type: 'string', description: 'Absolute path of the HTML file to write, such as one in the temp folder.' },
139          },
140          required: ['theme', 'path'],
141        },
142      })
143      await $.tool.register({
144        name: 'set_theme',
145        description:
146          'Sets the pixel-pet theme: the pet, its props, minis, status lines, HUD look, and scene, at once, kept for later sessions. `theme` is in the format the `pixel-pet:pixel-pet` skill describes, or null for the default slime. Leave `theme` out to set the last theme preview_theme drew in this session. Returns the resting frame and notes on anything repaired.',
147        inputSchema: {
148          type: 'object',
149          properties: { theme: { type: ['object', 'null'], description: 'The theme as a JSON object, null for the default slime, or left out for the last preview.' } },
150        },
151      })
152      await $.tool.register({
153        name: 'get_theme',
154        description:
155          'Returns the pixel-pet theme on screen: the one set_theme kept, or the default slime\'s. Start a change from it, so set_theme keeps everything the change leaves alone.',
156        inputSchema: { type: 'object', properties: {} },
157      })
158    } catch {
159      // Without the tools the pet still draws; only changing the theme is missing.
160    }
161
162    $.clock.every(TICK_MS, async () => {
163      const t = await $.clock.now()
164      beat += 1
165      if (beat % USAGE_EVERY_BEATS === 0) {
166        hud = await usageOr($, t, hud)
167        $.ui.invalidate('ui.render')
168      }
169      if (settings.minis && beat % AGENTS_EVERY_BEATS === 0) {
170        minis = await minisOr($, t, minis)
171      }
172
173      const trail = trailWidth(minis.length)
174      const room = Math.max(0, bodyColumns - BODY_W - trail - STATUS_ROOM)
175      const scene = body && sceneLayout(body)
176      const obstacles = scene ? obstacleSpans(scene) : []
177      await update($, anim, a => {
178        const moved = step(a, { isWorking, activeTools, activeMode, activeTarget, lastToolAt, room, obstacles, trail }, t, settings)
179        // Minis hop on every tick, so they keep the redraw rate up while the pet idles.
180        const slowBeat = minis.length > 0 ? undefined : SLOW_BEATS[moved.mode]
181        return slowBeat !== undefined && moved.mode === a.mode && beat % slowBeat !== 0 ? a : moved
182      })
183    })
184
185    return next(e)
186  })
187
188  on('tool.call', async ($, e, next) => {
189    if (e.tool.startsWith(OWN_TOOLS)) {
190      return next(e)
191    }
192    const t = await $.clock.now()
193    activeTools += 1
194    activeMode = toolMode(e.tool)
195    const input = e as unknown as Record<string, unknown>
196    activeTarget = settings.targets ? targetOf(e.tool, input) : ''
197    lastToolAt = t
198
199    let result: Awaited<ReturnType<typeof next>>
200    try {
201      result = await next(e)
202    } finally {
203      activeTools = Math.max(0, activeTools - 1)
204      lastToolAt = await $.clock.now()
205    }
206    if ('deny' in result || ('isError' in result && result.isError)) {
207      await update($, anim, a => fail(a, lastToolAt))
208    }
209
210    return result
211  })
212
213  on('tool.call', { tool: PREVIEW_THEME }, async ($, e) => {
214    const { theme, path } = e as unknown as { theme?: unknown; path?: unknown }
215    const read = readTheme(theme)
216    if (read.errors) {
217      return { deny: `No preview was written: ${read.errors.join(' ')}` }
218    }
219    if (typeof path !== 'string' || path === '') {
220      return { deny: 'No preview was written: `path` is the HTML file to write.' }
221    }
222    const preview = animate(read.theme)
223    await $.fs.write(path, previewPage(preview, read.notes))
224    previewed = theme
225
226    return { result: `Wrote the preview of ${read.theme.name} to ${path}. What is on screen has not changed.\n\n${themeReport(preview, read.notes)}` }
227  })
228
229  on('tool.call', { tool: GET_THEME }, async $ => {
230    const kept = await $.store.get(THEME_KEY)
231    if (kept === undefined) {
232      return { result: `No theme is kept, so the slime is on screen. Its theme:\n\n${await $.fs.read(`${$.plugin.root}/assets/slime.json`)}` }
233    }
234
235    return { result: `The theme set_theme kept:\n\n${JSON.stringify(kept, null, 2)}` }
236  })
237
238  on('tool.call', { tool: SET_THEME }, async ($, e) => {
239    const given = (e as unknown as { theme?: unknown }).theme
240    if (given === undefined && previewed === undefined) {
241      return { deny: 'The theme was not set: no preview_theme call in this session to apply. Pass `theme`.' }
242    }
243    const value = given === undefined ? previewed : given
244    if (value === null) {
245      await $.store.delete(THEME_KEY)
246      body = await slimeBody($)
247      $.ui.invalidate('ui.render')
248
249      return { result: 'The slime is back, for this session and later ones.' }
250    }
251    const read = readTheme(value)
252    if (read.errors) {
253      return { deny: `The theme was not set: ${read.errors.join(' ')}` }
254    }
255    await $.store.set(THEME_KEY, value)
256    body = animate(read.theme)
257    $.ui.invalidate('ui.render')
258
259    return { result: `The ${read.theme.name} theme is on screen now, for this session and later ones.\n\n${themeReport(body, read.notes)}` }
260  })
261
262  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
263    if (!settings.hud || !hud || e.surface !== 'terminal') {
264      return next(e)
265    }
266    if (!body) {
267      body = await keptBody($)
268    }
269    const rows = hudRows(hud, body.look.hud)
270    if (rows.length === 0) {
271      return next(e)
272    }
273    const { Box, Raster, Text } = $.ui.resolve(e)
274    const edges = windowEdges(HUD_WINDOW_W)
275    const frame = frameColor(body.look.hud)
276
277    return (
278      <Box flexDirection="column">
279        {await next(e)}
280        <Box flexDirection="column" marginLeft={1}>
281          <Text color={frame}>{edges.top}</Text>
282          {rows.map(r => (
283            <Box key={r.key}>
284              <Text color={frame}>{edges.side}</Text>
285              <Box width={HUD_WINDOW_W - 2} paddingLeft={1}>
286                <Text color={r.color}>{r.label} </Text>
287                <Raster key={`bar-${r.key}`} columns={BAR_W} rows={1} cells={r.cells} />
288                {r.parts.map((p, i) => (
289                  <Text key={String(i)} color={p.color} bold={p.bold} wrap="truncate">
290                    {p.text}
291                  </Text>
292                ))}
293              </Box>
294              <Text color={frame}>{edges.side}</Text>
295            </Box>
296          ))}
297          <Text color={frame}>{edges.bottom}</Text>
298        </Box>
299      </Box>
300    )
301  })
302
303  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
304    try {
305      if (e.props.hasSurvey) {
306        return next(e)
307      }
308      isWorking = e.props.isWorking
309      bodyColumns = e.props.bodyColumns
310
311      if (!body) {
312        body = await keptBody($)
313      }
314      const a = await read($, anim)
315      const now = await $.clock.now()
316      const elapsed = now - a.since
317      const views = minisOnScreen(minis, now)
318      // A leap is the run mode playing the jump clip, slowed, while the pet travels.
319      const drawn = a.leap ? { mode: 'jump' as const, ms: leapClipMs((now - a.leap.since) * settings.pace) } : { mode: a.mode, ms: elapsed * settings.pace }
320      const picture = compose(body, drawn.mode, drawn.ms, a.dir, hud ? mood(hud) : 'ok', views)
321      const extra = views.length > MAX_MINIS ? ` (+${views.length - MAX_MINIS} minis)` : ''
322      const line = settings.statusLine ? statusLine(a.mode, a.since, elapsed, a.target, body.look.lines[a.mode]) + extra : ''
323      if (showsError) {
324        showsError = false
325        $.ui.status(undefined)
326      }
327
328      const scene = sceneLayout(body)
329      if (e.surface === 'terminal' && scene && body.scene) {
330        const { Box, Raster, Text } = $.ui.resolve(e)
331        const width = scene.width
332        const textW = line ? lineWidth(line) + 3 : 0
333        const left = Math.max(0, Math.min(Math.round(a.x), width - picture.w - textW))
334        const band = drawBand(body, body.scene, scene, picture, left, now)
335        const cells = (x: number, y: number, w: number, h: number) => encodeCells(crop(band, x, y, w, h))
336        // The status line cuts a hole in the band; the band shows above, below, and right of it.
337        const textAt = left + picture.w
338        const shown = Math.min(textW, width - textAt)
339        const rest = width - textAt - shown
340
341        return (
342          <Box flexDirection="column" height={ROWS + GROUND_ROWS}>
343            <Box height={ROWS}>
344              <Raster key="pet" columns={textAt} rows={ROWS} cells={cells(0, 0, textAt, HEIGHT)} />
345              {shown > 0 && (
346                <Box key="line" flexDirection="column" width={shown}>
347                  <Raster key="above" columns={shown} rows={ROWS - 2} cells={cells(textAt, 0, shown, HEIGHT - 4)} />
348                  <Text color={lineColor(a.mode, body.look.lineColors)} bold wrap="truncate">
349                    {` › ${line}`}
350                  </Text>
351                  <Raster key="below" columns={shown} rows={1} cells={cells(textAt, HEIGHT - 2, shown, 2)} />
352                </Box>
353              )}
354              {rest > 0 && <Raster key="rest" columns={rest} rows={ROWS} cells={cells(textAt + shown, 0, rest, HEIGHT)} />}
355            </Box>
356            <Raster key="ground" columns={width} rows={GROUND_ROWS} cells={cells(0, HEIGHT, width, GROUND_H)} />
357          </Box>
358        )
359      }
360      if (e.surface === 'terminal') {
361        const { Box, Raster, Text } = $.ui.resolve(e)
362        const room = Math.max(0, bodyColumns - picture.w - line.length - 4)
363
364        return (
365          <Box height={ROWS}>
366            <Box marginLeft={Math.min(Math.round(a.x), room)} alignItems="flex-end">
367              <Raster key="pet" columns={picture.w} rows={ROWS} cells={encodeCells(picture)} />
368              {line && (
369                <Box marginBottom={1} marginLeft={1}>
370                  <Text color={lineColor(a.mode, body.look.lineColors)} bold>
371                    › {line}
372                  </Text>
373                </Box>
374              )}
375            </Box>
376          </Box>
377        )
378      }
379      if (e.surface === 'desktop') {
380        const { Box, Svg, Text } = $.ui.resolve(e)
381
382        return (
383          <Box alignItems="flex-end">
384            <Box marginLeft={Math.round(a.x)}>
385              <Svg source={encodeSvg(picture)} alt={`${body.name}, ${a.mode}`} width={picture.w * 4} height={80} />
386            </Box>
387            {line && (
388              <Text color={lineColor(a.mode, body.look.lineColors)} bold>
389                {line}
390              </Text>
391            )}
392          </Box>
393        )
394      }
395
396      return next(e)
397    } catch (err) {
398      showsError = true
399      $.ui.status(`pixel-pet: ${String(err)}`)
400
401      return next(e)
402    }
403  })
404}
405
hooks/anim.ts 151 lines
1import type { Anim, Leap, Mode } from '../types'
2import { BODY_W, MODES } from './pixels'
3import type { Span } from './scene'
4import { DEFAULTS } from './settings'
5import type { Settings } from './settings'
6import type { ToolMode } from './status'
7
8export const TICK_MS = 100
9const SPEED = 9 // cells per second at normal pace
10const RUN_AFTER_TOOL_MS = 4000
11const JUMP_YIELDS_MS = 400 // a tool call cuts the start-of-turn hop short after this long
12const JUMP_MS = MODES.jump.once as number
13/** A leap plays the jump clip this much slower, so the pet clears its own width while in the air. */
14const LEAP_SLOW = 4 / 3
15export const LEAP_MS = JUMP_MS * LEAP_SLOW
16// The part of the jump clip in which a leaping pet travels: from the frame it has lifted 5 px of 9 to the one it
17// has come down to 6. The pet crouches before it and drops straight down after it.
18const AIR = [3 / 14, 9 / 14] as const
19const LEAP_GAP = 2 // the most columns between the body canvas and an obstacle when the pet takes off
20
21/** What the session is doing, as the hooks last saw it. */
22export type Activity = {
23  isWorking: boolean
24  activeTools: number
25  activeMode: ToolMode // the latest tool call's mode
26  activeTarget: string
27  lastToolAt: number
28  room: number // the furthest column a running pet may reach
29  obstacles: Span[] // the scene's obstacles, in band columns
30  trail: number // the minis' width, drawn behind the pet
31}
32
33/** The mode the pet holds while nothing starts or ends. */
34function settle(w: Activity, t: number): Mode {
35  if (!w.isWorking) {
36    return 'idle'
37  }
38  if (w.activeTools > 0) {
39    return w.activeMode
40  }
41
42  return t - w.lastToolAt < RUN_AFTER_TOOL_MS ? 'run' : 'think'
43}
44
45/**
46 * The pet one tick later, at time `t`. A turn starting makes it jump and a turn ending makes it cheer. A mode
47 * with a fixed length (jump, cheer, error) runs out before the pet settles, sooner at a faster pace. Idle turns
48 * to sleep after `sleepAfterMs`. A running pet moves and turns at `room`; at an obstacle it leaps, or turns when
49 * the landing is past the edge. A leap plays out before anything else changes. An `a` saved by another version
50 * of the mod, with a mode this one lacks, starts over idle.
51 */
52export function step(a: Anim, w: Activity, t: number, s: Pick<Settings, 'pace' | 'sleepAfterMs'> = DEFAULTS): Anim {
53  if (a.leap) {
54    const u = (t - a.leap.since) * s.pace
55    // Nothing else starts mid-air: `working` holds, so a turn that ended or began still cheers or jumps on landing.
56    if (u < LEAP_MS) {
57      return { ...a, x: leapX(a.leap, u), tick: a.tick + 1 }
58    }
59    const { leap: _, ...landed } = a
60    a = { ...landed, x: a.leap.to }
61  }
62  let { mode, since, x, dir } = a
63  let leap: Leap | undefined
64  if (!(mode in MODES)) {
65    mode = 'idle'
66    since = t
67  }
68  const length = MODES[mode].once
69  const once = length === undefined ? undefined : length / s.pace
70
71  if (a.working && !w.isWorking) {
72    mode = 'cheer'
73    since = t
74  } else if (!a.working && w.isWorking) {
75    mode = 'jump'
76    since = t
77  } else if (once !== undefined) {
78    const yields = mode === 'jump' && w.activeTools > 0 && t - since >= JUMP_YIELDS_MS / s.pace
79    if (t - since >= once || yields) {
80      mode = settle(w, t)
81      since = t
82    }
83  } else {
84    let want = settle(w, t)
85    const canSleep = s.sleepAfterMs > 0
86    if (want === 'idle' && canSleep && (mode === 'sleep' || (mode === 'idle' && t - since >= s.sleepAfterMs))) {
87      want = 'sleep'
88    }
89    if (want !== mode) {
90      mode = want
91      since = t
92    }
93  }
94
95  const ahead = mode === 'run' ? obstacleAhead(x, dir, w) : undefined
96  if (ahead) {
97    const to = landing(ahead, dir, w)
98    if (to >= 0 && to <= w.room) {
99      leap = { since: t, from: x, to }
100    } else {
101      dir = dir === 1 ? -1 : 1
102    }
103  } else if (mode === 'run') {
104    x += (dir * SPEED * s.pace * TICK_MS) / 1000
105    if (x >= w.room) {
106      x = w.room
107      dir = -1
108    } else if (x <= 0) {
109      x = 0
110      dir = 1
111    }
112  }
113
114  return { mode, since, x, dir, tick: a.tick + 1, working: w.isWorking, target: w.activeTools > 0 ? w.activeTarget : a.target, ...(leap && { leap }) }
115}
116
117// The body canvas holds every pose, the crouch and the landing dust included, so a leap measured on it clears the
118// obstacle at every frame.
119/** The first column of the body canvas, for a picture at column `x`: the trail is on its left unless it faces left. */
120const bodyAt = (x: number, dir: 1 | -1, w: Activity) => x + (dir === 1 ? w.trail : 0)
121
122/** The obstacle just ahead of a running pet, close enough to leap now. */
123function obstacleAhead(x: number, dir: 1 | -1, w: Activity) {
124  const left = bodyAt(x, dir, w)
125  const right = left + BODY_W
126  return w.obstacles.find(o => {
127    const gap = dir === 1 ? o.x - right : left - (o.x + o.w)
128    return gap >= 0 && gap <= LEAP_GAP
129  })
130}
131
132/** Where a leap over `o` lands: the picture's column with the body canvas one column past the obstacle. */
133function landing(o: Span, dir: 1 | -1, w: Activity) {
134  const body = dir === 1 ? o.x + o.w + 1 : o.x - 1 - BODY_W
135  return body - bodyAt(0, dir, w)
136}
137
138/** The picture's column `u` ms (at pace) into `leap`. */
139export function leapX(leap: Leap, u: number) {
140  const p = Math.min(1, Math.max(0, (u / LEAP_MS - AIR[0]) / (AIR[1] - AIR[0])))
141  return leap.from + (leap.to - leap.from) * p
142}
143
144/** How far into the jump clip a leap `u` ms (at pace) in has come, at the clip's own speed. */
145export const leapClipMs = (u: number) => u / LEAP_SLOW
146
147/** The pet after a failed tool call: an error face, unless it is cheering. */
148export function fail(a: Anim, t: number): Anim {
149  return a.mode === 'cheer' ? a : { ...a, mode: 'error', since: t }
150}
151
hooks/hud.ts 184 lines
1import type { SessionUsage } from 'claude-code'
2
3import { encodeCells } from './pixels'
4import type { Canvas } from './pixels'
5
6export const BAR_W = 20 // cells; a bar is one cell row, two pixels tall
7
8// The window frame around the HUD. Blue, not white, so it shows on a light terminal too.
9export const FRAME_COLOR = '#5aa9ff'
10
11export type Hud = {
12  hp: number // context left, %
13  mp?: number // the 5-hour rate limit left, %; absent off a subscription or before its first reading
14  mpResetsInMin?: number
15  st?: number // the 7-day rate limit left, %; absent like MP
16  stResetsInMin?: number
17}
18
19export type Mood = 'ok' | 'worried' | 'critical' | 'tired'
20
21/** A pet's look for one bar: its label, the label's color, and the fill while the bar is healthy. */
22export type BarLook = { label?: string; color?: string; fill?: [string, string] }
23/** A pet's look for the HUD: the frame's color, and each bar's look, or false to hide it. */
24export type HudLook = { frame?: string; hp?: BarLook | false; mp?: BarLook | false; st?: BarLook | false }
25
26/** The frame's color: the pet's own, else the mod's. */
27export const frameColor = (look: HudLook) => look.frame ?? FRAME_COLOR
28
29const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
30const finite = (v: number | undefined) => (v !== undefined && Number.isFinite(v) ? v : undefined)
31
32/** One rate-limit window: the share left, and the minutes until it resets. Both absent without a reading. */
33function limitLeft(u: SessionUsage, kind: string, now: number) {
34  const limit = u.rateLimits.find(r => r.kind === kind && finite(r.percentUsed) !== undefined)
35  const resets = limit?.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
36
37  return {
38    left: limit ? clamp(Math.round(100 - limit.percentUsed), 0, 100) : undefined,
39    resetsInMin: !limit || Number.isNaN(resets) ? undefined : Math.max(0, Math.round((resets - now) / 60000)),
40  }
41}
42
43export function hudFrom(u: SessionUsage, now: number): Hud {
44  const mp = limitLeft(u, 'five_hour', now)
45  const st = limitLeft(u, 'seven_day', now)
46
47  return {
48    hp: clamp(Math.round(100 - (finite(u.context.percent) ?? 0)), 0, 100),
49    mp: mp.left,
50    mpResetsInMin: mp.resetsInMin,
51    st: st.left,
52    stResetsInMin: st.resetsInMin,
53  }
54}
55
56export function mood(h: Hud): Mood {
57  if (h.hp <= 25) {
58    return 'critical'
59  }
60  if (h.hp <= 50) {
61    return 'worried'
62  }
63
64  return [h.mp, h.st].some(left => left !== undefined && left < 20) ? 'tired' : 'ok'
65}
66
67/** Minutes as the HUD prints them: `36m`, `2h13m`, then whole hours past a day, `3d4h`. */
68export function fmtMin(m: number) {
69  if (m >= 1440) {
70    return `${Math.floor(m / 1440)}d${Math.floor((m % 1440) / 60)}h`
71  }
72
73  return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
74}
75
76type Pair = [number, number]
77const GREEN: Pair = [0x166534, 0x4ade80]
78const YELLOW: Pair = [0xa16207, 0xfacc15]
79const RED: Pair = [0x991b1b, 0xf87171]
80const BLUE: Pair = [0x3b5bdb, 0xa78bfa]
81const GOLD: Pair = [0xb45309, 0xfbbf24]
82
83const channel = (c: number, shift: number) => (c >> shift) & 255
84const mix = (a: number, b: number, t: number) =>
85  [16, 8, 0].reduce((out, s) => out | (Math.round(channel(a, s) + (channel(b, s) - channel(a, s)) * t) << s), 0)
86const darker = (c: number) => [16, 8, 0].reduce((out, s) => out | (Math.round(channel(c, s) * 0.6) << s), 0)
87
88/** A bevelled bar: grey end caps, a gradient fill with a darker lower half. */
89export function barCanvas(pct: number, [from, to]: Pair): Canvas {
90  const px = new Array<number>(BAR_W * 2).fill(0x1b1e26)
91  const fill = Math.round((clamp(pct, 0, 100) / 100) * (BAR_W - 2))
92  for (let i = 0; i < fill; i++) {
93    const c = mix(from, to, i / (BAR_W - 3))
94    px[1 + i] = c
95    px[BAR_W + 1 + i] = darker(c)
96  }
97  for (const x of [0, BAR_W - 1]) {
98    px[x] = 0x6f7787
99    px[BAR_W + x] = 0x6f7787
100  }
101
102  return { w: BAR_W, h: 2, px }
103}
104
105/** One run of a row's text. The reading is bold in its bar's color; the details beside it are grey. */
106export type HudPart = { text: string; color: string; bold?: boolean }
107
108export type HudRow = { key: string; label: string; color: string; cells: string; parts: HudPart[] }
109
110// A mid grey, so the details read on a dark terminal and on a light one.
111export const DETAIL_COLOR = '#8b93b8'
112
113const cssColor = (c: number) => `#${c.toString(16).padStart(6, '0')}`
114const reading = (text: string, [, bright]: Pair): HudPart => ({ text: ` ${text}`, color: cssColor(bright), bold: true })
115const detail = (bits: (string | undefined)[]): HudPart[] => {
116  const text = bits.filter(b => b !== undefined).join('  ')
117
118  return text ? [{ text: `  ${text}`, color: DETAIL_COLOR }] : []
119}
120
121const resets = (min: number | undefined) => (min === undefined ? undefined : `reset in ${fmtMin(min)}`)
122
123const pairOf = (fill: [string, string] | undefined, fallback: Pair): Pair => (fill ? [parseInt(fill[0].slice(1), 16), parseInt(fill[1].slice(1), 16)] : fallback)
124
125/**
126 * The HUD's rows: HP, and MP and ST when the session has their readings, each in the pet's `look` unless
127 * the look hides it. A bar's warning colors (yellow and red) replace its fill whatever the look.
128 */
129export function hudRows(h: Hud, look: HudLook = {}): HudRow[] {
130  const rows: (HudRow & { look: BarLook | false | undefined })[] = []
131  const hpFill = h.hp > 50 ? pairOf(look.hp ? look.hp.fill : undefined, GREEN) : h.hp > 25 ? YELLOW : RED
132  rows.push({
133    key: 'hp',
134    look: look.hp,
135    label: h.hp < 10 ? '⚠ HP' : '♥ HP',
136    color: '#f87171',
137    cells: encodeCells(barCanvas(h.hp, hpFill)),
138    parts: [reading(`${h.hp}%`, hpFill), ...(h.hp < 10 ? [{ ...reading('/compact', RED), text: '  /compact' }] : [])],
139  })
140  if (h.mp !== undefined) {
141    const mpFill = h.mp < 15 ? RED : pairOf(look.mp ? look.mp.fill : undefined, BLUE)
142    rows.push({
143      key: 'mp',
144      look: look.mp,
145      label: '✦ MP',
146      color: '#7aa7ff',
147      cells: encodeCells(barCanvas(h.mp, mpFill)),
148      parts: [reading(`${h.mp}%`, mpFill), ...detail([resets(h.mpResetsInMin)])],
149    })
150  }
151  if (h.st !== undefined) {
152    const stFill = h.st < 15 ? RED : pairOf(look.st ? look.st.fill : undefined, GOLD)
153    rows.push({
154      key: 'st',
155      look: look.st,
156      label: '◆ ST',
157      color: '#fbbf24',
158      cells: encodeCells(barCanvas(h.st, stFill)),
159      parts: [reading(`${h.st}%`, stFill), ...detail([resets(h.stResetsInMin)])],
160    })
161  }
162  const shown = rows
163    .filter(r => r.look !== false)
164    .map(({ look: bar, ...r }) => (bar ? { ...r, label: bar.label ?? r.label, color: bar.color ?? r.color } : r))
165  // Labels pad to one width, so the bars line up.
166  const width = Math.max(0, ...shown.map(r => [...r.label].length))
167
168  return shown.map(r => ({ ...r, label: r.label + ' '.repeat(width - [...r.label].length) }))
169}
170
171/**
172 * The window's edges as text, `width` cells across, side cells included. A full block is one pixel wide
173 * and a half block one pixel tall, so the lines are as thick as the pet's pixels. The corner cells stay
174 * empty, which notches each corner by one pixel.
175 */
176export function windowEdges(width: number) {
177  const run = Math.max(0, width - 2)
178
179  return { top: ` ${'▄'.repeat(run)} `, side: '█', bottom: ` ${'▀'.repeat(run)} ` }
180}
181
182/** The HUD window's width in cells, sides included: room for a label, a bar and the longest row's text. */
183export const HUD_WINDOW_W = 54
184
hooks/minis.ts 39 lines
1import type { AgentInfo } from 'claude-code'
2
3import type { MiniView } from './pixels'
4
5/** A subagent's mini: it joins when the agent starts and leaves `LEAVE_MS` after the agent ends. */
6export type Mini = { id: string; since: number; doneAt?: number; failed?: boolean }
7
8const LEAVE_MS = 1500
9
10const ALIVE = new Set(['running', 'pending'])
11
12/** The minis after a look at the session's agents at time `t`. */
13export function reconcile(minis: Mini[], agents: AgentInfo[], t: number): Mini[] {
14  const byId = new Map(agents.map(a => [a.id, a]))
15  const kept = minis.flatMap((m): Mini[] => {
16    if (m.doneAt !== undefined) {
17      return t - m.doneAt < LEAVE_MS ? [m] : []
18    }
19    const a = byId.get(m.id)
20    if (a && ALIVE.has(a.status)) {
21      return [m]
22    }
23
24    // An agent gone from the list counts as finished, not failed.
25    return [{ ...m, doneAt: t, failed: a !== undefined && a.status !== 'completed' }]
26  })
27  const known = new Set(minis.map(m => m.id))
28  const born = agents.filter(a => ALIVE.has(a.status) && !known.has(a.id)).map(a => ({ id: a.id, since: t }))
29
30  return [...kept, ...born]
31}
32
33/** The minis to draw at time `t`, as compose takes them: every running one, and every one that ended under `LEAVE_MS` ago. */
34export function minisOnScreen(minis: Mini[], t: number): MiniView[] {
35  return minis
36    .filter(m => m.doneAt === undefined || t - m.doneAt < LEAVE_MS)
37    .map(m => ({ age: t - m.since, doneFor: m.doneAt === undefined ? undefined : t - m.doneAt, failed: m.failed }))
38}
39
hooks/theme.ts 515 lines
1import type { Mode } from '../types'
2import type { BarLook, HudLook } from './hud'
3import { BODY_W, EYE_COLOR, HEIGHT, MINI_SIZE, MODES, PROP_W } from './pixels'
4import type { Body, BodyFrame, Look } from './pixels'
5import { EVERY, SCENE_SIZE } from './scene'
6import type { Scene } from './scene'
7
8/** A theme as its file spells it: the pet's sprite and everything else it changes. `skills/pixel-pet/FORMAT.md` documents each field for the people who write one. */
9export type Theme = {
10  name: string
11  scale: number
12  sprite: string[]
13  palette: Record<string, string>
14  outline?: string
15  eyes?: [[number, number], [number, number]] // absent: no eyes and no faces
16  eyeColor: string
17  cheeks?: [[number, number], [number, number]]
18  cheekColor?: string
19  mini: { top: string; body: string; edge: string }
20  miniSprite?: string[]
21  props: Body['props']
22  scene?: Scene
23} & Look
24
25// The frames mark cheeks and sparkles, and the resting frame pupils, with characters a palette may not use.
26const CHEEK_MARK = '*'
27const SPARKLE_MARK = '+'
28const PUPIL_MARK = '@'
29const RESERVED = ['.', CHEEK_MARK, SPARKLE_MARK, PUPIL_MARK]
30const SPARKLE_COLOR = 0xffe25a
31const SLIME_MINI = { top: '#9ad2ff', body: '#3d84f0', edge: '#1e3a8a' }
32const PROP_SIZE = { w: PROP_W, h: HEIGHT }
33const MAX_PROP_FRAMES = 8
34const MAX_LINE = 40 // characters in a status line, so it fits beside the pet
35const MAX_LABEL = 6 // characters in a HUD label, so the HUD fits its window
36const BARS = ['hp', 'mp', 'st'] as const
37
38// Each pose squashes the sprite by sx, sy and lifts it by dy pixels, all before `scale`. The run and jump poses
39// also carry an eye hint. Tuned on the slime; any pet that fits the canvas reuses them.
40type Pose = [sx: number, sy: number, dy: number, hint?: string]
41const STAND: Pose[] = Array.from({ length: 16 }, (_, i) => {
42  const s = (1 - Math.cos((2 * Math.PI * i) / 16)) / 2
43  return [1 + 0.06 * s, 1 - 0.09 * s, 0]
44})
45const RUN: Pose[] = [[1.18, 0.74, 0, 'open'], [0.86, 1.22, 1, 'wide'], [0.9, 1.15, 4, 'wide'], [0.95, 1.05, 6, 'open'], [0.92, 1.12, 4, 'open'], [0.9, 1.18, 1, 'open'], [1.22, 0.7, 0, 'bar'], [1.08, 0.9, 0, 'open']]
46const JUMP: Pose[] = [[1.15, 0.8, 0, 'open'], [1.22, 0.7, 0, 'bar'], [0.85, 1.25, 2, 'wide'], [0.88, 1.2, 5, 'wide'], [0.92, 1.12, 8, 'wide'], [0.96, 1.04, 9, 'open'], [1, 1, 9, 'open'],
47  [0.96, 1.04, 8, 'open'], [0.92, 1.1, 6, 'open'], [0.9, 1.15, 3, 'open'], [0.9, 1.2, 1, 'open'], [1.25, 0.68, 0, 'bar'], [1.12, 0.85, 0, 'bar'], [1.04, 0.95, 0, 'open']]
48const THINK: Pose[] = Array.from({ length: 12 }, (_, i) => {
49  const wobble = 1 + 0.03 * Math.sin((2 * Math.PI * i) / 6)
50  return [wobble, 2 - wobble, 0]
51})
52const CHEER: Pose[] = [[1, 1, 0], [1.18, 0.8, 0], [0.9, 1.18, 2], [0.95, 1.08, 4], [0.98, 1.02, 4], [0.92, 1.12, 2], [1.2, 0.76, 0], [0.88, 1.2, 2], [0.95, 1.08, 4],
53  [0.98, 1.02, 4], [0.92, 1.12, 2], [1.2, 0.76, 0], [0.88, 1.2, 2], [0.95, 1.08, 4], [0.98, 1.02, 4], [0.92, 1.12, 2], [1.2, 0.76, 0], [1, 1, 0]]
54const POSES = [...STAND, ...RUN, ...JUMP, ...THINK, ...CHEER]
55
56function sparkle(x: number, y: number, isBig: boolean): [number, number][] {
57  return isBig ? [[x, y], [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]] : [[x, y]]
58}
59
60const cheerSparkles = (i: number) =>
61  i % 3 ? [...sparkle(2, 8, i % 2 === 0), ...sparkle(16, 6, i % 2 === 1)] : [...sparkle(3, 6, true), ...sparkle(15, 9, false)]
62
63// Round half to even. The frames theme.test.ts pins depend on it.
64function roundHalfEven(v: number) {
65  const r = Math.round(v)
66  return Math.abs(v % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r
67}
68
69/** The largest sprite, in pixels, that fits the canvas in every pose at `scale`. */
70export function maxSize(scale: number) {
71  return {
72    w: Math.floor(Math.min(...POSES.map(([sx]) => BODY_W / (sx * scale))) + 1e-9),
73    h: Math.floor(Math.min(...POSES.map(([, sy, dy]) => (HEIGHT / scale - dy) / sy)) + 1e-9),
74  }
75}
76
77const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
78const isPoint = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(n => Number.isInteger(n))
79const isPair = (v: unknown): v is [[number, number], [number, number]] => Array.isArray(v) && v.length === 2 && v.every(isPoint)
80
81/** `#rrggbb` from `#rrggbb` or `#rgb`, or undefined for anything else. */
82function color(v: unknown) {
83  if (typeof v !== 'string') {
84    return undefined
85  }
86  if (/^#[0-9a-fA-F]{6}$/.test(v)) {
87    return v.toLowerCase()
88  }
89
90  return /^#[0-9a-fA-F]{3}$/.test(v) ? `#${[...v.slice(1)].map(c => c + c).join('')}`.toLowerCase() : undefined
91}
92
93/** The largest scale, up to `scale`, at which a w×h sprite fits the canvas in every pose. */
94function fitScale(w: number, h: number, scale: number) {
95  const fits = Math.min(scale, ...POSES.map(([sx, sy, dy]) => Math.min(BODY_W / (sx * w), HEIGHT / (dy + sy * h))))
96
97  return fits < scale ? Math.floor(fits * 100) / 100 : scale
98}
99
100/** Rows drawn in the pet's palette, cut to `max`, or undefined when `v` is not rows. Notes say what changed. */
101function paletteRows(v: unknown, max: { w: number; h: number }, palette: Record<string, string>, what: string, notes: string[]) {
102  if (!Array.isArray(v) || !v.some(r => typeof r === 'string' && r.length > 0)) {
103    notes.push(`${what} is not a list of text rows, so it is left out.`)
104    return undefined
105  }
106  let rows = v.map(r => (typeof r === 'string' ? r : ''))
107  const w = Math.max(...rows.map(r => r.length))
108  if (w > max.w || rows.length > max.h) {
109    notes.push(`${what} is ${w}×${rows.length}, past the largest, ${max.w}×${max.h}, so its bottom-left part is kept.`)
110    rows = rows.slice(-max.h).map(r => r.slice(0, max.w))
111  }
112  rows = rows.map(r => r.padEnd(Math.min(w, max.w), '.'))
113  if (rows.some(r => /[*+@]/.test(r))) {
114    notes.push(`In ${what}, "*", "+", "@" pixels are drawn clear: the mod marks its own drawings with them.`)
115    rows = rows.map(r => r.replace(/[*+@]/g, '.'))
116  }
117  const uncolored = [...new Set(rows.join(''))].filter(c => c !== '.' && !(c in palette))
118  if (uncolored.length > 0) {
119    notes.push(`In ${what}, ${uncolored.map(c => `"${c}"`).join(', ')} has no palette color, so it is drawn clear.`)
120  }
121
122  return rows
123}
124
125/** The pet's own props by mode: frames of rows, or null for no prop. */
126function readProps(v: unknown, palette: Record<string, string>, notes: string[]) {
127  const props: Body['props'] = {}
128  if (v === undefined) {
129    return props
130  }
131  if (!isObject(v)) {
132    notes.push('`props` maps a mode to its prop, so it is left out.')
133    return props
134  }
135  for (const [mode, value] of Object.entries(v)) {
136    if (!(mode in MODES) || mode === 'run') {
137      notes.push(`"${mode}" in \`props\` is not a mode that can hold a prop, so it is left out.`)
138      continue
139    }
140    if (value === false || value === null) {
141      props[mode as Mode] = null
142      continue
143    }
144    const frames = Array.isArray(value) && value.length > 0 && value.every(Array.isArray) ? value : [value]
145    if (frames.length > MAX_PROP_FRAMES) {
146      notes.push(`The ${mode} prop keeps its first ${MAX_PROP_FRAMES} frames.`)
147    }
148    const kept = frames
149      .slice(0, MAX_PROP_FRAMES)
150      .map((f, i) => paletteRows(f, PROP_SIZE, palette, frames.length > 1 ? `frame ${i + 1} of the ${mode} prop` : `the ${mode} prop`, notes))
151      .filter((f): f is string[] => f !== undefined)
152    if (kept.length > 0) {
153      props[mode as Mode] = kept
154    }
155  }
156
157  return props
158}
159
160/** Status lines or line colors by mode, each value checked by `read`. */
161function byMode<T>(v: unknown, field: string, read: (value: unknown, mode: string) => T | undefined, notes: string[]) {
162  const out: Partial<Record<Mode, T>> = {}
163  if (v === undefined) {
164    return out
165  }
166  if (!isObject(v)) {
167    notes.push(`\`${field}\` maps a mode to its value, so it is left out.`)
168    return out
169  }
170  for (const [mode, value] of Object.entries(v)) {
171    if (!(mode in MODES)) {
172      notes.push(`"${mode}" in \`${field}\` is not a mode, so it is left out.`)
173      continue
174    }
175    const kept = read(value, mode)
176    if (kept !== undefined) {
177      out[mode as Mode] = kept
178    }
179  }
180
181  return out
182}
183
184function readLines(v: unknown, notes: string[]) {
185  return byMode(v, 'lines', (value, mode) => {
186    const lines = (Array.isArray(value) ? value : [value]).filter((l): l is string => typeof l === 'string' && l.trim() !== '').map(l => l.trim())
187    if (lines.length === 0) {
188      notes.push(`\`lines.${mode}\` has no text, so the ${mode} mode keeps its own lines.`)
189      return undefined
190    }
191    if (lines.some(l => l.length > MAX_LINE)) {
192      notes.push(`Lines in \`lines.${mode}\` are cut to ${MAX_LINE} characters.`)
193    }
194    return lines.map(l => l.slice(0, MAX_LINE))
195  }, notes)
196}
197
198function readLineColors(v: unknown, notes: string[]) {
199  return byMode(v, 'lineColors', (value, mode) => {
200    const c = color(value)
201    if (c === undefined) {
202      notes.push(`\`lineColors.${mode}\`, ${JSON.stringify(value)}, is not "#rrggbb", so the ${mode} mode keeps its own color.`)
203    }
204    return c
205  }, notes)
206}
207
208function readHud(v: unknown, notes: string[]) {
209  const hud: HudLook = {}
210  if (v === undefined) {
211    return hud
212  }
213  if (!isObject(v)) {
214    notes.push('`hud` is an object, so the HUD keeps its own look.')
215    return hud
216  }
217  if (v.frame !== undefined) {
218    hud.frame = color(v.frame)
219    if (hud.frame === undefined) {
220      notes.push(`\`hud.frame\`, ${JSON.stringify(v.frame)}, is not "#rrggbb", so the frame keeps its own color.`)
221    }
222  }
223  for (const key of BARS) {
224    const b = v[key]
225    if (b === undefined) {
226      continue
227    }
228    if (b === false) {
229      hud[key] = false
230      continue
231    }
232    if (!isObject(b)) {
233      notes.push(`\`hud.${key}\` is an object or false, so that bar keeps its own look.`)
234      continue
235    }
236    const look: BarLook = {}
237    if (typeof b.label === 'string' && b.label.trim() !== '') {
238      const label = [...b.label.trim()]
239      if (label.length > MAX_LABEL) {
240        notes.push(`\`hud.${key}.label\` is cut to ${MAX_LABEL} characters.`)
241      }
242      look.label = label.slice(0, MAX_LABEL).join('')
243    }
244    if (b.color !== undefined) {
245      look.color = color(b.color)
246      if (look.color === undefined) {
247        notes.push(`\`hud.${key}.color\` is not "#rrggbb", so the label keeps its own color.`)
248      }
249    }
250    if (b.fill !== undefined) {
251      const fill = Array.isArray(b.fill) && b.fill.length === 2 ? b.fill.map(color) : []
252      if (fill[0] !== undefined && fill[1] !== undefined) {
253        look.fill = [fill[0], fill[1]]
254      } else {
255        notes.push(`\`hud.${key}.fill\` is two "#rrggbb" colors, so the bar keeps its own fill.`)
256      }
257    }
258    hud[key] = look
259  }
260
261  return hud
262}
263
264/** A list of drawings in the pet's palette, each cut to `max`; at most SCENE_SIZE.items of them. */
265function drawings(v: unknown, max: { w: number; h: number }, palette: Record<string, string>, what: string, notes: string[]) {
266  if (v === undefined) {
267    return []
268  }
269  const list = Array.isArray(v) && v.length > 0 && v.every(Array.isArray) ? v : [v]
270  if (list.length > SCENE_SIZE.items) {
271    notes.push(`\`${what}\` keeps its first ${SCENE_SIZE.items}.`)
272  }
273
274  return list
275    .slice(0, SCENE_SIZE.items)
276    .map((d, i) => paletteRows(d, max, palette, `\`${what}\` ${i + 1}`, notes))
277    .filter((d): d is string[] => d !== undefined)
278}
279
280function readScene(v: unknown, palette: Record<string, string>, notes: string[]): Scene | undefined {
281  if (v === undefined) {
282    return undefined
283  }
284  if (!isObject(v)) {
285    notes.push('`scene` is an object, so the pet has no scene.')
286    return undefined
287  }
288  const ground = v.ground === undefined ? undefined : paletteRows(v.ground, SCENE_SIZE.ground, palette, '`scene.ground`', notes)
289  const sky = v.sky === undefined ? undefined : paletteRows(v.sky, SCENE_SIZE.sky, palette, '`scene.sky`', notes)
290  const obstacles = drawings(v.obstacles, SCENE_SIZE.obstacle, palette, 'scene.obstacles', notes)
291  const decor = drawings(v.decor, SCENE_SIZE.decor, palette, 'scene.decor', notes)
292  if (!ground && !sky && obstacles.length === 0 && decor.length === 0) {
293    notes.push('`scene` has no ground, sky, obstacles, or decor to draw, so the pet has no scene.')
294    return undefined
295  }
296  let every = EVERY.normal
297  if (v.every !== undefined) {
298    const asked = typeof v.every === 'number' ? Math.round(v.every) : NaN
299    every = Number.isNaN(asked) ? EVERY.normal : Math.min(EVERY.max, Math.max(EVERY.min, asked))
300    if (every !== asked) {
301      notes.push(`\`scene.every\` is a number of columns from ${EVERY.min} to ${EVERY.max}, so it is ${every}.`)
302    }
303  }
304
305  return { ground, ...(sky && { sky }), obstacles, decor, every }
306}
307
308/**
309 * A theme from a parsed theme file, or why there is none. Only a value with no sprite is refused. Anything else
310 * draws: the mod repairs what it can and says what it did in `notes`, which the drawer may act on or ignore.
311 */
312export function readTheme(v: unknown): { theme: Theme; notes: string[]; errors?: undefined } | { errors: string[] } {
313  if (!isObject(v)) {
314    return { errors: ['A theme is a JSON object with a `sprite`.'] }
315  }
316  if (!Array.isArray(v.sprite) || !v.sprite.some(r => typeof r === 'string' && r.length > 0)) {
317    return { errors: ['`sprite` is a list of text rows, one character per pixel.'] }
318  }
319  const notes: string[] = []
320  const name = typeof v.name === 'string' && v.name.trim() !== '' ? v.name.trim().slice(0, 24) : 'pet'
321
322  let rows = (v.sprite as unknown[]).map(r => (typeof r === 'string' ? r : ''))
323  const w = Math.max(...rows.map(r => r.length))
324  if (rows.some(r => r.length !== w)) {
325    notes.push(`Rows of different widths were padded with "." to ${w}.`)
326    rows = rows.map(r => r.padEnd(w, '.'))
327  }
328  if (rows.some(r => RESERVED.slice(1).some(c => r.includes(c)))) {
329    notes.push(`The sprite's ${RESERVED.slice(1).map(c => `"${c}"`).join(', ')} pixels are drawn clear: the mod marks its own drawings with them.`)
330    rows = rows.map(r => r.replace(/[*+@]/g, '.'))
331  }
332  const h = rows.length
333
334  const palette: Record<string, string> = {}
335  for (const [ch, value] of Object.entries(isObject(v.palette) ? v.palette : {})) {
336    const c = color(value)
337    if (c === undefined) {
338      notes.push(`The palette color for "${ch}", ${JSON.stringify(value)}, is not "#rrggbb", so "${ch}" is drawn clear.`)
339    } else if (!RESERVED.includes(ch)) {
340      palette[ch] = c
341    }
342  }
343  const uncolored = [...new Set(rows.join(''))].filter(c => c !== '.' && !(c in palette))
344  if (uncolored.length > 0) {
345    notes.push(`${uncolored.map(c => `"${c}"`).join(', ')} has no palette color, so it is drawn clear.`)
346  }
347
348  const asked = typeof v.scale === 'number' && v.scale > 0 ? v.scale : 1
349  const scale = fitScale(w, h, asked)
350  if (scale < asked) {
351    notes.push(`A ${w}×${h} sprite is drawn at scale ${scale} to fit every pose. At scale 1 the largest is ${maxSize(1).w}×${maxSize(1).h}, and a smaller scale blurs detail.`)
352  }
353
354  const outline = typeof v.outline === 'string' && v.outline in palette ? v.outline : undefined
355  const eyes = isPair(v.eyes) ? v.eyes : undefined
356  if (v.eyes !== undefined && eyes === undefined) {
357    notes.push('`eyes` is two [x, y] points, so the pet has no eyes and no faces.')
358  }
359  const inside = (x: number, y: number) => x >= 0 && x < w && y >= 0 && y < h
360  for (const [x, y] of eyes ?? []) {
361    if (!inside(x, y - 1) || !inside(x + 2, y + 1)) {
362      notes.push(`The eye at [${x}, ${y}] draws its 3×3 box partly off the ${w}×${h} sprite.`)
363    }
364  }
365  if (eyes && Math.abs(eyes[0][0] - eyes[1][0]) < 3 && Math.abs(eyes[0][1] - eyes[1][1]) < 3) {
366    notes.push('The eye boxes overlap, so the wide eyes and hearts merge into one shape. Pupils 4 pixels apart keep them apart.')
367  }
368  const cheeks = isPair(v.cheeks) ? v.cheeks : undefined
369  if (v.cheeks !== undefined && cheeks === undefined) {
370    notes.push('`cheeks` is two [x, y] pixels, so the pet has no cheeks.')
371  }
372  const inEyeBox = ([x, y]: [number, number]) => (eyes ?? []).some(([ex, ey]) => x >= ex && x <= ex + 2 && y >= ey - 1 && y <= ey + 1)
373  if (cheeks?.some(inEyeBox)) {
374    notes.push('A cheek sits inside an eye box, where some faces draw over it.')
375  }
376  const mini = isObject(v.mini) ? { top: color(v.mini.top), body: color(v.mini.body), edge: color(v.mini.edge) } : undefined
377  const hasMini = mini?.top !== undefined && mini.body !== undefined && mini.edge !== undefined
378  if (v.mini !== undefined && !hasMini) {
379    notes.push('`mini` needs three colors, `top`, `body`, and `edge`, so the minis keep the slime\'s blues.')
380  }
381  const miniSprite = v.miniSprite === undefined ? undefined : paletteRows(v.miniSprite, MINI_SIZE, palette, '`miniSprite`', notes)
382
383  return {
384    theme: {
385      name,
386      scale,
387      sprite: rows,
388      palette,
389      outline,
390      eyes,
391      eyeColor: color(v.eyeColor) ?? '#000000',
392      cheeks,
393      cheekColor: cheeks ? (color(v.cheekColor) ?? '#ff8aaa') : undefined,
394      mini: hasMini ? (mini as Theme['mini']) : SLIME_MINI,
395      miniSprite,
396      props: readProps(v.props, palette, notes),
397      lines: readLines(v.lines, notes),
398      lineColors: readLineColors(v.lineColors, notes),
399      hud: readHud(v.hud, notes),
400      scene: readScene(v.scene, palette, notes),
401    },
402    notes,
403  }
404}
405
406const colorOf = (color: string) => parseInt(color.slice(1), 16)
407
408function poseFrame(theme: Theme, pose: Pose, extra: [number, number][] = []): BodyFrame {
409  const [sx, sy, dy] = [pose[0] * theme.scale, pose[1] * theme.scale, pose[2] * theme.scale]
410  const sw = (theme.sprite[0] as string).length
411  const sh = theme.sprite.length
412  const cx = sw / 2
413  const tcx = sw % 2 ? BODY_W / 2 : Math.floor(BODY_W / 2) // whole-pixel aligned, so at scale 1 a resting sprite is copied, not resampled
414  const tb = HEIGHT - dy
415  const g = Array.from({ length: HEIGHT }, () => new Array<string>(BODY_W).fill('.'))
416  const put = (x: number, y: number, ch: string) => {
417    const [px, py] = [roundHalfEven(x), roundHalfEven(y)]
418    if (px >= 0 && px < BODY_W && py >= 0 && py < HEIGHT) {
419      ;(g[py] as string[])[px] = ch
420    }
421  }
422  // Each canvas pixel takes the sprite color that covers most of it; the outline counts extra, so it survives a squash.
423  for (let ty = 0; ty < HEIGHT; ty++) {
424    for (let tx = 0; tx < BODY_W; tx++) {
425      const u0 = cx + (tx - tcx) / sx
426      const u1 = cx + (tx + 1 - tcx) / sx
427      const v0 = sh - (tb - ty) / sy
428      const v1 = sh - (tb - ty - 1) / sy
429      const cover = new Map<string, number>()
430      for (let v = Math.max(0, Math.floor(v0)); v < Math.min(sh, Math.ceil(v1)); v++) {
431        for (let u = Math.max(0, Math.floor(u0)); u < Math.min(sw, Math.ceil(u1)); u++) {
432          const area = (Math.min(u1, u + 1) - Math.max(u0, u)) * (Math.min(v1, v + 1) - Math.max(v0, v))
433          const ch = (theme.sprite[v] as string)[u] as string
434          if (area > 0 && ch !== '.') {
435            cover.set(ch, (cover.get(ch) ?? 0) + area * (ch === theme.outline ? 1.6 : 1))
436          }
437        }
438      }
439      let total = 0
440      let best = ''
441      for (const [ch, area] of cover) {
442        total += area
443        if (best === '' || area > (cover.get(best) as number)) {
444          best = ch
445        }
446      }
447      if (best !== '' && total >= 0.45 * (u1 - u0) * (v1 - v0)) {
448        ;(g[ty] as string[])[tx] = best
449      }
450    }
451  }
452  const at = ([x, y]: [number, number]) => [tcx + (x - cx) * sx, tb - (sh - y) * sy] as const
453  for (const [x, y] of theme.cheeks ?? []) {
454    const [px, py] = at([x + 0.5, y + 0.5])
455    put(px - 0.5, py - 0.5, CHEEK_MARK)
456  }
457  for (const [x, y] of extra) {
458    put(x, y, SPARKLE_MARK)
459  }
460  const [l, r] = (theme.eyes ?? [[0, 0], [0, 0]]).map(([x, y]) => {
461    const [px, py] = at([x + 1.5, y + 0.5])
462    return [Math.floor(px - 1.5 + 0.5), Math.floor(py - 0.5 + 0.5) - 1] as [number, number]
463  }) as [[number, number], [number, number]]
464
465  return { g: g.map(row => row.join('')), l, r, e: pose[3] ?? '' }
466}
467
468/** The pet's frames for every clip, with its colors as the drawing code reads them. */
469export function animate(theme: Theme): Body {
470  const palette: Record<string, number> = { [SPARKLE_MARK]: SPARKLE_COLOR }
471  for (const [ch, color] of Object.entries(theme.palette)) {
472    palette[ch] = colorOf(color)
473  }
474  if (theme.cheekColor !== undefined) {
475    palette[CHEEK_MARK] = colorOf(theme.cheekColor)
476  }
477
478  return {
479    name: theme.name,
480    w: BODY_W,
481    h: HEIGHT,
482    palette,
483    eye: theme.eyes ? { ...EYE_COLOR, K: colorOf(theme.eyeColor) } : {},
484    mini: { top: colorOf(theme.mini.top), body: colorOf(theme.mini.body), edge: colorOf(theme.mini.edge) },
485    miniSprite: theme.miniSprite,
486    props: theme.props,
487    look: { lines: theme.lines, lineColors: theme.lineColors, hud: theme.hud },
488    scene: theme.scene,
489    clips: {
490      stand: { fps: 8, frames: STAND.map(p => poseFrame(theme, p)) },
491      run: { fps: 12, frames: RUN.map(p => poseFrame(theme, p)) },
492      jump: { fps: 12, frames: JUMP.map(p => poseFrame(theme, p)) },
493      think: { fps: 6, frames: THINK.map(p => poseFrame(theme, p)) },
494      cheer: { fps: 10, frames: CHEER.map((p, i) => poseFrame(theme, p, cheerSparkles(i))) },
495    },
496  }
497}
498
499/** The resting frame as text, trimmed to the rows in use: palette characters, `@` for a pupil, `*` for a cheek. */
500export function restingFrame(body: Body) {
501  const frame = body.clips.stand.frames[0] as BodyFrame
502  const g = frame.g.map(row => [...row])
503  for (const [x, y] of Object.keys(body.eye).length > 0 ? [frame.l, frame.r] : []) {
504    for (const [dx, dy] of [[0, 1], [1, 1], [0, 2], [1, 2]] as const) {
505      const row = g[y + dy]
506      if (row && x + dx >= 0 && x + dx < row.length) {
507        row[x + dx] = PUPIL_MARK
508      }
509    }
510  }
511  const rows = g.map(r => r.join(''))
512
513  return rows.slice(rows.findIndex(r => /[^.]/.test(r))).join('\n')
514}
515
hooks/preview.ts 219 lines
1import type { Anim, Mode } from '../types'
2import { TICK_MS, leapClipMs, step } from './anim'
3import { HUD_WINDOW_W, frameColor, hudRows, windowEdges } from './hud'
4import type { Hud } from './hud'
5import { BODY_W, FACES, HEIGHT, MODES, compose, composeFace } from './pixels'
6import type { Body, Canvas, Clip } from './pixels'
7import { GROUND_H, drawBand, layScene, obstacleSpans } from './scene'
8import { LINES, lineColor } from './status'
9
10// When each mode plays, in the words of the README's table.
11const WHEN: Record<Mode, string> = {
12  idle: 'Claude is idle',
13  sleep: 'Idle for a while',
14  jump: 'A turn starts',
15  think: 'Claude thinks longer',
16  read: 'Read',
17  search: 'Grep, Glob',
18  edit: 'Edit, MultiEdit, Write, NotebookEdit, TodoWrite',
19  bash: 'Bash, and any tool not listed',
20  web: 'WebFetch, WebSearch',
21  run: 'A tool call ends',
22  agent: 'A subagent starts',
23  error: 'A tool call fails',
24  cheer: 'A turn ends',
25}
26// What the status line names in each mode, for the lines that name something.
27const SAMPLE_TARGET: Partial<Record<Mode, string>> = { read: 'app.ts', search: 'useState', edit: 'app.ts', bash: 'npm test', web: 'docs.anthropic.com' }
28const SAMPLE_HUDS: [string, Hud][] = [
29  ['Early in a session', { hp: 86, mp: 72, mpResetsInMin: 213, st: 64, stResetsInMin: 4560 }],
30  ['Running low', { hp: 22, mp: 12, mpResetsInMin: 41, st: 9, stResetsInMin: 1500 }],
31]
32const MOTION_FPS = 10
33const SCENE_W = 96 // columns of the sample band
34const SCENE_MS = 12000
35const FACE_FPS = 5
36const LOOP_MS = 2400
37
38// One character per palette entry; the set leaves out what would need escaping in HTML or a JS string.
39const CODES = [...Array.from({ length: 94 }, (_, i) => String.fromCharCode(33 + i)).filter(c => !`"'\\<>&\``.includes(c)), ...Array.from({ length: 200 }, (_, i) => String.fromCharCode(192 + i))]
40
41const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
42
43type Tile = { label: string; note: string; w: number; h: number; fps: number; frames: string[] }
44
45/**
46 * The preview: a self-contained HTML page with every motion, face, and frame the mod makes for `body`. It shows each mode in
47 * motion with when it plays, the pet running through its scene when it has one, each face, each mode's status lines,
48 * the HUD in two sample states, and each clip's frames before eyes go on. A button switches to a light
49 * terminal's background. `notes` are readTheme's.
50 */
51export function previewPage(body: Body, notes: string[]): string {
52  const colors: number[] = []
53  const encode = (c: Canvas) =>
54    c.px
55      .map(color => {
56        if (color === -1) {
57          return ' '
58        }
59        let i = colors.indexOf(color)
60        if (i === -1) {
61          i = colors.push(color) - 1
62        }
63        return CODES[i] ?? ' '
64      })
65      .join('')
66  const frames = (count: number, fps: number, draw: (t: number) => Canvas) => Array.from({ length: count }, (_, i) => draw((i * 1000) / fps))
67
68  const motions: Tile[] = (Object.keys(WHEN) as Mode[]).map(mode => {
69    const length = MODES[mode].once ?? LOOP_MS
70    const minis = mode === 'agent' ? [{ age: 1000 }, { age: 400 }] : []
71    const shots = frames(Math.ceil((length / 1000) * MOTION_FPS), MOTION_FPS, t => compose(body, mode, t, 1, 'ok', minis.map(m => ({ age: m.age + t }))))
72    const first = shots[0] as Canvas
73    return { label: mode, note: WHEN[mode], w: first.w, h: first.h, fps: MOTION_FPS, frames: shots.map(encode) }
74  })
75  const faces: Tile[] = FACES.map(name => {
76    const shots = frames((LOOP_MS / 1000) * FACE_FPS, FACE_FPS, t => composeFace(body, name, t))
77    const first = shots[0] as Canvas
78    return { label: name, note: '', w: first.w, h: first.h, fps: FACE_FPS, frames: shots.map(encode) }
79  })
80  const clips: Tile[] = (Object.keys(body.clips) as Clip[]).map(clip => {
81    const { fps, frames: list } = body.clips[clip]
82    const shots = list.map(f => {
83      const c: Canvas = { w: BODY_W, h: HEIGHT, px: [] }
84      for (const row of f.g) {
85        for (const ch of row) {
86          c.px.push(body.palette[ch] ?? -1)
87        }
88      }
89      return c
90    })
91    return { label: clip, note: `${list.length} frames at ${fps} fps`, w: BODY_W, h: HEIGHT, fps, frames: shots.map(encode) }
92  })
93  const scenes: Tile[] = []
94  if (body.scene) {
95    const layout = layScene(body.scene, SCENE_W)
96    const between = { isWorking: true, activeTools: 0, activeMode: 'bash' as const, activeTarget: '', lastToolAt: Infinity, room: SCENE_W - BODY_W, obstacles: obstacleSpans(layout), trail: 0 }
97    let a: Anim = { mode: 'run', since: 0, x: 0, dir: 1, tick: 0, target: '', working: true }
98    const shots: string[] = []
99    for (let t = 0; t < SCENE_MS; t += TICK_MS) {
100      a = step(a, between, t)
101      const picture = a.leap ? compose(body, 'jump', leapClipMs(t - a.leap.since), a.dir) : compose(body, 'run', t - a.since, a.dir)
102      shots.push(encode(drawBand(body, body.scene, layout, picture, Math.round(a.x), t)))
103    }
104    scenes.push({ label: 'run', note: 'Between tool calls, leaping each obstacle on the way', w: SCENE_W, h: HEIGHT + GROUND_H, fps: 1000 / TICK_MS, frames: shots })
105  }
106  const palette = colors.map(c => `#${c.toString(16).padStart(6, '0')}`)
107  const name = escapeHtml(body.name)
108  const tiles = (list: Tile[], kind: string) =>
109    list.map((t, i) => `<figure><canvas data-kind="${kind}" data-i="${i}" width="${t.w * 4}" height="${t.h * 4}"></canvas><figcaption><b>${t.label}</b>${t.note ? `<span>${escapeHtml(t.note)}</span>` : ''}</figcaption></figure>`).join('')
110  const statusLines = (Object.keys(WHEN) as Mode[])
111    .map(mode => {
112      const lines = (body.look.lines[mode] ?? LINES[mode]).map(l => `<li>› ${escapeHtml(l.replace('{}', SAMPLE_TARGET[mode] ?? 'something'))}</li>`).join('')
113      return `<div class="lines"><b>${mode}</b><ul style="color: ${lineColor(mode, body.look.lineColors)}">${lines}</ul></div>`
114    })
115    .join('')
116  const edges = windowEdges(HUD_WINDOW_W)
117  const huds = SAMPLE_HUDS.map(([label, h]) => {
118    const rows = hudRows(h, body.look.hud)
119    const inside = rows.length === 0
120      ? '<p>The pet hides every bar, so the HUD does not show.</p>'
121      : `<pre class="hud" style="color: ${frameColor(body.look.hud)}">${escapeHtml(edges.top)}\n${rows
122          .map(r => `${edges.side} <span style="color: ${r.color}">${escapeHtml(r.label)} </span><canvas class="bar" data-cells="${r.cells}"></canvas>${r.parts.map(p => `<span style="color: ${p.color}${p.bold ? '; font-weight: bold' : ''}">${escapeHtml(p.text)}</span>`).join('')}`)
123          .join('\n')}\n${escapeHtml(edges.bottom)}</pre>`
124    return `<figure class="wide"><figcaption><b>${label}</b></figcaption>${inside}</figure>`
125  }).join('')
126  const noteList = notes.length > 0 ? `<section class="notes"><h2>Notes</h2><ul>${notes.map(n => `<li>${escapeHtml(n)}</li>`).join('')}</ul></section>` : ''
127
128  return `<!doctype html>
129<html lang="en">
130<head>
131<meta charset="utf-8">
132<meta name="viewport" content="width=device-width, initial-scale=1">
133<title>${name}: preview</title>
134<style>
135  body { margin: 0; padding: 32px; font: 15px/1.5 ui-monospace, Menlo, monospace; background: #0b0f2a; color: #e3e8ff; }
136  body.light { background: #f4f5f9; color: #1b1e2b; }
137  header { display: flex; flex-wrap: wrap; gap: 16px; align-items: baseline; justify-content: space-between; max-width: 1100px; }
138  h1 { margin: 0; font-size: 28px; }
139  h2 { font-size: 18px; margin: 32px 0 12px; }
140  p { margin: 4px 0 0; opacity: 0.8; }
141  button { font: inherit; padding: 8px 14px; border: 2px solid currentColor; background: transparent; color: inherit; cursor: pointer; }
142  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; max-width: 1100px; }
143  figure { margin: 0; padding: 12px; border: 2px solid #2a3470; display: grid; gap: 8px; justify-items: center; }
144  body.light figure { border-color: #c9cede; }
145  canvas { image-rendering: pixelated; max-width: 100%; }
146  figcaption { display: grid; text-align: center; }
147  figcaption span { font-size: 12px; opacity: 0.7; }
148  .notes { max-width: 1100px; border: 2px solid #e0b400; padding: 0 16px 8px; }
149  .wide-cells { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
150  .stack { display: grid; gap: 16px; max-width: 1100px; }
151  .wide { justify-items: start; overflow-x: auto; }
152  .wide canvas[data-kind] { width: 100%; height: auto; }
153  .lines ul { margin: 4px 0 0; padding: 0; list-style: none; font-weight: bold; }
154  .hud { margin: 0; line-height: 18px; }
155  .hud canvas.bar { width: 180px; height: 18px; vertical-align: top; }
156</style>
157</head>
158<body>
159<header>
160  <div><h1>${name}</h1><p>Every motion, face, status line, and HUD look of this pet${scenes.length > 0 ? ', and its scene' : ''}. Approve it in Claude Code, or say what to change.</p></div>
161  <button type="button" aria-pressed="false" id="light">Light terminal</button>
162</header>
163${noteList}
164<h2>Motions</h2>
165<div class="grid">${tiles(motions, 'motion')}</div>
166${scenes.length > 0 ? `<h2>Scene</h2>\n<div class="stack">${tiles(scenes, 'scene').replace('<figure>', '<figure class="wide">')}</div>` : ''}
167<h2>Faces</h2>
168<div class="grid">${tiles(faces, 'face')}</div>
169<h2>Status lines</h2>
170<p>The line beside the pet in each mode. One shows at a time, and the next takes over every 4 seconds.</p>
171<div class="grid wide-cells">${statusLines}</div>
172<h2>HUD</h2>
173<div class="stack">${huds}</div>
174<h2>Frames</h2>
175<p>Each clip as the mod squashes and stretches the sprite, before eyes and props go on.</p>
176<div class="grid">${tiles(clips, 'clip')}</div>
177<script>
178const PALETTE = ${JSON.stringify(palette)}
179const CODES = ${JSON.stringify(CODES.slice(0, palette.length).join(''))}
180const TILES = { motion: ${JSON.stringify(motions)}, scene: ${JSON.stringify(scenes)}, face: ${JSON.stringify(faces)}, clip: ${JSON.stringify(clips)} }
181const still = matchMedia('(prefers-reduced-motion: reduce)').matches
182const canvases = [...document.querySelectorAll('canvas[data-kind]')].map(c => ({ ctx: c.getContext('2d'), tile: TILES[c.dataset.kind][c.dataset.i] }))
183function draw(ctx, tile, frame) {
184  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
185  for (let i = 0; i < frame.length; i++) {
186    const k = CODES.indexOf(frame[i])
187    if (k < 0) continue
188    ctx.fillStyle = PALETTE[k]
189    ctx.fillRect((i % tile.w) * 4, Math.floor(i / tile.w) * 4, 4, 4)
190  }
191}
192function loop(now) {
193  for (const { ctx, tile } of canvases) draw(ctx, tile, tile.frames[still ? 0 : Math.floor((now / 1000) * tile.fps) % tile.frames.length])
194  if (!still) requestAnimationFrame(loop)
195}
196requestAnimationFrame(loop)
197// A bar's cells as the terminal draws them: little-endian u32 triplets [glyph, fg, bg], ▀ or ▄, 2 pixels per cell.
198for (const bar of document.querySelectorAll('canvas.bar')) {
199  const b = atob(bar.dataset.cells)
200  const u32 = k => (b.charCodeAt(k) | (b.charCodeAt(k + 1) << 8) | (b.charCodeAt(k + 2) << 16) | (b.charCodeAt(k + 3) << 24)) >>> 0
201  const cells = b.length / 12
202  bar.width = cells * 9
203  bar.height = 18
204  const ctx = bar.getContext('2d')
205  for (let i = 0; i < cells; i++) {
206    const [glyph, fg, bg] = [u32(i * 12), u32(i * 12 + 4), u32(i * 12 + 8)]
207    const half = (color, y) => { if (color < 0x1000000) { ctx.fillStyle = '#' + color.toString(16).padStart(6, '0'); ctx.fillRect(i * 9, y, 9, 9) } }
208    if (glyph === 0x2580) { half(fg, 0); half(bg, 9) }
209    if (glyph === 0x2584) half(fg, 9)
210  }
211}
212const light = document.getElementById('light')
213light.onclick = () => light.setAttribute('aria-pressed', String(document.body.classList.toggle('light')))
214</script>
215</body>
216</html>
217`
218}
219
hooks/settings.ts 33 lines
1/** The user's settings, from the plugin's `userConfig`, as the mod uses them. */
2export type Settings = {
3  pace: number // 1 is normal; the pet runs and animates this many times as fast
4  sleepAfterMs: number // 0 keeps the pet awake
5  hud: boolean
6  statusLine: boolean
7  targets: boolean // the status line names files, patterns, commands, and hosts
8  minis: boolean
9}
10
11const PACE: Record<string, number> = { slow: 0.6, normal: 1, fast: 1.6 }
12
13export const DEFAULTS: Settings = { pace: 1, sleepAfterMs: 60000, hud: true, statusLine: true, targets: true, minis: true }
14
15const flag = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
16
17/**
18 * Settings from the options Claude Code passes to `register`. A missing or malformed value takes its default,
19 * so settings saved by another version of the mod never stop it loading.
20 */
21export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
22  const sleep = options.sleepAfter
23
24  return {
25    pace: PACE[String(options.speed)] ?? DEFAULTS.pace,
26    sleepAfterMs: typeof sleep === 'number' && Number.isFinite(sleep) && sleep >= 0 ? sleep * 1000 : DEFAULTS.sleepAfterMs,
27    hud: flag(options.hud, DEFAULTS.hud),
28    statusLine: flag(options.statusLine, DEFAULTS.statusLine),
29    targets: flag(options.targets, DEFAULTS.targets),
30    minis: flag(options.minis, DEFAULTS.minis),
31  }
32}
33
hooks/pixels.ts 567 lines
1import type { Mode } from '../types'
2import type { HudLook } from './hud'
3import type { Scene } from './scene'
4
5export type Clip = 'stand' | 'run' | 'jump' | 'think' | 'cheer'
6export type BodyFrame = { g: string[]; l: [number, number]; r: [number, number]; e: string }
7export type MiniColors = { top: number; body: number; edge: number }
8/** A pet ready to draw: `animate` in theme.ts makes one from a theme. */
9export type Body = {
10  name: string
11  w: number
12  h: number
13  palette: Record<string, number> // frame character -> color
14  eye: Record<string, number> // expression character -> color
15  mini: MiniColors
16  miniSprite?: string[] // the pet's own mini, in its palette, in place of the drop
17  props: Partial<Record<Mode, string[][] | null>> // the pet's own props: frames of rows in its palette; null for none
18  look: Look
19  scene?: Scene
20  clips: Record<Clip, { fps: number; frames: BodyFrame[] }>
21}
22/** What a pet changes beyond its drawing: its status lines, their colors, and the HUD. */
23export type Look = { lines: Partial<Record<Mode, string[]>>; lineColors: Partial<Record<Mode, string>>; hud: HudLook }
24export type Canvas = { w: number; h: number; px: number[] }
25
26const NONE = -1
27const DEFAULT = 0x01000000
28export const PROP_X = 17 // the prop's 16x20 box starts here; the body's right edge is empty from column 17
29export const BODY_W = 19
30export const PROP_W = 16
31export const HEIGHT = 20
32const PROP_FPS = 4 // for a pet's own props
33
34export const EYE_COLOR: Record<string, number> = { K: 0x000000, W: 0xffffff, Y: 0xffe25a, H: 0xff78aa, B: 0x78c8ff }
35
36export type ModeSpec = {
37  clip: Clip
38  fps?: number
39  once?: number // ms; the mode ends by itself
40  eyes: [string, number][] // expression name and how long it holds, looping
41  prop?: boolean
42}
43
44export const MODES: Record<Mode, ModeSpec> = {
45  idle: { clip: 'stand', eyes: [['open', 2400], ['blink', 200], ['open', 1800], ['look', 2600], ['open', 1600], ['wave', 3000], ['blink', 200], ['wink', 1800], ['content', 1800], ['open', 1800]] },
46  sleep: { clip: 'stand', fps: 3, eyes: [['sleepy', 2400]] },
47  think: { clip: 'think', eyes: [['look', 1800], ['curious', 2400], ['look', 1800], ['blink', 160], ['curious', 2400]] },
48  read: { clip: 'stand', fps: 4, prop: true, eyes: [['scan', 2600], ['blink', 160], ['scan', 2600]] },
49  search: { clip: 'stand', fps: 7, prop: true, eyes: [['wide', 1400], ['look', 1800], ['wide', 1400], ['heart', 1200]] },
50  edit: { clip: 'stand', fps: 11, prop: true, eyes: [['focus', 3000], ['blink', 160], ['focus', 3000]] },
51  bash: { clip: 'stand', fps: 9, prop: true, eyes: [['spiral', 3000], ['blink', 160], ['spiral', 3000]] },
52  web: { clip: 'stand', fps: 6, prop: true, eyes: [['wave', 2600], ['wide', 800], ['wave', 2600]] },
53  agent: { clip: 'stand', fps: 8, eyes: [['wide', 1500], ['happy', 1200], ['happy', 1200], ['wink', 1200]] },
54  run: { clip: 'run', eyes: [['open', 1000]] },
55  jump: { clip: 'jump', once: (14 / 12) * 1000, eyes: [['open', 1000]] },
56  cheer: { clip: 'cheer', once: 1800, eyes: [['star', 900], ['happy', 900]] },
57  error: { clip: 'stand', once: 2000, eyes: [['dizzy', 1000]] },
58}
59
60export function frameIndex(count: number, fps: number, elapsedMs: number, isLoop: boolean) {
61  const n = Math.floor((Math.max(0, elapsedMs) / 1000) * fps)
62
63  return isLoop ? n % count : Math.min(n, count - 1)
64}
65
66export function canvas(w: number, h: number): Canvas {
67  return { w, h, px: new Array<number>(w * h).fill(NONE) }
68}
69
70function put(c: Canvas, x: number, y: number, color: number) {
71  if (x >= 0 && x < c.w && y >= 0 && y < c.h) {
72    c.px[y * c.w + x] = color
73  }
74}
75
76function rect(c: Canvas, x: number, y: number, w: number, h: number, color: number) {
77  for (let j = 0; j < h; j++) {
78    for (let i = 0; i < w; i++) {
79      put(c, x + i, y + j, color)
80    }
81  }
82}
83
84export function stamp(c: Canvas, ox: number, oy: number, rows: string[], palette: Record<string, number>) {
85  rows.forEach((row, y) => {
86    for (let x = 0; x < row.length; x++) {
87      const color = palette[row[x] as string]
88      if (color !== undefined) {
89        put(c, ox + x, oy + y, color)
90      }
91    }
92  })
93}
94
95export const hash = (n: number) => {
96  let h = Math.imul(n | 0, 2654435761) >>> 0
97  h ^= h >>> 13
98  h = Math.imul(h, 1274126177) >>> 0
99
100  return (h >>> 8) / 16777216
101}
102
103// ---- eyes: 3x3 box per eye ----
104
105type Eyes = { l: string[]; r: string[]; fx?: (c: Canvas) => void }
106
107const OPEN = ['...', 'KW.', 'KK.']
108const BLINK = ['...', 'KKK', '...']
109const same = (p: string[], fx?: Eyes['fx']): Eyes => ({ l: p, r: p, fx })
110
111function pupil(ox: number, oy: number) {
112  const g = [0, 1, 2].map(() => ['.', '.', '.'])
113  const set = (x: number, y: number, ch: string) => {
114    ;(g[y] as string[])[x] = ch
115  }
116  set(ox, oy, 'K')
117  set(ox + 1, oy, 'W')
118  set(ox, oy + 1, 'K')
119  set(ox + 1, oy + 1, 'K')
120
121  return g.map(r => r.join(''))
122}
123
124const RING: [number, number][] = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 2], [0, 2], [0, 1]]
125
126const EXPRESSIONS: Record<string, (t: number) => Eyes> = {
127  open: () => same(OPEN),
128  blink: () => same(BLINK),
129  wide: () => same(['KW.', 'KK.', 'KK.']),
130  wave: t => {
131    const g = [0, 1, 2].map(() => ['.', '.', '.'])
132    for (let c = 0; c < 3; c++) {
133      ;(g[1 + Math.round((Math.sin(t / 170 - c * 2.2) + 1) / 2)] as string[])[c] = 'K'
134    }
135
136    return same(g.map(r => r.join('')))
137  },
138  look: t => {
139    const [ox, oy] = [[0, 1], [1, 1], [1, 1], [0, 1], [0, 0], [1, 0]][Math.floor(t / 500) % 6] as [number, number]
140
141    return same(pupil(ox, oy))
142  },
143  scan: t => {
144    const ox = [0, 0, 1, 1, 1, 0][Math.floor(t / 220) % 6] as number
145
146    return same(pupil(ox, Math.floor(t / 1300) % 2))
147  },
148  wink: t => ({ l: OPEN, r: Math.floor(t / 700) % 2 ? ['...', '.K.', 'K.K'] : OPEN }),
149  content: () => same(['...', 'K.K', '.K.']),
150  happy: () => same(['...', '.K.', 'K.K']),
151  heart: () => same(['H.H', 'HHH', '.H.']),
152  star: t => same(['.Y.', `Y${Math.floor(t / 180) % 2 ? 'W' : 'Y'}Y`, '.Y.']),
153  focus: () => ({ l: ['K..', '.K.', 'K..'], r: ['..K', '.K.', '..K'] }),
154  curious: t => {
155    const small = OPEN
156    const big = ['KKK', 'KWK', 'KKK']
157
158    return Math.floor(t / 800) % 2 ? { l: small, r: big } : { l: big, r: small }
159  },
160  spiral: t => {
161    const g = [0, 1, 2].map(() => ['.', '.', '.'])
162    const head = Math.floor(t / 90) % 8
163    for (let k = 0; k < 5; k++) {
164      const [x, y] = RING[(head + k) % 8] as [number, number]
165      ;(g[y] as string[])[x] = 'K'
166    }
167
168    return same(g.map(r => r.join('')))
169  },
170  sleepy: t => {
171    const q = (t / 1500) % 1
172
173    return same(Math.floor(t / 1200) % 2 ? ['...', '...', 'KKK'] : BLINK, c => {
174      if (q < 0.75) {
175        stamp(c, 14, Math.round(8 - q * 7), ['XXX', '..X', '.X.', 'X..', 'XXX'], { X: 0x9fd7ff })
176      }
177    })
178  },
179  dizzy: t => same(['K.K', '.K.', 'K.K'], c => rect(c, 13, 12 + (Math.floor(t / 300) % 3), 1, 2, 0x78c8ff)),
180  sweat: t => same(OPEN, c => rect(c, 13, 12 + (Math.floor(t / 500) % 2), 1, 2, 0x78c8ff)),
181  tired: () => same(['...', '...', 'KKK']),
182}
183
184/** Every face the pet can make, from any sprite. */
185export const FACES = Object.keys(EXPRESSIONS)
186
187/** What low HP or MP does to the face while the pet has no tool in hand. */
188const MOOD_EYES: Record<string, string> = { worried: 'sweat', critical: 'dizzy', tired: 'tired' }
189
190const RUN_EYES: Record<string, string> = { open: 'open', wide: 'wide', bar: 'blink' }
191
192export function expressionName(mode: Mode, elapsedMs: number, hint: string, mood = 'ok') {
193  const spec = MODES[mode]
194  if ((mode === 'idle' || mode === 'think') && MOOD_EYES[mood]) {
195    return MOOD_EYES[mood] as string
196  }
197  if (mode === 'run' || mode === 'jump') {
198    return RUN_EYES[hint] ?? 'open'
199  }
200  const total = spec.eyes.reduce((sum, [, ms]) => sum + ms, 0)
201  let u = Math.max(0, elapsedMs) % total
202  for (const [name, ms] of spec.eyes) {
203    if (u < ms) {
204      return name
205    }
206    u -= ms
207  }
208
209  return 'open'
210}
211
212// ---- props: each is drawn in a 16x20 box ----
213
214const hex = (s: string) => parseInt(s.slice(1), 16)
215
216const PROPS: Partial<Record<Mode, (c: Canvas, t: number) => void>> = {
217  read: (c, t) => {
218    const row = Math.floor(t / 300) % 3
219    rect(c, 0, 8, 16, 10, hex('#6b3f17'))
220    rect(c, 1, 9, 6, 8, hex('#f1e8c8'))
221    rect(c, 9, 9, 6, 8, hex('#f1e8c8'))
222    rect(c, 7, 9, 2, 8, hex('#a98b55'))
223    for (const y of [10, 12, 14]) {
224      rect(c, 2, y, 4, 1, hex('#b9ad8a'))
225      rect(c, 10, y, 4, 1, hex('#b9ad8a'))
226    }
227    rect(c, 10, 10 + row * 2, 4, 1, hex('#4cae3b'))
228    if (t % 2400 > 2100) {
229      rect(c, 9, 9, 6, 8, hex('#e4d9b4'))
230      rect(c, 5, 6, 5, 8, hex('#f7f0d8'))
231      rect(c, 5, 13, 5, 1, hex('#cbbd90'))
232    }
233  },
234  search: (c, t) => {
235    rect(c, 1, 4, 10, 14, hex('#e9ecf2'))
236    rect(c, 1, 4, 10, 1, hex('#c4c9d4'))
237    for (let y = 7; y <= 15; y += 2) {
238      rect(c, 3, y, 6, 1, hex('#8b93a1'))
239    }
240    const cx = Math.round(5 + 4 * Math.sin(t / 600))
241    const cy = Math.round(10 + 3 * Math.sin(t / 900 + 1))
242    const ring = hex('#9fd7ff')
243    rect(c, cx - 1, cy - 1, 3, 3, hex('#c1dcf6'))
244    rect(c, cx - 1, cy - 2, 3, 1, ring)
245    rect(c, cx - 1, cy + 2, 3, 1, ring)
246    rect(c, cx - 2, cy - 1, 1, 3, ring)
247    rect(c, cx + 2, cy - 1, 1, 3, ring)
248    for (const [dx, dy] of [[3, 3], [4, 4], [3, 2], [2, 3]] as const) {
249      put(c, cx + dx, cy + dy, hex('#7a4a1e'))
250    }
251    if (t % 2800 > 2400) {
252      rect(c, 13, 2, 1, 3, hex('#ffe25a'))
253      put(c, 13, 6, hex('#ffe25a'))
254    }
255  },
256  edit: (c, t) => {
257    const widths = [6, 4, 7, 5, 6, 3]
258    const indents = [2, 4, 4, 4, 2, 4]
259    const colors = ['#6ad23a', '#9fd7ff', '#ffe25a', '#9fd7ff', '#6ad23a', '#ffe25a'].map(hex)
260    const line = Math.floor(t / 900) % 7
261    const done = (t % 900) / 900
262    rect(c, 0, 3, 13, 15, hex('#f4f4f4'))
263    rect(c, 0, 3, 13, 1, hex('#c4c9d4'))
264    let tx = 2
265    let ty = 5
266    for (let i = 0; i < 6; i++) {
267      const y = 5 + i * 2
268      const indent = indents[i] as number
269      const color = colors[i] as number
270      if (i < line) {
271        rect(c, 1 + indent, y, widths[i] as number, 1, color)
272      } else if (i === line) {
273        const w = Math.max(1, Math.round((widths[i] as number) * done))
274        rect(c, 1 + indent, y, w, 1, color)
275        tx = 1 + indent + w
276        ty = y
277      }
278    }
279    put(c, tx, ty + 1, hex('#2b2b2b'))
280    for (let k = 0; k < 3; k++) {
281      put(c, tx + 1 + k, ty - k, hex('#ffd23f'))
282    }
283    put(c, tx + 4, ty - 3, hex('#f56060'))
284  },
285  bash: (c, t) => {
286    rect(c, 0, 4, 16, 14, hex('#3a404c'))
287    rect(c, 1, 7, 14, 10, hex('#0b0d10'))
288    rect(c, 1, 5, 14, 2, hex('#2a2f3a'))
289    put(c, 2, 5, hex('#f56060'))
290    put(c, 4, 5, hex('#ffe25a'))
291    put(c, 6, 5, hex('#4cae3b'))
292    const n = Math.floor(t / 420)
293    for (let k = 0; k < 5; k++) {
294      const li = n - 4 + k
295      const y = 8 + k * 2
296      if (li < 0) {
297        continue
298      }
299      if (li % 5 === 0) {
300        put(c, 2, y, hex('#4cae3b'))
301        rect(c, 4, y, Math.min(8, 3 + Math.floor(hash(li) * 6)), 1, hex('#e6e8ec'))
302      } else {
303        rect(c, 2, y, 2 + Math.floor(hash(li) * 10), 1, li % 7 === 3 ? hex('#f56060') : hex('#7d8696'))
304      }
305    }
306    if (Math.floor(t / 250) % 2 === 0) {
307      rect(c, 2, 16, 2, 1, hex('#e6e8ec'))
308    }
309  },
310  web: (c, t) => {
311    const off = t / 250
312    for (let y = 4; y <= 17; y++) {
313      for (let x = 1; x <= 14; x++) {
314        const d = Math.hypot(x + 0.5 - 8, y + 0.5 - 11)
315        if (d > 6.6) {
316          continue
317        }
318        let color = d > 5.6 ? '#1b4f8f' : '#2e7fd9'
319        if (d <= 5.6 && Math.sin((x + off) * 0.9) + Math.cos(y * 0.8 + (x + off) * 0.3) > 0.9) {
320          color = '#4cae3b'
321        }
322        put(c, x, y, hex(color))
323      }
324    }
325    const a = t / 500
326    rect(c, Math.round(7 + 7 * Math.cos(a)), Math.round(10 + 3 * Math.sin(a)), 2, 2, hex(Math.sin(a) > 0 ? '#ffe25a' : '#a98b2f'))
327  },
328}
329
330// ---- minis: one per running subagent, in a trail behind the pet ----
331
332/** One mini as drawn: ms since its agent started, and since it ended once it has. */
333export type MiniView = { age: number; doneFor?: number; failed?: boolean }
334
335export const MAX_MINIS = 6
336const MINI_W = 6 // a 5 px mini and a 1 px gap
337export const MINI_SIZE = { w: 5, h: 7 }
338const MINI_DROP_MS = 300
339
340export const trailWidth = (count: number) => Math.min(count, MAX_MINIS) * MINI_W
341
342const MINI_FAILED = { top: hex('#b4b4b4'), body: hex('#8b93a1'), edge: hex('#5a606b') }
343
344const grey = (c: number) => {
345  const l = Math.round(0.3 * ((c >> 16) & 255) + 0.59 * ((c >> 8) & 255) + 0.11 * (c & 255))
346  return (l << 16) | (l << 8) | l
347}
348
349function drawMini(c: Canvas, ox: number, m: MiniView, k: number, body: Body) {
350  let lift = Math.round(Math.abs(Math.sin(m.age / 260 + k * 1.3)) * 3)
351  if (m.age < MINI_DROP_MS) {
352    lift = Math.round((1 - m.age / MINI_DROP_MS) * 10)
353  } else if (m.doneFor !== undefined && !m.failed) {
354    lift = Math.round(Math.abs(Math.sin(m.doneFor / 160)) * 5)
355  }
356  const rows = body.miniSprite
357  const y = HEIGHT - (rows?.length ?? 5) - lift
358  if (rows) {
359    const palette = m.failed ? Object.fromEntries(Object.entries(body.palette).map(([ch, color]) => [ch, grey(color)])) : body.palette
360    stamp(c, ox, y, rows, palette)
361  } else {
362    drawDrop(c, ox, y, m.failed ? MINI_FAILED : body.mini)
363  }
364  if (m.doneFor !== undefined && !m.failed && Math.floor(m.doneFor / 180) % 2 === 0) {
365    put(c, ox + 2, y - 2, EYE_COLOR.Y as number)
366  }
367}
368
369function drawDrop(c: Canvas, ox: number, y: number, colors: MiniColors) {
370  put(c, ox + 2, y, colors.top)
371  rect(c, ox + 1, y + 1, 3, 1, colors.top)
372  rect(c, ox, y + 2, 5, 2, colors.body)
373  put(c, ox + 1, y + 2, 0)
374  put(c, ox + 3, y + 2, 0)
375  rect(c, ox + 1, y + 4, 3, 1, colors.edge)
376}
377
378export function overlay(out: Canvas, src: Canvas, ox: number) {
379  for (let y = 0; y < src.h; y++) {
380    for (let x = 0; x < src.w; x++) {
381      const color = src.px[y * src.w + x] as number
382      if (color !== NONE) {
383        put(out, ox + x, y, color)
384      }
385    }
386  }
387}
388
389function mirror(c: Canvas) {
390  for (let y = 0; y < c.h; y++) {
391    const row = c.px.slice(y * c.w, (y + 1) * c.w).reverse()
392    c.px.splice(y * c.w, c.w, ...row)
393  }
394}
395
396// ---- effects on the body canvas ----
397
398const QUESTION = ['YYY', '..Y', '.Y.', '...', '.Y.']
399const DUST = hex('#8b93a1')
400const THOUGHT = hex('#c8c8c8')
401
402function effects(c: Canvas, mode: Mode, clipIndex: number, t: number, ownThink: boolean) {
403  if (mode === 'think' && !ownThink) {
404    stamp(c, 14, 2 + (Math.floor(t / 400) % 2), QUESTION, EYE_COLOR)
405    for (let k = 0; k < 1 + (Math.floor(t / 500) % 3); k++) {
406      put(c, 13 + k * 2, 9, THOUGHT)
407    }
408  }
409  const lands = (mode === 'run' && (clipIndex === 0 || clipIndex === 6)) || (mode === 'jump' && (clipIndex === 11 || clipIndex === 12))
410  if (lands) {
411    rect(c, 0, 19, 1, 1, DUST)
412    put(c, 1, 18, DUST)
413    rect(c, 18, 19, 1, 1, DUST)
414    put(c, 17, 18, DUST)
415  }
416}
417
418/** How the prop of `mode` draws: the pet's own, the mod's, or none. */
419function propOf(body: Body, mode: Mode): ((c: Canvas, t: number) => void) | undefined {
420  const own = body.props[mode]
421  if (own === null) {
422    return undefined
423  }
424  if (own) {
425    return (c, t) => {
426      const rows = own[frameIndex(own.length, PROP_FPS, t, true)] as string[]
427      stamp(c, 0, HEIGHT - rows.length, rows, body.palette)
428    }
429  }
430
431  return MODES[mode].prop ? PROPS[mode] : undefined
432}
433
434// ---- compose ----
435
436export function compose(body: Body, mode: Mode, elapsedMs: number, dir: 1 | -1, mood = 'ok', minis: MiniView[] = []): Canvas {
437  // The expressions index their sequences by time; a negative time would index past the start.
438  elapsedMs = Math.max(0, elapsedMs)
439  const spec = MODES[mode]
440  const clip = body.clips[spec.clip]
441  const isLoop = spec.once === undefined
442  const index = frameIndex(clip.frames.length, spec.fps ?? clip.fps, elapsedMs, isLoop)
443  const frame = clip.frames[index] as BodyFrame
444  const pet = canvas(BODY_W, HEIGHT)
445
446  stamp(pet, 0, 0, frame.g, body.palette)
447
448  const eyes = (EXPRESSIONS[expressionName(mode, elapsedMs, frame.e, mood)] as (t: number) => Eyes)(elapsedMs)
449  const shift = mode === 'run' ? 1 : 0
450  stamp(pet, frame.l[0] + shift, frame.l[1], eyes.l, body.eye)
451  stamp(pet, frame.r[0] + shift, frame.r[1], eyes.r, body.eye)
452  eyes.fx?.(pet)
453  // A pet's own think prop, or none, takes the place of the question mark.
454  effects(pet, mode, index, elapsedMs, body.props.think !== undefined)
455
456  const drawProp = mode === 'run' ? undefined : propOf(body, mode)
457  const trail = trailWidth(minis.length)
458  const out = canvas(trail + (drawProp ? PROP_X + PROP_W : BODY_W), HEIGHT)
459  minis.slice(0, MAX_MINIS).forEach((m, k) => drawMini(out, trail - (k + 1) * MINI_W, m, k, body))
460  overlay(out, pet, trail)
461  if (drawProp) {
462    const prop = canvas(PROP_W, HEIGHT)
463    drawProp(prop, elapsedMs)
464    overlay(out, prop, trail + PROP_X)
465  }
466  // Running or jumping left mirrors the whole picture, so the trail stays behind the pet. Neither mode has a prop.
467  if ((mode === 'run' || mode === 'jump') && dir === -1) {
468    mirror(out)
469  }
470
471  return out
472}
473
474/** The pet resting with one face, by name from FACES, at `t` ms into the face's own motion. */
475export function composeFace(body: Body, name: string, t: number): Canvas {
476  const frame = body.clips.stand.frames[0] as BodyFrame
477  const c = canvas(BODY_W, HEIGHT)
478  stamp(c, 0, 0, frame.g, body.palette)
479  const expression = EXPRESSIONS[name] ?? (EXPRESSIONS.open as (t: number) => Eyes)
480  const eyes = expression(Math.max(0, t))
481  stamp(c, frame.l[0], frame.l[1], eyes.l, body.eye)
482  stamp(c, frame.r[0], frame.r[1], eyes.r, body.eye)
483  eyes.fx?.(c)
484
485  return c
486}
487
488// ---- encoders ----
489
490const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
491
492function base64(bytes: number[]) {
493  let out = ''
494  for (let i = 0; i < bytes.length; i += 3) {
495    const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
496    out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
497    out += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
498    out += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
499  }
500
501  return out
502}
503
504/** The `w`×`h` part of `c` from column `x`, row `y`. */
505export function crop(c: Canvas, x: number, y: number, w: number, h: number): Canvas {
506  const out = canvas(w, h)
507  for (let j = 0; j < h; j++) {
508    for (let i = 0; i < w; i++) {
509      out.px[j * w + i] = c.px[(y + j) * c.w + x + i] as number
510    }
511  }
512
513  return out
514}
515
516/** Raster `cells`: little-endian u32 triplets [glyph, fg, bg], two pixels per cell as ▀ and ▄. */
517export function encodeCells(c: Canvas) {
518  const bytes: number[] = []
519  const word = (v: number) => bytes.push(v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255)
520  for (let r = 0; r < c.h / 2; r++) {
521    for (let x = 0; x < c.w; x++) {
522      const top = c.px[2 * r * c.w + x] as number
523      const bottom = c.px[(2 * r + 1) * c.w + x] as number
524      if (top === NONE && bottom === NONE) {
525        word(0x20)
526        word(DEFAULT)
527        word(DEFAULT)
528      } else if (bottom === NONE) {
529        word(0x2580)
530        word(top)
531        word(DEFAULT)
532      } else if (top === NONE) {
533        word(0x2584)
534        word(bottom)
535        word(DEFAULT)
536      } else {
537        word(0x2580)
538        word(top)
539        word(bottom)
540      }
541    }
542  }
543
544  return base64(bytes)
545}
546
547export function encodeSvg(c: Canvas) {
548  const paths = new Map<number, string[]>()
549  for (let y = 0; y < c.h; y++) {
550    let x = 0
551    while (x < c.w) {
552      const color = c.px[y * c.w + x] as number
553      let n = 1
554      while (x + n < c.w && c.px[y * c.w + x + n] === color) {
555        n += 1
556      }
557      if (color !== NONE) {
558        paths.set(color, [...(paths.get(color) ?? []), `M${x} ${y}h${n}v1h-${n}z`])
559      }
560      x += n
561    }
562  }
563  const body = [...paths].map(([color, d]) => `<path fill="#${color.toString(16).padStart(6, '0')}" d="${d.join('')}"/>`).join('')
564
565  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${c.w} ${c.h}" shape-rendering="crispEdges">${body}</svg>`
566}
567
hooks/scene.ts 92 lines
1import { HEIGHT, canvas, hash, overlay, stamp } from './pixels'
2import type { Body, Canvas } from './pixels'
3
4/**
5 * A theme's scene, in the pet's palette: a ground tile, a sky drawing that stays put, the obstacles a running pet
6 * leaps, and decor behind it.
7 */
8export type Scene = { ground?: string[]; sky?: string[]; obstacles: string[][]; decor: string[][]; every: number }
9/** The columns one obstacle fills on the band: `w` of them from `x`. */
10export type Span = { x: number; w: number }
11/** A drawing at column `x`; `drift`, when set, is the milliseconds it takes to drift one column left. */
12type Placed = { x: number; rows: string[]; drift?: number }
13/** Where a scene's obstacles and decor sit on a band `width` columns wide. */
14export type SceneLayout = { width: number; obstacles: Placed[]; decor: Placed[] }
15
16export const GROUND_H = 2 // pixels: one row of cells below the pet
17/** The largest ground tile, obstacle, and decor, in pixels, and how many obstacles and decor a scene keeps. */
18export const SCENE_SIZE = { ground: { w: 16, h: GROUND_H }, sky: { w: 16, h: 12 }, obstacle: { w: 8, h: 6 }, decor: { w: 16, h: HEIGHT }, items: 4 }
19/** The least and most columns between obstacles; the least leaves room to land a leap and take off for the next. */
20export const EVERY = { min: 30, max: 120, normal: 40 }
21const FIRST_OBSTACLE = 24 // columns left clear, so a pet that starts at the left edge stands clear of them
22const DECOR_EVERY = 12
23/** The fastest and slowest a raised decor drawing drifts: milliseconds per column. */
24export const DRIFT_MS = { fast: 1100, slow: 1900 }
25const SKY_AT = { right: 6, top: 1 } // columns from the band's right edge, pixels from its top
26
27const widthOf = (rows: string[]) => Math.max(...rows.map(r => r.length))
28const isRaised = (rows: string[]) => /^\.*$/.test(rows[rows.length - 1] ?? '')
29
30/** Lays out `scene` on a band `width` columns wide. The same scene and width always lay out the same way. */
31export function layScene(scene: Scene, width: number): SceneLayout {
32  const obstacles: Placed[] = []
33  for (let x = FIRST_OBSTACLE, k = 0; scene.obstacles.length > 0; k++) {
34    const rows = scene.obstacles[k % scene.obstacles.length] as string[]
35    if (x + widthOf(rows) > width) {
36      break
37    }
38    obstacles.push({ x, rows })
39    x += widthOf(rows) + scene.every + Math.round(((hash(k) - 0.5) * scene.every) / 3)
40  }
41  const isClear = (x: number, w: number) => obstacles.every(o => x + w < o.x - 1 || x > o.x + widthOf(o.rows))
42  const decor: Placed[] = []
43  for (let x = 2, k = 0; scene.decor.length > 0; k++) {
44    const rows = scene.decor[Math.floor(hash(k + 101) * scene.decor.length)] as string[]
45    const w = widthOf(rows)
46    if (x + w > width) {
47      break
48    }
49    if (isClear(x, w)) {
50      decor.push(isRaised(rows) ? { x, rows, drift: Math.round(DRIFT_MS.fast + hash(k + 503) * (DRIFT_MS.slow - DRIFT_MS.fast)) } : { x, rows })
51    }
52    x += w + DECOR_EVERY + Math.round(hash(k + 307) * DECOR_EVERY)
53  }
54
55  return { width, obstacles, decor }
56}
57
58export const obstacleSpans = (layout: SceneLayout): Span[] => layout.obstacles.map(o => ({ x: o.x, w: widthOf(o.rows) }))
59
60/**
61 * The band with a scene at `ms`: the sky drawing near the top right, decor, then obstacles standing on the ground, then
62 * `picture` (the pet as `compose` drew it) from column `left`. Decor with a `drift` moves left and comes back in from
63 * the right edge. The canvas is `layout.width` wide; its bottom GROUND_H rows hold the ground.
64 */
65export function drawBand(body: Body, scene: Scene, layout: SceneLayout, picture: Canvas, left: number, ms: number): Canvas {
66  const c = canvas(layout.width, HEIGHT + GROUND_H)
67  if (scene.sky) {
68    stamp(c, layout.width - widthOf(scene.sky) - SKY_AT.right, SKY_AT.top, scene.sky, body.palette)
69  }
70  for (const d of layout.decor) {
71    const y = HEIGHT - d.rows.length
72    if (d.drift === undefined) {
73      stamp(c, d.x, y, d.rows, body.palette)
74      continue
75    }
76    const x = (((d.x - Math.floor(ms / d.drift)) % layout.width) + layout.width) % layout.width
77    stamp(c, x, y, d.rows, body.palette)
78    stamp(c, x - layout.width, y, d.rows, body.palette) // the part past the right edge, back in at the left
79  }
80  for (const o of layout.obstacles) {
81    stamp(c, o.x, HEIGHT - o.rows.length, o.rows, body.palette)
82  }
83  if (scene.ground) {
84    for (let x = 0; x < layout.width; x += widthOf(scene.ground)) {
85      stamp(c, x, HEIGHT, scene.ground, body.palette)
86    }
87  }
88  overlay(c, picture, left)
89
90  return c
91}
92
hooks/status.ts 114 lines
1import type { Mode } from '../types'
2
3export type ToolMode = 'read' | 'search' | 'edit' | 'bash' | 'web' | 'agent'
4
5const TOOL_MODE: Record<string, ToolMode> = {
6  Read: 'read',
7  Grep: 'search',
8  Glob: 'search',
9  Edit: 'edit',
10  MultiEdit: 'edit',
11  Write: 'edit',
12  NotebookEdit: 'edit',
13  TodoWrite: 'edit',
14  Bash: 'bash',
15  WebFetch: 'web',
16  WebSearch: 'web',
17  Task: 'agent',
18  Agent: 'agent',
19}
20
21const MAX_TARGET = 24
22
23export function toolMode(tool: string): ToolMode {
24  return TOOL_MODE[tool] ?? 'bash'
25}
26
27const clip = (s: string) => (s.length > MAX_TARGET ? `${s.slice(0, MAX_TARGET - 1)}…` : s)
28const text = (v: unknown) => (typeof v === 'string' ? v : '')
29
30/** What the tool works on, as the status line names it: a file, a pattern, a command, a host. */
31export function targetOf(tool: string, input: Record<string, unknown>) {
32  switch (tool in TOOL_MODE ? tool : 'other') {
33    case 'Read':
34    case 'Edit':
35    case 'MultiEdit':
36    case 'Write':
37      return clip(text(input.file_path).split('/').pop() ?? '')
38    case 'NotebookEdit':
39      return clip(text(input.notebook_path).split('/').pop() ?? '')
40    case 'TodoWrite':
41      return 'the todo list'
42    case 'Grep':
43    case 'Glob':
44      return clip(text(input.pattern))
45    case 'Bash':
46      return clip(text(input.command).split('\n')[0] ?? '')
47    case 'WebFetch':
48      return clip(text(input.url).replace(/^https?:\/\//, '').split('/')[0] ?? '')
49    case 'WebSearch':
50      return clip(text(input.query))
51    case 'other':
52      return clip(tool.split('__').pop() ?? tool)
53    default:
54      return ''
55  }
56}
57
58/** The mod's status lines by mode; `{}` stands for the target. A pet's own `lines` replace a mode's. */
59export const LINES: Record<Mode, string[]> = {
60  idle: ['hmm, what shall we build?', 'counting pixels…', '*stretches*', 'tidying up', 'ready when you are'],
61  sleep: ['zzz… wake me with a prompt', 'zzz… dreaming in pixels', 'zzz…'],
62  think: ['pondering', 'chewing on the problem', 'asking around', 'compiling excuses…', 'almost got it…'],
63  read: ['reading {}', 'turning pages of {}', 'skimming {}…', 'turning pages…'],
64  search: ['hunting for "{}"', 'sniffing out "{}"', 'looking under every file'],
65  edit: ['editing {}', 'writing the change', 'shaping {}'],
66  bash: ['$ {}', 'typing: {}', 'crossing fingers…'],
67  web: ['fetching {}', 'surfing to {}', 'waiting for the net…'],
68  agent: ['sending out the minis', 'the little ones are on it'],
69  run: ['zoom, zoom', 'on it!', 'hop hop hop'],
70  jump: ['here we go!', 'on it!'],
71  cheer: ['ta-da!', 'all done!', 'nailed it ✦'],
72  error: ['oops, that one bit back', 'that did not work', 'ouch…'],
73}
74
75const SWAP_MS = 4000
76
77/** The columns a terminal gives `text`: two for an emoji, none for a joiner or variation selector. */
78export const lineWidth = (text: string) =>
79  [...text].reduce((n, ch) => n + (/[\u200d\ufe0e\ufe0f]/.test(ch) ? 0 : /\p{Extended_Pictographic}/u.test(ch) ? 2 : 1), 0)
80
81// Mid-tone colors, so the line reads on a dark terminal and on a light one.
82const LINE_COLOR: Record<Mode, string> = {
83  idle: '#3b9dff',
84  sleep: '#8b8ff0',
85  think: '#a77bf3',
86  read: '#e8a33d',
87  search: '#e8a33d',
88  edit: '#e0b400',
89  bash: '#2fbf5b',
90  web: '#14b3c9',
91  agent: '#3b9dff',
92  run: '#3b9dff',
93  jump: '#3b9dff',
94  cheer: '#e0b400',
95  error: '#f0506e',
96}
97
98/** The color of the status line and its arrow in this mode: the pet's own, else the mod's. */
99export const lineColor = (mode: Mode, own: Partial<Record<Mode, string>> = {}) => own[mode] ?? LINE_COLOR[mode]
100
101/**
102 * A line for the mode, from the pet's `own` lines when it has them; the pick moves on every 4 s and starts
103 * differently per `since`. With no target, only lines that need none.
104 */
105export function statusLine(mode: Mode, since: number, elapsedMs: number, target: string, own?: string[]) {
106  const all = own ?? LINES[mode]
107  const untargeted = all.filter(l => !l.includes('{}'))
108  const lines = target || untargeted.length === 0 ? all : untargeted
109  const seed = Math.abs(Math.floor(since / 100)) % lines.length
110  const line = lines[(seed + Math.floor(Math.max(0, elapsedMs) / SWAP_MS)) % lines.length] as string
111
112  return line.replace('{}', target || 'something')
113}
114
types/index.d.ts 35 lines
1export type Mode =
2  | 'idle'
3  | 'sleep'
4  | 'think'
5  | 'read'
6  | 'search'
7  | 'edit'
8  | 'bash'
9  | 'web'
10  | 'agent'
11  | 'run'
12  | 'jump'
13  | 'cheer'
14  | 'error'
15
16export type Anim = {
17  mode: Mode
18  since: number
19  x: number
20  dir: 1 | -1
21  tick: number
22  target: string
23  working: boolean // whether a turn was running at the last tick
24  leap?: Leap // a running pet's leap over an obstacle, until it lands
25}
26
27/** A leap from column `from` to column `to`, begun at `since`. The pet plays its jump clip, slowed, on the way. */
28export type Leap = { since: number; from: number; to: number }
29
30declare module 'claude-code' {
31  interface PluginState {
32    'pixel-pet': { anim: Anim }
33  }
34}
35