派小幫手時,右邊面板顯示像素辦公室,員工小人會隨機打字、講電話、衝去影印,並顯示每位小幫手的名字、任務和已工作多久

A collection of Claude Code plugins (mods) that add live status displays to the interface.
Displays session usage above the prompt: 5-hour and 7-day rate-limit consumption with reset countdowns, token counts, and session cost.
![]()
When you send out helpers (subagents), a pixel office opens in the side pane. Each helper sits at a desk and randomly types, talks on the phone, rushes to the copier, drinks coffee, or thinks. Finished helpers raise their arms. Interrupted helpers turn grey and fall asleep at their desk.
![]()
/helpersclaude plugin marketplace add mikekuo0725/usage-band
claude plugin install usage-band@usage-band
claude plugin install pixel-helpers@usage-band
本專案收錄 Claude Code 外掛(mod),為介面加入即時狀態顯示。
於輸入框上方即時顯示本次工作階段的用量資訊,包括 5 小時與 7 天額度使用率、重置倒數、token 用量及花費。
![]()
派小幫手時,右邊面板會顯示一間像素辦公室。每位小幫手坐在自己的辦公桌前,隨機打字、講電話、衝去影印、喝咖啡或想事情。做完的小幫手會舉起雙手;被打斷的會變灰、趴在桌上睡著。
![]()
/helpers 打開claude plugin marketplace add mikekuo0725/usage-band
claude plugin install usage-band@usage-band
claude plugin install pixel-helpers@usage-bandhooks/register.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { formatElapsed } from './format'
5import { addHelper, countTool, finishHelper, ordered } from './helpers'
6import { ACTION_LABEL, actionFor, columnsFor, drawOffice, hex, lookFor, toRaster, toSvg } from './office'
7import type { Desk } from './office'
8
9const helpers = atom({ plugin: 'pixel-helpers', key: 'helpers' } as const, [])
10const now = atom({ plugin: 'pixel-helpers', key: 'now' } as const, 0)
11
12const PANE = 'pixel-helpers'
13const TITLE = '小幫手'
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 await $.command.register({ name: 'helpers', description: '打開小幫手面板' })
18 const ms = await $.clock.now()
19 await update($, now, () => ms)
20 // 有人在工作時每秒走一下,時間和走路動畫才會動
21 $.clock.every(1000, () => {
22 void (async () => {
23 const list = await read($, helpers)
24 if (!list.some(x => x.status === 'running')) return
25 const t = await $.clock.now()
26 await update($, now, () => t)
27 })()
28 })
29 return next(e)
30 })
31
32 on('command.run', { command: 'helpers' }, async $ => {
33 await $.ui.open({ id: PANE, title: TITLE })
34 return { text: '小幫手面板打開了。' }
35 })
36
37 on('session.end', async ($, e, next) => {
38 if (e.reason === 'clear') await update($, helpers, () => [])
39 return next(e)
40 })
41
42 // 小幫手出發:記下名字、任務,打開面板
43 on('agent.spawn', async ($, e, next) => {
44 const result = await next(e)
45 if (result.agentId) {
46 const startedAt = await $.clock.now()
47 const id = result.agentId
48 await update($, helpers, list =>
49 addHelper(list, {
50 id,
51 name: e.name ?? e.subagentType,
52 type: e.subagentType,
53 task: e.description || firstLine(e.prompt),
54 startedAt,
55 }),
56 )
57 await update($, now, () => startedAt)
58 void $.ui.open({ id: PANE, title: TITLE })
59 }
60 return result
61 })
62
63 // 小幫手每用一個工具就記一筆
64 on('tool.call', async ($, e, next) => {
65 const id = e.agentId
66 if (id) {
67 const list = await read($, helpers)
68 if (list.some(x => x.id === id)) await update($, helpers, l => countTool(l, id))
69 }
70 return next(e)
71 })
72
73 // 小幫手收工
74 on('turn.complete', async ($, e, next) => {
75 const result = await next(e)
76 const id = e.agentId
77 if (id) {
78 const at = await $.clock.now()
79 const failed = e.reason !== 'answer'
80 await update($, helpers, list => finishHelper(list, id, at, failed))
81 await update($, now, () => at)
82 }
83 return result
84 })
85
86 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
87 const list = ordered(await read($, helpers))
88 const t = await read($, now)
89 const tick = Math.floor(t / 1000)
90 const { Box, Text } = $.ui.resolve(e)
91
92 const desks: Desk[] = list.map(one => ({ helper: one, action: actionFor(one, tick), look: lookFor(one) }))
93 const room = e.props.bodyColumns || 80
94 // 終端機一個字一個像素;桌面版一個像素畫 3px,一個字大約 7px
95 const cols = columnsFor(e.surface === 'terminal' ? room - 2 : Math.floor((room * 7) / 3))
96 const office = drawOffice(desks, cols, tick)
97
98 const scene = () => {
99 if (e.surface === 'terminal') {
100 const { Raster } = $.ui.resolve(e)
101 return <Raster key="office" {...toRaster(office)} />
102 }
103 const { Svg } = $.ui.resolve(e)
104 return <Svg source={toSvg(office)} alt="像素辦公室:小幫手坐在辦公桌前工作" width={office.w * 3} height={office.h * 3} />
105 }
106
107 const working = list.filter(one => one.status === 'running').length
108 const title = list.length === 0 ? '辦公室還空著,派小幫手出去時他們會來上班' : working > 0 ? `${working} 位小幫手上班中` : '大家都下班了'
109
110 return (
111 <Box flexDirection="column" paddingX={1}>
112 <Text bold>{title}</Text>
113 <Box marginTop={1}>{scene()}</Box>
114 {desks.map(({ helper: one, action, look }) => {
115 const ms = (one.endedAt ?? t) - one.startedAt
116 const status =
117 one.status === 'running'
118 ? `⏱ 已工作 ${formatElapsed(ms)} · 🔧 ${one.tools} 個工具`
119 : one.status === 'done'
120 ? `✓ 完成,花了 ${formatElapsed(ms)} · 🔧 ${one.tools} 個工具`
121 : `✗ 中斷了,做了 ${formatElapsed(ms)}`
122 return (
123 <Box key={one.id} flexDirection="column" marginTop={1}>
124 <Text bold dimColor={one.status !== 'running'}>
125 <Text color={hex(look.jacket)}>■ </Text>
126 {one.name}
127 <Text dimColor>{one.name === one.type ? '' : ` (${one.type})`}</Text>
128 <Text>{` ${ACTION_LABEL[action]}`}</Text>
129 </Text>
130 <Text dimColor={one.status !== 'running'}>{` ${one.task}`}</Text>
131 <Text color={one.status === 'running' ? 'cyan' : one.status === 'done' ? 'green' : 'red'}>{` ${status}`}</Text>
132 </Box>
133 )
134 })}
135 </Box>
136 )
137 })
138
139 // 架在 usage-band 上面:有人在工作時,輸入框上方多一行
140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141 const below = await next(e)
142 if (e.props.hasSurvey) return below
143 const running = (await read($, helpers)).filter(one => one.status === 'running')
144 if (running.length === 0) return below
145 const { Box, Text } = $.ui.resolve(e)
146 const names = running.map(one => one.name).join(' · ')
147 return (
148 <Box flexDirection="column">
149 <Box flexDirection="row" paddingX={1}>
150 <Text color="cyan" bold>{`🏃 ${running.length} 位小幫手工作中`}</Text>
151 <Text dimColor>{` ${names} (/helpers 看詳細)`}</Text>
152 </Box>
153 {below ?? ''}
154 </Box>
155 )
156 })
157}
158
159function firstLine(text: string): string {
160 const line = text.split('\n').find(l => l.trim()) ?? ''
161 return line.length > 60 ? `${line.slice(0, 60)}…` : line
162}
163hooks/format.ts 9 lines1// 65000 → "1 分 05 秒";不到一分鐘只顯示秒
2export function formatElapsed(ms: number): string {
3 const s = Math.max(0, Math.floor(ms / 1000))
4 if (s < 60) return `${s} 秒`
5 const m = Math.floor(s / 60)
6 if (m < 60) return `${m} 分 ${String(s % 60).padStart(2, '0')} 秒`
7 return `${Math.floor(m / 60)} 時 ${String(m % 60).padStart(2, '0')} 分`
8}
9hooks/helpers.ts 36 lines1import type { Helper } from '../types'
2
3// 做完的小幫手最多留幾位在面板上
4const KEEP_DONE = 6
5
6// 新的小幫手出發
7export function addHelper(list: Helper[], h: Omit<Helper, 'endedAt' | 'status' | 'tools'>): Helper[] {
8 const fresh: Helper = { ...h, endedAt: null, status: 'running', tools: 0 }
9 return trim([...list.filter(x => x.id !== h.id), fresh])
10}
11
12// 小幫手用了一個工具
13export function countTool(list: Helper[], id: string): Helper[] {
14 return list.map(h => (h.id === id && h.status === 'running' ? { ...h, tools: h.tools + 1 } : h))
15}
16
17// 小幫手收工;failed 表示被打斷或出錯
18export function finishHelper(list: Helper[], id: string, at: number, failed: boolean): Helper[] {
19 return trim(
20 list.map(h => (h.id === id && h.status === 'running' ? { ...h, endedAt: at, status: failed ? 'failed' : 'done' } : h)),
21 )
22}
23
24// 工作中的排前面(先出發的在上),做完的排後面(最近的在上),舊的做完的丟掉
25export function ordered(list: Helper[]): Helper[] {
26 const running = list.filter(h => h.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
27 const ended = list.filter(h => h.status !== 'running').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
28 return [...running, ...ended]
29}
30
31function trim(list: Helper[]): Helper[] {
32 const ended = list.filter(h => h.status !== 'running').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
33 const drop = new Set(ended.slice(KEEP_DONE).map(h => h.id))
34 return list.filter(h => !drop.has(h.id))
35}
36hooks/office.ts 531 lines1// 像素辦公室:每位小幫手一張辦公桌,右邊有影印機和咖啡機
2
3import type { Helper } from '../types'
4
5export type Action = 'typing' | 'phone' | 'copier' | 'coffee' | 'thinking' | 'done' | 'failed'
6
7export const ACTION_LABEL: Record<Action, string> = {
8 typing: '⌨ 打字中',
9 phone: '📞 講電話',
10 copier: '🏃 衝去影印',
11 coffee: '☕ 喝咖啡',
12 thinking: '💭 想事情',
13 done: '🙌 收工',
14 failed: '💤 趴著',
15}
16
17// 每 6 秒換一次動作;同一個人同一段時間永遠抽到同一個,畫面才不會亂跳
18const SLOT = 6
19
20export function actionFor(h: Pick<Helper, 'id' | 'status'>, tick: number): Action {
21 if (h.status === 'done') return 'done'
22 if (h.status === 'failed') return 'failed'
23 const r = hash(`${h.id}:${Math.floor(tick / SLOT)}`) % 100
24 if (r < 40) return 'typing'
25 if (r < 60) return 'phone'
26 if (r < 75) return 'copier'
27 if (r < 90) return 'coffee'
28 return 'thinking'
29}
30
31// ---------- 版面 ----------
32
33export const CELL_W = 34
34export const CELL_H = 34
35export const WALL = 10
36export const SIDE = 30
37
38export type Look = {
39 jacket: number
40 hair: number
41 skin: number
42 tie: number
43 hairStyle: 0 | 1 | 2 // 短髮、長髮、禿頭
44 glasses: boolean
45}
46
47const JACKETS: Record<string, number> = {
48 Explore: 0x2f6fd6,
49 'general-purpose': 0xd9822b,
50 Plan: 0x7a4fc4,
51}
52const SPARE_JACKETS = [0x2e9e6a, 0xc43d5a, 0x1f8a8a, 0x8a6a1f, 0x4a5568]
53const HAIRS = [0x3b2a1a, 0x1a1a1a, 0x8a5a2b, 0xc9a04a, 0x6b3e26, 0x9a9a9a]
54const SKINS = [0xf2c9a0, 0xe0ac7e, 0xc68b5e, 0x8d5a3b]
55const TIES = [0xc0392b, 0x2c3e50, 0x16a085, 0x8e44ad, 0xd4ac0d]
56
57export function lookFor(h: Pick<Helper, 'id' | 'type'>): Look {
58 const n = hash(h.id)
59 const style = n % 10 < 5 ? 0 : n % 10 < 8 ? 1 : 2
60 return {
61 jacket: Object.hasOwn(JACKETS, h.type) ? JACKETS[h.type]! : pick(SPARE_JACKETS, hash(h.type)),
62 hair: pick(HAIRS, n >>> 4),
63 skin: pick(SKINS, n >>> 8),
64 tie: pick(TIES, n >>> 12),
65 hairStyle: style,
66 glasses: (n >>> 16) % 10 < 3,
67 }
68}
69
70// 這麼寬的面板放得下幾張桌子一排
71export function columnsFor(pixelWidth: number): number {
72 return Math.max(1, Math.min(4, Math.floor((pixelWidth - SIDE) / CELL_W)))
73}
74
75// ---------- 畫布 ----------
76
77const FLOOR = 0xe8e1d0
78const TRANSPARENT = -1
79
80export class Canvas {
81 readonly w: number
82 readonly h: number
83 readonly px: Int32Array
84 constructor(w: number, h: number) {
85 this.w = w
86 this.h = h
87 this.px = new Int32Array(w * h).fill(FLOOR)
88 }
89 set(x: number, y: number, c: number) {
90 if (c === TRANSPARENT || x < 0 || y < 0 || x >= this.w || y >= this.h) return
91 this.px[y * this.w + x] = c
92 }
93 rect(x: number, y: number, w: number, h: number, c: number) {
94 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c)
95 }
96 get(x: number, y: number): number {
97 return this.px[y * this.w + x] ?? FLOOR
98 }
99}
100
101// ---------- 員工 ----------
102
103// 頭:h 頭髮 H 頭髮亮面 d 頭髮暗面/眉毛 S 皮膚 s 皮膚陰影 O 眼白 E 眼珠 m 嘴巴
104const HEADS: readonly (readonly string[])[] = [
105 // 短髮
106 [
107 '...dhhhhd...',
108 '..dhhHHhhd..',
109 '.dhhhhhHhhd.',
110 '.dhSSSSSShd.',
111 '.dSddSSddSd.',
112 'sSSOESSEOSSs',
113 '.SSSSssSSSS.',
114 '.sSSSmmSSSs.',
115 '..sSSSSSSs..',
116 ],
117 // 長髮
118 [
119 '...dhhhhd...',
120 '..dhhHHhhd..',
121 '.dhhhhhHhhd.',
122 'dhhSSSSSShhd',
123 'dhSddSSddShd',
124 'dhSOESSEOShd',
125 'dhSSSssSSShd',
126 'dhsSSmmSSshd',
127 'dhhsSSSSshhd',
128 ],
129 // 禿頭,兩側一點頭髮
130 [
131 '............',
132 '....sSSs....',
133 '..sSSSSSSs..',
134 '.dSSSSSSSSd.',
135 '.dSddSSddSd.',
136 'sSSOESSEOSSs',
137 '.SSSSssSSSS.',
138 '.sSSSmmSSSs.',
139 '..sSSSSSSs..',
140 ],
141]
142
143// 身體:W 白襯衫 T 領帶 t 領帶結 J 西裝 j 西裝陰影 L 翻領 G 鈕扣
144const BODY = [
145 '...jWWWWj...',
146 'jJJLWttWLJJj',
147 'JJJLWTTWLJJJ',
148 'JJJJLWTWLJJJ',
149 'JJJJJLTLJJJJ',
150 'jJJJJJGJJJJj',
151 'jJJJJJJJJJJj',
152]
153
154const LEGS_A = ['..KKKKKKKK..', '..KKK..KKK..', '..KK....KK..', '..KK....KK..', '.BBB....BBB.']
155const LEGS_B = ['..KKKKKKKK..', '...KK..KK...', '...KK..KK...', '...KK..KK...', '..BBB..BBB..']
156
157const HEAD_H = 9
158const PERSON_H = HEAD_H + BODY.length
159
160type Dot = [x: number, y: number, ch: string]
161
162function colorOf(ch: string, look: Look, grey: boolean): number {
163 const g = (c: number) => (grey ? greyOf(c) : c)
164 switch (ch) {
165 case 'h': return g(look.hair)
166 case 'H': return g(shade(look.hair, 1.5))
167 case 'd': return g(shade(look.hair, 0.65))
168 case 'S': return g(look.skin)
169 case 's': return g(shade(look.skin, 0.85))
170 case 'O': return 0xffffff
171 case 'E': return 0x2a1d14
172 case 'C': return 0x3a2a20 // 瞇眼
173 case 'm': return g(0xb5524a)
174 case 'g': return 0x222222 // 眼鏡
175 case 'W': return g(0xf7f7f7)
176 case 'T': return g(look.tie)
177 case 't': return g(shade(look.tie, 0.7))
178 case 'J': return g(look.jacket)
179 case 'j': return g(shade(look.jacket, 0.75))
180 case 'L': return g(shade(look.jacket, 0.6))
181 case 'G': return g(0xe5c76b)
182 case 'K': return 0x2d2d3a
183 case 'B': return 0x3b2414
184 case 'P': return 0x1a1a1a
185 case 'R': return 0xc0392b // 紅色話筒
186 case 'M': return 0xffffff
187 case 'V': return 0xbfc5cc
188 case 'D': return 0x7fc4ff
189 case 'Z': return 0x8aa0c8
190 default: return TRANSPARENT
191 }
192}
193
194function drawGrid(cv: Canvas, x0: number, y0: number, rows: readonly string[], look: Look, grey = false) {
195 rows.forEach((row, y) => [...row].forEach((ch, x) => cv.set(x0 + x, y0 + y, colorOf(ch, look, grey))))
196}
197
198function drawDots(cv: Canvas, x0: number, y0: number, dots: Dot[], look: Look) {
199 for (const [x, y, ch] of dots) cv.set(x0 + x, y0 + y, colorOf(ch, look, false))
200}
201
202function drawPerson(cv: Canvas, x: number, y: number, look: Look, grey = false, happy = false) {
203 drawGrid(cv, x, y, HEADS[look.hairStyle]!, look, grey)
204 drawGrid(cv, x, y + HEAD_H, BODY, look, grey)
205 if (happy) drawDots(cv, x, y, [[3, 5, 'S'], [4, 5, 'C'], [3, 4, 'C'], [8, 5, 'S'], [7, 5, 'C'], [8, 4, 'C'], [5, 7, 'm'], [6, 7, 'm'], [5, 8, 'm']], look)
206 if (look.glasses && !grey) {
207 drawDots(cv, x, y, [[2, 5, 'g'], [5, 5, 'g'], [6, 5, 'g'], [9, 5, 'g'], [3, 6, 'g'], [4, 6, 'g'], [7, 6, 'g'], [8, 6, 'g']], look)
208 }
209}
210
211// ---------- 家具 ----------
212
213function drawRoom(cv: Canvas, cols: number) {
214 // 地磚
215 for (let y = WALL + 9; y < cv.h; y += 10) cv.rect(0, y, cv.w, 1, 0xddd4bf)
216 for (let y = WALL; y < cv.h; y += 10) {
217 const off = ((y - WALL) / 10) % 2 === 0 ? 0 : 10
218 for (let x = off; x < cv.w; x += 20) cv.rect(x, y, 1, 9, 0xe1d9c6)
219 }
220 // 牆、窗戶、踢腳板、時鐘
221 cv.rect(0, 0, cv.w, WALL, 0xc9d6e3)
222 cv.rect(0, WALL - 2, cv.w, 2, 0x8a9bb0)
223 for (let x = 5; x + 18 < cols * CELL_W; x += 40) {
224 cv.rect(x, 1, 18, 6, 0xffffff)
225 cv.rect(x + 1, 2, 7, 4, 0xa8d8f0)
226 cv.rect(x + 10, 2, 7, 4, 0xa8d8f0)
227 cv.set(x + 2, 2, 0xe0f4ff)
228 cv.set(x + 11, 2, 0xe0f4ff)
229 }
230 const cx = cols * CELL_W + 12
231 cv.rect(cx, 1, 6, 6, 0x555555)
232 cv.rect(cx + 1, 2, 4, 4, 0xffffff)
233 cv.set(cx + 2, 3, 0x222222)
234 cv.set(cx + 3, 3, 0x222222)
235 cv.set(cx + 2, 2, 0x222222)
236}
237
238function drawChair(cv: Canvas, cx: number, cy: number) {
239 cv.rect(cx + 8, cy + 7, 14, 13, 0x3d3d48)
240 cv.rect(cx + 9, cy + 8, 12, 1, 0x55556a)
241}
242
243function drawDesk(cv: Canvas, cx: number, cy: number) {
244 cv.rect(cx + 2, cy + 19, 30, 2, 0x8b5a2b) // 桌面
245 cv.rect(cx + 2, cy + 19, 30, 1, 0x9e6a37)
246 cv.rect(cx + 2, cy + 21, 30, 7, 0xa0693a) // 前板
247 cv.rect(cx + 22, cy + 23, 8, 4, 0x8b5a2b) // 抽屜
248 cv.rect(cx + 25, cy + 25, 2, 1, 0xe5c76b)
249 cv.rect(cx + 3, cy + 28, 2, 3, 0x6b4421)
250 cv.rect(cx + 29, cy + 28, 2, 3, 0x6b4421)
251 // 一疊紙、筆筒、鍵盤
252 cv.rect(cx + 3, cy + 17, 5, 2, 0xffffff)
253 cv.rect(cx + 3, cy + 17, 5, 1, 0xe6e6e6)
254 cv.rect(cx + 9, cy + 16, 2, 3, 0x5d6d7e)
255 cv.set(cx + 9, cy + 15, 0xc0392b)
256 cv.set(cx + 10, cy + 14, 0x2471a3)
257 cv.rect(cx + 12, cy + 19, 9, 1, 0xd0d3d4)
258}
259
260function drawMonitor(cv: Canvas, cx: number, cy: number, action: Action | 'empty', tick: number) {
261 const x = cx + 22
262 const y = cy + 8
263 cv.rect(x, y, 11, 8, 0x2c2c2c)
264 cv.rect(x + 1, y + 1, 9, 6, screenBg(action))
265 cv.rect(x + 4, y + 8, 3, 2, 0x2c2c2c)
266 cv.rect(x + 2, y + 10, 7, 1, 0x2c2c2c)
267 if (action === 'typing') {
268 // 一行一行跑的程式碼
269 const colors = [0xf5b041, 0x58d68d, 0xec7063, 0xffffff]
270 for (let line = 0; line < 3; line++) {
271 const len = 2 + ((tick + line * 3) % 6)
272 cv.rect(x + 2 + (line % 2), y + 2 + line * 2 - 1 + 1, Math.min(len, 7), 1, colors[(tick + line) % colors.length]!)
273 }
274 } else if (action === 'done') {
275 cv.set(x + 3, y + 4, 0xffffff)
276 cv.set(x + 4, y + 5, 0xffffff)
277 cv.set(x + 5, y + 4, 0xffffff)
278 cv.set(x + 6, y + 3, 0xffffff)
279 cv.set(x + 7, y + 2, 0xffffff)
280 } else if (action === 'thinking' || action === 'phone') {
281 cv.rect(x + 2, y + 2, 5, 1, 0x9fc5e8)
282 cv.rect(x + 2, y + 4, 3, 1, 0x9fc5e8)
283 }
284}
285
286function screenBg(action: Action | 'empty'): number {
287 switch (action) {
288 case 'failed':
289 case 'empty':
290 return 0x1a1a1a
291 case 'done':
292 return 0x27ae60
293 case 'typing':
294 return 0x1e2b3a
295 default:
296 return 0x2e5d8a
297 }
298}
299
300function drawCopier(cv: Canvas, x: number, y: number, busy: boolean, tick: number) {
301 cv.rect(x, y, 18, 4, 0x8f969c)
302 cv.rect(x + 1, y + 1, 10, 2, 0x5d6d7e) // 蓋子上的玻璃
303 cv.rect(x, y + 4, 18, 11, 0xd5d8dc)
304 cv.rect(x + 2, y + 7, 14, 1, 0xa6acaf)
305 cv.rect(x + 2, y + 10, 14, 1, 0xa6acaf)
306 cv.rect(x + 13, y + 1, 3, 2, 0x34495e)
307 cv.set(x + 14, y + 1, busy && tick % 2 === 0 ? 0x2ecc71 : 0x1e7a46)
308 if (busy) {
309 cv.rect(x - 4, y + 5 + (tick % 2), 4, 3, 0xffffff) // 吐出來的紙
310 cv.rect(x - 3, y + 6 + (tick % 2), 2, 1, 0xcccccc)
311 }
312}
313
314function drawCoffeeMachine(cv: Canvas, x: number, y: number) {
315 cv.rect(x, y, 13, 15, 0x5a3d2b)
316 cv.rect(x + 1, y + 1, 11, 4, 0x3b281c)
317 cv.rect(x + 2, y + 2, 4, 2, 0x85c1e9)
318 cv.set(x + 9, y + 2, 0xe74c3c)
319 cv.set(x + 10, y + 2, 0x2ecc71)
320 cv.rect(x + 4, y + 7, 5, 1, 0x2b1d14)
321 cv.rect(x + 5, y + 10, 3, 3, 0xffffff)
322 cv.set(x + 8, y + 11, 0xffffff)
323}
324
325function drawPlant(cv: Canvas, x: number, y: number) {
326 cv.rect(x + 2, y + 9, 7, 5, 0xb5651d)
327 cv.rect(x + 2, y + 9, 7, 1, 0xca7a32)
328 cv.rect(x + 1, y + 3, 9, 6, 0x3f9b4f)
329 cv.rect(x + 3, y, 5, 3, 0x56b866)
330 cv.set(x + 2, y + 4, 0x6fcf7f)
331 cv.set(x + 7, y + 2, 0x6fcf7f)
332}
333
334// ---------- 整個場景 ----------
335
336export type Desk = { helper: Helper; action: Action; look: Look }
337
338export function drawOffice(desks: Desk[], cols: number, tick: number): Canvas {
339 const rows = Math.max(1, Math.ceil(Math.max(desks.length, 1) / cols))
340 const cv = new Canvas(cols * CELL_W + SIDE, WALL + rows * CELL_H)
341 drawRoom(cv, cols)
342
343 const copierX = cols * CELL_W + 8
344 const copierY = WALL + 4
345 const someoneCopying = desks.some(d => d.action === 'copier' && walkPhase(tick) === 1)
346 drawCopier(cv, copierX, copierY, someoneCopying, tick)
347 drawCoffeeMachine(cv, copierX + 2, copierY + 20)
348 drawPlant(cv, copierX + 4, cv.h - 15)
349
350 const walkers: { desk: Desk; cx: number; cy: number }[] = []
351 for (let i = 0; i < Math.max(desks.length, cols); i++) {
352 const cx = (i % cols) * CELL_W
353 const cy = WALL + Math.floor(i / cols) * CELL_H
354 const d = desks[i]
355 if (!d) {
356 drawChair(cv, cx, cy)
357 drawDesk(cv, cx, cy)
358 drawMonitor(cv, cx, cy, 'empty', tick)
359 continue
360 }
361 drawChair(cv, cx, cy)
362 if (d.action === 'copier') walkers.push({ desk: d, cx, cy })
363 else drawSeated(cv, cx, cy, d, tick)
364 drawDesk(cv, cx, cy)
365 drawMonitor(cv, cx, cy, d.action, tick)
366 drawHands(cv, cx, cy, d, tick)
367 }
368 // 走去影印的人最後畫,才會在桌子前面
369 for (const { desk, cx, cy } of walkers) drawWalker(cv, cx, cy, copierX, copierY, desk, tick)
370 return cv
371}
372
373const PX = 9 // 員工在格子裡的位置
374const PY = 4
375
376function drawSeated(cv: Canvas, cx: number, cy: number, d: Desk, tick: number) {
377 const x = cx + PX
378 const y = cy + PY
379 const blink = tick % 2 === 0
380 const look = d.look
381 if (d.action === 'failed') {
382 drawPerson(cv, x, y + 6, look, true) // 趴下去
383 drawDots(cv, x, y, blink ? [[12, 0, 'Z'], [13, 0, 'Z'], [13, 1, 'Z'], [12, 2, 'Z'], [13, 2, 'Z']] : [[14, -2, 'Z'], [15, -2, 'Z'], [15, -1, 'Z'], [14, 0, 'Z'], [15, 0, 'Z']], look)
384 return
385 }
386 drawPerson(cv, x, y, look, false, d.action === 'done')
387 const dots: Dot[] = []
388 switch (d.action) {
389 case 'phone':
390 // 左手拿話筒貼耳朵
391 dots.push([0, 4, 'R'], [0, 5, 'R'], [0, 6, 'R'], [-1, 6, 'R'], [1, 4, 'R'], [-1, 7, 'S'], [-1, 8, 'S'], [-1, 9, 'J'], [-1, 10, 'J'], [0, 9, 'J'])
392 if (blink) dots.push([-6, 0, 'M'], [-5, 0, 'M'], [-4, 0, 'M'], [-6, 1, 'M'], [-5, 1, 'P'], [-4, 1, 'M'], [-3, 2, 'M'])
393 else dots.push([-6, 1, 'M'], [-5, 1, 'M'], [-4, 1, 'M'], [-6, 2, 'M'], [-5, 2, 'P'], [-4, 2, 'M'], [-3, 3, 'M'])
394 break
395 case 'coffee':
396 // 端著馬克杯,杯子冒煙
397 dots.push([-2, 9, 'M'], [-1, 9, 'M'], [0, 9, 'M'], [-3, 10, 'M'], [-2, 10, 'M'], [-1, 10, 'M'], [0, 10, 'M'], [-2, 11, 'M'], [-1, 11, 'M'], [0, 11, 'M'], [1, 11, 'S'], [2, 11, 'S'])
398 dots.push(blink ? [-1, 7, 'V'] : [-2, 7, 'V'], blink ? [-2, 6, 'V'] : [-1, 6, 'V'], blink ? [-1, 5, 'V'] : [-2, 5, 'V'])
399 break
400 case 'thinking':
401 // 手托下巴,頭上冒泡泡
402 dots.push([5, 9, 'S'], [6, 9, 'S'], [5, 10, 'J'], [6, 10, 'J'], [12, 1, 'M'], [13, -1, 'M'])
403 if (blink) dots.push([14, -4, 'M'], [15, -4, 'M'], [16, -4, 'M'], [14, -3, 'M'], [15, -3, 'M'], [16, -3, 'M'], [17, -3, 'M'], [15, -5, 'M'])
404 break
405 case 'done':
406 // 兩手舉高
407 dots.push([-1, 3, 'S'], [-1, 4, 'S'], [-1, 5, 'J'], [-1, 6, 'J'], [-1, 7, 'J'], [-1, 8, 'J'], [12, 3, 'S'], [12, 4, 'S'], [12, 5, 'J'], [12, 6, 'J'], [12, 7, 'J'], [12, 8, 'J'])
408 if (blink) dots.push([-2, 2, 'S'], [13, 2, 'S'])
409 break
410 }
411 drawDots(cv, x, y, dots, look)
412}
413
414function drawHands(cv: Canvas, cx: number, cy: number, d: Desk, tick: number) {
415 if (d.action !== 'typing') return
416 const skin = d.look.skin
417 const up = tick % 2 === 0
418 cv.rect(cx + (up ? 12 : 13), cy + 18, 2, 1, skin)
419 cv.rect(cx + (up ? 19 : 18), cy + 18, 2, 1, skin)
420}
421
422// 去影印:0.33 → 0.66 → 到了 → 到了 → 0.66 → 0.33
423const PHASES = [0.33, 0.66, 1, 1, 0.66, 0.33]
424function walkPhase(tick: number): number {
425 return PHASES[tick % SLOT] ?? 0
426}
427
428function drawWalker(cv: Canvas, cx: number, cy: number, copierX: number, copierY: number, d: Desk, tick: number) {
429 const t = walkPhase(tick)
430 const fromX = cx + PX
431 const fromY = cy + 12
432 const toX = copierX - 16
433 const toY = copierY - 2
434 const x = Math.round(fromX + (toX - fromX) * t)
435 const y = Math.round(fromY + (toY - fromY) * t)
436 const step = tick % 2 === 0
437 drawPerson(cv, x, y, d.look)
438 drawGrid(cv, x, y + PERSON_H, step ? LEGS_A : LEGS_B, d.look)
439 // 手臂擺動
440 drawDots(cv, x, y, step ? [[-1, 10, 'J'], [-1, 11, 'J'], [-1, 12, 'S'], [12, 11, 'J'], [12, 12, 'J'], [12, 13, 'S']] : [[-1, 11, 'J'], [-1, 12, 'J'], [-1, 13, 'S'], [12, 10, 'J'], [12, 11, 'J'], [12, 12, 'S']], d.look)
441 if (t < 1 && step) drawDots(cv, x, y, [[13, 2, 'D'], [13, 3, 'D'], [14, 4, 'D']], d.look) // 急到流汗
442}
443
444// ---------- 輸出 ----------
445
446const HALF_BLOCK = 0x2580
447
448// 終端機:一格放上下兩個像素
449export function toRaster(cv: Canvas): { columns: number; rows: number; cells: string } {
450 const rows = Math.ceil(cv.h / 2)
451 const words = new Uint32Array(cv.w * rows * 3)
452 let i = 0
453 for (let r = 0; r < rows; r++) {
454 for (let x = 0; x < cv.w; x++) {
455 words[i++] = HALF_BLOCK
456 words[i++] = cv.get(x, r * 2)
457 words[i++] = r * 2 + 1 < cv.h ? cv.get(x, r * 2 + 1) : cv.get(x, r * 2)
458 }
459 }
460 return { columns: cv.w, rows, cells: toBase64(new Uint8Array(words.buffer)) }
461}
462
463// 桌面版:同顏色的橫向連續像素合成一段路徑,SVG 才不會太大
464export function toSvg(cv: Canvas): string {
465 const paths = new Map<number, string[]>()
466 for (let y = 0; y < cv.h; y++) {
467 let x = 0
468 while (x < cv.w) {
469 const c = cv.get(x, y)
470 let end = x + 1
471 while (end < cv.w && cv.get(end, y) === c) end++
472 if (c !== FLOOR) {
473 const list = paths.get(c) ?? []
474 list.push(`M${x} ${y}h${end - x}v1h-${end - x}z`)
475 paths.set(c, list)
476 }
477 x = end
478 }
479 }
480 const body = [...paths].map(([c, d]) => `<path fill="${hex(c)}" d="${d.join('')}"/>`).join('')
481 return (
482 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${cv.w} ${cv.h}" shape-rendering="crispEdges">` +
483 `<rect width="${cv.w}" height="${cv.h}" fill="${hex(FLOOR)}"/>${body}</svg>`
484 )
485}
486
487export function hex(c: number): string {
488 return `#${c.toString(16).padStart(6, '0')}`
489}
490
491// 調亮(>1)或調暗(<1)一個顏色
492function shade(c: number, k: number): number {
493 const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v * k)))
494 return (ch((c >> 16) & 255) << 16) | (ch((c >> 8) & 255) << 8) | ch(c & 255)
495}
496
497function greyOf(c: number): number {
498 const v = Math.round((((c >> 16) & 255) + ((c >> 8) & 255) + (c & 255)) / 3)
499 return (v << 16) | (v << 8) | v
500}
501
502function pick<T>(list: readonly T[], n: number): T {
503 return list[n % list.length]!
504}
505
506// FNV-1a
507function hash(s: string): number {
508 let n = 0x811c9dc5
509 for (let i = 0; i < s.length; i++) {
510 n ^= s.charCodeAt(i)
511 n = Math.imul(n, 0x01000193) >>> 0
512 }
513 return n
514}
515
516const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
517
518export function toBase64(bytes: Uint8Array): string {
519 let out = ''
520 for (let i = 0; i < bytes.length; i += 3) {
521 const a = bytes[i]!
522 const b = bytes[i + 1]
523 const c = bytes[i + 2]
524 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
525 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
526 out += b === undefined ? '=' : B64[(n >> 6) & 63]!
527 out += c === undefined ? '=' : B64[n & 63]!
528 }
529 return out
530}
531types/index.d.ts 18 lines1export type HelperStatus = 'running' | 'done' | 'failed'
2export type Helper = {
3 id: string
4 name: string
5 type: string
6 task: string
7 startedAt: number
8 endedAt: number | null
9 status: HelperStatus
10 tools: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'pixel-helpers': { helpers: Helper[]; now: number }
16 }
17}
18