SLOPSHOPPER

pixel-helpers

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

newpanebandguardcommandtimer
v0.3.0no licenseupdated 2026-10-09mikekuo0725/usage-band/pixel-helpers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-helpers
│ ┃ 小幫手 ✕ › fix the failing auth test and add an audit log call │ ┃ 辦公室還空著,派小幫手出去時他們會來上班 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /helpers │ ┃ ⎿ pixel-helpers: 小幫手面板打開了。 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 小幫手
辦公室還空著,派小幫手出去時他們會來上班 ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
README

usage-band

A collection of Claude Code plugins (mods) that add live status displays to the interface.

usage-band

Displays session usage above the prompt: 5-hour and 7-day rate-limit consumption with reset countdowns, token counts, and session cost.

usage-band

  • Context line: context window utilization, with a trend chart of recent turns
  • 5h / 7d: rate-limit utilization and time remaining until reset
  • ↑ / ↓: input and output tokens for the current session
  • ≈: cached tokens
  • $: accumulated cost for the current session

pixel-helpers

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.

pixel-helpers

  • Pane: opens by itself when a helper starts, or with /helpers
  • Above the prompt: while helpers work, a line shows how many are running and their names
  • Status: running helpers show time worked and tools used; finished ones show the total time

Install

claude plugin marketplace add mikekuo0725/usage-band
claude plugin install usage-band@usage-band
claude plugin install pixel-helpers@usage-band

usage-band(中文)

本專案收錄 Claude Code 外掛(mod),為介面加入即時狀態顯示。

usage-band

於輸入框上方即時顯示本次工作階段的用量資訊,包括 5 小時與 7 天額度使用率、重置倒數、token 用量及花費。

usage-band

  • 上下文列:上下文視窗使用率,並以趨勢圖呈現最近幾輪的變化
  • 5h / 7d:額度使用率及距離重置的剩餘時間
  • ↑ / ↓:本次工作階段的輸入與輸出 token 數
  • ≈:快取 token 數
  • $:本次工作階段的累計花費

pixel-helpers

派小幫手時,右邊面板會顯示一間像素辦公室。每位小幫手坐在自己的辦公桌前,隨機打字、講電話、衝去影印、喝咖啡或想事情。做完的小幫手會舉起雙手;被打斷的會變灰、趴在桌上睡著。

pixel-helpers

  • 面板:小幫手出發時自動打開,或用 /helpers 打開
  • 輸入框上方:有小幫手在工作時,會顯示幾位在工作中和他們的名字
  • 狀態:工作中的顯示已工作時間和工具數;做完的顯示總共花多久;被中斷的顯示做了多久

安裝

claude plugin marketplace add mikekuo0725/usage-band
claude plugin install usage-band@usage-band
claude plugin install pixel-helpers@usage-band
Source 5 files
hooks/register.tsx 163 lines
1import { 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}
163
hooks/format.ts 9 lines
1// 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}
9
hooks/helpers.ts 36 lines
1import 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}
36
hooks/office.ts 531 lines
1// 像素辦公室:每位小幫手一張辦公桌,右邊有影印機和咖啡機
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}
531
types/index.d.ts 18 lines
1export 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