SLOPSHOPPER

tiny-pet

A tiny Claude pet with its own little computer that works alongside you in Claude Code.

newbandguardcommandprompttimer
★ 3v0.11.0MITupdated 2026-10-08cabrera-evil/claude-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tiny-pet
› 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 › /pet ⎿ tiny-pet: Pet is taking a nap (hidden). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tiny-pet

A Claude Code mod that puts a tiny pixel-art Claude pet, with its own little laptop, in a band above your prompt. A mod is a plugin made of function hooks, so it installs and uninstalls with the usual /plugin commands.

Preview

The pet with a crew of four subagents (reading, planning, taking a coffee break, and reacting to an error):

The pet coding next to four subagents

The main pet in each of its personalities and states:

Main pet: thinking, coding, reading, running, browsing, testing, working, planning, delegating, asking, coffee, sleepy, error found, done, error, idle

A subagent in each of its looks:

Subagent looks: thinking, coding, reading, running, browsing, testing, working, planning, delegating, asking, coffee, sleepy, alarmed, done, failed

These images are drawn from the same pixel code the mod uses. The terminal shows them in true color with half-block characters, so they look the same up to the terminal's cell shape.

Behavior

StateWhenWhat you see
WorkingA turn is runningThe pet acts out what it is doing, see Personalities below
DoneA turn ended with an answerArms up, happy eyes, a check mark on the screen
ErrorA turn ended with an error or refusalCrossed eyes, a cross on the screen
IdleOtherwise, and 5 seconds after DoneThe pet naps beside a dim laptop

An interrupted turn goes straight back to Idle.

Personalities

While working, the main pet and every subagent act out what they are doing, judged from the tool in use. The caption (main pet) or label (subagent) names it.

PersonalityWhenWhat you see
thinkingBetween tool callsEyes look up, a thought cloud with cycling dots above the head, a blinking cursor
codingEdit, Write, MultiEdit, NotebookEditHeadphones, hands typing, colored code scrolling
readingRead, Grep, Glob, LSWide scanning eyes, holding an open book up to read, a highlight moving over text
runningBashSquinting at a green terminal, a blinking status light
browsingWebFetch, WebSearchA web page on screen, a wifi blip above the laptop
testingPlaywright and Chrome DevTools MCP toolsA bubbling lab tube, a test checklist ticking and a progress bar
planningTodoWrite, plan mode toolsEyes up and to the side, a checklist being ticked off
delegatingTask, Agent, SendMessageA blinking antenna, a tree of boxes passing work down
askingAskUserQuestionA raised hand, a blinking question mark, a faint one floating off the head
workingAny other tool, including MCP toolsBlinking indicator lights on screen and a spark
coffeeQuiet for 6 seconds, or a Bash command running over 12 secondsSips a mug of coffee with translucent steam wisps while it waits
sleepyQuiet for 25 secondsHeavy eyelids, a dim screen and floating Zzz that grow
error foundA tool call returned an error, for 3 secondsArms up and a hop, a sweat drop, red ! marks over the head, a flashing red warning on screen

Subagent crew

Each running subagent appears as a smaller copy of the main pet, sitting at its own little desk with a laptop. The laptop lid is tinted by subagent type, and the label under it shows the type and its current personality. A finished subagent hops with a smile and a green check, a failed one shows crossed eyes, a sweat drop and a red X, and it leaves 3 seconds later. With no subagents running the row does not exist.

Too many subagents to show:

  • Working subagents are shown first, then finished ones.
  • At most 5 mini pets are drawn, fewer on narrow terminals or at larger sizes.
  • The rest collapse into one summary tile, for example +6 more, 2 working, 1 failed.
  • If the terminal is too narrow even for the tile, the row is skipped and the caption still reports how many helpers are working.
  • The mod tracks at most 64 subagents at once.

The main caption shows how many helpers are working, and the pet says "Supervising" when only subagents are running.

Commands (the typeahead shows /pet [small|medium|large|hide|show]):

  • /pet: hide or show the pet.
  • /pet hide and /pet show: hide or show it explicitly.
  • /pet small, /pet medium, /pet large: resize it, see Size below.

The band stays hidden only while the session has no input, so an empty session shows just the native welcome. The pet appears and starts working the moment you submit your first message. Slash commands do not count as input, and a resumed session shows it right away. It is also hidden while a survey is shown. It is drawn on the terminal and on the desktop Code tab. The terminal gets a true-color cell grid, the desktop gets an SVG of the same art.

Size

The size option scales the whole pet, including the subagent crew:

ValueScaleMain pet (columns x rows)
smallhalf14 x 4
medium1x27 x 7 (default)
large2x54 x 14

Every size scales width and height together, so the pet keeps its proportions. Small halves the art in both directions and keeps the eyes and other dark details visible. Large needs a wide and tall terminal, and fewer mini pets fit beside it. If the space above the prompt is too short for the chosen size, the pet steps down to the next size that fits instead of being cut off. On the desktop Code tab the SVG scales by the same factor.

Change it with /pet small, /pet medium or /pet large. It applies immediately, even in the middle of a turn, and is remembered across sessions.

The size option in the plugin's options screen (shown at install time and in the config menu) sets the default for when no size has been chosen with /pet. Once you run /pet <size>, that choice wins over the option.

Install

In any Claude Code session:

/plugin install tiny-pet --marketplace cabrera-evil/claude-pet

Answer y to add the marketplace, then choose the user scope so the pet loads in every session. It is active right away, with no restart.

From a local clone instead:

git clone https://github.com/cabrera-evil/claude-pet
claude plugin marketplace add ./claude-pet
claude plugin install tiny-pet@claude-pet

To try it for one session without installing:

claude --plugin-dir /path/to/claude-pet

Uninstall

claude plugin uninstall tiny-pet@claude-pet
claude plugin marketplace remove claude-pet

Or inside a session: run /plugin, select tiny-pet, then Uninstall.

Update

For a marketplace added from a folder, edit the files and run /reload-plugins. For one added from GitHub, pull the new version with:

claude plugin update tiny-pet@claude-pet

Troubleshooting

  • The pet does not show: run /plugin and confirm tiny-pet is enabled, then /reload-plugins. Run /pet in case it was hidden.
  • Colors look wrong: the terminal needs true-color support.
  • Flicker on a slow terminal: raise WORK_TICK_MS and IDLE_TICK_MS in hooks/config.ts.
  • Inspect load problems with claude --debug.

Development

Validate the manifest, marketplace and hooks module:

claude plugin validate .

Layout:

  • .claude-plugin/plugin.json: mod manifest.
  • .claude-plugin/marketplace.json: makes this repository installable as a marketplace.
  • hooks/hooks.json: names the hooks module.
  • hooks/register.tsx: events, state atoms and the ticker. Everything that touches the harness lives here.
  • hooks/config.ts: timings and layout constants.
  • hooks/format.ts: small text and color formatters.
  • hooks/view/band.tsx: draws the band (pet, crew, caption) from plain data.
  • hooks/logic/caption.ts: caption title and detail text.
  • hooks/logic/size.ts: size names, scales and fitting to the available rows.
  • hooks/art/pixels.ts: pixel canvas helpers and the main color set. hooks/art/palette.ts holds colors shared by the pet and the mini pets.
  • hooks/logic/activity.ts: decides which personality applies from the tool in use, its timing and errors.
  • hooks/art/pet/: the main pet and laptop art, one file per part (screen, eyes, body, props) assembled in draw.ts. Change colors and shapes here.
  • hooks/art/mini/: the mini subagent pets, split the same way, plus desk.ts and identity.ts (accent color, label, note), assembled in draw.ts.
  • scripts/bump-version.py: bumps the version in plugin.json from the files changed between two revisions.
  • .github/workflows/bump-version.yml: runs the script on pushes to master and commits the bump.
  • docs/: the preview images used by this README.
  • hooks/logic/crew.ts: the logic that fits the crew into the available width.
  • hooks/render/render.ts: converts the pixels to a terminal cell grid (half-block characters, scaled up or down) or an SVG.
  • types/index.d.ts: the state contract the module is validated against.

License

See LICENSE.

Source 27 files
hooks/register.tsx 205 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Member, Mood } from '../types'
5import { activityFor } from './logic/activity'
6import { isActive, statusFor } from './logic/crew'
7import { isSize } from './logic/size'
8import {
9  ALERT_MS,
10  ARGUMENT_HINT,
11  CREW_REST_MS,
12  IDLE_TICK_MS,
13  MAX_TRACKED,
14  REST_MS,
15  SIZE_KEY,
16  USAGE,
17  WORK_TICK_MS,
18} from './config'
19import { renderBand } from './view/band'
20
21const mood = atom({ plugin: 'tiny-pet', key: 'mood' } as const, 'idle')
22const frame = atom({ plugin: 'tiny-pet', key: 'frame' } as const, 0)
23const tool = atom({ plugin: 'tiny-pet', key: 'tool' } as const, null)
24const toolSince = atom({ plugin: 'tiny-pet', key: 'toolSince' } as const, 0)
25const alert = atom({ plugin: 'tiny-pet', key: 'alert' } as const, false)
26const isHidden = atom({ plugin: 'tiny-pet', key: 'isHidden' } as const, false)
27const hasStarted = atom({ plugin: 'tiny-pet', key: 'hasStarted' } as const, false)
28const size = atom({ plugin: 'tiny-pet', key: 'size' } as const, 'medium')
29const crew = atom({ plugin: 'tiny-pet', key: 'crew' } as const, [])
30
31let ticker: { cancel: () => void } | null = null
32
33const retime = ($: EngineInterface, ms: number) => {
34  ticker?.cancel()
35  ticker = $.clock.every(ms, () => void update($, frame, n => n + 1))
36}
37
38const settle = async ($: EngineInterface) => {
39  const isBusy = (await read($, mood)) === 'working' || (await read($, crew)).some(isActive)
40  retime($, isBusy ? WORK_TICK_MS : IDLE_TICK_MS)
41}
42
43export const register: Register = (on, options) => {
44  const defaultSize = String(options.size)
45  let rest: { cancel: () => void } | null = null
46  let startedAt = 0
47
48  on('session.start', async ($, e, next) => {
49    await $.command.register({
50      name: 'pet',
51      description: 'Show, hide or resize the Claude pet',
52      argumentHint: ARGUMENT_HINT,
53      immediate: true,
54    })
55    const saved = String((await $.store.get(SIZE_KEY)) ?? '')
56    await update($, size, () => (isSize(saved) ? saved : isSize(defaultSize) ? defaultSize : 'medium'))
57    retime($, IDLE_TICK_MS)
58    if ((await $.session.turns()) > 0) await update($, hasStarted, () => true)
59
60    return next(e)
61  })
62
63  on('prompt.submit', async ($, e, next) => {
64    if (!e.text.trimStart().startsWith('/')) await update($, hasStarted, () => true)
65
66    return next(e)
67  }).catch((_$, e, next) => next(e))
68
69  on('command.run', { command: 'pet' }, async ($, e) => {
70    const arg = e.args.trim().toLowerCase()
71
72    if (isSize(arg)) {
73      await $.store.set(SIZE_KEY, arg)
74      await update($, size, () => arg)
75      await update($, isHidden, () => false)
76
77      return { text: `Pet size set to ${arg}.` }
78    }
79    if (arg === '' || arg === 'hide' || arg === 'show') {
80      const hidden = arg === '' ? !(await read($, isHidden)) : arg === 'hide'
81      await update($, isHidden, () => hidden)
82
83      return { text: hidden ? 'Pet is taking a nap (hidden).' : 'Pet is back.' }
84    }
85
86    return { text: USAGE }
87  })
88
89  on('turn.start', async ($, e, next) => {
90    rest?.cancel()
91    startedAt = await $.clock.now()
92    await update($, hasStarted, () => true)
93    await update($, mood, (): Mood => 'working')
94    await update($, tool, () => null)
95    await update($, toolSince, () => startedAt)
96    await update($, alert, () => false)
97    await settle($)
98
99    return next(e)
100  })
101
102  on('agent.spawn', async ($, e, next) => {
103    const started = await next(e)
104    if ('agentId' in started && started.agentId) {
105      const member: Member = {
106        id: started.agentId,
107        kind: e.subagentType,
108        tool: null,
109        status: 'working',
110        since: await $.clock.now(),
111        alert: false,
112      }
113      await update($, crew, list => [...list.filter(m => m.id !== member.id), member].slice(-MAX_TRACKED))
114      await settle($)
115    }
116
117    return started
118  }).catch((_$, e, next) => next(e))
119
120  on('tool.call', async ($, e, next) => {
121    const agentId = e.agentId
122    if (agentId === undefined) {
123      await update($, tool, () => e.tool)
124      const mainStarted = await $.clock.now()
125      await update($, toolSince, () => mainStarted)
126      const mainRan = await next(e)
127      await update($, tool, () => null)
128      const mainEnded = await $.clock.now()
129      await update($, toolSince, () => mainEnded)
130      if (mainRan.isError === true) {
131        await update($, alert, () => true)
132        $.clock.after(ALERT_MS, () => void update($, alert, () => false))
133      }
134
135      return mainRan
136    }
137
138    const startedCall = await $.clock.now()
139    await update($, crew, list => list.map(m => (m.id === agentId ? { ...m, tool: e.tool, since: startedCall } : m)))
140    const ran = await next(e)
141    const endedCall = await $.clock.now()
142    const failed = ran.isError === true
143    await update($, crew, list =>
144      list.map(m => (m.id === agentId ? { ...m, tool: null, since: endedCall, alert: m.alert || failed } : m)),
145    )
146    if (failed) {
147      $.clock.after(ALERT_MS, () => {
148        void update($, crew, list => list.map(m => (m.id === agentId ? { ...m, alert: false } : m)))
149      })
150    }
151
152    return ran
153  }).catch((_$, e, next) => next(e))
154
155  on('turn.complete', async ($, e, next) => {
156    const agentId = e.agentId
157    if (agentId !== undefined) {
158      const status = statusFor(e.reason)
159      await update($, crew, list => list.map(m => (m.id === agentId ? { ...m, status, tool: null, alert: false } : m)))
160      $.clock.after(CREW_REST_MS, () => {
161        void update($, crew, list => list.filter(m => m.id !== agentId)).then(() => settle($))
162      })
163      await settle($)
164
165      return next(e)
166    }
167
168    await update($, mood, (): Mood => (e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'idle' : 'error'))
169    await update($, tool, () => null)
170    await update($, alert, () => false)
171    await settle($)
172    rest = $.clock.after(REST_MS, () => void update($, mood, (): Mood => 'idle'))
173
174    return next(e)
175  })
176
177  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
178    if (e.props.hasSurvey || (await read($, isHidden)) || !(await read($, hasStarted))) {
179      return next(e)
180    }
181
182    const current = await read($, mood)
183    const label = await read($, tool)
184    const members = await read($, crew)
185    const tick = await read($, frame)
186    const now = await $.clock.now()
187    const seconds = current === 'working' ? Math.round((now - startedAt) / 1000) : 0
188    const activity = current === 'working' ? activityFor({ tool: label, since: await read($, toolSince), alert: await read($, alert) }, now) : 'thinking'
189    return renderBand({
190      elements: $.ui.resolve(e),
191      surface: e.surface,
192      maxRows: e.props.maxRows,
193      bodyColumns: e.props.bodyColumns,
194      sizeName: await read($, size),
195      current,
196      activity,
197      label,
198      seconds,
199      tick,
200      now,
201      members,
202    })
203  })
204}
205
hooks/logic/activity.ts 67 lines
1export type Activity =
2  | 'thinking'
3  | 'coding'
4  | 'researching'
5  | 'running'
6  | 'browsing'
7  | 'testing'
8  | 'tooling'
9  | 'planning'
10  | 'delegating'
11  | 'asking'
12  | 'coffee'
13  | 'sleepy'
14  | 'alarmed'
15
16export type Pulse = { tool: string | null; since: number; alert: boolean }
17
18const COFFEE_AFTER_MS = 6000
19const SLEEPY_AFTER_MS = 25000
20const SLOW_COMMAND_MS = 12000
21
22const TEST_TOOLS = /playwright|chrome-devtools/i
23const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
24const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
25const WEB_TOOLS = new Set(['WebFetch', 'WebSearch'])
26const PLAN_TOOLS = new Set(['TodoWrite', 'ExitPlanMode', 'EnterPlanMode'])
27const DELEGATE_TOOLS = new Set(['Task', 'Agent', 'SendMessage', 'TeamCreate'])
28
29export const VERBS: Record<Activity, string> = {
30  thinking: 'thinking',
31  coding: 'coding',
32  researching: 'reading',
33  running: 'running',
34  browsing: 'browsing',
35  testing: 'testing',
36  tooling: 'working',
37  planning: 'planning',
38  delegating: 'delegating',
39  asking: 'asking',
40  coffee: 'coffee',
41  sleepy: 'sleepy',
42  alarmed: 'error!',
43}
44
45export const activityOf = (tool: string): Activity => {
46  if (TEST_TOOLS.test(tool)) return 'testing'
47  if (READ_TOOLS.has(tool)) return 'researching'
48  if (WRITE_TOOLS.has(tool)) return 'coding'
49  if (tool === 'Bash') return 'running'
50  if (WEB_TOOLS.has(tool)) return 'browsing'
51  if (PLAN_TOOLS.has(tool)) return 'planning'
52  if (DELEGATE_TOOLS.has(tool)) return 'delegating'
53  if (tool === 'AskUserQuestion') return 'asking'
54  return 'tooling'
55}
56
57export const activityFor = (pulse: Pulse, now: number): Activity => {
58  const waited = now - pulse.since
59  if (pulse.alert) return 'alarmed'
60  if (pulse.tool === null) {
61    if (waited > SLEEPY_AFTER_MS) return 'sleepy'
62    return waited > COFFEE_AFTER_MS ? 'coffee' : 'thinking'
63  }
64  if (pulse.tool === 'Bash' && waited > SLOW_COMMAND_MS) return 'coffee'
65  return activityOf(pulse.tool)
66}
67
hooks/logic/crew.ts 33 lines
1import type { Member, MemberStatus } from '../../types'
2
3export const OVERFLOW_COLUMNS = 10
4
5const MAX_SHOWN = 5
6
7export type CrewFit = {
8  shown: Member[]
9  overflow: { total: number; working: number; failed: number } | null
10}
11
12export const fitCrew = (members: readonly Member[], room: number, slot: number): CrewFit => {
13  const ordered = [...members].sort((a, b) => Number(b.status === 'working') - Number(a.status === 'working'))
14  const fits = Math.min(MAX_SHOWN, Math.floor(room / slot))
15  if (ordered.length <= fits) return { shown: ordered, overflow: null }
16  if (room < OVERFLOW_COLUMNS) return { shown: [], overflow: null }
17
18  const count = Math.max(0, Math.min(MAX_SHOWN, Math.floor((room - OVERFLOW_COLUMNS - 1) / slot)))
19  const hidden = ordered.slice(count)
20  return {
21    shown: ordered.slice(0, count),
22    overflow: {
23      total: hidden.length,
24      working: hidden.filter(m => m.status === 'working').length,
25      failed: hidden.filter(m => m.status === 'error').length,
26    },
27  }
28}
29
30export const isActive = (member: Member) => member.status === 'working'
31
32export const statusFor = (reason: string): MemberStatus => (reason === 'answer' ? 'done' : 'error')
33
hooks/logic/size.ts 10 lines
1import { HEIGHT, WIDTH } from '../art/pixels'
2import { cellSize } from '../render/render'
3
4export const SIZES: Record<string, number> = { small: 0.5, medium: 1, large: 2 }
5
6export const isSize = (value: string) => Object.keys(SIZES).includes(value)
7
8export const fitScale = (wanted: number, maxRows: number) =>
9  [2, 1, 0.5].filter(candidate => candidate <= wanted).find(candidate => cellSize(WIDTH, HEIGHT, candidate).rows <= maxRows) ?? 0.5
10
hooks/config.ts 14 lines
1export const WORK_TICK_MS = 300
2export const IDLE_TICK_MS = 900
3export const REST_MS = 5000
4export const CREW_REST_MS = 3000
5export const ALERT_MS = 3000
6export const MAX_TRACKED = 64
7export const SVG_SCALE = 6
8export const SIZE_KEY = 'size'
9export const ARGUMENT_HINT = '[small|medium|large|hide|show]'
10export const USAGE = `Usage: /pet ${ARGUMENT_HINT}. With no argument it hides or shows the pet.`
11export const CAPTION_COLUMNS = 24
12export const COLUMN_GAP = 2
13export const LABEL_COLUMNS = 10
14
hooks/view/band.tsx 88 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Member, Mood } from '../../types'
4import { MINI_HEIGHT, MINI_WIDTH } from '../art/mini/constants'
5import { drawMini } from '../art/mini/draw'
6import { colorFor, labelFor, noteFor } from '../art/mini/identity'
7import { draw } from '../art/pet/draw'
8import { HEIGHT, WIDTH } from '../art/pixels'
9import { CAPTION_COLUMNS, COLUMN_GAP, LABEL_COLUMNS, SVG_SCALE } from '../config'
10import { clip, hex } from '../format'
11import type { Activity } from '../logic/activity'
12import { caption } from '../logic/caption'
13import { fitCrew, isActive, OVERFLOW_COLUMNS } from '../logic/crew'
14import { fitScale, SIZES } from '../logic/size'
15import { cellSize, toCells, toSvg } from '../render/render'
16
17type Elements = ReturnType<EngineInterface['ui']['resolve']>
18
19export type BandInput = {
20  elements: Elements
21  surface: string
22  maxRows: number
23  bodyColumns: number
24  sizeName: string
25  current: Mood
26  activity: Activity
27  label: string | null
28  seconds: number
29  tick: number
30  now: number
31  members: readonly Member[]
32}
33
34export const renderBand = ({ elements, surface, maxRows, bodyColumns, sizeName, current, activity, label, seconds, tick, now, members }: BandInput) => {
35  const pixels = draw(current, tick, activity)
36  const { title, detail } = caption(current, activity, label, seconds, members.filter(isActive).length)
37  const scale = fitScale(SIZES[sizeName] ?? 1, maxRows)
38  const main = cellSize(WIDTH, HEIGHT, scale)
39  const mini = cellSize(MINI_WIDTH, MINI_HEIGHT, scale)
40  const { Box, Text } = elements
41  const art =
42    surface === 'terminal'
43      ? elements.Raster({ key: 'pet', columns: main.columns, rows: main.rows, cells: toCells(pixels, scale) })
44      : elements.Svg({ source: toSvg(pixels, SVG_SCALE * scale), alt: `Claude pet is ${current}` })
45
46  const room = bodyColumns - main.columns - CAPTION_COLUMNS - COLUMN_GAP * 2
47  const { shown, overflow } = fitCrew(members, room, Math.max(mini.columns, LABEL_COLUMNS) + 1)
48
49  const minis = shown.map((member, index) => {
50    const sprite = drawMini(member, tick + index, now)
51    const picture =
52      surface === 'terminal'
53        ? elements.Raster({ key: `crew-${member.id}`, columns: mini.columns, rows: mini.rows, cells: toCells(sprite, scale) })
54        : elements.Svg({ source: toSvg(sprite, SVG_SCALE * scale), alt: `${labelFor(member.kind)} subagent is ${member.status}` })
55    const note = noteFor(member, now)
56
57    return (
58      <Box key={member.id} flexDirection="column" width={Math.max(mini.columns, LABEL_COLUMNS)}>
59        {picture}
60        <Text bold color={hex(colorFor(member.kind))}>{clip(labelFor(member.kind), Math.max(mini.columns, LABEL_COLUMNS))}</Text>
61        <Text dimColor>{clip(note, Math.max(mini.columns, LABEL_COLUMNS))}</Text>
62      </Box>
63    )
64  })
65
66  return (
67    <Box flexDirection="row" gap={COLUMN_GAP}>
68      {art}
69      {(shown.length > 0 || overflow) && (
70        <Box flexDirection="row" gap={1} marginTop={Math.max(0, main.rows - mini.rows - 2)}>
71          {minis}
72          {overflow && (
73            <Box flexDirection="column" width={OVERFLOW_COLUMNS} height={mini.rows + 2} justifyContent="center">
74              <Text bold color="claude">+{overflow.total} more</Text>
75              <Text dimColor>{overflow.working} working</Text>
76              {overflow.failed > 0 && <Text dimColor>{overflow.failed} failed</Text>}
77            </Box>
78          )}
79        </Box>
80      )}
81      <Box flexDirection="column" marginTop={Math.max(0, Math.floor((main.rows - 2) / 2))} width={CAPTION_COLUMNS}>
82        <Text bold color="claude">{title}</Text>
83        <Text dimColor>{detail}</Text>
84      </Box>
85    </Box>
86  )
87}
88
hooks/art/pixels.ts 46 lines
1import type { Mood } from '../../types'
2
3export const WIDTH = 27
4export const HEIGHT = 14
5export const CLEAR = -1
6
7export type Pixels = number[][]
8
9export const C = {
10  body: 0xd97757,
11  light: 0xe99a7e,
12  shade: 0xb5573b,
13  eye: 0x1c1917,
14  screen: 0x0b1220,
15  glow: 0x142238,
16  bezel: 0x2d3340,
17  base: 0xaab2bf,
18  edge: 0xd2d8e0,
19  key: 0x6b7280,
20  green: 0x4ade80,
21  red: 0xf87171,
22  white: 0xe5e7eb,
23  slate: 0x7d8aa0,
24  blue: 0x60a5fa,
25  amber: 0xfbbf24,
26} as const
27
28export type Draw = (x: number, y: number, w: number, h: number, color: number) => void
29
30export type Point = readonly [number, number]
31
32export const canvas = (width = WIDTH, height = HEIGHT): Pixels =>
33  Array.from({ length: height }, () => Array<number>(width).fill(CLEAR))
34
35export const fill = (px: Pixels, x: number, y: number, w: number, h: number, color: number) => {
36  for (let j = y; j < y + h; j++) {
37    for (let i = x; i < x + w; i++) {
38      if (px[j]?.[i] !== undefined) px[j][i] = color
39    }
40  }
41}
42
43export const dots = (px: Pixels, color: number, points: readonly Point[]) => {
44  for (const [x, y] of points) fill(px, x, y, 1, 1, color)
45}
46
hooks/render/render.ts 80 lines
1import { C, CLEAR, type Pixels } from '../art/pixels'
2
3const DEFAULT_COLOR = 0x01000000
4const UPPER_HALF = 0x2580
5const LOWER_HALF = 0x2584
6const SPACE = 0x20
7const MIN_OPAQUE = 2
8
9export const columnsOf = (px: Pixels) => px[0].length
10export const rowsOf = (px: Pixels) => px.length
11
12export const cellSize = (width: number, height: number, scale: number) =>
13  scale < 1
14    ? { columns: Math.ceil(width / 2), rows: Math.ceil(Math.ceil(height / 2) / 2) }
15    : { columns: width * scale, rows: (height * scale) / 2 }
16
17const upscale = (px: Pixels, factor: number): Pixels =>
18  px.flatMap(line => {
19    const wide = line.flatMap(color => Array<number>(factor).fill(color))
20    return Array.from({ length: factor }, () => wide)
21  })
22
23const pack = (words: Uint32Array) => new Uint8Array(words.buffer).toBase64()
24
25const halfBlocks = (px: Pixels): string => {
26  const rows = rowsOf(px) / 2
27  const columns = columnsOf(px)
28  const words = new Uint32Array(columns * rows * 3)
29  let at = 0
30  for (let row = 0; row < rows; row++) {
31    for (let x = 0; x < columns; x++) {
32      const top = px[row * 2][x]
33      const bottom = px[row * 2 + 1][x]
34      const hasTop = top !== CLEAR
35      const hasBottom = bottom !== CLEAR
36      words[at++] = hasTop ? UPPER_HALF : hasBottom ? LOWER_HALF : SPACE
37      words[at++] = hasTop ? top : hasBottom ? bottom : DEFAULT_COLOR
38      words[at++] = hasTop && hasBottom ? bottom : DEFAULT_COLOR
39    }
40  }
41  return pack(words)
42}
43
44const downsample = (px: Pixels): Pixels => {
45  const rows = Math.ceil(rowsOf(px) / 2)
46  const columns = Math.ceil(columnsOf(px) / 2)
47  const small: Pixels = Array.from({ length: rows + (rows % 2) }, () => Array<number>(columns).fill(CLEAR))
48  for (let y = 0; y < rows; y++) {
49    for (let x = 0; x < columns; x++) {
50      const block = [px[y * 2]?.[x * 2], px[y * 2]?.[x * 2 + 1], px[y * 2 + 1]?.[x * 2], px[y * 2 + 1]?.[x * 2 + 1]]
51      const opaque = block.filter((c): c is number => c !== undefined && c !== CLEAR)
52      if (opaque.length < MIN_OPAQUE) continue
53      if (opaque.includes(C.eye)) {
54        small[y][x] = C.eye
55        continue
56      }
57      const counts = new Map<number, number>()
58      for (const color of opaque) counts.set(color, (counts.get(color) ?? 0) + 1)
59      small[y][x] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0]
60    }
61  }
62  return small
63}
64
65export const toCells = (px: Pixels, scale: number): string =>
66  scale < 1 ? halfBlocks(downsample(px)) : halfBlocks(upscale(px, scale))
67
68
69const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
70
71export const toSvg = (px: Pixels, scale: number): string => {
72  const rects: string[] = []
73  px.forEach((line, y) =>
74    line.forEach((color, x) => {
75      if (color !== CLEAR) rects.push(`<rect x="${x}" y="${y}" width="1" height="1" fill="${hex(color)}"/>`)
76    }),
77  )
78  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${columnsOf(px)} ${rowsOf(px)}" width="${columnsOf(px) * scale}" height="${rowsOf(px) * scale}" shape-rendering="crispEdges">${rects.join('')}</svg>`
79}
80
hooks/art/mini/constants.ts 20 lines
1import { C } from '../pixels'
2
3export const MINI_WIDTH = 18
4
5export const MINI_HEIGHT = 12
6
7export const ACCENTS = [0x4fb3a5, 0x9b7ede, 0xe07aa8, 0x8fbf4a, 0x5aa0e0] as const
8
9export const BEZEL = 0x2d3340
10
11export const WOOD_TOP = 0xc29a6b
12
13export const WOOD_SIDE = 0x8b6b4a
14
15export const LINE_LENGTHS = [4, 2, 6, 3, 5, 2, 6] as const
16
17export const LINE_INDENTS = [0, 1, 0, 2, 0, 1, 0] as const
18
19export const LINE_COLORS = [C.green, C.slate, C.amber, C.blue, C.slate] as const
20
hooks/art/mini/draw.ts 28 lines
1import { C, type Pixels, canvas, dots } from '../pixels'
2import { activityFor } from '../../logic/activity'
3import { drawPet } from './body'
4import { MINI_HEIGHT, MINI_WIDTH } from './constants'
5import { drawDesk, drawFrame } from './desk'
6import type { Mini } from './identity'
7import { drawProps } from './props'
8import { drawScreen } from './screen'
9
10export const drawMini = (member: Mini, frame: number, now: number): Pixels => {
11  const px = canvas(MINI_WIDTH, MINI_HEIGHT)
12  const activity = member.status === 'working' ? activityFor(member, now) : 'thinking'
13
14  drawFrame(px, member.kind)
15  drawPet(px, member, frame, activity)
16  drawDesk(px)
17
18  if (member.status === 'working') {
19    drawScreen(px, activity, frame)
20    drawProps(px, activity, frame)
21  } else if (member.status === 'done') {
22    dots(px, C.green, [[12, 4], [13, 5], [14, 4], [15, 3], [16, 2]])
23  } else {
24    dots(px, C.red, [[13, 2], [15, 2], [14, 3], [13, 4], [15, 4]])
25  }
26  return px
27}
28
hooks/art/mini/identity.ts 18 lines
1import type { Member } from '../../../types'
2import { activityFor, VERBS } from '../../logic/activity'
3import { ACCENTS } from './constants'
4
5const hash = (text: string) => [...text].reduce((sum, ch) => (sum * 31 + ch.charCodeAt(0)) >>> 0, 7)
6
7export const colorFor = (kind: string): number => ACCENTS[hash(kind) % ACCENTS.length]
8
9export const labelFor = (kind: string): string => kind.split(':').pop() ?? kind
10
11export type Mini = Pick<Member, 'status' | 'kind' | 'tool' | 'since' | 'alert'>
12
13export const noteFor = (member: Mini, now: number): string => {
14  if (member.status === 'done') return 'done'
15  if (member.status === 'error') return 'failed'
16  return VERBS[activityFor(member, now)]
17}
18
hooks/art/pet/draw.ts 26 lines
1import { C, type Draw, type Pixels, canvas, fill } from '../pixels'
2import type { Mood } from '../../../types'
3import type { Activity } from '../../logic/activity'
4import { drawArms, drawZ } from './body'
5import { drawEyes } from './eyes'
6import { drawProps } from './props'
7import { drawScreen } from './screen'
8
9export const draw = (mood: Mood, frame: number, activity: Activity): Pixels => {
10  const px = canvas()
11  const hop = mood === 'done' || (mood === 'working' && activity === 'alarmed') ? -(frame % 2) : 0
12  const at: Draw = (x, y, w, h, color) => fill(px, x, y + hop, w, h, color)
13
14  drawScreen(px, mood, activity, frame)
15  at(2, 3, 10, 8, C.body)
16  at(2, 3, 10, 1, C.light)
17  at(11, 4, 1, 7, C.shade)
18  at(2, 10, 10, 1, C.shade)
19  for (const x of [3, 5, 8, 10]) at(x, 11, 1, 2, C.shade)
20  drawArms(mood, activity, frame, at)
21  drawEyes(mood, activity, frame, at, px)
22  if (mood === 'working') drawProps(px, activity, frame)
23  if (mood === 'idle') drawZ(px, frame)
24  return px
25}
26