SLOPSHOPPER

codex-pet

Your Codex pet, living above the Claude Code prompt: it works while Claude works, waits when you are needed, and celebrates when a turn is done.

newbandguardcommandtoastprocess
★ 1v0.5.0MITupdated 2026-10-07steven-panxd/codex-pet-in-claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-pet
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ codex-pet │ ⏺ Read(src/auth.ts) │ Codex Pet: Node.js was not found, and │ ⎿ Read 6 lines │ reading a Codex pet needs it. Showing no │ ⏺ Update(src/auth.ts) │ pet; /pet list names the pets found. │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pet ⎿ codex-pet: No pet is loaded. ⎿ codex-pet: ⎿ codex-pet: Usage: `/pet list` | `use <id|auto>` | `install <name on petdex.dev>` | `refresh` | `hide` | `show` | `<mood>` ⎿ codex-pet: ⎿ codex-pet: Moods to preview: idle, running-right, running-left, waving, jumping, failed, waiting, running, review ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Codex Pet for Claude Code

English | 简体中文

The Codex pet going through its moods above the Claude Code prompt: waving, working, waiting, failed, done, ready for review, idle

<sub>Codex, the Codex app's own pet, with the frames this plugin draws from the copy on your machine. The pet's artwork is OpenAI's: it appears here to show what the plugin does, is not part of the plugin, and is not covered by this repository's license.</sub>

Your Codex pet, living above the Claude Code prompt. It works while Claude works, waits when you are needed, and celebrates when a turn is done.

It reads the pets already on your machine, in the Codex pet format, and draws them in the Claude Code desktop app and in terminals that show images. In any other terminal the pet is a small face of characters that follows the same moods. No pet artwork is bundled except Blob, a small original pet shown when no Codex pet is found.

An unofficial community plugin. Not affiliated with, endorsed by, or sponsored by OpenAI or Anthropic. "Codex" is a trademark of OpenAI; "Claude" is a trademark of Anthropic.

What it shows

The sessionThe pet
A turn is runningrunning
Claude asks you a question, wants a plan approved, or a tool needs permissionwaiting
A tool call failsfailed, briefly (not when you refused it, or interrupted the turn)
A turn finishesjumping, then review for 20 seconds, then idle
A turn ends in an errorfailed for 6 seconds
You interrupt a turnidle
The turn is over but background agents are still goingstays running until they finish (background shell commands do not count)
The session startswaving

Requirements

  • Claude Code 2.1.289 or later, with plugin function hooks. This is an early-access API that Anthropic is rolling out gradually: it may change between releases, and it may not be switched on for your account yet. See Nothing shows up.
  • Node.js 18 or later (or Bun) on your PATH, to convert a pet's spritesheet. Without it the plugin still runs and shows Blob.
  • For a pet whose spritesheet is WebP (most are), one of: macOS (its sips is used), dwebp from libwebp, ImageMagick, ffmpeg, or Python with Pillow. PNG spritesheets need nothing.

Install

claude plugin marketplace add steven-panxd/codex-pet-in-claude
claude plugin install codex-pet@codex-pet

Or try it from a checkout without installing:

claude --plugin-dir /path/to/codex-pet-in-claude

Which pet

With the default setting, auto, the plugin picks in this order:

  1. a pet you installed in ~/.codex/pets (with /pet install <name>, with npx petdex install <slug>, or one you hatched in Codex),
  2. the Codex desktop app's own default pet, if the app is installed (macOS),
  3. Blob.

/pet list names every pet found, and /pet use <id> switches. The choice is remembered across sessions.

Works with Petdex

Petdex is the community gallery of pets in the Codex format. Petdex compatibility: this plugin reads pets installed to ~/.codex/pets, and /pet install <name> downloads any Petdex pet by the name in its page's address.

/pet install boba

The pets are their creators' work, shared through Petdex; this plugin only fetches and draws them. Thanks to the Petdex team and its contributors for the gallery and the open pet format.

Commands

CommandWhat it does
/petSays which pet is showing and lists the commands
/pet listLists the pets found on this machine
/pet use <id>Switches to a pet and remembers it
/pet use autoGoes back to following the pet setting
/pet install <name>Downloads a pet from petdex.dev into ~/.codex/pets, shows it and remembers it. <name> is the last part of the pet's page address, or the whole address
/pet refreshConverts the current pet again, whatever is cached
/pet hide, /pet showHides the pet for this session, or brings it back
/pet <mood>Previews a mood for 6 seconds: idle, running, waiting, review, failed, jumping, waving, running-left, running-right

Settings

Set these in /config, under the plugin's name.

SettingValuesDefault
petauto, or a pet's idautoThe pet shown unless /pet use chose another
sizesmall, medium, largemediumDesktop: 72, 104 or 156 pixels tall. A terminal picture: 4, 6 or 9 rows. Terminal blocks: 7 rows for small, 13 otherwise
animationlively, calm, stillcalmcalm lets a pet that is idle, waiting or up for review rest between movements; still draws one frame a mood
labelon, offonThe pet's name and what it is doing, beside it
alignleft, center, rightcenterWhere the pet stands in the band above the prompt
terminalStyleauto, picture, face, blocksautoHow the pet is drawn in a terminal. See below
terminalCellsstandard, tallstandardFor the terminal picture and blocks. Set to tall if the pet looks stretched upward in your terminal (its line spacing is roomy): it is then drawn wider to keep its shape

How it looks on each surface

  • Desktop app: the pet's own pixels, up to 192 by 208 a frame, in up to 32 colors a mood. A pet too detailed to fit a frame at that size is drawn at half size or with fewer colors.
  • Terminal that shows images (kitty, Ghostty): the pet's own pixels, 4, 6 or 9 rows tall by size. Experimental: it is covered by tests but has not been tried in a real kitty or Ghostty yet. It is not used through tmux or over ssh, and if the terminal turns out not to draw the picture the plugin falls back to the face by itself.
  • Any other terminal: a face of plain characters in the pet's main color, on one row, such as (•‿•) Codex idle. It blinks, spins while a turn runs, and changes with each mood. A character cell cannot hold enough pixels to do the pet's artwork justice, so the plugin does not try by default.
  • Blocks, if you want the pet's shape anyway: set terminalStyle to blocks for quadrant block characters, 48 by 26 pixels in 13 rows, or 24 by 14 in 7 rows when the terminal is short or size is small. Recognizable, coarse.

Privacy and what it touches

  • It reads ~/.codex/pets and, on macOS, the pet spritesheets inside the Codex app's bundle. It reads nothing else of Codex's: no settings, no sessions, no credentials.
  • Converted frames are cached in ~/.cache/codex-pet-claude (or $XDG_CACHE_HOME/codex-pet-claude). You may delete that folder; it is rebuilt at the next session's start, or by /pet refresh.
  • The plugin reaches the network only when you run /pet install, and then only petdex.dev: it reads that pet's install script without running it, and downloads the two files it names (a manifest and a spritesheet), checking that they are what they claim. Nothing is uploaded, and nothing else is ever fetched.
  • Pets from the Codex app are OpenAI's artwork. They are read in place on your own machine and are not included in or redistributed by this plugin.

Nothing shows up

The plugin installs on any account, but its pet only appears where Claude Code runs plugin function hooks, which is behind a gradual rollout (seen in Claude Code 2.1.292).

  1. Update Claude Code, then restart it.
  2. Check whether the rollout has reached your account. This prints true when it has:
   grep -o '"tengu_plugin_hooks_modules": *[a-z]*' ~/.claude.json
  1. If it prints false or nothing, the plugin cannot draw yet on your account, and nothing in this repository can change that. Star or watch the repository to try again later.
  2. If it prints true and there is still no pet, run /pet. A reply means the plugin is loaded: try /pet show, then /pet list. No reply means it is not installed or not enabled: check claude plugin list.

Known limits

  • After you approve a permission prompt, the pet stays waiting until that tool call finishes. Claude Code raises no event for the approval itself. In a terminal, a long-running command's "run in background" hint ends the wait early; the desktop app has no such sign.
  • The Codex app's built-in pets are found on macOS only. Elsewhere, set CODEX_APP_ASAR to the app's app.asar, or install a pet into ~/.codex/pets.
  • The two rows of look-direction frames in newer spritesheets, and running-left / running-right, are not tied to anything: the pet does not move across the screen.
  • In the desktop app the pet animates by redrawing about six times a second while it moves, some 50 KB a frame. calm rests a pet that is idle, waiting or up for review most of the time, so only a running turn animates throughout; still stops animation altogether.
  • A pet named the same as one from an earlier source is listed with its source, as codex@codex-app.

Development

node --test scripts/pet.test.mjs
claude plugin validate .
claude plugin test .
  • scripts/pet.mjs finds pets and converts one into the frames the plugin draws. It has no dependencies.
  • hooks/register.tsx is the plugin: the session events it follows and what it draws.
  • hooks/draw.ts turns converted frames into terminal cells and SVG.
  • pets/blob is the bundled pet: its spritesheet, and its frames already converted (cache/) so it draws without Node. After changing the spritesheet, rebuild them with node scripts/pet.mjs build blob --out pets/blob/cache.

License

MIT, for the code and for Blob. See LICENSE. The animation at the top of this page shows OpenAI's Codex pet and is not covered by it.

Source 3 files
hooks/register.tsx 884 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Mood } from '../types'
5import { FACES, LABEL, MOODS, cellsOf, faceOf, frameCount, imageOf, isMood, petOf, stateOf, svgOf } from './draw'
6import type { Pet, TerminalSize } from './draw'
7
8const FRAME_MS = 160
9// the timer's period while nothing moves: a hidden band, a resting pet
10const REST_MS = 1_000
11// calm: a pet that is idle, waiting or up for review plays its frames
12// through, then holds the first this long, so a quiet session draws little
13const CALM_REST_MS = 8_000
14const CALM_MOODS: ReadonlySet<Mood> = new Set<Mood>(['idle', 'waiting', 'review'])
15const REVIEW_MS = 20_000
16const FLASH_MS = 2_500
17const FAILED_MS = 6_000
18const JUMP_MS = 5 * FRAME_MS // the jump's five frames, once through
19// how long background agents a turn left behind may keep the pet at work
20// with no word of them: one that ends unseen must not pin it there
21const AGENTS_CAP_MS = 15 * 60_000
22const DESKTOP_HEIGHT = { small: 72, medium: 104, large: 156 } // CSS pixels
23// a terminal that shows images: rows the picture takes, and the fewest worth drawing
24const IMAGE_ROWS = { small: 4, medium: 6, large: 9 }
25const IMAGE_MIN_ROWS = 3
26const JUSTIFY = { left: 'flex-start', center: 'center', right: 'flex-end' } as const
27// what may run scripts/pet.mjs, in the order tried: an app started from the
28// dock has a short PATH, so the usual homes of node are named too
29const RUNNERS = [['node'], ['bun'], ['/opt/homebrew/bin/node'], ['/usr/local/bin/node']]
30const SCRIPT_TIMEOUT_MS = 60_000
31const SHELL_TIMEOUT_MS = 20_000
32const STORE_PET = 'pet' // the id /pet use chose, across sessions
33const STORE_WARNED = 'warned' // the last problem toasted, so it is said once
34const STORE_RUNNER = 'runner' // the command that ran the script last time
35
36const mood = atom({ plugin: 'codex-pet', key: 'mood' } as const, 'idle')
37const isHidden = atom({ plugin: 'codex-pet', key: 'isHidden' } as const, false)
38const step = atom({ plugin: 'codex-pet', key: 'step' } as const, 0)
39const loads = atom({ plugin: 'codex-pet', key: 'loads' } as const, 0)
40
41type Settings = {
42  pet: string
43  size: keyof typeof DESKTOP_HEIGHT
44  animation: 'lively' | 'calm' | 'still'
45  hasLabel: boolean
46  /** In a terminal: the pet's picture where it can be shown and a face elsewhere, or one of them always, or blocks of color. */
47  style: 'auto' | 'picture' | 'face' | 'blocks'
48  align: keyof typeof JUSTIFY
49  hasTallCells: boolean
50}
51
52// What scripts/pet.mjs prints: one line of JSON, an `error` when it failed.
53type ScriptAnswer = {
54  error?: string
55  dir?: string
56  id?: string
57  name?: string
58  /** A pet `auto` passed over because it would not convert, and why. */
59  skipped?: string
60  pets?: { id: string; name: string; source: string }[]
61  auto?: string
62}
63
64const SOURCE: Record<string, string> = {
65  installed: 'installed in ~/.codex/pets',
66  'codex-app': 'from the Codex app',
67  bundled: 'bundled',
68}
69
70// The module's own state: lost on a reload, which loads the pet again.
71let settings: Settings = { pet: 'auto', size: 'medium', animation: 'calm', hasLabel: true, style: 'auto', align: 'center', hasTallCells: false }
72let pet: Pet | undefined
73let runner: string[] | undefined
74// the session's pet being loaded: a command typed at once waits on it
75let booting: Promise<void> | undefined
76
77// What the session's events say. The main loop's turn is running; agents its
78// last stop left in flight; and what waits on the person: the calls that are
79// a question to them, the tools a permission dialog is open for, and the
80// inputs an MCP server asked for.
81let isTurnRunning = false
82let agentsInFlight = 0
83let agentsCap: Timer | undefined
84const questions = new Set<string>()
85const permissions: string[] = []
86let elicitations = 0
87// tool calls in flight, by id: which tool each is
88const calls = new Map<string, string>()
89// tools the person just refused at the dialog: their error is no failure
90const refused: string[] = []
91// calls whose run-in-background pill the terminal drew, a sign the call is
92// past its dialog; the timer settles them, since a render hook writes nothing
93const pilled = new Set<string>()
94const started: string[] = []
95
96// The mood the events have asked for; the band draws the `mood` atom, which
97// follows it. Only `show` writes it.
98let target: Mood = 'idle'
99let generation = 0
100let revert: Timer | undefined
101
102// What the band last drew, for the frame timer.
103let bandId: string | undefined
104// undefined until the band is first drawn: until then the desktop's frames
105// are read too, so its first drawing has them
106let isOnDesktop: boolean | undefined
107let terminalSize: TerminalSize = 'lo'
108// whether this terminal shows images (the kitty graphics protocol), as far as
109// its environment says; and what the band drew last, a picture or half blocks
110let hasImages = false
111let isImageDrawn = false
112// the band drew the pet as a face of characters, redrawn a frame at a time
113let isFaceDrawn = false
114// the picture drawn has not been repainted yet: the first repaint says
115// whether the terminal took it or drew its text in its place
116let isImageUnproven = false
117let ticks = 0
118let frame = 0
119let restedMs = 0
120let timer: Timer | undefined
121let isTicking = false
122const cells = new Map<string, string>()
123
124function removeOne(list: string[], item: string): boolean {
125  const at = list.indexOf(item)
126
127  if (at >= 0) {
128    list.splice(at, 1)
129  }
130
131  return at >= 0
132}
133
134// The mood with nothing passing over it: waiting on the person comes first.
135function baseMood(): Mood {
136  if (questions.size > 0 || permissions.length > 0 || elicitations > 0) {
137    return 'waiting'
138  }
139
140  return isTurnRunning || agentsInFlight > 0 ? 'running' : 'idle'
141}
142
143function isBase(one: Mood): boolean {
144  return one === 'idle' || one === 'running' || one === 'waiting'
145}
146
147// The frame timer, one period from now: after a change, and after each tick.
148function wake($: EngineInterface, delay = FRAME_MS): void {
149  timer?.cancel()
150  timer = $.clock.after(delay, () => void tick($))
151}
152
153// Shows `to`. Given `after`, it then moves on to `then`, or to the base mood
154// as it stands by then; a `review` goes back to the base in its own time.
155async function show($: EngineInterface, to: Mood, after?: number, then?: Mood): Promise<void> {
156  generation += 1
157  const mine = generation
158  revert?.cancel()
159  // armed before anything is awaited, so a later `show` cancels it
160  const wait = after ?? (to === 'review' ? REVIEW_MS : undefined)
161  revert = wait === undefined ? undefined : $.clock.after(wait, () => void show($, then ?? baseMood()))
162  target = to
163  ticks = 0
164  frame = 0
165  restedMs = 0
166
167  if (isOnDesktop !== false) {
168    await ensurePaths($, to)
169  }
170
171  // a later `show` came in meanwhile: this one is not what is wanted now
172  if (mine !== generation) {
173    return
174  }
175
176  await update($, mood, () => to)
177  wake($)
178}
179
180// The facts changed: shows the base mood, unless something passing (a jump,
181// a failure, the review) is on, which ends in the base mood by itself. A wait
182// on the person is never held back.
183async function settle($: EngineInterface): Promise<void> {
184  const base = baseMood()
185
186  if (base !== target && (base === 'waiting' || isBase(target))) {
187    await show($, base)
188  }
189}
190
191async function runScript($: EngineInterface, args: readonly string[]): Promise<ScriptAnswer> {
192  const script = `${$.plugin.root}/scripts/pet.mjs`
193
194  if (runner === undefined) {
195    const stored = await $.store.get(STORE_RUNNER).catch(() => undefined)
196    runner = Array.isArray(stored) && stored.every(part => typeof part === 'string') ? stored : undefined
197  }
198
199  // last, the person's own shell as they log in: a node a version manager
200  // put on the PATH is found there and nowhere else (fish takes no "$@")
201  const shell = await $.env.get('SHELL').catch(() => undefined)
202  const viaShell =
203    shell === undefined || shell === '' || shell.endsWith('fish') ? [] : [[shell, '-lic', 'exec node "$0" "$@"']]
204  const known = runner === undefined ? [] : [runner]
205
206  for (const command of [...known, ...RUNNERS, ...viaShell]) {
207    try {
208      const ran = await $.process.run([...command, script, ...args], {
209        timeoutMs: command.length > 1 ? SHELL_TIMEOUT_MS : SCRIPT_TIMEOUT_MS,
210      })
211      const answer: ScriptAnswer = JSON.parse(ran.stdout.trim().split('\n').pop() ?? '')
212
213      if (runner?.join(' ') !== command.join(' ')) {
214        runner = command
215        await $.store.set(STORE_RUNNER, command).catch(() => undefined)
216      }
217
218      return answer
219    } catch {
220      // not installed here, or it printed no answer: try the next
221    }
222  }
223
224  runner = undefined
225
226  return { error: 'Node.js was not found, and reading a Codex pet needs it' }
227}
228
229async function readPet($: EngineInterface, dir: string): Promise<Pet | undefined> {
230  try {
231    return petOf(await $.fs.read(`${dir}/meta.json`), dir)
232  } catch {
233    return undefined
234  }
235}
236
237// Reads the mood's desktop frames once; a state that cannot be read is left
238// empty, so the band draws the label alone rather than asking again.
239async function ensurePaths($: EngineInterface, of: Mood): Promise<void> {
240  const current = pet
241
242  if (current === undefined) {
243    return
244  }
245
246  const state = stateOf(current, of)
247
248  if (current.paths.has(state)) {
249    return
250  }
251
252  let frames: string[] = []
253
254  try {
255    const parsed: unknown = JSON.parse(await $.fs.read(`${current.dir}/svg-${state}.json`))
256    frames = Array.isArray(parsed) ? parsed.map(String) : []
257  } catch {
258    // gone from the cache: the label alone is drawn until /pet refresh
259  }
260
261  current.paths.set(state, frames)
262}
263
264// Loads the pet `wanted` names, converting it first when it is not cached.
265// Answers what went wrong, if anything; with `orBundled`, a pet that cannot
266// be loaded is replaced by the bundled one, and otherwise the current stays.
267async function loadPet(
268  $: EngineInterface,
269  wanted: string,
270  { orBundled, isForced = false }: { orBundled: boolean; isForced?: boolean },
271): Promise<string | undefined> {
272  const built = await runScript($, ['build', wanted, ...(isForced ? ['--force'] : [])])
273  const converted = built.dir === undefined ? undefined : await readPet($, built.dir)
274  const problem =
275    converted === undefined
276      ? (built.error ?? 'the converted pet could not be read')
277      : built.skipped === undefined
278        ? undefined
279        : `skipped ${built.skipped}`
280  const loaded = converted ?? (orBundled ? await readPet($, `${$.plugin.root}/pets/blob/cache`) : undefined)
281
282  if (loaded !== undefined) {
283    pet = loaded
284    cells.clear()
285    ticks = 0
286    frame = 0
287
288    if (isOnDesktop !== false) {
289      await ensurePaths($, target)
290    }
291
292    await update($, loads, n => n + 1)
293    wake($)
294  }
295
296  return problem
297}
298
299// Whether the terminal the session runs in shows images: kitty and Ghostty
300// do, by their own word in the environment. Not through tmux, which passes
301// none on, nor over ssh, where the terminal cannot read this machine's files.
302async function detectImages($: EngineInterface): Promise<boolean> {
303  if (settings.style !== 'auto') {
304    return settings.style === 'picture'
305  }
306
307  const [term, program, kitty, ghostty, tmux, ssh] = await Promise.all([
308    $.env.get('TERM').catch(() => undefined),
309    $.env.get('TERM_PROGRAM').catch(() => undefined),
310    $.env.get('KITTY_WINDOW_ID').catch(() => undefined),
311    $.env.get('GHOSTTY_RESOURCES_DIR').catch(() => undefined),
312    $.env.get('TMUX').catch(() => undefined),
313    $.env.get('SSH_CONNECTION').catch(() => undefined),
314  ])
315  const isCapable = term === 'xterm-kitty' || term === 'xterm-ghostty' || program === 'ghostty' || !!kitty || !!ghostty
316
317  return isCapable && !tmux && !ssh
318}
319
320async function chosenPet($: EngineInterface): Promise<string> {
321  const stored = await $.store.get(STORE_PET).catch(() => undefined)
322
323  return typeof stored === 'string' && stored !== '' ? stored : settings.pet
324}
325
326// Says a problem once, not at every session's start.
327async function report($: EngineInterface, problem: string | undefined): Promise<void> {
328  try {
329    const last = await $.store.get(STORE_WARNED)
330
331    if (problem === undefined) {
332      if (last !== undefined) {
333        await $.store.delete(STORE_WARNED)
334      }
335
336      return
337    }
338
339    if (last === problem) {
340      return
341    }
342
343    await $.store.set(STORE_WARNED, problem)
344  } catch {
345    // no store: say it anyway
346  }
347
348  if (problem !== undefined) {
349    $.ui.toast(`Codex Pet: ${problem}. Showing ${pet?.name ?? 'no pet'}; /pet list names the pets found.`, {
350      timeoutMs: 10_000,
351    })
352  }
353}
354
355// The session's pet, loaded behind the session's start rather than in it:
356// a first conversion, or a slow shell, holds nothing up.
357async function boot($: EngineInterface): Promise<void> {
358  try {
359    hasImages = await detectImages($)
360    await report($, await loadPet($, await chosenPet($), { orBundled: true }))
361  } catch {
362    // no pet this session: the band draws nothing
363  }
364}
365
366async function paint($: EngineInterface, current: Pet): Promise<void> {
367  if (isOnDesktop === true) {
368    await update($, step, n => (n + 1) % 1_000_000)
369  } else if (bandId !== undefined && isImageDrawn) {
370    const answer = await $.ui.blit({ requestId: bandId, key: 'pet', source: imageOf(current, target, frame) })
371    isImageUnproven = false
372
373    // the terminal drew the picture's text in its place: no pictures from here on
374    if (answer.deny !== undefined) {
375      hasImages = false
376      await update($, loads, n => n + 1)
377    }
378  } else if (bandId !== undefined && isFaceDrawn) {
379    await update($, step, n => (n + 1) % 1_000_000)
380  } else if (bandId !== undefined) {
381    const key = `${terminalSize}:${target}:${frame}`
382    const packed = cells.get(key) ?? cellsOf(current, terminalSize, target, frame)
383    cells.set(key, packed)
384    await $.ui.blit({ requestId: bandId, key: 'pet', cells: packed })
385  }
386}
387
388// One period of the frame timer. Answers how long until the next.
389async function advance($: EngineInterface): Promise<number> {
390  // a call whose pill appeared is running: its permission dialog is behind it
391  while (started.length > 0) {
392    const tool = calls.get(started.shift() ?? '')
393
394    if (tool !== undefined && removeOne(permissions, tool)) {
395      await settle($)
396    }
397  }
398
399  const current = pet
400
401  if (current === undefined || (isOnDesktop !== true && bandId === undefined)) {
402    return REST_MS
403  }
404
405  // the desktop drew before this mood's frames were read: read, then redraw
406  if (isOnDesktop === true && !current.paths.has(stateOf(current, target))) {
407    await ensurePaths($, target)
408    await paint($, current)
409
410    return FRAME_MS
411  }
412
413  // a picture just drawn is repainted once, moving or not, to hear whether
414  // the terminal took it
415  if (isImageDrawn && isImageUnproven) {
416    await paint($, current)
417  }
418
419  const count = isFaceDrawn ? FACES[target].length : frameCount(current, target)
420
421  if (settings.animation === 'still' || count <= 1) {
422    return REST_MS
423  }
424
425  // calm: once through, then the first frame held a while
426  if (settings.animation === 'calm' && CALM_MOODS.has(target) && ticks >= count) {
427    restedMs += REST_MS
428
429    if (restedMs >= CALM_REST_MS) {
430      ticks = 0
431      restedMs = 0
432    }
433
434    return REST_MS
435  }
436
437  ticks += 1
438  const due = ticks % count
439
440  if (due !== frame) {
441    frame = due
442    await paint($, current)
443  }
444
445  return FRAME_MS
446}
447
448async function tick($: EngineInterface): Promise<void> {
449  // this period's timer has fired: whoever finishes arms the next
450  timer = undefined
451
452  if (isTicking) {
453    return
454  }
455
456  isTicking = true
457  let delay = REST_MS
458
459  try {
460    delay = await advance($)
461  } catch {
462    // a band gone between the tick and the paint is no fault
463  } finally {
464    isTicking = false
465  }
466
467  // unless a change woke the timer meanwhile
468  if (timer === undefined) {
469    wake($, delay)
470  }
471}
472
473async function listPets($: EngineInterface): Promise<string> {
474  const answer = await runScript($, ['list'])
475
476  if (answer.pets === undefined) {
477    return `Could not list pets: ${answer.error ?? 'the script printed nothing'}.`
478  }
479
480  const rows = answer.pets.map(one => {
481    const mark = one.id === pet?.id ? '>' : ' '
482
483    return `${mark} ${one.id.padEnd(16)} ${one.name.padEnd(24)} ${SOURCE[one.source] ?? one.source}`
484  })
485
486  // fenced: a command's text is drawn as markdown, which would reflow the columns
487  return [
488    'Pets found (`>` is showing). `/pet use <id>` switches; `/pet use auto` follows the setting.',
489    '```',
490    ...rows,
491    '```',
492    `auto would pick: ${answer.auto ?? 'none'}`,
493  ].join('\n')
494}
495
496function showing(problem: string | undefined, isKept = false): string {
497  const name = pet?.name ?? 'no pet'
498
499  return problem === undefined ? `Showing ${name}.` : `${problem}. ${isKept ? 'Still showing' : 'Showing'} ${name}.`
500}
501
502async function usePet($: EngineInterface, wanted: string): Promise<string> {
503  if (wanted.startsWith('-')) {
504    return usage()
505  }
506
507  if (wanted === 'auto') {
508    await $.store.delete(STORE_PET).catch(() => undefined)
509
510    return showing(await loadPet($, settings.pet, { orBundled: true }))
511  }
512
513  const problem = await loadPet($, wanted, { orBundled: false })
514
515  if (problem !== undefined) {
516    return showing(problem, true)
517  }
518
519  // the id as the script knows it, whatever case it was typed in
520  await $.store.set(STORE_PET, pet?.id ?? wanted).catch(() => undefined)
521
522  return showing(undefined)
523}
524
525// Downloads a pet from petdex.dev by its name there, or its page's address,
526// then shows it. The script does the fetching and checks what arrives.
527async function installPet($: EngineInterface, wanted: string): Promise<string> {
528  const slug = wanted
529    .replace(/^https?:\/\/(www\.)?petdex\.dev\/([a-z-]+\/)?pets\//, '')
530    .replace(/[/?#].*$/, '')
531    .toLowerCase()
532  const installed = await runScript($, ['install', slug])
533
534  if (installed.id === undefined) {
535    return showing(installed.error ?? 'the pet could not be installed', true)
536  }
537
538  const shown = await usePet($, installed.id)
539
540  return `Installed ${installed.name ?? installed.id} from petdex.dev into ~/.codex/pets. ${shown}`
541}
542
543function usage(): string {
544  const now = pet === undefined ? 'No pet is loaded.' : `Showing ${pet.name} (${SOURCE[pet.source] ?? pet.source}): ${LABEL[target]}`
545
546  return [
547    now,
548    '',
549    'Usage: `/pet list` | `use <id|auto>` | `install <name on petdex.dev>` | `refresh` | `hide` | `show` | `<mood>`',
550    '',
551    `Moods to preview: ${MOODS.join(', ')}`,
552  ].join('\n')
553}
554
555export const register: Register = (on, options) => {
556  settings = {
557    pet: typeof options.pet === 'string' && options.pet !== '' ? options.pet : 'auto',
558    size: options.size === 'small' || options.size === 'large' ? options.size : 'medium',
559    animation: options.animation === 'lively' || options.animation === 'still' ? options.animation : 'calm',
560    hasLabel: options.label !== false,
561    style:
562      options.terminalStyle === 'picture' || options.terminalStyle === 'face' || options.terminalStyle === 'blocks'
563        ? options.terminalStyle
564        : 'auto',
565    align: options.align === 'left' || options.align === 'right' ? options.align : 'center',
566    hasTallCells: options.terminalCells === 'tall',
567  }
568
569  on('session.start', async ($, e, next) => {
570    await $.command.register({
571      name: 'pet',
572      description: 'Your Codex pet: /pet list | use <id|auto> | install <name> | refresh | hide | show | <mood>',
573    })
574
575    booting = boot($)
576    void show($, 'waving', FLASH_MS)
577
578    return next(e)
579  })
580
581  on('turn.start', async ($, e, next) => {
582    isTurnRunning = true
583    // nothing of the last turn is still asked of the person
584    questions.clear()
585    permissions.length = 0
586    refused.length = 0
587    elicitations = 0
588    await show($, 'running')
589
590    return next(e)
591  })
592
593  on('turn.complete', async ($, e, next) => {
594    // a subagent's run ends in a turn.complete too: one fewer in flight
595    if (e.agentId !== undefined) {
596      if (agentsInFlight > 0) {
597        agentsInFlight -= 1
598        await settle($)
599      }
600
601      return next(e)
602    }
603
604    isTurnRunning = false
605    questions.clear()
606    permissions.length = 0
607    elicitations = 0
608
609    if (e.reason === 'aborted') {
610      await show($, baseMood())
611    } else if (e.reason !== 'answer') {
612      await show($, 'failed', FAILED_MS)
613    } else if (agentsInFlight > 0) {
614      await show($, 'running')
615    } else {
616      await show($, 'jumping', JUMP_MS, 'review')
617    }
618
619    return next(e)
620  })
621
622  // The main loop's stop says what is still in flight behind it; it may come
623  // before or after turn.complete, so both settle on the same count. Agents
624  // count, shells do not: a dev server left running is not the pet at work.
625  on('classic.Stop', async ($, e, next) => {
626    agentsInFlight = (e.background_tasks ?? []).filter(
627      task => task.agent_type !== undefined || /agent/i.test(task.type),
628    ).length
629    agentsCap?.cancel()
630    agentsCap = undefined
631
632    if (agentsInFlight > 0) {
633      agentsCap = $.clock.after(AGENTS_CAP_MS, () => {
634        agentsInFlight = 0
635        void settle($)
636      })
637
638      if (!isTurnRunning && target !== 'failed') {
639        await show($, 'running')
640      }
641    }
642
643    return next(e)
644  }).catch(($, e, next) => next(e))
645
646  on('classic.PermissionRequest', async ($, e, next) => {
647    permissions.push(e.tool_name)
648    await settle($)
649
650    return next(e)
651  }).catch(($, e, next) => next(e))
652
653  on('classic.PermissionDenied', async ($, e, next) => {
654    removeOne(permissions, e.tool_name)
655    refused.push(e.tool_name)
656    await settle($)
657
658    return next(e)
659  }).catch(($, e, next) => next(e))
660
661  on('classic.Elicitation', async ($, e, next) => {
662    elicitations += 1
663    await settle($)
664
665    return next(e)
666  }).catch(($, e, next) => next(e))
667
668  on('classic.ElicitationResult', async ($, e, next) => {
669    elicitations = Math.max(0, elicitations - 1)
670    await settle($)
671
672    return next(e)
673  }).catch(($, e, next) => next(e))
674
675  on('tool.call', async ($, e, next) => {
676    const tool = String(e.tool)
677    const id = e.tool_use_id
678    // these two tools are the model waiting on the person
679    const isQuestion = tool === 'AskUserQuestion' || tool === 'ExitPlanMode'
680    calls.set(id, tool)
681
682    if (isQuestion) {
683      questions.add(id)
684      await settle($)
685    }
686
687    const ran = await next(e)
688    // this call is over, and with it whatever of it waited on the person
689    calls.delete(id)
690    pilled.delete(id)
691    questions.delete(id)
692    removeOne(permissions, tool)
693    const wasRefused = removeOne(refused, tool)
694    const hasFailed = ran.deny === undefined && ran.isError === true
695    // not a failure worth showing: the person said no, or the turn it ran
696    // in was interrupted, or the pet is waiting on the person for another
697    const isWorthShowing = !wasRefused && (isTurnRunning || e.agentId !== undefined) && baseMood() !== 'waiting'
698
699    if (hasFailed && isWorthShowing) {
700      await show($, 'failed', FLASH_MS)
701    } else {
702      await settle($)
703    }
704
705    return ran
706  }).catch(($, e, next) => next(e))
707
708  on('command.run', { command: 'pet' }, async ($, e) => {
709    await booting
710    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
711    const arg = rest.join(' ')
712
713    if (verb === 'list') {
714      return { text: await listPets($) }
715    }
716
717    if (verb === 'use' && arg !== '') {
718      return { text: await usePet($, arg) }
719    }
720
721    if (verb === 'install' && arg !== '') {
722      return { text: await installPet($, arg) }
723    }
724
725    if (verb === 'refresh') {
726      return { text: showing(await loadPet($, await chosenPet($), { orBundled: true, isForced: true })) }
727    }
728
729    if (verb === 'hide' || verb === 'show') {
730      await update($, isHidden, () => verb === 'hide')
731      wake($)
732
733      return { text: `${pet?.name ?? 'The pet'} is ${verb === 'hide' ? 'hidden; /pet show brings it back' : 'back'}.` }
734    }
735
736    if (isMood(verb)) {
737      await update($, isHidden, () => false)
738      await show($, verb, 6_000)
739
740      return { text: `${pet?.name ?? 'The pet'}: ${verb}` }
741    }
742
743    return { text: usage() }
744  })
745
746  // Approving a permission dialog raises no event of its own. On the terminal
747  // a long call then draws its pill, which says that call is running: the one
748  // sign there is, and only there (the desktop raises no ToolProgress).
749  on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
750    const id = e.props.tool_use_id
751
752    if (calls.has(id) && !pilled.has(id)) {
753      pilled.add(id)
754      started.push(id)
755    }
756
757    return next(e)
758  })
759
760  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
761    // each read subscribes the band: a write to any of them draws it again
762    const [now, hidden] = await Promise.all([read($, mood), read($, isHidden), read($, step), read($, loads)])
763    const current = pet
764
765    if (hidden || e.props.hasSurvey || current === undefined) {
766      bandId = undefined
767      isOnDesktop = undefined
768      isFaceDrawn = false
769
770      return next(e)
771    }
772
773    isOnDesktop = e.surface === 'desktop'
774    const label = `${current.name}: ${LABEL[now]}`
775    // the band is as wide as the prompt: where in it the pet stands
776    const justify = JUSTIFY[settings.align]
777
778    if (e.surface === 'terminal') {
779      const { Box, Image, Raster, Text } = $.ui.resolve(e)
780      const beside = settings.hasLabel && (
781        <Box flexDirection="column" justifyContent="flex-end" marginLeft={1}>
782          <Text bold>{current.name}</Text>
783          <Text dimColor>{LABEL[now]}</Text>
784        </Box>
785      )
786      // a terminal that shows images gets the pet's own pixels, in fewer rows
787      const imageRows = Math.min(IMAGE_ROWS[settings.size], e.props.maxRows)
788
789      if (hasImages && imageRows >= IMAGE_MIN_ROWS) {
790        // a cell is about twice as tall as it is wide
791        const columns = Math.max(1, Math.round((imageRows * (settings.hasTallCells ? 2.4 : 2) * current.png.width) / current.png.height))
792        isImageUnproven ||= !isImageDrawn || bandId !== e.requestId
793        bandId = e.requestId
794        isImageDrawn = true
795        isFaceDrawn = false
796
797        return (
798          <Box width="100%" justifyContent={justify}>
799            <Image key="pet" source={imageOf(current, now, frame)} columns={columns} rows={imageRows} alt={label} />
800            {beside}
801          </Box>
802        )
803      }
804
805      isImageDrawn = false
806      isFaceDrawn = settings.style !== 'blocks'
807
808      // no picture here: a face of characters, one row and sharp, rather
809      // than the pet in blocks of color too coarse to do it justice
810      if (isFaceDrawn) {
811        bandId = e.requestId
812
813        return (
814          <Box width="100%" justifyContent={justify}>
815            <Text color={current.tint} bold>
816              {faceOf(now, frame)}
817            </Text>
818            {settings.hasLabel && <Text bold> {current.name}</Text>}
819            {settings.hasLabel && <Text dimColor> {LABEL[now]}</Text>}
820          </Box>
821        )
822      }
823
824      // the small size, or a terminal too short for the full one, draws the
825      // half-size pet; one too short for that, the label alone
826      const full: TerminalSize = settings.hasTallCells ? 'loTall' : 'lo'
827      const half: TerminalSize = settings.hasTallCells ? 'tinyTall' : 'tiny'
828      const fits = (one: TerminalSize) => e.props.maxRows >= current.terminal[one].rows
829      const size = settings.size !== 'small' && fits(full) ? full : fits(half) ? half : undefined
830
831      if (size === undefined) {
832        bandId = undefined
833
834        return settings.hasLabel ? <Text dimColor>{label}</Text> : next(e)
835      }
836
837      bandId = e.requestId
838      terminalSize = size
839
840      return (
841        <Box width="100%" justifyContent={justify}>
842          <Raster
843            key="pet"
844            columns={current.terminal[size].columns}
845            rows={current.terminal[size].rows}
846            cells={cellsOf(current, size, now, frame)}
847          />
848          {beside}
849        </Box>
850      )
851    }
852
853    if (e.surface === 'desktop') {
854      const { Box, Svg, Text } = $.ui.resolve(e)
855      const source = svgOf(current, now, frame)
856      const height = DESKTOP_HEIGHT[settings.size]
857
858      // this mood's frames are not read yet: the timer reads them and redraws
859      if (source === undefined) {
860        return settings.hasLabel ? <Text dimColor>{label}</Text> : next(e)
861      }
862
863      return (
864        <Box width="100%" justifyContent={justify}>
865          <Svg
866            source={source}
867            alt={label}
868            width={Math.round((height * current.svg.width) / current.svg.height)}
869            height={height}
870          />
871          {settings.hasLabel && (
872            <Box flexDirection="column" justifyContent="flex-end" marginLeft={1}>
873              <Text bold>{current.name}</Text>
874              <Text dimColor>{LABEL[now]}</Text>
875            </Box>
876          )}
877        </Box>
878      )
879    }
880
881    return next(e)
882  })
883}
884
hooks/draw.ts 261 lines
1import type { Mood } from '../types'
2
3// A converted pet, as scripts/pet.mjs writes it and the hooks module reads it.
4export type PetMeta = {
5  version: number
6  id: string
7  name: string
8  source: string
9  // terminal: the pet at each size and cell shape, on one palette. A frame is
10  // 2 * columns by 2 * rows pixels, a character a pixel: '.' transparent,
11  // else the palette index as the character 48 + index
12  terminal: { palette: string[] } & Record<TerminalSize, Frames>
13  // a terminal that shows images: each frame a PNG file beside the manifest
14  png: { width: number; height: number }
15  // desktop: how many frames each state has; their markup is a file a state
16  svg: { width: number; height: number; colors: number; frames: Partial<Record<Mood, number>> }
17}
18
19type Frames = { columns: number; rows: number; states: Partial<Record<Mood, string[]>> }
20
21export type TerminalSize = 'lo' | 'tiny' | 'loTall' | 'tinyTall'
22
23export type Pet = PetMeta & {
24  dir: string
25  rgb: number[]
26  /** The pet's main color, for the face a plain terminal draws. */
27  tint: string
28  /** A state's frames as SVG path markup, read when the desktop first needs them. */
29  paths: Map<Mood, string[]>
30}
31
32export const META_VERSION = 7
33
34export const MOODS: readonly Mood[] = [
35  'idle',
36  'running-right',
37  'running-left',
38  'waving',
39  'jumping',
40  'failed',
41  'waiting',
42  'running',
43  'review',
44]
45
46export const LABEL: Record<Mood, string> = {
47  idle: 'idle',
48  'running-right': 'on the move',
49  'running-left': 'on the move',
50  waving: 'hi!',
51  jumping: 'done!',
52  failed: 'that failed',
53  waiting: 'needs you',
54  running: 'working…',
55  review: 'ready for review',
56}
57
58const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
59
60// The pet as a face of plain characters, a few frames a mood: what a terminal
61// that shows no images draws, sharp at any size where blocks of color are not.
62// A mood's frames are all one width, so nothing beside the face shifts.
63export const FACES: Record<Mood, readonly string[]> = {
64  idle: ['(•‿•)', '(•‿•)', '(•‿•)', '(-‿-)'],
65  'running-right': ['(•_•)>', '(•_•)»'],
66  'running-left': ['<(•_•)', '«(•_•)'],
67  waving: ['(^‿^)/', '(^‿^)-'],
68  jumping: ['\\(^o^)/', ' (^o^) '],
69  failed: ['(x_x)', '(>_<)'],
70  waiting: ['(•_•)?', '(•_•) '],
71  running: SPINNER.map(mark => `${mark} (•_•)`),
72  review: ['(^‿^)*', '(^‿^) '],
73}
74
75export function faceOf(mood: Mood, at: number): string {
76  const frames = FACES[mood]
77
78  return frames[at % frames.length] ?? ''
79}
80
81const TRANSPARENT = 46 // '.'
82const FIRST = 48 // '0': palette index n is the char FIRST + n
83const DEFAULT_COLOR = 0x01000000
84
85export function isMood(text: string): text is Mood {
86  return MOODS.some(one => one === text)
87}
88
89// What the converter wrote, or undefined when it is not that.
90export function petOf(text: string, dir: string): Pet | undefined {
91  let meta: Partial<PetMeta>
92
93  try {
94    meta = JSON.parse(text)
95  } catch {
96    return undefined
97  }
98
99  const isWhole =
100    meta.version === META_VERSION &&
101    typeof meta.id === 'string' &&
102    typeof meta.name === 'string' &&
103    Array.isArray(meta.terminal?.palette) &&
104    (['lo', 'tiny', 'loTall', 'tinyTall'] as const).every(size => (meta.terminal?.[size]?.states?.idle?.length ?? 0) > 0) &&
105    (meta.png?.width ?? 0) > 0 &&
106    (meta.png?.height ?? 0) > 0 &&
107    (meta.svg?.frames?.idle ?? 0) > 0
108
109  if (!isWhole) {
110    return undefined
111  }
112
113  const whole = meta as PetMeta
114  // the color most of its first idle frame is
115  const counts = new Map<number, number>()
116
117  for (const char of whole.terminal.lo.states.idle?.[0] ?? '') {
118    const code = char.charCodeAt(0)
119
120    if (code !== TRANSPARENT) {
121      counts.set(code, (counts.get(code) ?? 0) + 1)
122    }
123  }
124
125  const [most] = [...counts].sort((a, b) => b[1] - a[1])
126
127  return {
128    ...whole,
129    dir,
130    tint: whole.terminal.palette[(most?.[0] ?? FIRST) - FIRST] ?? '#888888',
131    rgb: whole.terminal.palette.map(hex => parseInt(hex.slice(1), 16)),
132    paths: new Map(),
133  }
134}
135
136// A state with no frames of its own is drawn as idle.
137export function stateOf(pet: Pet, mood: Mood): Mood {
138  return (pet.terminal.lo.states[mood]?.length ?? 0) > 0 ? mood : 'idle'
139}
140
141export function frameCount(pet: Pet, mood: Mood): number {
142  return pet.terminal.lo.states[stateOf(pet, mood)]?.length ?? 1
143}
144
145function colorAt(pet: Pet, frame: string, index: number): number {
146  const code = index < frame.length ? frame.charCodeAt(index) : TRANSPARENT
147
148  return code === TRANSPARENT ? -1 : (pet.rgb[code - FIRST] ?? -1)
149}
150
151// The block that fills the quadrants a mask names (1 top left, 2 top right,
152// 4 bottom left, 8 bottom right), by mask.
153const QUADRANTS = [
154  0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f,
155  0x2588,
156]
157// every way to split a cell's four pixels in two groups, as the mask of one
158const SPLITS = [0b0011, 0b0101, 0b0110, 0b0001, 0b0010, 0b0100, 0b1000]
159
160function mean(colors: number[], mask: number): number {
161  let r = 0
162  let g = 0
163  let b = 0
164  let n = 0
165
166  colors.forEach((color, at) => {
167    if (mask & (1 << at)) {
168      r += color >> 16
169      g += (color >> 8) & 0xff
170      b += color & 0xff
171      n += 1
172    }
173  })
174
175  return n === 0 ? 0 : (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
176}
177
178function spread(colors: number[], mask: number, to: number): number {
179  let sum = 0
180
181  colors.forEach((color, at) => {
182    if (mask & (1 << at)) {
183      sum += ((color >> 16) - (to >> 16)) ** 2 + (((color >> 8) & 0xff) - ((to >> 8) & 0xff)) ** 2 + ((color & 0xff) - (to & 0xff)) ** 2
184    }
185  })
186
187  return sum
188}
189
190// One cell's four pixels (top left, top right, bottom left, bottom right; -1
191// transparent) as the block and two colors that come closest to them.
192function cellOf(pixels: number[]): [number, number, number] {
193  const opaque = pixels.reduce((mask, color, at) => (color < 0 ? mask : mask | (1 << at)), 0)
194
195  // part of the cell shows the terminal through: the rest is one color
196  if (opaque !== 0b1111) {
197    return [QUADRANTS[opaque] ?? 0x20, opaque === 0 ? DEFAULT_COLOR : mean(pixels, opaque), DEFAULT_COLOR]
198  }
199
200  let best: [number, number, number] = [0x2588, mean(pixels, 0b1111), DEFAULT_COLOR]
201  let least = spread(pixels, 0b1111, best[1])
202
203  for (const mask of SPLITS) {
204    const fore = mean(pixels, mask)
205    const back = mean(pixels, ~mask & 0b1111)
206    const error = spread(pixels, mask, fore) + spread(pixels, ~mask & 0b1111, back)
207
208    if (error < least) {
209      least = error
210      best = [QUADRANTS[mask] ?? 0x2588, fore, back]
211    }
212  }
213
214  return best
215}
216
217// A terminal frame as a Raster's cells, four pixels a cell: the quadrant
218// block and the two colors that draw them best.
219export function cellsOf(pet: Pet, size: TerminalSize, mood: Mood, at: number): string {
220  const { columns, rows, states } = pet.terminal[size]
221  const frames = states[stateOf(pet, mood)] ?? []
222  const frame = frames[at % Math.max(1, frames.length)] ?? ''
223  const width = columns * 2
224  const words = new Uint32Array(columns * rows * 3)
225
226  for (let row = 0; row < rows; row += 1) {
227    for (let column = 0; column < columns; column += 1) {
228      const top = row * 2 * width + column * 2
229      const pixels = [top, top + 1, top + width, top + width + 1].map(index => colorAt(pet, frame, index))
230      words.set(cellOf(pixels), (row * columns + column) * 3)
231    }
232  }
233
234  return new Uint8Array(words.buffer).toBase64()
235}
236
237// One frame as the PNG file the converter left in the cache, for a terminal
238// that shows images: it reads the file itself, so no pixel crosses the plugin.
239export function imageOf(pet: Pet, mood: Mood, at: number): { file: string; format: 'png' } {
240  const state = stateOf(pet, mood)
241
242  return { file: `${pet.dir}/png-${state}-${at % frameCount(pet, mood)}.png`, format: 'png' }
243}
244
245// One desktop frame as an SVG of its own, or undefined until the state's
246// markup is read. An image, so the band shows through it; and one frame an
247// element, since a whole animation at the atlas's size fits none.
248export function svgOf(pet: Pet, mood: Mood, at: number): string | undefined {
249  const frames = pet.paths.get(stateOf(pet, mood))
250  const paths = frames?.[at % Math.max(1, frames.length)]
251
252  if (paths === undefined) {
253    return undefined
254  }
255
256  const { width, height } = pet.svg
257
258  // paths are written on whole rows; the half pixel centers each stroke on its row
259  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges" fill="none" stroke-width="1"><g transform="translate(0 .5)">${paths}</g></svg>`
260}
261
types/index.d.ts 24 lines
1export type Mood =
2  | 'idle'
3  | 'running-right'
4  | 'running-left'
5  | 'waving'
6  | 'jumping'
7  | 'failed'
8  | 'waiting'
9  | 'running'
10  | 'review'
11
12declare module 'claude-code' {
13  interface PluginState {
14    'codex-pet': {
15      mood: Mood
16      isHidden: boolean
17      /** Counts the desktop's frame changes: each write redraws the band. */
18      step: number
19      /** Counts the pets loaded, 0 before the first: a write redraws the band. */
20      loads: number
21    }
22  }
23}
24