A pixel Claude critter above the prompt that reacts to typing, working, finishing and going AFK.

![]()
Claude Code now has mods: plugins made of TypeScript function hooks that can draw UI right inside the terminal, react to everything Claude does, and change how it works. This repo contains four mods I use every day. There's a pixel critter that lives above your prompt, a control panel for model, effort and parallel agents, a project launcher box, and a live status bar. It also has everything you need to build your own in about 10 minutes.
⚠️ Mods are early access. The function-hooks API can change between Claude Code releases. Everything here was built and tested on Claude Code 2.1.294.
Open Claude Code in a terminal and paste:
/plugin install pixel-buddy --marketplace Bocskay-Botond/claude-code-mods
Answer y to add the marketplace, press Enter for the user scope, and you're done. A little orange critter appears above your prompt. Start typing and watch it pull out a laptop. 🧡
Want the full setup? 👉 quick-start/
| Mod | What it does | Commands |
|---|---|---|
| 🐾 pixel-buddy | A 12×4 pixel Claude critter above the prompt. It types on a tiny laptop while you type, patrols while Claude works, celebrates when a turn finishes, plays (keepy-uppy, jump rope, butterflies, bubbles, reading) when you're idle, and falls asleep after 3 minutes. | /buddy /pet |
| 🛠️ tristens-tools | A tools menu and status line: model & effort picker, Clean View (hides tool noise and shows a simple checklist), Prompt Upgrade, Agent Dock (split each request across 1–100 helper agents and watch them live), and a Roadtrip racing minigame. | /tools /dock /roadtrip |
| 📁 project-box | A folder-tree card beside the status bar listing your projects by category. Click a category, then a project, to see its note and path. pixel-buddy walks over and "types the cd" for you. | /projects |
| 📊 statusbar | A minimal one-line status bar: working/idle, tool count, last tool, last turn duration, turns. The simplest real mod in the repo, so read this one first. | — |
| 👋 hello-mod | The tutorial example: a slash command, a toast and a band in about 35 lines. | /hello |
Install any of them the same way:
/plugin install <mod-name> --marketplace Bocskay-Botond/claude-code-mods
claude-code-mods/
├── .claude-plugin/
│ └── marketplace.json # makes this repo a plugin marketplace
├── mods/
│ ├── pixel-buddy/ # 🐾 the critter
│ ├── tristens-tools/ # 🛠️ tools menu, Agent Dock, Roadtrip
│ ├── project-box/ # 📁 project launcher card
│ └── statusbar/ # 📊 one-line status bar
├── examples/
│ └── hello-mod/ # 👋 your first mod, fully commented
├── quick-start/ # ⚡ 5-minute setup
├── docs/
│ ├── installation.md # every way to install & manage mods
│ ├── writing-your-own-mod.md
│ ├── api-cheatsheet.md # events, $ nouns, UI elements
│ └── troubleshooting.md
├── mod-builder-prompt/ # 🤖 a prompt that makes Claude build mods for you
└── assets/ # 🖼️ social preview (HTML source + PNG)
Every mod is the same four small files:
my-mod/
├── .claude-plugin/plugin.json # name, version, description
├── hooks/hooks.json # { "modules": ["./register.tsx"] }
├── hooks/register.tsx # the code
└── types/index.d.ts # the state your mod keeps (optional)
on(event, hook) / next(e) middleware chain worksclaude plugin test and ship it as a GitHub marketplaceclaude --version)No Node, no npm install, no build step. Claude Code compiles the TypeScript itself.
The fastest way is to ask Claude. Inside Claude Code:
Make me a mod that shows a 🍅 pomodoro timer above the prompt and plays a toast when it ends.
Claude Code has a built-in skill for this: it writes the mod, hot-reloads it into your session and tests it. For a better result, paste the prompt from mod-builder-prompt/ first.
Prefer to do it by hand? docs/writing-your-own-mod.md walks through hello-mod line by line, and docs/api-cheatsheet.md has the events and elements you'll use most.
project-box shows a demo list until you give it yours. Copy the example and edit it:
# macOS / Linux
cp mods/project-box/project-box.example.json ~/.claude/project-box.json
# Windows (PowerShell)
Copy-Item mods\project-box\project-box.example.json $HOME\.claude\project-box.json
Restart Claude Code (or run /reload-plugins) and your projects show up. Colors can be any terminal color name (magenta, cyan, green, yellow, blue, red). Set "live": true for a green dot.
| Problem | Fix |
|---|---|
| Nothing appears after install | Run /reload-plugins, or restart Claude Code. Make sure you're in a terminal: /plugin isn't available in the desktop app's Code tab. |
Marketplace file not found | Check the repo name: Bocskay-Botond/claude-code-mods |
| A mod silently does nothing | Start with claude --debug. A failing hook is skipped and logged with the reason. |
| Two bands fight for space | pixel-buddy, project-box and tristens-tools are designed to stack. Turn things off with /buddy, /projects, or /plugin → disable. |
More: docs/troubleshooting.md
Built a cool mod? PRs are welcome!
mods/<your-mod>/ (four files, see above)claude plugin validate mods/<your-mod> and claude plugin test mods/<your-mod> pass.claude-plugin/marketplace.json and to the table in this READMEMIT. Do whatever you want, just don't blame me if your critter falls asleep on the job. 😴
tristens-tools (Clean View, Agent Dock, Roadtrip and the tools menu). Go watch it!If this made your terminal a little more fun, ⭐ star the repo and share it with someone who lives in Claude Code.
Now go build a mod. Your terminal has been boring for long enough. 🚀
hooks/register.tsx 581 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4// A 12x4 pixel critter drawn with quadrant blocks (one cell = 2x2 pixels, so
5// a leg is one pixel: half a column), on a stage of `width` columns, 4 rows.
6const isShown = atom({ plugin: 'pixel-buddy', key: 'isShown' } as const, true)
7
8// The project box (project-box) beside the status bar: read (never written) so the
9// critter can walk up to it and react when it opens, picks or hides.
10const projCat = atom({ plugin: 'project-box', key: 'openCat' } as const, '')
11const projPick = atom({ plugin: 'project-box', key: 'openProject' } as const, '')
12const projHidden = atom({ plugin: 'project-box', key: 'isHidden' } as const, false)
13// its card widths, as project-box lays them out (closed / open cap)
14const PROJ_NARROW = 24
15const PROJ_WIDE = 46
16type ProjEvent = 'open' | 'close' | 'pick' | 'hide' | 'show'
17const PROJ_LEN: Record<ProjEvent, number> = { open: 3800, close: 2500, pick: 5500, hide: 2500, show: 3200 }
18
19const ORANGE = 0xd97757
20const EYE = 0x1c1c1c
21const GOLD = 0xf5c26b
22const SLEEPY = 0x8ea4d2
23const PINK = 0xe0607e
24const GREEN = 0x2fd27a
25const DIM = 0x8a8a8a
26const LID = 0x5d6169
27const KEYS = 0xa7adb5
28const SCREEN = 0x6fa8dc
29const SCREEN_LIT = 0xcfe8ff
30const BALL = 0xf2f2f2
31const ROPE = 0xd8d8d8
32const PAGE = 0xf4efe6
33const FOLD = 0xc9bfae
34const WING_A = 0xf5c26b
35const WING_B = 0xe0607e
36const BUBBLE = 0x9fd3ff
37const NONE = 0x01000000
38
39const ROWS = 4
40const PH = ROWS * 2
41const SPRITE_W = 12
42const SPRITE_H = 4
43const TICK = 100 // animation cadence (blinks, beats, walking speeds are per tick)
44const FRAME_MS = 40 // repaint rate: 25 fps
45const PLAY_MS = 20_000 // quiet this long: start playing
46const PLAY_LEN = 12_000 // each game lasts this long
47const SLEEP_MS = 180_000 // quiet this long: fall asleep
48const PLAYS = ['keepy', 'dance', 'rope', 'butterfly', 'read', 'bubbles', 'visit'] as const
49
50type Eyes = 'center' | 'left' | 'right' | 'closed'
51type Arms = 'down' | 'up' | 'waveL' | 'waveR' | 'tapL' | 'tapR'
52type Pose = { eyes: Eyes; arms: Arms; legs: 0 | 1; squash: boolean }
53type Label = { x: number; row: number; text: string; color: number }
54type Dot = { x: number; y: number; color: number }
55
56// ---- animation state (module-local: a reload just restarts the critter) ----
57const s = {
58 width: 32,
59 requestId: '',
60 x: 4,
61 dir: 1 as 1 | -1,
62 target: 4,
63 isWorking: false,
64 tools: 0,
65 toolName: '',
66 toolAt: 0,
67 lastKey: 0,
68 keys: 0,
69 lastActive: 0,
70 wokeAt: 0,
71 doneAt: 0,
72 petAt: 0,
73 submitAt: 0,
74 lastFrame: '',
75 typeStart: 0,
76 typed: [] as { ch: string; at: number; dx: number }[],
77 now: 0,
78 isProjSeen: false,
79 projCat: '',
80 projPick: '',
81 projHidden: false,
82 projEvent: 'open' as ProjEvent,
83 projArg: '',
84 projAt: -1e9,
85 projUntil: 0,
86 // the run to (or from) the project box: eased from `from` to `to`
87 trip: { at: -1, from: 0, to: 0, start: 0, dur: 0 },
88 prevNow: 0,
89 seed: 0,
90 helloAt: 0,
91}
92
93// ---- pixels ----
94const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
95const toBase64 = (bytes: Uint8Array) => {
96 let out = ''
97 for (let i = 0; i < bytes.length; i += 3) {
98 const a = bytes[i] ?? 0, b = bytes[i + 1], c = bytes[i + 2]
99 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
100 out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
101 out += b === undefined ? '=' : B64.charAt((n >> 6) & 63)
102 out += c === undefined ? '=' : B64.charAt(n & 63)
103 }
104 return out
105}
106
107// quadrant glyph by mask: top-left 1, top-right 2, bottom-left 4, bottom-right 8
108const QUAD = [0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588]
109
110const putter = (px: Uint32Array, pw: number, ox: number, oy: number) => (x: number, y: number, color: number) => {
111 const X = ox + x, Y = oy + y
112 if (X >= 0 && X < pw && Y >= 0 && Y < PH) px[Y * pw + X] = color
113}
114
115// Laptop in side view to the critter's right: the keyboard deck on the ground,
116// the lid hinged at its far end, `lid` pixels raised (0 = shut, 3 = open and
117// tilted back), its screen glowing toward the critter.
118const drawLaptop = (px: Uint32Array, pw: number, ox: number, oy: number, lid: number, isLit: boolean, isTapDown: boolean) => {
119 const put = putter(px, pw, ox, oy)
120 for (let x = 12; x <= 17; x++) put(x, 3, KEYS)
121 for (let y = 4 - Math.min(lid, 3); y <= 3; y++) put(18, y, LID)
122 if (lid >= 3) {
123 put(19, 0, LID)
124 put(17, 0, isLit ? SCREEN_LIT : SCREEN)
125 put(17, 1, isLit ? SCREEN_LIT : SCREEN)
126 }
127 if (isTapDown) put(12, 2, ORANGE)
128}
129
130const drawCritter = (px: Uint32Array, pw: number, ox: number, oy: number, p: Pose) => {
131 const put = putter(px, pw, ox, oy)
132 const top = p.squash ? 1 : 0
133 for (let y = top; y <= 2; y++) for (let x = 2; x <= 9; x++) put(x, y, ORANGE)
134 const [left, right] = {
135 down: [1, 1], up: [0, 0], waveL: [0, 1], waveR: [1, 0], tapL: [1, 2], tapR: [1, 1],
136 }[p.arms] as [number, number]
137 // a raised arm points up and out from the shoulder, its hand above the head,
138 // so it never lines up with the top of the head
139 const arm = (inner: number, outer: number, y: number) => {
140 if (y === 0) {
141 put(inner, top, ORANGE)
142 put(outer, top - 1, ORANGE)
143 } else {
144 const ay = Math.min(2, y + top)
145 put(inner, ay, ORANGE)
146 put(outer, ay, ORANGE)
147 }
148 }
149 arm(1, 0, left)
150 arm(10, 11, right)
151 if (p.eyes !== 'closed') {
152 const [l, r] = p.eyes === 'left' ? [2, 7] : p.eyes === 'right' ? [4, 9] : [3, 8]
153 put(l, 1, EYE)
154 put(r, 1, EYE)
155 }
156 for (const lx of p.legs === 0 ? [2, 4, 7, 9] : [3, 5, 6, 8]) put(lx, 3, ORANGE)
157}
158
159const render = (w: number, px: Uint32Array, labels: Label[]) => {
160 const pw = w * 2
161 const words = new Uint32Array(w * ROWS * 3)
162 for (let r = 0; r < ROWS; r++) {
163 for (let cx = 0; cx < w; cx++) {
164 const quad = [
165 px[2 * r * pw + 2 * cx] ?? 0, px[2 * r * pw + 2 * cx + 1] ?? 0,
166 px[(2 * r + 1) * pw + 2 * cx] ?? 0, px[(2 * r + 1) * pw + 2 * cx + 1] ?? 0,
167 ]
168 const counts = new Map<number, number>()
169 for (const c of quad) if (c !== 0) counts.set(c, (counts.get(c) ?? 0) + 1)
170 const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c)
171 const fg = ranked[0]
172 const i = (r * w + cx) * 3
173 if (fg === undefined) {
174 words.set([0x20, NONE, NONE], i)
175 continue
176 }
177 // a cell holds two colors: the commonest is drawn, the next fills the rest
178 const mask = quad.reduce((m, c, k) => (c === fg ? m | (1 << k) : m), 0)
179 words.set([QUAD[mask] ?? 0x2588, fg, ranked[1] ?? NONE], i)
180 }
181 }
182 for (const l of labels) {
183 for (let k = 0; k < l.text.length; k++) {
184 const x = l.x + k
185 if (x < 0 || x >= w || l.row < 0 || l.row >= ROWS) continue
186 const i = (l.row * w + x) * 3
187 if (words[i] !== 0x20) continue
188 words.set([l.text.charCodeAt(k), l.color, NONE], i)
189 }
190 }
191 return toBase64(new Uint8Array(words.buffer))
192}
193
194// ---- behaviour ----
195const modeAt = (now: number) => {
196 if (now - s.doneAt < 2500) return 'done' as const
197 if (s.isWorking) return s.tools === 0 ? ('think' as const) : ('work' as const)
198 if (now - s.lastKey < 1900) return 'type' as const
199 if (s.trip.at !== s.projAt ? now - s.projAt < 5000 : now < s.projUntil) return 'proj' as const
200 if (now - s.lastActive > SLEEP_MS) return 'sleep' as const
201 if (now - s.lastActive > PLAY_MS) return 'play' as const
202 return 'idle' as const
203}
204
205const frame = (now: number) => {
206 const w = s.width
207 const pw = w * 2
208 const px = new Uint32Array(pw * PH)
209 const labels: Label[] = []
210 const mode = modeAt(now)
211 const tick = Math.floor(now / TICK)
212 const maxX = Math.max(0, pw - SPRITE_W - 8)
213 const pose: Pose = { eyes: 'center', arms: 'down', legs: 0, squash: false }
214 let lift = 0
215 let laptop = -1
216 let isLit = false
217 let isTapDown = false
218 let shift = 0
219 const dots: Dot[] = []
220 const ox = Math.round(s.x)
221 const cx = () => Math.round(s.x / 2)
222 const right = () => cx() + SPRITE_W / 2
223
224 // speeds are per tick; frames come every FRAME_MS, so move by elapsed time
225 const step = Math.min(3, Math.max(0, now - s.prevNow) / TICK)
226 s.prevNow = Math.max(s.prevNow, now)
227 const dash = (min: number, ticks: number) => Math.min(4, Math.max(min, Math.abs(s.target - s.x) / ticks))
228 const walk = (speed: number) => {
229 if (Math.abs(s.target - s.x) <= speed * step) {
230 s.x = s.target
231 return false
232 }
233 s.dir = s.target > s.x ? 1 : -1
234 s.x += s.dir * speed * step
235 pose.legs = tick % 2 === 0 ? 0 : 1
236 pose.eyes = s.dir > 0 ? 'right' : 'left'
237 return true
238 }
239
240 if (mode === 'sleep') {
241 pose.squash = true
242 pose.eyes = 'closed'
243 const phase = Math.floor(now / 600) % 4
244 if (phase >= 1) labels.push({ x: right(), row: 2, text: 'z', color: SLEEPY })
245 if (phase >= 2) labels.push({ x: right() + 1, row: 1, text: 'z', color: SLEEPY })
246 if (phase >= 3) labels.push({ x: right() + 2, row: 0, text: 'Z', color: SLEEPY })
247 } else if (mode === 'type') {
248 // pull the laptop out, lift the lid, tap away; shut it after a pause
249 const opening = Math.min(1, (now - s.typeStart) / 450)
250 const closing = Math.max(0, Math.min(1, (now - s.lastKey - 1500) / 400))
251 laptop = Math.round(3 * opening * (1 - closing))
252 const isTyping = closing === 0 && opening === 1
253 pose.eyes = 'right'
254 pose.arms = isTyping ? (s.keys % 2 === 0 ? 'tapL' : 'tapR') : 'down'
255 isTapDown = isTyping && s.keys % 2 === 0
256 isLit = now - s.lastKey < 120
257 for (const t of s.typed) {
258 const age = now - t.at
259 if (age > 900) continue
260 labels.push({ x: cx() + 6 + (t.dx % 3), row: age < 450 ? 1 : 0, text: t.ch, color: age < 450 ? GOLD : DIM })
261 }
262 } else if (mode === 'think') {
263 pose.arms = Math.floor(now / 700) % 2 === 0 ? 'down' : 'waveR'
264 pose.eyes = Math.floor(now / 1400) % 2 === 0 ? 'right' : 'center'
265 const dots = '.'.repeat(1 + (Math.floor(now / 400) % 3))
266 labels.push({ x: right(), row: 1, text: dots, color: DIM })
267 labels.push({ x: right() - 1, row: 0, text: '?', color: GOLD })
268 } else if (mode === 'work') {
269 const patrol = Math.min(maxX, 56)
270 if (s.target === s.x || s.target > patrol) s.target = s.x < patrol / 2 ? patrol : 0
271 walk(1)
272 pose.arms = tick % 4 < 2 ? 'waveL' : 'waveR'
273 lift = tick % 4 === 0 ? 1 : 0
274 const back = s.dir > 0 ? cx() - 1 : right()
275 if (tick % 3 === 0) labels.push({ x: back, row: 3, text: '✦', color: GOLD })
276 else if (tick % 3 === 1) labels.push({ x: back, row: 3, text: '·', color: DIM })
277 if (now - s.toolAt < 1500 && s.toolName !== '') {
278 // top row: raised hands and hops reach row 1 and would cut the word
279 labels.push({ x: Math.max(0, Math.min(w - s.toolName.length, cx())), row: 0, text: s.toolName, color: GOLD })
280 }
281 } else if (mode === 'done') {
282 const t = (now - s.doneAt) / 1000
283 lift = Math.max(0, Math.round(Math.sin(t * Math.PI * 2.4) * 3))
284 pose.arms = 'up'
285 labels.push({ x: right(), row: 1, text: '✓', color: GREEN })
286 if (tick % 4 < 2) labels.push({ x: cx() - 1, row: 1, text: '✦', color: GOLD })
287 else labels.push({ x: right() + 1, row: 0, text: '✦', color: GOLD })
288 } else if (mode === 'play') {
289 const k = now - s.lastActive - PLAY_MS
290 const picked = PLAYS[(s.seed + Math.floor(k / PLAY_LEN)) % PLAYS.length] ?? 'dance'
291 const play = picked === 'visit' && s.projHidden ? 'dance' : picked
292 const t = k % PLAY_LEN
293 if (play === 'keepy') {
294 // keepy-uppy off the head, counting, until the ball gets away
295 const n = Math.floor(t / 900)
296 if (t < 9900) {
297 const ph = (t % 900) / 900
298 lift = ph < 0.12 ? 1 : 0
299 pose.arms = n % 2 === 0 ? 'waveL' : 'waveR'
300 const h = Math.round(12 * ph * (1 - ph))
301 dots.push({ x: ox + 5 + (n % 2), y: Math.max(0, PH - SPRITE_H - 1 - lift - h), color: BALL })
302 if (n >= 2) labels.push({ x: right() + 1, row: 0, text: String(n), color: DIM })
303 } else {
304 const d = Math.floor((t - 9900) / 120)
305 dots.push({ x: ox + 12 + d, y: PH - 1, color: BALL })
306 pose.eyes = 'right'
307 pose.arms = 'up'
308 if (t - 9900 < 900) labels.push({ x: right(), row: 1, text: '!', color: GOLD })
309 }
310 } else if (play === 'dance') {
311 const beat = Math.floor(t / 300)
312 shift = [0, 1, 0, -1][beat % 4] ?? 0
313 pose.arms = beat % 2 === 0 ? 'waveL' : 'waveR'
314 pose.legs = beat % 2 === 0 ? 0 : 1
315 pose.eyes = beat % 4 < 2 ? 'left' : 'right'
316 lift = beat % 2 === 0 ? 1 : 0
317 labels.push(beat % 4 < 2
318 ? { x: cx() - 1, row: beat % 2, text: '♪', color: GOLD }
319 : { x: right() + 1, row: beat % 2, text: '♫', color: GOLD })
320 } else if (play === 'rope') {
321 // the rope swings over the head, then under the feet as it hops
322 const ph = (t % 800) / 800
323 lift = ph > 0.55 && ph < 0.95 ? 2 : 0
324 const oy = PH - SPRITE_H - lift
325 for (let dx = -1; dx <= 12; dx++) {
326 if (ph < 0.5) {
327 const u = (dx - 5.5) / 6.5
328 dots.push({ x: ox + dx, y: Math.max(0, oy + 1 - Math.round(3 * (1 - u * u))), color: ROPE })
329 } else {
330 dots.push({ x: ox + dx, y: PH - 1, color: ROPE })
331 }
332 }
333 if (t > 2400) labels.push({ x: right() + 1, row: 0, text: String(Math.floor(t / 800)), color: DIM })
334 } else if (play === 'butterfly') {
335 // a butterfly flits about; chase it and leap when it comes close
336 const tt = t / 1000
337 const bx = Math.round(Math.max(1, Math.min(pw - 2, pw / 2 + (pw / 2 - 4) * Math.sin(tt * 0.7))))
338 const by = 1 + Math.round(1.2 * Math.sin(tt * 3.1))
339 dots.push({ x: bx, y: by, color: tick % 2 === 0 ? WING_A : WING_B })
340 s.target = Math.max(0, Math.min(maxX, bx - 6))
341 walk(dash(0.6, 15))
342 pose.eyes = bx > ox + 6 ? 'right' : 'left'
343 if (Math.abs(bx - (ox + 6)) < 3 && t % 1600 < 400) {
344 lift = 2
345 pose.arms = 'up'
346 }
347 } else if (play === 'read') {
348 // nose in a book, peeking over it now and then, turning pages
349 // an open book held up: page corners raised, the fold shaded
350 const oy = PH - SPRITE_H
351 for (let x = 2; x <= 9; x++) dots.push({ x: ox + x, y: oy + 1, color: x >= 4 && x <= 7 ? FOLD : PAGE })
352 dots.push({ x: ox + 2, y: oy, color: PAGE }, { x: ox + 9, y: oy, color: PAGE })
353 pose.eyes = 'closed'
354 if (t % 3000 < 700) dots.push({ x: ox + 3, y: oy, color: EYE }, { x: ox + 8, y: oy, color: EYE })
355 if (t % 2500 > 2200) dots.push({ x: ox + 8, y: Math.max(0, oy - 1), color: PAGE })
356 } else if (play === 'visit') {
357 // stroll over to the project box, knock, then lean in and peek
358 s.target = maxX
359 const moving = walk(dash(0.5, 25))
360 if (!moving) {
361 pose.eyes = 'right'
362 if (t % 4000 < 1600) {
363 pose.arms = t % 400 < 200 ? 'waveR' : 'down'
364 labels.push({ x: right(), row: t % 800 < 400 ? 1 : 2, text: 'tok', color: DIM })
365 } else {
366 shift = 1
367 if (t % 4000 > 3400) labels.push({ x: right(), row: 1, text: '?', color: GOLD })
368 }
369 }
370 } else {
371 // bubbles: blow a few and watch them float away
372 pose.arms = 'waveR'
373 pose.eyes = 'right'
374 const glyphs = ['°', 'o', 'O']
375 for (let i = 0; i < 3; i++) {
376 const phase = (t + i * 700) % 2100
377 const step = Math.floor(phase / 700)
378 labels.push({ x: right() + i + step, row: 2 - step, text: glyphs[step] ?? 'o', color: BUBBLE })
379 }
380 }
381 } else if (mode === 'proj') {
382 const ev = s.projEvent
383 if (s.trip.at !== s.projAt) {
384 // plan the run once per event: ~110 px/s, never longer than 2.4 s
385 const to = ev === 'open' || ev === 'pick' || ev === 'show' ? maxX : ev === 'close' ? Math.round(maxX / 2) : s.x
386 const start = s.projAt + (ev === 'close' ? 900 : 0)
387 const dur = Math.min(2400, Math.abs(to - s.x) * 9)
388 s.trip = { at: s.projAt, from: s.x, to, start, dur }
389 s.projUntil = start + dur + PROJ_LEN[ev]
390 }
391 const tr = s.trip
392 const k = tr.dur === 0 ? 1 : Math.min(1, Math.max(0, (now - tr.start) / tr.dur))
393 const moving = now >= tr.start && k < 1
394 if (now >= tr.start) s.x = tr.from + (tr.to - tr.from) * (0.5 - Math.cos(Math.PI * k) / 2)
395 s.target = s.x
396 const t = now - tr.start - tr.dur // since arrival; negative on the way
397 if (moving) {
398 // eased run: quick feet, a little hop, dust behind
399 s.dir = tr.to > tr.from ? 1 : -1
400 pose.eyes = s.dir > 0 ? 'right' : 'left'
401 pose.legs = Math.floor(now / 70) % 2 === 0 ? 0 : 1
402 pose.arms = Math.floor(now / 140) % 2 === 0 ? 'waveL' : 'waveR'
403 lift = Math.floor(now / 140) % 2
404 if (now - s.projAt < 500) labels.push({ x: right(), row: 1, text: '!', color: GOLD })
405 if (Math.floor(now / 120) % 2 === 0) labels.push({ x: s.dir > 0 ? cx() - 1 : right(), row: 3, text: '·', color: DIM })
406 } else if (ev === 'open') {
407 // point at the folder that just opened
408 pose.eyes = 'right'
409 pose.arms = t % 600 < 300 ? 'waveR' : 'down'
410 labels.push({ x: right() + (t % 600 < 300 ? 1 : 0), row: 2, text: '→', color: GOLD })
411 } else if (ev === 'pick') {
412 // open the project: laptop out, type the cd
413 laptop = 3
414 pose.eyes = 'right'
415 pose.arms = tick % 2 === 0 ? 'tapL' : 'tapR'
416 isTapDown = tick % 2 === 0
417 isLit = tick % 3 === 0
418 const cmd = `cd ${s.projArg}`.slice(0, Math.max(3, Math.floor(t / 120)))
419 labels.push({ x: Math.max(0, cx() - 2), row: 0, text: cmd, color: GOLD })
420 } else if (ev === 'close') {
421 pose.arms = 'up'
422 pose.eyes = 'left'
423 labels.push({ x: right(), row: 1, text: 'ok', color: DIM })
424 } else if (ev === 'hide') {
425 pose.eyes = 'right'
426 pose.arms = tick % 4 < 2 ? 'waveR' : 'down'
427 labels.push({ x: right(), row: 1, text: 'bye!', color: GOLD })
428 } else {
429 pose.eyes = 'right'
430 pose.arms = tick % 4 < 2 ? 'waveR' : 'up'
431 lift = t % 600 < 150 ? 1 : 0
432 labels.push({ x: right(), row: 1, text: 'hi!', color: GOLD })
433 }
434 } else {
435 // idle: wander now and then, look around, blink
436 const moving = walk(dash(0.5, 30))
437 if (!moving) {
438 const look = Math.floor(now / 2300) % 5
439 pose.eyes = look === 1 ? 'left' : look === 3 ? 'right' : 'center'
440 if (tick % 70 === 0 && (tick / 70) % 3 === 1) s.target = Math.round(Math.random() * maxX)
441 }
442 if (tick % 45 === 0 || tick % 45 === 2) pose.eyes = 'closed'
443 }
444
445 if (now - s.helloAt < 2500) {
446 pose.arms = tick % 4 < 2 ? 'waveL' : 'waveR'
447 labels.push({ x: right(), row: 1, text: 'hi!', color: GOLD })
448 }
449 if (now - s.wokeAt < 900) {
450 labels.push({ x: right(), row: 1, text: '!', color: GOLD })
451 lift = 1
452 pose.eyes = 'center'
453 pose.squash = false
454 }
455 if (now - s.petAt < 2200) {
456 const k = Math.floor((now - s.petAt) / 300)
457 labels.push({ x: cx() + 1 + (k % 3), row: Math.max(0, 1 - Math.floor(k / 3)), text: '♥', color: PINK })
458 labels.push({ x: cx() + 4 - (k % 2), row: Math.max(0, 1 - Math.floor(k / 2)), text: '♥', color: PINK })
459 pose.eyes = 'closed'
460 pose.arms = 'up'
461 }
462
463 s.x = Math.max(0, Math.min(maxX, s.x))
464 drawCritter(px, pw, Math.round(s.x) + shift, PH - SPRITE_H - lift, pose)
465 for (const d of dots) if (d.x >= 0 && d.x < pw && d.y >= 0 && d.y < PH) px[d.y * pw + d.x] = d.color
466 if (laptop >= 0) drawLaptop(px, pw, Math.round(s.x), PH - SPRITE_H, laptop, isLit, isTapDown)
467 return { cells: render(w, px, labels), mode }
468}
469
470// Synchronous on purpose: prompt.edit runs on every key within a 50 ms budget,
471// so activity is stamped with the tick's clock (at most one tick old).
472const touch = () => {
473 const now = s.now
474 if (now - s.lastActive > PLAY_MS) s.wokeAt = now
475 s.lastActive = now
476 s.seed = Math.floor(Math.random() * PLAYS.length)
477 return now
478}
479
480export const register: Register = on => {
481 on('session.start', async ($, e, next) => {
482 await $.command.register({ name: 'buddy', description: 'Show or hide the Claude buddy above the prompt' })
483 await $.command.register({ name: 'pet', description: 'Pet the Claude buddy' })
484 s.now = await $.clock.now()
485 s.lastActive = s.now
486 s.helloAt = s.now
487 s.seed = Math.floor(Math.random() * PLAYS.length)
488
489 $.clock.every(FRAME_MS, async () => {
490 s.now = await $.clock.now()
491 if (s.requestId === '' || !(await read($, isShown))) return
492 const { cells } = frame(s.now)
493 if (cells === s.lastFrame) return
494 s.lastFrame = cells
495 await $.ui.blit({ requestId: s.requestId, key: 'buddy', cells, columns: s.width, rows: ROWS })
496 })
497 return next(e)
498 })
499
500 on('command.run', { command: 'buddy' }, async $ => {
501 const shown = await update($, isShown, v => !v)
502 return { text: shown ? 'Claude buddy is back.' : 'Claude buddy hidden. /buddy brings it back.' }
503 })
504 on('command.run', { command: 'pet' }, async $ => {
505 s.petAt = touch()
506 return { text: '♥' }
507 })
508
509 on('prompt.edit', async ($, e, next) => {
510 const prev = s.lastKey
511 const now = touch()
512 if (now - prev > 1900) s.typeStart = now
513 s.lastKey = now
514 s.keys += 1
515 const ch = e.inputText.slice(-1)
516 if (ch >= '!' && ch <= '~') {
517 s.typed = [...s.typed.filter(t => now - t.at < 900), { ch, at: now, dx: (s.keys * 5) % 6 }].slice(-4)
518 }
519 return next(e)
520 })
521 on('prompt.submit', async ($, e, next) => {
522 s.submitAt = touch()
523 s.isWorking = true
524 s.tools = 0
525 return next(e)
526 })
527 on('tool.call', async ($, e, next) => {
528 s.toolAt = touch()
529 s.tools += 1
530 s.toolName = e.tool.replace(/^mcp__[^_]+(_[^_]+)*__/, '').slice(0, 10)
531 return next(e)
532 })
533 on('turn.complete', async ($, e, next) => {
534 s.doneAt = touch()
535 s.isWorking = false
536 return next(e)
537 })
538
539 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
540 if (e.surface !== 'terminal' || e.props.hasSurvey || !(await read($, isShown))) return next(e)
541 const below = await next(e)
542 const { Box, Raster } = $.ui.resolve(e)
543 s.requestId = e.requestId
544 // follow the project box: react to what changed since the last draw, and
545 // stretch the stage up to the box's left edge so the critter can reach it
546 const [cat, pick, hidden] = await Promise.all([read($, projCat), read($, projPick), read($, projHidden)])
547 if (s.isProjSeen) {
548 const happen = (ev: ProjEvent, arg = '') => {
549 s.projEvent = ev
550 s.projArg = arg
551 s.projAt = s.now
552 }
553 if (hidden !== s.projHidden) happen(hidden ? 'hide' : 'show')
554 else if (pick !== s.projPick && pick !== '') happen('pick', pick)
555 else if (cat !== s.projCat) happen(cat === '' ? 'close' : 'open', cat)
556 }
557 Object.assign(s, { isProjSeen: true, projCat: cat, projPick: pick, projHidden: hidden })
558 const cols = e.props.bodyColumns
559 const box = hidden ? 0 : (cat === '' ? PROJ_NARROW : Math.min(PROJ_WIDE, Math.max(PROJ_NARROW, cols - 40))) + 2
560 s.width = Math.max(SPRITE_W + 8, cols - box - 1)
561 s.isWorking = s.isWorking || e.props.isWorking
562 const { cells } = frame(s.now)
563 s.lastFrame = cells
564 // The critter floats bottom-left, just above the band's last row (the
565 // status line), so a tall neighbour (a box beside the status bar) never
566 // pushes it up and away, whichever plugin wraps whom. An empty strut keeps
567 // the band tall enough for it; the engine's rows sit bottom-aligned beside.
568 return (
569 <Box flexDirection="row" alignItems="flex-end">
570 <Box width={0} height={ROWS + 1} />
571 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
572 {below}
573 </Box>
574 <Box position="absolute" left={0} bottom={1}>
575 <Raster key="buddy" columns={s.width} rows={ROWS} cells={cells} />
576 </Box>
577 </Box>
578 )
579 })
580}
581types/index.d.ts 16 lines1export type Shown = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'pixel-buddy': {
6 isShown: Shown
7 }
8 // read only: the project box the critter walks up to (project-box)
9 'project-box': {
10 isHidden: boolean
11 openCat: string
12 openProject: string
13 }
14 }
15}
16