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

<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">
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.
| When | The pet |
|---|---|
| Claude is idle | Breathes, blinks, and looks around. Falls asleep after the Sleep after time, a minute by default. |
| A turn starts | Jumps |
| A tool call ends | Runs back and forth for 4 seconds |
| Claude thinks longer | Looks around, with ? and dots |
Read | Reads a book: reading app.ts |
Grep, Glob | Sweeps a magnifier: hunting for "useState" |
Edit, MultiEdit, Write, NotebookEdit, TodoWrite | Writes with a pen: editing app.ts |
Bash, and any tool not listed | Types in a small terminal: $ npm test |
WebFetch, WebSearch | Spins a globe: fetching docs.anthropic.com |
| A subagent starts | Smiles. 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 fails | x x eyes and a sweat drop |
| A turn ends | Cheers |
A pixel window below the prompt holds up to three bars:
/compact under 10 %.reset in 4h26m.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.
<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:
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.
In a session, run /plugin configure pixel-pet@pixel-pet.
| Setting | Default | What it does |
|---|---|---|
| Speed | normal | How quickly the pet runs and animates: slow, normal, or fast. |
| Sleep after (seconds) | 60 | Idle time before the pet falls asleep. 0 keeps it awake. |
| HUD | on | The HP, MP, and ST bars below the prompt. |
| Status line | on | The text beside the pet. |
| Name files and commands | on | The status line names the file, pattern, command, host, or search query a tool works on. Turn it off to share your screen. |
| Subagent minis | on | A 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.
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.
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.
A mod runs inside Claude Code with your permissions. pixel-pet:
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;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.
.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.
Made by halluqinate. Say hi on X, Instagram, TikTok, or LinkedIn.
hooks/register.tsx 405 lines1import { 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}
405hooks/anim.ts 151 lines1import 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}
151hooks/hud.ts 184 lines1import 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
184hooks/minis.ts 39 lines1import 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}
39hooks/theme.ts 515 lines1import 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}
515hooks/preview.ts 219 lines1import 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}
219hooks/settings.ts 33 lines1/** 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}
33hooks/pixels.ts 567 lines1import 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}
567hooks/scene.ts 92 lines1import { 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}
92hooks/status.ts 114 lines1import 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}
114types/index.d.ts 35 lines1export 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