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

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.
![]()
![]()
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./pet color pink changes it. As you type the name it tries each colour on, and the names show up as suggestions.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/pixel-pet
/plugin install pixel-pet@pixel-pet
| Command | |
|---|---|
/pet | Its 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 show | Tuck it away or bring it back |
Settings (/config): name, color.
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.
Part of lakmadev/claude-mods, a pack of eleven Claude Code mods. MIT licensed.
hooks/register.tsx 539 lines1import { 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}
539hooks/meter.ts 185 lines1// 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}
185hooks/meter-view.tsx 47 lines1// 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
47hooks/pet-view.tsx 59 lines1// 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
59hooks/sprite.ts 279 lines1import 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}
279hooks/status.ts 76 lines1// 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 => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[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}
76hooks/mini.ts 89 lines1// 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}
89hooks/line2.ts 90 lines1// 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}
90hooks/minis-view.tsx 75 lines1// 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
75hooks/status-view.tsx 42 lines1// 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
42hooks/hit.tsx 46 lines1// 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
46types/index.d.ts 30 lines1export 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