SLOPSHOPPER

oxen-pet

A pixel Luffy above the Claude Code prompt who acts out each tool call in five gears, an HP/MP/ST HUD for context, rate limits, and prompt cache, a shield that…

newpanebandspinnerguardcommand
★ 2v1.1.3MITupdated 2026-10-06thanhnhoncntt/oxen-pet/plugins/oxen-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · oxen-pet
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ oxen-pet │ ⏺ Read(src/auth.ts) │ oxen-pet: the luffy theme does not read │ ⎿ Read 6 lines │ (ENOENT: no such file │ ⏺ Update(src/auth.ts) │ /plugins/oxen-pet/assets/luffy.json). │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ oxen-pet │ │ oxen-pet: the luffy theme does not read │ ● Done. refresh now rejects expired claims and logs an audit event. │ (ENOENT: no such file │ │ /plugins/oxen-pet/assets/luffy.json). │ ✻ Worked for 42s · done 4:20 PM ╰────────────────────────────────────────────╯ › /pet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ oxen-pet: oxen-pet: Error: ENOENT: no such file /plugins/oxen-pet/assets/luffy.json
README

oxen-pet

A pixel pet and an RPG-style usage HUD for Claude Code

A pixel Luffy lives above your Claude Code prompt and acts out every tool call, shifting through five gears as Claude reads, runs, edits, fails and wins. Below the prompt, a game HUD shows your context window, 5-hour and weekly rate limits, and prompt cache, so you always know how much is left. Turn on its shield, and in bypass mode it stops destructive commands until you say so; failing tests summon a bug boss to beat.

Claude Code mod Version License: MIT No network Audited fork

<img src="docs/images/demo.gif" alt="oxen-pet in Claude Code: a pixel Luffy reads, searches, fetches, edits and runs tests while the HP, MP and ST bars below the prompt track context, rate limits and prompt cache" width="860">

Install · The pet · The HUD · The shield · /pet · Make it yours · Settings · Security · FAQ · oxen-meter


Why oxen-pet

  • 🎮 See what Claude is doing at a glance. The pet reads a book on Read, sweeps a magnifier on Grep, types on Bash, spins a globe on WebFetch, and cheers when a turn ends.
  • 📊 Never run out of usage by surprise. HP, MP and ST bars track the context window and the 5-hour and 7-day rate limits of a Pro or Max plan, with the time to each reset.
  • 🧭 Know whether you can keep going. A pace mark on each limit bar shows where you would be at an even burn, and MP warns empty ~1h20m before the 5-hour limit runs out.
  • 🔥 Keep the prompt cache warm. A countdown beside HP shows how long the cache lasts after the last turn, so you know when the next message gets more expensive.
  • 🛡️ Run bypass mode without fear, if you want a second check. Turn on Shield, and before rm -rf, git push --force, git reset --hard, DROP TABLE and other destructive commands run unasked, the pet raises a shield and asks you, with how many files each target holds. It is off by default.
  • 🐛 Make failing tests a game. A failed test run brings a bug boss into the band; the next green run defeats it.
  • 📈 See your session at a glance. /pet opens a pane with tool calls, files touched, test runs, and your burn rate.
  • 🎨 Make your own pet. Ask Claude for a cat, a duck or an alien with its own props, scene, status lines and HUD colors. Claude shows you a preview page before anything changes.
  • 🔒 Safe to run. No network, no processes, no environment variables, no tokens spent. It is a hardened, audited fork of pixel-pet.

oxen-meter: the prompt cache, for a team

The same marketplace has a second plugin, with no pet. oxen-meter shows the prompt cache of every session, subagent and model while it runs, asks before a cold resume sends a whole context to the model again, counts the quota each session used, and exports anonymized numbers for a team report. A command-line companion does the same for Codex CLI and Devin CLI, from their own logs and hooks, so one report covers all three.

<img src="docs/images/meter-pane.png" alt="The oxen-meter pane: the cache hit rate, token counts and TTLs of the session, the main thread warm for 53 more minutes, and a subagent that sat 7 minutes shown cold in red" width="860">

claude plugin install oxen-meter@oxen-pet

Then type /meter. The picture is from a scripted demo; the oxen-meter guide has real captures from a live machine beside it: the pane, the cold resume question, the Codex and Devin hooks, and a month's report. It also covers setting up Codex and Devin, reading the report, the team report, and what it records.

Install

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

claude plugin marketplace add thanhnhoncntt/oxen-pet
claude plugin install oxen-pet@oxen-pet

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

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

Luffy's five gears

GearWhenLuffy
Gear 1Idle, sleeping, thinking, reading, searchingStraw hat, red vest, yellow sash; gnaws on meat while he reads. "Gonna be King of the Pirates!"
Gear 2Bash, running, jumpingPink steam; a flaming Red Hawk punch on every command. "Gear Second! $ npm test"
Gear 3EditingA giant Haki fist: Elephant Gun. "Gomu Gomu no Elephant Gun!"
Gear 4A failed call, the shield upBoundman, and a Kong Gun when a call fails. "Not on my ship!"
Gear 5A turn ends, a boss fallsNika: white cloud hair, clouds curling behind his back, the drums of liberation. "Shishishi! Freedom!"

He calls the web on a Den Den Mushi ("puru puru puru…"), throws a Gatling and sends straw-hat minis out as subagents ("Zoro, don't get lost!"), and runs over the sea under the Jolly Roger.

What the pet does

WhenThe pet
Claude is idleBreathes, blinks, and looks around. Falls asleep after the Sleep after time, a minute by default.
A turn startsJumps
A tool call endsRuns back and forth for 4 seconds
Claude thinks longerLooks around, with ? and dots
ReadReads a book
Grep, GlobSweeps a magnifier
Edit, MultiEdit, Write, NotebookEdit, TodoWriteWrites with a pen
Bash, and any tool not listedTypes in a small terminal
WebFetch, WebSearchSpins a globe
A subagent startsSmiles. A mini joins the trail behind the pet until that subagent finishes.
A tool call failsx x eyes and a sweat drop
A turn endsCheers
A destructive command waits for youHolds up a shield (The shield)
A test run failsA bug boss walks in at the right, with a pip per failed run. The next passing run defeats it, and the pet cheers.
The context runs lowSays so in red, and a toast suggests /compact or a hand-off

The pet's face follows the HUD too: it looks worried as the context fills up, and tired when a rate limit runs low. With Name files and commands on, the status line also names the target, such as reading app.ts or $ npm test. It is off by default.

The HUD

<img src="docs/images/hud.png" alt="The oxen-pet HUD in one line: HP 78% with the prompt cache warm for an hour, MP 88% resetting in 3h39m, ST 81% resetting in 3d0h" width="819">

One line below the prompt holds up to three bars. Each shows what is left, not what is used.

BarTracksBeside the reading
♥ HPThe context window leftcache 52m: how long the prompt cache stays warm after the last turn, then cache cold. ⚠ HP and /compact under 20 %.
✦ MPThe 5-hour rate limit leftThe time to its reset, and how far ahead of an even pace you are (15% spare) or behind (5% over). empty ~1h20m in red when the burn so far would empty it before the reset.
◆ STThe 7-day (weekly) rate limit leftThe time to its reset, and spare or over as for MP.

Reading the pace mark. The light column on the MP and ST bars is where the bar would be if the limit were used evenly through its window. Fill to the right of the mark means you are using less than the pace and can push harder. Fill to the left means you are burning faster than the window allows.

The line shows each reading and one short detail: the cache for HP, the time to reset for MP and ST, or a warning in their place. For every detail in the table above, spare and over included, set HUD layout to stacked: a framed window with one bar per line. A terminal too narrow for the line (about 90 columns) gets the stacked window. One narrower than the window (64 columns), such as a pane split beside other agents, gets one bar per line with no window, the bars shorter and the text cut at the edge; under 16 columns the HUD hides.

HP turns yellow at 50 % or less and red at 25 % or less. As it drops under 20 %, a toast suggests /compact, or handing off to a fresh session, once until HP climbs back to 30 %. MP and ST turn red under 15 %, and show on Pro and Max plans once a response has reported its limit.

The shield

The shield is off by default: in bypass mode it asked about every destructive command, which is more than most people running bypass mode want. Turn Shield on in /plugin configure oxen-pet@oxen-pet for a second check.

Bypass mode and auto mode are fast, until Claude runs the wrong rm -rf. The shield steps in only when Claude Code would run a Bash command without asking you, and only for commands that destroy work:

oxen-pet shield: rm -rf build dist deletes files and folders for good. build: 132 files · dist: 2000+ files. Run it?

  • Block it (the first option) refuses the call, and Claude reads that you blocked it. Run it lets it through.
  • Covered: recursive rm, git push --force, git reset --hard, git clean -f, git checkout -- ., git restore, git branch -D, git stash drop/clear, DROP/TRUNCATE TABLE, terraform destroy, kubectl delete, docker system prune, find -delete, dd and mkfs.
  • When Claude Code asks you anyway (default mode), the shield adds what the command deletes to its dialog and asks nothing itself.
  • With no one to answer (claude -p, CI), the command is blocked. Leave Shield off for unattended runs.

/pet: session stats

Type /pet to open a pane with what this session did; /pet again closes it.

Session    1h12m · 14 turns
Tools      96 calls: 31 read · 12 search · 18 edit · 33 bash · 2 agent · 3 failed
Files      22 read · 9 edited
Tests      6 runs: 4 passed · 2 failed · 1 boss beaten
Shield     2 asked: 1 blocked · 1 ran
Burn       MP 9%/h · context 41% used

File names stay out unless Name files and commands is on.

Make it yours

Ask Claude in any session, or run /oxen-pet:oxen-pet:

"Make my pet an orange cat with a fish instead of the book, and put it on the moon."

Claude can change the pet, its props, minis, status lines, HUD colors and labels, its look in each mode (forms, like Luffy's gears), or add a scene with ground, sky and obstacles the pet leaps over. It writes a preview page (/tmp/oxen-pet-preview-*.html) with every motion and face first, and sets the theme only when you approve. To undo, ask for the default pet back.

Your pets survive updates. Claude saves each pet you make as <name>.theme.json in ~/.claude/oxen-pet/themes/, outside the plugin's install folder, which an update replaces. Drop theme files there yourself, or point Custom folder at a folder you sync.

/pet theme          list the built-in pets and yours
/pet theme zoro     switch now, no tokens, and keep it for later sessions

Set Theme to make one the pet every session starts with. Built in: luffy (default), slime, duck, and alien (which uses every field). The theme format is in FORMAT.md.

Settings

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

SettingDefaultWhat it does
SpeednormalHow quickly the pet runs and animates: slow, normal, or fast.
Sleep after (seconds)60Idle time before the pet falls asleep. 0 keeps it awake.
HUDonThe HP, MP, and ST bars below the prompt.
Status lineonThe text beside the pet.
Name files and commandsoffThe status line names the file, pattern, command, host, or search query a tool works on.
Subagent minisonA mini behind the pet for each running subagent.
Cache timer1hHow long the HUD counts the prompt cache warm after a turn: 1h, 5m, or off.
ThemeluffyThe pet a session starts with: luffy, slime, duck, alien, or the name of one of your own.
Custom folderemptyThe folder of your own <name>.theme.json files. Empty: ~/.claude/oxen-pet/themes.
HUD layoutrowrow: the three bars in one line with no frame, each with its reading and one short detail; stacked on a terminal under about 90 columns. stacked: a framed window, one bar per line, with every detail.
ShieldoffAsk before a destructive Bash command runs unasked. With no one to answer, it is blocked.
Bug bossonA failed test run brings a bug boss into the band.

From a shell:

echo '{"speed": "fast", "targets": "true"}' | claude plugin configure oxen-pet@oxen-pet --values-stdin

Settings apply after Claude Code restarts.

Privacy and security

oxen-pet runs inside your Claude Code session, so it is built to do as little as possible:

  • No network requests, no processes, no environment variables. The shipped code is checked for each of these before every push; the commands are in SECURITY-AUDIT.md.
  • No tokens spent. The HUD reads the same usage figures as the status line. It never calls the model.
  • The shield reads, it never runs. It matches the command's text and counts files under an rm target with the file system's own listing, never following a symbolic link. It refuses a call only on your Block it, or when no one can answer. If the shield itself fails, Claude Code's own decision stands.
  • Nothing on screen you did not choose. File names and commands stay out of the status line unless you turn on Name files and commands, so a shared screen or a recording shows none.
  • One guarded file write. The theme preview writes only oxen-pet-preview*.html files, and refuses .. paths and symbolic links.
  • No self-updating marketplace. Upstream changes come in only by a full read of the diff and a cherry-pick.

FAQ

Install oxen-pet. The MP bar is the 5-hour limit and the ST bar the weekly (7-day) limit, each with the percentage left and the time to its reset. They appear on Pro and Max plans after the first response of a session.

You have 38 points more of the limit left than you would at an even pace. For example, three days before a weekly reset an even pace leaves about 43 %; at 81 % left you are 38 points ahead.

Claude Code caches the conversation so each new message re-reads it cheaply. The cache lapses some time after the last turn: an hour or five minutes, depending on your setup. Set Cache timer to match. Once it reads cache cold, the next message rebuilds the cache and costs more of your limit.

In any terminal with 24-bit color, and in the Desktop app's Code tab, where the pet, its scene, the boss and the HUD draw as SVG. The /pet pane shows on every surface that shows panes. The VS Code chat panel, claude -p, and cloud sessions do not show the band.

Safer with the shield on (it is off by default): a destructive Bash command waits for your answer instead of running. It is a pattern match, not a sandbox, so it can miss a command spelled in a way it does not know (a script that deletes files, say). Keep your work committed, and use Claude Code's own permission rules for anything that must never run.

oxen-pet is a fork of pixel-pet with a security audit and fixes (a guarded preview write, file names hidden by default), plus the pace marks, the MP forecast, the prompt cache timer, the shield, the bug boss, the /pet pane and the desktop HUD. What each version changed is in CHANGELOG.md.

Update

claude plugin marketplace update oxen-pet
claude plugin update oxen-pet@oxen-pet

Installed copies update only when version in plugins/oxen-pet/.claude-plugin/plugin.json changes. What each version changed is in CHANGELOG.md.

Develop

.claude-plugin/marketplace.json   the repo is a marketplace with two plugins: oxen-pet and oxen-meter
plugins/oxen-pet/                 the plugin: a Claude Code mod
  .claude-plugin/plugin.json      name, version, and settings
  hooks/hooks.json                points Claude Code at register.tsx
  hooks/register.tsx              wires Claude Code's events to the modules, and serves the tools
  hooks/anim.ts                   what the pet does on each tick
  hooks/pixels.ts                 draws a frame
  hooks/theme.ts                  reads a theme and makes its frames
  hooks/scene.ts                  lays out and draws a theme's scene
  hooks/preview.ts                writes the preview page
  hooks/previewPath.ts            where preview_theme may write
  hooks/status.ts                 the status line
  hooks/hud.ts                    the HP, MP, and ST bars, the pace marks, the cache timer, the low-context alert
  hooks/guard.ts                  the shield: which commands destroy work, and the question it asks
  hooks/boss.ts                   the bug boss: test runs, hits, and its drawing
  hooks/stats.ts                  what the /pet pane counts
  hooks/minis.ts                  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
  assets/                         luffy (default), slime, duck, alien themes
  hooks/custom.ts                 the custom themes folder and theme names
  skills/oxen-pet/                the skill that draws a pet with you, and the pet format
plugins/oxen-meter/               the prompt cache meter: a mod with no band
  hooks/register.tsx              wires Claude Code's events to the modules
  hooks/record.ts                 step and event records, and the collector
  hooks/provider.ts               a model's provider and family, and what its tokens weigh
  hooks/analyze.ts                hit rate, token equivalent, TTLs, gap curves, cold resumes, handoffs, quota, anti-patterns
  hooks/sessionFile.ts            a session's file: written whole, kept under 3 MiB, emptied when expired
  hooks/dataPath.ts               where the meter (and its CLI) may write
  hooks/resume.ts                 the cold resume guard: who a message resumes, the risk, what the user is asked
  hooks/hookGuard.ts              the CLI's guard for Codex and Devin: the risk, the warning, the held-back prompt
  hooks/codexLog.ts               Codex's rollout files, a line at a time, as records
  hooks/devinLog.ts               Devin's session nodes as records
  hooks/exportFile.ts             /meter export: the sessions of the last days, anonymized
  hooks/project.ts                the project's name, hashed
  hooks/report.ts                 the /meter pane's rows and /meter report
  hooks/codex.ts                  which Bash calls are Codex handoffs or outcomes
  hooks/timing.ts                 how long the meter's own hooks take
  hooks/settings.ts               reads the settings
  README.md                       for the team: install, use, Codex and Devin, reading the report, what it records
tools/meter/oxen-meter.mjs        the CLI for Codex and Devin: import, report, export, setup, hook (ships in the clone)
tools/meter/lib/                  its parts: files and guards, imports, hooks, setup
tools/meter/aggregate.mjs         builds the team report from exports (developer tool)
tools/preview/build.mjs           writes the preview of a theme file
tools/demo/record.mjs             records docs/images/demo.gif and hud.png (developer tool, never shipped)
tools/demo/meter.mjs              records docs/images/meter-demo.gif and meter-pane.png (the same)
docs/                             design spec and plan of the fork, and the README images

Load your working copy for one session with claude --plugin-dir ./plugins/oxen-pet. Before a push, run:

claude plugin validate . --strict
claude plugin validate plugins/oxen-pet --strict
claude plugin test plugins/oxen-pet
claude plugin validate plugins/oxen-meter --strict
claude plugin test plugins/oxen-meter
node --test 'tools/meter/*.test.mjs'

After one --plugin-dir session, npx -p typescript tsc -p plugins/oxen-pet type-checks the mod. node tools/preview/build.mjs [theme file] (Node 22.18+) writes tools/preview/preview.html. node tools/demo/record.mjs (Node 22.18+, Google Chrome and ffmpeg) records the README's GIF from the mod's own modules: no screen capture and no tokens. node tools/demo/meter.mjs does the same for oxen-meter's guide, from the meter's modules; --text prints its key frames without Chrome. Contributor rules are in CLAUDE.md.

License

Luffy, the Straw Hat Jolly Roger and One Piece are © Eiichiro Oda / Shueisha / Toei Animation. The pixel Luffy here is fan art, drawn for this project; oxen-pet is not affiliated with or endorsed by them. The code is MIT.

MIT. Original work © 2026 halluqinate (pixel-pet); fork changes © 2026 NhonNguyen.

<sub>Keywords: Claude Code plugin, Claude Code mod, Claude Code statusline, Claude Code HUD, bypass permissions guard, rm -rf protection, usage tracker, One Piece Luffy pixel art, Gear 5, rate limit monitor, context window, prompt cache, terminal pet, pixel art, Anthropic Claude.</sub>

Source 16 files
hooks/register.tsx 790 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Anim, Mode } from '../types'
5import { TICK_MS, fail, leapClipMs, step } from './anim'
6import { BUILT_IN, DEFAULT_THEME, customDirOf, isThemeName, themeFile, themeList, themeNames } from './custom'
7import { BOSS_W, bossAfter, bossAlt, bossOnScreen, drawBoss, isTestCommand, testOutcome } from './boss'
8import type { Boss } from './boss'
9import { GUARD_OPTIONS, guardLine, guardQuestion, riskOf, sizeOf } from './guard'
10import type { Risk } from './guard'
11import { newStats, noteMp, recordShield, recordTest, recordTool, recordTurn, statsRows } from './stats'
12import { DETAIL_COLOR, HUD_WINDOW_W, ROW_GAP, cacheLeftMin, compactBarW, hudRowWidth, contextAlert, contextAlertText, frameColor, hudFrom, hudRows, mood, windowEdges } from './hud'
13import type { Hud } from './hud'
14import { minisOnScreen, reconcile } from './minis'
15import type { Mini } from './minis'
16import { animate, readTheme, restingFrame } from './theme'
17import { previewPage } from './preview'
18import { previewPathError, previewTargetError } from './previewPath'
19import { readSettings } from './settings'
20import { BODY_W, FACES, HEIGHT, MAX_MINIS, compose, composeFace, crop, encodeCells, encodeSvg, overlay, trailWidth } from './pixels'
21import type { Body } from './pixels'
22import { DESKTOP_BAND_W, GROUND_H, drawBand, layScene, obstacleSpans } from './scene'
23import type { SceneLayout } from './scene'
24import { lineColor, lineWidth, statusLine, targetOf, toolMode } from './status'
25import type { ToolMode } from './status'
26
27const ROWS = 10 // a cell is two pixels tall, so the frames are 20 px high
28const GROUND_ROWS = GROUND_H / 2
29const STATUS_ROOM = 20 // columns kept free beside a running pet for its status line
30const USAGE_EVERY_BEATS = 20
31const AGENTS_EVERY_BEATS = 5
32const SLOW_BEATS: Partial<Record<Mode, number>> = { idle: 2, sleep: 4 } // ticks per redraw while nothing moves fast
33const SVG_PX = 4 // CSS pixels per pet pixel on the desktop
34const HUD_SVG_PX = 6 // CSS pixels per bar pixel on the desktop, so a bar is as tall as its text
35
36const GUARD_HOLD_MS = 2500 // how long the shield stays up after the answer, so it shows even when the dialog hid the band
37const NOTICE_MS = 8000 // how long the pet says the context is almost full
38const NOTICE_COLOR = '#f0506e'
39const BLOCKED = 'The user blocked this command with the oxen-pet shield. Ask them before trying it another way.'
40const UNANSWERED = 'Blocked by the oxen-pet shield: no one answered its question. To let destructive commands run unasked, turn off Shield in /plugin configure oxen-pet@oxen-pet.'
41
42const THEME_KEY = 'theme' // in $.store: the theme set_theme last took
43const CHOSEN_KEY = 'chosen' // in $.store: the theme /pet theme last put on screen, by name
44const OWN_TOOLS = 'mcp__oxen-pet__'
45// Literals, so `claude plugin validate` can read the hooks' matchers.
46const SET_THEME = 'mcp__oxen-pet__set_theme'
47const PREVIEW_THEME = 'mcp__oxen-pet__preview_theme'
48const GET_THEME = 'mcp__oxen-pet__get_theme'
49const PET_COMMAND = 'pet'
50const PANE_ID = 'pet'
51
52const anim = atom({ plugin: 'oxen-pet', key: 'anim' } as const, {
53  mode: 'idle',
54  since: 0,
55  x: 0,
56  dir: 1,
57  tick: 0,
58  target: '',
59  working: false,
60} as Anim)
61
62/** The HUD from fresh usage, or `last` when the usage call fails. */
63async function usageOr($: EngineInterface, now: number, last: Hud | undefined) {
64  try {
65    return hudFrom(await $.session.usage(), now)
66  } catch {
67    return last
68  }
69}
70
71/** The minis after a fresh look at the session's agents, or `last` when the list call fails. */
72async function minisOr($: EngineInterface, now: number, last: Mini[]) {
73  try {
74    return reconcile(last, await $.agent.list(), now)
75  } catch {
76    return last
77  }
78}
79
80/** The stat of `path` with where it lands, or undefined when nothing is there. */
81async function statOf($: EngineInterface, path: string) {
82  try {
83    return await $.fs.stat(path, { resolve: true })
84  } catch {
85    return undefined
86  }
87}
88
89/** Why preview_theme must not write to `path`: its spelling first, then where it leads on disk. */
90async function previewWriteError($: EngineInterface, path: unknown) {
91  const spelled = previewPathError(path)
92  if (spelled !== undefined || typeof path !== 'string') {
93    return spelled
94  }
95  const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
96
97  return previewTargetError(await statOf($, path), await statOf($, path.slice(0, cut + 1)))
98}
99
100/** How many files each of `risk`'s targets holds, undefined where its spelling cannot be sized. */
101async function sizesOf($: EngineInterface, risk: Risk) {
102  const fs = { stat: (path: string) => $.fs.stat(path), list: (path: string) => $.fs.list(path) }
103
104  return Promise.all(risk.targets.map(t => (t.unsized ? undefined : sizeOf(t.path, fs))))
105}
106
107/** 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. */
108function themeReport(pet: Body, notes: string[]) {
109  const made = `${Object.keys(pet.clips).join(', ')}, and ${FACES.length} faces`
110  const noted = notes.length > 0 ? `\n\nNotes, to act on or leave as drawn:\n- ${notes.join('\n- ')}` : ''
111
112  return `The mod made every clip and face from the sprite: ${made}.\n\nResting frame (@ is a pupil, * a cheek):\n${restingFrame(pet)}${noted}`
113}
114
115/** The default pet, from the plugin's own file. */
116async function defaultBody($: EngineInterface) {
117  const read = readTheme(JSON.parse(await $.fs.read(`${$.plugin.root}/assets/${DEFAULT_THEME}.json`)))
118  if (read.errors) {
119    throw new Error(`assets/${DEFAULT_THEME}.json: ${read.errors.join(' ')}`)
120  }
121
122  return animate(read.theme)
123}
124
125/**
126 * A theme by name, as its file spells it: the custom folder's `<name>.theme.json` first, so the user's own wins over a
127 * built-in one of the same name, then the plugin's `assets/<name>.json`. Throws when neither is there or it is not JSON.
128 */
129async function namedTheme($: EngineInterface, name: string, dir: string | undefined): Promise<unknown> {
130  if (!isThemeName(name)) {
131    throw new Error(`No theme named ${name}.`)
132  }
133  if (dir !== undefined && (await $.fs.exists(themeFile(dir, name)).catch(() => false))) {
134    return JSON.parse(await $.fs.read(themeFile(dir, name)))
135  }
136  if ((BUILT_IN as readonly string[]).includes(name)) {
137    return JSON.parse(await $.fs.read(`${$.plugin.root}/assets/${name}.json`))
138  }
139  throw new Error(`No theme named ${name}.`)
140}
141
142/**
143 * The pet a session shows: the theme set_theme kept, else the one /pet theme chose, else the Theme setting's, else
144 * the default. One that no longer reads gives way to the next, with a toast.
145 */
146async function keptBody($: EngineInterface, setting: string, customDir: string) {
147  const kept = await $.store.get(THEME_KEY)
148  if (kept !== undefined) {
149    const read = readTheme(kept)
150    if (!read.errors) {
151      return animate(read.theme)
152    }
153    $.ui.toast(`oxen-pet: your theme no longer reads (${read.errors[0]}). Showing ${DEFAULT_THEME}.`)
154  }
155  const chosen = await $.store.get(CHOSEN_KEY)
156  const name = typeof chosen === 'string' ? chosen : setting
157  try {
158    const read = readTheme(await namedTheme($, name, customDirOf($.plugin.root, customDir)))
159    if (read.errors) {
160      throw new Error(read.errors[0])
161    }
162    return animate(read.theme)
163  } catch (err) {
164    $.ui.toast(`oxen-pet: the ${name} theme does not read (${err instanceof Error ? err.message : String(err)}). Showing ${DEFAULT_THEME}.`)
165  }
166
167  return defaultBody($)
168}
169
170export const register: Register = (on, options) => {
171  const settings = readSettings(options)
172  let isWorking = false
173  let lastToolAt = 0
174  let activeTools = 0
175  let activeMode: ToolMode = 'bash'
176  let activeTarget = ''
177  let bodyColumns = 80
178  let beat = 0
179  let showsError = false
180  let body: Body | undefined
181  let hud: Hud | undefined
182  let lastTurnEndAt: number | undefined // when the main thread's last turn ended, which keeps its prompt cache warm
183  let minis: Mini[] = []
184  let previewed: unknown // the last theme preview_theme drew, for set_theme to apply without resending it
185  let layout: SceneLayout | undefined // the scene of `layoutOf` on a band `bandWidth()` wide
186  let layoutOf: Body | undefined
187  let guarding = 0 // risky commands waiting for the user's answer
188  let shield: { result: 'blocked' | 'ran'; until: number } | undefined // the last answer, shown until `until`
189  let boss: Boss | undefined
190  let alertArmed = true // the low-context alert fires once per drop under LOW_HP
191  let notice: { text: string; until: number } | undefined // what the pet says in place of its status line, until `until`
192  let stats = newStats(0)
193
194  // The band leaves the last column free, so a full row never wraps.
195  const bandWidth = () => Math.max(BODY_W, bodyColumns - 1)
196  /** The layout of the pet's scene on the band as wide as it is now, or undefined for a pet with no scene. */
197  const sceneLayout = (pet: Body) => {
198    if (!pet.scene) {
199      return undefined
200    }
201    if (layout?.width !== bandWidth() || layoutOf !== pet) {
202      layout = layScene(pet.scene, bandWidth())
203      layoutOf = pet
204    }
205    return layout
206  }
207
208  on('session.start', async ($, e, next) => {
209    const now = await $.clock.now()
210    await update($, anim, () => ({ mode: 'idle', since: now, x: 0, dir: 1, tick: 0, target: '', working: false }))
211    hud = await usageOr($, now, undefined)
212    lastTurnEndAt = undefined
213    stats = newStats(now)
214    try {
215      await $.tool.register({
216        name: 'preview_theme',
217        description:
218          'Writes the preview of a oxen-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 `oxen-pet:oxen-pet` skill describes. Returns the resting frame and notes on anything repaired.',
219        inputSchema: {
220          type: 'object',
221          properties: {
222            theme: { type: 'object', description: 'The theme as a JSON object, in the format FORMAT.md documents.' },
223            path: { type: 'string', description: 'Absolute path of the HTML file to write, in an existing folder, named oxen-pet-preview….html, such as /tmp/oxen-pet-preview-cat.html. No `..`, no symbolic link; any other path is refused.' },
224          },
225          required: ['theme', 'path'],
226        },
227      })
228      await $.tool.register({
229        name: 'set_theme',
230        description:
231          'Sets the oxen-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 `oxen-pet:oxen-pet` skill describes, or null for the default pet. Leave `theme` out to set the last theme preview_theme drew in this session. Returns the resting frame and notes on anything repaired.',
232        inputSchema: {
233          type: 'object',
234          properties: { theme: { type: ['object', 'null'], description: 'The theme as a JSON object, null for the default pet, or left out for the last preview.' } },
235        },
236      })
237      await $.tool.register({
238        name: 'get_theme',
239        description:
240          'Returns the oxen-pet theme on screen: the one set_theme kept, or the one chosen by name (by default Luffy), and where the user\'s own themes folder is. Start a change from it, so set_theme keeps everything the change leaves alone.',
241        inputSchema: { type: 'object', properties: {} },
242      })
243    } catch {
244      // Without the tools the pet still draws; only changing the theme is missing.
245    }
246    try {
247      await $.command.register({ name: PET_COMMAND, description: 'oxen-pet: open or close the session stats pane; /pet theme lists or switches pets', argumentHint: '[theme [name]]', immediate: true })
248    } catch {
249      // Without the command the pet and the HUD still draw; only the stats pane is missing.
250    }
251
252    $.clock.every(TICK_MS, async () => {
253      const t = await $.clock.now()
254      beat += 1
255      if (beat % USAGE_EVERY_BEATS === 0) {
256        hud = await usageOr($, t, hud)
257        if (hud?.mp !== undefined) {
258          stats = noteMp(stats, hud.mp, t)
259        }
260        if (settings.hud && hud) {
261          const alert = contextAlert(alertArmed, hud.hp)
262          alertArmed = alert.armed
263          if (alert.alert) {
264            $.ui.toast(contextAlertText(hud.hp), { timeoutMs: 10000 })
265            notice = { text: 'context almost full: /compact?', until: t + NOTICE_MS }
266          }
267        }
268        $.ui.invalidate('ui.render')
269      }
270      if (settings.minis && beat % AGENTS_EVERY_BEATS === 0) {
271        minis = await minisOr($, t, minis)
272      }
273
274      const trail = trailWidth(minis.length)
275      // A boss stands at the right of the band, so the running pet turns before it.
276      const bossRoom = settings.boss && bossOnScreen(boss, t) ? BOSS_W + 2 : 0
277      const room = Math.max(0, bodyColumns - BODY_W - trail - STATUS_ROOM - bossRoom)
278      const scene = body && sceneLayout(body)
279      const obstacles = scene ? obstacleSpans(scene) : []
280      const isGuarding = guarding > 0 || (shield !== undefined && t < shield.until)
281      await update($, anim, a => {
282        const moved = step(a, { isWorking, activeTools, activeMode, activeTarget, lastToolAt, room, obstacles, trail, guarding: isGuarding }, t, settings)
283        // Minis and the boss move on every tick, so they keep the redraw rate up while the pet idles.
284        const slowBeat = minis.length > 0 || bossRoom > 0 ? undefined : SLOW_BEATS[moved.mode]
285        return slowBeat !== undefined && moved.mode === a.mode && beat % slowBeat !== 0 ? a : moved
286      })
287    })
288
289    return next(e)
290  })
291
292  // A subagent's requests carry its own prompt, so only the main thread's turns keep the session's cache warm.
293  on('turn.complete', async ($, e, next) => {
294    const result = await next(e)
295    if (e.agentId === undefined) {
296      lastTurnEndAt = await $.clock.now()
297      stats = recordTurn(stats)
298      $.ui.invalidate('ui.render')
299    }
300
301    return result
302  })
303
304  // The shield: a destructive command that would run unasked waits for the user's answer, the pet holding up its shield.
305  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
306    const verdict = await next(e)
307    // A query (no tool_use_id) asks what would happen, and must never open a dialog.
308    if (!settings.guard || e.tool_use_id === undefined || verdict.decision === 'deny') {
309      return verdict
310    }
311    let command: string
312    let risk: Risk | undefined
313    try {
314      const given = (e.input as { command?: unknown } | undefined)?.command
315      command = typeof given === 'string' ? given : ''
316      risk = riskOf(command)
317    } catch {
318      return verdict
319    }
320    if (!risk) {
321      return verdict
322    }
323    const question = guardQuestion(command, risk, await sizesOf($, risk).catch(() => []))
324    // Claude Code asks already: its dialog shows what the command deletes.
325    if (verdict.decision === 'ask') {
326      return { ...verdict, reason: question }
327    }
328    guarding += 1
329    try {
330      const answer = await $.ui.ask(question, { header: 'Shield', options: [GUARD_OPTIONS.block, GUARD_OPTIONS.run] }).catch(() => undefined)
331      const ran = answer === GUARD_OPTIONS.run
332      shield = { result: ran ? 'ran' : 'blocked', until: (await $.clock.now()) + GUARD_HOLD_MS }
333      stats = recordShield(stats, shield.result)
334      if (ran) {
335        return { decision: 'allow' as const, reason: 'The user let it run when the oxen-pet shield asked.' }
336      }
337
338      return { decision: 'deny' as const, reason: answer === undefined ? UNANSWERED : BLOCKED }
339    } finally {
340      guarding -= 1
341    }
342  })
343
344  on('tool.call', async ($, e, next) => {
345    // The shield's own question is a call of AskUserQuestion; the pet holds its shield through it.
346    if (e.tool.startsWith(OWN_TOOLS) || (guarding > 0 && e.tool === 'AskUserQuestion')) {
347      return next(e)
348    }
349    const t = await $.clock.now()
350    activeTools += 1
351    activeMode = toolMode(e.tool)
352    const input = e as unknown as Record<string, unknown>
353    activeTarget = settings.targets ? targetOf(e.tool, input) : ''
354    lastToolAt = t
355
356    let result: Awaited<ReturnType<typeof next>>
357    try {
358      result = await next(e)
359    } finally {
360      activeTools = Math.max(0, activeTools - 1)
361      lastToolAt = await $.clock.now()
362    }
363    const failed = 'deny' in result || ('isError' in result && result.isError === true)
364    if (failed) {
365      await update($, anim, a => fail(a, lastToolAt))
366    }
367    stats = recordTool(stats, e.tool, input, failed)
368    const outcome = e.tool === 'Bash' && typeof input.command === 'string' && isTestCommand(input.command) ? testOutcome(result) : undefined
369    if (outcome) {
370      const before = settings.boss ? bossOnScreen(boss, lastToolAt) : undefined
371      boss = settings.boss ? bossAfter(before, outcome, lastToolAt) : undefined
372      const beat = before !== undefined && before.defeatedAt === undefined && boss?.defeatedAt !== undefined
373      stats = recordTest(stats, outcome, beat)
374      // The pet cheers over a defeated boss.
375      if (beat) {
376        const at = lastToolAt
377        await update($, anim, a => ({ ...a, mode: 'cheer' as const, since: at }))
378      }
379      $.ui.invalidate('ui.render')
380    }
381
382    return result
383  })
384
385  on('tool.call', { tool: PREVIEW_THEME }, async ($, e) => {
386    const { theme, path } = e as unknown as { theme?: unknown; path?: unknown }
387    const read = readTheme(theme)
388    if (read.errors) {
389      return { deny: `No preview was written: ${read.errors.join(' ')}` }
390    }
391    const badPath = await previewWriteError($, path)
392    if (badPath !== undefined || typeof path !== 'string') {
393      return { deny: `No preview was written: ${badPath}` }
394    }
395    const preview = animate(read.theme)
396    await $.fs.write(path, previewPage(preview, read.notes))
397    previewed = theme
398
399    return { result: `Wrote the preview of ${read.theme.name} to ${path}. What is on screen has not changed.\n\n${themeReport(preview, read.notes)}` }
400  })
401
402  on('tool.call', { tool: GET_THEME }, async $ => {
403    const dir = customDirOf($.plugin.root, settings.customDir)
404    const folder = dir === undefined ? '' : `\n\nThe user's own themes go in ${dir} as <name>.theme.json, which updates never touch; /pet theme <name> puts one on screen.`
405    const kept = await $.store.get(THEME_KEY)
406    if (kept !== undefined) {
407      return { result: `The theme set_theme kept:\n\n${JSON.stringify(kept, null, 2)}${folder}` }
408    }
409    const chosen = await $.store.get(CHOSEN_KEY)
410    const name = typeof chosen === 'string' ? chosen : settings.theme
411    const theme = await namedTheme($, name, dir).catch(() => namedTheme($, DEFAULT_THEME, undefined))
412
413    return { result: `No theme is kept, so ${name} is on screen. Its theme:\n\n${JSON.stringify(theme, null, 2)}${folder}` }
414  })
415
416  on('tool.call', { tool: SET_THEME }, async ($, e) => {
417    const given = (e as unknown as { theme?: unknown }).theme
418    if (given === undefined && previewed === undefined) {
419      return { deny: 'The theme was not set: no preview_theme call in this session to apply. Pass `theme`.' }
420    }
421    const value = given === undefined ? previewed : given
422    if (value === null) {
423      await $.store.delete(THEME_KEY)
424      await $.store.delete(CHOSEN_KEY)
425      body = await keptBody($, settings.theme, settings.customDir)
426      $.ui.invalidate('ui.render')
427
428      return { result: 'The default pet is back, for this session and later ones.' }
429    }
430    const read = readTheme(value)
431    if (read.errors) {
432      return { deny: `The theme was not set: ${read.errors.join(' ')}` }
433    }
434    await $.store.set(THEME_KEY, value)
435    await $.store.delete(CHOSEN_KEY)
436    body = animate(read.theme)
437    $.ui.invalidate('ui.render')
438
439    return { result: `The ${read.theme.name} theme is on screen now, for this session and later ones.\n\n${themeReport(body, read.notes)}` }
440  })
441
442  // /pet opens the pane with what the session did, and closes it when it is open.
443  on('command.run', { command: PET_COMMAND }, async ($, e) => {
444    // /pet theme lists the pets; /pet theme <name> puts one on screen, for this session and later ones.
445    const [sub, name] = e.args.trim().split(/\s+/)
446    if (sub === 'theme') {
447      const dir = customDirOf($.plugin.root, settings.customDir)
448      if (name === undefined) {
449        const own = dir === undefined ? [] : themeNames(await $.fs.list(dir).catch(() => []))
450        const kept = await $.store.get(THEME_KEY)
451        const chosen = await $.store.get(CHOSEN_KEY)
452        return { text: themeList(own, kept !== undefined ? undefined : typeof chosen === 'string' ? chosen : settings.theme, dir) }
453      }
454      let read: ReturnType<typeof readTheme>
455      try {
456        read = readTheme(await namedTheme($, name, dir))
457      } catch (err) {
458        return { text: `${err instanceof Error ? err.message : String(err)} Run /pet theme for the list.` }
459      }
460      if (read.errors) {
461        return { text: `The ${name} theme does not read: ${read.errors.join(' ')}` }
462      }
463      await $.store.set(CHOSEN_KEY, name)
464      await $.store.delete(THEME_KEY)
465      body = animate(read.theme)
466      $.ui.invalidate('ui.render')
467      return { text: `${read.theme.name} is on screen now, and stays for later sessions.` }
468    }
469    if ((await $.ui.panes()).some(p => p.id === PANE_ID)) {
470      await $.ui.close({ id: PANE_ID })
471      return {}
472    }
473    const opened = await $.ui.open({ id: PANE_ID, title: 'oxen-pet', closeOnEscape: true, rows: 10 })
474    if (opened.isPlaced) {
475      return {}
476    }
477    // Where no pane shows, the stats print as the command's output.
478    const rows = statsRows(stats, await $.clock.now(), hud, settings.targets)
479
480    return { text: rows.map(r => `${r.label}: ${r.value}`).join('\n') }
481  })
482
483  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
484    if (e.requestId !== PANE_ID) {
485      return next(e)
486    }
487    if (!body) {
488      body = await keptBody($, settings.theme, settings.customDir)
489    }
490    const now = await $.clock.now()
491    const rows = statsRows(stats, now, hud, settings.targets)
492    const labelW = Math.max(...rows.map(r => r.label.length))
493    const face = composeFace(body, stats.bosses > 0 ? 'star' : 'happy', now)
494    const frame = frameColor(body.look.hud)
495    if (e.surface === 'terminal') {
496      const { Box, Raster, Text } = $.ui.resolve(e)
497
498      return (
499        <Box gap={2}>
500          <Raster key="face" columns={BODY_W} rows={ROWS} cells={encodeCells(face)} />
501          <Box flexDirection="column">
502            {rows.map(r => (
503              <Box key={r.label}>
504                <Text color={frame} bold>{`${r.label.padEnd(labelW)}  `}</Text>
505                <Text color={DETAIL_COLOR}>{r.value}</Text>
506              </Box>
507            ))}
508          </Box>
509        </Box>
510      )
511    }
512    const { Box, Svg, Text } = $.ui.resolve(e)
513
514    return (
515      <Box gap={2}>
516        <Svg key="face" source={encodeSvg(face)} alt={`${body.name}, happy`} width={BODY_W * SVG_PX} height={HEIGHT * SVG_PX} />
517        <Box flexDirection="column">
518          {rows.map(r => (
519            <Box key={r.label}>
520              <Text color={frame} bold>{`${r.label.padEnd(labelW)}  `}</Text>
521              <Text color={DETAIL_COLOR}>{r.value}</Text>
522            </Box>
523          ))}
524        </Box>
525      </Box>
526    )
527  })
528
529  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
530    if (!settings.hud || !hud || e.surface !== 'terminal') {
531      return next(e)
532    }
533    if (!body) {
534      body = await keptBody($, settings.theme, settings.customDir)
535    }
536    const cacheMin = cacheLeftMin(lastTurnEndAt, settings.cacheTtlMin, await $.clock.now())
537    const rows = hudRows({ ...hud, cacheMin }, body.look.hud)
538    if (rows.length === 0) {
539      return next(e)
540    }
541    const { Box, Raster, Text } = $.ui.resolve(e)
542    const frame = frameColor(body.look.hud)
543    // Laid in a row when the user asked and the terminal has the room, else stacked.
544    const inRow = settings.hudRow ? hudRows({ ...hud, cacheMin }, body.look.hud, true) : []
545    const rowW = hudRowWidth(inRow)
546    // A row is one line with no window, the bars apart by gaps in the frame's color.
547    if (inRow.length > 0 && (e.viewport === undefined || e.viewport.columns >= rowW + 2)) {
548      return (
549        <Box flexDirection="column">
550          {await next(e)}
551          <Box key="row" marginLeft={1}>
552            {inRow.map((r, k) => (
553              <Box key={r.key}>
554                {k > 0 && <Text color={frame}>{ROW_GAP}</Text>}
555                <Text color={r.color}>{r.label} </Text>
556                <Raster key={`bar-${r.key}`} columns={r.bar.w} rows={1} cells={r.cells} />
557                {r.parts.map((p, i) => (
558                  <Text key={String(i)} color={p.color} bold={p.bold}>
559                    {p.text}
560                  </Text>
561                ))}
562              </Box>
563            ))}
564          </Box>
565        </Box>
566      )
567    }
568    // Narrower than the window, as in a pane split beside others: one bar a line, no window, the text cut at the edge.
569    if (e.viewport !== undefined && e.viewport.columns < HUD_WINDOW_W + 1) {
570      const barW = compactBarW(e.viewport.columns)
571      if (barW === undefined) {
572        return next(e)
573      }
574      const compact = hudRows({ ...hud, cacheMin }, body.look.hud, true, barW)
575
576      return (
577        <Box flexDirection="column">
578          {await next(e)}
579          <Box key="compact" flexDirection="column" marginLeft={1} width={e.viewport.columns - 2}>
580            {compact.map(r => (
581              <Box key={r.key}>
582                {/* The label, the bar and the reading keep their width; only the detail gives way at the edge. */}
583                <Box key="fixed" flexShrink={0}>
584                  <Text color={r.color}>{r.label} </Text>
585                  <Raster key={`bar-${r.key}`} columns={r.bar.w} rows={1} cells={r.cells} />
586                  <Text color={r.parts[0]?.color} bold>
587                    {r.parts[0]?.text ?? ''}
588                  </Text>
589                </Box>
590                {r.parts.length > 1 && (
591                  <Text color={r.parts[1]?.color} wrap="truncate">
592                    {r.parts
593                      .slice(1)
594                      .map(p => p.text)
595                      .join('')}
596                  </Text>
597                )}
598              </Box>
599            ))}
600          </Box>
601        </Box>
602      )
603    }
604    const edges = windowEdges(HUD_WINDOW_W)
605
606    return (
607      <Box flexDirection="column">
608        {await next(e)}
609        <Box flexDirection="column" marginLeft={1}>
610          <Text color={frame}>{edges.top}</Text>
611          {rows.map(r => (
612            <Box key={r.key}>
613              <Text color={frame}>{edges.side}</Text>
614              <Box width={HUD_WINDOW_W - 2} paddingLeft={1}>
615                <Text color={r.color}>{r.label} </Text>
616                <Raster key={`bar-${r.key}`} columns={r.bar.w} rows={1} cells={r.cells} />
617                {r.parts.map((p, i) => (
618                  <Text key={String(i)} color={p.color} bold={p.bold} wrap="truncate">
619                    {p.text}
620                  </Text>
621                ))}
622              </Box>
623              <Text color={frame}>{edges.side}</Text>
624            </Box>
625          ))}
626          <Text color={frame}>{edges.bottom}</Text>
627        </Box>
628      </Box>
629    )
630  })
631
632  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
633    try {
634      if (e.props.hasSurvey) {
635        return next(e)
636      }
637      isWorking = e.props.isWorking
638      bodyColumns = e.props.bodyColumns
639
640      if (!body) {
641        body = await keptBody($, settings.theme, settings.customDir)
642      }
643      const a = await read($, anim)
644      const now = await $.clock.now()
645      const elapsed = now - a.since
646      const views = minisOnScreen(minis, now)
647      // A leap is the run mode playing the jump clip, slowed, while the pet travels.
648      const drawn = a.leap ? { mode: 'jump' as const, ms: leapClipMs((now - a.leap.since) * settings.pace) } : { mode: a.mode, ms: elapsed * settings.pace }
649      const picture = compose(body, drawn.mode, drawn.ms, a.dir, hud ? mood(hud) : 'ok', views)
650      const extra = views.length > MAX_MINIS ? ` (+${views.length - MAX_MINIS} minis)` : ''
651      const shielded = shield !== undefined && guarding === 0 && now < shield.until ? guardLine(shield.result) : undefined
652      const noticed = notice !== undefined && now < notice.until ? notice.text : undefined
653      const line = settings.statusLine ? (shielded ?? noticed ?? statusLine(a.mode, a.since, elapsed, a.target, body.look.lines[a.mode])) + extra : ''
654      const tint = noticed && !shielded ? NOTICE_COLOR : lineColor(a.mode, body.look.lineColors)
655      if (showsError) {
656        showsError = false
657        $.ui.status(undefined)
658      }
659
660      const scene = sceneLayout(body)
661      const shownBoss = settings.boss ? bossOnScreen(boss, now) : undefined
662      const bossArt = shownBoss && drawBoss(shownBoss, now)
663      const bossW = bossArt ? BOSS_W + 1 : 0
664      if (e.surface === 'terminal' && scene && body.scene) {
665        const { Box, Raster, Text } = $.ui.resolve(e)
666        const width = scene.width
667        const textW = line ? lineWidth(line) + 3 : 0
668        const left = Math.max(0, Math.min(Math.round(a.x), width - picture.w - textW - bossW))
669        const band = drawBand(body, body.scene, scene, picture, left, now)
670        if (bossArt) {
671          overlay(band, bossArt, width - bossW)
672        }
673        const cells = (x: number, y: number, w: number, h: number) => encodeCells(crop(band, x, y, w, h))
674        // The status line cuts a hole in the band; the band shows above, below, and right of it.
675        const textAt = left + picture.w
676        const shown = Math.min(textW, width - textAt)
677        const rest = width - textAt - shown
678
679        return (
680          <Box flexDirection="column" height={ROWS + GROUND_ROWS}>
681            <Box height={ROWS}>
682              <Raster key="pet" columns={textAt} rows={ROWS} cells={cells(0, 0, textAt, HEIGHT)} />
683              {shown > 0 && (
684                <Box key="line" flexDirection="column" width={shown}>
685                  <Raster key="above" columns={shown} rows={ROWS - 2} cells={cells(textAt, 0, shown, HEIGHT - 4)} />
686                  <Text color={tint} bold wrap="truncate">
687                    {` › ${line}`}
688                  </Text>
689                  <Raster key="below" columns={shown} rows={1} cells={cells(textAt, HEIGHT - 2, shown, 2)} />
690                </Box>
691              )}
692              {rest > 0 && <Raster key="rest" columns={rest} rows={ROWS} cells={cells(textAt + shown, 0, rest, HEIGHT)} />}
693            </Box>
694            <Raster key="ground" columns={width} rows={GROUND_ROWS} cells={cells(0, HEIGHT, width, GROUND_H)} />
695          </Box>
696        )
697      }
698      if (e.surface === 'terminal') {
699        const { Box, Raster, Text } = $.ui.resolve(e)
700        const room = Math.max(0, bodyColumns - picture.w - line.length - 4 - bossW)
701
702        return (
703          <Box height={ROWS} width={bandWidth()}>
704            <Box marginLeft={Math.min(Math.round(a.x), room)} alignItems="flex-end">
705              <Raster key="pet" columns={picture.w} rows={ROWS} cells={encodeCells(picture)} />
706              {line && (
707                <Box marginBottom={1} marginLeft={1}>
708                  <Text color={tint} bold>
709                    › {line}
710                  </Text>
711                </Box>
712              )}
713            </Box>
714            {bossArt && <Box flexGrow={1} />}
715            {bossArt && <Raster key="boss" columns={BOSS_W} rows={ROWS} cells={encodeCells(bossArt)} />}
716          </Box>
717        )
718      }
719      if (e.surface === 'desktop') {
720        // The desktop has no Raster: the pet, its scene and the HUD's bars draw as SVG, and the HUD sits in the band,
721        // since the desktop's hint line under the prompt is its own.
722        const { Box, Svg, Text } = $.ui.resolve(e)
723        const rows = settings.hud && hud ? hudRows({ ...hud, cacheMin: cacheLeftMin(lastTurnEndAt, settings.cacheTtlMin, now) }, body.look.hud, settings.hudRow) : []
724        const hudFrame = frameColor(body.look.hud)
725        const hudBox = rows.length > 0 && (
726          <Box key="hud" flexDirection={settings.hudRow ? 'row' : 'column'} alignSelf="flex-start" {...(settings.hudRow ? {} : { borderStyle: 'round', borderColor: hudFrame, paddingX: 1 })}>
727            {rows.map((r, k) => (
728              <Box key={r.key} alignItems="center">
729                {settings.hudRow && k > 0 && <Text color={hudFrame}>{ROW_GAP}</Text>}
730                <Text color={r.color}>{r.label} </Text>
731                <Svg source={encodeSvg(r.bar)} alt={`${r.key.toUpperCase()} bar, ${r.pct}% left`} width={r.bar.w * HUD_SVG_PX} height={r.bar.h * HUD_SVG_PX} />
732                {r.parts.map((p, i) => (
733                  <Text key={String(i)} color={p.color} bold={p.bold}>
734                    {p.text}
735                  </Text>
736                ))}
737              </Box>
738            ))}
739          </Box>
740        )
741        if (scene && body.scene) {
742          const width = Math.min(scene.width, DESKTOP_BAND_W)
743          const left = Math.max(0, Math.min(Math.round(a.x), width - picture.w - bossW))
744          const band = crop(drawBand(body, body.scene, scene, picture, left, now), 0, 0, width, HEIGHT + GROUND_H)
745          if (bossArt) {
746            overlay(band, bossArt, width - bossW)
747          }
748
749          return (
750            <Box flexDirection="column">
751              <Svg source={encodeSvg(band)} alt={`${body.name}, ${a.mode}, in its scene`} width={width * SVG_PX} height={band.h * SVG_PX} />
752              {line && (
753                <Text color={tint} bold>
754                  › {line}
755                </Text>
756              )}
757              {hudBox}
758            </Box>
759          )
760        }
761
762        return (
763          <Box flexDirection="column">
764            <Box alignItems="flex-end">
765              <Box marginLeft={Math.round(a.x)}>
766                <Svg source={encodeSvg(picture)} alt={`${body.name}, ${a.mode}`} width={picture.w * SVG_PX} height={HEIGHT * SVG_PX} />
767              </Box>
768              {line && (
769                <Text color={tint} bold>
770                  {line}
771                </Text>
772              )}
773              {shownBoss && bossArt && <Box flexGrow={1} />}
774              {shownBoss && bossArt && <Svg key="boss" source={encodeSvg(bossArt)} alt={bossAlt(shownBoss)} width={BOSS_W * SVG_PX} height={HEIGHT * SVG_PX} />}
775            </Box>
776            {hudBox}
777          </Box>
778        )
779      }
780
781      return next(e)
782    } catch (err) {
783      showsError = true
784      $.ui.status(`oxen-pet: ${String(err)}`)
785
786      return next(e)
787    }
788  })
789}
790
hooks/anim.ts 158 lines
1import type { Anim, Leap, Mode } from '../types'
2import { BODY_W, MODES } from './pixels'
3import type { Span } from './scene'
4import { DEFAULTS } from './settings'
5import type { Settings } from './settings'
6import type { ToolMode } from './status'
7
8export const TICK_MS = 100
9const SPEED = 9 // cells per second at normal pace
10const RUN_AFTER_TOOL_MS = 4000
11const JUMP_YIELDS_MS = 400 // a tool call cuts the start-of-turn hop short after this long
12const JUMP_MS = MODES.jump.once as number
13/** A leap plays the jump clip this much slower, so the pet clears its own width while in the air. */
14const LEAP_SLOW = 4 / 3
15export const LEAP_MS = JUMP_MS * LEAP_SLOW
16// The part of the jump clip in which a leaping pet travels: from the frame it has lifted 5 px of 9 to the one it
17// has come down to 6. The pet crouches before it and drops straight down after it.
18const AIR = [3 / 14, 9 / 14] as const
19const LEAP_GAP = 2 // the most columns between the body canvas and an obstacle when the pet takes off
20
21/** What the session is doing, as the hooks last saw it. */
22export type Activity = {
23  isWorking: boolean
24  activeTools: number
25  activeMode: ToolMode // the latest tool call's mode
26  activeTarget: string
27  lastToolAt: number
28  room: number // the furthest column a running pet may reach
29  obstacles: Span[] // the scene's obstacles, in band columns
30  trail: number // the minis' width, drawn behind the pet
31  guarding?: boolean // a risky command waits for the user's answer, or just got it
32}
33
34/** The mode the pet holds while nothing starts or ends. */
35function settle(w: Activity, t: number): Mode {
36  if (!w.isWorking) {
37    return 'idle'
38  }
39  if (w.activeTools > 0) {
40    return w.activeMode
41  }
42
43  return t - w.lastToolAt < RUN_AFTER_TOOL_MS ? 'run' : 'think'
44}
45
46/**
47 * The pet one tick later, at time `t`. A turn starting makes it jump and a turn ending makes it cheer. A mode
48 * with a fixed length (jump, cheer, error) runs out before the pet settles, sooner at a faster pace. Idle turns
49 * to sleep after `sleepAfterMs`. A running pet moves and turns at `room`; at an obstacle it leaps, or turns when
50 * the landing is past the edge. A leap plays out before anything else changes. An `a` saved by another version
51 * of the mod, with a mode this one lacks, starts over idle.
52 */
53export function step(a: Anim, w: Activity, t: number, s: Pick<Settings, 'pace' | 'sleepAfterMs'> = DEFAULTS): Anim {
54  if (a.leap) {
55    const u = (t - a.leap.since) * s.pace
56    // Nothing else starts mid-air: `working` holds, so a turn that ended or began still cheers or jumps on landing.
57    if (u < LEAP_MS) {
58      return { ...a, x: leapX(a.leap, u), tick: a.tick + 1 }
59    }
60    const { leap: _, ...landed } = a
61    a = { ...landed, x: a.leap.to }
62  }
63  let { mode, since, x, dir } = a
64  let leap: Leap | undefined
65  if (!(mode in MODES)) {
66    mode = 'idle'
67    since = t
68  }
69  const length = MODES[mode].once
70  const once = length === undefined ? undefined : length / s.pace
71
72  if (w.guarding) {
73    // The shield goes up at once, whatever the pet was doing, and stays up until the user answers.
74    if (mode !== 'guard') {
75      mode = 'guard'
76      since = t
77    }
78  } else if (a.working && !w.isWorking) {
79    mode = 'cheer'
80    since = t
81  } else if (!a.working && w.isWorking) {
82    mode = 'jump'
83    since = t
84  } else if (once !== undefined) {
85    const yields = mode === 'jump' && w.activeTools > 0 && t - since >= JUMP_YIELDS_MS / s.pace
86    if (t - since >= once || yields) {
87      mode = settle(w, t)
88      since = t
89    }
90  } else {
91    let want = settle(w, t)
92    const canSleep = s.sleepAfterMs > 0
93    if (want === 'idle' && canSleep && (mode === 'sleep' || (mode === 'idle' && t - since >= s.sleepAfterMs))) {
94      want = 'sleep'
95    }
96    if (want !== mode) {
97      mode = want
98      since = t
99    }
100  }
101
102  const ahead = mode === 'run' ? obstacleAhead(x, dir, w) : undefined
103  if (ahead) {
104    const to = landing(ahead, dir, w)
105    if (to >= 0 && to <= w.room) {
106      leap = { since: t, from: x, to }
107    } else {
108      dir = dir === 1 ? -1 : 1
109    }
110  } else if (mode === 'run') {
111    x += (dir * SPEED * s.pace * TICK_MS) / 1000
112    if (x >= w.room) {
113      x = w.room
114      dir = -1
115    } else if (x <= 0) {
116      x = 0
117      dir = 1
118    }
119  }
120
121  return { mode, since, x, dir, tick: a.tick + 1, working: w.isWorking, target: w.activeTools > 0 ? w.activeTarget : a.target, ...(leap && { leap }) }
122}
123
124// The body canvas holds every pose, the crouch and the landing dust included, so a leap measured on it clears the
125// obstacle at every frame.
126/** The first column of the body canvas, for a picture at column `x`: the trail is on its left unless it faces left. */
127const bodyAt = (x: number, dir: 1 | -1, w: Activity) => x + (dir === 1 ? w.trail : 0)
128
129/** The obstacle just ahead of a running pet, close enough to leap now. */
130function obstacleAhead(x: number, dir: 1 | -1, w: Activity) {
131  const left = bodyAt(x, dir, w)
132  const right = left + BODY_W
133  return w.obstacles.find(o => {
134    const gap = dir === 1 ? o.x - right : left - (o.x + o.w)
135    return gap >= 0 && gap <= LEAP_GAP
136  })
137}
138
139/** Where a leap over `o` lands: the picture's column with the body canvas one column past the obstacle. */
140function landing(o: Span, dir: 1 | -1, w: Activity) {
141  const body = dir === 1 ? o.x + o.w + 1 : o.x - 1 - BODY_W
142  return body - bodyAt(0, dir, w)
143}
144
145/** The picture's column `u` ms (at pace) into `leap`. */
146export function leapX(leap: Leap, u: number) {
147  const p = Math.min(1, Math.max(0, (u / LEAP_MS - AIR[0]) / (AIR[1] - AIR[0])))
148  return leap.from + (leap.to - leap.from) * p
149}
150
151/** How far into the jump clip a leap `u` ms (at pace) in has come, at the clip's own speed. */
152export const leapClipMs = (u: number) => u / LEAP_SLOW
153
154/** The pet after a failed tool call: an error face, unless it is cheering. */
155export function fail(a: Anim, t: number): Anim {
156  return a.mode === 'cheer' ? a : { ...a, mode: 'error', since: t }
157}
158
hooks/custom.ts 47 lines
1/**
2 * The user's own themes: a folder of `<name>.theme.json` files outside the plugin's install folder, which an update
3 * replaces. The mod reads no environment, so it finds the user's `.claude` folder from where the plugin is installed,
4 * unless the Custom folder setting names another.
5 */
6
7/** The pets that ship with the plugin, in `assets/`; the first is the default. */
8export const BUILT_IN = ['luffy', 'slime', 'duck', 'alien'] as const
9export const DEFAULT_THEME = BUILT_IN[0]
10const SUFFIX = '.theme.json'
11
12const isAbsolute = (path: string) => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
13
14/** The custom folder: the setting's, when it is an absolute path with no `..`; else `oxen-pet/themes` in the `.claude` folder the plugin is installed under. */
15export function customDirOf(pluginRoot: string, setting: string): string | undefined {
16  const asked = setting.trim()
17  if (isAbsolute(asked) && !asked.split(/[\\/]/).includes('..') && !/[$~]/.test(asked)) {
18    return asked.replace(/[\\/]+$/, '')
19  }
20  const at = pluginRoot.search(/[\\/]\.claude[\\/]/)
21
22  return at < 0 ? undefined : `${pluginRoot.slice(0, at + '/.claude'.length)}/oxen-pet/themes`
23}
24
25/** Whether `name` names a theme: letters, digits, `-` and `_`, so it stays one file in the folder. */
26export const isThemeName = (name: string) => /^[A-Za-z0-9_-]{1,40}$/.test(name)
27
28export const themeFile = (dir: string, name: string) => `${dir}/${name}${SUFFIX}`
29
30/** The theme names among a folder's entries: its `<name>.theme.json` files, sorted. */
31export const themeNames = (entries: readonly { name: string; kind: string }[]) =>
32  entries
33    .filter(e => e.kind === 'file' && e.name.endsWith(SUFFIX))
34    .map(e => e.name.slice(0, -SUFFIX.length))
35    .filter(isThemeName)
36    .sort()
37
38/** What `/pet theme` prints: the built-in pets and the user's own, the one on screen marked, and where the folder is. */
39export function themeList(own: string[], current: string | undefined, dir: string | undefined) {
40  const mark = (name: string) => (name === current ? `${name} ◀ on screen` : name)
41  const where = dir
42    ? `Your themes folder: ${dir}\nPut <name>.theme.json there, or ask Claude to draw a pet and save it there. Updates never touch it.`
43    : 'No themes folder yet. Set Custom folder in /plugin configure oxen-pet@oxen-pet to an absolute path.'
44
45  return [`Built in: ${BUILT_IN.map(mark).join(', ')}`, `Yours: ${own.length > 0 ? own.map(mark).join(', ') : 'none yet'}`, where, 'Switch with /pet theme <name>.'].join('\n')
46}
47
hooks/boss.ts 127 lines
1import { HEIGHT, canvas, stamp } from './pixels'
2import type { Canvas } from './pixels'
3
4/**
5 * The boss: a failed test run brings a bug into the band, every failed run after hits harder, and the next run
6 * that passes defeats it. A run piped into another command (`npm test | tail`) ends with that command's exit
7 * code, so it reads as passed.
8 */
9export type Boss = { since: number; hits: number; defeatedAt?: number }
10export type TestOutcome = 'passed' | 'failed'
11
12export const BOSS_W = 16
13export const DEFEAT_MS = 1600
14const FLASH_MS = 600 // the first part of the defeat, the boss flashing white before it puffs away
15const MAX_PIPS = 5
16
17const TEST_RUNS = [
18  /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/,
19  /\bnpx\s+(jest|vitest|mocha|playwright\s+test)\b/,
20  /(^|[;&|(]\s*)(jest|vitest|mocha|pytest|rspec|phpunit)\b/,
21  /\bpython3?\s+-m\s+pytest\b/,
22  /\b(go|cargo|deno|dotnet|make)\s+test\b/,
23  /\b(mvnw?|gradlew?)\s+test\b/,
24  /\bclaude\s+plugin\s+test\b/,
25]
26
27/** Whether `command` runs a test suite with one of the common tools. */
28export const isTestCommand = (command: string) => TEST_RUNS.some(re => re.test(command))
29
30/** A test run's outcome from its tool.call result; undefined when it was refused, interrupted, or sent to the background. */
31export function testOutcome(result: { deny?: string; isError?: true; result?: unknown; text?: string }): TestOutcome | undefined {
32  if (result.deny !== undefined) {
33    return undefined
34  }
35  if (result.isError) {
36    return 'failed'
37  }
38  const out = (result.result ?? {}) as { interrupted?: boolean; backgroundTaskId?: string }
39
40  return out.interrupted || out.backgroundTaskId !== undefined ? undefined : 'passed'
41}
42
43/** The boss after a test run with `outcome` at `t`. */
44export function bossAfter(boss: Boss | undefined, outcome: TestOutcome | undefined, t: number): Boss | undefined {
45  if (outcome === 'failed') {
46    return boss && boss.defeatedAt === undefined ? { ...boss, hits: boss.hits + 1 } : { since: t, hits: 1 }
47  }
48  if (outcome === 'passed' && boss && boss.defeatedAt === undefined) {
49    return { ...boss, defeatedAt: t }
50  }
51
52  return boss
53}
54
55/** The boss while it is on screen: until its defeat has played out. */
56export const bossOnScreen = (boss: Boss | undefined, t: number) => (boss && (boss.defeatedAt === undefined || t - boss.defeatedAt < DEFEAT_MS) ? boss : undefined)
57
58// An original bug: antennae, a purple shell, red eyes and a toothy grin, on four legs.
59const BUG = [
60  '...A........A...',
61  '....A......A....',
62  '.....DDDDDD.....',
63  '...DDPPPPPPDD...',
64  '..DPPPPPPPPPPD..',
65  '..DPWWPPPPWWPD..',
66  '.DPPWRPPPPRWPPD.',
67  '.DPPPPPPPPPPPPD.',
68  '.DPPKKKKKKKKPPD.',
69  '.DPPKWKWKWKWPPD.',
70  '..DPPPPPPPPPPD..',
71  '..GDDPPPPPPDDG..',
72]
73const LEGS = [
74  ['.G..G.DDDD.G..G.', 'G...G......G...G'],
75  ['..G.G.DDDD.G.G..', '..G..G....G..G..'],
76]
77const PALETTE = { A: 0xa78bfa, D: 0x4c1d95, P: 0x7c3aed, W: 0xffffff, R: 0xf43f5e, K: 0x1e1033, G: 0x8b5cf6 }
78const WHITE = Object.fromEntries(Object.keys(PALETTE).map(ch => [ch, 0xffffff]))
79const PIP = 0xf43f5e
80const SMOKE = 0x9ca3af
81const STAR = 0xffe25a
82
83function drawBug(c: Canvas, t: number, palette: Record<string, number>) {
84  const step = Math.floor(t / 400) % 2
85  const rows = [...BUG, ...(LEGS[step] as string[])]
86  stamp(c, 0, HEIGHT - rows.length - step, rows, palette)
87}
88
89/** The boss at `t`, BOSS_W by HEIGHT: bobbing, with a pip per hit above it, or flashing and puffing away once defeated. */
90export function drawBoss(boss: Boss, t: number): Canvas {
91  const c = canvas(BOSS_W, HEIGHT)
92  if (boss.defeatedAt === undefined) {
93    drawBug(c, t, PALETTE)
94    for (let k = 0; k < Math.min(boss.hits, MAX_PIPS); k++) {
95      c.px[2 * BOSS_W + 2 + k * 3] = PIP
96      c.px[2 * BOSS_W + 3 + k * 3] = PIP
97    }
98    return c
99  }
100  const u = t - boss.defeatedAt
101  if (u < FLASH_MS) {
102    drawBug(c, t, Math.floor(u / 100) % 2 ? WHITE : PALETTE)
103    return c
104  }
105  // A ring of smoke and four stars spreading from where the bug stood.
106  const k = (u - FLASH_MS) / (DEFEAT_MS - FLASH_MS)
107  const dot = (x: number, y: number, color: number) => {
108    const [px, py] = [Math.round(x), Math.round(y)]
109    if (px >= 0 && px < BOSS_W && py >= 0 && py < HEIGHT) {
110      c.px[py * BOSS_W + px] = color
111    }
112  }
113  for (let i = 0; i < 8; i++) {
114    const a = (i / 8) * 2 * Math.PI
115    dot(8 + Math.cos(a) * (2 + 5 * k), 13 + Math.sin(a) * (2 + 4 * k), SMOKE)
116  }
117  for (let i = 0; i < 4; i++) {
118    const a = (i / 4) * 2 * Math.PI + Math.PI / 4
119    dot(8 + Math.cos(a) * (1 + 6 * k), 13 + Math.sin(a) * (1 + 5 * k), STAR)
120  }
121
122  return c
123}
124
125/** What the boss drawing says to a reader that cannot see it. */
126export const bossAlt = (boss: Boss) => (boss.defeatedAt === undefined ? `a bug boss, ${boss.hits} hit${boss.hits === 1 ? '' : 's'}` : 'a bug boss, defeated')
127
hooks/guard.ts 252 lines
1/**
2 * The shield: which Bash commands destroy work, and what to tell the user before one runs. Pure, so the hooks in
3 * register.tsx only ask and answer. A false alarm costs one question; a missed command costs work, so a command
4 * that only mentions a risky word (`echo "rm -rf"`) is asked about too.
5 */
6
7/** One path a command deletes: `unsized` when its spelling (a glob, a variable, `~`, a path after `cd`) names no file the mod can look at. */
8export type Target = { path: string; unsized?: true }
9/** What a risky command does, in a few words each, and the paths it deletes when it names them. */
10export type Risk = { what: string[]; targets: Target[] }
11/** How many files a target holds: at least `files` when capped; `isMissing` when nothing is there. */
12export type Size = { files: number; isCapped?: true; isMissing?: true }
13/** The two calls `sizeOf` needs, which register.tsx answers with `$.fs`. */
14export type GuardFs = {
15  stat: (path: string) => Promise<{ kind: string; isLink: boolean }>
16  list: (path: string) => Promise<{ name: string; kind: string; isLink: boolean }[]>
17}
18
19export const GUARD_CAP = 2000 // files counted per target before the count reads `2000+`
20const GUARD_DEPTH = 8 // folders deep the count goes
21const GUARD_DIRS = 300 // folders listed per target
22const MAX_COMMAND = 80
23const MAX_PATH = 40
24const MAX_TARGETS = 4
25
26/** The two answers, block first, so a dialog that answers for an absent user picks it. */
27export const GUARD_OPTIONS = { block: 'Block it', run: 'Run it' } as const
28
29const DELETES = 'deletes files and folders for good'
30
31/** The command split where the shell splits it into commands: `&&`, `||`, `;`, `|`, and new lines, outside quotes. */
32function segments(command: string) {
33  const out: string[] = []
34  let cur = ''
35  let quote = ''
36  for (let i = 0; i < command.length; i++) {
37    const ch = command[i] as string
38    if (quote) {
39      quote = ch === quote ? '' : quote
40      cur += ch
41    } else if (ch === "'" || ch === '"') {
42      quote = ch
43      cur += ch
44    } else if (ch === ';' || ch === '\n' || ch === '|' || ch === '&') {
45      out.push(cur)
46      cur = ''
47    } else {
48      cur += ch
49    }
50  }
51
52  return [...out, cur].map(s => s.trim()).filter(Boolean)
53}
54
55/** One command's words, with quotes taken off as the shell would. */
56function words(segment: string) {
57  const out: string[] = []
58  let cur = ''
59  let quote = ''
60  let has = false
61  for (const ch of segment) {
62    if (quote) {
63      if (ch === quote) {
64        quote = ''
65      } else {
66        cur += ch
67      }
68    } else if (ch === "'" || ch === '"') {
69      quote = ch
70      has = true
71    } else if (/\s/.test(ch)) {
72      if (has || cur) {
73        out.push(cur)
74      }
75      cur = ''
76      has = false
77    } else {
78      cur += ch
79    }
80  }
81  if (has || cur) {
82    out.push(cur)
83  }
84
85  return out
86}
87
88const WRAPPERS = new Set(['sudo', 'command', 'exec', 'time', 'nice', 'nohup', 'xargs', 'doas'])
89
90/** The words from the command's own name on: past `sudo`, `xargs` and the like, their flags, and `VAR=value`. */
91function commandWords(all: string[]) {
92  let i = 0
93  while (i < all.length && (WRAPPERS.has(all[i] as string) || /^-/.test(all[i] as string) || /^[A-Za-z_]\w*=/.test(all[i] as string))) {
94    i += 1
95  }
96
97  return all.slice(i)
98}
99
100const hasFlag = (args: string[], short: string, long?: string) => args.some(a => (long !== undefined && a === long) || new RegExp(`^-[a-zA-Z]*${short}[a-zA-Z]*$`).test(a))
101
102/** The git subcommand and its words, past `-C dir` and `-c key=value`. */
103function gitWords(args: string[]) {
104  let i = 0
105  while (i < args.length && /^-/.test(args[i] as string)) {
106    i += args[i] === '-C' || args[i] === '-c' ? 2 : 1
107  }
108
109  return { sub: args[i] ?? '', rest: args.slice(i + 1) }
110}
111
112/** What one git command destroys, if anything. */
113function gitRisk(args: string[]) {
114  const { sub, rest } = gitWords(args)
115  switch (sub) {
116    case 'push':
117      return rest.includes('--force') || rest.some(a => /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a)) || rest.some(a => /^\+/.test(a)) ? "force-pushes, which rewrites the remote branch's history" : undefined
118    case 'reset':
119      return rest.includes('--hard') ? 'throws away uncommitted changes' : undefined
120    case 'clean':
121      return (hasFlag(rest, 'f', '--force') && !hasFlag(rest, 'n', '--dry-run')) ? 'deletes untracked files' : undefined
122    case 'checkout':
123      return rest.includes('--') || rest.includes('.') ? 'throws away uncommitted changes to files' : undefined
124    case 'restore':
125      return rest.includes('--staged') && !rest.includes('--worktree') ? undefined : 'throws away uncommitted changes to files'
126    case 'branch':
127      return rest.includes('-D') || (rest.includes('--delete') && rest.includes('--force')) ? 'deletes a branch, merged or not' : undefined
128    case 'stash':
129      return rest[0] === 'drop' || rest[0] === 'clear' ? 'drops stashed changes' : undefined
130    default:
131      return undefined
132  }
133}
134
135const isUnsizable = (path: string) => /[*?[\]{}$`~]/.test(path)
136
137/**
138 * What `command` destroys, or undefined for a command that destroys nothing the shield knows of. A recursive `rm`
139 * names its targets; after a `cd`, a relative one is unsized, since it names a folder the mod cannot place.
140 */
141export function riskOf(command: string): Risk | undefined {
142  const what = new Set<string>()
143  const targets: Target[] = []
144  let movedDir = false
145  for (const segment of segments(command)) {
146    const [word = '', ...args] = commandWords(words(segment))
147    const name = word.split('/').pop() ?? '' // `/bin/rm` is rm
148    if (name === 'cd' || name === 'pushd') {
149      movedDir = true
150    } else if (name === 'rm' && hasFlag(args.filter((_, i) => !args.slice(0, i).includes('--')), '[rR]', '--recursive')) {
151      what.add(DELETES)
152      const end = args.indexOf('--')
153      for (const [i, path] of args.entries()) {
154        const isPath = end >= 0 ? i > end : !/^-/.test(path)
155        if (isPath) {
156          targets.push(isUnsizable(path) || (movedDir && !path.startsWith('/')) ? { path, unsized: true } : { path })
157        }
158      }
159    } else if (name === 'git') {
160      const risk = gitRisk(args)
161      if (risk) {
162        what.add(risk)
163      }
164    } else if (name === 'find' && (args.includes('-delete') || (args.includes('-exec') && args.includes('rm')))) {
165      what.add('deletes every file find matches')
166    } else if (/^(terraform|tofu|pulumi)$/.test(name) && (args[0] === 'destroy' || args.includes('-destroy'))) {
167      what.add('destroys infrastructure')
168    } else if ((name === 'kubectl' && args[0] === 'delete') || (name === 'helm' && /^(uninstall|delete)$/.test(args[0] ?? ''))) {
169      what.add('deletes cluster resources')
170    } else if (name === 'docker' && ((args[0] === 'system' && args[1] === 'prune') || (args[0] === 'volume' && /^(rm|prune)$/.test(args[1] ?? '')))) {
171      what.add('deletes Docker data')
172    } else if (/^mkfs/.test(name) || (name === 'dd' && args.some(a => a.startsWith('of=/dev/')))) {
173      what.add('writes over a disk')
174    }
175    if (/\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b/i.test(segment)) {
176      what.add('drops or empties database tables')
177    }
178  }
179
180  return what.size > 0 ? { what: [...what], targets } : undefined
181}
182
183/** How many files `path` holds, counted through `fs`: links are counted, never followed, and the count stops at the cap. */
184export async function sizeOf(path: string, fs: GuardFs): Promise<Size> {
185  let top: { kind: string; isLink: boolean }
186  try {
187    top = await fs.stat(path)
188  } catch {
189    return { files: 0, isMissing: true }
190  }
191  if (top.isLink || top.kind !== 'dir') {
192    return { files: 1 }
193  }
194  let files = 0
195  let listed = 0
196  const queue = [{ path, depth: 0 }]
197  while (queue.length > 0) {
198    const dir = queue.shift() as { path: string; depth: number }
199    listed += 1
200    if (listed > GUARD_DIRS) {
201      return { files, isCapped: true }
202    }
203    let entries: { name: string; kind: string; isLink: boolean }[]
204    try {
205      entries = await fs.list(dir.path)
206    } catch {
207      continue
208    }
209    for (const entry of entries) {
210      if (entry.kind === 'dir' && !entry.isLink) {
211        if (dir.depth + 1 > GUARD_DEPTH) {
212          return { files, isCapped: true }
213        }
214        queue.push({ path: `${dir.path.replace(/\/$/, '')}/${entry.name}`, depth: dir.depth + 1 })
215        continue
216      }
217      files += 1
218      if (files >= GUARD_CAP) {
219        return { files: GUARD_CAP, isCapped: true }
220      }
221    }
222  }
223
224  return { files }
225}
226
227const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
228
229function sizeText(target: Target, size: Size | undefined) {
230  if (size === undefined) {
231    return 'not sized'
232  }
233  // Claude's shell may have moved since the session began, so a relative path the mod cannot find may still be there.
234  if (size.isMissing) {
235    return target.path.startsWith('/') ? 'not there' : 'not found from the project folder'
236  }
237
238  return `${size.files}${size.isCapped ? '+' : ''} file${size.files === 1 && !size.isCapped ? '' : 's'}`
239}
240
241/** The question the shield asks before `command` runs; `sizes` line up with `risk.targets`, undefined where unsized. */
242export function guardQuestion(command: string, risk: Risk, sizes: (Size | undefined)[]) {
243  const shown = risk.targets.slice(0, MAX_TARGETS).map((t, i) => `${cut(t.path, MAX_PATH)}: ${sizeText(t, sizes[i])}`)
244  const more = risk.targets.length > MAX_TARGETS ? [`${risk.targets.length - MAX_TARGETS} more`] : []
245  const radius = shown.length > 0 ? ` ${[...shown, ...more].join(' · ')}.` : ''
246
247  return `oxen-pet shield: \`${cut(command.replace(/\s+/g, ' ').trim(), MAX_COMMAND)}\` ${risk.what.join(', and ')}.${radius} Run it?`
248}
249
250/** The status line once the user answered: what the shield did. */
251export const guardLine = (result: 'blocked' | 'ran') => (result === 'blocked' ? 'shield up: blocked it' : 'okay, let it through')
252
hooks/stats.ts 100 lines
1import type { Hud } from './hud'
2import { fmtMin } from './hud'
3import { toolMode } from './status'
4import type { ToolMode } from './status'
5import type { TestOutcome } from './boss'
6
7/** What the session did, as the hooks saw it, for the `/pet` pane. Files are kept as paths and shown as counts. */
8export type Stats = {
9  startedAt: number
10  turns: number
11  tools: Partial<Record<ToolMode, number>>
12  failed: number
13  read: string[]
14  edited: string[]
15  tests: { passed: number; failed: number }
16  bosses: number // defeated
17  shield: { blocked: number; ran: number }
18  firstMp?: { left: number; at: number } // MP's first reading in its window, for the burn rate
19}
20
21/** One row of the pane: a label and its value. */
22export type StatsRow = { label: string; value: string }
23
24const MODE_ORDER: ToolMode[] = ['read', 'search', 'edit', 'bash', 'web', 'agent']
25const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
26const MAX_FILES = 1000
27const MAX_NAMES = 4
28const BURN_AFTER_MIN = 15 // MP's burn rate is noise before this long
29
30export const newStats = (t: number): Stats => ({ startedAt: t, turns: 0, tools: {}, failed: 0, read: [], edited: [], tests: { passed: 0, failed: 0 }, bosses: 0, shield: { blocked: 0, ran: 0 } })
31
32const withFile = (files: string[], path: unknown) => (typeof path === 'string' && path !== '' && !files.includes(path) && files.length < MAX_FILES ? [...files, path] : files)
33
34/** The stats after one tool call of `tool` with `input`, which `failed` or not. */
35export function recordTool(s: Stats, tool: string, input: Record<string, unknown>, failed: boolean): Stats {
36  const mode = toolMode(tool)
37  const path = input.file_path ?? input.notebook_path
38
39  return {
40    ...s,
41    tools: { ...s.tools, [mode]: (s.tools[mode] ?? 0) + 1 },
42    failed: s.failed + (failed ? 1 : 0),
43    read: tool === 'Read' ? withFile(s.read, path) : s.read,
44    edited: EDITS.has(tool) ? withFile(s.edited, path) : s.edited,
45  }
46}
47
48export const recordTurn = (s: Stats): Stats => ({ ...s, turns: s.turns + 1 })
49
50export const recordTest = (s: Stats, outcome: TestOutcome, beatBoss: boolean): Stats => ({
51  ...s,
52  tests: { ...s.tests, [outcome]: s.tests[outcome] + 1 },
53  bosses: s.bosses + (beatBoss ? 1 : 0),
54})
55
56export const recordShield = (s: Stats, result: 'blocked' | 'ran'): Stats => ({ ...s, shield: { ...s.shield, [result]: s.shield[result] + 1 } })
57
58/** The stats after an MP reading of `left` at `t`: the first one in its window starts the burn rate. A reset, MP climbing, starts it over. */
59export const noteMp = (s: Stats, left: number, t: number): Stats => (s.firstMp === undefined || left > s.firstMp.left ? { ...s, firstMp: { left, at: t } } : s)
60
61const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
62const names = (paths: string[]) => {
63  const shown = paths.slice(0, MAX_NAMES).map(p => p.split(/[\\/]/).pop())
64  return `${shown.join(', ')}${paths.length > MAX_NAMES ? ', …' : ''}`
65}
66
67/** The pane's rows at `now`: only what the session did, and file names only when `showNames`. */
68export function statsRows(s: Stats, now: number, hud: Hud | undefined, showNames: boolean): StatsRow[] {
69  const rows: StatsRow[] = [{ label: 'Session', value: `${fmtMin(Math.max(0, Math.floor((now - s.startedAt) / 60000)))} · ${plural(s.turns, 'turn')}` }]
70  const calls = MODE_ORDER.reduce((n, m) => n + (s.tools[m] ?? 0), 0)
71  if (calls > 0) {
72    const parts = MODE_ORDER.filter(m => s.tools[m]).map(m => `${s.tools[m]} ${m}`)
73    rows.push({ label: 'Tools', value: `${plural(calls, 'call')}: ${[...parts, ...(s.failed > 0 ? [`${s.failed} failed`] : [])].join(' · ')}` })
74  }
75  if (s.read.length + s.edited.length > 0) {
76    const read = s.read.length > 0 ? [`${s.read.length} read`] : []
77    const edited = s.edited.length > 0 ? [`${s.edited.length} edited${showNames ? `: ${names(s.edited)}` : ''}`] : []
78    rows.push({ label: 'Files', value: [...read, ...edited].join(' · ') })
79  }
80  if (s.tools.agent) {
81    rows.push({ label: 'Subagents', value: String(s.tools.agent) })
82  }
83  const runs = s.tests.passed + s.tests.failed
84  if (runs > 0) {
85    const beaten = s.bosses > 0 ? [`${s.bosses} boss${s.bosses === 1 ? '' : 'es'} beaten`] : []
86    rows.push({ label: 'Tests', value: `${plural(runs, 'run')}: ${[`${s.tests.passed} passed`, `${s.tests.failed} failed`, ...beaten].join(' · ')}` })
87  }
88  const asked = s.shield.blocked + s.shield.ran
89  if (asked > 0) {
90    rows.push({ label: 'Shield', value: `${asked} asked: ${s.shield.blocked} blocked · ${s.shield.ran} ran` })
91  }
92  if (hud) {
93    const hours = s.firstMp ? (now - s.firstMp.at) / 3600000 : 0
94    const burn = s.firstMp && hud.mp !== undefined && hours * 60 >= BURN_AFTER_MIN ? [`MP ${Math.round((s.firstMp.left - hud.mp) / hours)}%/h`] : []
95    rows.push({ label: 'Burn', value: [...burn, `context ${100 - hud.hp}% used`].join(' · ') })
96  }
97
98  return rows
99}
100
hooks/hud.ts 306 lines
1import type { SessionUsage } from 'claude-code'
2
3import { encodeCells } from './pixels'
4import type { Canvas } from './pixels'
5
6export const BAR_W = 20 // cells; a bar is one cell row, two pixels tall
7export const ROW_BAR_W = 10 // a bar's cells when the HUD lays its bars in a row
8
9// The window frame around the HUD. Blue, not white, so it shows on a light terminal too.
10export const FRAME_COLOR = '#5aa9ff'
11
12export type Hud = {
13  hp: number // context left, %
14  mp?: number // the 5-hour rate limit left, %; absent off a subscription or before its first reading
15  mpResetsInMin?: number
16  st?: number // the 7-day rate limit left, %; absent like MP
17  stResetsInMin?: number
18  cacheMin?: number // minutes the prompt cache stays warm, 0 once cold; absent before the first turn or with the timer off
19}
20
21// Each rate-limit window's length in minutes, which the pace runs through.
22export const MP_WINDOW_MIN = 300
23export const ST_WINDOW_MIN = 10080
24// How long a window runs before its burn rate is steady enough to forecast from.
25const FORECAST_AFTER_MIN = 15
26
27export type Mood = 'ok' | 'worried' | 'critical' | 'tired'
28
29/** HP under this warns: `⚠ HP`, `/compact` beside the reading, and the low-context alert. */
30export const LOW_HP = 20
31const REARM_HP = 30 // HP must climb back to this, by a /compact or a new session, before the alert can fire again
32
33/** Whether the low-context alert fires at `hp`, and whether it is armed after: it fires once per drop under LOW_HP. */
34export function contextAlert(armed: boolean, hp: number) {
35  if (armed && hp < LOW_HP) {
36    return { alert: true, armed: false }
37  }
38
39  return { alert: false, armed: armed || hp >= REARM_HP }
40}
41
42/** The toast the low-context alert shows. */
43export const contextAlertText = (hp: number) => `oxen-pet: ${hp}% of the context left. Run /compact now, or hand off to a fresh session.`
44
45/** A pet's look for one bar: its label, the label's color, and the fill while the bar is healthy. */
46export type BarLook = { label?: string; color?: string; fill?: [string, string] }
47/** A pet's look for the HUD: the frame's color, and each bar's look, or false to hide it. */
48export type HudLook = { frame?: string; hp?: BarLook | false; mp?: BarLook | false; st?: BarLook | false }
49
50/** The frame's color: the pet's own, else the mod's. */
51export const frameColor = (look: HudLook) => look.frame ?? FRAME_COLOR
52
53const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
54const finite = (v: number | undefined) => (v !== undefined && Number.isFinite(v) ? v : undefined)
55
56/** One rate-limit window: the share left, and the minutes until it resets. Both absent without a reading. */
57function limitLeft(u: SessionUsage, kind: string, now: number) {
58  const limit = u.rateLimits.find(r => r.kind === kind && finite(r.percentUsed) !== undefined)
59  const resets = limit?.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
60
61  return {
62    left: limit ? clamp(Math.round(100 - limit.percentUsed), 0, 100) : undefined,
63    resetsInMin: !limit || Number.isNaN(resets) ? undefined : Math.max(0, Math.round((resets - now) / 60000)),
64  }
65}
66
67export function hudFrom(u: SessionUsage, now: number): Hud {
68  const mp = limitLeft(u, 'five_hour', now)
69  const st = limitLeft(u, 'seven_day', now)
70
71  return {
72    hp: clamp(Math.round(100 - (finite(u.context.percent) ?? 0)), 0, 100),
73    mp: mp.left,
74    mpResetsInMin: mp.resetsInMin,
75    st: st.left,
76    stResetsInMin: st.resetsInMin,
77  }
78}
79
80/** How many points a limit's share left is ahead of an even pace through its window; negative when behind. */
81export function spareOf(left: number, resetsInMin: number, windowMin: number) {
82  const evenLeft = (clamp(resetsInMin, 0, windowMin) / windowMin) * 100
83
84  return Math.round(left - evenLeft)
85}
86
87/**
88 * Minutes until a limit runs out at its average burn so far in the window, when that comes before its reset.
89 * Absent when it lasts to the reset, or in the window's first minutes, when the rate is still noise.
90 */
91export function emptyInMin(left: number, resetsInMin: number, windowMin: number) {
92  const elapsed = windowMin - clamp(resetsInMin, 0, windowMin)
93  const used = 100 - left
94  if (elapsed < FORECAST_AFTER_MIN || used <= 0) {
95    return undefined
96  }
97  const empty = Math.round((left * elapsed) / used)
98
99  return empty < resetsInMin ? empty : undefined
100}
101
102/** Minutes the prompt cache stays warm after the main thread's last turn ended at `endedAt`, 0 once cold. */
103export function cacheLeftMin(endedAt: number | undefined, ttlMin: number, now: number) {
104  if (endedAt === undefined || ttlMin <= 0) {
105    return undefined
106  }
107
108  return Math.max(0, Math.ceil((ttlMin * 60000 - (now - endedAt)) / 60000))
109}
110
111export function mood(h: Hud): Mood {
112  if (h.hp <= 25) {
113    return 'critical'
114  }
115  if (h.hp <= 50) {
116    return 'worried'
117  }
118
119  return [h.mp, h.st].some(left => left !== undefined && left < 20) ? 'tired' : 'ok'
120}
121
122/** Minutes as the HUD prints them: `36m`, `2h13m`, then whole hours past a day, `3d4h`. */
123export function fmtMin(m: number) {
124  if (m >= 1440) {
125    return `${Math.floor(m / 1440)}d${Math.floor((m % 1440) / 60)}h`
126  }
127
128  return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
129}
130
131type Pair = [number, number]
132const GREEN: Pair = [0x166534, 0x4ade80]
133const YELLOW: Pair = [0xa16207, 0xfacc15]
134const RED: Pair = [0x991b1b, 0xf87171]
135const BLUE: Pair = [0x3b5bdb, 0xa78bfa]
136const GOLD: Pair = [0xb45309, 0xfbbf24]
137
138const channel = (c: number, shift: number) => (c >> shift) & 255
139const mix = (a: number, b: number, t: number) =>
140  [16, 8, 0].reduce((out, s) => out | (Math.round(channel(a, s) + (channel(b, s) - channel(a, s)) * t) << s), 0)
141// The even-pace mark on a bar. Light, so it shows on the bar's dark track and on its fill.
142export const MARKER_COLOR = 0xf1f5f9
143
144const darker = (c: number) => [16, 8, 0].reduce((out, s) => out | (Math.round(channel(c, s) * 0.6) << s), 0)
145
146/** A bevelled bar `w` cells long: grey end caps, a gradient fill with a darker lower half, and a mark at `markPct` when given. */
147export function barCanvas(pct: number, [from, to]: Pair, markPct?: number, w = BAR_W): Canvas {
148  const px = new Array<number>(w * 2).fill(0x1b1e26)
149  const fill = Math.round((clamp(pct, 0, 100) / 100) * (w - 2))
150  for (let i = 0; i < fill; i++) {
151    const c = mix(from, to, i / (w - 3))
152    px[1 + i] = c
153    px[w + 1 + i] = darker(c)
154  }
155  for (const x of [0, w - 1]) {
156    px[x] = 0x6f7787
157    px[w + x] = 0x6f7787
158  }
159  if (markPct !== undefined) {
160    const x = Math.min(1 + Math.round((clamp(markPct, 0, 100) / 100) * (w - 2)), w - 2)
161    px[x] = MARKER_COLOR
162    px[w + x] = MARKER_COLOR
163  }
164
165  return { w, h: 2, px }
166}
167
168/** One run of a row's text. The reading is bold in its bar's color; the details beside it are grey. */
169export type HudPart = { text: string; color: string; bold?: boolean }
170
171/** One bar's row: `pct` is its reading, `bar` its pixels, and `cells` those pixels as Raster cells. */
172export type HudRow = { key: string; label: string; color: string; pct: number; bar: Canvas; cells: string; parts: HudPart[] }
173
174// A mid grey, so the details read on a dark terminal and on a light one.
175export const DETAIL_COLOR = '#8b93b8'
176
177const cssColor = (c: number) => `#${c.toString(16).padStart(6, '0')}`
178const reading = (text: string, [, bright]: Pair): HudPart => ({ text: ` ${text}`, color: cssColor(bright), bold: true })
179const detail = (bits: (string | undefined)[]): HudPart[] => {
180  const text = bits.filter(b => b !== undefined).join('  ')
181
182  return text ? [{ text: `  ${text}`, color: DETAIL_COLOR }] : []
183}
184
185const resets = (min: number | undefined) => (min === undefined ? undefined : `reset in ${fmtMin(min)}`)
186const shortReset = (min: number | undefined) => (min === undefined ? undefined : fmtMin(min))
187const cache = (min: number | undefined) => (min === undefined ? undefined : min > 0 ? `cache ${fmtMin(min)}` : 'cache cold')
188const evenLeft = (resetsInMin: number | undefined, windowMin: number) =>
189  resetsInMin === undefined ? undefined : (clamp(resetsInMin, 0, windowMin) / windowMin) * 100
190const pace = (left: number, resetsInMin: number | undefined, windowMin: number) => {
191  if (resetsInMin === undefined) {
192    return undefined
193  }
194  const spare = spareOf(left, resetsInMin, windowMin)
195
196  return spare === 0 ? 'on pace' : spare > 0 ? `${spare}% spare` : `${-spare}% over`
197}
198/** The red warning that a limit runs out before its reset. */
199const runsOut = (min: number | undefined): HudPart[] => (min === undefined ? [] : [{ text: `  empty ~${fmtMin(min)}`, color: cssColor(RED[1]) }])
200
201const pairOf = (fill: [string, string] | undefined, fallback: Pair): Pair => (fill ? [parseInt(fill[0].slice(1), 16), parseInt(fill[1].slice(1), 16)] : fallback)
202
203/**
204 * The HUD's rows: HP, and MP and ST when the session has their readings, each in the pet's `look` unless
205 * the look hides it. A bar's warning colors (yellow and red) replace its fill whatever the look. `isRow` lays
206 * them side by side: shorter bars, and beside each reading only one short detail, or its warning. `barW` sets
207 * the bars' width in cells, for a HUD that must fit a narrow terminal.
208 */
209export function hudRows(h: Hud, look: HudLook = {}, isRow = false, barW?: number): HudRow[] {
210  const w = barW ?? (isRow ? ROW_BAR_W : BAR_W)
211  const rows: (Omit<HudRow, 'cells'> & { look: BarLook | false | undefined })[] = []
212  const hpFill = h.hp > 50 ? pairOf(look.hp ? look.hp.fill : undefined, GREEN) : h.hp > 25 ? YELLOW : RED
213  rows.push({
214    key: 'hp',
215    look: look.hp,
216    label: h.hp < LOW_HP ? '⚠ HP' : '♥ HP',
217    color: '#f87171',
218    pct: h.hp,
219    bar: barCanvas(h.hp, hpFill, undefined, w),
220    parts: [
221      reading(`${h.hp}%`, hpFill),
222      ...(h.hp < LOW_HP ? [{ ...reading('/compact', RED), text: '  /compact' }] : []),
223      ...(isRow && h.hp < LOW_HP ? [] : detail([cache(h.cacheMin)])),
224    ],
225  })
226  if (h.mp !== undefined) {
227    const mpFill = h.mp < 15 ? RED : pairOf(look.mp ? look.mp.fill : undefined, BLUE)
228    // Running out before the reset says more than how far over pace MP is, so it takes that place.
229    const empty = h.mpResetsInMin === undefined ? undefined : emptyInMin(h.mp, h.mpResetsInMin, MP_WINDOW_MIN)
230    rows.push({
231      key: 'mp',
232      look: look.mp,
233      label: '✦ MP',
234      color: '#7aa7ff',
235      pct: h.mp,
236      bar: barCanvas(h.mp, mpFill, evenLeft(h.mpResetsInMin, MP_WINDOW_MIN), w),
237      parts: isRow
238        ? [reading(`${h.mp}%`, mpFill), ...(empty === undefined ? detail([shortReset(h.mpResetsInMin)]) : runsOut(empty))]
239        : [
240            reading(`${h.mp}%`, mpFill),
241            ...detail([resets(h.mpResetsInMin), empty === undefined ? pace(h.mp, h.mpResetsInMin, MP_WINDOW_MIN) : undefined]),
242            ...runsOut(empty),
243          ],
244    })
245  }
246  if (h.st !== undefined) {
247    const stFill = h.st < 15 ? RED : pairOf(look.st ? look.st.fill : undefined, GOLD)
248    rows.push({
249      key: 'st',
250      look: look.st,
251      label: '◆ ST',
252      color: '#fbbf24',
253      pct: h.st,
254      bar: barCanvas(h.st, stFill, evenLeft(h.stResetsInMin, ST_WINDOW_MIN), w),
255      parts: [reading(`${h.st}%`, stFill), ...detail(isRow ? [shortReset(h.stResetsInMin)] : [resets(h.stResetsInMin), pace(h.st, h.stResetsInMin, ST_WINDOW_MIN)])],
256    })
257  }
258  const shown = rows
259    .filter(r => r.look !== false)
260    .map(({ look: bar, ...r }) => (bar ? { ...r, label: bar.label ?? r.label, color: bar.color ?? r.color } : r))
261  // Stacked, labels pad to one width, so the bars line up.
262  const width = isRow ? 0 : Math.max(0, ...shown.map(r => [...r.label].length))
263
264  return shown.map(r => ({ ...r, label: r.label + ' '.repeat(Math.max(0, width - [...r.label].length)), cells: encodeCells(r.bar) }))
265}
266
267/**
268 * The window's edges as text, `width` cells across, side cells included. A full block is one pixel wide
269 * and a half block one pixel tall, so the lines are as thick as the pet's pixels. The corner cells stay
270 * empty, which notches each corner by one pixel.
271 */
272export function windowEdges(width: number) {
273  const run = Math.max(0, width - 2)
274
275  return { top: ` ${'▄'.repeat(run)} `, side: '█', bottom: ` ${'▀'.repeat(run)} ` }
276}
277
278/** The cells between bars laid in a row. */
279export const ROW_GAP = ' │ '
280
281/** The width in cells of `rows` laid in a row, one line with no window: each label, bar and text, and the gaps between them. */
282export const hudRowWidth = (rows: HudRow[]) =>
283  rows.reduce((n, r) => n + [...r.label].length + 1 + r.bar.w + r.parts.reduce((m, p) => m + [...p.text].length, 0), 0) + ROW_GAP.length * (rows.length - 1)
284
285/** The HUD window's width in cells, sides included: room for a label, a bar and the longest row's text. */
286export const HUD_WINDOW_W = 64
287
288/** The cells a compact row needs beside its bar: the margin, a label, a gap, a reading and one short detail. */
289const COMPACT_TEXT_W = 19
290const COMPACT_MIN_BAR_W = 4
291/** Under this many columns the HUD draws nothing: a bar would be too short to read. */
292export const COMPACT_MIN_W = 16
293
294/**
295 * The bar width of the compact HUD, one bar a line with no window, for a terminal narrower than the window, such
296 * as a pane split beside others: the row's bar width when it fits, shorter down to four cells, none under
297 * COMPACT_MIN_W columns. Text past the edge is cut, never wrapped.
298 */
299export function compactBarW(columns: number): number | undefined {
300  if (columns < COMPACT_MIN_W) {
301    return undefined
302  }
303
304  return Math.max(COMPACT_MIN_BAR_W, Math.min(ROW_BAR_W, columns - COMPACT_TEXT_W))
305}
306
hooks/minis.ts 39 lines
1import type { AgentInfo } from 'claude-code'
2
3import type { MiniView } from './pixels'
4
5/** A subagent's mini: it joins when the agent starts and leaves `LEAVE_MS` after the agent ends. */
6export type Mini = { id: string; since: number; doneAt?: number; failed?: boolean }
7
8const LEAVE_MS = 1500
9
10const ALIVE = new Set(['running', 'pending'])
11
12/** The minis after a look at the session's agents at time `t`. */
13export function reconcile(minis: Mini[], agents: AgentInfo[], t: number): Mini[] {
14  const byId = new Map(agents.map(a => [a.id, a]))
15  const kept = minis.flatMap((m): Mini[] => {
16    if (m.doneAt !== undefined) {
17      return t - m.doneAt < LEAVE_MS ? [m] : []
18    }
19    const a = byId.get(m.id)
20    if (a && ALIVE.has(a.status)) {
21      return [m]
22    }
23
24    // An agent gone from the list counts as finished, not failed.
25    return [{ ...m, doneAt: t, failed: a !== undefined && a.status !== 'completed' }]
26  })
27  const known = new Set(minis.map(m => m.id))
28  const born = agents.filter(a => ALIVE.has(a.status) && !known.has(a.id)).map(a => ({ id: a.id, since: t }))
29
30  return [...kept, ...born]
31}
32
33/** The minis to draw at time `t`, as compose takes them: every running one, and every one that ended under `LEAVE_MS` ago. */
34export function minisOnScreen(minis: Mini[], t: number): MiniView[] {
35  return minis
36    .filter(m => m.doneAt === undefined || t - m.doneAt < LEAVE_MS)
37    .map(m => ({ age: t - m.since, doneFor: m.doneAt === undefined ? undefined : t - m.doneAt, failed: m.failed }))
38}
39
hooks/theme.ts 581 lines
1import type { Mode } from '../types'
2import type { BarLook, HudLook } from './hud'
3import { BODY_W, EYE_COLOR, HEIGHT, MINI_SIZE, MODES, PROP_W } from './pixels'
4import type { Body, BodyFrame, Look } from './pixels'
5import { EVERY, SCENE_SIZE } from './scene'
6import type { Scene } from './scene'
7
8/** A theme as its file spells it: the pet's sprite and everything else it changes. `skills/oxen-pet/FORMAT.md` documents each field for the people who write one. */
9export type Theme = {
10  name: string
11  scale: number
12  squash: number // how much the poses squash and stretch the sprite: 1 fully, 0 not at all, so it only lifts
13  sprite: string[]
14  palette: Record<string, string>
15  outline?: string
16  eyes?: [[number, number], [number, number]] // absent: no eyes and no faces
17  eyeColor: string
18  cheeks?: [[number, number], [number, number]]
19  cheekColor?: string
20  mini: { top: string; body: string; edge: string }
21  miniSprite?: string[]
22  props: Body['props']
23  scene?: Scene
24  forms: Partial<Record<Mode, Form>> // how the pet looks in a mode, in place of its own sprite fields
25} & Look
26
27/** The sprite fields a form sets for one mode; those it leaves out are the pet's own. */
28export type Form = Pick<Theme, 'scale' | 'squash' | 'sprite' | 'palette' | 'outline' | 'eyes' | 'eyeColor' | 'cheeks' | 'cheekColor'>
29const FORM_KEYS = ['sprite', 'palette', 'scale', 'squash', 'outline', 'eyes', 'eyeColor', 'cheeks', 'cheekColor'] as const
30
31// The frames mark cheeks and sparkles, and the resting frame pupils, with characters a palette may not use.
32const CHEEK_MARK = '*'
33const SPARKLE_MARK = '+'
34const PUPIL_MARK = '@'
35const RESERVED = ['.', CHEEK_MARK, SPARKLE_MARK, PUPIL_MARK]
36const SPARKLE_COLOR = 0xffe25a
37const SLIME_MINI = { top: '#9ad2ff', body: '#3d84f0', edge: '#1e3a8a' }
38const PROP_SIZE = { w: PROP_W, h: HEIGHT }
39const MAX_PROP_FRAMES = 8
40const MAX_LINE = 40 // characters in a status line, so it fits beside the pet
41const MAX_LABEL = 6 // characters in a HUD label, so the HUD fits its window
42const BARS = ['hp', 'mp', 'st'] as const
43
44// Each pose squashes the sprite by sx, sy and lifts it by dy pixels, all before `scale`. The run and jump poses
45// also carry an eye hint. Tuned on the slime; any pet that fits the canvas reuses them.
46type Pose = [sx: number, sy: number, dy: number, hint?: string]
47const STAND: Pose[] = Array.from({ length: 16 }, (_, i) => {
48  const s = (1 - Math.cos((2 * Math.PI * i) / 16)) / 2
49  return [1 + 0.06 * s, 1 - 0.09 * s, 0]
50})
51const 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']]
52const 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'],
53  [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']]
54const THINK: Pose[] = Array.from({ length: 12 }, (_, i) => {
55  const wobble = 1 + 0.03 * Math.sin((2 * Math.PI * i) / 6)
56  return [wobble, 2 - wobble, 0]
57})
58const 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],
59  [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]]
60const POSES = [...STAND, ...RUN, ...JUMP, ...THINK, ...CHEER]
61
62function sparkle(x: number, y: number, isBig: boolean): [number, number][] {
63  return isBig ? [[x, y], [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]] : [[x, y]]
64}
65
66const cheerSparkles = (i: number) =>
67  i % 3 ? [...sparkle(2, 8, i % 2 === 0), ...sparkle(16, 6, i % 2 === 1)] : [...sparkle(3, 6, true), ...sparkle(15, 9, false)]
68
69// Round half to even. The frames theme.test.ts pins depend on it.
70function roundHalfEven(v: number) {
71  const r = Math.round(v)
72  return Math.abs(v % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r
73}
74
75/** The largest sprite, in pixels, that fits the canvas in every pose at `scale`. */
76export function maxSize(scale: number) {
77  return {
78    w: Math.floor(Math.min(...POSES.map(([sx]) => BODY_W / (sx * scale))) + 1e-9),
79    h: Math.floor(Math.min(...POSES.map(([, sy, dy]) => (HEIGHT / scale - dy) / sy)) + 1e-9),
80  }
81}
82
83const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
84const isPoint = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(n => Number.isInteger(n))
85const isPair = (v: unknown): v is [[number, number], [number, number]] => Array.isArray(v) && v.length === 2 && v.every(isPoint)
86
87/** `#rrggbb` from `#rrggbb` or `#rgb`, or undefined for anything else. */
88function color(v: unknown) {
89  if (typeof v !== 'string') {
90    return undefined
91  }
92  if (/^#[0-9a-fA-F]{6}$/.test(v)) {
93    return v.toLowerCase()
94  }
95
96  return /^#[0-9a-fA-F]{3}$/.test(v) ? `#${[...v.slice(1)].map(c => c + c).join('')}`.toLowerCase() : undefined
97}
98
99/** The largest scale, up to `scale`, at which a w×h sprite fits the canvas in every pose. */
100function fitScale(w: number, h: number, scale: number) {
101  const fits = Math.min(scale, ...POSES.map(([sx, sy, dy]) => Math.min(BODY_W / (sx * w), HEIGHT / (dy + sy * h))))
102
103  return fits < scale ? Math.floor(fits * 100) / 100 : scale
104}
105
106/** Rows drawn in the pet's palette, cut to `max`, or undefined when `v` is not rows. Notes say what changed. */
107function paletteRows(v: unknown, max: { w: number; h: number }, palette: Record<string, string>, what: string, notes: string[]) {
108  if (!Array.isArray(v) || !v.some(r => typeof r === 'string' && r.length > 0)) {
109    notes.push(`${what} is not a list of text rows, so it is left out.`)
110    return undefined
111  }
112  let rows = v.map(r => (typeof r === 'string' ? r : ''))
113  const w = Math.max(...rows.map(r => r.length))
114  if (w > max.w || rows.length > max.h) {
115    notes.push(`${what} is ${w}×${rows.length}, past the largest, ${max.w}×${max.h}, so its bottom-left part is kept.`)
116    rows = rows.slice(-max.h).map(r => r.slice(0, max.w))
117  }
118  rows = rows.map(r => r.padEnd(Math.min(w, max.w), '.'))
119  if (rows.some(r => /[*+@]/.test(r))) {
120    notes.push(`In ${what}, "*", "+", "@" pixels are drawn clear: the mod marks its own drawings with them.`)
121    rows = rows.map(r => r.replace(/[*+@]/g, '.'))
122  }
123  const uncolored = [...new Set(rows.join(''))].filter(c => c !== '.' && !(c in palette))
124  if (uncolored.length > 0) {
125    notes.push(`In ${what}, ${uncolored.map(c => `"${c}"`).join(', ')} has no palette color, so it is drawn clear.`)
126  }
127
128  return rows
129}
130
131/** The pet's own props by mode: frames of rows, or null for no prop. */
132function readProps(v: unknown, palette: Record<string, string>, notes: string[]) {
133  const props: Body['props'] = {}
134  if (v === undefined) {
135    return props
136  }
137  if (!isObject(v)) {
138    notes.push('`props` maps a mode to its prop, so it is left out.')
139    return props
140  }
141  for (const [mode, value] of Object.entries(v)) {
142    if (!(mode in MODES) || mode === 'run') {
143      notes.push(`"${mode}" in \`props\` is not a mode that can hold a prop, so it is left out.`)
144      continue
145    }
146    if (value === false || value === null) {
147      props[mode as Mode] = null
148      continue
149    }
150    const frames = Array.isArray(value) && value.length > 0 && value.every(Array.isArray) ? value : [value]
151    if (frames.length > MAX_PROP_FRAMES) {
152      notes.push(`The ${mode} prop keeps its first ${MAX_PROP_FRAMES} frames.`)
153    }
154    const kept = frames
155      .slice(0, MAX_PROP_FRAMES)
156      .map((f, i) => paletteRows(f, PROP_SIZE, palette, frames.length > 1 ? `frame ${i + 1} of the ${mode} prop` : `the ${mode} prop`, notes))
157      .filter((f): f is string[] => f !== undefined)
158    if (kept.length > 0) {
159      props[mode as Mode] = kept
160    }
161  }
162
163  return props
164}
165
166/** Status lines or line colors by mode, each value checked by `read`. */
167function byMode<T>(v: unknown, field: string, read: (value: unknown, mode: string) => T | undefined, notes: string[]) {
168  const out: Partial<Record<Mode, T>> = {}
169  if (v === undefined) {
170    return out
171  }
172  if (!isObject(v)) {
173    notes.push(`\`${field}\` maps a mode to its value, so it is left out.`)
174    return out
175  }
176  for (const [mode, value] of Object.entries(v)) {
177    if (!(mode in MODES)) {
178      notes.push(`"${mode}" in \`${field}\` is not a mode, so it is left out.`)
179      continue
180    }
181    const kept = read(value, mode)
182    if (kept !== undefined) {
183      out[mode as Mode] = kept
184    }
185  }
186
187  return out
188}
189
190function readLines(v: unknown, notes: string[]) {
191  return byMode(v, 'lines', (value, mode) => {
192    const lines = (Array.isArray(value) ? value : [value]).filter((l): l is string => typeof l === 'string' && l.trim() !== '').map(l => l.trim())
193    if (lines.length === 0) {
194      notes.push(`\`lines.${mode}\` has no text, so the ${mode} mode keeps its own lines.`)
195      return undefined
196    }
197    if (lines.some(l => l.length > MAX_LINE)) {
198      notes.push(`Lines in \`lines.${mode}\` are cut to ${MAX_LINE} characters.`)
199    }
200    return lines.map(l => l.slice(0, MAX_LINE))
201  }, notes)
202}
203
204function readLineColors(v: unknown, notes: string[]) {
205  return byMode(v, 'lineColors', (value, mode) => {
206    const c = color(value)
207    if (c === undefined) {
208      notes.push(`\`lineColors.${mode}\`, ${JSON.stringify(value)}, is not "#rrggbb", so the ${mode} mode keeps its own color.`)
209    }
210    return c
211  }, notes)
212}
213
214function readHud(v: unknown, notes: string[]) {
215  const hud: HudLook = {}
216  if (v === undefined) {
217    return hud
218  }
219  if (!isObject(v)) {
220    notes.push('`hud` is an object, so the HUD keeps its own look.')
221    return hud
222  }
223  if (v.frame !== undefined) {
224    hud.frame = color(v.frame)
225    if (hud.frame === undefined) {
226      notes.push(`\`hud.frame\`, ${JSON.stringify(v.frame)}, is not "#rrggbb", so the frame keeps its own color.`)
227    }
228  }
229  for (const key of BARS) {
230    const b = v[key]
231    if (b === undefined) {
232      continue
233    }
234    if (b === false) {
235      hud[key] = false
236      continue
237    }
238    if (!isObject(b)) {
239      notes.push(`\`hud.${key}\` is an object or false, so that bar keeps its own look.`)
240      continue
241    }
242    const look: BarLook = {}
243    if (typeof b.label === 'string' && b.label.trim() !== '') {
244      const label = [...b.label.trim()]
245      if (label.length > MAX_LABEL) {
246        notes.push(`\`hud.${key}.label\` is cut to ${MAX_LABEL} characters.`)
247      }
248      look.label = label.slice(0, MAX_LABEL).join('')
249    }
250    if (b.color !== undefined) {
251      look.color = color(b.color)
252      if (look.color === undefined) {
253        notes.push(`\`hud.${key}.color\` is not "#rrggbb", so the label keeps its own color.`)
254      }
255    }
256    if (b.fill !== undefined) {
257      const fill = Array.isArray(b.fill) && b.fill.length === 2 ? b.fill.map(color) : []
258      if (fill[0] !== undefined && fill[1] !== undefined) {
259        look.fill = [fill[0], fill[1]]
260      } else {
261        notes.push(`\`hud.${key}.fill\` is two "#rrggbb" colors, so the bar keeps its own fill.`)
262      }
263    }
264    hud[key] = look
265  }
266
267  return hud
268}
269
270/** A list of drawings in the pet's palette, each cut to `max`; at most SCENE_SIZE.items of them. */
271function drawings(v: unknown, max: { w: number; h: number }, palette: Record<string, string>, what: string, notes: string[]) {
272  if (v === undefined) {
273    return []
274  }
275  const list = Array.isArray(v) && v.length > 0 && v.every(Array.isArray) ? v : [v]
276  if (list.length > SCENE_SIZE.items) {
277    notes.push(`\`${what}\` keeps its first ${SCENE_SIZE.items}.`)
278  }
279
280  return list
281    .slice(0, SCENE_SIZE.items)
282    .map((d, i) => paletteRows(d, max, palette, `\`${what}\` ${i + 1}`, notes))
283    .filter((d): d is string[] => d !== undefined)
284}
285
286function readScene(v: unknown, palette: Record<string, string>, notes: string[]): Scene | undefined {
287  if (v === undefined) {
288    return undefined
289  }
290  if (!isObject(v)) {
291    notes.push('`scene` is an object, so the pet has no scene.')
292    return undefined
293  }
294  const ground = v.ground === undefined ? undefined : paletteRows(v.ground, SCENE_SIZE.ground, palette, '`scene.ground`', notes)
295  const sky = v.sky === undefined ? undefined : paletteRows(v.sky, SCENE_SIZE.sky, palette, '`scene.sky`', notes)
296  const obstacles = drawings(v.obstacles, SCENE_SIZE.obstacle, palette, 'scene.obstacles', notes)
297  const decor = drawings(v.decor, SCENE_SIZE.decor, palette, 'scene.decor', notes)
298  if (!ground && !sky && obstacles.length === 0 && decor.length === 0) {
299    notes.push('`scene` has no ground, sky, obstacles, or decor to draw, so the pet has no scene.')
300    return undefined
301  }
302  let every = EVERY.normal
303  if (v.every !== undefined) {
304    const asked = typeof v.every === 'number' ? Math.round(v.every) : NaN
305    every = Number.isNaN(asked) ? EVERY.normal : Math.min(EVERY.max, Math.max(EVERY.min, asked))
306    if (every !== asked) {
307      notes.push(`\`scene.every\` is a number of columns from ${EVERY.min} to ${EVERY.max}, so it is ${every}.`)
308    }
309  }
310
311  return { ground, ...(sky && { sky }), obstacles, decor, every }
312}
313
314/**
315 * A theme from a parsed theme file, or why there is none. Only a value with no sprite is refused. Anything else
316 * draws: the mod repairs what it can and says what it did in `notes`, which the drawer may act on or ignore.
317 */
318export function readTheme(v: unknown): { theme: Theme; notes: string[]; errors?: undefined } | { errors: string[] } {
319  if (!isObject(v)) {
320    return { errors: ['A theme is a JSON object with a `sprite`.'] }
321  }
322  if (!Array.isArray(v.sprite) || !v.sprite.some(r => typeof r === 'string' && r.length > 0)) {
323    return { errors: ['`sprite` is a list of text rows, one character per pixel.'] }
324  }
325  const notes: string[] = []
326  const name = typeof v.name === 'string' && v.name.trim() !== '' ? v.name.trim().slice(0, 24) : 'pet'
327
328  let rows = (v.sprite as unknown[]).map(r => (typeof r === 'string' ? r : ''))
329  const w = Math.max(...rows.map(r => r.length))
330  if (rows.some(r => r.length !== w)) {
331    notes.push(`Rows of different widths were padded with "." to ${w}.`)
332    rows = rows.map(r => r.padEnd(w, '.'))
333  }
334  if (rows.some(r => RESERVED.slice(1).some(c => r.includes(c)))) {
335    notes.push(`The sprite's ${RESERVED.slice(1).map(c => `"${c}"`).join(', ')} pixels are drawn clear: the mod marks its own drawings with them.`)
336    rows = rows.map(r => r.replace(/[*+@]/g, '.'))
337  }
338  const h = rows.length
339
340  const palette: Record<string, string> = {}
341  for (const [ch, value] of Object.entries(isObject(v.palette) ? v.palette : {})) {
342    const c = color(value)
343    if (c === undefined) {
344      notes.push(`The palette color for "${ch}", ${JSON.stringify(value)}, is not "#rrggbb", so "${ch}" is drawn clear.`)
345    } else if (!RESERVED.includes(ch)) {
346      palette[ch] = c
347    }
348  }
349  const uncolored = [...new Set(rows.join(''))].filter(c => c !== '.' && !(c in palette))
350  if (uncolored.length > 0) {
351    notes.push(`${uncolored.map(c => `"${c}"`).join(', ')} has no palette color, so it is drawn clear.`)
352  }
353
354  const asked = typeof v.scale === 'number' && v.scale > 0 ? v.scale : 1
355  const scale = fitScale(w, h, asked)
356  if (scale < asked) {
357    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.`)
358  }
359
360  let squash = 1
361  if (v.squash !== undefined) {
362    const isNumber = typeof v.squash === 'number' && Number.isFinite(v.squash)
363    squash = isNumber ? Math.min(1, Math.max(0, v.squash as number)) : 1
364    if (!isNumber || squash !== v.squash) {
365      notes.push(`\`squash\` is a number from 0 to 1, so ${JSON.stringify(v.squash)} reads as ${squash}.`)
366    }
367  }
368
369  const outline = typeof v.outline === 'string' && v.outline in palette ? v.outline : undefined
370  const eyes = isPair(v.eyes) ? v.eyes : undefined
371  if (v.eyes !== undefined && eyes === undefined) {
372    notes.push('`eyes` is two [x, y] points, so the pet has no eyes and no faces.')
373  }
374  const inside = (x: number, y: number) => x >= 0 && x < w && y >= 0 && y < h
375  for (const [x, y] of eyes ?? []) {
376    if (!inside(x, y - 1) || !inside(x + 2, y + 1)) {
377      notes.push(`The eye at [${x}, ${y}] draws its 3×3 box partly off the ${w}×${h} sprite.`)
378    }
379  }
380  if (eyes && Math.abs(eyes[0][0] - eyes[1][0]) < 3 && Math.abs(eyes[0][1] - eyes[1][1]) < 3) {
381    notes.push('The eye boxes overlap, so the wide eyes and hearts merge into one shape. Pupils 4 pixels apart keep them apart.')
382  }
383  const cheeks = isPair(v.cheeks) ? v.cheeks : undefined
384  if (v.cheeks !== undefined && cheeks === undefined) {
385    notes.push('`cheeks` is two [x, y] pixels, so the pet has no cheeks.')
386  }
387  const inEyeBox = ([x, y]: [number, number]) => (eyes ?? []).some(([ex, ey]) => x >= ex && x <= ex + 2 && y >= ey - 1 && y <= ey + 1)
388  if (cheeks?.some(inEyeBox)) {
389    notes.push('A cheek sits inside an eye box, where some faces draw over it.')
390  }
391  const mini = isObject(v.mini) ? { top: color(v.mini.top), body: color(v.mini.body), edge: color(v.mini.edge) } : undefined
392  const hasMini = mini?.top !== undefined && mini.body !== undefined && mini.edge !== undefined
393  if (v.mini !== undefined && !hasMini) {
394    notes.push('`mini` needs three colors, `top`, `body`, and `edge`, so the minis keep the slime\'s blues.')
395  }
396  const miniSprite = v.miniSprite === undefined ? undefined : paletteRows(v.miniSprite, MINI_SIZE, palette, '`miniSprite`', notes)
397
398  return {
399    theme: {
400      name,
401      scale,
402      squash,
403      sprite: rows,
404      palette,
405      outline,
406      eyes,
407      eyeColor: color(v.eyeColor) ?? '#000000',
408      cheeks,
409      cheekColor: cheeks ? (color(v.cheekColor) ?? '#ff8aaa') : undefined,
410      mini: hasMini ? (mini as Theme['mini']) : SLIME_MINI,
411      miniSprite,
412      props: readProps(v.props, palette, notes),
413      lines: readLines(v.lines, notes),
414      lineColors: readLineColors(v.lineColors, notes),
415      hud: readHud(v.hud, notes),
416      scene: readScene(v.scene, palette, notes),
417      forms: readForms(v, notes),
418    },
419    notes,
420  }
421}
422
423/**
424 * The forms by mode. Each is read as a sprite of its own, from the pet's sprite fields with the form's over them and
425 * the palettes merged, so a form may only recolor. Its notes say which form they are about, past those the pet's own
426 * fields gave already.
427 */
428function readForms(v: Record<string, unknown>, notes: string[]): Theme['forms'] {
429  const forms: Theme['forms'] = {}
430  if (v.forms === undefined) {
431    return forms
432  }
433  if (!isObject(v.forms)) {
434    notes.push('`forms` maps a mode to its form, so it is left out.')
435    return forms
436  }
437  const fields = (o: Record<string, unknown>) => Object.fromEntries(FORM_KEYS.filter(k => o[k] !== undefined).map(k => [k, o[k]]))
438  const before = new Set(notes)
439  for (const [mode, form] of Object.entries(v.forms)) {
440    if (!(mode in MODES)) {
441      notes.push(`"${mode}" in \`forms\` is not a mode, so it is left out.`)
442      continue
443    }
444    if (!isObject(form)) {
445      notes.push(`\`forms.${mode}\` is not an object of sprite fields, so it is left out.`)
446      continue
447    }
448    const palette = { ...(isObject(v.palette) ? v.palette : {}), ...(isObject(form.palette) ? form.palette : {}) }
449    const read = readTheme({ ...fields(v), ...fields(form), palette })
450    if (read.errors) {
451      notes.push(`forms.${mode}: ${read.errors.join(' ')}`)
452      continue
453    }
454    notes.push(...read.notes.filter(n => !before.has(n)).map(n => `forms.${mode}: ${n}`))
455    const t = read.theme
456    forms[mode as Mode] = { scale: t.scale, squash: t.squash, sprite: t.sprite, palette: t.palette, outline: t.outline, eyes: t.eyes, eyeColor: t.eyeColor, cheeks: t.cheeks, cheekColor: t.cheekColor }
457  }
458
459  return forms
460}
461
462const colorOf = (color: string) => parseInt(color.slice(1), 16)
463
464function poseFrame(theme: Form, pose: Pose, extra: [number, number][] = []): BodyFrame {
465  // squash scales how far the pose bends the sprite; the lift stays whole.
466  const bend = (f: number) => 1 + (f - 1) * theme.squash
467  const [sx, sy, dy] = [bend(pose[0]) * theme.scale, bend(pose[1]) * theme.scale, pose[2] * theme.scale]
468  const sw = (theme.sprite[0] as string).length
469  const sh = theme.sprite.length
470  const cx = sw / 2
471  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
472  const tb = HEIGHT - dy
473  const g = Array.from({ length: HEIGHT }, () => new Array<string>(BODY_W).fill('.'))
474  const put = (x: number, y: number, ch: string) => {
475    const [px, py] = [roundHalfEven(x), roundHalfEven(y)]
476    if (px >= 0 && px < BODY_W && py >= 0 && py < HEIGHT) {
477      ;(g[py] as string[])[px] = ch
478    }
479  }
480  // Each canvas pixel takes the sprite color that covers most of it; the outline counts extra, so it survives a squash.
481  for (let ty = 0; ty < HEIGHT; ty++) {
482    for (let tx = 0; tx < BODY_W; tx++) {
483      const u0 = cx + (tx - tcx) / sx
484      const u1 = cx + (tx + 1 - tcx) / sx
485      const v0 = sh - (tb - ty) / sy
486      const v1 = sh - (tb - ty - 1) / sy
487      const cover = new Map<string, number>()
488      for (let v = Math.max(0, Math.floor(v0)); v < Math.min(sh, Math.ceil(v1)); v++) {
489        for (let u = Math.max(0, Math.floor(u0)); u < Math.min(sw, Math.ceil(u1)); u++) {
490          const area = (Math.min(u1, u + 1) - Math.max(u0, u)) * (Math.min(v1, v + 1) - Math.max(v0, v))
491          const ch = (theme.sprite[v] as string)[u] as string
492          if (area > 0 && ch !== '.') {
493            cover.set(ch, (cover.get(ch) ?? 0) + area * (ch === theme.outline ? 1.6 : 1))
494          }
495        }
496      }
497      let total = 0
498      let best = ''
499      for (const [ch, area] of cover) {
500        total += area
501        if (best === '' || area > (cover.get(best) as number)) {
502          best = ch
503        }
504      }
505      if (best !== '' && total >= 0.45 * (u1 - u0) * (v1 - v0)) {
506        ;(g[ty] as string[])[tx] = best
507      }
508    }
509  }
510  const at = ([x, y]: [number, number]) => [tcx + (x - cx) * sx, tb - (sh - y) * sy] as const
511  for (const [x, y] of theme.cheeks ?? []) {
512    const [px, py] = at([x + 0.5, y + 0.5])
513    put(px - 0.5, py - 0.5, CHEEK_MARK)
514  }
515  for (const [x, y] of extra) {
516    put(x, y, SPARKLE_MARK)
517  }
518  const [l, r] = (theme.eyes ?? [[0, 0], [0, 0]]).map(([x, y]) => {
519    const [px, py] = at([x + 1.5, y + 0.5])
520    return [Math.floor(px - 1.5 + 0.5), Math.floor(py - 0.5 + 0.5) - 1] as [number, number]
521  }) as [[number, number], [number, number]]
522
523  return { g: g.map(row => row.join('')), l, r, e: pose[3] ?? '' }
524}
525
526/** A sprite's colors as the drawing code reads them, and its frames for every clip. */
527function drawn(theme: Form): Pick<Body, 'palette' | 'eye' | 'clips'> {
528  const palette: Record<string, number> = { [SPARKLE_MARK]: SPARKLE_COLOR }
529  for (const [ch, color] of Object.entries(theme.palette)) {
530    palette[ch] = colorOf(color)
531  }
532  if (theme.cheekColor !== undefined) {
533    palette[CHEEK_MARK] = colorOf(theme.cheekColor)
534  }
535
536  return {
537    palette,
538    eye: theme.eyes ? { ...EYE_COLOR, K: colorOf(theme.eyeColor) } : {},
539    clips: {
540      stand: { fps: 8, frames: STAND.map(p => poseFrame(theme, p)) },
541      run: { fps: 12, frames: RUN.map(p => poseFrame(theme, p)) },
542      jump: { fps: 12, frames: JUMP.map(p => poseFrame(theme, p)) },
543      think: { fps: 6, frames: THINK.map(p => poseFrame(theme, p)) },
544      cheer: { fps: 10, frames: CHEER.map((p, i) => poseFrame(theme, p, cheerSparkles(i))) },
545    },
546  }
547}
548
549/** The pet's frames for every clip and every form, with its colors as the drawing code reads them. */
550export function animate(theme: Theme): Body {
551  return {
552    name: theme.name,
553    w: BODY_W,
554    h: HEIGHT,
555    ...drawn(theme),
556    mini: { top: colorOf(theme.mini.top), body: colorOf(theme.mini.body), edge: colorOf(theme.mini.edge) },
557    miniSprite: theme.miniSprite,
558    props: theme.props,
559    look: { lines: theme.lines, lineColors: theme.lineColors, hud: theme.hud },
560    scene: theme.scene,
561    forms: Object.fromEntries(Object.entries(theme.forms).map(([mode, form]) => [mode, drawn(form)])),
562  }
563}
564
565/** The resting frame as text, trimmed to the rows in use: palette characters, `@` for a pupil, `*` for a cheek. */
566export function restingFrame(body: Body) {
567  const frame = body.clips.stand.frames[0] as BodyFrame
568  const g = frame.g.map(row => [...row])
569  for (const [x, y] of Object.keys(body.eye).length > 0 ? [frame.l, frame.r] : []) {
570    for (const [dx, dy] of [[0, 1], [1, 1], [0, 2], [1, 2]] as const) {
571      const row = g[y + dy]
572      if (row && x + dx >= 0 && x + dx < row.length) {
573        row[x + dx] = PUPIL_MARK
574      }
575    }
576  }
577  const rows = g.map(r => r.join(''))
578
579  return rows.slice(rows.findIndex(r => /[^.]/.test(r))).join('\n')
580}
581
hooks/preview.ts 249 lines
1import type { Anim, Mode } from '../types'
2import { TICK_MS, leapClipMs, step } from './anim'
3import { HUD_WINDOW_W, ROW_GAP, frameColor, hudRows, windowEdges } from './hud'
4import type { Hud } from './hud'
5import { BOSS_W, DEFEAT_MS, drawBoss } from './boss'
6import { BODY_W, FACES, HEIGHT, MODES, canvas, compose, composeFace, overlay } from './pixels'
7import type { Body, Canvas, Clip } from './pixels'
8import { GROUND_H, drawBand, layScene, obstacleSpans } from './scene'
9import { LINES, lineColor } from './status'
10
11// When each mode plays, in the words of the README's table.
12const WHEN: Record<Mode, string> = {
13  idle: 'Claude is idle',
14  sleep: 'Idle for a while',
15  jump: 'A turn starts',
16  think: 'Claude thinks longer',
17  read: 'Read',
18  search: 'Grep, Glob',
19  edit: 'Edit, MultiEdit, Write, NotebookEdit, TodoWrite',
20  bash: 'Bash, and any tool not listed',
21  web: 'WebFetch, WebSearch',
22  run: 'A tool call ends',
23  agent: 'A subagent starts',
24  error: 'A tool call fails',
25  cheer: 'A turn ends',
26  guard: 'A destructive command waits for your answer',
27}
28// What the status line names in each mode, for the lines that name something.
29const SAMPLE_TARGET: Partial<Record<Mode, string>> = { read: 'app.ts', search: 'useState', edit: 'app.ts', bash: 'npm test', web: 'docs.anthropic.com', guard: 'rm -rf build' }
30const SAMPLE_HUDS: [string, Hud][] = [
31  ['Early in a session', { hp: 86, cacheMin: 52, mp: 72, mpResetsInMin: 213, st: 64, stResetsInMin: 4560 }],
32  ['Running low', { hp: 18, cacheMin: 0, mp: 12, mpResetsInMin: 41, st: 9, stResetsInMin: 1500 }],
33  ['Burning fast', { hp: 70, cacheMin: 4, mp: 40, mpResetsInMin: 180, st: 20, stResetsInMin: 5040 }],
34]
35const BOSS_GAP = 12 // columns between the pet and the boss in the boss tile
36const BOSS_BEATEN_AT = 2600
37const MOTION_FPS = 10
38const SCENE_W = 96 // columns of the sample band
39const SCENE_MS = 12000
40const FACE_FPS = 5
41const LOOP_MS = 2400
42
43// One character per palette entry; the set leaves out what would need escaping in HTML or a JS string.
44const CODES = [...Array.from({ length: 94 }, (_, i) => String.fromCharCode(33 + i)).filter(c => !`"'\\<>&\``.includes(c)), ...Array.from({ length: 200 }, (_, i) => String.fromCharCode(192 + i))]
45
46const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
47
48type Tile = { label: string; note: string; w: number; h: number; fps: number; frames: string[] }
49
50/**
51 * The preview: a self-contained HTML page with every motion, face, and frame the mod makes for `body`. It shows each mode in
52 * motion with when it plays, the pet running through its scene when it has one, the boss fight, each face, each mode's status lines,
53 * the HUD in two sample states, and each clip's frames before eyes go on. A button switches to a light
54 * terminal's background. `notes` are readTheme's.
55 */
56export function previewPage(body: Body, notes: string[]): string {
57  const colors: number[] = []
58  const encode = (c: Canvas) =>
59    c.px
60      .map(color => {
61        if (color === -1) {
62          return ' '
63        }
64        let i = colors.indexOf(color)
65        if (i === -1) {
66          i = colors.push(color) - 1
67        }
68        return CODES[i] ?? ' '
69      })
70      .join('')
71  const frames = (count: number, fps: number, draw: (t: number) => Canvas) => Array.from({ length: count }, (_, i) => draw((i * 1000) / fps))
72
73  const motions: Tile[] = (Object.keys(WHEN) as Mode[]).map(mode => {
74    const length = MODES[mode].once ?? LOOP_MS
75    const minis = mode === 'agent' ? [{ age: 1000 }, { age: 400 }] : []
76    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 }))))
77    const first = shots[0] as Canvas
78    return { label: mode, note: WHEN[mode], w: first.w, h: first.h, fps: MOTION_FPS, frames: shots.map(encode) }
79  })
80  const faces: Tile[] = FACES.map(name => {
81    const shots = frames((LOOP_MS / 1000) * FACE_FPS, FACE_FPS, t => composeFace(body, name, t))
82    const first = shots[0] as Canvas
83    return { label: name, note: '', w: first.w, h: first.h, fps: FACE_FPS, frames: shots.map(encode) }
84  })
85  const clips: Tile[] = (Object.keys(body.clips) as Clip[]).map(clip => {
86    const { fps, frames: list } = body.clips[clip]
87    const shots = list.map(f => {
88      const c: Canvas = { w: BODY_W, h: HEIGHT, px: [] }
89      for (const row of f.g) {
90        for (const ch of row) {
91          c.px.push(body.palette[ch] ?? -1)
92        }
93      }
94      return c
95    })
96    return { label: clip, note: `${list.length} frames at ${fps} fps`, w: BODY_W, h: HEIGHT, fps, frames: shots.map(encode) }
97  })
98  const scenes: Tile[] = []
99  if (body.scene) {
100    const layout = layScene(body.scene, SCENE_W)
101    const between = { isWorking: true, activeTools: 0, activeMode: 'bash' as const, activeTarget: '', lastToolAt: Infinity, room: SCENE_W - BODY_W, obstacles: obstacleSpans(layout), trail: 0 }
102    let a: Anim = { mode: 'run', since: 0, x: 0, dir: 1, tick: 0, target: '', working: true }
103    const shots: string[] = []
104    for (let t = 0; t < SCENE_MS; t += TICK_MS) {
105      a = step(a, between, t)
106      const picture = a.leap ? compose(body, 'jump', leapClipMs(t - a.leap.since), a.dir) : compose(body, 'run', t - a.since, a.dir)
107      shots.push(encode(drawBand(body, body.scene, layout, picture, Math.round(a.x), t)))
108    }
109    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 })
110  }
111  // A test run fails and brings the boss; the next run passes and defeats it, and the pet cheers.
112  const bossShots: string[] = []
113  const BOSS_FIGHT_W = BODY_W + BOSS_GAP + BOSS_W
114  for (let t = 0; t < BOSS_BEATEN_AT + DEFEAT_MS; t += TICK_MS) {
115    const isBeaten = t >= BOSS_BEATEN_AT
116    const c = canvas(BOSS_FIGHT_W, HEIGHT)
117    overlay(c, isBeaten ? compose(body, 'cheer', t - BOSS_BEATEN_AT, 1) : compose(body, t < MODES.error.once! ? 'error' : 'idle', t, 1), 0)
118    overlay(c, drawBoss(isBeaten ? { since: 0, hits: 2, defeatedAt: BOSS_BEATEN_AT } : { since: 0, hits: t < 1200 ? 1 : 2 }, t), BODY_W + BOSS_GAP)
119    bossShots.push(encode(c))
120  }
121  const bossFight: Tile[] = [{ label: 'boss', note: 'Failed test runs bring a bug; the next passing run defeats it', w: BOSS_FIGHT_W, h: HEIGHT, fps: 1000 / TICK_MS, frames: bossShots }]
122  const palette = colors.map(c => `#${c.toString(16).padStart(6, '0')}`)
123  const name = escapeHtml(body.name)
124  const tiles = (list: Tile[], kind: string) =>
125    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('')
126  const statusLines = (Object.keys(WHEN) as Mode[])
127    .map(mode => {
128      const lines = (body.look.lines[mode] ?? LINES[mode]).map(l => `<li>› ${escapeHtml(l.replace('{}', SAMPLE_TARGET[mode] ?? 'something'))}</li>`).join('')
129      return `<div class="lines"><b>${mode}</b><ul style="color: ${lineColor(mode, body.look.lineColors)}">${lines}</ul></div>`
130    })
131    .join('')
132  const edges = windowEdges(HUD_WINDOW_W)
133  const huds = SAMPLE_HUDS.map(([label, h]) => {
134    const rows = hudRows(h, body.look.hud)
135    const inside = rows.length === 0
136      ? '<p>The pet hides every bar, so the HUD does not show.</p>'
137      : `<pre class="hud" style="color: ${frameColor(body.look.hud)}">${escapeHtml(edges.top)}\n${rows
138          .map(r => {
139            // Padded to the window's inside, so the right side lands where the terminal draws it.
140            const used = 1 + [...r.label].length + 1 + r.bar.w + r.parts.reduce((n, p) => n + [...p.text].length, 0)
141            const pad = ' '.repeat(Math.max(0, HUD_WINDOW_W - 2 - used))
142            return `${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('')}${pad}${edges.side}`
143          })
144          .join('\n')}\n${escapeHtml(edges.bottom)}</pre>`
145    return `<figure class="wide"><figcaption><b>${label}</b></figcaption>${inside}</figure>`
146  }).join('')
147  // The first sample again, laid in a row as the HUD layout setting's `row` draws it.
148  const inRow = hudRows((SAMPLE_HUDS[0] as [string, Hud])[1], body.look.hud, true)
149  const rowHud = inRow.length === 0
150    ? ''
151    : `<figure class="wide"><figcaption><b>In a row</b><span>HUD layout: row, the default: one line, no window</span></figcaption><pre class="hud-row" style="color: ${frameColor(body.look.hud)}">${inRow
152        .map(r => `<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('')}`)
153        .join(escapeHtml(ROW_GAP))}</pre></figure>`
154  const noteList = notes.length > 0 ? `<section class="notes"><h2>Notes</h2><ul>${notes.map(n => `<li>${escapeHtml(n)}</li>`).join('')}</ul></section>` : ''
155
156  return `<!doctype html>
157<html lang="en">
158<head>
159<meta charset="utf-8">
160<meta name="viewport" content="width=device-width, initial-scale=1">
161<title>${name}: preview</title>
162<style>
163  body { margin: 0; padding: 32px; font: 15px/1.5 ui-monospace, Menlo, monospace; background: #0b0f2a; color: #e3e8ff; }
164  body.light { background: #f4f5f9; color: #1b1e2b; }
165  header { display: flex; flex-wrap: wrap; gap: 16px; align-items: baseline; justify-content: space-between; max-width: 1100px; }
166  h1 { margin: 0; font-size: 28px; }
167  h2 { font-size: 18px; margin: 32px 0 12px; }
168  p { margin: 4px 0 0; opacity: 0.8; }
169  button { font: inherit; padding: 8px 14px; border: 2px solid currentColor; background: transparent; color: inherit; cursor: pointer; }
170  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; max-width: 1100px; }
171  figure { margin: 0; padding: 12px; border: 2px solid #2a3470; display: grid; gap: 8px; justify-items: center; }
172  body.light figure { border-color: #c9cede; }
173  canvas { image-rendering: pixelated; max-width: 100%; }
174  figcaption { display: grid; text-align: center; }
175  figcaption span { font-size: 12px; opacity: 0.7; }
176  .notes { max-width: 1100px; border: 2px solid #e0b400; padding: 0 16px 8px; }
177  .wide-cells { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
178  .stack { display: grid; gap: 16px; max-width: 1100px; }
179  .wide { justify-items: start; overflow-x: auto; }
180  .wide canvas[data-kind] { width: 100%; height: auto; }
181  .lines ul { margin: 4px 0 0; padding: 0; list-style: none; font-weight: bold; }
182  .hud, .hud-row { margin: 0; line-height: 18px; }
183  .hud canvas.bar, .hud-row canvas.bar { height: 18px; vertical-align: top; } /* as wide as its cells, which the script sets */
184</style>
185</head>
186<body>
187<header>
188  <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>
189  <button type="button" aria-pressed="false" id="light">Light terminal</button>
190</header>
191${noteList}
192<h2>Motions</h2>
193<div class="grid">${tiles(motions, 'motion')}</div>
194${scenes.length > 0 ? `<h2>Scene</h2>\n<div class="stack">${tiles(scenes, 'scene').replace('<figure>', '<figure class="wide">')}</div>` : ''}
195<h2>Boss</h2>
196<div class="grid">${tiles(bossFight, 'boss')}</div>
197<h2>Faces</h2>
198<div class="grid">${tiles(faces, 'face')}</div>
199<h2>Status lines</h2>
200<p>The line beside the pet in each mode. One shows at a time, and the next takes over every 4 seconds.</p>
201<div class="grid wide-cells">${statusLines}</div>
202<h2>HUD</h2>
203<div class="stack">${huds}${rowHud}</div>
204<h2>Frames</h2>
205<p>Each clip as the mod squashes and stretches the sprite, before eyes and props go on.</p>
206<div class="grid">${tiles(clips, 'clip')}</div>
207<script>
208const PALETTE = ${JSON.stringify(palette)}
209const CODES = ${JSON.stringify(CODES.slice(0, palette.length).join(''))}
210const TILES = { motion: ${JSON.stringify(motions)}, scene: ${JSON.stringify(scenes)}, boss: ${JSON.stringify(bossFight)}, face: ${JSON.stringify(faces)}, clip: ${JSON.stringify(clips)} }
211const still = matchMedia('(prefers-reduced-motion: reduce)').matches
212const canvases = [...document.querySelectorAll('canvas[data-kind]')].map(c => ({ ctx: c.getContext('2d'), tile: TILES[c.dataset.kind][c.dataset.i] }))
213function draw(ctx, tile, frame) {
214  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
215  for (let i = 0; i < frame.length; i++) {
216    const k = CODES.indexOf(frame[i])
217    if (k < 0) continue
218    ctx.fillStyle = PALETTE[k]
219    ctx.fillRect((i % tile.w) * 4, Math.floor(i / tile.w) * 4, 4, 4)
220  }
221}
222function loop(now) {
223  for (const { ctx, tile } of canvases) draw(ctx, tile, tile.frames[still ? 0 : Math.floor((now / 1000) * tile.fps) % tile.frames.length])
224  if (!still) requestAnimationFrame(loop)
225}
226requestAnimationFrame(loop)
227// A bar's cells as the terminal draws them: little-endian u32 triplets [glyph, fg, bg], ▀ or ▄, 2 pixels per cell.
228for (const bar of document.querySelectorAll('canvas.bar')) {
229  const b = atob(bar.dataset.cells)
230  const u32 = k => (b.charCodeAt(k) | (b.charCodeAt(k + 1) << 8) | (b.charCodeAt(k + 2) << 16) | (b.charCodeAt(k + 3) << 24)) >>> 0
231  const cells = b.length / 12
232  bar.width = cells * 9
233  bar.height = 18
234  const ctx = bar.getContext('2d')
235  for (let i = 0; i < cells; i++) {
236    const [glyph, fg, bg] = [u32(i * 12), u32(i * 12 + 4), u32(i * 12 + 8)]
237    const half = (color, y) => { if (color < 0x1000000) { ctx.fillStyle = '#' + color.toString(16).padStart(6, '0'); ctx.fillRect(i * 9, y, 9, 9) } }
238    if (glyph === 0x2580) { half(fg, 0); half(bg, 9) }
239    if (glyph === 0x2584) half(fg, 9)
240  }
241}
242const light = document.getElementById('light')
243light.onclick = () => light.setAttribute('aria-pressed', String(document.body.classList.toggle('light')))
244</script>
245</body>
246</html>
247`
248}
249
hooks/previewPath.ts 51 lines
1import type { FsStat } from 'claude-code'
2
3/** The name every preview file starts with. preview_theme writes nowhere else, so a misled call cannot overwrite a dotfile or source code. */
4export const PREVIEW_PREFIX = 'oxen-pet-preview'
5
6const baseName = (p: string) => p.split(/[\\/]/).pop() ?? ''
7const isPreviewName = (name: string) => name.startsWith(PREVIEW_PREFIX) && name.endsWith('.html')
8
9const isAbsolute = (p: string) => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)
10
11/** Why preview_theme must not write to `path`, or undefined when it may: an absolute path with no `..`, to a file named oxen-pet-preview….html. */
12export function previewPathError(path: unknown): string | undefined {
13  if (typeof path !== 'string' || path === '') {
14    return '`path` is the HTML file to write.'
15  }
16  if (!isAbsolute(path)) {
17    return '`path` must be absolute.'
18  }
19  const parts = path.split(/[\\/]/)
20  if (parts.includes('..')) {
21    return '`path` must not contain `..`.'
22  }
23  if (!isPreviewName(parts[parts.length - 1] ?? '')) {
24    return `\`path\` must name a file that starts with ${PREVIEW_PREFIX} and ends with .html, such as /tmp/${PREVIEW_PREFIX}-cat.html.`
25  }
26
27  return undefined
28}
29
30/**
31 * Why preview_theme must not write where an allowed `path` leads, or undefined when it may. `own` is the path's stat
32 * with `realPath`, undefined when nothing is there yet; `folder` is its folder's. The spelling check alone lets a
33 * symbolic link planted at an allowed name send the write to any file, and lets the write create missing folders.
34 */
35export function previewTargetError(own: FsStat | undefined, folder: FsStat | undefined): string | undefined {
36  if (own === undefined) {
37    return folder?.kind === 'dir' ? undefined : 'its folder must already exist.'
38  }
39  if (own.isLink) {
40    return '`path` is a symbolic link.'
41  }
42  if (own.kind !== 'file') {
43    return '`path` is not a plain file.'
44  }
45  if (own.realPath === undefined || !isPreviewName(baseName(own.realPath))) {
46    return `\`path\` lands on ${own.realPath ?? 'a place that cannot be resolved'}, not an ${PREVIEW_PREFIX} file.`
47  }
48
49  return undefined
50}
51
hooks/settings.ts 48 lines
1import { DEFAULT_THEME, isThemeName } from './custom'
2
3/** The user's settings, from the plugin's `userConfig`, as the mod uses them. */
4export type Settings = {
5  pace: number // 1 is normal; the pet runs and animates this many times as fast
6  sleepAfterMs: number // 0 keeps the pet awake
7  hud: boolean
8  statusLine: boolean
9  targets: boolean // the status line names files, patterns, commands, and hosts
10  minis: boolean
11  cacheTtlMin: number // how long the prompt cache stays warm after a turn; 0 hides the cache timer
12  guard: boolean // the shield asks before a destructive command runs unasked
13  boss: boolean // a failed test run brings a bug boss into the band
14  hudRow: boolean // the HUD lays its bars side by side in one row
15  theme: string // the pet a session starts with: a built-in one or one in the custom folder, by name
16  customDir: string // where the user's own themes are; empty for the .claude folder's oxen-pet/themes
17}
18
19const PACE: Record<string, number> = { slow: 0.6, normal: 1, fast: 1.6 }
20const CACHE_TTL: Record<string, number> = { '1h': 60, '5m': 5, off: 0 }
21
22export const DEFAULTS: Settings = { pace: 1, sleepAfterMs: 60000, hud: true, statusLine: true, targets: false, minis: true, cacheTtlMin: 60, guard: false, boss: true, hudRow: true, theme: DEFAULT_THEME, customDir: '' }
23
24const flag = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
25
26/**
27 * Settings from the options Claude Code passes to `register`. A missing or malformed value takes its default,
28 * so settings saved by another version of the mod never stop it loading.
29 */
30export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
31  const sleep = options.sleepAfter
32
33  return {
34    pace: PACE[String(options.speed)] ?? DEFAULTS.pace,
35    sleepAfterMs: typeof sleep === 'number' && Number.isFinite(sleep) && sleep >= 0 ? sleep * 1000 : DEFAULTS.sleepAfterMs,
36    hud: flag(options.hud, DEFAULTS.hud),
37    statusLine: flag(options.statusLine, DEFAULTS.statusLine),
38    targets: flag(options.targets, DEFAULTS.targets),
39    minis: flag(options.minis, DEFAULTS.minis),
40    cacheTtlMin: Object.hasOwn(CACHE_TTL, String(options.cacheTtl)) ? CACHE_TTL[String(options.cacheTtl)]! : DEFAULTS.cacheTtlMin,
41    guard: flag(options.guard, DEFAULTS.guard),
42    boss: flag(options.boss, DEFAULTS.boss),
43    hudRow: options.hudLayout !== 'stacked',
44    theme: typeof options.theme === 'string' && isThemeName(options.theme.trim()) ? options.theme.trim() : DEFAULT_THEME,
45    customDir: typeof options.customDir === 'string' ? options.customDir : DEFAULTS.customDir,
46  }
47}
48