Hand-drawn pixel-art cats and dogs wander above the prompt: they stroll, sit, nap, zoom, share hearts and chase each other. Choose how many, and cats, dogs or…

![]()
Hand-drawn pixel-art cats and dogs wander in a lane above your Claude Code prompt while you work. They stroll, sit down, nap, zoom about, fall in love and chase each other, and now and then one wanders off the edge while a new one wanders in. You choose how many there are and whether you want cats, dogs or both.
At the prompt of a Claude Code terminal session:
/plugin install pixel-parade --marketplace VkaZas/claude-code-mods
Answer y to add the marketplace, then pick a scope (user is the first). From your shell instead (Claude Code 2.1.292 or later):
claude plugin install pixel-parade --marketplace VkaZas/claude-code-mods
zZz; dogs break into a zoom and kick up dust.!, and sometimes gives chase.Set the defaults in /config (or when you install):
| Option | Values | Default |
|---|---|---|
| Count | 1–6 or 8 | 3 |
| Animals | both, cats, dogs | both |
| Style | pixel (the art), ascii (=^.^= and U^ᴥ^U) | pixel |
| Language | en, zh | en |
And change them on the fly; these overrides are remembered across sessions:
| Command | Does |
|---|---|
/parade | Show or hide the parade |
/parade 5 | Set how many (1–12) |
/parade cats · dogs · both | Cats only, dogs only, or both; the ones you no longer want walk off |
/parade pixel · ascii | Switch the style |
/parade status | Who is out and what each one is doing |
/parade reset | Back to the /config defaults |
session.start: registers the /parade command, restores your saved overrides and starts a clock that moves the animals every 0.2 seconds. The session start itself passes through unchanged.command.run, for /parade only: reads the arguments and answers with one line of text. No other command reaches it.ui.render on AbovePrompt: draws the lane, with whatever is drawn beneath it passed through unchanged below./parade overrides, in the plugin's own store.在 Claude Code 提示框上方,一群手绘像素小猫小狗走来走去:散步、坐下甩尾/摇尾巴、猫趴着打盹冒 zZz、狗疯跑扬灰;同类面对面会坐下冒爱心,狗朝猫走过去猫会冒 ! 逃跑。猫 6 种(橘猫、奶牛猫、黑猫、布偶、狸花、三花),狗 6 种(柴犬、金毛、边牧、萨摩耶、柯基、哈士奇)。
在 /config 里把 Language 设成 zh 就是中文提示。命令:/parade 显示或隐藏,/parade 5 设数量,/parade cats|dogs|both(也可以用 猫、狗、都要),/parade pixel|ascii,/parade status 看看都有谁,/parade reset 恢复默认。
hooks/register.tsx 627 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ParadeCritter, ParadeFilter, ParadeKind, ParadeSettings, ParadeStyle, ParadeWorld } from '../types'
5
6const TICK_MS = 200
7
8const EMPTY: ParadeWorld = { frame: 0, width: 60, critters: [], nextId: 1 }
9const NO_OVERRIDES: ParadeSettings = { hidden: false, count: null, kind: null, style: null }
10
11const world = atom({ plugin: 'pixel-parade', key: 'world' } as const, EMPTY)
12const settings = atom({ plugin: 'pixel-parade', key: 'settings' } as const, NO_OVERRIDES)
13
14// ---------- pixel art ----------
15//
16// Each animal is 12 by 8 pixels, facing right (mirrored to face left), and
17// two pixels share a terminal cell (▀ in the top's color over the bottom's),
18// so it stands four rows tall. The letters are channels a look colors in:
19// B body, S stripes or patches, W chest and paws, A ears, D tail, M muzzle,
20// E eye, C closed eye, N nose.
21
22const PIXEL_W = 12
23const PIXEL_H = 8
24
25const CAT_POSES = {
26 walk: [
27 ['.........A.A', '........BBBB', 'D.......BBEB', 'D......BBBBN', 'D..BSBSBBBW.', '.DBBSBSBBBW.', '..BBBBBBBB..', '..W.W...W.W.'],
28 ['.........A.A', '........BBBB', 'D.......BBEB', 'D......BBBBN', 'D..BSBSBBBW.', '.DBBSBSBBBW.', '..BBBBBBBB..', '...WW...WW..'],
29 ],
30 sit: [
31 ['.........A.A', '........BBBB', '........BBEB', '.......BBBBN', '......BSBBW.', '......BSBBW.', '.D...BBSBBW.', '..DDDBBBWBW.'],
32 ['.........A.A', '........BBBB', '........BBEB', '.......BBBBN', '......BSBBW.', 'D.....BSBBW.', '.D...BBSBBW.', '..DDDBBBWBW.'],
33 ],
34 // Curled up, breathing.
35 sleep: [
36 ['............', '............', '............', '.........A.A', '...BSBSBBBCB', '..BBSBSBBBBN', '..BBBBBBBBB.', '.DDDDDDDDD..'],
37 ['............', '............', '............', '.....BB..A.A', '...BSBSBBBCB', '..BBSBSBBBBN', '..BBBBBBBBB.', '.DDDDDDDDD..'],
38 ],
39}
40
41const DOG_POSES = {
42 walk: [
43 ['........BB..', '.......BBBBB', 'D......AEBBN', '.D.....ABMM.', '..BBSSBBBW..', '..BBBBBBBW..', '..B.B...B.B.', '..W.W...W.W.'],
44 ['........BB..', '.......BBBBB', 'D......AEBBN', '.D.....ABMM.', '..BBSSBBBW..', '..BBBBBBBW..', '...BB...BB..', '..W..W.W..W.'],
45 ],
46 // Sitting, tail wagging between the two.
47 sit: [
48 ['........BB..', '.......BBBBB', '.......AEBBN', '.......ABMM.', '......BBBW..', '..D..BBSBW..', '...DBBBSBW..', '....BBBBW.W.'],
49 ['........BB..', '.......BBBBB', '.......AEBBN', '.......ABMM.', '......BBBW..', '.....BBSBW..', '.DDDBBBSBW..', '....BBBBW.W.'],
50 ],
51}
52
53// One nose color per kind, which nothing else uses (the tests count them).
54const CAT_NOSE = 0xf48fa0
55const DOG_NOSE = 0x141414
56
57// A look's name is at its index in the language's cat or dog names.
58type Look = { pal: Record<string, number> }
59
60// A channel a look leaves out is drawn in its body color; -1 is not drawn.
61const CAT_LOOKS: Look[] = [
62 { pal: { B: 0xf0a050, S: 0xd9822b, W: 0xfbe3c0, D: 0xd9822b, E: 0x2b2b2b, C: 0xb86a25 } },
63 { pal: { B: 0x3c3c46, W: 0xf4f4f4, E: 0xf5d547, C: 0x8a8a96 } },
64 { pal: { B: 0x34343c, S: 0x2a2a30, E: 0xf5d547, C: 0x6a6a75 } },
65 { pal: { B: 0xf1e6d6, W: 0xffffff, A: 0x7a5a45, D: 0x7a5a45, E: 0x4da3ff, C: 0x7a5a45 } },
66 { pal: { B: 0xa08c74, S: 0x6e5e4c, W: 0xe8dccb, D: 0x6e5e4c, E: 0x7ac36a, C: 0x4a3f33 } },
67 { pal: { B: 0xf7f3ea, S: 0xe8963f, A: 0x2e2e33, D: 0x2e2e33, E: 0x2b2b2b, C: 0x2b2b2b } },
68]
69
70const DOG_LOOKS: Look[] = [
71 { pal: { B: 0xe58e3e, W: 0xfff0da, M: 0xfff0da, E: 0x2b2b2b } },
72 { pal: { B: 0xe6bc6e, W: 0xf2d49a, A: 0xc9963f, E: 0x2b2b2b } },
73 { pal: { B: 0x45454f, W: 0xf2f2f2, M: 0xf2f2f2, E: 0xc9a86a } },
74 { pal: { B: 0xf7f7f2, W: 0xffffff, A: 0xdadad0, E: 0x222222 } },
75 { pal: { B: 0xe8964a, W: 0xfff6ea, M: 0xfff6ea, D: -1, E: 0x2b2b2b } },
76 { pal: { B: 0x8c929c, S: 0x5e646e, W: 0xf2f2f2, A: 0x5e646e, M: 0xf2f2f2, E: 0x6fb7ff } },
77]
78
79// Emotes in pixels, drawn in the free space behind the head.
80const HEART = ['HH.HH', 'HHHHH', '.HHH.', '..H..']
81const NOTE = ['.HH', '.H.', 'HH.', 'HH.']
82const HEART_COLOR = 0xff4d6d
83const NOTE_COLOR = 0xb48eff
84const GLYPH_COLOR: Record<string, number> = { '!': 0xff5a5a, '?': 0x7fc8f8, z: 0x9aa0b4, Z: 0x9aa0b4 }
85const DUST = [0x8a8a96, 0x5a5a66]
86
87// The terminal's own color, for a cell's empty half.
88const TERMINAL = 0x01000000
89
90const ASCII: Record<ParadeKind, string[]> = { cat: ['=^.^=', '=^..^='], dog: ['U^ᴥ^U', '(ᵔᴥᵔ)'] }
91const ASCII_COLOR: Record<ParadeKind, string> = { cat: '#F2B880', dog: '#C8A27A' }
92const EMOTE_COLOR: Record<string, string> = { '♡': '#FF7A90', '!': '#FF6B6B', '?': '#7FC8F8', '♪': '#B48EFF' }
93
94// ---------- words, in the language the options choose ----------
95
96type Lang = 'en' | 'zh'
97
98const TEXT = {
99 en: {
100 command:
101 'Pixel cats and dogs above the prompt: /parade to show or hide, /parade 5 for how many, /parade cats|dogs|both, /parade pixel|ascii, /parade status for who is out',
102 cats: ['orange tabby', 'tuxedo cat', 'black cat', 'ragdoll', 'brown tabby', 'calico'],
103 dogs: ['shiba', 'golden retriever', 'border collie', 'samoyed', 'corgi', 'husky'],
104 mode: { walk: 'strolling', idle: 'sitting', sleep: 'napping', zoom: 'zooming', leave: 'leaving' },
105 countRange: 'The count goes from 1 to 12.',
106 count: (n: number) => `Parade size: ${n}`,
107 kind: { cats: 'Cats only.', dogs: 'Dogs only.', both: 'Cats and dogs.' },
108 pixel: 'Pixel art it is.',
109 ascii: 'Drawn in characters now.',
110 reset: (c: Config) => `Back to the /config settings: ${c.count}, ${c.kind}, ${c.style}`,
111 hidden: 'The parade is put away. Type /parade to bring it back.',
112 shown: 'The parade is on.',
113 none: 'Nobody is out.',
114 out: (who: string[]) => `${who.length} out: ${who.join(', ')}`,
115 going: (who: string[]) => `; ${who.join(', ')} leaving`,
116 named: (name: string, mode: string) => `${name} (${mode})`,
117 },
118 zh: {
119 command:
120 '提示框上方的像素猫狗巡游:/parade 显示或隐藏,/parade 5 设数量,/parade cats|dogs|both,/parade pixel|ascii,/parade status 看看都有谁',
121 cats: ['橘猫', '奶牛猫', '黑猫', '布偶', '狸花', '三花'],
122 dogs: ['柴犬', '金毛', '边牧', '萨摩耶', '柯基', '哈士奇'],
123 mode: { walk: '散步', idle: '坐着', sleep: '打盹', zoom: '疯跑', leave: '离开中' },
124 countRange: '数量请在 1 到 12 之间。',
125 count: (n: number) => `巡游数量:${n}`,
126 kind: { cats: '只要猫了。', dogs: '只要狗了。', both: '猫狗都有。' },
127 pixel: '换成像素画。',
128 ascii: '换成字符样式。',
129 reset: (c: Config) => `恢复 /config 里的设置:${c.count} 只,${c.kind},${c.style}`,
130 hidden: '巡游收起来了。再输入 /parade 放出来。',
131 shown: '巡游开始。',
132 none: '一只都没有。',
133 out: (who: string[]) => `现在有 ${who.length} 只:${who.join('、')}`,
134 going: (who: string[]) => `;${who.join('、')} 正在离开`,
135 named: (name: string, mode: string) => `${name}(${mode})`,
136 },
137}
138
139let lang: Lang = 'en'
140
141function nameOf(c: ParadeCritter): string {
142 const names = c.kind === 'cat' ? TEXT[lang].cats : TEXT[lang].dogs
143 return names[c.look % names.length]
144}
145
146// ---------- settings: /config gives the defaults, /parade overrides them ----------
147
148type Config = { count: number; kind: ParadeFilter; style: ParadeStyle }
149
150let defaults: Config = { count: 3, kind: 'both', style: 'pixel' }
151let laneWidth = 60
152
153// Overrides as saved, with a style from an older version dropped.
154function normSettings(s: unknown): ParadeSettings {
155 const v = { ...NO_OVERRIDES, ...((s ?? {}) as Partial<ParadeSettings>) }
156 if (v.style !== 'pixel' && v.style !== 'ascii') v.style = null
157 return v
158}
159
160function effective(s: ParadeSettings): Config {
161 return {
162 count: s.count ?? defaults.count,
163 kind: s.kind ?? defaults.kind,
164 style: s.style ?? defaults.style,
165 }
166}
167
168function allowed(kind: ParadeKind, filter: ParadeFilter): boolean {
169 return filter === 'both' || (filter === 'cats' ? kind === 'cat' : kind === 'dog')
170}
171
172function lookOf(c: ParadeCritter): Look {
173 const looks = c.kind === 'cat' ? CAT_LOOKS : DOG_LOOKS
174 return looks[c.look % looks.length]
175}
176
177function asciiSprite(c: ParadeCritter): string {
178 return ASCII[c.kind][c.look % ASCII[c.kind].length]
179}
180
181function cells(text: string): number {
182 return [...text].length
183}
184
185// How many columns an animal takes in a style.
186function bodyWidth(c: ParadeCritter, style: ParadeStyle): number {
187 return style === 'pixel' ? PIXEL_W : cells(asciiSprite(c))
188}
189
190// ---------- the world, one tick at a time ----------
191
192const MEET_COOLDOWN = 40
193
194function rand(lo: number, hi: number): number {
195 return lo + Math.floor(Math.random() * (hi - lo + 1))
196}
197
198function spawn(w: ParadeWorld, cfg: Config, entering: boolean, at?: number): ParadeCritter {
199 const kinds: ParadeKind[] = cfg.kind === 'cats' ? ['cat'] : cfg.kind === 'dogs' ? ['dog'] : ['cat', 'dog']
200 const kind = kinds[rand(0, kinds.length - 1)]
201 const dir = Math.random() < 0.5 ? -1 : 1
202 const span = cfg.style === 'pixel' ? PIXEL_W : 6
203 const x = entering ? (dir > 0 ? -span : w.width) : (at ?? rand(0, Math.max(0, w.width - span)))
204 return { id: w.nextId, kind, look: rand(0, 5), x, dir, mode: 'walk', until: 0, emote: '', emoteUntil: 0 }
205}
206
207// Those the settings no longer want (another kind, past the count) head for the nearest edge.
208function retire(critters: ParadeCritter[], cfg: Config, width: number) {
209 const send = (c: ParadeCritter) => {
210 c.mode = 'leave'
211 c.dir = c.x < width / 2 ? -1 : 1
212 }
213 for (const c of critters) if (c.mode !== 'leave' && !allowed(c.kind, cfg.kind)) send(c)
214 for (const c of critters.filter(c => c.mode !== 'leave').slice(cfg.count)) send(c)
215}
216
217// The middle of the widest stretch of lane nobody stands on.
218function freeSpot(critters: ParadeCritter[], width: number, span: number): number {
219 let best = { start: 0, len: -1 }
220 let edge = 0
221 for (const x of [...critters.map(c => c.x).sort((a, b) => a - b), width]) {
222 if (x - edge > best.len) best = { start: edge, len: x - edge }
223 edge = Math.max(edge, x + span)
224 }
225 return Math.max(0, Math.min(width - span, best.start + Math.floor((best.len - span) / 2)))
226}
227
228// Fill to the count at once (on start, and when the settings change).
229function populate(w: ParadeWorld, cfg: Config): ParadeWorld {
230 const next = { ...w, critters: w.critters.map(c => ({ ...c })) }
231 retire(next.critters, cfg, next.width)
232 const span = cfg.style === 'pixel' ? PIXEL_W : 6
233 while (next.critters.filter(c => c.mode !== 'leave').length < cfg.count) {
234 next.critters.push(spawn(next, cfg, false, freeSpot(next.critters, next.width, span)))
235 next.nextId += 1
236 }
237 return next
238}
239
240// Walking toward the other, or standing still beside it.
241function heading(p: ParadeCritter, q: ParadeCritter): boolean {
242 return p.mode === 'idle' || p.mode === 'sleep' || (q.x - p.x) * p.dir > 0
243}
244
245function stepWorld(w: ParadeWorld, cfg: Config): ParadeWorld {
246 const frame = w.frame + 1
247 const width = laneWidth
248 let nextId = w.nextId
249 let critters = w.critters.map(c => ({ ...c }))
250
251 retire(critters, cfg, width)
252
253 for (const c of critters) {
254 const bw = bodyWidth(c, cfg.style)
255 switch (c.mode) {
256 case 'walk':
257 if (c.kind === 'dog' || frame % 2 === 0) c.x += c.dir
258 if (Math.random() < 0.02) {
259 c.mode = 'idle'
260 c.until = frame + rand(10, 30)
261 } else if (c.kind === 'dog' && Math.random() < 0.01) {
262 c.mode = 'zoom'
263 c.until = frame + rand(12, 25)
264 } else if (Math.random() < 0.01) c.dir = -c.dir
265 break
266 case 'zoom':
267 c.x += c.dir * 2
268 if (frame >= c.until) c.mode = 'walk'
269 break
270 case 'idle':
271 if (frame >= c.until) {
272 if (c.kind === 'cat' && Math.random() < 0.3) {
273 c.mode = 'sleep'
274 c.until = frame + rand(50, 120)
275 } else {
276 c.mode = 'walk'
277 if (Math.random() < 0.4) c.dir = -c.dir
278 }
279 } else if (frame > c.emoteUntil && Math.random() < 0.015) {
280 c.emote = Math.random() < 0.5 ? '♪' : '?'
281 c.emoteUntil = frame + 8
282 }
283 break
284 case 'sleep':
285 if (frame >= c.until) c.mode = 'walk'
286 break
287 case 'leave':
288 c.x += c.dir * (c.kind === 'dog' ? 1 : frame % 2)
289 break
290 }
291 // The edges, reached going outward (a newcomer walks in from beyond one):
292 // mostly turn back, sometimes wander off for good.
293 const outward = (c.x < 0 && c.dir < 0) || (c.x + bw > width && c.dir > 0)
294 if (c.mode !== 'leave' && outward) {
295 if (Math.random() < 0.6) {
296 c.dir = -c.dir
297 c.x = Math.max(0, Math.min(width - bw, c.x))
298 } else c.mode = 'leave'
299 }
300 }
301
302 // Meetings: a dog near a cat sends the cat running (and sometimes gives
303 // chase); two of a kind sit down face to face and share a heart.
304 for (let i = 0; i < critters.length; i++) {
305 for (let j = i + 1; j < critters.length; j++) {
306 const a = critters[i]
307 const b = critters[j]
308 if (a.mode === 'leave' || b.mode === 'leave') continue
309 if (Math.abs(a.x - b.x) > Math.max(bodyWidth(a, cfg.style), bodyWidth(b, cfg.style)) + 1) continue
310 if (frame < a.emoteUntil + MEET_COOLDOWN || frame < b.emoteUntil + MEET_COOLDOWN) continue
311 if (a.kind !== b.kind) {
312 const cat = a.kind === 'cat' ? a : b
313 const dog = a.kind === 'dog' ? a : b
314 if (!heading(dog, cat)) continue
315 cat.emote = '!'
316 cat.emoteUntil = frame + 8
317 cat.mode = 'zoom'
318 cat.dir = cat.x >= dog.x ? 1 : -1
319 cat.until = frame + 12
320 dog.emote = '♪'
321 dog.emoteUntil = frame + 8
322 if (Math.random() < 0.4) {
323 dog.mode = 'zoom'
324 dog.dir = cat.dir
325 dog.until = frame + 8
326 }
327 } else if (a.mode !== 'sleep' && b.mode !== 'sleep' && heading(a, b) && heading(b, a)) {
328 a.dir = b.x >= a.x ? 1 : -1
329 b.dir = -a.dir
330 for (const c of [a, b]) {
331 c.emote = '♡'
332 c.emoteUntil = frame + 10
333 c.mode = 'idle'
334 c.until = frame + 10
335 }
336 }
337 }
338 }
339
340 // Gone off the edge, gone; and now and then someone new wanders in.
341 critters = critters.filter(c => !(c.mode === 'leave' && (c.x + bodyWidth(c, cfg.style) < -1 || c.x > width + 1)))
342 if (critters.filter(c => c.mode !== 'leave').length < cfg.count && Math.random() < 0.1) {
343 critters.push(spawn({ ...w, width, nextId }, cfg, true))
344 nextId += 1
345 }
346 return { frame, width, critters, nextId }
347}
348
349// ---------- effects on $ (top level, as the loader asks) ----------
350
351async function step($: EngineInterface) {
352 const s = normSettings(await read($, settings))
353 if (s.hidden) return
354 const cfg = effective(s)
355 await update($, world, w => stepWorld({ ...EMPTY, ...(w ?? {}) }, cfg))
356}
357
358async function applySettings($: EngineInterface, change: Partial<ParadeSettings>) {
359 await update($, settings, s => ({ ...normSettings(s), ...change }))
360 const s = normSettings(await read($, settings))
361 try {
362 await $.store.set('settings', s)
363 } catch {
364 // remembering is best effort
365 }
366 await update($, world, w => populate({ ...EMPTY, ...(w ?? {}), width: laneWidth }, effective(s)))
367 return effective(s)
368}
369
370async function start($: EngineInterface) {
371 try {
372 const saved = await $.store.get('settings')
373 if (saved !== undefined && typeof saved === 'object') await update($, settings, () => normSettings(saved))
374 } catch {
375 // no saved overrides
376 }
377 const s = normSettings(await read($, settings))
378 await update($, world, w => populate({ ...EMPTY, ...(w ?? {}), width: laneWidth }, effective(s)))
379 void $.clock.every(TICK_MS, () => step($))
380}
381
382// ---------- drawing pixels ----------
383
384function pose(c: ParadeCritter, frame: number): string[] {
385 if (c.kind === 'cat' && c.mode === 'sleep') return CAT_POSES.sleep[(frame >> 3) & 1]
386 const poses = c.kind === 'cat' ? CAT_POSES : DOG_POSES
387 if (c.mode === 'idle' || c.mode === 'sleep') {
388 // A dog wags, faster when happy; a cat's tail flicks now and then.
389 const happy = frame < c.emoteUntil && (c.emote === '♡' || c.emote === '♪')
390 const beat = c.kind === 'dog' ? (happy ? frame : frame >> 1) : frame >> 3
391 return poses.sit[beat & 1]
392 }
393 // Legs swap on each step: a cat steps every other tick, a dog every tick.
394 const beat = c.mode === 'zoom' || c.kind === 'dog' ? frame : frame >> 1
395 return poses.walk[beat & 1]
396}
397
398const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
399
400// Standard padded base64.
401function base64(bytes: Uint8Array): string {
402 let out = ''
403 for (let i = 0; i < bytes.length; i += 3) {
404 const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
405 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63]
406 out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
407 out += i + 2 < bytes.length ? B64[n & 63] : '='
408 }
409 return out
410}
411
412// The lane as a Raster's cells: PIXEL_H / 2 rows of `width` cells.
413function paint(w: ParadeWorld, width: number): string {
414 const px = new Int32Array(width * PIXEL_H).fill(-1)
415 const glyphs = new Map<number, { ch: number; fg: number }>()
416 const dot = (x: number, y: number, color: number) => {
417 if (color >= 0 && x >= 0 && x < width && y >= 0 && y < PIXEL_H) px[y * width + x] = color
418 }
419 const glyph = (x: number, row: number, ch: string) => {
420 if (x >= 0 && x < width) glyphs.set(row * width + x, { ch: ch.charCodeAt(0), fg: GLYPH_COLOR[ch] ?? 0xffffff })
421 }
422 // Where a shape `size` wide, at `col` of a right-facing sprite, falls for this one.
423 const at = (c: ParadeCritter, col: number, size: number) => c.x + (c.dir < 0 ? PIXEL_W - col - size : col)
424 const shape = (x: number, rows: string[], color: number) =>
425 rows.forEach((row, y) => [...row].forEach((ch, i) => ch !== '.' && dot(x + i, y, color)))
426
427 for (const c of w.critters) {
428 const look = lookOf(c)
429 const nose = c.kind === 'cat' ? CAT_NOSE : DOG_NOSE
430 pose(c, w.frame).forEach((row, y) => {
431 for (let i = 0; i < PIXEL_W; i++) {
432 const ch = row[i]
433 if (ch === '.') continue
434 const color = ch === 'N' ? nose : (look.pal[ch] ?? look.pal.B)
435 dot(c.x + (c.dir < 0 ? PIXEL_W - 1 - i : i), y, color)
436 }
437 })
438 if (c.mode === 'zoom') {
439 // Dust kicked up behind.
440 const back = c.dir > 0 ? c.x - 2 : c.x + PIXEL_W + 1
441 dot(back, PIXEL_H - 1, DUST[0])
442 dot(back - c.dir * 2, PIXEL_H - 1 - (w.frame & 1), DUST[1])
443 }
444 }
445
446 // Emotes go over everyone.
447 for (const c of w.critters) {
448 if (c.kind === 'cat' && c.mode === 'sleep') {
449 const zs = ['z', 'zZ', 'zZz'][(w.frame >> 2) % 3]
450 ;[...zs].forEach((ch, i) => glyph(at(c, 8, 3) + i, 0, ch))
451 continue
452 }
453 if (w.frame >= c.emoteUntil) continue
454 if (c.emote === '♡') shape(at(c, 2, 5), HEART, HEART_COLOR)
455 else if (c.emote === '♪') shape(at(c, 4, 3), NOTE, NOTE_COLOR)
456 else if (c.emote === '!' || c.emote === '?') glyph(at(c, 6, 1), 0, c.emote)
457 }
458
459 const rows = PIXEL_H / 2
460 const view = new DataView(new ArrayBuffer(width * rows * 12))
461 let o = 0
462 for (let r = 0; r < rows; r++) {
463 for (let x = 0; x < width; x++) {
464 const top = px[2 * r * width + x]
465 const bottom = px[(2 * r + 1) * width + x]
466 const g = glyphs.get(r * width + x)
467 let cell = [0x20, TERMINAL, TERMINAL]
468 if (g !== undefined) cell = [g.ch, g.fg, TERMINAL]
469 else if (top >= 0 && bottom >= 0) cell = top === bottom ? [0x2588, top, TERMINAL] : [0x2580, top, bottom]
470 else if (top >= 0) cell = [0x2580, top, TERMINAL]
471 else if (bottom >= 0) cell = [0x2584, bottom, TERMINAL]
472 for (const v of cell) {
473 view.setUint32(o, v, true)
474 o += 4
475 }
476 }
477 }
478 return base64(new Uint8Array(view.buffer))
479}
480
481// ---------- drawing characters (the ascii style) ----------
482
483type Seg = { text: string; color?: string }
484
485// Place items on a row of `width` cells; anything not wholly inside is left out.
486function place(items: { x: number; text: string; color?: string }[], width: number): Seg[] {
487 const out: Seg[] = []
488 let at = 0
489 for (const i of [...items].sort((a, b) => a.x - b.x)) {
490 const w = cells(i.text)
491 if (i.x < at || i.x < 0 || i.x + w > width) continue
492 if (i.x > at) out.push({ text: ' '.repeat(i.x - at) })
493 out.push({ text: i.text, color: i.color })
494 at = i.x + w
495 }
496 if (at < width) out.push({ text: ' '.repeat(width - at) })
497 return out
498}
499
500function asciiRows(w: ParadeWorld): { emotes: Seg[]; bodies: Seg[] } {
501 const emotes: { x: number; text: string; color?: string }[] = []
502 const bodies: { x: number; text: string; color?: string }[] = []
503 for (const c of w.critters) {
504 const look = asciiSprite(c)
505 bodies.push({ x: c.x, text: look, color: ASCII_COLOR[c.kind] })
506 if (c.mode === 'zoom') bodies.push({ x: c.dir > 0 ? c.x - 3 : c.x + cells(look) + 1, text: '··', color: 'subtle' })
507 const emote =
508 c.mode === 'sleep' ? ['z', 'zZ', 'zZz'][Math.floor(w.frame / 4) % 3] : w.frame < c.emoteUntil ? c.emote : ''
509 if (emote !== '') emotes.push({ x: c.x + 1, text: emote, color: EMOTE_COLOR[emote] ?? 'subtle' })
510 }
511 return { emotes: place(emotes, laneWidth), bodies: place(bodies, laneWidth) }
512}
513
514// ---------- the mod ----------
515
516type Options = { count?: string; kind?: string; style?: string; language?: string }
517
518export const register: Register = (on, options) => {
519 const opt = (options ?? {}) as Options
520 defaults = {
521 count: Math.max(1, Math.min(8, Number(opt.count ?? '3') || 3)),
522 kind: opt.kind === 'cats' || opt.kind === 'dogs' ? opt.kind : 'both',
523 style: opt.style === 'ascii' ? 'ascii' : 'pixel',
524 }
525 lang = opt.language === 'zh' ? 'zh' : 'en'
526 const t = TEXT[lang]
527
528 on('session.start', async ($, e, next) => {
529 const started = await next(e)
530 await $.command.register({
531 name: 'parade',
532 description: t.command,
533 })
534 await start($)
535 return started
536 })
537
538 on('command.run', { command: 'parade' }, async ($, e) => {
539 const arg = e.args.trim().toLowerCase()
540 const n = Number(arg)
541 if (arg !== '' && Number.isInteger(n)) {
542 if (n < 1 || n > 12) return { text: t.countRange }
543 const cfg = await applySettings($, { count: n, hidden: false })
544 return { text: t.count(cfg.count) }
545 }
546 if (arg === 'cats' || arg === '猫' || arg === 'dogs' || arg === '狗' || arg === 'both' || arg === '都要') {
547 const kind: ParadeFilter = arg === 'cats' || arg === '猫' ? 'cats' : arg === 'dogs' || arg === '狗' ? 'dogs' : 'both'
548 await applySettings($, { kind, hidden: false })
549 return { text: t.kind[kind] }
550 }
551 if (arg === 'pixel' || arg === '像素' || arg === 'emoji') {
552 await applySettings($, { style: 'pixel', hidden: false })
553 return { text: t.pixel }
554 }
555 if (arg === 'ascii' || arg === '字符') {
556 await applySettings($, { style: 'ascii', hidden: false })
557 return { text: t.ascii }
558 }
559 if (arg === 'reset') {
560 const cfg = await applySettings($, { count: null, kind: null, style: null })
561 return { text: t.reset(cfg) }
562 }
563 if (arg === 'status' || arg === 'list' || arg === '谁') {
564 const w = { ...EMPTY, ...((await read($, world)) ?? {}) } as ParadeWorld
565 const s = normSettings(await read($, settings))
566 if (s.hidden) return { text: t.hidden }
567 const out = w.critters.filter(c => c.mode !== 'leave')
568 const going = w.critters.filter(c => c.mode === 'leave').map(nameOf)
569 const text = out.length === 0 ? t.none : t.out(out.map(c => t.named(nameOf(c), t.mode[c.mode])))
570 return { text: going.length === 0 ? text : text + t.going(going) }
571 }
572 const s = normSettings(await read($, settings))
573 const hidden = arg === 'hide' ? true : arg === 'show' ? false : !s.hidden
574 await applySettings($, { hidden })
575 return { text: hidden ? t.hidden : t.shown }
576 })
577
578 // Above the prompt, on top of whatever else draws there.
579 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
580 // Nothing may answer beneath (a test, an engine that draws nothing there).
581 const below = await next(e).catch(() => null)
582 if (e.props.hasSurvey) return below
583 try {
584 laneWidth = Math.max(20, Math.min(512, e.props.bodyColumns - 2))
585 const s = normSettings(await read($, settings))
586 const w = { ...EMPTY, ...((await read($, world)) ?? {}) } as ParadeWorld
587 if (s.hidden || w.critters.length === 0) return below
588 const ui = $.ui.resolve(e) as any
589 const { Box, Text } = ui
590
591 let lane
592 // Pixels need a terminal; anywhere else the animals are drawn in characters.
593 if (effective(s).style === 'pixel' && e.surface === 'terminal' && ui.Raster !== undefined) {
594 lane = (
595 <Box flexDirection="column" paddingX={1}>
596 {ui.Raster({ key: 'parade-lane', columns: laneWidth, rows: PIXEL_H / 2, cells: paint(w, laneWidth) })}
597 </Box>
598 )
599 } else {
600 const { emotes, bodies } = asciiRows(w)
601 const row = (segs: Seg[]) => (
602 <Box flexDirection="row">
603 {segs.map(seg => (
604 <Text color={seg.color}>{seg.text}</Text>
605 ))}
606 </Box>
607 )
608 lane = (
609 <Box flexDirection="column" paddingX={1}>
610 {row(emotes)}
611 {row(bodies)}
612 </Box>
613 )
614 }
615 if (below === null || below === undefined) return lane
616 return (
617 <Box flexDirection="column">
618 {lane}
619 {below}
620 </Box>
621 )
622 } catch {
623 return below
624 }
625 })
626}
627types/index.d.ts 45 lines1export type ParadeKind = 'cat' | 'dog'
2
3export type ParadeFilter = 'both' | 'cats' | 'dogs'
4
5export type ParadeStyle = 'pixel' | 'ascii'
6
7export type ParadeCritter = {
8 id: number
9 kind: ParadeKind
10 // Which look of its kind (an index into the sprite lists).
11 look: number
12 // Column of its left edge in the lane, and which way it faces.
13 x: number
14 dir: number
15 mode: 'walk' | 'idle' | 'sleep' | 'zoom' | 'leave'
16 // The frame its mode ends on, and an emote shown above it until a frame.
17 until: number
18 emote: string
19 emoteUntil: number
20}
21
22export type ParadeWorld = {
23 frame: number
24 width: number
25 critters: ParadeCritter[]
26 nextId: number
27}
28
29// Overrides set with /parade, kept across sessions; unset falls back to /config.
30export type ParadeSettings = {
31 hidden: boolean
32 count: number | null
33 kind: ParadeFilter | null
34 style: ParadeStyle | null
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'pixel-parade': {
40 world: ParadeWorld
41 settings: ParadeSettings
42 }
43 }
44}
45