A pixel pet above the Claude Code prompt that reacts to your session

A pixel pet that lives above the Claude Code prompt. It hatches, blinks, hops when Claude edits a file, falls asleep when you leave, and gets hungry if you forget it.
▄████▄
█ ● ● █ “ooh, an edit!”
█ ·◡ · █ Pip · hatchling · excited · streak 7 8: pet 9: feed
▀████▀
claude plugin marketplace add settivishal/tamaclaude
claude plugin install tamaclaude@tamaclaude
Requires Claude Code 2.1.278+ and a terminal (the band is terminal-only).
It grows on a streak of clean turns (no abort, no refusal, no error). A stage once reached is kept; an error only resets the streak.
| stage | streak | colour | sprite |
|---|---|---|---|
| egg | 0 | beige | see below |
| hatchling | 5 | yellow | see below |
| adult | 30 | green | see below |
| elder | 100 | purple | see below |
egg hatchling adult elder
▄████▄ ▄████▄ ▄██████▄ ▄█▀██▀█▄
██▒███████ █ ● ● █ █ ● ● █ █ ● ● █
██████▒███ █ ·◡ · █ ▐█ · ◡ · █▌ ▐█ · ◡ · █▌
▀██████▀ ▀████▀ ▀██████▀ ▀█▒▒▒▒█▀
asleep hungry sulking dancing worried
▄████▄ z ▄████▄ ▄████▄ ▘▄████▄▝ ▄████▄ '
█ ─ ─ █ █ ● ● █ █ ╥ ╥ █ █ ● ● █ █ ● ● █
█ ·◡ · █ █ ∩ █ █ ' ∩ █ █ ·◡ · █ █ ~ █
▀████▀ ▀████▀ ▀████▀ ▀████▀ ▀████▀
small (8×2) draws the hatchling art for every stage, told apart by colour.
Write / Edit → hops for 1.5 s (the egg too)Bash (pytest, jest, vitest, cargo test, go test, npm test, …) that comes back clean → a happy dance when the turn ends; FAIL / error in its output → sulksgit commit, git push, gh pr create|merge in Bash that comes back clean → "shipped!"Sleep after setting) → sleeps; z's drift up; any prompt wakes it9 with an empty prompt, or /tamaclaude feed8 pets it → a dance (a hop under quiet); praise in a prompt (thanks, good job, well done) → a dance tooquiet mutes the chatter)/tamaclaude | status line |
/tamaclaude feed | feed it |
/tamaclaude name <x> | rename it |
/tamaclaude reset | a fresh egg |
/config)| key | default | |
|---|---|---|
enabled | true | off hides the pet |
size | normal | small is 8×2, normal 16×4 |
sleepAfterMin | 10 | minutes idle before sleep |
quiet | false | no dance, yawn or idle chatter |
The pet is stored in the plugin's store, so it follows you across sessions and repos.
claude --plugin-dir ./tamaclaude
claude plugin validate tamaclaude
for t in tamaclaude/tests/*.test.ts; do npx -y tsx "$t"; done
Sprites are plain strings in tamaclaude/hooks/sprites.ts, one char per cell; add a mood or stage there.
Two plugins drawing the band? Each AbovePrompt hook must await next(e) and stack the result, or the outer one hides the rest.
hooks/hooks.tsx 216 lines1import type { EngineInterface, Register } from "claude-code";
2import { sprite, cells, DIMS, type Size, type Mood } from "./sprites.ts";
3import { hatch, load, grow, isHungry, mood, ageDays, EFFECT_MS, PRAISE, QUIP, CHATTER, CHATTER_EVERY_MS, SAY_MS, WORRY_PERCENT, LIMIT_PERCENT, turnRemark, type Pet, type Effects } from "./pet.ts";
4
5type Cfg = { enabled: boolean; size: Size; sleepAfterMs: number; quiet: boolean };
6const cfg: Cfg = { enabled: true, size: "normal", sleepAfterMs: 600_000, quiet: false };
7
8let pet: Pet = hatch(0);
9let effects: Effects = {};
10let lastTurn = 0;
11let bandId: string | undefined; // the AbovePrompt requestId the Raster is mounted under
12let tick = 0;
13let drawnMood: Mood = "idle";
14let sessionStart = 0;
15let lastYawn = 0;
16let testsPassed = false; // a test run this turn came back clean
17let ctxPercent = 0; // context fill from session.measure
18let limitWarned = false; // said its piece about the rate limit this crossing
19let turnEdits = 0; // Write/Edit calls this turn
20let said = { text: "", until: 0 }; // a line it spoke, over its quip
21let drawnLine = "";
22let lastChatter = 0;
23let blitted = ""; // the cells last sent to the band, so an unchanged frame is not re-sent
24const packed = new Map<string, string>(); // size/stage/mood/frame → cells
25
26const TEST_RUNNER = /\b(pytest|jest|vitest|mocha|cargo test|go test|npm test|pnpm test|yarn test|bun test|tsx .*\.test\.|node --test|rspec|phpunit|mvn test|gradle test|dotnet test|make test)\b/;
27const TEST_FAIL = /\bFAIL(ED|URE)?\b|\berror\b/i;
28const GIT_SHIP = /\bgit\s+(commit|push)\b|\bgh\s+pr\s+(create|merge)\b/;
29const LONG_SESSION_MS = 2 * 60 * 60 * 1000, YAWN_EVERY_MS = 4 * 60 * 1000;
30
31const MOOD_LABEL: Record<Mood, string> = { idle: "content", hop: "excited", dance: "dancing", sulk: "sulking", sleep: "asleep", yawn: "yawning", hungry: "hungry", worry: "worried" };
32
33async function save($: EngineInterface): Promise<void> {
34 await $.store.set("pet", pet);
35}
36
37function currentMood(now: number): Mood {
38 return mood({ effects, lastTurn, sleepAfterMs: cfg.sleepAfterMs, hungry: isHungry(pet, now), quiet: cfg.quiet, worried: ctxPercent >= WORRY_PERCENT }, now);
39}
40
41// the speech line: something it said recently, else the mood's quip
42function line(m: Mood, now: number): string {
43 return said.until > now ? said.text : QUIP[m];
44}
45
46function say(text: string, now: number, ms = SAY_MS): void {
47 said = { text, until: now + ms };
48}
49
50function frame(m: Mood, t: number): string {
51 // idle blinks briefly every 3 s; the other moods alternate every other tick
52 const f = m === "idle" ? (t % 12 === 11 ? 1 : 0) : (t >> 1) & 1;
53 const k = `${cfg.size}/${pet.stage}/${m}/${f}`;
54 let c = packed.get(k);
55 if (!c) packed.set(k, c = cells(sprite(cfg.size, pet.stage, m, f), pet.stage, m));
56 return c;
57}
58
59function poke(k: keyof Effects, now: number): void {
60 effects = { ...effects, [k]: now + EFFECT_MS[k] };
61}
62
63async function feed($: EngineInterface): Promise<string> {
64 const now = await $.clock.now();
65 pet = { ...pet, fed: now };
66 say("yum!", now);
67 await save($);
68 $.ui.invalidate("ui.render");
69 return `${pet.name} munches happily.`;
70}
71
72// a pat cheers it up; quiet mode has no dance, so it hops
73async function pat($: EngineInterface): Promise<void> {
74 const now = await $.clock.now();
75 poke(cfg.quiet ? "hop" : "dance", now);
76 say("hehe.", now);
77}
78
79async function sulk($: EngineInterface, now: number): Promise<void> {
80 poke("sulk", now);
81 pet = { ...pet, sulks: pet.sulks + 1 };
82 await save($);
83}
84
85async function step($: EngineInterface): Promise<void> {
86 const now = await $.clock.now();
87 if (now - sessionStart >= LONG_SESSION_MS && now - lastYawn >= YAWN_EVERY_MS) { lastYawn = now; poke("yawn", now); }
88 const m = currentMood(now);
89 tick++;
90 if (m === "idle" && !cfg.quiet && now - lastChatter >= CHATTER_EVERY_MS) { lastChatter = now; say(CHATTER[Math.floor(Math.random() * CHATTER.length)]!, now); }
91 const l = line(m, now);
92 if (m !== drawnMood || l !== drawnLine) { drawnMood = m; drawnLine = l; $.ui.invalidate("ui.render"); return; } // text lines change with the mood
93 const c = frame(m, tick);
94 if (bandId && c !== blitted) { blitted = c; void $.ui.blit({ requestId: bandId, key: "pet", cells: c }); }
95}
96
97async function command($: EngineInterface, args: string): Promise<string> {
98 const [verb, ...rest] = args.trim().split(/\s+/);
99 const now = await $.clock.now();
100 if (verb === "feed") return feed($);
101 if (verb === "name") {
102 const name = rest.join(" ").trim().slice(0, 16);
103 if (!name) return "usage: /tamaclaude name <name>";
104 pet = { ...pet, name };
105 await save($);
106 $.ui.invalidate("ui.render");
107 return `Your pet is now called ${name}.`;
108 }
109 if (verb === "reset") {
110 pet = hatch(now);
111 effects = {};
112 said = { text: "", until: 0 };
113 await save($);
114 $.ui.invalidate("ui.render");
115 return `A new egg. Say hi to ${pet.name}.`;
116 }
117 return `${pet.name} · ${pet.stage} · ${MOOD_LABEL[currentMood(now)]} · streak ${pet.streak} · age ${ageDays(pet, now)}d · ${pet.sessions} sessions · ${pet.edits} edits · ${pet.tests} tests · ${pet.sulks} sulks\n` +
118 `/tamaclaude feed | name <x> | reset`;
119}
120
121export const register: Register = (on, options) => {
122 cfg.enabled = options.enabled !== false;
123 cfg.size = options.size === "small" ? "small" : "normal";
124 cfg.sleepAfterMs = Math.max(1, Number(options.sleepAfterMin) || 10) * 60_000;
125 cfg.quiet = options.quiet === true;
126
127 on("session.start", async ($, e, next) => {
128 const now = await $.clock.now();
129 pet = load(await $.store.get("pet"), now);
130 pet = { ...pet, sessions: pet.sessions + 1 };
131 lastTurn = sessionStart = lastYawn = lastChatter = now;
132 await save($);
133 await $.command.register({ name: "tamaclaude", description: "Your pixel pet: status, feed, name <x>, reset.", argumentHint: "[feed|name <x>|reset]" });
134 if (cfg.enabled) $.clock.every(250, () => void step($));
135 return next(e);
136 });
137
138 on("command.run", { command: "tamaclaude" }, async ($, e) => ({ text: await command($, e.args) }));
139
140 on("prompt.submit", async ($, e, next) => {
141 lastTurn = await $.clock.now();
142 if (PRAISE.test(e.text)) { poke("dance", lastTurn); say("aw, thanks!", lastTurn); }
143 return next(e);
144 });
145 on("turn.complete", async ($, e, next) => {
146 const now = lastTurn = await $.clock.now();
147 if (e.agentId) return next(e); // subagent turns do not count
148 if (e.reason === "refusal") await sulk($, now);
149 else if (e.reason === "answer" && testsPassed) poke("dance", now);
150 testsPassed = false;
151 const remark = turnRemark(e.reason, e.durationMs, turnEdits);
152 if (remark) say(remark, now);
153 turnEdits = 0;
154 const before = pet.stage;
155 pet = grow({ ...pet, streak: e.reason === "answer" ? pet.streak + 1 : 0 });
156 if (pet.stage !== before) $.ui.toast(`${pet.name} grew into ${pet.stage === "hatchling" ? "a hatchling" : `an ${pet.stage}`}!`);
157 await save($);
158 $.ui.invalidate("ui.render"); // streak shows on the text line
159 return next(e);
160 });
161
162 on("session.measure", async ($, e, next) => {
163 ctxPercent = e.context.percent ?? 0;
164 const limit = Math.max(0, ...e.rateLimits.map(r => r.percentUsed));
165 if (limit >= LIMIT_PERCENT && !limitWarned) say(`easy... ${Math.round(limit)}% of the limit's gone.`, await $.clock.now(), 8000);
166 limitWarned = limit >= LIMIT_PERCENT;
167 return next(e);
168 });
169
170 on("tool.call", async ($, e, next) => {
171 const now = await $.clock.now();
172 const isTest = e.tool === "Bash" && TEST_RUNNER.test(e.command);
173 if (e.tool === "Write" || e.tool === "Edit") { poke("hop", now); turnEdits++; pet = { ...pet, edits: pet.edits + 1 }; }
174 if (isTest) pet = { ...pet, tests: pet.tests + 1 };
175 const r = await next(e);
176 const after = await $.clock.now();
177 if (r.deny !== undefined) await sulk($, after); // a plugin beneath refused it
178 else if (e.tool === "Bash" && GIT_SHIP.test(e.command) && !r.isError) say("shipped!", after);
179 else if (isTest) {
180 if (r.isError || TEST_FAIL.test(r.text ?? "")) await sulk($, after);
181 else testsPassed = true;
182 }
183 return r;
184 });
185
186 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
187 if (!cfg.enabled || e.props.hasSurvey || e.surface !== "terminal") return next(e); // Raster is terminal-only
188 const { Box, Text, Button, Raster } = $.ui.resolve(e);
189 const below = await next(e); // the other plugins' band rows stack under the pet
190 bandId = e.requestId;
191 blitted = "";
192 const m = drawnMood;
193 const { columns, rows } = DIMS[cfg.size];
194 const hungry = m === "hungry";
195 return (
196 <Box key="tama" flexDirection="column">
197 <Box gap={1} justifyContent="flex-end">
198 <Box flexDirection="column" justifyContent="flex-end">
199 {drawnLine && <Text wrap="truncate">{`“${drawnLine}”`}</Text>}
200 <Box gap={1}>
201 <Text dimColor wrap="truncate">{`${pet.name} · ${pet.stage} · ${MOOD_LABEL[m]} · streak ${pet.streak}`}</Text>
202 <Button key="pat" plain dimColor hotkey="8" onPress={() => void pat($)}>pet</Button>
203 <Button key="feed" plain dimColor={!hungry} hotkey="9" onPress={() => void feed($)}>feed</Button>
204 </Box>
205 </Box>
206 <Raster key="pet" columns={columns} rows={rows} cells={frame(m, tick)} />
207 </Box>
208 <Box display="none" hover={{ display: "flex" }} justifyContent="flex-end" paddingRight={columns + 1}>
209 <Text dimColor wrap="truncate">{`${pet.sessions} sessions · ${pet.edits} edits · ${pet.tests} tests · ${pet.sulks} sulks · /tamaclaude`}</Text>
210 </Box>
211 {below}
212 </Box>
213 );
214 });
215};
216hooks/sprites.ts 356 lines1// Sprite data: one string per row, one char per cell. Pure; no $ here.
2// Glyphs: blocks are the body (stage colour); ● ─ eyes, ╥ crying, ◡ ∩ ○ ~ mouths, · cheeks, ' a tear or sweat, z Z sleep, ▒ egg speckles and the elder's beard.
3import { pack, DEFAULT, type Cell } from "./raster.ts";
4
5export type Stage = "egg" | "hatchling" | "adult" | "elder";
6export type Mood = "idle" | "hop" | "dance" | "sulk" | "sleep" | "yawn" | "hungry" | "worry";
7export type Size = "small" | "normal";
8export type Sprite = readonly string[];
9
10export const DIMS: Record<Size, { columns: number; rows: number }> = { small: { columns: 8, rows: 2 }, normal: { columns: 16, rows: 4 } };
11
12export const STAGE_COLOR: Record<Stage, number> = { egg: 0xf5deb3, hatchling: 0xfacc15, adult: 0x22c55e, elder: 0xa855f7 };
13const Z_COLOR = 0x60a5fa, TEAR = 0x38bdf8, CHEEK = 0xf9a8d4, EYE = DEFAULT;
14
15// normal 16×4 — two frames per state; second frame is the blink / wobble / drift
16const N: Record<Stage, Partial<Record<Mood, [Sprite, Sprite]>>> = {
17 egg: {
18 idle: [
19 [" ▄████▄ ",
20 " ██▒███████ ",
21 " ██████▒███ ",
22 " ▀██████▀ "],
23 [" ▄████▄ ",
24 " ██▒███████",
25 " ██████▒███",
26 " ▀██████▀ "],
27 ],
28 hop: [
29 [" ██████████ ",
30 " ██▒███████ ",
31 " ▀██████▀ ",
32 " "],
33 [" ▄████▄ ",
34 " ██▒███████ ",
35 " ██████▒███ ",
36 " ▀██████▀ "],
37 ],
38 sleep: [
39 [" ▄████▄ z ",
40 " ██▒███████ ",
41 " ██████▒███ ",
42 " ▀██████▀ "],
43 [" ▄████▄ Z",
44 " ██▒███████ ",
45 " ██████▒███ ",
46 " ▀██████▀ "],
47 ],
48 hungry: [
49 [" ▄████▄ ",
50 " ████ ████ ",
51 " ██████▒███ ",
52 " ▀██████▀ "],
53 [" ▄████▄ ",
54 " ████ ████ ",
55 " ██████▒███ ",
56 " ▀██████▀ "],
57 ],
58 },
59 hatchling: {
60 idle: [
61 [" ▄████▄ ",
62 " █ ● ● █ ",
63 " █ ·◡ · █ ",
64 " ▀████▀ "],
65 [" ▄████▄ ",
66 " █ ─ ─ █ ",
67 " █ ·◡ · █ ",
68 " ▀████▀ "],
69 ],
70 hop: [
71 [" █ ● ● █ ",
72 " █ ·◡ · █ ",
73 " ▀████▀ ",
74 " "],
75 [" ▄████▄ ",
76 " █ ● ● █ ",
77 " █ ·◡ · █ ",
78 " ▀████▀ "],
79 ],
80 sleep: [
81 [" ▄████▄ z ",
82 " █ ─ ─ █ ",
83 " █ ◡ █ ",
84 " ▀████▀ "],
85 [" ▄████▄ Z",
86 " █ ─ ─ █ ",
87 " █ ◡ █ ",
88 " ▀████▀ "],
89 ],
90 hungry: [
91 [" ▄████▄ ",
92 " █ ● ● █ ",
93 " █ ∩ █ ",
94 " ▀████▀ "],
95 [" ▄████▄ ",
96 " █ ─ ─ █ ",
97 " █ ∩ █ ",
98 " ▀████▀ "],
99 ],
100 dance: [
101 [" ▘▄████▄▝ ",
102 " █ ● ● █ ",
103 " █ ·◡ · █ ",
104 " ▀████▀ "],
105 [" █ ● ● █ ",
106 " ▗█ ·◡ · █▖ ",
107 " ▀████▀ ",
108 " "],
109 ],
110 sulk: [
111 [" ▄████▄ ",
112 " █ ╥ ╥ █ ",
113 " █ ' ∩ █ ",
114 " ▀████▀ "],
115 [" ▄████▄ ",
116 " █ ╥ ╥ █ ",
117 " █ ∩ '█ ",
118 " ▀████▀ "],
119 ],
120 yawn: [
121 [" ▄████▄ ",
122 " █ ─ ─ █ ",
123 " █ ○ █ ",
124 " ▀████▀ "],
125 [" ▄████▄ ",
126 " █ ─ ─ █ ",
127 " █ ◡ █ ",
128 " ▀████▀ "],
129 ],
130 worry: [
131 [" ▄████▄ ' ",
132 " █ ● ● █ ",
133 " █ ~ █ ",
134 " ▀████▀ "],
135 [" ▄████▄ ",
136 " █ ● ● █ ' ",
137 " █ ~ █ ",
138 " ▀████▀ "],
139 ],
140 },
141 adult: {
142 idle: [
143 [" ▄██████▄ ",
144 " █ ● ● █ ",
145 " ▐█ · ◡ · █▌ ",
146 " ▀██████▀ "],
147 [" ▄██████▄ ",
148 " █ ─ ─ █ ",
149 " ▐█ · ◡ · █▌ ",
150 " ▀██████▀ "],
151 ],
152 hop: [
153 [" █ ● ● █ ",
154 " ▐█ · ◡ · █▌ ",
155 " ▀██████▀ ",
156 " "],
157 [" ▄██████▄ ",
158 " █ ● ● █ ",
159 " ▐█ · ◡ · █▌ ",
160 " ▀██████▀ "],
161 ],
162 dance: [
163 [" ▘ ▄██████▄ ▝ ",
164 " █ ● ● █ ",
165 " █ · ◡ · █ ",
166 " ▀██████▀ "],
167 [" ▄██████▄ ",
168 " ▗█ ● ● █▖ ",
169 " █ · ◡ · █ ",
170 " ▀██████▀ "],
171 ],
172 sulk: [
173 [" ▄██████▄ ",
174 " █ ╥ ╥ █ ",
175 " ▐█ ' ∩ █▌ ",
176 " ▀██████▀ "],
177 [" ▄██████▄ ",
178 " █ ╥ ╥ █ ",
179 " ▐█ ∩ '█▌ ",
180 " ▀██████▀ "],
181 ],
182 yawn: [
183 [" ▄██████▄ ",
184 " █ ─ ─ █ ",
185 " ▐█ ○ █▌ ",
186 " ▀██████▀ "],
187 [" ▄██████▄ ",
188 " █ ─ ─ █ ",
189 " ▐█ ◡ █▌ ",
190 " ▀██████▀ "],
191 ],
192 sleep: [
193 [" ▄██████▄ z ",
194 " █ ─ ─ █ ",
195 " ▐█ ◡ █▌ ",
196 " ▀██████▀ "],
197 [" ▄██████▄ Z",
198 " █ ─ ─ █ ",
199 " ▐█ ◡ █▌ ",
200 " ▀██████▀ "],
201 ],
202 hungry: [
203 [" ▄██████▄ ",
204 " █ ● ● █ ",
205 " ▐█ ∩ █▌ ",
206 " ▀██████▀ "],
207 [" ▄██████▄ ",
208 " █ ─ ─ █ ",
209 " ▐█ ∩ █▌ ",
210 " ▀██████▀ "],
211 ],
212 worry: [
213 [" ▄██████▄ ' ",
214 " █ ● ● █ ",
215 " ▐█ ~ █▌ ",
216 " ▀██████▀ "],
217 [" ▄██████▄ ",
218 " █ ● ● █' ",
219 " ▐█ ~ █▌ ",
220 " ▀██████▀ "],
221 ],
222 },
223 elder: {
224 idle: [
225 [" ▄█▀██▀█▄ ",
226 " █ ● ● █ ",
227 " ▐█ · ◡ · █▌ ",
228 " ▀█▒▒▒▒█▀ "],
229 [" ▄█▀██▀█▄ ",
230 " █ ─ ─ █ ",
231 " ▐█ · ◡ · █▌ ",
232 " ▀█▒▒▒▒█▀ "],
233 ],
234 hop: [
235 [" █ ● ● █ ",
236 " ▐█ · ◡ · █▌ ",
237 " ▀█▒▒▒▒█▀ ",
238 " "],
239 [" ▄█▀██▀█▄ ",
240 " █ ● ● █ ",
241 " ▐█ · ◡ · █▌ ",
242 " ▀█▒▒▒▒█▀ "],
243 ],
244 dance: [
245 [" ▘ ▄█▀██▀█▄ ▝ ",
246 " █ ● ● █ ",
247 " █ · ◡ · █ ",
248 " ▀█▒▒▒▒█▀ "],
249 [" ▄█▀██▀█▄ ",
250 " ▗█ ● ● █▖ ",
251 " █ · ◡ · █ ",
252 " ▀█▒▒▒▒█▀ "],
253 ],
254 sulk: [
255 [" ▄█▀██▀█▄ ",
256 " █ ╥ ╥ █ ",
257 " ▐█ ' ∩ █▌ ",
258 " ▀█▒▒▒▒█▀ "],
259 [" ▄█▀██▀█▄ ",
260 " █ ╥ ╥ █ ",
261 " ▐█ ∩ '█▌ ",
262 " ▀█▒▒▒▒█▀ "],
263 ],
264 yawn: [
265 [" ▄█▀██▀█▄ ",
266 " █ ─ ─ █ ",
267 " ▐█ ○ █▌ ",
268 " ▀█▒▒▒▒█▀ "],
269 [" ▄█▀██▀█▄ ",
270 " █ ─ ─ █ ",
271 " ▐█ ◡ █▌ ",
272 " ▀█▒▒▒▒█▀ "],
273 ],
274 sleep: [
275 [" ▄█▀██▀█▄ z ",
276 " █ ─ ─ █ ",
277 " ▐█ ◡ █▌ ",
278 " ▀█▒▒▒▒█▀ "],
279 [" ▄█▀██▀█▄ Z",
280 " █ ─ ─ █ ",
281 " ▐█ ◡ █▌ ",
282 " ▀█▒▒▒▒█▀ "],
283 ],
284 hungry: [
285 [" ▄█▀██▀█▄ ",
286 " █ ● ● █ ",
287 " ▐█ ∩ █▌ ",
288 " ▀█▒▒▒▒█▀ "],
289 [" ▄█▀██▀█▄ ",
290 " █ ─ ─ █ ",
291 " ▐█ ∩ █▌ ",
292 " ▀█▒▒▒▒█▀ "],
293 ],
294 worry: [
295 [" ▄█▀██▀█▄ ' ",
296 " █ ● ● █ ",
297 " ▐█ ~ █▌ ",
298 " ▀█▒▒▒▒█▀ "],
299 [" ▄█▀██▀█▄ ",
300 " █ ● ● █' ",
301 " ▐█ ~ █▌ ",
302 " ▀█▒▒▒▒█▀ "],
303 ],
304 },
305};
306
307// small 8×2 — adult and elder reuse the hatchling art, told apart by colour
308const S: Record<Stage, Partial<Record<Mood, [Sprite, Sprite]>>> = {
309 egg: {
310 idle: [[" ▄████▄ ", " ██████ "], [" ▄████▄", " ██████"]],
311 hop: [[" ██████ ", " "], [" ▄████▄ ", " ██████ "]],
312 sleep: [[" ▄████▄z", " ██████ "], [" ▄████▄Z", " ██████ "]],
313 hungry: [[" ▄████▄ ", " ██ ██ "], [" ▄████▄ ", " ██ ██ "]],
314 },
315 hatchling: {
316 idle: [[" ▄████▄ ", "█ ●◡● █ "], [" ▄████▄ ", "█ ─◡─ █ "]],
317 hop: [["█ ●◡● █ ", " ▀████▀ "], [" ▄████▄ ", "█ ●◡● █ "]],
318 sleep: [[" ▄████▄z", "█ ─◡─ █ "], [" ▄████▄Z", "█ ─◡─ █ "]],
319 hungry: [[" ▄████▄ ", "█ ●∩● █ "], [" ▄████▄ ", "█ ─∩─ █ "]],
320 dance: [["▘▄████▄▝", "█ ●◡● █ "], ["█ ●◡● █ ", " ▀████▀ "]],
321 sulk: [[" ▄████▄ ", "█ ╥∩╥'█ "], [" ▄████▄ ", "█'╥∩╥ █ "]],
322 yawn: [[" ▄████▄ ", "█ ─○─ █ "], [" ▄████▄ ", "█ ─◡─ █ "]],
323 worry: [[" ▄████▄'", "█ ●~● █ "], [" ▄████▄ ", "█ ●~● █'"]],
324 },
325 adult: {}, // small: hatchling art, stage colour
326 elder: {},
327};
328
329const SHEETS: Record<Size, typeof N> = { normal: N, small: S };
330
331// the frame for a state; a mood or stage without art falls back to the nearest that has it
332export function sprite(size: Size, stage: Stage, mood: Mood, frame: number): Sprite {
333 const sheet = SHEETS[size];
334 const stages: Stage[] = [stage, "hatchling", "egg"];
335 const moods: Mood[] = [mood, "idle"];
336 for (const s of stages) for (const m of moods) {
337 const f = sheet[s][m];
338 if (f) return f[frame & 1]!;
339 }
340 return sheet.egg.idle![frame & 1]!;
341}
342
343export const BLOCKS = new Set("▄▀█▘▝▖▗▌▐");
344const dim = (c: number) => ((c >> 1) & 0x7f7f7f);
345
346// colour by char: body blocks in the stage colour (dimmed asleep), z's blue, tears cyan, the rest default
347export function cells(rows: Sprite, stage: Stage, mood: Mood): string {
348 const body = mood === "sleep" ? dim(STAGE_COLOR[stage]) : STAGE_COLOR[stage];
349 const out: Cell[] = [];
350 for (const row of rows) for (const ch of row) {
351 const cp = ch.codePointAt(0)!;
352 out.push([cp, BLOCKS.has(ch) ? body : ch === "z" || ch === "Z" ? Z_COLOR : ch === "'" ? TEAR : ch === "·" ? CHEEK : EYE]);
353 }
354 return pack(out);
355}
356hooks/pet.ts 86 lines1// Pet state and its pure transitions. Persisted shape is Pet; no $ here.
2import type { Stage, Mood } from "./sprites.ts";
3
4export type Pet = {
5 name: string;
6 stage: Stage;
7 streak: number; // consecutive clean turns
8 fed: number; // ms epoch of the last feed
9 born: number; // ms epoch
10 sessions: number;
11 edits: number;
12 tests: number;
13 sulks: number;
14};
15
16export const STAGES: Stage[] = ["egg", "hatchling", "adult", "elder"];
17// clean-turn streak needed to reach each stage; reached, a stage is kept
18export const THRESHOLDS: Record<Stage, number> = { egg: 0, hatchling: 5, adult: 30, elder: 100 };
19export const HUNGRY_MS = 24 * 60 * 60 * 1000;
20export const NAMES = ["Pip", "Mochi", "Bit", "Nib", "Blip", "Tofu", "Kiwi", "Dot"];
21
22export function hatch(now: number, name = NAMES[Math.floor(Math.random() * NAMES.length)]!): Pet {
23 return { name, stage: "egg", streak: 0, fed: now, born: now, sessions: 0, edits: 0, tests: 0, sulks: 0 };
24}
25
26// a stored value that may be missing fields (older version) or garbage
27export function load(raw: unknown, now: number): Pet {
28 const p = (raw && typeof raw === "object" ? raw : {}) as Partial<Pet>;
29 const base = hatch(now);
30 return {
31 ...base,
32 ...p,
33 name: typeof p.name === "string" && p.name.trim() ? p.name.trim().slice(0, 16) : base.name,
34 stage: STAGES.includes(p.stage as Stage) ? (p.stage as Stage) : "egg",
35 };
36}
37
38// the stage a streak has earned, never lower than the one already reached
39export function grow(pet: Pet): Pet {
40 const earned = [...STAGES].reverse().find(s => pet.streak >= THRESHOLDS[s])!;
41 return STAGES.indexOf(earned) > STAGES.indexOf(pet.stage) ? { ...pet, stage: earned } : pet;
42}
43
44export const isHungry = (pet: Pet, now: number) => now - pet.fed > HUNGRY_MS;
45
46// transient effects the session sets, each an expiry
47export type Effects = { hop?: number; dance?: number; sulk?: number; yawn?: number };
48export const EFFECT_MS = { hop: 1500, dance: 3000, sulk: 30_000, yawn: 2500 };
49
50export const WORRY_PERCENT = 85; // context fill that worries it
51export type MoodInput = { effects: Effects; lastTurn: number; sleepAfterMs: number; hungry: boolean; quiet: boolean; worried: boolean };
52
53// priority: sulk > dance > hop > yawn > sleep > worry > hungry > idle; quiet drops dance and yawn
54export function mood(m: MoodInput, now: number): Mood {
55 const on = (k: keyof Effects) => (m.effects[k] ?? 0) > now;
56 if (on("sulk")) return "sulk";
57 if (!m.quiet && on("dance")) return "dance";
58 if (on("hop")) return "hop";
59 if (!m.quiet && on("yawn")) return "yawn";
60 if (now - m.lastTurn >= m.sleepAfterMs) return "sleep";
61 if (m.worried) return "worry";
62 if (m.hungry) return "hungry";
63 return "idle";
64}
65
66// what it says: a line per mood while the mood lasts, and idle chatter now and then
67export const QUIP: Record<Mood, string> = { idle: "", hop: "ooh, an edit!", dance: "yay!", sulk: "...", sleep: "zzz", yawn: "long day, huh?", hungry: "feed me?", worry: "context's getting tight..." };
68export const CHATTER = ["hi.", "what are we building?", "ship it.", "did you commit?", "hydrate.", "i like this repo.", "tests are friends.", "*looks around*"];
69export const CHATTER_EVERY_MS = 5 * 60 * 1000, SAY_MS = 4000;
70export const LIMIT_PERCENT = 90; // rate-limit window fill it warns about, once per crossing
71export const LONG_TURN_MS = 5 * 60 * 1000, BUSY_EDITS = 8;
72
73// what it says when a main-loop turn ends; "" for nothing worth a word
74export function turnRemark(reason: string, durationMs: number, edits: number): string {
75 if (reason === "aborted") return "oh. ok.";
76 if (reason === "error") return "ouch.";
77 if (edits >= BUSY_EDITS) return "busy turn!";
78 if (durationMs >= LONG_TURN_MS) return "phew.";
79 return "";
80}
81
82// praise in a prompt: the pet dances
83export const PRAISE = /\b(thanks|thank you|good (job|work|bo[yt])|well done|nice work|great work|love (you|it))\b/i;
84
85export const ageDays = (pet: Pet, now: number) => Math.floor((now - pet.born) / 86_400_000);
86hooks/raster.ts 25 lines1// Raster cell packing: [codePoint, fg, bg] triplets → little-endian u32 → base64 (RasterProps.cells).
2export const DEFAULT = 0x01000000; // the terminal's own colour
3
4export type Cell = [cp: number, fg: number, bg?: number];
5
6export function pack(cells: Cell[]): string {
7 const words = new Uint32Array(cells.length * 3);
8 cells.forEach(([cp, fg, bg], i) => { words[i * 3] = cp; words[i * 3 + 1] = fg; words[i * 3 + 2] = bg ?? DEFAULT; });
9 return b64(new Uint8Array(words.buffer));
10}
11
12// Uint8Array.toBase64 is Node 26+; the plugin runtime has it, CI's Node may not
13const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
14function b64(bytes: Uint8Array): string {
15 const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64;
16 if (native) return native.call(bytes);
17 let out = "";
18 for (let i = 0; i < bytes.length; i += 3) {
19 const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2];
20 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
21 out += B64[n >> 18]! + B64[(n >> 12) & 63]! + (b === undefined ? "=" : B64[(n >> 6) & 63]!) + (c === undefined ? "=" : B64[n & 63]!);
22 }
23 return out;
24}
25