SLOPSHOPPER

pixel-pet

An animated pixel-art companion above your prompt that reacts to your session and your typing, with a live context meter.

newbandguardcommandprompttimer
v0.1.0MITupdated 2026-10-09lakmadev/claude-mods/plugins/pixel-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-pet
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pet ⎿ pixel-pet: Bit · teal ⎿ pixel-pet: 1 turns together · petted 0 times · 0 days old ⎿ pixel-pet: Click it to pet it. /pet color <name> changes its colour, /pet rename <name> its name. ▣ client module ./pet-view.tsx ▣ client module ./status-view.tsx ▣ client module ./meter-view.tsx ⓘ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./pet-view.tsx ▣ client module ./status-view.tsx ▣ client module ./meter-view.tsx ⓘ
README

pixel-pet

A tiny animated pixel pet that lives above your Claude Code prompt. It sleeps when things are quiet, narrates what Claude is doing, watches you type, hatches little helpers for subagents, and shows a live context meter beside it.

the band in the terminal and the desktop app

moods and 28 colours

What it does

  • Sleeps and wakes. A new session finds it fast asleep: eyes closed, breathing slowly, z's drifting up and a snore bubble that swells and pops. Your first keystroke or message wakes it slowly (blinks, a yawn, a little stretch), then it carries on. After a quiet minute it nods off again through a few heavy-lidded, yawning seconds.
  • Narrates Claude's work, one line per action as it starts: "is reading auth.ts…", "is editing app.ts ✏️", "is running the tests 🧪", "is committing the work ✍️", "is installing packages 📦", "is searching the web for …", "is waiting for your OK ✋", "is tidying its memory 🧹" and more. Each line stays up for at least a second. On desktop the new line slides up into place as the old one drifts away; in the terminal, where text moves only by whole rows, it fades through.
  • Hatches helpers for subagents. Each subagent Claude starts gets a mini pet half its size, in the agent's own colour (the color: in its definition) or a stable random one. Helpers line up on the meter line, left of the context bar, newest first. A new one opens its slot, smoothly pushing the rest of the line right, then springs in. Several started together arrive one by one. It bobs while its agent works; when the agent is done it waves bye, floats up and fades, and its slot closes.
  • Watches you type. Its eyes follow your caret across the line as if reading along ("is reading your code…" when it looks like code). A backspace makes it wince, deleting a big chunk makes it gasp with a "!", a huge paste gets a "whoa", and please or thanks makes it blush.
  • Click it to pet it. A heart pops where you click and it blushes.
  • Context meter on its second line: a gradient bar (mint → amber → red) that glides to each reading and shimmers while Claude works. After it come your Claude plan's 5-hour and weekly usage as small pills, with a window's reset time once it passes 70%. On the API or Bedrock/Vertex/Foundry it shows the dollars actually billed instead. Point at the meter (ⓘ) for the details: context tokens, when each window resets, and how you're billed.
  • 28 colours. /pet color pink changes it. As you type the name it tries each colour on, and the names show up as suggestions.
  • Smooth everywhere. In the terminal the pet, its helpers, the line and the meter animate on the terminal's own frame clock. In the desktop app they're transparent SVGs with the motion built in, gliding between pixels. Moods and colours fade into each other instead of popping.

Install

Requires Claude Code 2.1.293 or later: mods are an early-access feature and gain events with each release. Check with claude --version; update with claude update.

/plugin marketplace add lakmadev/claude-mods
/plugin install pixel-pet@claude-mods

Commands

Command
/petIts name, colour, turns together, pets, and age
/pet color <name>teal, red, orange, yellow, green, blue, purple, pink, white, gray, black, brown, cyan, mint, lime, sky, navy, indigo, violet, lavender, magenta, rose, peach, coral, gold, lava, ghost, midnight
/pet rename <name>Rename it (default: Bit)
/pet hide / /pet showTuck it away or bring it back

Settings (/config): name, color.

What it reads

Hook events (tool calls, prompts, turns, subagent starts) and the usage figures Claude Code reports, plus a custom agent's definition file for its colour. It reads the CLAUDE_CODE_USE_BEDROCK / _VERTEX / _FOUNDRY switches to name who bills you, never an API key. Nothing leaves your machine; its name, colour and counts are kept in Claude Code's plugin store.

Source 12 files
hooks/register.tsx 539 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { ContextMeter, Mini, Mood, PetView } from '../types'
5import { TONES, cardLines, hex, segments } from './meter'
6import type { Source } from './meter'
7import type { MeterViewProps } from './meter-view'
8import type { PetViewProps } from './pet-view'
9import { COLOR_NAMES, COLORS, DEFAULT_COLOR, colorName, petSvg } from './sprite'
10import { LINE_COLORS, actionLine, lineSvg, lineWidth } from './status'
11import { LEAVE_MS, colorFromDefinition, miniColor } from './mini'
12import { lineTwoSvg } from './line2'
13import type { Layout } from './line2'
14import type { MinisViewProps } from './minis-view'
15import type { StatusViewProps } from './status-view'
16
17// `xp` is a leftover field from when the pet had levels; old records keep it, nothing reads it.
18type Pet = { name: string; born: number; turns: number; pets: number; color?: string; xp?: number }
19
20const LINES: Record<Mood, string[]> = {
21  idle: ['is waiting for you', 'is vibing', 'is counting pixels', 'is guarding your repo', 'is humming quietly'],
22  working: ['is typing furiously…', 'is reading your code…', 'is thinking very hard…', 'is chasing a bug…', 'is consulting the docs…'],
23  happy: ['did a happy dance!', 'is proud of you!', 'says: ship it!', 'high-fives the terminal!'],
24  oops: ["saw an error. it's fine. everything's fine.", 'is sweating nervously', "says: that wasn't supposed to happen"],
25  sleepy: ['is fast asleep… zzz', 'is snoring softly', 'dreams of green tests', 'is napping on your keyboard'],
26  dozing: ['is getting sleepy…', 'yawns…', 'is fighting to stay awake'],
27  waking: ['is waking up… ☀️', 'stretches and yawns', 'rubs its eyes'],
28  loved: ['loves you back ♥', 'purrs in binary', 'is blushing in pixels'],
29  reading: ['is reading along…', 'is peeking at what you type 👀', 'is following every keystroke', 'is taking notes'],
30  wince: ['winces at the typo', 'pretends not to see that', 'says: backspace, backspace'],
31  shocked: ['gasps: where did it all go?!', 'is shocked by the delete', 'clutches its pixels'],
32}
33
34const pick = (list: readonly string[]) => list[Math.floor(Math.random() * list.length)]!
35const SLEEP_AFTER_MS = 60_000
36const METER_CELLS = 14
37
38// The configured colour, used until /pet color picks one; and the one being tried on while typing it.
39let defaultColor = DEFAULT_COLOR
40let previewColor: string | undefined
41let meter: ContextMeter = {}
42// The helpers on screen, one per running subagent, and which agent each stands for.
43let minis: Mini[] = []
44// Helpers that start together arrive one by one, this far apart.
45const HATCH_GAP_MS = 450
46let lastHatchAt = 0
47// Where the desktop's second line last put each helper and the bar, to glide from.
48let lineTwoLayout: Layout | undefined
49const miniOf = new Map<string, string>()
50
51export function viewOf(pet: Pet, mood: Mood, line: string, isHidden: boolean): PetView {
52  return { mood, line, name: pet.name, color: previewColor ?? colorName(pet.color) ?? defaultColor, isHidden, meter, minis }
53}
54
55const view = atom({ plugin: 'pixel-pet', key: 'view' } as const, viewOf({ name: 'Bit', born: 0, turns: 0, pets: 0 }, 'sleepy', 'is fast asleep… zzz', false))
56
57// Module state between hooks; the pet's record is rebuilt from the store on reload.
58let pet: Pet = { name: 'Bit', born: 0, turns: 0, pets: 0 }
59// A new session finds the pet asleep; the first keystroke wakes it.
60let mood: Mood = 'sleepy'
61let moodUntil = 0
62let isWorking = false
63let lastActive = 0
64// Where the eyes point while reading along (-1 left … 1 right), and what the band last said.
65let look = 0
66let lastLine = ''
67// What the desktop last drew, so a change of mood or colour fades from it and the meter glides.
68let shown: { mood: Mood; color: string } | undefined
69let fadeFrom: { mood: Mood; color: string; at: number } | undefined
70let drawnPercent = 0
71let shownLine: string | undefined
72let slideFrom: { line: string; at: number } | undefined
73// The desktop draws its line as SVG text, which can't follow the theme by itself.
74let theme: 'dark' | 'light' = 'dark'
75// Who bills the tokens when no plan limits come back: the API, or a cloud provider.
76let provider: Source = 'api'
77// A line stays up at least this long, so a burst of tool calls reads as a story, not a flicker.
78const HOLD_MS = 900
79let lineShownAt = 0
80let pendingLine: string | undefined
81let isLineQueued = false
82
83function baseMood(now: number): Mood {
84  if (isWorking) return 'working'
85  return now - lastActive > SLEEP_AFTER_MS ? 'sleepy' : 'idle'
86}
87
88async function setMood($: EngineInterface, next: Mood, forMs = 0) {
89  const now = await $.clock.now()
90  moodUntil = forMs ? now + forMs : 0
91  if (next === mood && !forMs) return
92  mood = next
93  lineShownAt = now
94  const line = next === 'working' ? 'is thinking… 💭' : next === 'idle' && (meter.percent ?? 0) >= 80 ? `is stuffed with tokens (${meter.percent}% context)` : pick(LINES[next])
95  await update($, view, current => viewOf(pet, next, line, current.isHidden))
96}
97
98type Reading = { context: { percent?: number; tokens?: number; window: number }; cost?: { usd: number }; rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[] }
99
100// Plan windows (5-hour, weekly) only come back on a Claude subscription; a gateway reports a spend
101// limit; with neither, once a reply has come back, the tokens are billed by the API or the provider.
102export function sourceOf(reading: Reading, fallback: Source): Source | undefined {
103  if (reading.rateLimits.some(r => r.kind === 'five_hour' || r.kind.startsWith('seven_day'))) return 'plan'
104  if (reading.rateLimits.some(r => r.kind === 'spend_limit')) return 'gateway'
105  return reading.context.percent !== undefined ? fallback : undefined
106}
107
108async function setMeter($: EngineInterface, reading: Reading) {
109  const limits = reading.rateLimits.map(r => (r.resetsAt ? { kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt } : { kind: r.kind, percent: r.percentUsed }))
110  const source = sourceOf(reading, provider) ?? meter.source
111  const next: ContextMeter = {}
112  const percent = reading.context.percent ?? meter.percent
113  const tokens = reading.context.tokens ?? meter.tokens
114  const usd = reading.cost?.usd ?? meter.usd
115  if (percent !== undefined) next.percent = percent
116  if (tokens !== undefined) next.tokens = tokens
117  if (reading.context.window || meter.window) next.window = reading.context.window || meter.window!
118  if (usd !== undefined) next.usd = usd
119  if (limits.length || meter.limits) next.limits = limits.length ? limits : meter.limits!
120  if (source) next.source = source
121  meter = next
122  await update($, view, current => ({ ...current, meter }))
123}
124
125// Show a working line, holding the current one for HOLD_MS first; only the latest waiting line
126// is kept, so a burst of calls skips straight to what's happening now.
127async function say($: EngineInterface, line: string) {
128  const now = await $.clock.now()
129  const wait = HOLD_MS - (now - lineShownAt)
130  if (wait > 0) {
131    pendingLine = line
132    if (!isLineQueued) {
133      isLineQueued = true
134      $.clock.after(wait, () => {
135        isLineQueued = false
136        const queued = pendingLine
137        pendingLine = undefined
138        if (queued && isWorking) say($, queued).catch(() => undefined)
139      })
140    }
141    return
142  }
143  lineShownAt = now
144  lastLine = line
145  await update($, view, current => (current.line === line ? current : { ...current, line }))
146}
147
148// React first, save after: the blush shouldn't wait on a disk write.
149async function petIt($: EngineInterface) {
150  pet = { ...pet, pets: pet.pets + 1 }
151  await setMood($, 'loved', 4000)
152  await $.store.set('pet', pet)
153}
154
155const COLOR_COMMAND = /^\s*\/pet\s+colou?r\s+(\S*)$/i
156
157// While the draft reads `/pet color <partial>`, the colour to try on: the exact name, else the
158// first that starts with what's typed.
159export function previewFor(draft: string): string | undefined {
160  const partial = COLOR_COMMAND.exec(draft)?.[1]?.toLowerCase()
161  if (!partial) return undefined
162  return colorName(partial) ?? COLOR_NAMES.find(name => name.startsWith(partial))
163}
164
165const afterEdit = (e: { text: string; start: number; end: number; inputText: string }) => e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end)
166
167async function preview($: EngineInterface, color: string | undefined) {
168  if (color === previewColor) return
169  previewColor = color
170  const line = color ? `is trying on ${color}… press Enter to keep it` : pick(LINES.idle)
171  await update($, view, current => viewOf(pet, current.mood, line, current.isHidden))
172}
173
174const CODEY = /[{}();=<>[\]`]|=>|\b(function|const|def|class|import|return)\b/
175
176// How the pet reacts to one edit of the draft: what changed, where the caret is, and what the draft says.
177export function reactionTo(e: { text: string; cursor: number; start: number; end: number; inputText: string; key?: unknown }) {
178  const removed = e.end - e.start
179  const draft = e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end)
180  const caret = e.start + e.inputText.length
181  // The eyes sweep left to right across each 30 characters, like reading a line.
182  const look = Math.round(((caret % 30) / 30) * 2 - 1)
183  if (removed >= 10 || (removed > 0 && draft.trim() === '')) return { mood: 'shocked' as const, look, line: undefined }
184  if (removed > 0) return { mood: 'wince' as const, look, line: undefined }
185  if (e.inputText.length > 40 && e.key === undefined) return { mood: 'shocked' as const, look, line: "whoa, that's a lot of text" }
186  if (e.inputText === '') return undefined // a bare caret move
187  const lastWords = draft.slice(-24).toLowerCase()
188  if (/\b(please|thanks|thank you|ty)\s?$/.test(lastWords)) return { mood: 'loved' as const, look, line: 'appreciates your manners ♥' }
189  if (/\bbug\s?$/.test(lastWords)) return { mood: 'reading' as const, look, line: 'spots the bug 👀' }
190  return { mood: 'reading' as const, look, line: CODEY.test(draft) ? 'is reading your code…' : undefined }
191}
192
193// Typing moods hold while the keys keep coming and fade ~2s after the last one. Only a change of
194// mood, gaze or words touches the band, so a run of keystrokes costs a handful of redraws.
195async function react($: EngineInterface, reaction: NonNullable<ReturnType<typeof reactionTo>>) {
196  const now = await $.clock.now()
197  lastActive = now
198  // Asleep or nodding off, the first keys wake it gently; reactions resume once it's up.
199  if (mood === 'sleepy' || mood === 'dozing') return wake($)
200  if (mood === 'waking' && now < moodUntil) return
201  moodUntil = now + 2000
202  const isSame = mood === reaction.mood && look === reaction.look && (reaction.line === undefined || reaction.line === lastLine)
203  look = reaction.look
204  if (isSame) return
205  const isNewMood = mood !== reaction.mood
206  mood = reaction.mood
207  if (reaction.line !== undefined || isNewMood) lastLine = reaction.line ?? pick(LINES[reaction.mood])
208  await update($, view, current => viewOf(pet, reaction.mood, lastLine, current.isHidden))
209}
210
211const WAKE_MS = 1600
212const DOZE_MS = 2800
213
214// Blinks, a yawn and a little rise, then on with whatever comes next.
215async function wake($: EngineInterface) {
216  if (mood === 'waking') return
217  await setMood($, 'waking', WAKE_MS)
218}
219
220// A passing mood (happy, oops, loved, waking) wears off; a long quiet nods off into sleep, through a
221// few heavy-lidded seconds of dozing. The motion itself runs on the surface's clock or in the SVG.
222async function checkMood($: EngineInterface) {
223  const now = await $.clock.now()
224  if (moodUntil && now > moodUntil) {
225    const next = baseMood(now)
226    // Drifting off from an awake mood goes through dozing first.
227    if (next === 'sleepy' && mood !== 'dozing') await setMood($, 'dozing', DOZE_MS)
228    else await setMood($, next)
229  } else if (mood === 'idle' && baseMood(now) === 'sleepy') await setMood($, 'dozing', DOZE_MS)
230  await tendMinis($, now)
231}
232
233// Helpers whose agents have finished wave bye; ones that have waved long enough leave.
234async function tendMinis($: EngineInterface, now: number) {
235  if (minis.length === 0) return
236  const here = minis.filter(m => m.state === 'here' && now - m.at > 3000)
237  if (here.length) {
238    const agents = await $.agent.list().catch(() => [])
239    const finished = new Set(agents.filter(a => ['completed', 'failed', 'killed'].includes(a.status)).map(a => a.id))
240    for (const [agentId, miniId] of miniOf) if (finished.has(agentId)) await sayBye($, miniId)
241  }
242  const kept = minis.filter(m => m.state === 'here' || now - m.at < LEAVE_MS)
243  if (kept.length !== minis.length) {
244    minis = kept
245    await update($, view, current => ({ ...current, minis }))
246  }
247}
248
249async function hatch($: EngineInterface, id: string, type: string) {
250  const name = type.split(':').pop() ?? type
251  const color = (await definedColor($, name)) ?? miniColor(id, colorName(pet.color) ?? defaultColor)
252  const now = await $.clock.now()
253  const at = Math.max(now, lastHatchAt + HATCH_GAP_MS)
254  lastHatchAt = at
255  minis = [...minis.filter(m => m.id !== id), { id, name, color, state: 'here' as const, at }].slice(-6)
256  await update($, view, current => ({ ...current, minis }))
257  // A queued helper's turn: draw again when it's due, so its slot starts opening on time.
258  if (at > now) $.clock.after(at - now, () => void update($, view, current => ({ ...current, minis })).catch(() => undefined))
259}
260
261async function sayBye($: EngineInterface, id: string) {
262  const mini = minis.find(m => m.id === id)
263  if (!mini || mini.state === 'bye') return
264  const now = await $.clock.now()
265  minis = minis.map(m => (m.id === id ? { ...m, state: 'bye' as const, at: now } : m))
266  await update($, view, current => ({ ...current, minis }))
267  if (isWorking) say($, `${mini.name} is back with answers 👋`).catch(() => undefined)
268}
269
270// A custom agent may name its colour in its definition (`color: blue`): the project's, then the person's.
271async function definedColor($: EngineInterface, name: string): Promise<string | undefined> {
272  if (!/^[\w-]+$/.test(name)) return undefined
273  const home = await $.env.get('HOME').catch(() => undefined)
274  const cwd = await $.session.cwd().catch(() => undefined)
275  for (const dir of [cwd && `${cwd}/.claude/agents`, home && `${home}/.claude/agents`]) {
276    if (!dir) continue
277    const text = await $.fs.read(`${dir}/${name}.md`).catch(() => undefined)
278    const color = text ? colorFromDefinition(text) : undefined
279    if (color) return color
280  }
281  return undefined
282}
283
284export const register: Register = (on, options) => {
285  defaultColor = colorName(String(options.color)) ?? DEFAULT_COLOR
286
287  on('session.start', async ($, e, next) => {
288    await $.command.register({ name: 'pet', description: 'Your pixel pet: stats, or show / hide / rename <name> / color <name>', argumentHint: '[show|hide|rename <name>|color <name>]' })
289    const now = await $.clock.now()
290    const saved = (await $.store.get('pet')) as Pet | undefined
291    pet = saved ?? { name: String(options.name ?? 'Bit'), born: now, turns: 0, pets: 0 }
292    if (!saved) await $.store.set('pet', pet)
293    lastActive = now
294    // Which provider bills the tokens, from the switches Claude Code reads (never a key).
295    if (await $.env.get('CLAUDE_CODE_USE_BEDROCK').catch(() => undefined)) provider = 'bedrock'
296    else if (await $.env.get('CLAUDE_CODE_USE_VERTEX').catch(() => undefined)) provider = 'vertex'
297    else if (await $.env.get('CLAUDE_CODE_USE_FOUNDRY').catch(() => undefined)) provider = 'foundry'
298    const themeRow = (await $.config.list().catch(() => [])).find(row => row.key === 'theme')
299    theme = /light/i.test(String(themeRow?.value ?? '')) ? 'light' : 'dark'
300    const usage = await $.session.usage().catch(() => undefined)
301    if (usage) await setMeter($, usage)
302    mood = 'sleepy'
303    await update($, view, current => viewOf(pet, 'sleepy', saved ? pick(LINES.sleepy) : 'just hatched and fell asleep… type to wake it', current.isHidden))
304    $.clock.every(1000, () => void checkMood($).catch(() => undefined))
305    return next(e)
306  })
307
308  // Keystrokes in the prompt box: answer at once, react off the typing path.
309  on('prompt.edit', async ($, e, next) => {
310    const box = await next(e)
311    // Typing `/pet color …` tries the colour on; anything else reacts to the typing.
312    const color = previewFor(afterEdit(e))
313    if (color || previewColor) preview($, color).catch(() => undefined)
314    else {
315      const reaction = reactionTo(e)
316      if (reaction) react($, reaction).catch(() => undefined)
317    }
318    return box
319  })
320
321  // The colour names as typeahead rows while the colour word is being typed, each with its swatch.
322  on('prompt.autocomplete', async ($, e, next) => {
323    const offered = await next(e)
324    if (!COLOR_COMMAND.test(e.text.slice(0, e.cursor))) return offered
325    const partial = e.token.toLowerCase()
326    const rows = COLOR_NAMES.filter(name => name.startsWith(partial)).map(name => ({ text: name, description: hex(COLORS[name]!.body) }))
327    return { suggestions: [...offered.suggestions, ...rows] }
328  })
329
330  // A click on the sprite (terminal) or on the layer over it (desktop) pets it.
331  on('ui.message', async ($, e, next) => {
332    if ((e.element === 'pet' || e.element === 'pet-hit') && (e.data as { pet?: boolean } | null)?.pet) petIt($).catch(() => undefined)
333    return next(e)
334  })
335
336  on('prompt.submit', async ($, e, next) => {
337    lastActive = await $.clock.now()
338    look = 0
339    if (mood === 'sleepy' || mood === 'dozing') await wake($)
340    else if (mood === 'reading' || mood === 'wince' || mood === 'shocked') await setMood($, 'idle')
341    return next(e)
342  })
343
344  on('turn.start', async ($, e, next) => {
345    isWorking = true
346    lastActive = await $.clock.now()
347    // Asleep, it wakes first; once up, the waking mood wears off into working.
348    if (mood === 'sleepy' || mood === 'dozing') await wake($)
349    else if (!moodUntil) await setMood($, 'working')
350    return next(e)
351  })
352
353  // A sentence for each kind of action as it starts; the pet keeps its working mood.
354  // Each subagent hatches a little helper beside the pet, in its own colour; it waves bye when done.
355  on('agent.spawn', async ($, e, next) => {
356    if (!e.parentAgentId) say($, `sent ${e.subagentType.split(':').pop()} off on a side quest 🧭`).catch(() => undefined)
357    await hatch($, e.tool_use_id, e.subagentType).catch(() => undefined)
358    const started = await next(e)
359    if (started.deny !== undefined || !started.agentId) await sayBye($, e.tool_use_id).catch(() => undefined)
360    else miniOf.set(started.agentId, e.tool_use_id)
361    return started
362  })
363
364  on('session.compact', async ($, e, next) => {
365    say($, 'is tidying its memory 🧹').catch(() => undefined)
366    return next(e)
367  })
368
369  on('classic.Notification', async ($, e, next) => {
370    if (/permission/i.test(e.notification_type)) say($, 'is waiting for your OK ✋').catch(() => undefined)
371    return next(e)
372  })
373
374  on('turn.complete', async ($, e, next) => {
375    const done = await next(e)
376    if (e.agentId !== undefined) {
377      const miniId = miniOf.get(e.agentId)
378      if (miniId) await sayBye($, miniId).catch(() => undefined)
379      return done
380    }
381    isWorking = false
382    lastActive = await $.clock.now()
383    if (e.isAborted) {
384      await setMood($, 'idle')
385      return done
386    }
387    pet = { ...pet, turns: pet.turns + 1 }
388    await $.store.set('pet', pet)
389    await setMood($, 'happy', 6000)
390    return done
391  })
392
393  on('tool.call', async ($, e, next) => {
394    if (e.agentId === undefined && isWorking) {
395      const line = actionLine(String(e.tool), e as unknown as Record<string, unknown>)
396      if (line) say($, line).catch(() => undefined)
397    }
398    const ran = await next(e)
399    if (ran.deny === undefined && ran.isError === true && e.agentId === undefined) await setMood($, 'oops', 5000)
400    return ran
401  })
402
403  on('session.measure', async ($, e, next) => {
404    const measured = await next(e)
405    await setMeter($, e)
406    return measured
407  })
408
409  on('command.run', { command: 'pet' }, async ($, e) => {
410    const [verb, ...rest] = e.args.trim().split(/\s+/)
411    if (verb === 'hide' || verb === 'show') {
412      await update($, view, current => ({ ...current, isHidden: verb === 'hide' }))
413      return { text: verb === 'hide' ? `${pet.name} is resting off-screen. /pet show brings it back.` : `${pet.name} is back!` }
414    }
415    if (verb === 'color' || verb === 'colour') {
416      const choice = colorName(rest[0])
417      if (!choice) {
418        await preview($, undefined)
419        return { text: `Colours: ${COLOR_NAMES.join(', ')}. Try /pet color pink.` }
420      }
421      previewColor = undefined
422      pet = { ...pet, color: choice }
423      await $.store.set('pet', pet)
424      await update($, view, current => viewOf(pet, current.mood, current.line, current.isHidden))
425      return { text: `${pet.name} is now ${choice}.` }
426    }
427    if (verb === 'rename' && rest.length) {
428      pet = { ...pet, name: rest.join(' ').slice(0, 24) }
429      await $.store.set('pet', pet)
430      await update($, view, current => viewOf(pet, current.mood, current.line, current.isHidden))
431      return { text: `Your pet is now called ${pet.name}.` }
432    }
433    const days = Math.max(0, Math.floor(((await $.clock.now()) - pet.born) / 86_400_000))
434    return {
435      text: [
436        `${pet.name} · ${colorName(pet.color) ?? defaultColor}`,
437        `${pet.turns} turns together · petted ${pet.pets} times · ${days} days old`,
438        'Click it to pet it. /pet color <name> changes its colour, /pet rename <name> its name.',
439      ].join('\n'),
440    }
441  })
442
443  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
444    const current = await read($, view)
445    if (e.props.hasSurvey || current.isHidden || e.props.maxRows < 4) return next(e)
446    const { Box, Text } = $.ui.resolve(e)
447    const now = await $.clock.now()
448    const percent = current.meter.percent
449    const parts = segments(current.meter, now)
450    // The helpers' ages at this draw: each one's story (pop in, bob, wave, fade) runs from its age.
451    // Newest first, so a new helper takes the leftmost slot and pushes the rest along; one still
452    // queued has a negative age and no slot yet.
453    const helpers = [...current.minis].sort((a, b) => b.at - a.at).map(m => ({ id: m.id, color: m.color, state: m.state, age: now - m.at }))
454    // Point at the meter for the details the line leaves out: tokens, resets, how you're billed.
455    // Hidden until hovered; it opens over the band, which clips it, so it stays within four rows.
456    const card = cardLines(current.meter, now)
457    const details = card.length > 0 && (
458      <Box position="absolute" top={-2} left={0} display="none" hover={{ display: 'flex' }} flexDirection="column" paddingX={1} backgroundColor={theme === 'light' ? '#f3f3f5' : '#2b2b30'}>
459        {card.map(text => <Text wrap="truncate">{text}</Text>)}
460      </Box>
461    )
462    const hint = card.length > 0 && <Text dimColor> ⓘ</Text>
463
464    // The terminal draws pixels as half-block text on its grid and animates the line and the meter
465    // there too; anywhere else text doesn't line up into pixels, so pet, line and meter are SVGs
466    // with their motion built in.
467    if (e.surface === 'terminal') {
468      const { Client } = $.ui.resolve(e)
469      const pet: PetViewProps = { mood: current.mood, look, color: current.color }
470      const status: StatusViewProps = { name: current.name, line: current.line }
471      const gauge: MeterViewProps = { ...(percent === undefined ? {} : { percent }), segments: parts, isWorking, width: METER_CELLS }
472      return (
473        <Box gap={2} alignItems="center">
474          <Client key="pet" module="./pet-view.tsx" width={10} height={4} props={pet} />
475          <Box flexDirection="column" justifyContent="center">
476            <Client key="status" module="./status-view.tsx" props={status} />
477            <Box key="meter-row" alignItems="flex-end">
478              {helpers.length > 0 && <Client key="minis" module="./minis-view.tsx" props={{ minis: helpers, stamp: now } satisfies MinisViewProps} />}
479              <Client key="meter" module="./meter-view.tsx" props={gauge} />
480              {hint}
481              {details}
482            </Box>
483          </Box>
484        </Box>
485      )
486    }
487
488    if (shown && (shown.mood !== current.mood || shown.color !== current.color)) fadeFrom = { ...shown, at: now }
489    shown = { mood: current.mood, color: current.color }
490    if (shownLine !== undefined && shownLine !== current.line) slideFrom = { line: shownLine, at: now }
491    shownLine = current.line
492    const from = drawnPercent
493    if (percent !== undefined) drawnPercent = percent
494    const { Svg } = $.ui.resolve(e)
495    // An invisible layer over the sprite catches the click; the SVG itself can't.
496    let hit: RenderChildren = null
497    if (e.surface === 'desktop') {
498      const { Client } = $.ui.resolve(e)
499      hit = (
500        <Box position="absolute" top={0} left={0}>
501          <Client key="pet-hit" module="./hit.tsx" width={6} height={2} props={{ columns: 6, rows: 2 }} />
502        </Box>
503      )
504    }
505    const previous = slideFrom && now - slideFrom.at < 600 ? slideFrom.line : undefined
506    // The second line, helpers and all, as one SVG that glides from where the last draw left it.
507    const resets = parts.filter(p => p.text.startsWith(' resets')).map(p => p.text.trim()).join(' · ')
508    const bill = parts.find(p => p.text.startsWith('$'))?.text
509    const two = lineTwoSvg({
510      minis: helpers,
511      ...(percent === undefined ? {} : { percent }),
512      from,
513      isWorking,
514      limits: current.meter.limits ?? [],
515      ...(resets ? { resets } : {}),
516      ...(bill ? { bill } : {}),
517      hasInfo: card.length > 0,
518      tones: { ...TONES[theme], text: LINE_COLORS[theme].text },
519    }, lineTwoLayout)
520    lineTwoLayout = two.layout
521    const helpersAlt = current.minis.map(m => `${m.name} helper${m.state === 'bye' ? ' waving bye' : ''}`)
522    return (
523      <Box gap={2} alignItems="center">
524        <Box>
525          <Svg source={petSvg(current.mood, fadeFrom && now - fadeFrom.at < 500 ? fadeFrom : undefined, look, current.color)} alt={`${current.name}, a pixel pet, ${current.mood}`} width={50} height={40} />
526          {hit}
527        </Box>
528        <Box flexDirection="column" justifyContent="center">
529          <Svg source={lineSvg(current.name, current.line, previous, LINE_COLORS[theme])} alt={`${current.name} ${current.line}`} width={lineWidth(current.name, current.line, previous)} height={20} />
530          <Box key="meter-row">
531            <Svg source={two.svg} alt={[...helpersAlt, ...card].join('; ') || 'context meter'} width={two.width} height={26} />
532            {details}
533          </Box>
534        </Box>
535      </Box>
536    )
537  })
538}
539
hooks/meter.ts 185 lines
1// The context meter on the band's second line: how full the context window is, what the session
2// cost, and the 5-hour limit. A gradient runs mint → amber → red along the bar, so the colour at
3// the tip says how worried to be.
4
5export type Limit = { kind: string; percent: number; resetsAt?: string }
6// Where the numbers come from: a Claude plan meters usage in 5-hour and weekly windows and bills
7// nothing per token; the API and the cloud providers bill per token and have no windows.
8export type Source = 'plan' | 'api' | 'bedrock' | 'vertex' | 'foundry' | 'gateway'
9export type Meter = { percent?: number; tokens?: number; window?: number; usd?: number; limits?: Limit[]; source?: Source }
10export type Segment = { text: string; color?: string; dim?: boolean; bold?: boolean }
11export type Run = { text: string; fg: string; bold?: boolean }
12
13const STOPS: [at: number, color: number][] = [[0, 0x34d399], [0.6, 0xfbbf24], [1, 0xf87171]]
14const TRACK = 0x3a3f4b
15
16const mix = (a: number, b: number, t: number) => {
17  const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - t) + ((b >> s) & 255) * t)
18  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
19}
20export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
21
22export function gradientAt(t: number): number {
23  const x = Math.max(0, Math.min(1, t))
24  for (let i = 1; i < STOPS.length; i++) {
25    const [a, ca] = STOPS[i - 1]!
26    const [b, cb] = STOPS[i]!
27    if (x <= b) return mix(ca, cb, (x - a) / (b - a))
28  }
29  return STOPS[STOPS.length - 1]![1]
30}
31
32const kilo = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
33
34const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'week', seven_day_opus: 'week·opus', spend_limit: 'spend' }
35export const SOURCE_LABELS: Record<Source, string> = { plan: 'Claude plan', api: 'API', bedrock: 'Bedrock', vertex: 'Vertex AI', foundry: 'Foundry', gateway: 'gateway' }
36
37// "1h52m", "38m", "3d4h": how long until a window resets.
38export function resetIn(resetsAt: string | undefined, now: number): string | undefined {
39  if (!resetsAt) return undefined
40  const ms = Date.parse(resetsAt) - now
41  if (!(ms > 0)) return undefined
42  const m = Math.round(ms / 60_000)
43  if (m < 60) return `${m}m`
44  if (m < 24 * 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
45  const h = Math.round(m / 60)
46  return `${Math.floor(h / 24)}d${h % 24 ? `${h % 24}h` : ''}`
47}
48
49export const miniGauge = (percent: number, width = 5) => {
50  const filled = Math.max(percent > 0 ? 1 : 0, Math.min(width, Math.round((percent / 100) * width)))
51  return '▰'.repeat(filled) + '▱'.repeat(width - filled)
52}
53
54// A window's reset only earns a place on the line once it's getting tight; the card always has it.
55export const RESET_SHOWN_FROM = 70
56
57// The terminal's line after the context bar: tokens (when there are no windows to show), each
58// usage window as a gauge and a percent, and the bill on the API or a cloud provider. On a plan
59// the $ is only what the session would cost at API prices, so it stays in the card.
60export function segments(m: Meter, now: number): Segment[] {
61  const out: Segment[] = []
62  const sep = () => out.length && out.push({ text: '  ' })
63  if (m.tokens !== undefined && m.window && !m.limits?.length) out.push({ text: `${kilo(m.tokens)}/${kilo(m.window)}`, dim: true })
64  for (const limit of m.limits ?? []) {
65    sep()
66    const color = hex(gradientAt(limit.percent / 100))
67    out.push({ text: `${LIMIT_LABELS[limit.kind] ?? limit.kind} `, dim: true }, { text: miniGauge(limit.percent), color }, { text: ` ${Math.round(limit.percent)}%`, color, bold: true })
68    const reset = limit.percent >= RESET_SHOWN_FROM ? resetIn(limit.resetsAt, now) : undefined
69    if (reset) out.push({ text: ` resets ${reset}`, dim: true })
70  }
71  if (m.source && m.source !== 'plan' && m.usd !== undefined) {
72    sep()
73    out.push({ text: `$${m.usd.toFixed(2)}`, bold: true })
74  }
75  return out
76}
77
78const LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly limit', seven_day_opus: 'Weekly Opus limit', spend_limit: 'Spend limit' }
79
80// The hover card: everything the line leaves out, in plain words.
81export function cardLines(m: Meter, now: number): string[] {
82  const lines: string[] = []
83  if (m.percent !== undefined) lines.push(`Context ${m.percent}% full${m.tokens !== undefined && m.window ? ` · ${kilo(m.tokens)} of ${kilo(m.window)} tokens` : ''}`)
84  for (const limit of m.limits ?? []) {
85    const reset = resetIn(limit.resetsAt, now)
86    lines.push(`${LIMIT_NAMES[limit.kind] ?? limit.kind} ${Math.round(limit.percent)}% used${reset ? ` · resets in ${reset}` : ''}`)
87  }
88  const usd = m.usd !== undefined ? `$${m.usd.toFixed(2)}` : undefined
89  if (m.source === 'plan') lines.push(`Billing: Claude plan, usage limits instead of per-token charges${usd ? ` (≈${usd} at API rates)` : ''}`)
90  else if (m.source) lines.push(`Billing: ${SOURCE_LABELS[m.source]}${usd ? `, ${usd} billed this session` : ''}`)
91  return lines
92}
93
94export type Tones = { label: string }
95export const TONES: Record<'dark' | 'light', Tones> = { dark: { label: '#9a9aa2' }, light: { label: '#6b7280' } }
96
97// Desktop: the usage windows as one SVG, so labels, pills and percents line up exactly
98// (the app's proportional font draws ▰ and ▱ at different widths).
99// The windows' pieces in their own 16px-tall coordinates; limitsSvg wraps them.
100export function limitsParts(limits: readonly Limit[], tones: Tones): { body: string; width: number } {
101  const CHAR = 7.4
102  const PILL = 30
103  let x = 0
104  let end = 0
105  const parts = limits.map(limit => {
106    const label = LIMIT_LABELS[limit.kind] ?? limit.kind
107    const color = hex(gradientAt(limit.percent / 100))
108    const pct = `${Math.round(limit.percent)}%`
109    const fill = Math.max(limit.percent > 0 ? 4 : 0, (Math.min(100, limit.percent) / 100) * PILL)
110    const lx = x
111    const px = lx + label.length * CHAR + 6
112    const tx = px + PILL + 6
113    end = tx + pct.length * 8.4 // bold digits run wider than labels
114    x = end + 16
115    return `<text x="${lx}" y="12" fill="${tones.label}">${label}</text>`
116      + `<rect x="${px}" y="5" width="${PILL}" height="5" rx="2.5" fill="#8b93a7" fill-opacity="0.25"/>`
117      + `<rect x="${px}" y="5" width="${fill.toFixed(1)}" height="5" rx="2.5" fill="${color}"/>`
118      + `<text x="${tx}" y="12" fill="${color}" font-weight="700">${pct}</text>`
119  })
120  return { body: parts.join(''), width: Math.ceil(Math.max(1, end + 2)) }
121}
122
123export const FONT = `font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, system-ui, sans-serif" font-size="12.5"`
124
125export function limitsSvg(limits: readonly Limit[], tones: Tones): { svg: string; width: number } {
126  const { body, width } = limitsParts(limits, tones)
127  return { width, svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="16" viewBox="0 0 ${width} 16" ${FONT}>${body}</svg>` }
128}
129
130export const segmentsText = (parts: readonly Segment[]) => parts.map(p => p.text).join('')
131
132// The terminal bar: heavy line cells in the gradient, a half cell for precision, a dim track after.
133// `shine` is the cell a highlight sweeps across while Claude works.
134export function barRuns(percent: number, width: number, shine?: number): Run[] {
135  const exact = (Math.max(0, Math.min(100, percent)) / 100) * width
136  const full = Math.floor(exact)
137  const half = exact - full >= 0.5
138  const runs: Run[] = []
139  const push = (text: string, color: number) => {
140    const fg = hex(color)
141    const last = runs[runs.length - 1]
142    if (last && last.fg === fg) last.text += text
143    else runs.push({ text, fg })
144  }
145  for (let i = 0; i < width; i++) {
146    const glow = shine === undefined ? 0 : Math.max(0, 1 - Math.abs(i - shine) / 2)
147    const color = mix(gradientAt(i / Math.max(1, width - 1)), 0xffffff, glow * 0.6)
148    if (i < full) push('━', color)
149    else if (i === full && half) push('╸', color)
150    else push('─', TRACK)
151  }
152  return runs
153}
154
155// Desktop: a rounded pill whose fill glides from the last reading to this one, with a soft
156// highlight sweeping across while Claude works and a glow at the tip.
157// The bar's pieces in its own 12px-tall coordinates: what meterSvg wraps, and what the band's
158// second line places among the helpers. Ids are local to whichever SVG holds them.
159export function meterParts(percent: number, from: number, isWorking: boolean, width = 110): { defs: string; body: string } {
160  const w = (p: number) => Math.max(0, Math.min(1, p / 100)) * width
161  const to = w(percent)
162  const start = w(from)
163  const tip = hex(gradientAt(percent / 100))
164  const glide = Math.abs(to - start) > 0.5
165    ? (attr: string) => `<animate attributeName="${attr}" from="${start.toFixed(1)}" to="${to.toFixed(1)}" dur="0.8s" calcMode="spline" keyTimes="0;1" keySplines="0.2 0.8 0.2 1" fill="freeze"/>`
166    : () => ''
167  const shimmer = isWorking
168    ? `<rect y="3" width="30" height="6" fill="url(#s)" clip-path="url(#c)"><animate attributeName="x" values="-30;${width}" dur="1.6s" repeatCount="indefinite"/></rect>`
169    : ''
170  return {
171    defs: `<linearGradient id="g" x1="0" x2="${width}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#34d399"/><stop offset="0.6" stop-color="#fbbf24"/><stop offset="1" stop-color="#f87171"/></linearGradient>`
172      + `<linearGradient id="s" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`
173      + `<clipPath id="c"><rect y="3" width="${to.toFixed(1)}" height="6" rx="3">${glide('width')}</rect></clipPath>`,
174    body: `<rect y="3" width="${width}" height="6" rx="3" fill="#8b93a7" fill-opacity="0.22"/>`
175      + `<rect y="3" width="${to.toFixed(1)}" height="6" rx="3" fill="url(#g)">${glide('width')}</rect>`
176      + shimmer
177      + `<circle cx="${to.toFixed(1)}" cy="6" r="4.5" fill="${tip}" fill-opacity="0.35">${glide('cx')}</circle>`,
178  }
179}
180
181export function meterSvg(percent: number, from: number, isWorking: boolean, width = 110): string {
182  const { defs, body } = meterParts(percent, from, isWorking, width)
183  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width + 6}" height="12" viewBox="-3 0 ${width + 6} 12"><defs>${defs}</defs>${body}</svg>`
184}
185
hooks/meter-view.tsx 47 lines
1// The terminal's context meter, on the surface's own clock: the bar and the number glide to each
2// new reading with an ease-out, and a highlight sweeps along the fill while Claude works.
3import type { ClientModule } from 'claude-code'
4
5import { barRuns, gradientAt, hex } from './meter'
6import type { Segment } from './meter'
7
8export type MeterViewProps = { percent?: number; segments: Segment[]; isWorking: boolean; width: number }
9
10type Ref = { shown: number; target: number; phase: number; isWorking: boolean }
11type State = { ref: Ref }
12
13const FRAME_MS = 60
14
15const MeterView: ClientModule<MeterViewProps, State> = (props, surface) => {
16  const { Box, Text } = surface.elements
17  // The first reading shows as it is; later ones glide.
18  const ref = surface.state?.ref ?? { shown: props.percent ?? 0, target: props.percent ?? 0, phase: 0, isWorking: props.isWorking }
19  ref.target = props.percent ?? 0
20  ref.isWorking = props.isWorking
21  if (surface.state === undefined) {
22    surface.setState({ ref })
23    surface.every(FRAME_MS, () => {
24      const isGliding = Math.abs(ref.target - ref.shown) > 0.05
25      if (isGliding) ref.shown += (ref.target - ref.shown) * 0.18
26      else ref.shown = ref.target
27      if (ref.isWorking) ref.phase += 1
28      if (isGliding || ref.isWorking) surface.setState({ ref })
29    })
30  }
31
32  if (props.percent === undefined) return <Text dimColor>context · waiting for the first reply</Text>
33
34  const filled = Math.max(1, Math.round((ref.shown / 100) * props.width))
35  const shine = ref.isWorking ? (ref.phase % (filled + 8)) - 4 : undefined
36  return (
37    <Box>
38      {barRuns(ref.shown, props.width, shine).map(run => <Text color={run.fg}>{run.text}</Text>)}
39      <Text bold color={hex(gradientAt(ref.shown / 100))}> {Math.round(ref.shown)}%</Text>
40      {props.segments.length > 0 && <Text> </Text>}
41      {props.segments.map(seg => <Text color={seg.color} dimColor={seg.dim} bold={seg.bold}>{seg.text}</Text>)}
42    </Box>
43  )
44}
45
46export default MeterView
47
hooks/pet-view.tsx 59 lines
1// The pet itself, drawn on the surface's own frame clock: it bobs and blinks between the band's
2// redraws, a change of mood or colour crossfades instead of popping, and a click pets it.
3import type { ClientModule } from 'claude-code'
4
5import type { Mood } from '../types'
6import { DEFAULT_COLOR, blend, sprite, toRuns } from './sprite'
7
8export type PetViewProps = { mood: Mood; look: number; color: string }
9
10type Ref = { t: number; mood: Mood; color: string; from?: { mood: Mood; color: string; at: number } }
11type State = { ref: Ref }
12
13const FRAME_MS = 100
14const FADE_MS = 350
15
16const PetView: ClientModule<PetViewProps, State> = (props, surface) => {
17  const { Box, Text } = surface.elements
18  const ref = surface.state?.ref ?? { t: 0, mood: props.mood, color: props.color }
19  if (props.mood !== ref.mood || props.color !== ref.color) {
20    ref.from = { mood: ref.mood, color: ref.color, at: ref.t }
21    ref.mood = props.mood
22    ref.color = props.color
23  }
24  if (surface.state === undefined) {
25    surface.setState({ ref })
26    // Some clicks arrive as a down, some only as an up: either pets, once per click.
27    let lastPet = -Infinity
28    surface.onPointer(event => {
29      if ((event.type !== 'down' && event.type !== 'up') || ref.t - lastPet < 300) return
30      lastPet = ref.t
31      surface.post({ pet: true })
32    })
33    surface.every(FRAME_MS, () => {
34      // Asleep it barely moves, so a quarter of the frames will do.
35      ref.t += FRAME_MS
36      if (ref.mood !== 'sleepy' || ref.t % (FRAME_MS * 4) === 0) surface.setState({ ref })
37    })
38  }
39
40  let grid = sprite(ref.mood, ref.t, props.look, ref.color ?? DEFAULT_COLOR)
41  if (ref.from) {
42    const k = (ref.t - ref.from.at) / FADE_MS
43    if (k >= 1) ref.from = undefined
44    else grid = blend(sprite(ref.from.mood, ref.t, 0, ref.from.color), grid, k * k * (3 - 2 * k)) // smoothstep
45  }
46
47  return (
48    <Box flexDirection="column">
49      {toRuns(grid).map(runs => (
50        <Text>
51          {runs.map(run => (run.fg ? <Text color={run.fg} backgroundColor={run.bg}>{run.text}</Text> : <Text>{run.text}</Text>))}
52        </Text>
53      ))}
54    </Box>
55  )
56}
57
58export default PetView
59
hooks/sprite.ts 279 lines
1import type { Mood } from '../types'
2
3// A 10x8 pixel canvas, two pixels per terminal row: 10 columns by 4 rows. null is see-through.
4export type Grid = (number | null)[][]
5export const WIDTH = 10
6export const HEIGHT = 8
7
8// Colours a person can pick with /pet color: the common names, plus a few moods. Each is a body
9// and a darker outline; the order is what a typed prefix matches first.
10export const COLORS: Record<string, { body: number; edge: number }> = {
11  teal: { body: 0x4fd1c5, edge: 0x1f7a73 },
12  red: { body: 0xef4444, edge: 0x7f1d1d },
13  orange: { body: 0xfb923c, edge: 0x9a3412 },
14  yellow: { body: 0xfacc15, edge: 0x854d0e },
15  green: { body: 0x4ade80, edge: 0x166534 },
16  blue: { body: 0x60a5fa, edge: 0x1e40af },
17  purple: { body: 0x8b7cf6, edge: 0x4b3cb0 },
18  pink: { body: 0xf9a8d4, edge: 0xbe185d },
19  white: { body: 0xf3f4f6, edge: 0x9ca3af },
20  gray: { body: 0x9ca3af, edge: 0x4b5563 },
21  black: { body: 0x374151, edge: 0x111827 },
22  brown: { body: 0xb08968, edge: 0x5c3d2e },
23  cyan: { body: 0x22d3ee, edge: 0x155e75 },
24  mint: { body: 0x86efac, edge: 0x15803d },
25  lime: { body: 0xa3e635, edge: 0x3f6212 },
26  sky: { body: 0x7dd3fc, edge: 0x0369a1 },
27  navy: { body: 0x4f63d2, edge: 0x1e2a78 },
28  indigo: { body: 0x818cf8, edge: 0x3730a3 },
29  violet: { body: 0xa78bfa, edge: 0x5b21b6 },
30  lavender: { body: 0xc4b5fd, edge: 0x6d28d9 },
31  magenta: { body: 0xe879f9, edge: 0x86198f },
32  rose: { body: 0xfb7185, edge: 0x9f1239 },
33  peach: { body: 0xfdba74, edge: 0xc2410c },
34  coral: { body: 0xff8a65, edge: 0xb2462a },
35  gold: { body: 0xf6c945, edge: 0x9c7a12 },
36  lava: { body: 0xf87171, edge: 0x991b1b },
37  ghost: { body: 0xe5e7eb, edge: 0x6b7280 },
38  midnight: { body: 0x64748b, edge: 0x1e293b },
39}
40export const COLOR_NAMES = Object.keys(COLORS)
41export const DEFAULT_COLOR = 'teal'
42const ALIASES: Record<string, string> = { grey: 'gray', auto: DEFAULT_COLOR }
43
44export const colorName = (name: string | undefined) => {
45  const n = (name ?? '').toLowerCase()
46  return COLORS[n] ? n : ALIASES[n] ?? undefined
47}
48export const paletteFor = (color = DEFAULT_COLOR) => COLORS[colorName(color) ?? DEFAULT_COLOR]!
49
50const INK = 0x1a1a2e
51const WHITE = 0xffffff
52const BLUSH = 0xff8fb1
53const TONGUE = 0xff5c6c
54const SPARK = 0xffe066
55const HEART = 0xff5c8a
56const SWEAT = 0x7fdbff
57const ZZZ = 0xcfd8ff
58
59const BODY = [
60  '..oooo..',
61  '.obbbbo.',
62  'obbbbbbo',
63  'obbbbbbo',
64  'opbbbbpo',
65  '.obbbbo.',
66  '.oo..oo.',
67]
68
69type Eyes = 'open' | 'glance' | 'closed' | 'happy' | 'dizzy' | 'wide'
70type Mouth = 'smile' | 'open' | 'flat' | 'o' | 'gasp'
71
72// Eyes are 1x2 at body columns 2 and 5, rows 2-3: [top, bottom].
73const EYES: Record<Eyes, [string, string]> = {
74  open: ['w', 'k'],
75  glance: ['k', 'k'],
76  closed: ['b', 'k'],
77  happy: ['k', 'b'],
78  dizzy: ['k', 'w'],
79  wide: ['k', 'k'],
80}
81const MOUTHS: Record<Mouth, [x: number, y: number, c: 'k' | 'r'][]> = {
82  smile: [[3, 5, 'k'], [4, 5, 'k']],
83  open: [[3, 4, 'k'], [4, 4, 'k'], [3, 5, 'r'], [4, 5, 'r']],
84  flat: [[2, 5, 'k'], [3, 5, 'k'], [4, 5, 'k'], [5, 5, 'k']],
85  o: [],
86  gasp: [[3, 4, 'k'], [4, 4, 'k'], [3, 5, 'k'], [4, 5, 'k']],
87}
88
89// Rhythm per mood: how fast it bobs (ms per cycle) and whether it shakes instead.
90const RHYTHM: Record<Mood, { period: number; shake?: boolean; still?: boolean }> = {
91  reading: { period: 1400 },
92  wince: { period: 200, shake: true },
93  shocked: { period: 300 },
94  idle: { period: 2400 },
95  working: { period: 640 },
96  happy: { period: 480 },
97  oops: { period: 260, shake: true },
98  sleepy: { period: 3600, still: true },
99  dozing: { period: 3000, still: true },
100  waking: { period: 520 },
101  loved: { period: 900 },
102}
103
104const wave = (t: number, period: number) => 0.5 - 0.5 * Math.cos((2 * Math.PI * (t % period)) / period)
105
106// Blink for 140ms every few seconds, at a beat that doesn't look mechanical.
107export const isBlinking = (t: number) => (t % 3700) < 140 || ((t + 1300) % 6100) < 120
108
109// look: where the eyes point, -1 left, 0 ahead, 1 right (reading along as you type).
110// t: the clock, for extras that drift (a sleeper's z's).
111type Pose = { lift?: number; dx?: number; blink?: boolean; odd?: boolean; look?: number; color?: string; t?: number; withBody?: boolean; withExtras?: boolean }
112
113// One still frame. The terminal animates by drawing many of these; the desktop SVG layers a few.
114export function draw(mood: Mood, { lift = 0, dx = 0, blink = false, odd = false, look = 0, color = DEFAULT_COLOR, t = 0, withBody = true, withExtras = true }: Pose = {}): Grid {
115  const { body, edge } = paletteFor(color)
116  const eyes: Eyes =
117    mood === 'happy' || mood === 'loved' ? 'happy'
118    : mood === 'sleepy' ? 'closed'
119    : mood === 'dozing' ? (blink ? 'open' : 'closed') // heavy lids: only now and then do they open
120    : mood === 'waking' ? (blink ? 'closed' : 'open') // quick bleary blinks
121    : mood === 'oops' ? 'dizzy'
122    : mood === 'wince' ? 'closed'
123    : mood === 'shocked' ? 'wide'
124    : blink ? 'closed'
125    : mood === 'working' && odd ? 'glance'
126    : 'open'
127  const mouth: Mouth = mood === 'happy' ? 'open' : mood === 'oops' || mood === 'wince' ? 'flat' : mood === 'sleepy' ? 'o' : mood === 'shocked' || mood === 'dozing' || mood === 'waking' ? 'gasp' : 'smile'
128
129  const grid: Grid = Array.from({ length: HEIGHT }, () => Array<number | null>(WIDTH).fill(null))
130  const ox = 1 + dx
131  const oy = 1 - lift
132  const put = (x: number, y: number, c: number) => {
133    if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) grid[y]![x] = c
134  }
135  const paint = { o: edge, b: body, p: BLUSH, k: INK, w: WHITE, r: TONGUE } as const
136
137  if (withBody) {
138    BODY.forEach((row, y) => [...row].forEach((c, x) => c !== '.' && put(ox + x, oy + y, paint[c as keyof typeof paint])))
139    const shift = mood === 'reading' ? Math.max(-1, Math.min(1, Math.round(look))) : 0
140    for (const col of [2 + shift, 5 + shift]) EYES[eyes].forEach((c, i) => put(ox + col, oy + 2 + i, paint[c as keyof typeof paint]))
141    for (const [x, y, c] of MOUTHS[mouth]) put(ox + x, oy + y, paint[c])
142  }
143  if (withExtras) {
144    if (mood === 'happy') for (const [x, y] of odd ? [[0, 1], [9, 3]] : [[9, 0], [0, 4]]) put(x!, y!, SPARK)
145    if (mood === 'oops') put(9, 2 + (odd ? 1 : 0), SWEAT)
146    if (mood === 'sleepy') {
147      // Two z's drifting up and away, half a cycle apart, and a snore bubble on the breath.
148      const path = [[7, 4], [8, 3], [8, 2], [9, 1], [9, 0]]
149      for (const offset of [0, 2]) {
150        const [x, y] = path[(Math.floor(t / 420) + offset) % path.length]!
151        put(x!, y!, ZZZ)
152      }
153      if (Math.floor(t / 1800) % 2 === 0) put(9, 4, SWEAT) // the snore bubble, beside the cheek
154    }
155    if (mood === 'loved') for (const [x, y] of odd ? [[8, 0], [9, 1]] : [[0, 0], [1, 1]]) put(x!, y!, HEART)
156    if (mood === 'shocked') for (const y of [0, 1, 3]) put(9, y, odd ? SPARK : 0xff6b6b) // a blinking "!"
157    if (mood === 'wince') put(odd ? 9 : 8, 2, SWEAT)
158  }
159  return grid
160}
161
162// The frame at time t: bob or shake on the mood's rhythm, blink now and then, twinkle the extras.
163export function sprite(mood: Mood, t: number, look = 0, color = DEFAULT_COLOR): Grid {
164  const rhythm = RHYTHM[mood]
165  const phase = wave(t, rhythm.period)
166  const lids = mood === 'dozing' ? (t % 1800) < 420 : mood === 'waking' ? (t % 520) < 140 : isBlinking(t)
167  return draw(mood, {
168    lift: rhythm.still || rhythm.shake ? 0 : phase > 0.5 ? 1 : 0,
169    dx: rhythm.shake ? (phase > 0.5 ? 1 : -1) : 0,
170    blink: lids,
171    odd: Math.floor(t / 350) % 2 === 1,
172    look,
173    color,
174    t,
175  })
176}
177
178export function mix(a: number, b: number, t: number): number {
179  const ch = (shift: number) => Math.round(((a >> shift) & 255) * (1 - t) + ((b >> shift) & 255) * t)
180  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
181}
182
183// A crossfade between two poses: colors blend, and a pixel that appears or vanishes swaps halfway.
184export function blend(from: Grid, to: Grid, t: number): Grid {
185  return to.map((row, y) => row.map((c, x) => {
186    const a = from[y]?.[x] ?? null
187    if (a === null || c === null) return t < 0.5 ? a : c
188    return mix(a, c, t)
189  }))
190}
191
192export type Run = { text: string; fg?: string; bg?: string }
193export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
194
195// Two pixels per cell with half blocks; empty pixels draw nothing, so the surface shows through.
196export const toRuns = (grid: Grid) => toRunsOf(grid, WIDTH)
197
198export function toRunsOf(grid: Grid, width: number): Run[][] {
199  const rows: Run[][] = []
200  for (let y = 0; y < grid.length; y += 2) {
201    const runs: Run[] = []
202    for (let x = 0; x < width; x++) {
203      const top = grid[y]![x] ?? null
204      const bottom = grid[y + 1]?.[x] ?? null
205      const cell: Run =
206        top === null && bottom === null ? { text: ' ' }
207        : top === null ? { text: '▄', fg: hex(bottom!) }
208        : bottom === null ? { text: '▀', fg: hex(top) }
209        : top === bottom ? { text: '█', fg: hex(top) }
210        : { text: '▀', fg: hex(top), bg: hex(bottom) }
211      const last = runs[runs.length - 1]
212      if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text
213      else runs.push(cell)
214    }
215    rows.push(runs)
216  }
217  return rows
218}
219
220// Desktop: one SVG with the motion built in, so it animates without redraws. The body glides on
221// an eased curve (smoother than whole pixels), blinks and twinkles switch layers, and a mood change
222// fades the previous pose out. Every animated layer rests where a still image should be, so a
223// surface that ignores SMIL still shows a clean pet.
224export function petSvg(mood: Mood, from?: { mood: Mood; color: string }, look = 0, color = DEFAULT_COLOR, px = 5): string {
225  // Pixels as rects, a row's same-coloured neighbours merged; with `over`, only the pixels that differ from it.
226  const rects = (grid: Grid, over?: Grid) =>
227    grid.map((row, y) => {
228      const cells = row.map((c, x) => (over && (over[y]![x] ?? null) === c ? null : c))
229      let out = ''
230      for (let x = 0; x < WIDTH; ) {
231        let end = x + 1
232        while (end < WIDTH && cells[end] === cells[x]) end++
233        if (cells[x] !== null) out += `<rect x="${x * px}" y="${y * px}" width="${(end - x) * px}" height="${px}" fill="${hex(cells[x]!)}"/>`
234        x = end
235      }
236      return out
237    }).join('')
238  const ease = 'calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"'
239  const once = (ms: number, spline = '0.2 0.8 0.2 1') => `dur="${ms}ms" calcMode="spline" keyTimes="0;1" keySplines="${spline}" fill="freeze"`
240  const rhythm = RHYTHM[mood]
241  // How the body moves: a bob or a shake on the mood's rhythm; asleep, a slow breath that swells it
242  // a touch; dozing off, a slow sink; waking, a rise from that sink with a little overshoot.
243  const motion =
244    mood === 'sleepy' ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${-px * 0.35};0 0" dur="3600ms" ${ease} repeatCount="indefinite"/>`
245    : mood === 'dozing' ? `<animateTransform attributeName="transform" type="translate" from="0 0" to="0 ${px * 0.6}" ${once(2600, '0.4 0 0.6 1')}/>`
246    : mood === 'waking' ? `<animateTransform attributeName="transform" type="translate" values="0 ${px * 0.6};0 ${-px * 0.5};0 0" keyTimes="0;0.6;1" dur="1100ms" calcMode="spline" keySplines="0.2 0.8 0.3 1;0.4 0 0.6 1" fill="freeze"/>`
247    : rhythm.shake ? `<animateTransform attributeName="transform" type="translate" values="${-px * 0.6} 0;${px * 0.6} 0;${-px * 0.6} 0" dur="${rhythm.period}ms" ${ease} repeatCount="indefinite"/>`
248    : `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${-px * (mood === 'idle' || mood === 'reading' ? 0.6 : 1)};0 0" dur="${rhythm.period}ms" ${ease} repeatCount="indefinite"/>`
249  const toggle = (dur: number, on: string) => `<animate attributeName="opacity" values="0;1" keyTimes="0;${on}" calcMode="discrete" dur="${dur}ms" repeatCount="indefinite"/>`
250
251  const base = draw(mood, { withExtras: false, look, color })
252  // Lids: a blink now and then; dozing, heavy lids that open less and less; waking, quick bleary blinks.
253  const lids =
254    mood === 'idle' || mood === 'working' || mood === 'reading' ? `<g opacity="0">${rects(draw(mood, { blink: true, withExtras: false, look, color }), base)}${toggle(3700, '0.962')}</g>`
255    : mood === 'dozing' ? `<g opacity="0">${rects(draw(mood, { blink: true, withExtras: false, color }), base)}<animate attributeName="opacity" values="0;1;0;1;0" keyTimes="0;0.1;0.35;0.45;1" calcMode="discrete" dur="2600ms" fill="freeze"/></g>`
256    : mood === 'waking' ? `<g opacity="0">${rects(draw(mood, { blink: true, withExtras: false, color }), base)}<animate attributeName="opacity" values="1;0;1;0" keyTimes="0;0.25;0.4;0.6" calcMode="discrete" dur="1100ms" fill="freeze"/></g>`
257    : ''
258  const glance = mood === 'working' ? `<g opacity="0">${rects(draw(mood, { odd: true, withExtras: false, color }), base)}${toggle(700, '0.5')}</g>` : ''
259
260  // Asleep: z's that drift up and fade, one after another, and a snore bubble that swells and pops.
261  const z = (begin: number) =>
262    `<g opacity="0"><rect x="${px * 7.6}" y="${px * 3.6}" width="${px * 0.9}" height="${px * 0.9}" fill="#cfd8ff"/>`
263    + `<animateTransform attributeName="transform" type="translate" values="0 0;${px * 1.2} ${-px * 3.4}" dur="2400ms" begin="${begin}ms" repeatCount="indefinite"/>`
264    + `<animate attributeName="opacity" values="0;0.95;0" keyTimes="0;0.25;1" dur="2400ms" begin="${begin}ms" repeatCount="indefinite"/></g>`
265  const snore = mood === 'sleepy'
266    ? z(0) + z(800) + z(1600)
267      + `<circle shape-rendering="geometricPrecision" cx="${px * 6.9}" cy="${px * 6.2}" r="0" fill="#7fdbff" fill-opacity="0.55" stroke="#bfefff" stroke-opacity="0.8" stroke-width="0.6"><animate attributeName="r" values="0;${px * 0.9};${px * 1.25};0;0" keyTimes="0;0.55;0.7;0.72;1" dur="3600ms" repeatCount="indefinite"/></circle>`
268    : ''
269  const extrasA = mood === 'sleepy' ? '' : rects(draw(mood, { withBody: false }))
270  const extrasB = mood === 'sleepy' ? '' : rects(draw(mood, { withBody: false, odd: true }))
271  const twinkle = extrasA || extrasB
272    ? `<g>${extrasA}<animate attributeName="opacity" values="1;0" keyTimes="0;0.5" calcMode="discrete" dur="700ms" repeatCount="indefinite"/></g><g opacity="0">${extrasB}${toggle(700, '0.5')}</g>`
273    : ''
274  const fade = from && (from.mood !== mood || from.color !== color)
275    ? `<g opacity="0">${rects(draw(from.mood, { color: from.color }))}<animate attributeName="opacity" values="1;0" dur="350ms" fill="freeze"/></g>`
276    : ''
277  return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH * px}" height="${HEIGHT * px}" viewBox="0 0 ${WIDTH * px} ${HEIGHT * px}" shape-rendering="crispEdges"><g>${rects(base)}${lids}${glance}${motion}</g>${twinkle}${snore}${fade}</svg>`
278}
279
hooks/status.ts 76 lines
1// What the pet says while Claude works, one line per kind of action, and the desktop's slide
2// between one line and the next.
3
4const base = (path: unknown) => (typeof path === 'string' ? path.split('/').filter(Boolean).pop() ?? path : 'a file')
5const clip = (text: unknown, max = 28) => {
6  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
7  return s.length > max ? `${s.slice(0, max - 1)}…` : s
8}
9
10const SHELL: [RegExp, string][] = [
11  [/\b(jest|vitest|pytest|mocha|rspec|phpunit|go test|cargo test|(npm|pnpm|yarn|bun) (run )?test)\b/, 'is running the tests 🧪'],
12  [/\bgit commit\b/, 'is committing the work ✍️'],
13  [/\bgit push\b/, 'is pushing to the remote 🚀'],
14  [/\bgit (pull|fetch|clone)\b/, 'is pulling from git…'],
15  [/\bgit (diff|status|log|show|blame)\b/, 'is checking the git history…'],
16  [/\bgit\b/, 'is talking to git…'],
17  [/\b((npm|pnpm|yarn|bun) (i|install|add)|pip3? install|poetry add|cargo add|brew install|go get|gem install)\b/, 'is installing packages 📦'],
18  [/\b(tsc|webpack|vite build|next build|make|cargo build|go build|gradle|mvn|(npm|pnpm|yarn|bun) run build)\b/, 'is building the project 🔨'],
19  [/\b(eslint|prettier|ruff|black|flake8|rubocop|biome|(npm|pnpm|yarn|bun) run lint)\b/, 'is tidying the code style…'],
20  [/\b(docker|kubectl|helm|terraform)\b/, 'is poking the infrastructure 🐳'],
21  [/\b(curl|wget|http)\b/, 'is fetching something from the web…'],
22  [/^\s*(ls|cat|head|tail|find|tree|pwd|wc)\b/, 'is looking around the repo 👀'],
23  [/\b(rm|mv|cp|mkdir)\b/, 'is moving files around…'],
24]
25
26// The line for a tool call that's starting, or undefined for a tool not worth a line.
27export function actionLine(tool: string, args: Record<string, unknown>): string | undefined {
28  const t = tool
29  if (t === 'Read' || t === 'NotebookRead') return `is reading ${base(args.file_path ?? args.notebook_path)}…`
30  if (t === 'Edit' || t === 'MultiEdit') return `is editing ${base(args.file_path)} ✏️`
31  if (t === 'Write') return `is writing ${base(args.file_path)} ✏️`
32  if (t === 'NotebookEdit') return 'is editing a notebook 📓'
33  if (t === 'Bash') {
34    const cmd = String(args.command ?? '')
35    return SHELL.find(([re]) => re.test(cmd))?.[1] ?? `is running \`${clip(cmd.split(/\s+/)[0], 16)}\`…`
36  }
37  if (t === 'Grep') return `is searching for "${clip(args.pattern, 22)}" 🔍`
38  if (t === 'Glob' || t === 'LS') return `is looking for ${clip(args.pattern ?? args.path, 22)}…`
39  if (t === 'WebSearch') return `is searching the web for "${clip(args.query, 22)}" 🌐`
40  if (t === 'WebFetch') {
41    const host = /^https?:\/\/([^/]+)/.exec(String(args.url ?? ''))?.[1]
42    return `is reading ${host ?? 'a web page'} 🌐`
43  }
44  if (t === 'TodoWrite' || t === 'TaskCreate' || t === 'TaskUpdate') return 'is planning the next steps 📝'
45  if (t === 'EnterPlanMode' || t === 'ExitPlanMode') return 'is drafting a plan 🗺️'
46  if (t === 'AskUserQuestion') return 'has a question for you ❓'
47  if (t === 'Skill') return `is using the ${clip(args.skill ?? args.name, 20)} skill 🧰`
48  if (t === 'Agent' || t === 'Task') return `is sending a helper off ${args.subagent_type ? `(${clip(args.subagent_type, 16)}) ` : ''}🧭`
49  const mcp = /^mcp__([^_]+(?:_[^_]+)*)__/.exec(t)?.[1]
50  if (mcp) return `is asking ${clip(mcp.replace(/[-_]/g, ' '), 20)}…`
51  return undefined
52}
53
54export type LineColors = { name: string; text: string }
55export const LINE_COLORS: Record<'dark' | 'light', LineColors> = {
56  dark: { name: '#d97757', text: '#e6e6e6' },
57  light: { name: '#c4613f', text: '#1f2328' },
58}
59
60const escape = (s: string) => s.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]!)
61
62// Desktop: the line as SVG text. A new line rises from below while the old one drifts up and
63// fades. Each layer rests where a still image should be, so without SMIL only the new line shows.
64export const lineWidth = (name: string, line: string, previous?: string) => Math.min(640, Math.ceil((name.length + 1 + Math.max(line.length, previous?.length ?? 0)) * 7.9) + 24)
65
66export function lineSvg(name: string, line: string, previous: string | undefined, colors: LineColors): string {
67  const width = lineWidth(name, line, previous)
68  const text = (l: string) => `<tspan fill="${colors.name}" font-weight="700">${escape(name)}</tspan><tspan fill="${colors.text}"> ${escape(l)}</tspan>`
69  const ease = 'dur="0.38s" calcMode="spline" keyTimes="0;1" keySplines="0.2 0.8 0.2 1" fill="freeze"'
70  const old = previous !== undefined && previous !== line
71    ? `<text x="0" y="-4" opacity="0">${text(previous)}<animate attributeName="y" values="15;-4" ${ease}/><animate attributeName="opacity" values="1;0" ${ease}/></text>`
72    : ''
73  const rise = old ? `<animate attributeName="y" values="32;15" ${ease}/><animate attributeName="opacity" values="0;1" ${ease}/>` : ''
74  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" viewBox="0 0 ${width} 20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, system-ui, sans-serif" font-size="14">${old}<text x="0" y="15">${text(line)}${rise}</text></svg>`
75}
76
hooks/mini.ts 89 lines
1// The pet's little helpers, one per running subagent: half its size (5x4 body, plus a column for a
2// waving arm), in the agent's own colour. They pop in, bob while their agent works, and when it's
3// done they wave bye, float up and fade away.
4import type { Grid } from './sprite'
5import { COLOR_NAMES, hex, paletteFor } from './sprite'
6
7export const MINI_W = 6
8export const MINI_H = 4
9export const ARRIVE_MS = 600
10export const WAVE_MS = 1300
11export const LEAVE_MS = 2200
12
13const INK = 0x1a1a2e
14const BODY = ['.ooo.', 'okbko', 'obbbo', '.o.o.']
15
16// A stable colour for an agent with none of its own, never the parent's.
17export function miniColor(id: string, avoid: string): string {
18  const names = COLOR_NAMES.filter(name => name !== avoid && name !== 'black' && name !== 'white')
19  let h = 0
20  for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0
21  return names[h % names.length]!
22}
23
24// The Claude Code agent colours as a definition file names them, onto the pet's palette.
25const AGENT_COLORS: Record<string, string> = { red: 'red', blue: 'blue', green: 'green', yellow: 'yellow', purple: 'purple', orange: 'orange', pink: 'pink', cyan: 'cyan' }
26export const colorFromDefinition = (markdown: string) => {
27  const front = /^---\n([\s\S]*?)\n---/.exec(markdown)?.[1] ?? ''
28  const named = /^color:\s*["']?([a-z]+)/im.exec(front)?.[1]?.toLowerCase()
29  return named ? AGENT_COLORS[named] ?? (COLOR_NAMES.includes(named) ? named : undefined) : undefined
30}
31
32export function miniDraw(color: string, { blink = false, armUp = false, arm = false } = {}): Grid {
33  const { body, edge } = paletteFor(color)
34  const paint: Record<string, number> = { o: edge, b: body, k: blink ? body : INK }
35  const grid: Grid = BODY.map(row => [...row].map(c => (c === '.' ? null : paint[c]!)))
36  for (const row of grid) row.push(null)
37  if (arm) grid[armUp ? 0 : 1]![5] = edge
38  return grid
39}
40
41// Terminal frames, by how long the mini has been in its state.
42export function miniFrame(color: string, state: 'here' | 'bye', age: number, seed: number): Grid | null {
43  if (state === 'here' && age < ARRIVE_MS) {
44    // A sparkle, then the body: a pop in two beats.
45    if (age < 200) return Array.from({ length: MINI_H }, (_, y) => Array.from({ length: MINI_W }, (_, x) => (x === 2 && y === 1 ? 0xffe066 : null)))
46    return miniDraw(color)
47  }
48  if (state === 'bye') {
49    if (age < WAVE_MS) return miniDraw(color, { arm: true, armUp: Math.floor(age / 220) % 2 === 0 })
50    if (age < WAVE_MS + 300) return miniDraw(color, { blink: true })
51    if (age < WAVE_MS + 550) return Array.from({ length: MINI_H }, (_, y) => Array.from({ length: MINI_W }, (_, x) => (x === 2 && y === 1 ? 0xcfd8ff : null)))
52    return null
53  }
54  return miniDraw(color, { blink: (age + seed * 377) % 3100 < 130 })
55}
56
57export const seedOf = (id: string) => [...id].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7) % 997
58
59// Desktop: one helper drawn at its slot's origin, its story told by its age: once its slot has
60// opened (SLOT_MS) it pops in with a little spring, then bobs and blinks at its own pace; leaving,
61// it waves, floats up and fades. Laid out by the band's second line (line2.ts).
62export const SLOT_MS = 380
63
64export function miniBody(mini: { id: string; color: string; state: 'here' | 'bye'; age: number }, px = 5): string {
65  const w = MINI_W * px
66  const h = MINI_H * px
67  const rects = (grid: Grid) => grid.map((row, y) => row.map((c, x) => (c === null ? '' : `<rect x="${x * px}" y="${y * px}" width="${px}" height="${px}" fill="${hex(c)}"/>`)).join('')).join('')
68  const seed = seedOf(mini.id)
69  if (mini.state === 'bye') {
70    const wave = (up: boolean) => `<g opacity="${up ? 1 : 0}">${rects(miniDraw(mini.color, { arm: true, armUp: up }))}<animate attributeName="opacity" values="${up ? '1;0' : '0;1'}" keyTimes="0;0.5" calcMode="discrete" dur="440ms" repeatCount="${Math.ceil(WAVE_MS / 440)}" fill="freeze"/></g>`
71    const fadeAt = Math.max(0, WAVE_MS - mini.age)
72    return `<g>${wave(true)}${wave(false)}`
73      + `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${-px * 1.6}" dur="800ms" begin="${fadeAt}ms" calcMode="spline" keyTimes="0;1" keySplines="0.3 0 0.7 1" fill="freeze"/>`
74      + `<animate attributeName="opacity" values="1;0" dur="800ms" begin="${fadeAt}ms" fill="freeze"/></g>`
75  }
76  const spline = 'calcMode="spline" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"'
77  const bob = `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${-px * 0.7};0 0" keyTimes="0;0.5;1" dur="${560 + (seed % 240)}ms" ${spline} repeatCount="indefinite"/>`
78  const blink = `<g opacity="0">${rects(miniDraw(mini.color, { blink: true }))}<animate attributeName="opacity" values="0;1" keyTimes="0;0.96" calcMode="discrete" dur="${2900 + (seed % 900)}ms" begin="${-(seed % 2900)}ms" repeatCount="indefinite"/></g>`
79  const body = `<g>${rects(miniDraw(mini.color))}${blink}${bob}</g>`
80  if (mini.age >= SLOT_MS + ARRIVE_MS) return body
81  // Not yet popped: hold at nothing while the slot opens, then spring out from the middle.
82  const wait = Math.max(0, SLOT_MS - mini.age)
83  const cx = (w - px) / 2
84  const cy = h / 2
85  return `<g transform="translate(${cx} ${cy})"><g transform="scale(${wait > 0 ? 0 : 1})">`
86    + `<animateTransform attributeName="transform" type="scale" values="0;1.2;1" keyTimes="0;0.65;1" dur="${ARRIVE_MS}ms" begin="${wait}ms" calcMode="spline" keySplines="0.2 0.8 0.3 1;0.4 0 0.6 1" fill="freeze"/>`
87    + `<g transform="translate(${-cx} ${-cy})">${body}</g></g></g>`
88}
89
hooks/line2.ts 90 lines
1// Desktop: the band's second line as one SVG: the helpers on the left, newest first, then the
2// context bar, its percent, the usage windows, the bill and the ⓘ. Each element starts where the
3// previous draw left it and glides to its new place, so a helper's slot opening pushes the line
4// right and one closing pulls it back.
5import { FONT, gradientAt, hex, limitsParts, meterParts } from './meter'
6import type { Limit, Tones } from './meter'
7import { MINI_W, miniBody } from './mini'
8
9export type Layout = Map<string, number>
10export type LineTwo = {
11  minis: { id: string; color: string; state: 'here' | 'bye'; age: number }[]
12  percent?: number
13  from: number
14  isWorking: boolean
15  limits: Limit[]
16  resets?: string
17  bill?: string
18  hasInfo: boolean
19  tones: Tones & { text: string }
20}
21
22const PX = 5
23const SLOT = MINI_W * PX + 4
24const HEIGHT = 26
25const PILL = 110
26const GLIDE = 'dur="420ms" calcMode="spline" keyTimes="0;1" keySplines="0.25 0.8 0.25 1" fill="freeze"'
27
28export function lineTwoSvg(line: LineTwo, previous: Layout | undefined): { svg: string; width: number; layout: Layout } {
29  const layout: Layout = new Map()
30  const minis = line.minis.filter(m => m.age >= 0)
31  minis.forEach((m, i) => layout.set(m.id, i * SLOT))
32  const contentX = minis.length ? minis.length * SLOT + 4 : 0
33  layout.set('content', contentX)
34
35  // Where an element was on the last draw, to glide from; a new helper has no past and pops in.
36  const place = (key: string, y: number) => {
37    const x = layout.get(key)!
38    const was = previous?.get(key)
39    const move = was !== undefined && Math.abs(was - x) > 0.5
40      ? `<animateTransform attributeName="transform" type="translate" values="${was} ${y};${x} ${y}" ${GLIDE}/>`
41      : ''
42    return { open: `<g transform="translate(${x} ${y})">${move}`, close: '</g>' }
43  }
44
45  let defs = ''
46  let content = ''
47  let x = 0
48  if (line.percent === undefined) {
49    content = `<text x="0" y="17.5" fill="${line.tones.label}">context · waiting for the first reply</text>`
50    x = 230
51  } else {
52    const meter = meterParts(line.percent, line.from, line.isWorking, PILL)
53    defs = meter.defs
54    const pct = `${line.percent}%`
55    content = `<g transform="translate(3 7)">${meter.body}</g>`
56      + `<text x="${PILL + 12}" y="17.5" fill="${hex(gradientAt(line.percent / 100))}" font-weight="700">${pct}</text>`
57    x = PILL + 12 + pct.length * 8.4 + 12
58    if (line.limits.length) {
59      const limits = limitsParts(line.limits, line.tones)
60      content += `<g transform="translate(${x.toFixed(1)} 5)">${limits.body}</g>`
61      x += limits.width + 10
62    }
63    if (line.resets) {
64      content += `<text x="${x.toFixed(1)}" y="17.5" fill="${line.tones.label}">${line.resets}</text>`
65      x += line.resets.length * 7.1 + 10
66    }
67    if (line.bill) {
68      content += `<text x="${x.toFixed(1)}" y="17.5" fill="${line.tones.text}" font-weight="700">${line.bill}</text>`
69      x += line.bill.length * 8.4 + 10
70    }
71  }
72  if (line.hasInfo) {
73    content += `<text x="${x.toFixed(1)}" y="17.5" fill="${line.tones.label}">ⓘ</text>`
74    x += 14
75  }
76
77  const helpers = minis.map(m => {
78    const at = place(m.id, 5)
79    return `${at.open}${miniBody(m, PX)}${at.close}`
80  })
81  const body = place('content', 0)
82  const width = Math.ceil(Math.max(contentX + x, (previous?.get('content') ?? 0) + x))
83  return {
84    layout,
85    width,
86    svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}" ${FONT}><defs>${defs}</defs>`
87      + `<g shape-rendering="crispEdges">${helpers.join('')}</g>${body.open}${content}${body.close}</svg>`,
88  }
89}
90
hooks/minis-view.tsx 75 lines
1// The terminal's helpers, left of the context bar, newest first, on the surface's own clock. A new
2// one first widens its slot a column at a time, pushing the bar along, then sparkles in; a leaving
3// one waves, shrinks to a speck, and its slot narrows back.
4import type { ClientModule } from 'claude-code'
5
6import { LEAVE_MS, MINI_W, SLOT_MS, miniFrame, seedOf } from './mini'
7import { toRunsOf } from './sprite'
8
9export type MinisViewProps = { minis: { id: string; color: string; state: 'here' | 'bye'; age: number }[]; stamp: number }
10
11type Ref = { t: number; stamp: number }
12type State = { ref: Ref }
13
14const FRAME_MS = 40
15const SLOT = MINI_W + 1
16const CLOSE_MS = 300
17
18// How wide a helper's slot is at this age: opening, full, then closing as it leaves.
19export function slotWidth(state: 'here' | 'bye', age: number): number {
20  if (age < 0) return 0
21  if (state === 'here') return Math.min(SLOT, Math.round((age / SLOT_MS) * SLOT))
22  const closing = age - (LEAVE_MS - CLOSE_MS)
23  return closing <= 0 ? SLOT : Math.max(0, Math.round(SLOT * (1 - closing / CLOSE_MS)))
24}
25
26const MinisView: ClientModule<MinisViewProps, State> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  const ref = surface.state?.ref ?? { t: 0, stamp: props.stamp }
29  if (props.stamp !== ref.stamp) {
30    // Fresh ages from the band; count on from them.
31    ref.stamp = props.stamp
32    ref.t = 0
33  }
34  if (surface.state === undefined) {
35    surface.setState({ ref })
36    surface.every(FRAME_MS, () => {
37      ref.t += FRAME_MS
38      surface.setState({ ref })
39    })
40  }
41
42  const slots = props.minis.map(m => {
43    const age = m.age + ref.t
44    const width = slotWidth(m.state, age)
45    // The helper itself waits for its slot to open; its own story starts then.
46    const grid = width === SLOT || m.state === 'bye' ? miniFrame(m.color, m.state, m.state === 'here' ? age - SLOT_MS : age, seedOf(m.id)) : null
47    const rows = grid ? toRunsOf(grid, MINI_W) : [[{ text: ' '.repeat(MINI_W) }], [{ text: ' '.repeat(MINI_W) }]]
48    return { width, rows }
49  })
50  return (
51    <Box flexDirection="column">
52      {[0, 1].map(row => (
53        <Text>
54          {slots.flatMap(slot => {
55            if (slot.width === 0) return []
56            // A slot narrower than the helper shows its left part: the room it has so far.
57            let room = slot.width
58            const out = []
59            for (const run of slot.rows[row]!) {
60              if (room <= 0) break
61              const text = run.text.slice(0, room)
62              room -= text.length
63              out.push(run.fg ? <Text color={run.fg} backgroundColor={run.bg}>{text}</Text> : <Text>{text}</Text>)
64            }
65            if (room > 0) out.push(<Text>{' '.repeat(room)}</Text>)
66            return out
67          })}
68        </Text>
69      ))}
70    </Box>
71  )
72}
73
74export default MinisView
75
hooks/status-view.tsx 42 lines
1// The terminal's status line. Text there moves in whole rows only, so a change can't glide; it
2// rolls instead: the old words fade out, then the new ones fade in, in about a quarter second.
3import type { ClientModule } from 'claude-code'
4
5export type StatusViewProps = { name: string; line: string }
6
7type Ref = { t: number; line: string; previous?: string; changedAt: number }
8type State = { ref: Ref }
9
10const FRAME_MS = 50
11const OUT_MS = 110
12const GAP_MS = 40
13const IN_MS = 130
14
15const StatusView: ClientModule<StatusViewProps, State> = (props, surface) => {
16  const { Text } = surface.elements
17  const ref = surface.state?.ref ?? { t: 0, line: props.line, changedAt: -Infinity }
18  if (props.line !== ref.line) {
19    ref.previous = ref.line
20    ref.line = props.line
21    ref.changedAt = ref.t
22  }
23  if (surface.state === undefined) {
24    surface.setState({ ref })
25    surface.every(FRAME_MS, () => {
26      ref.t += FRAME_MS
27      if (ref.t - ref.changedAt <= OUT_MS + GAP_MS + IN_MS + FRAME_MS) surface.setState({ ref })
28    })
29  }
30
31  const since = ref.t - ref.changedAt
32  const name = <Text bold color="claude">{props.name}</Text>
33  if (ref.previous !== undefined && since < OUT_MS) {
34    return <Text wrap="truncate">{name}<Text dimColor> {ref.previous}</Text></Text>
35  }
36  if (ref.previous !== undefined && since < OUT_MS + GAP_MS) return <Text wrap="truncate">{name}</Text>
37  const isFadingIn = ref.previous !== undefined && since < OUT_MS + GAP_MS + IN_MS
38  return <Text wrap="truncate">{name}<Text dimColor={isFadingIn}> {ref.line}</Text></Text>
39}
40
41export default StatusView
42
hooks/hit.tsx 46 lines
1// An invisible region laid over the desktop's SVG sprite: a click on it pets the pet. A heart pops
2// where you clicked at once, from here, while the pet's own reaction makes its round trip.
3import type { ClientModule } from 'claude-code'
4
5type Ref = { heart?: { x: number; y: number; at: number }; lastPet: number; t: number }
6type State = { ref: Ref }
7
8const HEART_MS = 700
9
10const Hit: ClientModule<{ columns: number; rows: number }, State> = (props, surface) => {
11  const { Box, Text } = surface.elements
12  const ref = surface.state?.ref ?? { lastPet: -Infinity, t: 0 }
13  if (surface.state === undefined) {
14    surface.setState({ ref })
15    surface.every(100, () => {
16      ref.t += 100
17      if (ref.heart && ref.t - ref.heart.at > HEART_MS) {
18        ref.heart = undefined
19        surface.setState({ ref })
20      }
21    })
22    // Some clicks arrive as a down, some only as an up: either pets, once per click.
23    surface.onPointer(event => {
24      if (event.type !== 'down' && event.type !== 'up') return
25      if (ref.t - ref.lastPet < 300) return
26      ref.lastPet = ref.t
27      ref.heart = { x: event.x, y: event.y, at: ref.t }
28      surface.setState({ ref })
29      surface.post({ pet: true })
30    })
31  }
32
33  const lines = Array.from({ length: Math.max(1, props.rows) }, (_, y) => {
34    const row = ' '.repeat(Math.max(1, props.columns)).split('')
35    if (ref.heart && ref.heart.y === y) row[Math.min(row.length - 1, Math.max(0, ref.heart.x))] = '♥'
36    return row.join('')
37  })
38  return (
39    <Box flexDirection="column">
40      {lines.map(line => <Text color="#ff5c8a" bold>{line}</Text>)}
41    </Box>
42  )
43}
44
45export default Hit
46
types/index.d.ts 30 lines
1export type Mood = 'idle' | 'working' | 'happy' | 'oops' | 'sleepy' | 'loved' | 'reading' | 'wince' | 'shocked' | 'dozing' | 'waking'
2
3// A subagent's little helper beside the pet: here while its agent works, waving bye when it's done.
4export type Mini = { id: string; name: string; color: string; state: 'here' | 'bye'; at: number }
5
6export type ContextMeter = {
7  percent?: number
8  tokens?: number
9  window?: number
10  usd?: number
11  limits?: { kind: string; percent: number; resetsAt?: string }[]
12  source?: 'plan' | 'api' | 'bedrock' | 'vertex' | 'foundry' | 'gateway'
13}
14
15export type PetView = {
16  mood: Mood
17  line: string
18  name: string
19  color: string
20  isHidden: boolean
21  meter: ContextMeter
22  minis: Mini[]
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'pixel-pet': { view: PetView }
28  }
29}
30