SLOPSHOPPER

pet

A tiny terminal pet that lives on your coding sessions

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-15Ron-Nefyodov/pet-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pet
│ ┃ Pixel ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Pixel the cat · level 1 · sad │ pet │ │ ┃ ⏺ Read(src/auth.ts) │ An egg hatched! Meet Pixel the cat. Say hi │ │ ┃ /\_/\ food █████████████████ ⎿ Read 6 lines │ with /pet │ │ ┃ ( ;_; ) happiness ███████████████░░ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ > ^ < ) energy █████████████████ ⎿ Added 2 lines, removed 1 line │ ┃ / \ ( level 1 ██░░░░░░░░░░░░░░░ ⏺ Bash(bun test) │ ┃ (_|_ _|_)_/ age 0 min ⎿ 3 pass, 1 fail │ ┃ │ ┃ Pixel looks a little down. Errors happen. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ /pet name <name> · Esc closes │ ✻ Worked for 42s · done 4:20 PM │ │ › /pet │ │ =;_;= Pixel · sad ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
=;_;= Pixel · sad
Pane · Pixel
Pixel the cat · level 1 · sad /\_/\ food ███████████████████░ 95 ( ;_; ) happiness ███████████████░░░░░ 74 > ^ < ) energy ████████████████████ 98 / \ ( level 1 ██░░░░░░░░░░░░░░░░░░ 5/50 xp (_|_ _|_)_/ age 0 min Pixel looks a little down. Errors happen. /pet name <name> · Esc closes
README

Pet

A tiny terminal pet that lives on your coding sessions.

Pet pane: a dragon named Tofu with food, happiness and energy bars

Why

Long sessions get dull. A pet makes your progress visible: it is happy when your tests pass and sad when a tool fails, so you notice both at a glance.

  • Reacts to real work. It watches answers, tool errors and passing test runs.
  • Out of the way. One line above the prompt. /pet for the big view.
  • Never dies. Leave for a week; it just gets grumpy.
  • Private. It lives in the mod's local store. No network calls.

Use

Your pet hatches on first start: a cat, dog, blob or dragon. It lives in one line above your prompt.

  • Fed when Claude answers. Sad when a tool fails.
  • Happy (and big xp) when your tests pass.
  • Sleeps after 30 idle minutes.
  • Levels up as you work. It never dies — it just gets grumpy.
CommandWhat
/petBig view: art, food / happiness / energy bars, level, age
/pet name <name>Name your pet

Install

/plugin marketplace add Ron-Nefyodov/pet-mod /plugin install pet@pet

Or try it locally: claude --plugin-dir .

Develop

npm test # claude plugin test . claude plugin validate .

Source 7 files
hooks/register.js 145 lines
1import { COMMAND, FRAME_MS, HELP_TEXT, PANE_ID as PET_PANE_ID, parsePetArgs } from './commands.js';
2import { bandSegments, drawBand, drawPane } from './draw.js';
3import { applyEvent, decay, hintOf, moodOf, normalizePet } from './pet.js';
4import { PET_KEY, noticesOf, updatePet } from './pet-store.js';
5import { bashOutputOf, looksLikePassingTests } from './testpass.js';
6
7let ticker = null;
8
9export function register(on) {
10  on('session.start', async ($, e, next) => {
11    const result = await next(e);
12    try {
13      await $.command.register(COMMAND);
14      const now = await $.clock.now();
15      const change = await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type: 'activity' }, now));
16      for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
17      $.ui.invalidate('ui.render');
18    } catch {
19      // The pet is best effort; never block a session.
20    }
21    return result;
22  }).catch(($, e, next) => next(e));
23
24  on('prompt.submit', async ($, e, next) => {
25    const result = await next(e);
26    if (e.origin.kind === 'composer') {
27      try {
28        const now = await $.clock.now();
29        await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type: 'activity' }, now));
30        $.ui.invalidate('ui.render');
31      } catch {
32      }
33    }
34    return result;
35  }).catch(($, e, next) => next(e));
36
37  on('turn.complete', async ($, e, next) => {
38    const result = await next(e);
39    try {
40      if (e.reason === 'answer' && e.agentId === undefined) {
41        const now = await $.clock.now();
42        const change = await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type: 'answer' }, now));
43        for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
44        $.ui.invalidate('ui.render');
45      }
46    } catch {
47      // best effort
48    }
49    return result;
50  }).catch(($, e, next) => next(e));
51
52  on('tool.call', async ($, e, next) => {
53    const result = await next(e);
54    try {
55      if (result && !result.deny) {
56        const type = result.isError ? 'toolError'
57          : e.tool === 'Bash' && looksLikePassingTests(bashOutputOf(result)) ? 'testsPassed'
58          : 'activity';
59        const now = await $.clock.now();
60        const change = await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type }, now));
61        for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
62        if (type !== 'activity') $.ui.invalidate('ui.render');
63      }
64    } catch {
65      // best effort
66    }
67    return result;
68  }).catch(($, e, next) => next(e));
69
70  on('command.run', { command: 'pet' }, async ($, e, next) => {
71    const args = parsePetArgs(e.args);
72    if (args.action === 'help') return { text: HELP_TEXT };
73    const now = await $.clock.now();
74    const io = { get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) };
75
76    if (args.action === 'rename') {
77      const change = await updatePet(io, now, (p) => applyEvent(p, { type: 'rename', name: args.name }, now));
78      $.ui.invalidate('ui.render');
79      return { text: `Your ${change.after.species} is now called ${change.after.name}.` };
80    }
81
82    const change = await updatePet(io, now, (p) => applyEvent(p, { type: 'activity' }, now));
83    for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
84    await $.ui.open({ id: PET_PANE_ID, title: change.after.name, focus: true, closeOnEscape: true, rows: 14 });
85    if (!ticker) {
86      ticker = $.clock.every(FRAME_MS, async () => {
87        try {
88          const isOpen = (await $.ui.panes()).some((pane) => pane.id === PET_PANE_ID);
89          if (isOpen) {
90            $.ui.invalidate('ui.render');
91            return;
92          }
93        } catch {
94          // fall through and stop
95        }
96        ticker?.cancel();
97        ticker = null;
98      });
99    }
100    $.ui.invalidate('ui.render');
101    return {};
102  }).catch(() => ({ text: '/pet hit an error. Try again.' }));
103
104  on('ui.close', async ($, e, next) => {
105    const result = await next(e);
106    if (e.id === PET_PANE_ID && ticker) {
107      ticker.cancel();
108      ticker = null;
109    }
110    return result;
111  }).catch(($, e, next) => next(e));
112
113  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e);
115    try {
116      const stored = normalizePet(await $.store.get(PET_KEY));
117      if (!stored) return next(e);
118      const now = await $.clock.now();
119      const pet = decay(stored, now);
120      return drawBand($.ui.resolve(e), bandSegments(pet, moodOf(stored, now), hintOf(pet), e.props.bodyColumns ?? 80));
121    } catch {
122      return next(e);
123    }
124  });
125
126  on('ui.render', { component: 'Pane', requestId: 'pet' }, async ($, e, next) => {
127    if (e.surface !== 'terminal') return next(e);
128    try {
129      const stored = normalizePet(await $.store.get(PET_KEY));
130      if (!stored) return next(e);
131      const now = await $.clock.now();
132      return drawPane($.ui.resolve(e), {
133        pet: decay(stored, now),
134        mood: moodOf(stored, now),
135        frame: Math.floor(now / FRAME_MS) % 2,
136        now,
137        columns: e.props.bodyColumns,
138      });
139    } catch {
140      return next(e);
141    }
142  });
143
144}
145
hooks/commands.js 24 lines
1import { cleanName } from './pet.js';
2
3export const PANE_ID = 'pet';
4export const FRAME_MS = 800;
5
6export const COMMAND = {
7  name: 'pet',
8  description: 'Visit your terminal pet',
9  argumentHint: '[name <name>]',
10};
11
12export const HELP_TEXT = 'Usage: /pet opens your pet · /pet name <name> renames it';
13
14export function parsePetArgs(args) {
15  const text = String(args ?? '').trim();
16  if (text === '') return { action: 'open' };
17  const match = /^name(?:\s+([\s\S]*))?$/i.exec(text);
18  if (match) {
19    const name = cleanName(match[1]);
20    return name ? { action: 'rename', name } : { action: 'help' };
21  }
22  return { action: 'help' };
23}
24
hooks/draw.js 101 lines
1import { ART_WIDTH, artFrame, colorOf, faceOf, moodLine } from './art.js';
2import { ageLabel, levelProgress } from './pet.js';
3
4const LABEL_WIDTH = 10;
5
6export function statBar(value, width) {
7  const ratio = Math.max(0, Math.min(100, value)) / 100;
8  const filled = value > 0 ? Math.max(1, Math.round(ratio * width)) : 0;
9  return '█'.repeat(filled) + '░'.repeat(width - filled);
10}
11
12function moodLabel(mood) {
13  return mood === 'sleeping' ? 'sleeping zZz' : mood;
14}
15
16export function bandSegments(pet, mood, hint, columns) {
17  const segments = [
18    { text: faceOf(pet.species, mood), color: colorOf(pet.species), bold: true },
19    { text: ` ${pet.name}`, bold: true },
20    { text: ` · ${moodLabel(mood)}`, dimColor: true },
21  ];
22  if (hint) segments.push({ text: ` · ${hint}`, color: 'warning' });
23  return fit(segments, Math.max(0, columns));
24}
25
26function fit(segments, columns) {
27  const out = [];
28  let left = columns;
29  for (const segment of segments) {
30    if (left <= 0) break;
31    if (segment.text.length <= left) {
32      out.push(segment);
33      left -= segment.text.length;
34      continue;
35    }
36    out.push({ ...segment, text: left > 1 ? `${segment.text.slice(0, left - 1)}…` : segment.text.slice(0, left) });
37    break;
38  }
39  return out;
40}
41
42export function drawBand({ Box, Text }, segments) {
43  return Box({
44    key: 'pet-band', flexDirection: 'row',
45    children: segments.map((segment) => Text({
46      wrap: 'truncate',
47      ...(segment.color && { color: segment.color }),
48      ...(segment.bold && { bold: true }),
49      ...(segment.dimColor && { dimColor: true }),
50      children: [segment.text],
51    })),
52  });
53}
54
55function statLine(label, value, width) {
56  return `${label.padEnd(LABEL_WIDTH)}${statBar(value, width)} ${String(Math.round(value)).padStart(3)}`;
57}
58
59export function drawPane({ Box, Text }, { pet, mood, frame, now, columns }) {
60  const row = (key, props) => Box({ key, children: [Text(props)] });
61  const width = Math.max(20, (columns ?? 60) - 2);
62  const isWide = width >= ART_WIDTH + 2 + LABEL_WIDTH + 16;
63  const barWidth = Math.max(6, Math.min(20, (isWide ? width - ART_WIDTH - 2 : width) - LABEL_WIDTH - 4));
64  const color = colorOf(pet.species);
65  const progress = levelProgress(pet.xp);
66
67  const art = Box({
68    key: 'pet-art', flexDirection: 'column', width: ART_WIDTH, flexShrink: 0,
69    children: artFrame(pet.species, mood, frame).map((line) => Text({ color, children: [line] })),
70  });
71  const stats = Box({
72    flexDirection: 'column', ...(isWide ? { marginLeft: 2 } : { marginTop: 1 }),
73    children: [
74      row('pet-stat-hunger', { children: [statLine('food', pet.hunger, barWidth)] }),
75      row('pet-stat-happiness', { children: [statLine('happiness', pet.happiness, barWidth)] }),
76      row('pet-stat-energy', { children: [statLine('energy', pet.energy, barWidth)] }),
77      row('pet-level', {
78        color,
79        children: [`${`level ${progress.level}`.padEnd(LABEL_WIDTH)}${statBar((progress.into / progress.need) * 100, barWidth)} ${progress.into}/${progress.need} xp`],
80      }),
81      row('pet-age', { dimColor: true, children: [`${'age'.padEnd(LABEL_WIDTH)}${ageLabel(pet.bornAt, now)}`] }),
82    ],
83  });
84
85  return Box({
86    key: 'pet-pane', flexDirection: 'column', paddingX: 1,
87    children: [
88      Box({
89        key: 'pet-header', flexDirection: 'row',
90        children: [
91          Text({ bold: true, color, children: [pet.name] }),
92          Text({ dimColor: true, children: [` the ${pet.species} · level ${progress.level} · ${moodLabel(mood)}`] }),
93        ],
94      }),
95      Box({ flexDirection: isWide ? 'row' : 'column', marginTop: 1, children: [art, stats] }),
96      Box({ key: 'pet-mood', marginTop: 1, children: [Text({ italic: true, children: [moodLine(pet.name, pet.species, mood)] })] }),
97      row('pet-footer', { dimColor: true, children: ['/pet name <name> · Esc closes'] }),
98    ],
99  });
100}
101
hooks/pet.js 155 lines
1export const SPECIES = ['cat', 'dog', 'blob', 'dragon'];
2export const MOODS = ['happy', 'ok', 'hungry', 'sad', 'grumpy', 'sleeping'];
3export const SLEEP_AFTER_MS = 30 * 60000;
4
5const NAMES = ['Mochi', 'Pixel', 'Bean', 'Noodle', 'Biscuit', 'Ziggy', 'Pip', 'Tofu', 'Waffles', 'Byte', 'Nugget', 'Sprout'];
6const HOUR = 3600000;
7const AWAKE_PER_HOUR = { hunger: -8, happiness: -5, energy: -6 };
8const ASLEEP_PER_HOUR = { hunger: -3, happiness: -1, energy: 25 };
9const SAD_FOR_MS = 3 * 60000;
10const HAPPY_FOR_MS = 5 * 60000;
11const TESTS_COOLDOWN_MS = 60000;
12const XP_ANSWER = 5;
13const XP_TESTS = 40;
14
15const clamp = (value) => Math.max(0, Math.min(100, value));
16const pick = (list, random) => list[Math.min(list.length - 1, Math.floor(random() * list.length))];
17
18export function hatch(now, random = Math.random) {
19  return {
20    species: pick(SPECIES, random),
21    name: pick(NAMES, random),
22    hunger: 80,
23    happiness: 80,
24    energy: 100,
25    xp: 0,
26    bornAt: now,
27    updatedAt: now,
28    lastActiveAt: now,
29    lastTestsAt: 0,
30    flash: null,
31  };
32}
33
34export function normalizePet(value) {
35  if (!value || typeof value !== 'object' || !SPECIES.includes(value.species)) return null;
36  const num = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
37  const bornAt = num(value.bornAt, 0);
38  return {
39    species: value.species,
40    name: typeof value.name === 'string' && value.name ? value.name : 'Pet',
41    hunger: clamp(num(value.hunger, 50)),
42    happiness: clamp(num(value.happiness, 50)),
43    energy: clamp(num(value.energy, 50)),
44    xp: Math.max(0, num(value.xp, 0)),
45    bornAt,
46    updatedAt: num(value.updatedAt, bornAt),
47    lastActiveAt: num(value.lastActiveAt, bornAt),
48    lastTestsAt: num(value.lastTestsAt, 0),
49    flash: value.flash && MOODS.includes(value.flash.mood) ? { mood: value.flash.mood, until: num(value.flash.until, 0) } : null,
50  };
51}
52
53function drift(pet, rates, ms) {
54  if (ms <= 0) return pet;
55  const hours = ms / HOUR;
56  return {
57    ...pet,
58    hunger: clamp(pet.hunger + rates.hunger * hours),
59    happiness: clamp(pet.happiness + rates.happiness * hours),
60    energy: clamp(pet.energy + rates.energy * hours),
61  };
62}
63
64export function decay(pet, now) {
65  const from = pet.updatedAt;
66  if (!(now > from)) return pet;
67  const sleepStart = pet.lastActiveAt + SLEEP_AFTER_MS;
68  const awakeMs = Math.max(0, Math.min(now, sleepStart) - from);
69  const asleepMs = Math.max(0, now - Math.max(from, sleepStart));
70  return { ...drift(drift(pet, AWAKE_PER_HOUR, awakeMs), ASLEEP_PER_HOUR, asleepMs), updatedAt: now };
71}
72
73function bump(pet, changes) {
74  return {
75    ...pet,
76    hunger: clamp(pet.hunger + (changes.hunger ?? 0)),
77    happiness: clamp(pet.happiness + (changes.happiness ?? 0)),
78    energy: clamp(pet.energy + (changes.energy ?? 0)),
79    xp: pet.xp + (changes.xp ?? 0),
80  };
81}
82
83export function applyEvent(pet, event, now) {
84  const current = { ...decay(pet, now), lastActiveAt: Math.max(pet.lastActiveAt, now) };
85  switch (event.type) {
86    case 'answer':
87      return bump(current, { hunger: 15, happiness: 2, energy: -2, xp: XP_ANSWER });
88    case 'toolError':
89      return { ...bump(current, { happiness: -8 }), flash: { mood: 'sad', until: now + SAD_FOR_MS } };
90    case 'testsPassed':
91      if (now - current.lastTestsAt < TESTS_COOLDOWN_MS) return current;
92      return { ...bump(current, { happiness: 20, xp: XP_TESTS }), lastTestsAt: now, flash: { mood: 'happy', until: now + HAPPY_FOR_MS } };
93    case 'rename':
94      return { ...current, name: event.name };
95    default:
96      return current;
97  }
98}
99
100export function isAsleep(pet, now) {
101  return now - pet.lastActiveAt >= SLEEP_AFTER_MS;
102}
103
104export function moodOf(pet, now) {
105  if (isAsleep(pet, now)) return 'sleeping';
106  const p = decay(pet, now);
107  if (p.flash && now < p.flash.until) return p.flash.mood;
108  if (Math.min(p.hunger, p.happiness, p.energy) < 15) return 'grumpy';
109  if (p.hunger < 35) return 'hungry';
110  if (p.happiness < 35) return 'sad';
111  if (Math.min(p.hunger, p.happiness, p.energy) >= 70) return 'happy';
112  return 'ok';
113}
114
115export function hintOf(pet) {
116  const needs = [
117    { value: pet.hunger, low: 30, hint: 'feed me a task' },
118    { value: pet.happiness, low: 30, hint: 'pass some tests?' },
119    { value: pet.energy, low: 20, hint: 'needs a nap' },
120  ].filter((need) => need.value < need.low);
121  needs.sort((a, b) => a.value - b.value);
122  return needs[0]?.hint ?? '';
123}
124
125const xpForLevel = (level) => 25 * level * (level - 1);
126
127export function levelOf(xp) {
128  let level = 1;
129  while (xpForLevel(level + 1) <= xp) level += 1;
130  return level;
131}
132
133export function levelProgress(xp) {
134  const level = levelOf(xp);
135  const start = xpForLevel(level);
136  return { level, into: xp - start, need: xpForLevel(level + 1) - start };
137}
138
139export function ageLabel(bornAt, now) {
140  const ms = Math.max(0, now - bornAt);
141  if (ms < HOUR) return `${Math.floor(ms / 60000)} min`;
142  if (ms < 24 * HOUR) return `${Math.floor(ms / HOUR)} h`;
143  const days = Math.floor(ms / (24 * HOUR));
144  return `${days} ${days === 1 ? 'day' : 'days'}`;
145}
146
147export function cleanName(raw) {
148  return String(raw ?? '')
149    .replace(/[\u0000-\u001f\u007f]/g, '')
150    .replace(/\s+/g, ' ')
151    .trim()
152    .slice(0, 16)
153    .trim();
154}
155
hooks/pet-store.js 27 lines
1import { hatch, levelOf, normalizePet } from './pet.js';
2
3export const PET_KEY = 'pet';
4
5let chain = Promise.resolve();
6
7// Serializes read-modify-write so parallel tool calls lose no update.
8export function updatePet(io, now, change, random = Math.random) {
9  const run = chain.then(async () => {
10    const stored = normalizePet(await io.get(PET_KEY));
11    const before = stored ?? hatch(now, random);
12    const after = change(before);
13    await io.set(PET_KEY, after);
14    return { before, after, hatched: !stored };
15  });
16  chain = run.catch(() => undefined);
17  return run;
18}
19
20export function noticesOf({ before, after, hatched }) {
21  const notices = [];
22  if (hatched) notices.push(`An egg hatched! Meet ${after.name} the ${after.species}. Say hi with /pet`);
23  const level = levelOf(after.xp);
24  if (level > levelOf(before.xp)) notices.push(`★ ${after.name} reached level ${level}!`);
25  return notices;
26}
27
hooks/testpass.js 33 lines
1const FAILURE_PATTERNS = [
2  /\b[1-9]\d*\s+(failed|failing|failures?|errors?|errored)\b/i,
3  /\b[1-9]\d*\s+tests?\s+failed\b/i,
4  /\bfail(?:ures|ed)?\s*[:=]\s*[1-9]/i,
5  /\bFAIL(?:ED)?\b/,
6  /^\s*not ok\b/m,
7  /\bAssertionError\b|\bpanic:|Traceback \(most recent call last\)/,
8];
9
10const PASS_PATTERNS = [
11  /\b[1-9]\d*\s+(passed|passing)\b/i,
12  /\b[1-9]\d*\s+tests?\s+passed\b/i,
13  /^ok\s+\S+\s+(\(cached\)|\d+(\.\d+)?s)/m,
14  /^OK(\s+\(.*\))?\s*$/m,
15  /\btest result: ok\./,
16  /\b[1-9]\d*\s+examples?,\s+0\s+failures\b/,
17];
18
19export function looksLikePassingTests(text) {
20  if (typeof text !== 'string' || text === '') return false;
21  if (FAILURE_PATTERNS.some((pattern) => pattern.test(text))) return false;
22  return PASS_PATTERNS.some((pattern) => pattern.test(text));
23}
24
25export function bashOutputOf(result) {
26  if (!result || result.deny || result.isError) return '';
27  const record = result.result;
28  const streams = record && typeof record === 'object'
29    ? [record.stdout, record.stderr].filter((s) => typeof s === 'string').join('\n')
30    : '';
31  return streams || (typeof result.text === 'string' ? result.text : '');
32}
33
hooks/art.js 137 lines
1export const ART_WIDTH = 14;
2export const ART_HEIGHT = 5;
3
4const EYES = {
5  happy: '^w^',
6  ok: 'o.o',
7  hungry: 'O~O',
8  sad: ';_;',
9  grumpy: '>_<',
10  sleeping: '-.-',
11};
12
13const SPECIES_ART = {
14  cat: {
15    color: 'warning',
16    face: (eyes) => `=${eyes}=`,
17    frames: [
18      [
19        String.raw`  /\_/\       `,
20        String.raw` ( @@@ )      `,
21        String.raw`  > ^ <    )  `,
22        String.raw` /     \  (   `,
23        String.raw`(_|_ _|_)_/   `,
24      ],
25      [
26        String.raw`  /\_/\       `,
27        String.raw` ( @@@ )      `,
28        String.raw`  > ^ <  (    `,
29        String.raw` /     \  )   `,
30        String.raw`(_|_ _|_)_\   `,
31      ],
32    ],
33    happy: 'purrs and kneads the keyboard.',
34    sleeping: 'is curled up in a warm loop. zZz',
35  },
36  dog: {
37    color: 'suggestion',
38    face: (eyes) => `U${eyes}U`,
39    frames: [
40      [
41        String.raw` __      __   `,
42        String.raw`/  \____/  \  `,
43        String.raw`\_|  @@@  |_/ `,
44        String.raw`  |  (_)  | / `,
45        String.raw`  '-------'/  `,
46      ],
47      [
48        String.raw` __      __   `,
49        String.raw`/  \____/  \  `,
50        String.raw`\_|  @@@  |_/ `,
51        String.raw`  |  (_)  | \ `,
52        String.raw`  '-------'\  `,
53      ],
54    ],
55    happy: 'wags so hard the whole terminal shakes.',
56    sleeping: 'is snoring on the rug. zZz',
57  },
58  blob: {
59    color: 'merged',
60    face: (eyes) => `(${eyes})`,
61    frames: [
62      [
63        String.raw`              `,
64        String.raw`    .---.     `,
65        String.raw`   ( @@@ )    `,
66        String.raw`  (       )   `,
67        String.raw`   '-----'    `,
68      ],
69      [
70        String.raw`     .-.      `,
71        String.raw`   .'   '.    `,
72        String.raw`   ( @@@ )    `,
73        String.raw`   (     )    `,
74        String.raw`    '---'     `,
75      ],
76    ],
77    happy: 'jiggles with pure joy.',
78    sleeping: 'has melted into a sleepy puddle. zZz',
79  },
80  dragon: {
81    color: 'success',
82    face: (eyes) => `<${eyes}>`,
83    frames: [
84      [
85        String.raw`    __/\__    `,
86        String.raw` /\( @@@ )/\  `,
87        String.raw` \ \ \/\/ / / `,
88        String.raw`    /    \__~ `,
89        String.raw`   ^^    ^^   `,
90      ],
91      [
92        String.raw` /\_ /\ _/\   `,
93        String.raw` \ ( @@@ ) /  `,
94        String.raw`   \ \/\/ /   `,
95        String.raw`    /    \__~ `,
96        String.raw`   ^^    ^^   `,
97      ],
98    ],
99    happy: 'puffs a happy little smoke ring.',
100    sleeping: 'snores tiny sparks. zZz',
101  },
102};
103
104const MOOD_COPY = {
105  ok: 'is hanging out, watching you code.',
106  hungry: "'s tummy rumbles. Got a task?",
107  sad: 'looks a little down. Errors happen.',
108  grumpy: 'is grumpy and wants some attention.',
109};
110
111const SNORE = ['z ', ' Z'];
112
113export function colorOf(species) {
114  return SPECIES_ART[species]?.color ?? 'text';
115}
116
117export function faceOf(species, mood) {
118  return SPECIES_ART[species].face(EYES[mood] ?? EYES.ok);
119}
120
121export function artFrame(species, mood, frameIndex) {
122  const art = SPECIES_ART[species];
123  const index = ((frameIndex % art.frames.length) + art.frames.length) % art.frames.length;
124  const eyes = EYES[mood] ?? EYES.ok;
125  const lines = art.frames[index].map((line) => line.replace('@@@', eyes).padEnd(ART_WIDTH));
126  if (mood === 'sleeping') {
127    lines[0] = lines[0].slice(0, ART_WIDTH - 2) + SNORE[index % SNORE.length];
128  }
129  return lines;
130}
131
132export function moodLine(name, species, mood) {
133  const art = SPECIES_ART[species];
134  const copy = art[mood] ?? MOOD_COPY[mood] ?? MOOD_COPY.ok;
135  return copy.startsWith("'") ? `${name}${copy}` : `${name} ${copy}`;
136}
137