SLOPSHOPPER

pixel-pets

Pixel-art pets above the prompt that react to what Claude and each subagent are doing

newbandguardcommandtoastprompt
★ 2v0.5.0MITupdated 2026-10-09HenriqueSchroeder/pixel-pets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-pets
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pet ⎿ pixel-pets: Pets hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pixel-pets

English · Português

A pixel-art pet that lives above your Claude Code prompt and reacts to what Claude is doing: it thinks, types while Claude runs commands, reads while Claude reads files, cheers when a turn ends and gets sad when a tool fails. Every subagent Claude starts gets a small pet of its own, in its own color, showing what that agent is doing right now.

Pets are plain JSON files, so you can draw your own or use one someone else made.

pixel-pets: clawd above the prompt while three subagents work

pixel-pets: the dragon in the same scene, flying off to land elsewhere and breathing fire when a long job is done

pixel-pets: the ghost in the same scene, fading out and turning up elsewhere as it strolls

pixel-pets: the fox in the same scene

pixel-pets: the capybara in the same scene, chewing grass as a bird lands on its head

What it shows

MoodWhenBorrows from
sleepingno turn is running(required)
deepSleepnothing has happened for 10 minutessleeping
idleawake with nothing to do, for a while after Claude finishes (see awakeMinutes)thinking
sleepyidle at night, from 22h to 6hidle
tiredidle after 3 hours of work with no break of an hoursleepy
walkingstrolling around while idle and alone; a pack without it never walksidle
watchingyou are typing in the prompt while Claude is idlereading
wakingfor a moment when a prompt wakes it from a deep sleepthinking
thinkinga turn startedsleeping
typingany other tool, MCP tools includedthinking
runningBashtyping
writingEdit, Writetyping
readingReadthinking
searchingGrep, Glob, WebFetch, WebSearch, LSPreading
supervisingClaude is waiting for its agents, or only thinking while they run, or they still run in the background after the turnthinking
compactingthe conversation is being compactedthinking
sweatinga turn has run for over 2 minutes and Claude is thinking or running a commandthinking
worriedidle after a few failures in a rowsweating
grumpyidle after many failures in a rowsad
proudidle after a streak of turns that went wellhappy
happyfor two seconds after the last agent is done, now and then after a turn of work, or after you allow a permission prompt ("thanks!")sleeping
celebratingfor four seconds after a turn of 5 minutes or more goes well, and on an anniversaryhappy
sadfor two seconds after a tool fails, a turn errors, or you deny a permission prompt ("okay, I won't")sleeping

A pack only has to draw sleeping. Any mood it leaves out borrows the frames of its nearest drawn parent, so running falls back to typing, then thinking, then sleeping.

The pet walks a stage under Claude's line: it strolls while idle, and when subagents start it stays put and they gather around it, each on the side with more free room: on both sides when it stands in the middle, on one when it is in a corner. Each keeps its side until it leaves. When they do not fit where it stands, it walks aside to make room and they join as it gets there. Only a pack that draws walking walks. One that draws main.teleport instead gets about by vanishing and turning up elsewhere, as the slime and the ghost do; a pack with neither stays put at the left. Packs draw their pet facing right; pixel-pets mirrors it to face left.

It keeps track of how the session goes. A failed tool or turn worries it and a turn that goes well makes it proud; a failed turn ends a streak of pride. Both fade a point every five minutes, and when it is idle the strongest feeling shows: grumpy, then worried, then tired, then proud, then sleepy at night. Worry and pride last for the session only; tiredness counts your work in every session, as a break is an hour with no prompt in any of them.

It has wants of its own too, for the session. Energy: sleep fills it and work drains it, and a pet low on energy dozes off sooner and rests longer between strolls. Boredom: it builds while nothing happens, and a bored pet gets up from a nap on its own for a while, though never out of a deep sleep. Longing: it builds while you are away, and a pet that misses you strolls near the prompt and greets your first key after half an hour away ("missed you!"), unless a turn is running.

The time of day is your local time. At night it is sleepy and dozes off in half of awakeMinutes. It remembers you across sessions. The day's first sight of you opens with one welcome: an anniversary (a week, a month, 100 days, each year together), "missed you!" after two days or more away, or good morning. /pet also says how long you have been together. Late at night it says so once, whichever session sees you first. A claude -p run does not count as seeing you.

Now and then it says something of its own, in quotes on Claude's line: "hmm…" after 30 seconds of thinking with no tool, a remark on the 20th file read in a turn, on three agents at work at once, on a prompt at night, a sigh when boredom gets it up from a nap, and a word in its sleep now and then. Each at most once a turn, and never two within three minutes.

Each subagent gets a mini pet with its type (Explore, Plan, ...) and its current action. When the agent ends, its pet is happy (or sad, if it failed) for a moment and then leaves.

Requirements

  • Claude Code 2.1.287 or later, where mods load by default. Built and tested on 2.1.291.
  • A terminal for the pixel art. The Desktop app's Code tab shows a line of text instead, and the VS Code chat panel and claude -p draw nothing.

Install

In a Claude Code session:

/plugin marketplace add HenriqueSchroeder/pixel-pets
/plugin install pixel-pets@pixel-pets

Or from your shell, then /reload-plugins in any open session:

claude plugin marketplace add HenriqueSchroeder/pixel-pets
claude plugin install pixel-pets@pixel-pets

/pet hides the pets and brings them back. /pet <name> gives the current project a pet of its own, so each of your Claude Code windows can tell which project it is in; it is kept for the project's next sessions too, and /pet default goes back to the one in your settings.

Configure

Run /plugin configure pixel-pets@pixel-pets, or find pixel-pets in /config:

OptionDefaultWhat it does
petcatWhich pet to show, picked from the shipped ones (see below), or custom for your own
customPetYour own pack when pet is custom: a file in ~/.claude/pets/, without .json
languageautoauto follows Claude Code's language setting, then $LANG. Or pick en, pt-BR
awakeMinutes1How long the pet stays awake, strolling around, after Claude finishes, before it falls asleep (half as long at night). 0 sends it straight to sleep

Use another pet

  1. Save the pack as ~/.claude/pets/<name>.json. The file name must match the pack's name.
  2. Pick custom in the pet option and set customPet to <name>.

A pack in ~/.claude/pets/ wins over a shipped one with the same name. If a pack can't be read, pixel-pets says why in a toast and shows the cat.

Shipped pets:

  • cat, the default, which turns side on to walk, and slime, which bounces in place, melts into a puddle in a deep sleep and never walks: it sinks into the floor and wells up somewhere else.
  • dog, which wags its tail, pants, tilts its head when it thinks and turns side on to walk.
  • ghost, which floats instead of walking, says boo now and then, fades as it sleeps and turns up elsewhere with a boo.
  • owl, which blinks slowly and turns its head all the way round.
  • clawd, shown at the top with the dragon, the ghost, the fox and the capybara: the critter on Claude Code's welcome screen, waving its little arms, cheering with both when a long job is done and cracking a whip at its agents to keep them at it. Fan art: not made or endorsed by Anthropic.
  • fox, which sways its brush, pounces on mice under the snow and turns side on to walk.
  • dragon, a big red dragon that breathes fire, counts its hoard and flies off to land elsewhere. It draws no mini pets.
  • capybara, the calmest of them all: it chews grass, lets a bird sit on its head and balances an orange. It draws no mini pets.

The cat, slime, dog and owl, idle, thinking, happy and asleep

Draw your own

A pack is a palette and some frames. Each frame is rows of palette letters, and . is see-through:

{
  "$schema": "https://raw.githubusercontent.com/HenriqueSchroeder/pixel-pets/main/schema/pet.schema.json",
  "name": "blob",
  "palette": { "o": "#1a1a1a", "b": "#7cc4f2", "e": "#ffffff" },
  "main": {
    "moods": {
      "sleeping": [
        ["..oooo..", ".obbbbo.", "obbbbbbo", "obobbobo", "obbbbbbo", ".oooooo."]
      ]
    }
  },
  "mini": { "moods": { "working": [[".oo.", "obbo", ".oo."]] } }
}
  • Only sleeping is required. A mood you leave out borrows from its parent, as the table in What it shows lists.
  • Give a mood several frames and they play in a loop at fps frames per second (1–12, default 4). Repeat a frame to hold a pose: at 4 fps, the same frame four times in a row holds it for a second.
  • Every frame of the main pet has the same size, up to 48×32 pixels. The mini pet goes up to 12×12.
  • Two pixel rows fit in one terminal row, so a 12×12 pet takes 12 columns and 6 rows.
  • The mini pet needs working. Its happy and sad, shown when an agent ends, and startled, shown while an action that startles plays, fall back to working.
  • mini.tint (default b) is the letter recolored for each subagent.
  • "mini": false draws no mini pets: while agents work, the pet shows them only by its own supervising. Handy for a wide pet that leaves little room beside it.
  • See every mood of a pack, and what it borrows, in your terminal: npx tsx scripts/preview-pet.ts <name or path>.

Make it feel alive

A loop alone looks like a machine. Three optional keys under main make the pet unpredictable:

"variants": {
  "thinking": [[["...frame..."], ["...frame..."]]]
},
"transitions": {
  "*>sleeping": [["...yawn..."], ["...eyes droop..."]],
  "sleeping>*": [["...stretch..."]]
},
"actions": {
  "blink": { "frames": [["...eyes shut..."]], "moods": ["thinking", "reading"], "every": [2, 6] }
}
  • variants: more loops for a mood you draw in moods. One of them, or the mood's own loop, is picked at random each time the mood starts.
  • transitions: frames played once when the mood changes, keyed from>to. Either side may be *. An exact key wins over from>*, which wins over *>to.
  • actions: frames played once at a random moment while the pet is in one of moods, every [min, max] seconds after the last time. Blinks, ear twitches and yawns live here, so the loops can stay calm. An action with startles, a frame counted from 0, makes the agents' mini pets on the side the pet faces play startled from that frame until it ends, always from its first frame: clawd's whip crack.
  • An action is a whole frame, so keep its face the same as the moods it plays in. The cat has one blink per expression for that reason.
  • A mood holds up to 16 frames; at 4 fps that is 4 seconds.

Give it a voice with speech, at the top level: lines by language code and situation, one picked at random. A language or situation you leave out says the default line.

"speech": {
  "en": { "longThink": ["mrrp… hmm"], "manyReads": ["so many files… mrow!"] },
  "pt-BR": { "longThink": ["mrrp… hmm"] }
}

The situations are longThink, manyReads, manyAgents, lateNight, bored and dreaming. Up to 8 lines each, one line of up to 40 characters.

A pet that does not walk can teleport: give main a teleport with vanish, played where it stands, and appear, played where it lands, frames like any other. It is how it strolls and how it makes room for agents.

Give it a nature with personality, at the root of the pack: three traits from 0 to 1, each the usual 0.5 when left out. energetic tires slower, curious gets bored and up from a nap sooner (0 never does), affectionate misses you sooner (0 never does). The dog is { "energetic": 0.8, "curious": 0.6, "affectionate": 0.9 }; the slime, lazy, is { "energetic": 0.2, "curious": 0.3, "affectionate": 0.6 }.

The schema gives your editor completion and checks. To share a pet, see CONTRIBUTING.md.

Check what it does before you install

A mod runs with your permissions. Clone this repository and list every event it hooks and every call it makes:

claude plugin validate ./pixel-pets

pixel-pets reads your pet packs, Claude Code's language setting and the HOME and LANG variables, and draws above the prompt. It never writes files, runs commands, or uses the network.

Limitations

  • The pixel art needs a terminal. Other surfaces get a line of text.
  • A pet pack is read when the plugin loads. After editing one, run /reload-plugins.
  • The mods API is in early access and can change between Claude Code releases.

Development

claude --plugin-dir .            # try it in a session
claude plugin validate .         # manifest, hooks and calls
claude plugin test .             # tests/
npx tsx scripts/check-pets.ts    # every pack in pets/
npx tsc -p .                     # types (after one --plugin-dir run lays them)

License

MIT

Source 13 files
hooks/register.tsx 813 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, ToolCallInput } from 'claude-code'
3
4import type { Activity, AgentPet, Feelings, Label, MiniMood, Mood, Reaction, Situation, Traits } from '../types'
5import { alertness, drift, engaged, misses, paceOf, rested, seen, stirred, stirs, USUAL } from './drives'
6import type { Drives, State } from './drives'
7import { calm, cheer, feel, idleMood, isNight, nightOf } from './feelings'
8import { pickLocale, say } from './i18n'
9import type { Locale, Text } from './i18n'
10import { asVisit, daysBetween, welcome } from './memory'
11import type { Visit } from './memory'
12import { step } from './motion'
13import type { Motion } from './motion'
14import { DEFAULT_PET, packPaths, parsePack } from './pack'
15import type { Pack } from './pack'
16import { encode, mirror, sizeOf } from './render'
17import { LONG_THINK_MS, MANY_AGENTS, MANY_READS, lineFor, maySpeak, quiet, spoke } from './speech'
18import type { Speaker } from './speech'
19import { EMPTY_STAGE, faceFor, holdFacing, moveOnStage, sideIn } from './walk'
20import type { Held, Side, Stage } from './walk'
21
22const asleep: Activity = { mood: 'sleeping', label: { text: 'sleeping', detail: '' } }
23
24const activity = atom({ plugin: 'pixel-pets', key: 'activity' } as const, asleep)
25const reaction = atom({ plugin: 'pixel-pets', key: 'reaction' } as const, null as Reaction | null)
26const override = atom({ plugin: 'pixel-pets', key: 'override' } as const, null as Activity | null)
27const turnStartedAt = atom({ plugin: 'pixel-pets', key: 'turnStartedAt' } as const, null as number | null)
28const lastActiveAt = atom({ plugin: 'pixel-pets', key: 'lastActiveAt' } as const, null as number | null)
29const typingAt = atom({ plugin: 'pixel-pets', key: 'typingAt' } as const, null as number | null)
30const agents = atom({ plugin: 'pixel-pets', key: 'agents' } as const, [] as AgentPet[])
31const isHidden = atom({ plugin: 'pixel-pets', key: 'isHidden' } as const, false)
32const feelings = atom({ plugin: 'pixel-pets', key: 'feelings' } as const, calm as Feelings)
33const restedAt = atom({ plugin: 'pixel-pets', key: 'restedAt' } as const, null as number | null)
34
35const REACTION_MS = 2000
36const WAKING_MS = 1200
37const LEAVING_MS = 1500
38const LONG_TURN_MS = 2 * 60_000
39const DEEP_SLEEP_MS = 10 * 60_000
40// A break this long rests it, as far as getting tired goes.
41const BREAK_MS = 60 * 60_000
42const GREETING_MS = 2500
43// A turn this long that goes well earns a bigger, longer celebration.
44const LONG_WIN_MS = 5 * 60_000
45const CELEBRATION_MS = 4000
46// How long a line it says stays on Claude's line.
47const SPEECH_MS = 4000
48// How long the pet keeps watching the prompt after the last key.
49const TYPING_MS = 2000
50// How long it stays up when boredom gets it out of a nap.
51const STIR_MS = 45_000
52// How long into a nap it may talk in its sleep, in minutes.
53const DREAM_MINUTES: [number, number] = [10, 20]
54const AGENT_SLOT = 20
55// Room kept for the " +N" that stands for agents with no slot.
56const OVERFLOW_COLUMNS = 4
57const MAX_AGENTS = 6
58// While only its own frame moves, the clock repaints that frame and redraws the
59// band in full only this often, in seconds, for what changes with time alone
60// (a reaction running out, dozing off): once a second awake, every 5 asleep.
61const REDRAW_AWAKE_S = 1
62const REDRAW_ASLEEP_S = 5
63
64// One body color per subagent, picked in spawn order.
65const AGENT_COLORS = [0x7cc4f2, 0x9bd57a, 0xc69af2, 0xf2d16b, 0xf28fb0, 0x6fd8c8]
66
67const SEARCHERS = new Set(['Grep', 'Glob', 'WebFetch', 'WebSearch', 'LSP'])
68
69// Waiting on something slow: these turn into sweating when a turn runs long.
70const PATIENT: readonly Mood[] = ['thinking', 'running', 'supervising']
71
72const short = (text: string, max = 32) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
73const basename = (path: string) => path.split('/').pop() ?? path
74const label = (text: Text, detail = ''): Label => ({ text, detail })
75const stillHere = (list: AgentPet[], now: number) => list.filter(pet => pet.leaving === undefined || pet.leaving.until > now)
76
77const describe = (e: ToolCallInput): Activity => {
78  switch (e.tool) {
79    case 'Bash':
80      return { mood: 'running', label: label('running', short(e.command)) }
81    case 'Edit':
82    case 'Write':
83      return { mood: 'writing', label: label('writing', basename(e.file_path)) }
84    case 'Read':
85      return { mood: 'reading', label: label('reading', basename(e.file_path)) }
86    case 'Agent':
87      return { mood: 'supervising', label: label('waitingForAgents') }
88  }
89  const tool = String(e.tool)
90  return SEARCHERS.has(tool)
91    ? { mood: 'searching', label: label('searching', tool) }
92    : { mood: 'typing', label: label('using', short(tool, 24)) }
93}
94
95const callKey = (agentId: string | undefined, tool: string) => `${agentId ?? 'main'}:${tool}`
96
97// The engine's words for a call the person said no to: there is no flag for it.
98const isDenial = (ran: { isError?: boolean; result?: unknown }) =>
99  ran.isError === true && typeof ran.result === 'string' && ran.result.includes("doesn't want to proceed")
100
101const reason = (error: unknown) => (error instanceof Error ? error.message : String(error))
102
103// The language and the pack are read once per module load: a settings change
104// reloads the module, and with it these.
105let choosing: Promise<Locale> | undefined
106// Kept with the name it is for: a draw can ask before `session.start` has read the
107// project's own pet, and the settings' pet must not stand in for it after.
108let loading: { name: string; pack: Promise<Pack> } | undefined
109
110function localeOf($: EngineInterface, choice: string) {
111  choosing ??= (async () => {
112    const settings = await $.settings.read()
113    return pickLocale(choice, settings.language, await $.env.get('LANG'))
114  })()
115  return choosing
116}
117
118// The first pack found wins; a broken or missing one is why not, in words.
119async function findPack($: EngineInterface, name: string, locale: Locale): Promise<Pack | string> {
120  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
121  const paths = packPaths(name, home, $.plugin.root)
122  let problem = say(locale, paths === undefined ? 'invalidName' : 'notFound', name)
123
124  for (const path of paths ?? []) {
125    let text: string
126    try {
127      text = await $.fs.read(path)
128    } catch {
129      continue
130    }
131    try {
132      return parsePack(JSON.parse(text))
133    } catch (error) {
134      problem = `${path}: ${reason(error)}`
135      break
136    }
137  }
138  return problem
139}
140
141// A broken or missing pack falls back to the shipped default, and the toast says why.
142async function readPack($: EngineInterface, name: string, locale: Locale): Promise<Pack> {
143  const found = await findPack($, name, locale)
144  if (typeof found !== 'string') return found
145  const fallback = parsePack(JSON.parse(await $.fs.read(`${$.plugin.root}/pets/${DEFAULT_PET}.json`)))
146  $.ui.toast(say(locale, 'fallback', found))
147  return fallback
148}
149
150function packOf($: EngineInterface, name: string, locale: Locale) {
151  if (loading?.name !== name) loading = { name, pack: readPack($, name, locale) }
152  return loading.pack
153}
154
155// `/pet <name>` picks a project's own pet, kept by project root; `/pet default`
156// goes back to the one in the settings.
157const DEFAULT_ARG = 'default'
158
159async function projectPets($: EngineInterface): Promise<Record<string, string>> {
160  const kept = await $.store.get('projectPets')
161  return typeof kept === 'object' && kept !== null && !Array.isArray(kept) ? (kept as Record<string, string>) : {}
162}
163
164// This session's project's pet, if one was picked.
165async function projectPet($: EngineInterface) {
166  try {
167    const name = (await projectPets($))[await $.session.root()]
168    return typeof name === 'string' ? name : undefined
169  } catch {
170    return undefined
171  }
172}
173
174async function keepProjectPet($: EngineInterface, root: string, name: string | undefined) {
175  const { [root]: _, ...others } = await projectPets($)
176  await $.store.set('projectPets', name === undefined ? others : { ...others, [root]: name })
177}
178
179const hourOf = (now: number) => new Date(now).getHours()
180const anniversary = (days: number): Label =>
181  days === 7
182    ? label('aWeekTogether')
183    : days === 30
184      ? label('aMonthTogether')
185      : days === 365
186        ? label('aYearTogether')
187        : days % 365 === 0
188          ? label('yearsTogether', String(days / 365))
189          : label('daysTogether', String(days))
190
191// Notes that the person is here now, and when they first met. Returns the visit
192// before this one. With no store it remembers nothing, and holds nothing up.
193async function remember($: EngineInterface, now: number) {
194  try {
195    const visit = asVisit(await $.store.get('visit'))
196    await $.store.set('visit', { metAt: visit?.metAt ?? now, lastSeenAt: now })
197    return visit
198  } catch {
199    return undefined
200  }
201}
202
203// How the day's first sight of the person goes: an anniversary, missing them, or
204// good morning. Checked when a session opens and on each prompt, so a session left
205// open overnight still greets the morning. `before` is the visit before this one.
206async function greet($: EngineInterface, now: number, before: Visit | undefined) {
207  const hello = welcome(before, now, hourOf(now))
208  if (hello === undefined) return
209  const until = now + GREETING_MS
210  const shown: Reaction =
211    hello.kind === 'anniversary'
212      ? { mood: 'celebrating', label: anniversary(hello.days), until: now + CELEBRATION_MS }
213      : hello.kind === 'missedYou'
214        ? { mood: 'happy', label: label('missedYou'), until }
215        : { mood: 'waking', label: label('goodMorning'), until }
216  await update($, reaction, () => shown)
217}
218
219// When the person came back from their last break, across every session: an hour
220// with no prompt in any of them rests the pet. `before` is the visit before this one.
221async function restedSince($: EngineInterface, now: number, before: Visit | undefined) {
222  try {
223    const kept = await $.store.get('restedAt')
224    if (typeof kept === 'number' && before !== undefined && now - before.lastSeenAt < BREAK_MS) return kept
225    await $.store.set('restedAt', now)
226  } catch {
227    // No store: rested as of now, as a session that remembers nothing.
228  }
229  return now
230}
231
232// Whether no session has said the late-night line yet tonight.
233async function quietTonight($: EngineInterface, now: number) {
234  try {
235    return (await $.store.get('lateNight')) !== nightOf(now)
236  } catch {
237    return true
238  }
239}
240
241async function saidTonight($: EngineInterface, now: number) {
242  try {
243    await $.store.set('lateNight', nightOf(now))
244  } catch {
245    // No store: this session alone keeps it.
246  }
247}
248
249// The person is at the prompt: the day's welcome, and their rest, from the visit before.
250async function atThePrompt($: EngineInterface, now: number) {
251  const before = await remember($, now)
252  await greet($, now, before)
253  const since = await restedSince($, now, before)
254  await update($, restedAt, () => since)
255}
256
257// "Together for 12 days", for /pet; nothing when it has no memory of the person.
258async function together($: EngineInterface, locale: Locale) {
259  try {
260    const visit = asVisit(await $.store.get('visit'))
261    if (visit === undefined) return ''
262    const days = daysBetween(visit.metAt, await $.clock.now())
263    const said = days === 0 ? say(locale, 'justMet') : days === 1 ? say(locale, 'togetherADay') : say(locale, 'togetherDays', String(days))
264    return ` ${said.charAt(0).toUpperCase()}${said.slice(1)}.`
265  } catch {
266    return ''
267  }
268}
269
270// Clears the override only if it is still the one this hook set.
271async function release($: EngineInterface, mood: Mood) {
272  await update($, override, current => (current?.mood === mood ? null : current))
273}
274
275export const register: Register = (on, options) => {
276  // The picker names a shipped pet, or `custom` for the person's own pack named in `customPet`.
277  const picked = options.pet === 'custom' ? options.customPet : options.pet
278  const configured = typeof picked === 'string' && picked !== '' ? picked : DEFAULT_PET
279  // The project's own pet, if `/pet <name>` picked one, else the settings'.
280  let petName = configured
281  // The pack the frame clock plays, the clock, and a way to start it again at another pack's pace.
282  let playing: Pack | undefined
283  let timer: Timer | undefined
284  let restartClock = () => {}
285  const language = typeof options.language === 'string' ? options.language : 'auto'
286  const awakeMs = (typeof options.awakeMinutes === 'number' && options.awakeMinutes >= 0 ? options.awakeMinutes : 1) * 60_000
287  let frame = 0
288  // What the main pet is playing and where it stands, between draws; a reload starts them fresh.
289  let motion: Motion | undefined
290  // Where it stands and each agent's side, kept so none hops over when another leaves.
291  let stage: Stage = EMPTY_STAGE
292  // A full redraw costs Claude Code far more than repainting the pet, and every
293  // open session pays it. While the band shows nothing but the pet's own frame
294  // moving, `still` holds what the clock needs to repaint that frame alone; while
295  // the pet is hidden the clock redraws nothing, as showing it again redraws.
296  let still: { requestId: string; mood: Mood; facing: Side; cells: string } | null = null
297  let isOff = false
298  // The tool call running for each agent and tool, so a permission prompt (which
299  // names only those) finds the call it is for; and the calls that asked.
300  // ponytail: two same-named calls of one agent at once share a key; the later wins.
301  const openCalls = new Map<string, string>()
302  const asked = new Set<string>()
303  // Only a session with a person at the prompt counts as seeing them: a `claude -p`
304  // run must not spend the day's welcome.
305  let isInteractive = false
306  // What it said lately and what it is saying now; a reload starts them fresh.
307  let speaker: Speaker = quiet
308  let saying: { text: string; until: number } | null = null
309  // Since when the main loop has run no tool (null while one runs), and its tools and reads this turn.
310  let thinkingSince: number | null = null
311  let toolsThisTurn = 0
312  let readsThisTurn = 0
313  // An agent finished since the pet last cheered: the turn that reports it is worth a cheer.
314  let agentsDone = false
315  // The night it last said it was getting late, by its evening's date.
316  let saidNight: string | null = null
317  // What it wants, from the first draw on, and what the band showed last, which is
318  // what it has been up to since; plus until when it is up on its own.
319  let drives: Drives | undefined
320  // How the pet is made, from its pack once the band has drawn it.
321  let traits: Traits = USUAL
322  let lastState: State = 'awake'
323  let stirredUntil = 0
324  // The way it faced as the action it plays began, kept until it ends.
325  let heldFacing: Held
326  // When it last woke up glad at the person's first key after a long while away,
327  // and whether that was since it went idle at `idleSince`.
328  let gladAt: number | null = null
329  // When it talks in its sleep next; null while awake.
330  let dreamAt: number | null = null
331  const wokeForThem = (idleSince: number | null) => gladAt !== null && idleSince !== null && gladAt >= idleSince
332  // Brings the drives up to `now` before `change` touches them.
333  const touch = (now: number, change: (settled: Drives) => Drives) => {
334    if (drives !== undefined) drives = change(drift(drives, now, lastState, traits))
335  }
336
337  // Says the line for `situation`, unless it spoke too lately or already did this turn.
338  const speak = (situation: Situation, now: number, pack: Pack, locale: Locale) => {
339    if (!maySpeak(speaker, situation, now)) return false
340    speaker = spoke(speaker, situation, now)
341    saying = { text: lineFor(pack.speech, locale.code, situation, say(locale, situation), Math.random), until: now + SPEECH_MS }
342    return true
343  }
344
345  on('session.start', async ($, e, next) => {
346    const locale = await localeOf($, language)
347    await $.command.register({ name: 'pet', description: say(locale, 'commandDescription'), argumentHint: '[name | default]' })
348    petName = (await projectPet($)) ?? configured
349    playing = await packOf($, petName, locale)
350    const now = await $.clock.now()
351    await update($, lastActiveAt, () => now)
352    const tick = () => {
353      const pack = playing
354      frame += 1
355      if (isOff || pack === undefined) return
356      const scene = still
357      const isAsleep = scene?.mood === 'sleeping' || scene?.mood === 'deepSleep'
358      const every = pack.fps * (isAsleep ? REDRAW_ASLEEP_S : REDRAW_AWAKE_S)
359      if (scene === null || frame % every === 0) {
360        $.ui.invalidate('ui.render')
361        return
362      }
363      const moved = step(pack, motion, scene.mood, frame, Math.random)
364      motion = moved.motion
365      const cells = encode(scene.facing === 1 ? moved.frame : mirror(moved.frame), pack.colors)
366      if (cells === scene.cells) return
367      still = { ...scene, cells }
368      // Refused when the band is no longer drawn as it was: redraw it in full.
369      void $.ui
370        .blit({ requestId: scene.requestId, key: 'main', cells })
371        .then(done => done.deny !== undefined)
372        .catch(() => true)
373        .then(refused => {
374          if (refused) $.ui.invalidate('ui.render')
375        })
376    }
377    restartClock = () => {
378      timer?.cancel()
379      timer = $.clock.every(Math.round(1000 / (playing?.fps ?? 4)), tick)
380    }
381    restartClock()
382    isInteractive = e.isInteractive
383    if (isInteractive) await atThePrompt($, now)
384    else await update($, restedAt, at => at ?? now)
385    return next(e)
386  })
387
388  on('command.run', { command: 'pet' }, async ($, e) => {
389    const locale = await localeOf($, language)
390    const asked = (e.args ?? '').trim()
391    if (asked === '') {
392      const hidden = await update($, isHidden, value => !value)
393      return { text: `${say(locale, hidden ? 'hidden' : 'shown')}${await together($, locale)}` }
394    }
395
396    // A pet asked by name is tried before it is kept: one that does not load leaves
397    // things as they were. `default` always lets go of the project's own, even when
398    // the settings' pet is broken and falls back to the shipped one.
399    const isDefault = asked === DEFAULT_ARG
400    const name = isDefault ? configured : asked
401    const found = isDefault ? await readPack($, name, locale) : await findPack($, name, locale)
402    if (typeof found === 'string') return { text: say(locale, 'projectPetFailed', found) }
403    try {
404      await keepProjectPet($, await $.session.root(), isDefault ? undefined : name)
405    } catch {
406      // No store: the pet changes for this session alone.
407    }
408    petName = name
409    loading = { name, pack: Promise.resolve(found) }
410    playing = found
411    motion = undefined
412    still = null
413    restartClock()
414    $.ui.invalidate('ui.render')
415    return { text: say(locale, isDefault ? 'projectPetDefault' : 'projectPet', name) }
416  })
417
418  // The pet looks at the prompt while the person types in it, glad at the first key
419  // after a long while away, unless a turn or something else is showing.
420  on('prompt.edit', async ($, e, next) => {
421    const now = await $.clock.now()
422    await update($, typingAt, () => now)
423    let missed = false
424    touch(now, settled => {
425      missed = misses(settled)
426      return seen(settled)
427    })
428    if (missed && (await read($, turnStartedAt)) === null) {
429      gladAt = now
430      const glad: Reaction = { mood: 'happy', label: label('missedYou'), until: now + REACTION_MS }
431      await update($, reaction, live => (live !== null && live.until > now ? live : glad))
432    }
433    return next(e)
434  }).catch(($, e, next) => next(e))
435
436  // Only a pet in deep sleep is startled awake; a dozing one just starts thinking,
437  // one keeping an eye on background agents was awake all along, and one glad to
438  // see the person woke up already.
439  on('prompt.submit', async ($, e, next) => {
440    const now = await $.clock.now()
441    const idleSince = await read($, lastActiveAt)
442    const busySince = await read($, turnStartedAt)
443    const isSupervising = stillHere(await read($, agents), now).some(one => one.leaving === undefined)
444    const isFastAsleep = idleSince !== null && now - idleSince > DEEP_SLEEP_MS && !wokeForThem(idleSince)
445    if (busySince === null && !isSupervising && isFastAsleep) {
446      await update($, reaction, (): Reaction => ({ mood: 'waking', label: label('wakingUp'), until: now + WAKING_MS }))
447    }
448    touch(now, seen)
449    if (isInteractive) await atThePrompt($, now)
450    // Once a night across every session: the first to see the person late says it.
451    const tonight = nightOf(now)
452    if (isNight(hourOf(now)) && saidNight !== tonight) {
453      if (await quietTonight($, now)) {
454        const locale = await localeOf($, language)
455        if (speak('lateNight', now, await packOf($, petName, locale), locale)) {
456          saidNight = tonight
457          await saidTonight($, now)
458        }
459      } else saidNight = tonight
460    }
461    return next(e)
462  }).catch(($, e, next) => next(e))
463
464  on('turn.start', async ($, e, next) => {
465    const now = await $.clock.now()
466    await update($, activity, (): Activity => ({ mood: 'thinking', label: label('thinking') }))
467    await update($, turnStartedAt, () => now)
468    speaker = { ...speaker, saidThisTurn: [] }
469    touch(now, engaged)
470    thinkingSince = now
471    toolsThisTurn = 0
472    readsThisTurn = 0
473    return next(e)
474  })
475
476  on('turn.complete', async ($, e, next) => {
477    const now = await $.clock.now()
478    const isBad = e.reason === 'error' || e.reason === 'refusal'
479
480    if (e.agentId !== undefined) {
481      const id = e.agentId
482      const leaving = { mood: (isBad || e.isAborted ? 'sad' : 'happy') as MiniMood, until: now + LEAVING_MS }
483      await update($, agents, list => stillHere(list, now).map(pet => (pet.id === id ? { ...pet, leaving } : pet)))
484      await update($, lastActiveAt, () => now)
485      agentsDone = true
486      return next(e)
487    }
488
489    if (!e.isAborted) await update($, feelings, felt => feel(felt, isBad ? 'turnFailed' : 'turnOk', now))
490    const startedAt = await read($, turnStartedAt)
491    const tookLong = startedAt !== null && now - startedAt >= LONG_WIN_MS
492    const agentsLeft = stillHere(await read($, agents), now).filter(one => one.leaving === undefined).length
493    const cheered = cheer({ tookLong, tools: toolsThisTurn, agentsDone, agentsLeft }, Math.random)
494    if (agentsLeft === 0) agentsDone = false
495    const until = now + REACTION_MS
496    // An interrupted turn earns no reaction, and one still showing (a thanks, a
497    // "fine, I won't" after a denied permission, a failure) is left to finish.
498    // A failure always shows; a turn that went well only sometimes cheers.
499    const live = await read($, reaction)
500    if (!e.isAborted && (live === null || live.until <= now) && (isBad || cheered !== null)) {
501      await update($, reaction, (): Reaction =>
502        isBad
503          ? { mood: 'sad', label: label('wentWrong'), until }
504          : cheered === 'celebrating'
505            ? { mood: 'celebrating', label: label('phew'), until: now + CELEBRATION_MS }
506            : { mood: 'happy', label: label('done'), until },
507      )
508    }
509    await update($, override, () => null)
510    await update($, turnStartedAt, () => null)
511    await update($, lastActiveAt, () => now)
512    return next(e)
513  })
514
515  on('agent.spawn', async ($, e, next) => {
516    const started = await next(e)
517    const id = 'agentId' in started ? started.agentId : undefined
518    if (id !== undefined) {
519      const now = await $.clock.now()
520      await update($, agents, list => {
521        const color = AGENT_COLORS[list.length % AGENT_COLORS.length] ?? 0xffffff
522        const pet: AgentPet = { id, toolUseId: e.tool_use_id, type: e.subagentType, label: label('thinking'), color }
523        return [...stillHere(list, now), pet]
524      })
525      const working = stillHere(await read($, agents), now).filter(one => one.leaving === undefined)
526      if (working.length >= MANY_AGENTS) {
527        const locale = await localeOf($, language)
528        speak('manyAgents', now, await packOf($, petName, locale), locale)
529      }
530    }
531    return started
532  }).catch(($, e, next) => next(e))
533
534  on('tool.call', async ($, e, next) => {
535    const now = describe(e)
536    const agentId = e.agentId
537
538    if (agentId === undefined) {
539      await update($, activity, () => now)
540    } else {
541      await update($, agents, list => list.map(pet => (pet.id === agentId ? { ...pet, label: now.label } : pet)))
542    }
543
544    if (agentId === undefined) {
545      thinkingSince = null
546      toolsThisTurn += 1
547    }
548    const key = callKey(agentId, String(e.tool))
549    openCalls.set(key, e.tool_use_id)
550    let ran: Awaited<ReturnType<typeof next>>
551    let wasAsked = false
552    try {
553      ran = await next(e)
554    } finally {
555      if (openCalls.get(key) === e.tool_use_id) openCalls.delete(key)
556      wasAsked = asked.delete(e.tool_use_id)
557    }
558
559    // A background agent's tool call returns at once; its pet leaves on its own
560    // turn.complete. Only a spawn that failed is dropped here.
561    if (e.tool === 'Agent' && ran.isError === true) {
562      await update($, agents, list => list.filter(pet => pet.toolUseId !== e.tool_use_id))
563    }
564    const at = await $.clock.now()
565    const until = at + REACTION_MS
566    if (agentId === undefined) {
567      thinkingSince = at
568      if (e.tool === 'Read' && (readsThisTurn += 1) === MANY_READS) {
569        const locale = await localeOf($, language)
570        speak('manyReads', at, await packOf($, petName, locale), locale)
571      }
572    }
573    if (wasAsked) {
574      // The person answered a permission prompt: thanks for a yes, fine for a no.
575      const thanked: Reaction = isDenial(ran)
576        ? { mood: 'sad', label: label('denied'), until }
577        : { mood: 'happy', label: label('thanks'), until }
578      await update($, reaction, () => thanked)
579    } else if (agentId === undefined && ran.isError === true) {
580      // "failed: npm test", or the tool's name when the action has no detail.
581      const failed = label('failed', short(now.label.detail || String(e.tool), 24))
582      await update($, reaction, (): Reaction => ({ mood: 'sad', label: failed, until }))
583      await update($, feelings, felt => feel(felt, 'toolFailed', at))
584    }
585    return ran
586  }).catch(($, e, next) => next(e))
587
588  // The band is hidden while a permission dialog is open (and `$.ui.notice` does
589  // not show under the terminal's), so the pet only marks the call that asked;
590  // tool.call reacts once the person has answered.
591  on('classic.PermissionRequest', async ($, e, next) => {
592    const id = openCalls.get(callKey(e.agent_id, e.tool_name))
593    if (id !== undefined) asked.add(id)
594    return next(e)
595  }).catch(($, e, next) => next(e))
596
597  on('session.compact', async ($, e, next) => {
598    await update($, override, (): Activity => ({ mood: 'compacting', label: label('compacting') }))
599    try {
600      return await next(e)
601    } finally {
602      await release($, 'compacting')
603    }
604  }).catch(($, e, next) => next(e))
605
606  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
607    still = null
608    isOff = await read($, isHidden)
609    if (e.props.hasSurvey || isOff) {
610      return next(e)
611    }
612
613    const locale = await localeOf($, language)
614    const pack = await packOf($, petName, locale)
615    const now = await $.clock.now()
616    const busy = await read($, override)
617    const flash = await read($, reaction)
618    const doing = await read($, activity)
619    const startedAt = await read($, turnStartedAt)
620    const idleSince = await read($, lastActiveAt)
621    const typedAt = await read($, typingAt)
622    const list = stillHere(await read($, agents), now)
623    const words = (one: Label) => say(locale, one.text, one.detail)
624    // ponytail: the drives move only when the band is drawn, so a band hidden by /pet
625    // counts all that time as what it last showed; give them a clock of their own if that skews them.
626    traits = pack.personality
627    drives = drives === undefined ? rested(now) : drift(drives, now, lastState, traits)
628
629    let shown: Activity
630    let extra = ''
631    if (busy !== null) {
632      shown = busy
633    } else if (flash !== null && flash.until > now) {
634      shown = flash
635    } else if (e.props.isWorking) {
636      const elapsed = startedAt === null ? 0 : now - startedAt
637      const isLong = elapsed > LONG_TURN_MS && PATIENT.includes(doing.mood)
638      if (thinkingSince !== null && now - thinkingSince >= LONG_THINK_MS) speak('longThink', now, pack, locale)
639      // A turn that only thinks while agents still run is waiting on them.
640      const current: Activity =
641        doing.mood === 'thinking' && list.some(one => one.leaving === undefined)
642          ? { mood: 'supervising', label: label('waitingForAgents') }
643          : doing
644      shown = isLong ? { ...current, mood: 'sweating' } : current
645      if (isLong) extra = ` · ${Math.floor(elapsed / 60_000)}m`
646    } else {
647      // It dozes off sooner at night, and when it runs low on energy. Unknown idle time
648      // (nothing has run yet) counts as a plain nap.
649      const hour = hourOf(now)
650      const awakeFor = (isNight(hour) ? awakeMs / 2 : awakeMs) * alertness(drives)
651      const idleFor = idleSince === null ? awakeFor + 1 : now - idleSince
652      const isTyping = typedAt !== null && now - typedAt < TYPING_MS
653      // Fast asleep it does not look up, unless it woke up glad to see them since.
654      if (isTyping && (idleFor <= DEEP_SLEEP_MS || wokeForThem(idleSince))) shown = { mood: 'watching', label: label('watching') }
655      // Background agents still at work: it keeps an eye on them rather than dozing off.
656      else if (list.some(one => one.leaving === undefined)) shown = { mood: 'supervising', label: label('waitingForAgents') }
657      // Once glad to see them it only dozes, so the prompt it sends does not startle it.
658      else if (idleSince !== null && idleFor > DEEP_SLEEP_MS && !wokeForThem(idleSince)) shown = { mood: 'deepSleep', label: label('deepSleep') }
659      else if (idleFor > awakeFor && now >= stirredUntil && !stirs(drives)) shown = asleep
660      else {
661        // Bored in a nap, it gets up for a while on its own.
662        if (idleFor > awakeFor && now >= stirredUntil) {
663          stirredUntil = now + STIR_MS
664          drives = stirred(drives)
665          speak('bored', now, pack, locale)
666        }
667        const felt = idleMood(await read($, feelings), now, await read($, restedAt), hour)
668        shown = felt === undefined ? { mood: 'idle', label: label('idle') } : { mood: felt, label: label(felt) }
669      }
670    }
671
672    lastState = shown.mood === 'sleeping' || shown.mood === 'deepSleep' ? 'asleep' : e.props.isWorking ? 'working' : 'awake'
673    // Asleep, it talks in its sleep now and then, as rarely as it speaks at all.
674    const dreamGap = () => (DREAM_MINUTES[0] + (DREAM_MINUTES[1] - DREAM_MINUTES[0]) * Math.random()) * 60_000
675    if (lastState !== 'asleep') dreamAt = null
676    else if (dreamAt === null) dreamAt = now + dreamGap()
677    else if (now >= dreamAt) {
678      speak('dreaming', now, pack, locale)
679      dreamAt = now + dreamGap()
680    }
681
682    const miniSize = sizeOf(pack.mini?.working[0] ?? [])
683    const slot = Math.max(miniSize.columns, AGENT_SLOT)
684    const petColumns = pack.moods.sleeping[0]?.[0]?.length ?? 0
685    // As many agents as fit beside the pet; the rest are a "+N" that needs room too.
686    // A pack with no mini pets shows its agents only by its own mood.
687    const room = e.props.bodyColumns - petColumns - 2
688    const fits = (count: number) => count * (slot + 1) + (count < list.length ? OVERFLOW_COLUMNS : 0) <= room
689    let shownAgents = pack.mini === null ? 0 : Math.min(list.length, MAX_AGENTS)
690    while (shownAgents > 0 && !fits(shownAgents)) shownAgents -= 1
691    const visible = list.slice(0, shownAgents)
692    const hidden = pack.mini === null ? 0 : list.length - visible.length
693    const agentWords = (one: AgentPet) =>
694      one.leaving === undefined ? words(one.label) : say(locale, one.leaving.mood === 'sad' ? 'wentWrong' : 'done')
695
696    // Every frame is one size, so the sleeping one tells whether the stage fits, tall and wide.
697    // A line of text has no stage, and must not move the pet on the one that has.
698    const mainSize = sizeOf(pack.moods.sleeping[0] ?? [])
699    if (e.surface !== 'terminal' || e.props.maxRows < mainSize.rows + 1 || e.props.bodyColumns < mainSize.columns) {
700      const { Text } = $.ui.resolve(e)
701      const others = list.map(one => ` · ${one.type}: ${agentWords(one)}`).join('')
702      return <Text dimColor>🐾 Claude: {words(shown.label)}{extra}{others}</Text>
703    }
704
705    // It only strolls alone; with agents around they gather on both sides, and when
706    // they do not fit where it stands it hurries off to make room.
707    // A proud pet struts about too; a sleepy, tired, worried or grumpy one stays put.
708    const isRestless = shown.mood === 'idle' || shown.mood === 'proud'
709    const width = slot + 1
710    const extraColumns = hidden > 0 ? OVERFLOW_COLUMNS : 0
711    // A pet that draws no walking but a teleport gets about by vanishing and appearing.
712    const teleport = pack.walks ? null : pack.teleport
713    const onStage = moveOnStage(stage, {
714      tick: frame,
715      room,
716      fps: pack.fps,
717      random: Math.random,
718      pace: paceOf(drives),
719      walks: pack.walks,
720      teleport: teleport === null ? null : { vanish: teleport.vanish.length, appear: teleport.appear.length },
721      agents: visible.map(one => one.id),
722      width,
723      extra: extraColumns,
724      wants: placed => (visible.length > 0 ? { go: placed.x } : list.length === 0 && isRestless ? 'wander' : 'stay'),
725    })
726    stage = onStage.stage
727    const { drawn, drawnCount, standX, blinking } = onStage
728    const blinkFrame = teleport === null || blinking === undefined ? undefined : teleport[blinking.phase][blinking.at]
729    const showsMore = standX === onStage.placed.x && hidden > 0
730    const sideOf = (index: number) => sideIn(drawn, index)
731    const isOnTheMove = onStage.moving || blinkFrame !== undefined
732    const isStrolling = isOnTheMove && onStage.wants === 'wander'
733    const mood: Mood = pack.walks && onStage.moving ? 'walking' : shown.mood
734    const labelShown = isStrolling ? label('strolling') : shown.label
735    // A line it says takes Claude's line for a moment, unless a reaction or a mood that
736    // must show is on it.
737    const isQuoting = saying !== null && saying.until > now && busy === null && !(flash !== null && flash.until > now)
738    const said = isQuoting ? `“${saying?.text}”` : words(labelShown)
739
740    const moved = step(pack, motion, mood, frame, Math.random)
741    motion = moved.motion
742    // It faces the way it walks, else the agent finishing as it says goodbye, else the
743    // first; through an action, the way it faced as the action began.
744    const finishing = visible.slice(0, drawnCount).findLastIndex(one => one.leaving !== undefined)
745    const facesNow = faceFor(isOnTheMove, stage.walk?.facing ?? 1, drawn, Math.max(finishing, 0))
746    const once = moved.motion.once
747    const held = holdFacing(heldFacing, once?.isAction ? once : undefined, facesNow, isOnTheMove)
748    heldFacing = held.held
749    const facing = held.side
750    const shape = blinkFrame ?? moved.frame
751    const body = facing === 1 ? shape : mirror(shape)
752    const cells = encode(body, pack.colors)
753    // Nothing but the pet's own frame will move until something is written or
754    // time passes: no turn, agents, stroll or blink, reaction or line it says.
755    const isStill =
756      !e.props.isWorking &&
757      list.length === 0 &&
758      !isOnTheMove &&
759      stage.blink === undefined &&
760      (!(pack.walks || teleport !== null) || onStage.wants === 'stay') &&
761      busy === null &&
762      !(flash !== null && flash.until > now) &&
763      !isQuoting
764    still = isStill ? { requestId: e.requestId, mood, facing, cells } : null
765
766    const { Box, Raster, Text } = $.ui.resolve(e)
767    const agentPet = (index: number, side: Side) => {
768      const one = visible[index]
769      if (one === undefined) return null
770      // The ones still at work on the side it faces jump while it startles them, their
771      // jump played from its first frame as it does, not with the clock.
772      const startledAt = side === facing && one.leaving === undefined ? moved.startled : undefined
773      const miniFrames = pack.mini?.[one.leaving?.mood ?? (startledAt === undefined ? 'working' : 'startled')] ?? []
774      const mini = miniFrames[(startledAt ?? frame) % miniFrames.length] ?? []
775      return (
776        // On the left the slot hugs the pet too: its pet and words lean right.
777        <Box
778          key={one.id}
779          flexDirection="column"
780          width={slot}
781          alignItems={side === 1 ? 'flex-start' : 'flex-end'}
782          marginLeft={side === 1 ? 1 : 0}
783          marginRight={side === 1 ? 0 : 1}
784        >
785          <Raster key={`mini-${one.id}`} {...miniSize} cells={encode(mini, { ...pack.colors, [pack.tint]: one.color })} />
786          <Text bold>{short(one.type, slot)}</Text>
787          <Text dimColor>{short(agentWords(one), slot)}</Text>
788        </Box>
789      )
790    }
791    const leftColumns = drawn.left.length * width + (showsMore && drawn.more === -1 ? extraColumns : 0)
792
793    return (
794      <Box flexDirection="column">
795        <Text>
796          <Text bold>Claude</Text> <Text dimColor>· {said}{extra}</Text>
797        </Text>
798        <Box flexDirection="row" marginLeft={standX - leftColumns}>
799          {showsMore && drawn.more === -1 && (
800            <Box width={OVERFLOW_COLUMNS}>
801              <Text dimColor>+{hidden}</Text>
802            </Box>
803          )}
804          {[...drawn.left].reverse().map(i => agentPet(i, -1))}
805          <Raster key="main" {...mainSize} cells={cells} />
806          {drawn.right.map(i => agentPet(i, 1))}
807          {showsMore && drawn.more === 1 && <Text dimColor> +{hidden}</Text>}
808        </Box>
809      </Box>
810    )
811  })
812}
813
hooks/drives.ts 76 lines
1import type { Traits } from '../types'
2import type { Pace } from './walk'
3
4// What the pet wants, apart from what Claude is doing: each in [0, 1], drifting
5// with time by what it was up to. Session only, like the feelings.
6export type Drives = { energy: number; boredom: number; longing: number; at: number }
7
8// What it was up to since `at`: what the band last showed.
9export type State = 'asleep' | 'awake' | 'working'
10
11const HOUR_MS = 60 * 60_000
12
13// Change per hour. Sleep fills energy in two hours; boredom fills in a quarter of
14// an hour with nothing to do; longing fills in an hour without the person, even
15// while Claude works, since it is about them.
16const RATES: Record<State, Omit<Drives, 'at'>> = {
17  asleep: { energy: 0.5, boredom: 4, longing: 1 },
18  awake: { energy: -0.1, boredom: 4, longing: 1 },
19  working: { energy: -0.25, boredom: 0, longing: 1 },
20}
21
22// Bored enough, and not too tired, to get up on its own.
23const STIR_BOREDOM = 1 / 3
24const STIR_ENERGY = 0.4
25// Below this it starts to flag.
26const LOW_ENERGY = 0.5
27// Missing the person this much, it greets their first key.
28const MISSING = 0.5
29
30export const USUAL: Traits = { energetic: 0.5, curious: 0.5, affectionate: 0.5 }
31
32export const rested = (now: number): Drives => ({ energy: 1, boredom: 0, longing: 0, at: now })
33
34const unit = (value: number) => Math.min(1, Math.max(0, value))
35
36// The drives at `now`, after spending the time since `at` in `state`, as a pet
37// made with `traits`: an energetic one tires slower, a curious one gets bored
38// sooner, an affectionate one misses the person sooner. The usual traits change nothing.
39export const drift = (drives: Drives, now: number, state: State, traits: Traits = USUAL): Drives => {
40  const hours = (now - drives.at) / HOUR_MS
41  if (hours <= 0) return drives
42  const rate = RATES[state]
43  const tiring = rate.energy < 0 ? 1.5 - traits.energetic : 1
44  return {
45    energy: unit(drives.energy + rate.energy * tiring * hours),
46    boredom: unit(drives.boredom + rate.boredom * traits.curious * 2 * hours),
47    longing: unit(drives.longing + rate.longing * traits.affectionate * 2 * hours),
48    at: now,
49  }
50}
51
52// A turn started: something to watch at last.
53export const engaged = (drives: Drives): Drives => ({ ...drives, boredom: 0 })
54
55// Up from a nap on its own: that cured its boredom.
56export const stirred = (drives: Drives): Drives => ({ ...drives, boredom: 0 })
57
58// The person typed or sent a prompt.
59export const seen = (drives: Drives): Drives => ({ ...drives, boredom: 0, longing: 0 })
60
61// How much of its usual awake time it lasts: all of it until energy runs low.
62export const alertness = (drives: Drives) => (drives.energy >= LOW_ENERGY ? 1 : Math.max(0.25, drives.energy * 2))
63
64// How it strolls: a tired pet rests longer between strolls, up to twice as long,
65// and one that misses the person keeps near the prompt.
66export const paceOf = (drives: Drives): Pace => ({
67  rest: drives.energy >= LOW_ENERGY ? 1 : 2 - drives.energy * 2,
68  lean: drives.longing,
69})
70
71// Whether it has missed the person enough to greet them.
72export const misses = (drives: Drives) => drives.longing >= MISSING
73
74// Whether a dozing pet gets up on its own.
75export const stirs = (drives: Drives) => drives.boredom >= STIR_BOREDOM && drives.energy >= STIR_ENERGY
76
hooks/feelings.ts 72 lines
1import type { Feelings, Mood } from '../types'
2
3export type Feeling = 'toolFailed' | 'turnFailed' | 'turnOk'
4
5const FADE_MS = 5 * 60_000
6const WORRIED = 3
7const GRUMPY = 6
8const PROUD = 3
9// Hours of work since its last long break that make it tired.
10const TIRED_MS = 3 * 60 * 60_000
11
12export const calm: Feelings = { worry: 0, pride: 0, at: 0 }
13
14// The feelings as they stand at `now`, the points faded since `at` taken off.
15export const settle = (feelings: Feelings, now: number): Feelings => {
16  const faded = Math.max(0, Math.floor((now - feelings.at) / FADE_MS))
17  if (faded === 0) return feelings
18  return {
19    worry: Math.max(0, feelings.worry - faded),
20    pride: Math.max(0, feelings.pride - faded),
21    at: feelings.at + faded * FADE_MS,
22  }
23}
24
25export const feel = (feelings: Feelings, what: Feeling, now: number): Feelings => {
26  const { worry, pride } = settle(feelings, now)
27  switch (what) {
28    case 'toolFailed':
29      return { worry: worry + 1, pride, at: now }
30    // Pride is for a streak: a turn that fails ends it.
31    case 'turnFailed':
32      return { worry: worry + 2, pride: 0, at: now }
33    case 'turnOk':
34      return { worry: Math.max(0, worry - 1), pride: pride + 1, at: now }
35  }
36}
37
38// A turn of work that went well cheers it only now and then, or it would cheer at every step.
39const CHEER_CHANCE = 0.6
40
41type Turn = { tookLong: boolean; tools: number; agentsDone: boolean; agentsLeft: number }
42
43// How a turn that went well ends. It waits for the last agent before cheering;
44// a long turn is celebrated, the last agent done is cheered, other work only now
45// and then, and a turn that only talked earns nothing.
46export const cheer = (turn: Turn, random: () => number): 'celebrating' | 'happy' | null => {
47  if (turn.agentsLeft > 0) return null
48  if (turn.tookLong) return 'celebrating'
49  if (turn.agentsDone) return 'happy'
50  if (turn.tools === 0) return null
51  return random() < CHEER_CHANCE ? 'happy' : null
52}
53
54export const isNight = (hour: number) => hour >= 22 || hour < 6
55export const isMorning = (hour: number) => hour >= 5 && hour < 11
56// The night `now` falls in, named by its evening's date: 2 a.m. belongs to the night before.
57export const nightOf = (now: number) => new Date(now - 6 * 60 * 60_000).toDateString()
58
59// The moods that stand in for plain `idle`; each is also the key of its words.
60export type Felt = Extract<Mood, 'grumpy' | 'worried' | 'tired' | 'proud' | 'sleepy'>
61
62// What it shows instead of plain `idle`, if anything: the strongest feeling wins.
63export const idleMood = (feelings: Feelings, now: number, restedAt: number | null, hour: number): Felt | undefined => {
64  const { worry, pride } = settle(feelings, now)
65  if (worry >= GRUMPY) return 'grumpy'
66  if (worry >= WORRIED) return 'worried'
67  if (restedAt !== null && now - restedAt >= TIRED_MS) return 'tired'
68  if (pride >= PROUD && worry === 0) return 'proud'
69  if (isNight(hour)) return 'sleepy'
70  return undefined
71}
72
hooks/i18n.ts 28 lines
1import type { Text } from '../types'
2import { en } from '../locales/en'
3import type { Locale } from '../locales/en'
4import { ptBR } from '../locales/pt-BR'
5
6export type { Locale, Text }
7
8export const LOCALES: readonly Locale[] = [en, ptBR]
9
10const byName = (wanted: string | undefined) => {
11  if (!wanted) return undefined
12  // "pt_BR.UTF-8" → "pt-br"
13  const name = wanted.trim().toLowerCase().split('.')[0]?.replace('_', '-') ?? ''
14  return (
15    LOCALES.find(locale => locale.names.includes(name)) ??
16    LOCALES.find(locale => locale.names.includes(name.split('-')[0] ?? ''))
17  )
18}
19
20// `auto` follows Claude Code's own `language` setting, then $LANG, then English.
21export const pickLocale = (choice: string, setting: unknown, lang: string | undefined): Locale =>
22  (choice !== 'auto' ? byName(choice) : undefined) ??
23  byName(typeof setting === 'string' ? setting : undefined) ??
24  byName(lang) ??
25  en
26
27export const say = (locale: Locale, text: Text, detail = '') => locale.strings[text].replace('{detail}', detail)
28
hooks/memory.ts 38 lines
1import { isMorning } from './feelings'
2
3// What the pet remembers of the person across sessions, kept in `$.store`.
4export type Visit = { metAt: number; lastSeenAt: number }
5
6// How a session opens, at most one: the first that applies wins.
7export type Welcome = { kind: 'anniversary'; days: number } | { kind: 'missedYou' } | { kind: 'goodMorning' }
8
9const DAY_MS = 24 * 60 * 60_000
10const MILESTONES = [7, 30, 100]
11const MISSED_DAYS = 2
12
13const midnight = (time: number) => new Date(time).setHours(0, 0, 0, 0)
14
15// Calendar days from `from` to `to`, in local time.
16export const daysBetween = (from: number, to: number) => Math.round((midnight(to) - midnight(from)) / DAY_MS)
17
18export const isAnniversary = (days: number) => MILESTONES.includes(days) || (days > 0 && days % 365 === 0)
19
20export const welcome = (visit: Visit | undefined, now: number, hour: number): Welcome | undefined => {
21  if (visit !== undefined) {
22    const away = daysBetween(visit.lastSeenAt, now)
23    // Seen already today: the day's welcome is spent.
24    if (away === 0) return undefined
25    const age = daysBetween(visit.metAt, now)
26    if (isAnniversary(age)) return { kind: 'anniversary', days: age }
27    if (away >= MISSED_DAYS) return { kind: 'missedYou' }
28  }
29  return isMorning(hour) ? { kind: 'goodMorning' } : undefined
30}
31
32// The store holds what anyone could have written: only a well-formed visit counts.
33export const asVisit = (value: unknown): Visit | undefined => {
34  if (typeof value !== 'object' || value === null) return undefined
35  const { metAt, lastSeenAt } = value as Record<string, unknown>
36  return typeof metAt === 'number' && typeof lastSeenAt === 'number' ? { metAt, lastSeenAt } : undefined
37}
38
hooks/motion.ts 95 lines
1import type { Frame, Mood } from '../types'
2import type { Pack } from './pack'
3
4// What the main pet is playing. Kept between draws so a loop runs from its own
5// start and a one-off finishes before the loop picks up again.
6export type Motion = {
7  mood: Mood
8  loop: Frame[]
9  // The tick the mood started on, before its transition.
10  began: number
11  // The tick the loop's first frame plays on.
12  since: number
13  // A transition or an action, played once from `start`; an action that startles
14  // the agents does so from its frame `startles` on.
15  once?: { frames: Frame[]; start: number; isAction: boolean; startles?: number }
16  // The tick from which each action may play again.
17  next: Record<string, number>
18}
19
20// A number in [0, 1): Math.random in the plugin, a fixed sequence in tests.
21export type Random = () => number
22
23// Moods of a turn at work: a burst of tools would flick between them every call.
24const WORK: ReadonlySet<Mood> = new Set(['thinking', 'typing', 'running', 'writing', 'reading', 'searching', 'supervising'])
25// How long a work mood shows before another work mood may take over.
26const DWELL_SECONDS = 1
27// An action this close to its end finishes before the mood changes.
28const FOLLOW_THROUGH_TICKS = 2
29
30const ticks = (seconds: number, fps: number) => Math.max(1, Math.round(seconds * fps))
31
32const someTime = ([min, max]: [number, number], fps: number, random: Random) => ticks(min + (max - min) * random(), fps)
33
34export const transitionFor = (pack: Pack, from: Mood, to: Mood) =>
35  pack.transitions[`${from}>${to}`] ?? pack.transitions[`${from}>*`] ?? pack.transitions[`*>${to}`]
36
37const start = (pack: Pack, previous: Motion | undefined, mood: Mood, tick: number, random: Random): Motion => {
38  const loops = [pack.moods[mood], ...pack.variants[mood]]
39  const loop = loops[Math.floor(random() * loops.length)] ?? pack.moods[mood]
40  const transition = previous === undefined ? undefined : transitionFor(pack, previous.mood, mood)
41  const next: Record<string, number> = {}
42  for (const action of pack.actions) {
43    if (action.moods.includes(mood)) next[action.name] = tick + someTime(action.every, pack.fps, random)
44  }
45  return {
46    mood,
47    loop,
48    began: tick,
49    since: tick + (transition?.length ?? 0),
50    once: transition === undefined ? undefined : { frames: transition, start: tick, isAction: false },
51    next,
52  }
53}
54
55// Whether it keeps playing `motion` on `tick` though the mood is now `mood`: a work
56// mood shows a moment before the next one, and an action about to end finishes.
57// Anything else, a reaction or sleep, takes over at once.
58const holds = (pack: Pack, motion: Motion, mood: Mood, tick: number) => {
59  if (WORK.has(motion.mood) && WORK.has(mood) && tick - motion.began < ticks(DWELL_SECONDS, pack.fps)) return true
60  const left = motion.once === undefined ? 0 : motion.once.start + motion.once.frames.length - tick
61  return motion.once?.isAction === true && left > 0 && left <= FOLLOW_THROUGH_TICKS
62}
63
64// The frame to draw on `tick`, and how many ticks ago it began to startle the
65// agents, if it does. Safe to call more than once per tick: the same tick gives
66// the same frame.
67export const step = (pack: Pack, previous: Motion | undefined, mood: Mood, tick: number, random: Random) => {
68  let motion =
69    previous !== undefined && (previous.mood === mood || holds(pack, previous, mood, tick))
70      ? previous
71      : start(pack, previous, mood, tick, random)
72
73  if (motion.once !== undefined) {
74    const at = tick - motion.once.start
75    const frame = motion.once.frames[at]
76    const { startles } = motion.once
77    const startled = startles !== undefined && at >= startles ? at - startles : undefined
78    if (frame !== undefined) return { motion, frame, startled }
79    motion = { ...motion, once: undefined }
80  }
81
82  // A mood on its way out starts no new action.
83  const isLeaving = motion.mood !== mood
84  const due = isLeaving ? undefined : pack.actions.find(action => (motion.next[action.name] ?? Infinity) <= tick)
85  if (due !== undefined) {
86    const again = tick + due.frames.length + someTime(due.every, pack.fps, random)
87    const once = { frames: due.frames, start: tick, isAction: true, startles: due.startles }
88    motion = { ...motion, once, next: { ...motion.next, [due.name]: again } }
89    return { motion, frame: due.frames[0] ?? [], startled: due.startles === 0 ? 0 : undefined }
90  }
91
92  const at = (((tick - motion.since) % motion.loop.length) + motion.loop.length) % motion.loop.length
93  return { motion, frame: motion.loop[at] ?? [], startled: undefined }
94}
95
hooks/pack.ts 328 lines
1import type { Frame, MiniMood, Mood, PackFile, Situation, Traits } from '../types'
2import { USUAL } from './drives'
3import { hexColor } from './render'
4import { SITUATIONS } from './speech'
5import type { Colors } from './render'
6
7// Each mood a pack leaves out borrows from its parent, so an old or small pack
8// still shows something close. `sleeping` is the root every pack must draw.
9export const PARENT: Record<Mood, Mood | null> = {
10  sleeping: null,
11  deepSleep: 'sleeping',
12  idle: 'thinking',
13  sleepy: 'idle',
14  tired: 'sleepy',
15  walking: 'idle',
16  watching: 'reading',
17  thinking: 'sleeping',
18  waking: 'thinking',
19  typing: 'thinking',
20  running: 'typing',
21  writing: 'typing',
22  reading: 'thinking',
23  searching: 'reading',
24  supervising: 'thinking',
25  compacting: 'thinking',
26  sweating: 'thinking',
27  worried: 'sweating',
28  grumpy: 'sad',
29  proud: 'happy',
30  happy: 'sleeping',
31  celebrating: 'happy',
32  sad: 'sleeping',
33}
34
35export const MOODS = Object.keys(PARENT) as Mood[]
36
37export const MINI_MOODS: readonly MiniMood[] = ['working', 'happy', 'sad', 'startled']
38
39export const DEFAULT_PET = 'cat'
40
41const LIMITS = {
42  main: { columns: 48, pixelRows: 32 },
43  mini: { columns: 12, pixelRows: 12 },
44  framesPerMood: 16,
45  variantsPerMood: 4,
46  transitions: 32,
47  actions: 16,
48  everySeconds: { min: 1, max: 600 },
49  linesPerSituation: 8,
50  lineLength: 40,
51  paletteSize: 16,
52  fps: { min: 1, max: 12 },
53}
54
55export type Action = { name: string; frames: Frame[]; moods: Mood[]; every: [number, number]; startles?: number }
56
57export type Pack = {
58  name: string
59  colors: Colors
60  fps: number
61  // The loop each mood plays, borrowed from a parent when the pack leaves it out.
62  moods: Record<Mood, Frame[]>
63  // More loops for a mood, following the same borrowing as `moods`.
64  variants: Record<Mood, Frame[][]>
65  transitions: Record<string, Frame[]>
66  actions: Action[]
67  // null when the pack draws no mini pets: its agents show only as its own `supervising`.
68  mini: Record<MiniMood, Frame[]> | null
69  tint: string
70  // Only a pack that draws `walking` leaves its spot by walking; one that draws
71  // `teleport` and no `walking` gets about by vanishing and appearing.
72  walks: boolean
73  teleport: { vanish: Frame[]; appear: Frame[] } | null
74  speech: Record<string, Partial<Record<Situation, string[]>>>
75  personality: Traits
76}
77
78const isRecord = (value: unknown): value is Record<string, unknown> =>
79  typeof value === 'object' && value !== null && !Array.isArray(value)
80
81const checkFrames = (frames: unknown, where: string, limit: { columns: number; pixelRows: number }): Frame[] => {
82  if (!Array.isArray(frames) || frames.length === 0) throw new Error(`${where}: needs at least one frame`)
83  if (frames.length > LIMITS.framesPerMood) throw new Error(`${where}: at most ${LIMITS.framesPerMood} frames`)
84  return frames.map((frame, i) => {
85    if (!Array.isArray(frame) || frame.length === 0 || !frame.every(row => typeof row === 'string')) {
86      throw new Error(`${where}[${i}]: a frame is a list of strings`)
87    }
88    const rows = frame as string[]
89    const width = rows[0]?.length ?? 0
90    if (width === 0 || rows.some(row => row.length !== width)) throw new Error(`${where}[${i}]: every row needs the same length`)
91    if (width > limit.columns || rows.length > limit.pixelRows) {
92      throw new Error(`${where}[${i}]: at most ${limit.columns}x${limit.pixelRows} pixels`)
93    }
94    return rows
95  })
96}
97
98const sameSize = (frames: Frame[], where: string) => {
99  const [first] = frames
100  if (frames.some(f => f.length !== first?.length || f[0]?.length !== first?.[0]?.length)) {
101    throw new Error(`${where}: every frame needs the same size`)
102  }
103}
104
105const isMood = (value: unknown): value is Mood => typeof value === 'string' && MOODS.includes(value as Mood)
106
107// Variants hang off a mood the pack draws: one on a borrowed mood would never show.
108const parseVariants = (raw: unknown, drawn: Map<Mood, Frame[]>) => {
109  const out = new Map<Mood, Frame[][]>()
110  if (raw === undefined) return out
111  if (!isRecord(raw)) throw new Error('main.variants: an object of mood to loops')
112  for (const [mood, loops] of Object.entries(raw)) {
113    if (!isMood(mood)) throw new Error(`main.variants: unknown mood ${mood}`)
114    if (!drawn.has(mood)) throw new Error(`main.variants.${mood}: draw main.moods.${mood} first`)
115    if (!Array.isArray(loops) || loops.length === 0 || loops.length > LIMITS.variantsPerMood) {
116      throw new Error(`main.variants.${mood}: 1 to ${LIMITS.variantsPerMood} loops`)
117    }
118    out.set(mood, loops.map((loop, i) => checkFrames(loop, `main.variants.${mood}[${i}]`, LIMITS.main)))
119  }
120  return out
121}
122
123const TRANSITION = /^(\*|[a-zA-Z]+)>(\*|[a-zA-Z]+)$/
124
125const parseTransitions = (raw: unknown) => {
126  const out: Record<string, Frame[]> = {}
127  if (raw === undefined) return out
128  if (!isRecord(raw)) throw new Error('main.transitions: an object of "from>to" to frames')
129  const entries = Object.entries(raw)
130  if (entries.length > LIMITS.transitions) throw new Error(`main.transitions: at most ${LIMITS.transitions}`)
131  for (const [key, frames] of entries) {
132    const [, from, to] = TRANSITION.exec(key) ?? []
133    const known = (side: string | undefined) => side === '*' || isMood(side)
134    if (!known(from) || !known(to) || key === '*>*') {
135      throw new Error(`main.transitions: "${key}" must be "from>to" with moods or one "*"`)
136    }
137    out[key] = checkFrames(frames, `main.transitions.${key}`, LIMITS.main)
138  }
139  return out
140}
141
142const ACTION_NAME = /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/
143
144const parseActions = (raw: unknown): Action[] => {
145  if (raw === undefined) return []
146  if (!isRecord(raw)) throw new Error('main.actions: an object of name to action')
147  const entries = Object.entries(raw)
148  if (entries.length > LIMITS.actions) throw new Error(`main.actions: at most ${LIMITS.actions}`)
149  return entries.map(([name, action]) => {
150    const where = `main.actions.${name}`
151    if (!ACTION_NAME.test(name)) throw new Error(`${where}: name with letters, digits, - or _ (up to 32)`)
152    if (!isRecord(action)) throw new Error(`${where}: needs frames, moods and every`)
153    const { moods, every } = action
154    if (!Array.isArray(moods) || moods.length === 0 || !moods.every(isMood)) throw new Error(`${where}.moods: a list of moods`)
155    const { min, max } = LIMITS.everySeconds
156    if (
157      !Array.isArray(every) ||
158      every.length !== 2 ||
159      !every.every(n => typeof n === 'number' && n >= min && n <= max) ||
160      every[0] > every[1]
161    ) {
162      throw new Error(`${where}.every: [min, max] seconds, ${min} to ${max}`)
163    }
164    const frames = checkFrames(action.frames, `${where}.frames`, LIMITS.main)
165    const { startles } = action
166    if (startles === undefined) return { name, frames, moods, every: [every[0], every[1]] }
167    if (typeof startles !== 'number' || !Number.isInteger(startles) || startles < 0 || startles >= frames.length) {
168      throw new Error(`${where}.startles: one of its frames, 0 to ${frames.length - 1}`)
169    }
170    return { name, frames, moods, every: [every[0], every[1]], startles }
171  })
172}
173
174const parseTeleport = (raw: unknown) => {
175  if (raw === undefined) return null
176  if (!isRecord(raw)) throw new Error('main.teleport: an object with vanish and appear frames')
177  return {
178    vanish: checkFrames(raw.vanish, 'main.teleport.vanish', LIMITS.main),
179    appear: checkFrames(raw.appear, 'main.teleport.appear', LIMITS.main),
180  }
181}
182
183// Each trait a number from 0 to 1; one left out is the usual.
184const parsePersonality = (raw: unknown): Traits => {
185  if (raw === undefined) return USUAL
186  if (!isRecord(raw)) throw new Error('personality: an object of trait to a number from 0 to 1')
187  const traits = { ...USUAL }
188  for (const [trait, value] of Object.entries(raw)) {
189    if (!Object.hasOwn(USUAL, trait)) throw new Error(`personality.${trait}: unknown trait, use ${Object.keys(USUAL).join(', ')}`)
190    if (typeof value !== 'number' || !(value >= 0 && value <= 1)) throw new Error(`personality.${trait}: a number from 0 to 1`)
191    traits[trait as keyof Traits] = value
192  }
193  return traits
194}
195
196const LANGUAGE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/
197
198// Lines are shown as they are: plain one-line text, short enough for the band.
199const parseSpeech = (raw: unknown) => {
200  const out: Record<string, Partial<Record<Situation, string[]>>> = {}
201  if (raw === undefined) return out
202  if (!isRecord(raw)) throw new Error('speech: an object of language to situations')
203  for (const [code, situations] of Object.entries(raw)) {
204    if (!LANGUAGE.test(code)) throw new Error(`speech: "${code}" is not a language code like "en" or "pt-BR"`)
205    if (!isRecord(situations)) throw new Error(`speech.${code}: an object of situation to lines`)
206    const lines: Partial<Record<Situation, string[]>> = {}
207    for (const [situation, list] of Object.entries(situations)) {
208      const where = `speech.${code}.${situation}`
209      if (!SITUATIONS.includes(situation as Situation)) throw new Error(`${where}: unknown situation, use ${SITUATIONS.join(', ')}`)
210      const { linesPerSituation: most, lineLength: longest } = LIMITS
211      if (
212        !Array.isArray(list) ||
213        list.length === 0 ||
214        list.length > most ||
215        !list.every(line => typeof line === 'string' && line.trim() !== '' && line.length <= longest && !/[\u0000-\u001f\u007f]/.test(line))
216      ) {
217        throw new Error(`${where}: 1 to ${most} lines of one line each, up to ${longest} characters`)
218      }
219      lines[situation as Situation] = list
220    }
221    out[code] = lines
222  }
223  return out
224}
225
226// Packs come from anyone, so everything is checked before it reaches the renderer.
227export const parsePack = (raw: unknown): Pack => {
228  if (!isRecord(raw)) throw new Error('a pack is a JSON object')
229  const file = raw as Partial<PackFile>
230
231  if (typeof file.name !== 'string' || file.name === '') throw new Error('name: required')
232
233  if (!isRecord(file.palette)) throw new Error('palette: required')
234  const entries = Object.entries(file.palette)
235  if (entries.length > LIMITS.paletteSize) throw new Error(`palette: at most ${LIMITS.paletteSize} colors`)
236  const colors: Colors = {}
237  for (const [letter, hex] of entries) {
238    if (letter.length !== 1 || letter === '.') throw new Error(`palette: "${letter}" must be one character other than "."`)
239    if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) throw new Error(`palette.${letter}: use #rrggbb`)
240    colors[letter] = hexColor(hex)
241  }
242
243  const fps = file.fps ?? 4
244  if (typeof fps !== 'number' || fps < LIMITS.fps.min || fps > LIMITS.fps.max) {
245    throw new Error(`fps: between ${LIMITS.fps.min} and ${LIMITS.fps.max}`)
246  }
247
248  if (!isRecord(file.main) || !isRecord(file.main.moods)) throw new Error('main.moods: required')
249  const given = file.main.moods as Record<string, unknown>
250  if (given.sleeping === undefined) throw new Error('main.moods.sleeping: required, other moods fall back to it')
251  const unknown = Object.keys(given).filter(key => !MOODS.includes(key as Mood))
252  if (unknown.length > 0) throw new Error(`main.moods: unknown mood ${unknown.join(', ')}`)
253
254  const drawn = new Map<Mood, Frame[]>()
255  for (const mood of MOODS) {
256    if (given[mood] !== undefined) drawn.set(mood, checkFrames(given[mood], `main.moods.${mood}`, LIMITS.main))
257  }
258  const extra = parseVariants(file.main.variants, drawn)
259  const moods = {} as Record<Mood, Frame[]>
260  const variants = {} as Record<Mood, Frame[][]>
261  for (const mood of MOODS) {
262    let source: Mood | null = mood
263    while (source !== null && !drawn.has(source)) source = PARENT[source]
264    moods[mood] = drawn.get(source ?? 'sleeping') ?? []
265    variants[mood] = extra.get(source ?? 'sleeping') ?? []
266  }
267  const transitions = parseTransitions(file.main.transitions)
268  const actions = parseActions(file.main.actions)
269  const teleport = parseTeleport(file.main.teleport)
270  sameSize(
271    [
272      ...drawn.values(),
273      ...[...extra.values()].flat(),
274      ...Object.values(transitions),
275      ...actions.map(a => a.frames),
276      ...(teleport === null ? [] : [teleport.vanish, teleport.appear]),
277    ].flat(),
278    'main',
279  )
280
281  const { mini, tint } = file.mini === false ? { mini: null, tint: 'b' } : parseMini(file.mini, colors)
282
283  const speech = parseSpeech(file.speech)
284  const personality = parsePersonality(file.personality)
285
286  return {
287    name: file.name,
288    colors,
289    fps,
290    moods,
291    variants,
292    transitions,
293    actions,
294    mini,
295    tint,
296    walks: drawn.has('walking'),
297    teleport,
298    speech,
299    personality,
300  }
301}
302
303const parseMini = (raw: unknown, colors: Colors) => {
304  if (!isRecord(raw) || !isRecord(raw.moods)) throw new Error('mini.moods: required, or mini: false for no mini pets')
305  const miniGiven = raw.moods as Record<string, unknown>
306  if (miniGiven.working === undefined) throw new Error('mini.moods.working: required, happy, sad and startled fall back to it')
307  const miniUnknown = Object.keys(miniGiven).filter(key => !MINI_MOODS.includes(key as MiniMood))
308  if (miniUnknown.length > 0) throw new Error(`mini.moods: unknown mood ${miniUnknown.join(', ')}`)
309  const working = checkFrames(miniGiven.working, 'mini.moods.working', LIMITS.mini)
310  const mini = { working } as Record<MiniMood, Frame[]>
311  for (const mood of MINI_MOODS) {
312    mini[mood] = miniGiven[mood] === undefined ? working : checkFrames(miniGiven[mood], `mini.moods.${mood}`, LIMITS.mini)
313  }
314  sameSize(MINI_MOODS.flatMap(mood => mini[mood]), 'mini')
315  const tint = raw.tint ?? 'b'
316  if (typeof tint !== 'string' || colors[tint] === undefined) throw new Error('mini.tint: must be a palette letter')
317  return { mini, tint }
318}
319
320const SAFE_NAME = /^[a-z0-9][a-z0-9_-]{0,40}$/
321
322// Where a pet named `name` may live, the person's own pets folder first.
323// Undefined for a name that could step outside those folders.
324export const packPaths = (name: string, home: string | undefined, root: string) =>
325  SAFE_NAME.test(name)
326    ? [...(home ? [`${home}/.claude/pets/${name}.json`] : []), `${root}/pets/${name}.json`]
327    : undefined
328
hooks/render.ts 35 lines
1import type { Frame } from '../types'
2
3const NONE = 0x01000000
4const HALF_TOP = 0x2580
5const HALF_BOTTOM = 0x2584
6
7export type Colors = Record<string, number>
8
9export const hexColor = (hex: string) => Number.parseInt(hex.slice(1), 16)
10
11export const sizeOf = (frame: Frame) => ({ columns: frame[0]?.length ?? 0, rows: Math.ceil(frame.length / 2) })
12
13// Two pixel rows share one terminal cell: '▀' takes the top as foreground
14// and the bottom as background. Letters missing from the palette are see-through.
15export const encode = (frame: Frame, colors: Colors) => {
16  const words: number[] = []
17  for (let r = 0; r < frame.length; r += 2) {
18    const top = frame[r] ?? ''
19    const bottom = frame[r + 1] ?? ''
20    for (let c = 0; c < top.length; c++) {
21      const up = colors[top[c] ?? '.']
22      const down = colors[bottom[c] ?? '.']
23      if (up === undefined && down === undefined) words.push(0x20, NONE, NONE)
24      else if (up === undefined) words.push(HALF_BOTTOM, down ?? NONE, NONE)
25      else words.push(HALF_TOP, up, down ?? NONE)
26    }
27  }
28  // The engine's runtime has TC39 base64; TypeScript's es2023 lib does not type it.
29  const bytes = new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }
30  return bytes.toBase64()
31}
32
33// The same frame facing the other way. Packs draw their pet facing right.
34export const mirror = (frame: Frame): Frame => frame.map(row => [...row].reverse().join(''))
35
hooks/speech.ts 37 lines
1import type { Situation } from '../types'
2import type { Random } from './motion'
3
4export const SITUATIONS: readonly Situation[] = ['longThink', 'manyReads', 'manyAgents', 'lateNight', 'bored', 'dreaming']
5
6// What the pet said lately: speech stays rare, at most once a turn for each
7// situation and never twice within a few minutes.
8export type Speaker = { spokeAt: number | null; saidThisTurn: readonly Situation[] }
9
10export const quiet: Speaker = { spokeAt: null, saidThisTurn: [] }
11
12const COOLDOWN_MS = 3 * 60_000
13// Thinking with no tool this long is a long think.
14export const LONG_THINK_MS = 30_000
15export const MANY_READS = 20
16export const MANY_AGENTS = 3
17
18export const maySpeak = (speaker: Speaker, situation: Situation, now: number) =>
19  (speaker.spokeAt === null || now - speaker.spokeAt >= COOLDOWN_MS) && !speaker.saidThisTurn.includes(situation)
20
21export const spoke = (speaker: Speaker, situation: Situation, now: number): Speaker => ({
22  spokeAt: now,
23  saidThisTurn: [...speaker.saidThisTurn, situation],
24})
25
26// The pack's own line for this language, picked at random, or the locale's.
27export const lineFor = (
28  lines: Record<string, Partial<Record<Situation, string[]>>>,
29  code: string,
30  situation: Situation,
31  fallback: string,
32  random: Random,
33) => {
34  const own = lines[code]?.[situation] ?? []
35  return own[Math.floor(random() * own.length)] ?? fallback
36}
37
hooks/walk.ts 244 lines
1import type { Random } from './motion'
2
3// Where the main pet stands on the stage, in columns from its left edge, and
4// where it is heading. Kept between draws like Motion.
5export type Walk = {
6  x: number
7  target: number
8  facing: 1 | -1
9  // While wandering, the tick before which it stays put after arriving.
10  restUntil: number
11  // The tick this walk was last stepped on: a second draw on it changes nothing.
12  tick: number
13  // Whether it took a step on that tick, so a second draw shows it walking too.
14  moving: boolean
15  // Where a stroll it broke halfway goes on to, after a short look around.
16  onward?: number
17}
18
19// wander: stroll to a random spot now and then. stay: hold still. go: hurry to a spot.
20export type Plan = 'wander' | 'stay' | { go: number }
21
22const COLUMNS_PER_TICK = 1
23const HURRY_COLUMNS_PER_TICK = 2
24const REST_SECONDS: [number, number] = [3, 10]
25// A stroll this long breaks halfway now and then, for a look around this long.
26const BREAK_FROM_COLUMNS = 12
27const BREAK_CHANCE = 0.3
28const LOOK_SECONDS: [number, number] = [1, 2]
29
30// How it strolls: `rest` stretches the pauses, `lean` (0 to 1) draws its spots
31// toward the left, where the prompt starts, and `stride` sets the columns a tick
32// in place of the usual walk: Infinity lands it there at once, as a teleport.
33export type Pace = { rest: number; lean: number; stride?: number }
34
35const EVEN: Pace = { rest: 1, lean: 0 }
36
37export const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), Math.max(max, 0))
38
39// Moves the pet one tick along `plan`; `moving` is true when it took a step.
40export const walkStep = (
41  walk: Walk | undefined,
42  plan: Plan,
43  tick: number,
44  maxX: number,
45  fps: number,
46  random: Random,
47  pace: Pace = EVEN,
48): { walk: Walk; moving: boolean } => {
49  if (walk === undefined) return { walk: { x: 0, target: 0, facing: 1, restUntil: tick, tick, moving: false }, moving: false }
50  const x = clamp(walk.x, maxX)
51  // A second draw on this tick only keeps it inside a stage that shrank since.
52  if (walk.tick === tick) return { walk: { ...walk, x }, moving: walk.moving }
53
54  const pause = ([min, max]: [number, number], scale: number) => tick + Math.round((min + (max - min) * random()) * scale * fps)
55  let { target, facing, restUntil, onward } = walk
56  target = clamp(target, maxX)
57
58  if (plan !== 'wander') onward = undefined
59  if (plan === 'stay') target = x
60  else if (typeof plan === 'object') target = clamp(plan.go, maxX)
61  else if (x === target && tick >= restUntil) {
62    if (onward !== undefined) {
63      target = clamp(onward, maxX)
64      onward = undefined
65    } else {
66      target = Math.round(random() ** (1 + 2 * pace.lean) * maxX)
67      const breaks = pace.stride === undefined && Math.abs(target - x) >= BREAK_FROM_COLUMNS
68      if (breaks && random() < BREAK_CHANCE) {
69        onward = target
70        target = Math.round((x + target) / 2)
71      }
72    }
73    if (target === x) restUntil = pause(REST_SECONDS, pace.rest)
74  }
75
76  if (x === target) return { walk: { x, target, facing, restUntil, tick, moving: false, onward }, moving: false }
77
78  const direction = target > x ? 1 : -1
79  const speed = pace.stride ?? (typeof plan === 'object' ? HURRY_COLUMNS_PER_TICK : COLUMNS_PER_TICK)
80  const next = x + direction * Math.min(speed, Math.abs(target - x))
81  if (next === target && plan === 'wander') restUntil = onward === undefined ? pause(REST_SECONDS, pace.rest) : pause(LOOK_SECONDS, 1)
82  return { walk: { x: next, target, facing: direction, restUntil, tick, moving: true, onward }, moving: true }
83}
84
85export type Side = 1 | -1
86
87// Where the subagents' pets stand around the main one, by their index: each
88// side's list runs from nearest to farthest. `more` is the side of the "+N".
89export type Gathering = { x: number; left: number[]; right: number[]; more: Side }
90
91// Gathers `count` agents, `width` columns each, plus `extra` columns for a "+N",
92// in the free room on both sides of a pet standing at `at`: each on the side it
93// stood on before (`sides[i]`) while it fits there, else on the side with more
94// room left. undefined when they do not all fit there.
95export const layoutAt = (
96  at: number,
97  room: number,
98  count: number,
99  width: number,
100  extra: number,
101  sides: readonly (Side | undefined)[] = [],
102): Gathering | undefined => {
103  const free = { [-1]: at, [1]: room - at }
104  const take = (need: number, wanted?: Side): Side | undefined => {
105    const first: Side = wanted ?? (free[1] >= free[-1] ? 1 : -1)
106    const side = ([first, -first] as Side[]).find(one => free[one] >= need)
107    if (side !== undefined) free[side] -= need
108    return side
109  }
110  const out: Gathering = { x: at, left: [], right: [], more: 1 }
111  for (let i = 0; i < count; i++) {
112    const side = take(width, sides[i])
113    if (side === undefined) return undefined
114    ;(side === 1 ? out.right : out.left).push(i)
115  }
116  if (extra > 0) {
117    const side = take(extra)
118    if (side === undefined) return undefined
119    out.more = side
120  }
121  return out
122}
123
124// Gathers them as `layoutAt` does, where the pet stands; when they do not fit
125// there, at the nearest spot where they do.
126export const gather = (
127  x: number,
128  room: number,
129  count: number,
130  width: number,
131  extra: number,
132  sides: readonly (Side | undefined)[] = [],
133): Gathering => {
134  for (let step = 0; step <= Math.max(room, 0); step++) {
135    for (const at of [x - step, x + step]) {
136      const found = at >= 0 && at <= room ? layoutAt(at, room, count, width, extra, sides) : undefined
137      if (found !== undefined) return found
138    }
139  }
140  return { x, left: [], right: [], more: 1 }
141}
142
143// The side agent `index` stands on.
144export const sideIn = (drawn: Gathering, index: number): Side => (drawn.right.includes(index) ? 1 : -1)
145
146// Which way it faces: the way it walks, else toward the drawn agent `at`.
147export const faceFor = (moving: boolean, facing: Side, drawn: Gathering, at: number): Side =>
148  moving || drawn.left.length + drawn.right.length === 0 ? facing : sideIn(drawn, at)
149
150// The way it faced as the action playing now began.
151export type Held = { start: number; side: Side } | undefined
152
153// While an action plays it keeps the way it faced as the action began, so a whip
154// raised at one side cracks there though an agent comes or goes meanwhile; with
155// no action, or on the move, it faces `side`, so it never walks backwards.
156export const holdFacing = (held: Held, action: { start: number } | undefined, side: Side, moving: boolean) => {
157  if (action === undefined || moving) return { held: undefined, side }
158  if (held?.start === action.start) return { held, side: held.side }
159  return { held: { start: action.start, side }, side }
160}
161
162// Who shows while the pet is on its way to `placed`: all of them once it stands
163// there, else as many as already fit around `x`, with no room kept for a "+N".
164export const onTheWay = (
165  x: number,
166  placed: Gathering,
167  room: number,
168  count: number,
169  width: number,
170  sides: readonly (Side | undefined)[] = [],
171): Gathering => {
172  if (x === placed.x) return placed
173  // All of them may fit already when only the "+N" sent it off.
174  for (let shown = count; shown > 0; shown--) {
175    const found = layoutAt(x, room, shown, width, 0, sides)
176    if (found !== undefined) return found
177  }
178  return { x, left: [], right: [], more: 1 }
179}
180
181// What the band keeps between draws about the main pet's spot: where it stands,
182// the teleport it is in the middle of, and the side each agent stood on.
183export type Stage = { walk?: Walk; blink?: { from: number; start: number }; sides: ReadonlyMap<string, Side> }
184
185export const EMPTY_STAGE: Stage = { sides: new Map() }
186
187type Scene = {
188  tick: number
189  room: number
190  fps: number
191  random: Random
192  pace: Pace
193  walks: boolean
194  // The frames of its teleport, as counts; null for a pet that walks or draws none.
195  teleport: { vanish: number; appear: number } | null
196  // The agents to stand around it, by id, `width` columns each, plus `extra` for a "+N".
197  agents: readonly string[]
198  width: number
199  extra: number
200  // What it wants this tick, given where the agents would gather.
201  wants: (placed: Gathering) => Plan
202}
203
204// Moves the main pet one tick and lays its agents out around it, as the band
205// draws them: it walks, or teleports, or is set down where they fit, and the
206// agents that do not fit yet join once it gets there. One teleport plays out
207// before it goes anywhere else; `blinking` is how far into it this tick is.
208export const moveOnStage = (stage: Stage, scene: Scene) => {
209  const { tick, room, teleport, agents } = scene
210  const sides = agents.map(id => stage.sides.get(id))
211  const from = clamp(stage.walk?.x ?? 0, room)
212  const placed = gather(from, room, agents.length, scene.width, scene.extra, sides)
213  const wants = scene.wants(placed)
214  const moves = scene.walks || teleport !== null
215  const pace = teleport === null ? scene.pace : { ...scene.pace, stride: Infinity }
216  const walked = walkStep(stage.walk, moves && stage.blink === undefined ? wants : 'stay', tick, room, scene.fps, scene.random, pace)
217  // A pet that does neither is set down where they fit.
218  const walk = moves || placed.x === walked.walk.x ? walked.walk : { ...walked.walk, x: placed.x, target: placed.x }
219
220  let blink = stage.blink ?? (teleport !== null && walked.moving ? { from, start: tick } : undefined)
221  // Where it shows: where it vanishes from until it is gone, then where it lands.
222  let standX = walk.x
223  let blinking: { phase: 'vanish' | 'appear'; at: number } | undefined
224  if (teleport !== null && blink !== undefined) {
225    const at = tick - blink.start
226    if (at < teleport.vanish) {
227      blinking = { phase: 'vanish', at }
228      standX = clamp(blink.from, room)
229    } else if (at < teleport.vanish + teleport.appear) blinking = { phase: 'appear', at: at - teleport.vanish }
230    else blink = undefined
231  }
232
233  const drawn = onTheWay(standX, placed, room, agents.length, scene.width, sides)
234  const drawnCount = drawn.left.length + drawn.right.length
235  // One not drawn yet keeps the side it had, so it does not hop over once it shows.
236  const kept = new Map(
237    agents.flatMap((id, i): [string, Side][] => {
238      const side = i < drawnCount ? sideIn(drawn, i) : stage.sides.get(id)
239      return side === undefined ? [] : [[id, side]]
240    }),
241  )
242  return { stage: { walk, blink, sides: kept }, wants, placed, drawn, drawnCount, standX, moving: walked.moving, blinking }
243}
244
locales/en.ts 64 lines
1import type { Text } from '../types'
2
3export type Locale = { code: string; names: string[]; strings: Record<Text, string> }
4
5// The reference locale: every other locale must give each of these strings.
6// `{detail}` is filled in at run time and must be kept as written.
7export const en: Locale = {
8  code: 'en',
9  // How Claude Code's `language` setting or $LANG may spell this locale.
10  names: ['en', 'english'],
11  strings: {
12    sleeping: 'sleeping',
13    thinking: 'thinking',
14    running: 'running {detail}',
15    writing: 'writing {detail}',
16    reading: 'reading {detail}',
17    searching: 'searching ({detail})',
18    using: 'using {detail}',
19    waitingForAgents: 'waiting for agents',
20    done: 'done!',
21    wentWrong: 'something went wrong',
22    failed: 'failed: {detail}',
23    wakingUp: 'waking up',
24    deepSleep: 'fast asleep',
25    thanks: 'thanks!',
26    sleepy: 'sleepy',
27    tired: 'tired',
28    worried: 'a bit worried',
29    grumpy: 'grumpy',
30    proud: 'proud of us',
31    goodMorning: 'good morning!',
32    phew: 'phew, done!',
33    missedYou: 'missed you!',
34    aWeekTogether: 'a week together!',
35    aMonthTogether: 'a month together!',
36    daysTogether: '{detail} days together!',
37    aYearTogether: 'a year together!',
38    yearsTogether: '{detail} years together!',
39    justMet: 'we just met',
40    togetherADay: 'together for a day',
41    togetherDays: 'together for {detail} days',
42    longThink: 'hmm…',
43    manyReads: 'so many files!',
44    manyAgents: 'full team today!',
45    lateNight: "it's getting late…",
46    bored: 'nothing to do…',
47    dreaming: 'zzz… mmh…',
48    denied: "okay, I won't",
49    compacting: 'tidying up its memory',
50    idle: 'hanging around',
51    strolling: 'strolling around',
52    watching: 'watching you type',
53    commandDescription: "Show or hide the pets above the prompt, or pick this project's pet",
54    hidden: 'Pets hidden.',
55    shown: 'Pets are back.',
56    projectPet: "This project's pet: {detail}.",
57    projectPetDefault: "This project is back to your settings' pet: {detail}.",
58    projectPetFailed: "Couldn't change the pet: {detail}.",
59    invalidName: '"{detail}" is not a valid pet name',
60    notFound: 'pet "{detail}" not found',
61    fallback: 'pixel-pets: {detail}. Using the default pet.',
62  },
63}
64
locales/pt-BR.ts 59 lines
1import type { Locale } from './en'
2
3export const ptBR: Locale = {
4  code: 'pt-BR',
5  names: ['pt-br', 'pt', 'portuguese', 'português', 'portugues', 'brazilian portuguese'],
6  strings: {
7    sleeping: 'dormindo',
8    thinking: 'pensando',
9    running: 'rodando {detail}',
10    writing: 'escrevendo {detail}',
11    reading: 'lendo {detail}',
12    searching: 'procurando ({detail})',
13    using: 'usando {detail}',
14    waitingForAgents: 'esperando os agents',
15    done: 'pronto!',
16    wentWrong: 'algo deu errado',
17    failed: 'falhou: {detail}',
18    wakingUp: 'acordando',
19    deepSleep: 'dormindo pesado',
20    thanks: 'valeu!',
21    sleepy: 'com sono',
22    tired: 'cansado',
23    worried: 'meio preocupado',
24    grumpy: 'emburrado',
25    proud: 'orgulhoso da gente',
26    goodMorning: 'bom dia!',
27    phew: 'ufa, pronto!',
28    missedYou: 'senti sua falta!',
29    aWeekTogether: 'uma semana juntos!',
30    aMonthTogether: 'um mês juntos!',
31    daysTogether: '{detail} dias juntos!',
32    aYearTogether: 'um ano juntos!',
33    yearsTogether: '{detail} anos juntos!',
34    justMet: 'a gente acabou de se conhecer',
35    togetherADay: 'juntos há um dia',
36    togetherDays: 'juntos há {detail} dias',
37    longThink: 'hmm…',
38    manyReads: 'quantos arquivos!',
39    manyAgents: 'time completo hoje!',
40    lateNight: 'já tá tarde…',
41    bored: 'que tédio…',
42    dreaming: 'zzz… hmm…',
43    denied: 'tá bom, não vou',
44    compacting: 'arrumando a memória',
45    idle: 'de bobeira',
46    strolling: 'passeando',
47    watching: 'olhando você digitar',
48    commandDescription: 'Mostra ou esconde os pets acima do prompt, ou escolhe o pet deste projeto',
49    hidden: 'Pets escondidos.',
50    shown: 'Pets de volta.',
51    projectPet: 'Pet deste projeto: {detail}.',
52    projectPetDefault: 'Este projeto voltou ao pet da config: {detail}.',
53    projectPetFailed: 'Não deu para trocar o pet: {detail}.',
54    invalidName: '"{detail}" não é um nome de pet válido',
55    notFound: 'pet "{detail}" não encontrado',
56    fallback: 'pixel-pets: {detail}. Usando o pet padrão.',
57  },
58}
59