Pac-Man runs above the prompt while Claude works, a ghost for each sub-agent still out, eaten as it answers

Claude 工作時,prompt 正上方跑一行 Pac-Man:每個還沒回的 sub-agent 一隻鬼(紅、紫、青、橘,最多 4 隻),回完一個就有一隻變藍、被吃掉;Claude 停下來、鬼也吃完就收起來。
· · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
一定要用 Nerd Font,否則小精靈和鬼會變成方塊:小精靈是 md-pac_man(U+F0BAF)、鬼是 U+F02A0。終端字型換成任一套 Nerd Font(例如 Maple Mono NF CN、Sarasa Term TC Nerd、FiraCode Nerd Font)。
需要 Claude Code 2.1.275 以上。
/plugin install pacman-band --marketplace Wenrong274/pacman-band
裝完開新 session 才會生效。
只想試一次:claude --plugin-dir <這個資料夾>。
workbench 的 band 本身就有這條跑道,已裝 workbench 的人不用裝這個;兩個都裝會看到兩隻小精靈。
深色主題用 Catppuccin Mocha,/config 的 theme 以 light 開頭時用 Latte。
codex、gemini…)不算鬼,只算 sub-agent。改動要四項全綠:
claude plugin test .
claude plugin validate .
npx -y -p typescript@5 tsc -p . --noEmit
npx -y prettier@3.9.9 --check .
tsc 需要 .claude-plugin/types/:用 claude --plugin-dir . 載入一次就會產生(不進 git)。
hooks/register.tsx 153 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { LiveStatus } from '../types'
5import { PacmanLane } from './logic/pacman-lane'
6import type { LaneCell } from './logic/pacman-lane'
7import { Palette } from './logic/palette'
8
9const isWorking = atom({ plugin: 'pacman-band', key: 'isWorking' } as const, false)
10const liveAgents = atom({ plugin: 'pacman-band', key: 'liveAgents' } as const, 0)
11const frame = atom({ plugin: 'pacman-band', key: 'frame' } as const, 0)
12const isLightTheme = atom({ plugin: 'pacman-band', key: 'isLightTheme' } as const, false)
13
14type $ = EngineInterface
15
16// Pac-Man steps a column each tick; the timer runs only while something is busy.
17const FRAME_MS = 125
18// How long an answered agent's ghost stays frightened at his mouth.
19const EAT_MS = 1_500
20const LIVE_STATUSES: readonly string[] = ['pending', 'running', 'waiting'] satisfies LiveStatus[]
21const RESET_REASONS = ['clear', 'resume']
22
23const lane = new PacmanLane()
24let timer: Timer | undefined
25let eatUntil = 0
26// Agents that answered: left out of the count even while the list still shows them running.
27const answered = new Set<string>()
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 void refreshTheme($).catch(ignoreFailure)
32
33 return next(e)
34 })
35
36 on('config.set', { key: 'theme' }, async ($, e, next) => {
37 const set = await next(e)
38 await refreshTheme($).catch(ignoreFailure)
39
40 return set
41 })
42
43 on('session.end', async ($, e, next) => {
44 if (RESET_REASONS.includes(e.reason)) {
45 await update($, isWorking, () => false)
46 await update($, liveAgents, () => 0)
47 eatUntil = 0
48 answered.clear()
49 await ensureTimer($)
50 }
51
52 return next(e)
53 })
54
55 // turn.start carries the person's prompt: the main agent's turns only.
56 on('turn.start', async ($, e, next) => {
57 await update($, isWorking, () => true)
58 await ensureTimer($)
59
60 return next(e)
61 })
62
63 on('agent.spawn', async ($, e, next) => {
64 const spawned = await next(e)
65 await refreshAgents($).catch(ignoreFailure)
66
67 return spawned
68 })
69
70 on('turn.complete', async ($, e, next) => {
71 const completed = await next(e)
72 if (e.agentId === undefined) {
73 await update($, isWorking, () => false)
74 } else {
75 // Its ghost, eaten.
76 eatUntil = (await $.clock.now()) + EAT_MS
77 answered.add(e.agentId)
78 await refreshAgents($).catch(ignoreFailure)
79 }
80 await ensureTimer($)
81
82 return completed
83 })
84
85 // A row of its own above whatever the other plugins draw, so it sits beside any band.
86 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87 if (e.props.hasSurvey || !(await isBusy($))) return next(e)
88 const { Box, Text } = $.ui.resolve(e)
89 const colors = new Palette(await read($, isLightTheme))
90 // A column spare: an icon the font draws two wide cannot wrap the row.
91 const cells = lane.cells(
92 Math.max(0, e.props.bodyColumns - 1),
93 await read($, frame),
94 await read($, liveAgents),
95 (await $.clock.now()) < eatUntil,
96 )
97 const below = await next(e)
98
99 return (
100 <Box flexDirection="column">
101 <Box>
102 {cells.map(cell => (
103 <Text bold={cell.kind !== 'dot' || undefined} color={laneColor(colors, cell)}>
104 {cell.text}
105 </Text>
106 ))}
107 </Box>
108 {below}
109 </Box>
110 )
111 })
112}
113
114async function isBusy($: $): Promise<boolean> {
115 return (await read($, isWorking)) || (await read($, liveAgents)) > 0 || (await $.clock.now()) < eatUntil
116}
117
118async function refreshAgents($: $) {
119 const live = (await $.agent.list()).filter(agent => LIVE_STATUSES.includes(agent.status) && !answered.has(agent.id)).length
120 await update($, liveAgents, () => live)
121 await ensureTimer($)
122}
123
124async function ensureTimer($: $) {
125 const busy = await isBusy($)
126 if (busy && timer === undefined) {
127 timer = $.clock.every(FRAME_MS, () => void tick($).catch(ignoreFailure))
128 } else if (!busy && timer !== undefined) {
129 timer.cancel()
130 timer = undefined
131 }
132}
133
134// Each tick asks afresh whether anything is still busy, so the timer stops by itself.
135async function tick($: $) {
136 await update($, frame, n => n + 1)
137 await ensureTimer($)
138}
139
140async function refreshTheme($: $) {
141 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
142 const isLight = typeof theme === 'string' && theme.startsWith('light')
143 if ((await read($, isLightTheme)) !== isLight) await update($, isLightTheme, () => isLight)
144}
145
146function laneColor(colors: Palette, cell: LaneCell): string {
147 if (cell.kind === 'pac') return colors.color('pac')
148 if (cell.kind === 'ghost') return colors.ghost(cell.index ?? 0)
149 return colors.color(cell.kind === 'scared' ? 'scared' : 'dim')
150}
151
152function ignoreFailure() {}
153hooks/logic/pacman-lane.ts 36 lines1// Swap these for what the terminal's font draws one column wide (the probe shows which).
2// Material Design's pac_man from the Nerd Font: ᗧ fell back to a font that drew it as ɑ.
3export const PAC_OPEN = '\u{f0baf}'
4export const PAC_SHUT = '●'
5export const GHOST = '\u{f02a0}'
6const DOT = '·'
7
8export type LaneCell = { text: string; kind: 'pac' | 'ghost' | 'scared' | 'dot' | 'empty'; index?: number }
9
10const MAX_GHOSTS = 4
11const GHOST_GAP = 3
12const DOT_EVERY = 2
13
14// The band while Claude works: Pac-Man crossing left to right a column a frame, eating the
15// dots before him, a ghost for each agent or job still out running ahead of him.
16export class PacmanLane {
17 // A cell a column. isEating puts a frightened ghost at his mouth, the others a column on.
18 cells(columns: number, frame: number, ghosts: number, isEating: boolean): LaneCell[] {
19 if (columns <= 0) return []
20 const at = frame % columns
21 // The dots lie on fixed columns, so they wait for him rather than move with him.
22 const cells: LaneCell[] = Array.from({ length: columns }, (_, column) =>
23 column > at && column % DOT_EVERY === 0 ? { text: DOT, kind: 'dot' } : { text: ' ', kind: 'empty' },
24 )
25 const place = (offset: number, cell: LaneCell) => {
26 cells[(at + offset) % columns] = cell
27 }
28 const shift = isEating ? 1 : 0
29 for (let i = 0; i < Math.min(ghosts, MAX_GHOSTS); i++) place(GHOST_GAP * (i + 1) + shift, { text: GHOST, kind: 'ghost', index: i })
30 if (isEating) place(1, { text: GHOST, kind: 'scared', index: 0 })
31 place(0, { text: frame % 2 === 0 ? PAC_OPEN : PAC_SHUT, kind: 'pac' })
32
33 return cells
34 }
35}
36hooks/logic/palette.ts 28 lines1export type Role = 'pac' | 'scared' | 'dim'
2
3// Catppuccin: Mocha on a dark theme, Latte on a light one.
4const MOCHA: Record<Role, string> = { pac: '#f9e2af', scared: '#89b4fa', dim: '#6c7086' }
5const LATTE: Record<Role, string> = { pac: '#df8e1d', scared: '#1e66f5', dim: '#9ca0b0' }
6// Blinky, Pinky, Inky and Clyde; Pinky in mauve, as workbench keeps pink for whose turn it is.
7const MOCHA_GHOSTS = ['#f38ba8', '#cba6f7', '#94e2d5', '#fab387']
8const LATTE_GHOSTS = ['#d20f39', '#8839ef', '#179299', '#fe640b']
9
10export class Palette {
11 private readonly colors: Record<Role, string>
12 private readonly ghosts: readonly string[]
13
14 constructor(isLight: boolean) {
15 this.colors = isLight ? LATTE : MOCHA
16 this.ghosts = isLight ? LATTE_GHOSTS : MOCHA_GHOSTS
17 }
18
19 color(role: Role): string {
20 return this.colors[role]
21 }
22
23 // The ghosts in turn, round again past the fourth.
24 ghost(index: number): string {
25 return this.ghosts[index % this.ghosts.length] ?? this.colors.pac
26 }
27}
28types/index.d.ts 17 lines1// A sub-agent's list status that still counts as out: a ghost.
2export type LiveStatus = 'pending' | 'running' | 'waiting'
3
4declare module 'claude-code' {
5 interface PluginState {
6 'pacman-band': {
7 // A main turn is running.
8 isWorking: boolean
9 // Sub-agents not yet answered: a ghost each.
10 liveAgents: number
11 frame: number
12 // The /config theme is a light one: Latte, not Mocha.
13 isLightTheme: boolean
14 }
15 }
16}
17