SLOPSHOPPER

tamaclaude

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

newbandguardcommandtoastprompt
v0.5.2MITupdated 2026-09-22settivishal/tamaclaude/tamaclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tamaclaude
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tamaclaude ⎿ tamaclaude: Mochi · egg · sulking · streak 1 · age 0d · 1 sessions · 3 edits · 1 tests · 1 sulks ⎿ tamaclaude: /tamaclaude feed | name <x> | reset ▄████▄ Mochi · egg · content · streak 1 8: pet 9: feed ██▒███████ ██████▒███ ▀██████▀ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄████▄ Mochi · egg · content · streak 1 8: pet 9: feed ██▒███████ ██████▒███ ▀██████▀ ⟨Claude Code's own drawing⟩
README

tamaclaude

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
     ▀████▀

Install

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).

Stages

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.

stagestreakcoloursprite
egg0beigesee below
hatchling5yellowsee below
adult30greensee below
elder100purplesee 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.

What it reacts to

  • Write / Edit → hops for 1.5 s (the egg too)
  • a test runner in 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 → sulks
  • git commit, git push, gh pr create|merge in Bash that comes back clean → "shipped!"
  • a tool call another plugin denies, or a turn that ends in a refusal → sulks 30 s
  • a streak of clean turns → it grows (see stages); an aborted, errored or refused turn resets the streak, never the stage
  • no turn for 10 min (Sleep after setting) → sleeps; z's drift up; any prompt wakes it
  • a session past 2 h → yawns every few minutes
  • context 85 % full → sweats and says so; a rate-limit window past 90 % → it warns you, once
  • a turn that was aborted, errored, ran 5 min or made 8+ edits → a word about it
  • unfed for 24 h → droopy. It never dies. Press 9 with an empty prompt, or /tamaclaude feed
  • 8 pets it → a dance (a hop under quiet); praise in a prompt (thanks, good job, well done) → a dance too
  • it talks: a line above its name for each mood, a word when fed, petted or praised, and some idle chatter every 5 min (quiet mutes the chatter)
  • hover the pet for its stats: sessions, edits witnessed, tests seen, sulks

Commands

/tamaclaudestatus line
/tamaclaude feedfeed it
/tamaclaude name <x>rename it
/tamaclaude reseta fresh egg

Settings (/config)

keydefault
enabledtrueoff hides the pet
sizenormalsmall is 8×2, normal 16×4
sleepAfterMin10minutes idle before sleep
quietfalseno dance, yawn or idle chatter

The pet is stored in the plugin's store, so it follows you across sessions and repos.

Develop

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.

Source 4 files
hooks/hooks.tsx 216 lines
1import 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};
216
hooks/sprites.ts 356 lines
1// 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}
356
hooks/pet.ts 86 lines
1// 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);
86
hooks/raster.ts 25 lines
1// 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