A pixel-art crab above the prompt that reacts live to what Claude is doing. /pet for commands.

Adds a pixel crab above the prompt. It reacts when Claude reads, writes, waits, finishes, or hits an error. Its belly reflects conversation fullness.
See the complete guide and creation prompt.
Test from the kit root:
claude plugin validate plugins/terminal-pet
claude plugin test plugins/terminal-pethooks/terminal-pet.mjs 292 lines1// Terminal Pet: a pixel-art crab above the prompt that reacts to what Claude
2// is doing. Visual only: no filesystem, network, model or permission calls.
3// Commands: /pet, /pet on|off, /pet name <name>, /pet sleep, /pet party, /pet wake,
4// /pet feed [percent] (fakes the context fill for a demo of the round and stuffed looks).
5
6import { update } from "claude-code";
7import { COLS, ROWS, frameCells, fullness, desktopSvg } from "./sprites.mjs";
8import {
9 ASCII_FACES,
10 EMPTY_PET,
11 EMPTY_STATS,
12 MOOD_WORDS,
13 PARTY_MS,
14 addUnique,
15 classify,
16 moodAt,
17 normalizePrefs,
18 onToolEnd,
19 onToolStart,
20 onTurnEnd,
21 onTurnStart,
22} from "./pet-core.mjs";
23
24const prefsRef = { plugin: "terminal-pet", key: "prefs" };
25const petRef = { plugin: "terminal-pet", key: "pet" };
26const statsRef = { plugin: "terminal-pet", key: "stats" };
27const fillRef = { plugin: "terminal-pet", key: "fill" };
28const STORE_KEY = "prefs";
29const RASTER_KEY = "pet";
30
31const CORAL = "#e8603c";
32const CREAM = "#f6efe3";
33const MUTED = "#b8ad9c";
34const RED = "#e23b3b";
35
36const FAST_MS = 83; // about 12 fps while something is happening
37const SLOW_MS = 220; // idle bob, blinks and snores
38
39// The animation loop. A hot reload drops it with the old environment; the next
40// draw starts a new one.
41const anim = { surface: "", requestId: "", timer: null, period: 0, pet: null, fill: 0, mood: "", cells: "" };
42
43function stopTicker() {
44 if (anim.timer) anim.timer.cancel();
45 anim.timer = null;
46 anim.period = 0;
47 anim.cells = "";
48}
49
50export function register(on) {
51 on("session.start", async ($, e, next) => {
52 const result = await next(e);
53 const saved = await $.store.get(STORE_KEY);
54 const now = await $.clock.now();
55 await $.state.set(prefsRef, normalizePrefs(saved));
56 // A hot reload raises session.start again: keep what this session already counted.
57 const { value: pet } = await $.state.get(petRef);
58 if (!pet) await $.state.set(petRef, { ...EMPTY_PET, since: now, last: now });
59 const { value: stats } = await $.state.get(statsRef);
60 if (!stats) await $.state.set(statsRef, EMPTY_STATS);
61 const u = await $.session.usage();
62 await $.state.set(fillRef, percentOf(u && u.context));
63 await $.command.register({
64 name: "pet",
65 description: "Terminal pet: status, or on, off, name <name>, sleep, party, wake, feed [percent]",
66 argumentHint: "[on|off|name <name>|sleep|party|wake|feed [percent]]",
67 immediate: true,
68 });
69 return result;
70 });
71
72 on("command.run", { command: "pet" }, async ($, e) => {
73 const { value: current } = await $.state.get(prefsRef);
74 const prefs = normalizePrefs(current);
75 const raw = (e.args || "").trim();
76 const [word, ...rest] = raw.split(/\s+/);
77 const arg = (word || "").toLowerCase();
78 const now = await $.clock.now();
79 let nextPrefs = prefs;
80 let note = "";
81
82 if (arg === "on") nextPrefs = { ...prefs, enabled: true };
83 else if (arg === "off") nextPrefs = { ...prefs, enabled: false };
84 else if (arg === "name") {
85 const name = rest.join(" ").trim();
86 if (!name) return { text: `Give it a name, like /pet name Pinch.` };
87 nextPrefs = { ...prefs, name: normalizePrefs({ name }).name };
88 note = `Say hi to ${nextPrefs.name}.`;
89 } else if (arg === "sleep") {
90 await update($, petRef, (p) => ({ ...EMPTY_PET, ...p, forced: "sleep", since: now }));
91 note = `${prefs.name} curls up for a nap.`;
92 } else if (arg === "party") {
93 await update($, petRef, (p) => ({ ...EMPTY_PET, ...p, forced: "party", since: now, until: now + PARTY_MS, last: now }));
94 note = `${prefs.name} throws a party.`;
95 } else if (arg === "feed") {
96 const pct = Math.max(0, Math.min(100, parseInt(rest[0], 10) || 95));
97 await $.state.set(fillRef, pct);
98 note = `${prefs.name} ate a big snack, belly at ${pct}% until the next real context reading.`;
99 } else if (arg === "wake") {
100 await update($, petRef, (p) => ({ ...EMPTY_PET, ...p, forced: "", mood: "idle", until: 0, since: now, last: now }));
101 note = `${prefs.name} is awake.`;
102 } else if (arg !== "" && arg !== "status" && arg !== "help") {
103 return { text: `Unknown option "${raw}". ${HELP}` };
104 }
105
106 if (nextPrefs !== prefs) {
107 await $.state.set(prefsRef, nextPrefs);
108 await $.store.set(STORE_KEY, nextPrefs);
109 }
110 const { value: pet } = await $.state.get(petRef);
111 const { value: stats } = await $.state.get(statsRef);
112 const status = statusLine(nextPrefs, moodAt(pet, now), stats);
113 return { text: note ? `${note} ${status}` : `${status} ${HELP}` };
114 });
115
116 on("turn.start", async ($, e, next) => {
117 const result = await next(e);
118 const now = await $.clock.now();
119 await update($, petRef, (p) => onTurnStart(p, now));
120 return result;
121 });
122
123 on("turn.complete", async ($, e, next) => {
124 const result = await next(e);
125 if (e.agentId) return result; // a subagent's run, not the main turn
126 const now = await $.clock.now();
127 await update($, petRef, (p) => onTurnEnd(p, e.reason, e.isAborted, now));
128 return result;
129 });
130
131 on("tool.call", async ($, e, next) => {
132 const mood = classify(e.tool, e);
133 const start = await $.clock.now();
134 await update($, petRef, (p) => onToolStart(p, mood, start));
135 const ran = await next(e);
136 const isError = ran.deny === undefined && ran.isError === true;
137 const end = await $.clock.now();
138 await update($, petRef, (p) => onToolEnd(p, mood, isError, end));
139 if (!isError && ran.deny === undefined) {
140 const path = typeof e.file_path === "string" ? e.file_path : typeof e.notebook_path === "string" ? e.notebook_path : "";
141 if (e.tool === "Read" && path) await update($, statsRef, (s) => ({ ...EMPTY_STATS, ...s, reads: addUnique((s && s.reads) || [], path) }));
142 else if (mood === "typing" && path) await update($, statsRef, (s) => ({ ...EMPTY_STATS, ...s, writes: addUnique((s && s.writes) || [], path) }));
143 else if (mood === "running" || mood === "nervous") {
144 await update($, statsRef, (s) => ({ ...EMPTY_STATS, ...s, ran: ((s && s.ran) || 0) + 1, risky: ((s && s.risky) || 0) + (mood === "nervous" ? 1 : 0) }));
145 }
146 }
147 return ran;
148 });
149
150 on("session.measure", async ($, e, next) => {
151 const result = await next(e);
152 if (e.changed.includes("context")) await $.state.set(fillRef, percentOf(e.context));
153 return result;
154 });
155
156 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
157 const original = await next(e);
158 const { value: rawPrefs } = await $.state.get(prefsRef);
159 const prefs = normalizePrefs(rawPrefs);
160 if (!prefs.enabled || e.props.hasSurvey) {
161 stopTicker();
162 return original;
163 }
164 const { value: pet = EMPTY_PET } = await $.state.get(petRef);
165 const { value: stats = EMPTY_STATS } = await $.state.get(statsRef);
166 const { value: fill = 0 } = await $.state.get(fillRef);
167 const now = await $.clock.now();
168 const mood = moodAt(pet, now);
169 const { Box, Text } = $.ui.resolve(e);
170 const stack = (pet) => (original ? Box({ flexDirection: "column", children: [original, pet] }) : pet);
171 const columns = e.props.bodyColumns || 80;
172
173 if (anim.surface !== e.surface) { stopTicker(); anim.surface = e.surface; }
174 if (e.surface === "desktop") {
175 anim.pet = pet;
176 anim.mood = mood;
177 // SVG runs its own frames. This timer only updates expired moods.
178 if (!anim.timer) {
179 anim.timer = $.clock.every(SLOW_MS, () => {
180 void (async () => {
181 const shown = moodAt(anim.pet, await $.clock.now());
182 if (shown !== anim.mood) {
183 anim.mood = shown;
184 $.ui.invalidate("ui.render");
185 }
186 })();
187 });
188 }
189 const { Svg } = $.ui.resolve(e);
190 return stack(Box({ flexDirection: "row", children: [
191 Svg({ source: desktopSvg(mood, fill), width: 200, height: 110,
192 alt: `${prefs.name}: ${MOOD_WORDS[mood] || mood}`, isInteractive: true }),
193 sideColumn(Box, Text, prefs, mood, stats, fill),
194 ] }));
195 }
196
197 const fits = e.surface === "terminal" && columns >= COLS + 2 && (e.props.maxRows || 0) >= ROWS + 1;
198 if (!fits) {
199 stopTicker();
200 return stack(fallbackLine(Text, prefs, mood, stats, fill));
201 }
202
203 anim.requestId = e.requestId;
204 anim.pet = pet;
205 anim.fill = fill;
206 anim.mood = mood;
207 const cells = frameCells(mood, now, fill);
208 anim.cells = cells;
209
210 const { Raster } = $.ui.resolve(e);
211 const parts = [Raster({ key: RASTER_KEY, columns: COLS, rows: ROWS, cells })];
212 if (columns >= COLS + 24) parts.push(sideColumn(Box, Text, prefs, mood, stats, fill));
213
214 // Keep the loop at the pace this mood needs.
215 const period = mood === "idle" || mood === "sleeping" ? SLOW_MS : FAST_MS;
216 if (!anim.timer || anim.period !== period) {
217 if (anim.timer) anim.timer.cancel();
218 anim.period = period;
219 anim.timer = $.clock.every(period, () => {
220 void (async () => {
221 const at = await $.clock.now();
222 if (!anim.pet || !anim.requestId) return;
223 const shown = moodAt(anim.pet, at);
224 if (shown !== anim.mood) {
225 anim.mood = shown;
226 $.ui.invalidate("ui.render"); // the mood word and the loop's pace follow
227 }
228 const fresh = frameCells(shown, at, anim.fill);
229 if (fresh === anim.cells) return;
230 anim.cells = fresh;
231 const r = await $.ui.blit({ requestId: anim.requestId, key: RASTER_KEY, cells: fresh });
232 if (r && r.deny) stopTicker();
233 })();
234 });
235 }
236
237 return stack(Box({ flexDirection: "row", paddingLeft: 1, children: parts }));
238 });
239}
240
241const HELP = "Try /pet on, /pet off, /pet name <name>, /pet sleep, /pet party, /pet wake, /pet feed [percent].";
242
243function percentOf(context) {
244 if (!context) return 0;
245 if (typeof context.percent === "number") return Math.round(context.percent);
246 if (context.tokens && context.window) return Math.round((context.tokens / context.window) * 100);
247 return 0;
248}
249
250function plural(n, word) {
251 return `${n} ${word}${n === 1 ? "" : "s"}`;
252}
253
254function bellyWord(fill) {
255 const f = fullness(fill);
256 return f === "stuffed" ? "stuffed" : f === "round" ? "getting full" : "";
257}
258
259function statusLine(prefs, mood, stats) {
260 const s = { ...EMPTY_STATS, ...(stats || {}) };
261 const on = prefs.enabled ? "" : " (hidden, /pet on to show)";
262 return `${prefs.name} (${MOOD_WORDS[mood] || mood})${on} ate ${plural(s.reads.length, "file")}, wrote ${s.writes.length}, ran ${s.ran}.`;
263}
264
265function sideColumn(Box, Text, prefs, mood, stats, fill) {
266 const s = { ...EMPTY_STATS, ...(stats || {}) };
267 const alarm = mood === "nervous" || mood === "oops";
268 const belly = bellyWord(fill);
269 const rows = [
270 Text({ color: CORAL, bold: true, wrap: "truncate", children: prefs.name }),
271 Text({ color: alarm ? RED : CREAM, bold: alarm, wrap: "truncate", children: MOOD_WORDS[mood] || mood }),
272 Text({ children: " " }),
273 Text({ color: CREAM, wrap: "truncate", children: `ate ${plural(s.reads.length, "file")}` }),
274 Text({ color: CREAM, wrap: "truncate", children: `wrote ${s.writes.length}` }),
275 Text({ color: MUTED, wrap: "truncate", children: `ran ${s.ran}${s.risky ? `, ${s.risky} scary` : ""}` }),
276 Text({ color: fill >= 90 ? RED : fill >= 70 ? CORAL : MUTED, wrap: "truncate", children: `belly ${fill}%${belly ? `, ${belly}` : ""}` }),
277 ];
278 return Box({ flexDirection: "column", marginLeft: 2, marginTop: 2, flexShrink: 1, children: rows });
279}
280
281function fallbackLine(Text, prefs, mood, stats, fill) {
282 const s = { ...EMPTY_STATS, ...(stats || {}) };
283 const face = `(\\/)${ASCII_FACES[mood] || ASCII_FACES.idle}(\\/)`;
284 const z = mood === "sleeping" ? " zzz" : "";
285 return Text({
286 color: CORAL,
287 wrap: "truncate",
288 children: `${face}${z} ${prefs.name} · ${MOOD_WORDS[mood] || mood} · ate ${plural(s.reads.length, "file")} · wrote ${s.writes.length} · belly ${fill}%`,
289 });
290}
291
292hooks/sprites.mjs 585 lines1// Terminal Pet sprites: hand-authored pixel grids, a palette map, and a pure
2// frame composer. No engine calls in here, so tests and previews can use it.
3//
4// The canvas is W x H pixels. Each terminal cell shows two pixels stacked with
5// the upper half block (fg = top pixel, bg = bottom pixel), so the Raster is
6// W columns by H / 2 rows.
7
8export const W = 40;
9export const H = 22;
10export const COLS = W;
11export const ROWS = H / 2;
12
13const DEFAULT = 0x01000000; // the terminal's own color
14const NONE = -1;
15
16export const PALETTE = {
17 O: 0x8a2f1b, // outline, dark coral
18 C: 0xe8603c, // coral body
19 D: 0xc44a2a, // shade
20 L: 0xf4895f, // light coral
21 W: 0xf6efe3, // cream
22 K: 0x141413, // ink (eyes, mouth)
23 P: 0xff8f8f, // blush and tongue
24 G: 0xb8ad9c, // bubble and book outline
25 M: 0x8a8178, // text lines in the book
26 S: 0x8fd3ff, // sweat
27 Y: 0xffd166, // sparks and stars
28 B: 0x9cc5a1, // sage confetti
29};
30
31// Body colors swapped in for the red alarm flash.
32const ALARM = { C: 0xe23b3b, L: 0xf06a6a, D: 0xb32626, O: 0x6e1414 };
33
34// --- shells: normal, round (context > 70%), stuffed (context > 90%) --------
35
36export const SHELLS = {
37 normal: {
38 mouthY: 6,
39 blushY: 5,
40 grid: [
41 ".....OOOOOO.....",
42 "...OOCCCCCCOO...",
43 "..OCWWCCCCCCCO..",
44 ".OCWLCCCCCCCCCO.",
45 ".OCLCCCCCCCCCCO.",
46 "OCCCCCCCCCCCCCCO",
47 "OCCCCCCCCCCCCCCO",
48 "ODCCCCCCCCCCCCDO",
49 ".ODDCCCCCCCCDDO.",
50 "..OOOOOOOOOOOO..",
51 ],
52 },
53 round: {
54 mouthY: 6,
55 blushY: 5,
56 grid: [
57 "......OOOOOO......",
58 "....OOCCCCCCOO....",
59 "..OOCWWCCCCCCCOO..",
60 ".OCCWLCCCCCCCCCCO.",
61 ".OCLCCCCCCCCCCCCO.",
62 "OCCCCCCCCCCCCCCCCO",
63 "OCCCCCCCCCCCCCCCCO",
64 "OCCCCCCCCCCCCCCCCO",
65 "ODCCCCCCCCCCCCCCDO",
66 ".ODDCCCCCCCCCCDDO.",
67 "..OOOOOOOOOOOOOO..",
68 ],
69 },
70 stuffed: {
71 mouthY: 5,
72 blushY: 4,
73 grid: [
74 ".......OOOOOO.......",
75 ".....OOCCCCCCOO.....",
76 "...OOCWWCCCCCCCOO...",
77 "..OCCWLCCCCCCCCCCO..",
78 ".OCCLCCCCCCCCCCCCCO.",
79 ".OCCCCCCCCCCCCCCCCO.",
80 "OCCCCCCCCCCCCCCCCCCO",
81 "OCCCCCWWWWWWWWCCCCCO",
82 "OCCCCWWWWWWWWWWCCCCO",
83 "ODCCCWWWWWWWWWWCCCDO",
84 ".ODDCCWWWWWWWWCCDDO.",
85 "..OOOOOOOOOOOOOOOO..",
86 ],
87 },
88};
89
90// --- claws (left one; the right one is its mirror) -------------------------
91
92export const CLAWS = {
93 open: [
94 "OO...OO",
95 "OCO.OCO",
96 "OCCOCCO",
97 "OCLCCCO",
98 ".OCCCO.",
99 "..OOO..",
100 ],
101 shut: [
102 ".OOOOO.",
103 "OCCOCCO",
104 "OCCOCCO",
105 "OCLCCCO",
106 ".OCCCO.",
107 "..OOO..",
108 ],
109};
110
111// --- props -------------------------------------------------------------------
112
113export const BUBBLE = [
114 "...GGGGGGG...",
115 ".GGWWWWWWWGG.",
116 "GWWWWWWWWWWWG",
117 "GWWWWWWWWWWWG",
118 "GWWWWWWWWWWWG",
119 "GWWWWWWWWWWWG",
120 ".GGWWWWWWWGG.",
121 "...GGGGGGG...",
122];
123const PUFF_S = [".G.", "GWG", ".G."];
124const PUFF_M = [".GG.", "GWWG", "GWWG", ".GG."];
125
126export const BOOK = [
127 ".CCC.CCC.",
128 "CWWWCWWWC",
129 "CWMMCMMWC",
130 "CWWWCWWWC",
131 "CWMMCMMWC",
132 "CWWWCWWWC",
133 ".CCCOCCC.",
134];
135
136const SPARK = [".Y.", "YWY", ".Y."];
137const STAR = [".Y.", "YYY", ".Y."];
138const DROP = [".S.", "SSS", "SSS", ".S."];
139const Z_SMALL = ["WWW", "..W", ".W.", "W..", "WWW"];
140const Z_BIG = ["WWWWW", "...W.", "..W..", ".W...", "WWWWW"];
141
142const MOUTHS = {
143 smile: ["K..K", ".KK."],
144 open: ["KKKK", "KPPK", ".KK."],
145 chompOpen: [".KK.", "K..K", ".KK."],
146 chompShut: ["KKKK"],
147 flat: [".KK."],
148 wavy: ["K.K.", ".K.K"],
149 ooh: [".KK.", "K..K", ".KK."],
150};
151
152export const SPRITES = { SHELLS, CLAWS, BUBBLE, BOOK, MOUTHS };
153
154// --- the composer ------------------------------------------------------------
155
156function canvas() {
157 return new Int32Array(W * H).fill(NONE);
158}
159
160function put(px, x, y, color) {
161 if (x < 0 || y < 0 || x >= W || y >= H || color === undefined) return;
162 px[y * W + x] = color;
163}
164
165function stamp(px, grid, x, y, { mirror = false, tint } = {}) {
166 for (let r = 0; r < grid.length; r++) {
167 const row = grid[r];
168 for (let c = 0; c < row.length; c++) {
169 const ch = row[mirror ? row.length - 1 - c : c];
170 if (ch === ".") continue;
171 put(px, x + c, y + r, (tint && tint[ch]) ?? PALETTE[ch]);
172 }
173 }
174}
175
176function line(px, x0, y0, x1, y1, color) {
177 const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
178 for (let i = 0; i <= steps; i++) {
179 put(px, Math.round(x0 + ((x1 - x0) * i) / steps), Math.round(y0 + ((y1 - y0) * i) / steps), color);
180 }
181}
182
183// An eyeball: ring, cream inside, a pupil or an expression.
184function eye(px, x, y, kind, a = 0, b = 0, tint) {
185 const O = tint?.O ?? PALETTE.O;
186 const ring = [".OOO.", "O...O", "O...O", "O...O", ".OOO."];
187 stamp(px, ring, x, y, { tint });
188 const inner = (cx, cy, color) => put(px, x + 1 + cx, y + 1 + cy, color);
189 const fill = (color) => {
190 for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) inner(i, j, color);
191 };
192 const { W: cream, K: ink, L: lid } = PALETTE;
193 if (kind === "closed") {
194 // a heavy lid: light coral on top, the lash line, coral below
195 fill(tint?.C ?? PALETTE.C);
196 for (let i = 0; i < 3; i++) inner(i, 0, tint?.L ?? lid);
197 for (let i = 0; i < 3; i++) inner(i, 1, ink);
198 return;
199 }
200 fill(cream);
201 if (kind === "open") {
202 // a 2x2 pupil at (a, b), with a glint in its upper left
203 inner(a, b, cream);
204 inner(a + 1, b, ink);
205 inner(a, b + 1, ink);
206 inner(a + 1, b + 1, ink);
207 } else if (kind === "wide") {
208 inner(1, 1, ink);
209 } else if (kind === "happy") {
210 inner(1, 1, ink);
211 inner(0, 2, ink);
212 inner(2, 2, ink);
213 } else if (kind === "spinA") {
214 inner(0, 0, ink); inner(2, 0, ink); inner(1, 1, ink); inner(0, 2, ink); inner(2, 2, ink);
215 } else if (kind === "spinB") {
216 inner(1, 0, ink); inner(0, 1, ink); inner(1, 1, ink); inner(2, 1, ink); inner(1, 2, ink);
217 }
218}
219
220function hash(n) {
221 let x = (n | 0) * 2654435761;
222 x ^= x >>> 15;
223 x = Math.imul(x, 2246822519);
224 x ^= x >>> 13;
225 return (x >>> 0) / 4294967296;
226}
227
228export function fullness(fill) {
229 if (fill >= 90) return "stuffed";
230 if (fill >= 70) return "round";
231 return "normal";
232}
233
234/**
235 * Composes one frame. `mood` is the pet's state, `now` the clock in ms (the
236 * loops run off it), `fill` the context fill percent. Returns W*H colors, -1
237 * where nothing is drawn.
238 */
239export function petFrame(mood, now, fill = 0) {
240 const px = canvas();
241 const shellName = fullness(fill);
242 const shell = SHELLS[shellName];
243 const sw = shell.grid[0].length;
244 const sh = shell.grid.length;
245 const cx = 14; // the pet's center column
246
247 let dx = 0; // whole-body shift
248 let lift = 0; // whole-body lift (jump)
249 let squat = 0; // upper body sinks onto the legs
250 let legs = "stand";
251 let eyes = { kind: "open", a: 1, b: 1 };
252 let stalk = 2;
253 let clawL = { y: 0, grid: CLAWS.open };
254 let clawR = { y: 0, grid: CLAWS.open };
255 let mouth = "smile";
256 let blush = shellName !== "normal";
257 let tint;
258
259 const phase = (period, n) => Math.floor(now / period) % n;
260
261 switch (mood) {
262 case "idle": {
263 squat = phase(700, 2);
264 const blink = now % 3600 < 160;
265 const look = phase(2900, 4);
266 eyes = blink ? { kind: "closed" } : { kind: "open", a: look === 1 ? 0 : look === 3 ? 1 : 1, b: look === 2 ? 0 : 1 };
267 if (look === 1) eyes.a = 0;
268 break;
269 }
270 case "thinking": {
271 squat = phase(900, 2);
272 eyes = { kind: "open", a: 1, b: 0 };
273 mouth = "flat";
274 clawR = { y: -1, grid: CLAWS.shut };
275 break;
276 }
277 case "reading": {
278 const scan = phase(380, 2);
279 eyes = { kind: "open", a: scan, b: 1 };
280 mouth = phase(260, 2) ? "chompOpen" : "chompShut";
281 clawR = { y: 2, grid: CLAWS.shut };
282 break;
283 }
284 case "typing": {
285 const tap = phase(150, 2);
286 eyes = { kind: "open", a: tap, b: 1 };
287 mouth = "flat";
288 clawL = { y: tap ? 3 : 0, grid: tap ? CLAWS.shut : CLAWS.open };
289 clawR = { y: tap ? 0 : 3, grid: tap ? CLAWS.open : CLAWS.shut };
290 break;
291 }
292 case "running": {
293 const step = phase(110, 2);
294 legs = step ? "runA" : "runB";
295 squat = step;
296 eyes = { kind: "open", a: 1, b: 1 };
297 mouth = "open";
298 clawL = { y: step ? 1 : -1, grid: CLAWS.shut };
299 clawR = { y: step ? -1 : 1, grid: CLAWS.shut };
300 break;
301 }
302 case "nervous": {
303 dx = phase(70, 2) ? 1 : -1;
304 eyes = { kind: "wide" };
305 stalk = 3;
306 mouth = "wavy";
307 if (phase(260, 2)) tint = ALARM;
308 clawL = { y: 1, grid: CLAWS.shut };
309 clawR = { y: 1, grid: CLAWS.shut };
310 break;
311 }
312 case "celebrate": {
313 const p = (now % 560) / 560;
314 lift = Math.round((shellName === "stuffed" ? 1 : 3) * Math.sin(Math.PI * p));
315 legs = lift > 0 ? "dangle" : "stand";
316 squat = lift === 0 ? 1 : 0;
317 eyes = { kind: "happy" };
318 mouth = "open";
319 blush = true;
320 clawL = { y: -3, grid: phase(140, 2) ? CLAWS.open : CLAWS.shut };
321 clawR = { y: -3, grid: phase(140, 2) ? CLAWS.shut : CLAWS.open };
322 break;
323 }
324 case "oops": {
325 dx = [0, 1, 0, -1][phase(240, 4)];
326 eyes = { kind: phase(180, 2) ? "spinA" : "spinB" };
327 mouth = "wavy";
328 clawL = { y: 2, grid: CLAWS.open };
329 clawR = { y: 2, grid: CLAWS.open };
330 break;
331 }
332 case "sleeping": {
333 legs = "none";
334 stalk = 0;
335 squat = phase(1400, 2);
336 eyes = { kind: "closed" };
337 mouth = "flat";
338 clawL = { y: 4, grid: CLAWS.shut };
339 clawR = { y: 4, grid: CLAWS.shut };
340 break;
341 }
342 default:
343 break;
344 }
345
346 // Layout, bottom up: legs touch the last row.
347 const groundY = H - 1;
348 const legRows = legs === "none" ? 0 : 2;
349 const shellBottom = groundY - legRows - lift;
350 const shellTop = shellBottom - sh + 1 + squat;
351 const shellX = cx - Math.floor(sw / 2) + dx;
352 const center = shellX + Math.floor(sw / 2);
353
354 // Background effects, behind the pet
355 if (mood === "running") {
356 const off = Math.floor(now / 60) % 6;
357 for (const [y, len] of [[15, 3], [17, 5], [19, 4]]) {
358 const start = (off + y) % 3;
359 for (let i = 0; i < len; i++) put(px, start + i, y, i === 0 ? PALETTE.G : PALETTE.W);
360 }
361 // dust behind the feet
362 const d = phase(110, 3);
363 put(px, 3 + d, H - 1, PALETTE.G);
364 put(px, 5 - d, H - 2, PALETTE.G);
365 }
366
367 if (mood === "celebrate") {
368 const colors = [PALETTE.C, PALETTE.W, PALETTE.Y, PALETTE.B, PALETTE.S, PALETTE.P];
369 for (let i = 0; i < 18; i++) {
370 const speed = 6 + hash(i * 31) * 10; // px per second
371 const y = Math.floor((now / 1000) * speed + hash(i * 17) * H) % H;
372 const x = Math.floor(hash(i * 7) * W + Math.sin(now / 300 + i) * 1.5);
373 const c = colors[i % colors.length];
374 put(px, x, y, c);
375 if ((Math.floor(now / 200) + i) % 2) put(px, x + 1, y, c);
376 else put(px, x, y + 1, c);
377 }
378 }
379
380 // Legs
381 if (legs !== "none") {
382 const y0 = shellTop + sh; // the row under the shell
383 const leftXs = [center - 6, center - 4, center - 2];
384 const rightXs = [center + 1, center + 3, center + 5];
385 leftXs.forEach((x, i) => {
386 const splay = legs === "runA" ? (i % 2 ? 0 : -1) : legs === "runB" ? (i % 2 ? -1 : 0) : -1;
387 put(px, x, y0, PALETTE.O);
388 if (legs !== "dangle" || i === 1) put(px, x + splay, y0 + 1, PALETTE.O);
389 });
390 rightXs.forEach((x, i) => {
391 const splay = legs === "runA" ? (i % 2 ? 1 : 0) : legs === "runB" ? (i % 2 ? 0 : 1) : 1;
392 put(px, x, y0, PALETTE.O);
393 if (legs !== "dangle" || i === 1) put(px, x + splay, y0 + 1, PALETTE.O);
394 });
395 }
396
397 // Eyes on stalks
398 const eyeTop = shellTop - 5 - stalk;
399 const eyeLX = center - 6;
400 const eyeRX = center + 1;
401 for (const ex of [eyeLX, eyeRX]) {
402 for (let s = 0; s < stalk + 1; s++) put(px, ex + 2, eyeTop + 5 + s, tint?.O ?? PALETTE.O);
403 }
404
405 // Claws and arms (behind the shell's edge)
406 const clawTop = shellTop - 1;
407 const lx = shellX - 5;
408 const rx = shellX + sw - 2;
409 const lY = clawTop + clawL.y;
410 const rY = clawTop + clawR.y;
411 const arm = tint?.O ?? PALETTE.O;
412 line(px, lx + 3, lY + 6, shellX + 1, shellTop + 5, arm);
413 line(px, lx + 4, lY + 6, shellX + 2, shellTop + 5, tint?.D ?? PALETTE.D);
414 line(px, rx + 3, rY + 6, shellX + sw - 2, shellTop + 5, arm);
415 line(px, rx + 2, rY + 6, shellX + sw - 3, shellTop + 5, tint?.D ?? PALETTE.D);
416 stamp(px, clawL.grid, lx, lY, { tint });
417 stamp(px, clawR.grid, rx, rY, { mirror: true, tint });
418
419 // Shell and face
420 stamp(px, shell.grid, shellX, shellTop, { tint });
421 const m = MOUTHS[mouth];
422 stamp(px, m, center - 2, shellTop + shell.mouthY);
423 if (blush) {
424 put(px, center - 5, shellTop + shell.blushY + 1, PALETTE.P);
425 put(px, center - 4, shellTop + shell.blushY + 1, PALETTE.P);
426 put(px, center + 3, shellTop + shell.blushY + 1, PALETTE.P);
427 put(px, center + 4, shellTop + shell.blushY + 1, PALETTE.P);
428 }
429
430 // Eyes last, over the stalks
431 for (const ex of [eyeLX, eyeRX]) eye(px, ex, eyeTop, eyes.kind, eyes.a, eyes.b, tint);
432
433 // --- effects -----------------------------------------------------------
434 if (shellName === "stuffed" && mood !== "sleeping") {
435 const t = (now % 1500) / 1500;
436 stamp(px, DROP, shellX - 1, shellTop - 1 + Math.round(t * 5));
437 }
438
439 if (mood === "thinking") {
440 stamp(px, PUFF_S, 22, 7);
441 stamp(px, PUFF_M, 24, 3);
442 stamp(px, BUBBLE, 27, 0);
443 const lit = phase(320, 4); // 0..3 dots
444 for (let d = 0; d < 3; d++) {
445 if (d < lit) {
446 const x = 27 + 3 + d * 3;
447 put(px, x, 3, PALETTE.C); put(px, x + 1, 3, PALETTE.C);
448 put(px, x, 4, PALETTE.C); put(px, x + 1, 4, PALETTE.C);
449 }
450 }
451 }
452
453 if (mood === "reading") {
454 const bob = phase(380, 2);
455 stamp(px, BOOK, 29, 12 + bob);
456 // crumbs flying out of the chomping mouth
457 if (phase(260, 2)) {
458 put(px, center + 3, shellTop + shell.mouthY + 3, PALETTE.W);
459 put(px, center - 4, shellTop + shell.mouthY + 4, PALETTE.W);
460 }
461 }
462
463 if (mood === "typing") {
464 const tap = phase(150, 2);
465 const sx = tap ? lx : rx;
466 const sy = (tap ? lY : rY) - 3;
467 stamp(px, SPARK, sx + 2, sy);
468 for (let i = 0; i < 4; i++) {
469 const r = hash(Math.floor(now / 150) * 7 + i);
470 put(px, 26 + Math.floor(r * 12), 6 + Math.floor(hash(i * 13 + Math.floor(now / 150)) * 12), PALETTE.Y);
471 }
472 }
473
474 if (mood === "nervous") {
475 const t = (now % 700) / 700;
476 stamp(px, DROP, eyeLX - 4, eyeTop + 1 + Math.round(t * 6));
477 stamp(px, DROP, eyeRX + 7, eyeTop + 3 + Math.round(((t + 0.5) % 1) * 6));
478 }
479
480 if (mood === "oops") {
481 const a = (now / 1000) * Math.PI * 1.6;
482 for (let i = 0; i < 3; i++) {
483 const ang = a + (i * Math.PI * 2) / 3;
484 const sx = Math.round(center + Math.cos(ang) * 11) - 1;
485 const sy = Math.round(eyeTop + 1 + Math.sin(ang) * 2) - 1;
486 stamp(px, STAR, sx, sy);
487 }
488 }
489
490 if (mood === "sleeping") {
491 const cycle = 2400;
492 for (let i = 0; i < 3; i++) {
493 const t = ((now + i * (cycle / 3)) % cycle) / cycle; // 0..1 rise
494 if (t > 0.85) continue; // gone before the next one reaches it
495 const x = center + 9 + Math.round(t * 12);
496 const y = Math.round(shellTop - 2 - t * (shellTop - 1));
497 stamp(px, t > 0.5 ? Z_BIG : Z_SMALL, x, y);
498 }
499 }
500
501 return px;
502}
503
504// --- encoding ------------------------------------------------------------
505
506const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
507
508export function base64(bytes) {
509 if (typeof bytes.toBase64 === "function") return bytes.toBase64();
510 let out = "";
511 let i = 0;
512 for (; i + 2 < bytes.length; i += 3) {
513 const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
514 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
515 }
516 const rest = bytes.length - i;
517 if (rest === 1) {
518 const n = bytes[i] << 16;
519 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + "==";
520 } else if (rest === 2) {
521 const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
522 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + "=";
523 }
524 return out;
525}
526
527/** Packs a pixel frame into Raster cells: COLS x ROWS half-block cells. */
528export function encodeCells(px) {
529 const words = new Uint32Array(COLS * ROWS * 3);
530 let k = 0;
531 for (let r = 0; r < ROWS; r++) {
532 for (let c = 0; c < COLS; c++) {
533 const top = px[2 * r * W + c];
534 const bottom = px[(2 * r + 1) * W + c];
535 if (top === NONE && bottom === NONE) {
536 words[k++] = 0x20; words[k++] = DEFAULT; words[k++] = DEFAULT;
537 } else if (bottom === NONE) {
538 words[k++] = 0x2580; words[k++] = top; words[k++] = DEFAULT;
539 } else if (top === NONE) {
540 words[k++] = 0x2584; words[k++] = bottom; words[k++] = DEFAULT;
541 } else {
542 words[k++] = 0x2580; words[k++] = top; words[k++] = bottom;
543 }
544 }
545 }
546 // Uint32Array stores the platform's byte order; write little-endian explicitly.
547 const bytes = new Uint8Array(words.length * 4);
548 for (let i = 0; i < words.length; i++) {
549 const w = words[i];
550 bytes[i * 4] = w & 255;
551 bytes[i * 4 + 1] = (w >>> 8) & 255;
552 bytes[i * 4 + 2] = (w >>> 16) & 255;
553 bytes[i * 4 + 3] = (w >>> 24) & 255;
554 }
555 return base64(bytes);
556}
557
558export function frameCells(mood, now, fill) {
559 return encodeCells(petFrame(mood, now, fill));
560}
561
562/** Desktop SVG: the same pixel frames, animated locally without script. */
563export function desktopSvg(mood, fill) {
564 const count = 8;
565 const step = mood === "idle" || mood === "sleeping" ? 220 : 83;
566 const groups = [];
567 for (let frame = 0; frame < count; frame++) {
568 const px = petFrame(mood, frame * step, fill);
569 const rects = [];
570 for (let y = 0; y < H; y++) {
571 for (let x = 0; x < W;) {
572 const color = px[y * W + x];
573 let end = x + 1;
574 while (end < W && px[y * W + end] === color) end++;
575 if (color !== NONE) rects.push(`<rect x="${x}" y="${y}" width="${end-x}" height="1" fill="#${color.toString(16).padStart(6,"0")}"/>`);
576 x = end;
577 }
578 }
579 const values = Array.from({length:count+1},(_,i)=>i%count===frame?1:0).join(";");
580 const times = Array.from({length:count+1},(_,i)=>i/count).join(";");
581 groups.push(`<g opacity="${frame===0?1:0}"><animate attributeName="opacity" values="${values}" keyTimes="${times}" calcMode="discrete" dur="${count*step}ms" repeatCount="indefinite"/>${rects.join("")}</g>`);
582 }
583 return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="110" viewBox="0 0 ${W} ${H}" shape-rendering="crispEdges">${groups.join("")}</svg>`;
584}
585hooks/pet-core.mjs 115 lines1// Terminal Pet: the pure state machine. No engine calls in here.
2
3export const SLEEP_AFTER_MS = 60_000;
4export const LINGER_MS = 1_500; // a tool mood stays on screen this long after the call
5export const CELEBRATE_MS = 2_200;
6export const OOPS_MS = 2_500;
7export const PARTY_MS = 8_000;
8
9export const DEFAULT_PREFS = { name: "Pinch", enabled: true };
10export const EMPTY_PET = { mood: "idle", since: 0, until: 0, last: 0, turn: false, busy: 0, errs: 0, forced: "" };
11export const EMPTY_STATS = { reads: [], writes: [], ran: 0, risky: 0 };
12
13const READ_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead"]);
14const WRITE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
15const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
16const RISKY = [
17 /\brm\s+(-[a-zA-Z]*[rRf][a-zA-Z]*\s+)+/,
18 /\brm\s+--(recursive|force)\b/,
19 /\bgit\s+reset\s+--hard\b/,
20 /\bgit\s+push\b[^|;&]*\s(--force\b|--force-with-lease\b|-f\b)/,
21 /\bgit\s+clean\s+-[a-zA-Z]*f/,
22 /\bgit\s+branch\s+-D\b/,
23 /\bdrop\s+(table|database|schema)\b/i,
24 /\btruncate\s+table\b/i,
25 /\bmkfs(\.\w+)?\b/,
26 /\bdd\s+if=/,
27 /\bchmod\s+-R\s+777\b/,
28];
29
30export const MOOD_WORDS = {
31 idle: "chillin",
32 thinking: "thinking...",
33 reading: "munching files",
34 typing: "typing away",
35 running: "running a command",
36 nervous: "nervous!!",
37 celebrate: "party time!",
38 oops: "dizzy, that failed",
39 sleeping: "zzz, asleep",
40};
41
42export const ASCII_FACES = {
43 idle: "(°,,°)",
44 thinking: "(°,,°)?",
45 reading: "(o,,o)",
46 typing: "(°,,°)",
47 running: "(>,,<)",
48 nervous: "(O,,O);",
49 celebrate: "(^,,^)",
50 oops: "(@,,@)",
51 sleeping: "(-,,-)",
52};
53
54export function isRisky(command) {
55 return typeof command === "string" && RISKY.some((re) => re.test(command));
56}
57
58/** Which mood a tool call puts the pet in. */
59export function classify(tool, input) {
60 if (READ_TOOLS.has(tool)) return "reading";
61 if (WRITE_TOOLS.has(tool)) return "typing";
62 if (SHELL_TOOLS.has(tool)) return isRisky(input && input.command) ? "nervous" : "running";
63 return "thinking";
64}
65
66export function normalizePrefs(v) {
67 const o = v && typeof v === "object" ? v : {};
68 const name = typeof o.name === "string" && o.name.trim() ? o.name.trim().slice(0, 24) : DEFAULT_PREFS.name;
69 return { name, enabled: typeof o.enabled === "boolean" ? o.enabled : DEFAULT_PREFS.enabled };
70}
71
72/** The mood shown at `now`, timeouts and sleep applied. */
73export function moodAt(pet, now) {
74 const p = pet || EMPTY_PET;
75 if (p.forced === "sleep") return "sleeping";
76 if (p.forced === "party" && now < p.until) return "celebrate";
77 let mood = p.mood;
78 const expired = p.forced === "party" || (p.until > 0 && now >= p.until);
79 if (expired && p.busy <= 0) mood = p.turn ? "thinking" : "idle";
80 if (mood === "idle" && now - p.last >= SLEEP_AFTER_MS) return "sleeping";
81 return mood;
82}
83
84// --- transitions -------------------------------------------------------------
85
86export function onTurnStart(pet, now) {
87 return { ...EMPTY_PET, ...pet, mood: "thinking", since: now, until: 0, last: now, turn: true, busy: 0, errs: 0, forced: "" };
88}
89
90export function onToolStart(pet, mood, now) {
91 const p = { ...EMPTY_PET, ...pet };
92 return { ...p, mood, since: now, until: 0, last: now, busy: p.busy + 1, forced: "" };
93}
94
95export function onToolEnd(pet, mood, isError, now) {
96 const p = { ...EMPTY_PET, ...pet };
97 const busy = Math.max(0, p.busy - 1);
98 if (isError) return { ...p, mood: "oops", since: now, until: now + OOPS_MS, last: now, busy, errs: p.errs + 1 };
99 // Another call started since: leave its mood alone.
100 if (p.mood !== mood && busy > 0) return { ...p, busy, last: now };
101 return { ...p, mood, until: now + LINGER_MS, last: now, busy };
102}
103
104export function onTurnEnd(pet, reason, isAborted, now) {
105 const p = { ...EMPTY_PET, ...pet, turn: false, busy: 0, last: now, forced: "" };
106 if (!isAborted && reason === "answer" && p.errs === 0) return { ...p, mood: "celebrate", since: now, until: now + CELEBRATE_MS };
107 if (!isAborted && (p.errs > 0 || reason === "error")) return { ...p, mood: "oops", since: now, until: now + OOPS_MS };
108 return { ...p, mood: "idle", since: now, until: 0 };
109}
110
111export function addUnique(list, item, cap = 2000) {
112 if (!item || list.includes(item)) return list;
113 return [...list, item].slice(-cap);
114}
115types/index.d.ts 19 lines1export type TerminalPetPrefs = { name: string; enabled: boolean };
2export type TerminalPetState = {
3 mood: string;
4 since: number;
5 until: number;
6 last: number;
7 turn: boolean;
8 busy: number;
9 errs: number;
10 forced: string;
11};
12export type TerminalPetStats = { reads: string[]; writes: string[]; ran: number; risky: number };
13
14declare module "claude-code" {
15 interface PluginState {
16 "terminal-pet": { prefs: TerminalPetPrefs; pet: TerminalPetState; stats: TerminalPetStats; fill: number };
17 }
18}
19