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

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.
The pet with a crew of four subagents (reading, planning, taking a coffee break, and reacting to an error):

The main pet in each of its personalities and states:

A subagent in each of its looks:

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.
| State | When | What you see |
|---|---|---|
| Working | A turn is running | The pet acts out what it is doing, see Personalities below |
| Done | A turn ended with an answer | Arms up, happy eyes, a check mark on the screen |
| Error | A turn ended with an error or refusal | Crossed eyes, a cross on the screen |
| Idle | Otherwise, and 5 seconds after Done | The pet naps beside a dim laptop |
An interrupted turn goes straight back to Idle.
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.
| Personality | When | What you see |
|---|---|---|
| thinking | Between tool calls | Eyes look up, a thought cloud with cycling dots above the head, a blinking cursor |
| coding | Edit, Write, MultiEdit, NotebookEdit | Headphones, hands typing, colored code scrolling |
| reading | Read, Grep, Glob, LS | Wide scanning eyes, holding an open book up to read, a highlight moving over text |
| running | Bash | Squinting at a green terminal, a blinking status light |
| browsing | WebFetch, WebSearch | A web page on screen, a wifi blip above the laptop |
| testing | Playwright and Chrome DevTools MCP tools | A bubbling lab tube, a test checklist ticking and a progress bar |
| planning | TodoWrite, plan mode tools | Eyes up and to the side, a checklist being ticked off |
| delegating | Task, Agent, SendMessage | A blinking antenna, a tree of boxes passing work down |
| asking | AskUserQuestion | A raised hand, a blinking question mark, a faint one floating off the head |
| working | Any other tool, including MCP tools | Blinking indicator lights on screen and a spark |
| coffee | Quiet for 6 seconds, or a Bash command running over 12 seconds | Sips a mug of coffee with translucent steam wisps while it waits |
| sleepy | Quiet for 25 seconds | Heavy eyelids, a dim screen and floating Zzz that grow |
| error found | A tool call returned an error, for 3 seconds | Arms up and a hop, a sweat drop, red ! marks over the head, a flashing red warning on screen |
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:
+6 more, 2 working, 1 failed.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.
The size option scales the whole pet, including the subagent crew:
| Value | Scale | Main pet (columns x rows) |
|---|---|---|
| small | half | 14 x 4 |
| medium | 1x | 27 x 7 (default) |
| large | 2x | 54 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.
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
claude plugin uninstall tiny-pet@claude-pet
claude plugin marketplace remove claude-pet
Or inside a session: run /plugin, select tiny-pet, then Uninstall.
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
/plugin and confirm tiny-pet is enabled, then /reload-plugins. Run /pet in case it was hidden.WORK_TICK_MS and IDLE_TICK_MS in hooks/config.ts.claude --debug.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.See LICENSE.
hooks/register.tsx 205 lines1import { 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}
205hooks/logic/activity.ts 67 lines1export 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}
67hooks/logic/crew.ts 33 lines1import 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')
33hooks/logic/size.ts 10 lines1import { 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
10hooks/config.ts 14 lines1export 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
14hooks/view/band.tsx 88 lines1import 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}
88hooks/art/pixels.ts 46 lines1import 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}
46hooks/render/render.ts 80 lines1import { 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}
80hooks/art/mini/constants.ts 20 lines1import { 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
20hooks/art/mini/draw.ts 28 lines1import { 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}
28hooks/art/mini/identity.ts 18 lines1import 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}
18hooks/art/pet/draw.ts 26 lines1import { 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