SLOPSHOPPER

clawd-hud

Pixel Clawd usage band above the prompt

newbandguardtoastprompttimer
v?no licenseupdated 2026-10-05segfaultlab/clawd-hud
A shopper browsing a rack in a slop shop
README

clawd-hud

Claude Code 桌面端的 mod:在 Code 页面的输入框上方显示一行像素风用量条,带一只会跟着状态做动作的像素 Clawd。

用量条

Clawd 的动作

  • 上下文、5 小时额度、本周额度的格子进度条
  • 5 小时额度的消耗速度,以及能不能撑到重置
  • 本会话花费、上一轮的耗时、输出 token 和缓存命中率
  • Clawd 跟着 Claude 的状态做动作:思考冒 ?、调用工具敲锤子、写文件拿笔写字、读文件和搜索捧书看(读完想下一步时也继续捧着)、等你批准时举手、压缩上下文揉纸团、子代理干活时脚边跟着小 Clawd、有后台命令时脚边亮着小终端、写回复飘字、出错晕倒、中断摔倒、空闲睡觉、额度快用完时冒汗发抖、用完躺平

只在本地读取 Claude Code 已有的用量数据,不额外消耗 token。

只在桌面端的 Code 页面显示,用 SVG 绘制。终端里的 Claude Code 不显示横条,也不弹额度提醒。

安装

claude plugin marketplace add segfaultlab/clawd-hud
claude plugin install clawd-hud@clawd-hud

装好后在桌面端新开一个会话就生效。

更新

建议打开自动更新:在终端里运行 claude,输入 /plugin,进入 Marketplaces,选中 clawd-hud,打开自动更新。之后有新版本时 Claude Code 会自己拉取,重启会话后生效。

不开自动更新的话,手动执行:

claude plugin marketplace update clawd-hud
claude plugin update clawd-hud@clawd-hud

5h 和 wk 两条来自订阅账号的额度数据,用 API key 登录时只显示 ctx。

本地开发

改代码时直接加载源码目录,不用安装:

claude --plugin-dir /path/to/clawd-hud

这时先在 /plugin 里停用已安装的 clawd-hud,否则会出现两条横条。终端里不显示横条,改完用 claude plugin test . 跑测试,用下面的脚本看效果图。

插件没有写版本号,每次提交到 GitHub 都算一个新版本。

改了画图代码后重新生成 README 里的图:

node scripts/preview.ts

文件

  • hooks/register.tsx:事件钩子,读取用量、跟踪状态
  • hooks/draw.ts:生成横条的 SVG(像素 Clawd、进度条、动画)
  • types/index.d.ts:mod 保存的状态类型
  • scripts/preview.ts:生成 docs/ 下的效果图
  • tests/band.test.tsx:桌面端能画出横条、终端不画的测试,用 claude plugin test . 运行
Source 3 files
hooks/register.tsx 332 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Act, Limit, Meter } from '../types'
5import { bandParts, limitName } from './draw'
6
7const meter = atom({ plugin: 'clawd-hud', key: 'meter' } as const, {
8  context: null,
9  rateLimits: [],
10  cost: null,
11  base: {},
12})
13const last = atom({ plugin: 'clawd-hud', key: 'last' } as const, null)
14const act = atom({ plugin: 'clawd-hud', key: 'act' } as const, 'idle')
15const helpers = atom({ plugin: 'clawd-hud', key: 'helpers' } as const, 0)
16const chores = atom({ plugin: 'clawd-hud', key: 'chores' } as const, 0)
17
18const SLEEP_AFTER = 60 * 60000
19
20const PEN_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit'])
21const BOOK_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch'])
22
23const toolAct = (name: string): Act =>
24  PEN_TOOLS.has(name)
25    ? 'writing'
26    : BOOK_TOOLS.has(name)
27      ? 'reading'
28      : name === 'Agent'
29        ? 'delegating'
30        : 'tool'
31
32const LOOK_CMDS = new Set(['cat', 'head', 'tail', 'less', 'grep', 'egrep', 'rg', 'find', 'fd', 'ls', 'tree', 'wc'])
33const PIPE_CMDS = new Set([...LOOK_CMDS, 'sed', 'awk', 'sort', 'uniq', 'cut'])
34
35const bashAct = (cmd: string): Act => {
36  if (/\$\(|`|\n/.test(cmd)) return 'tool'
37  const bare = cmd
38    .replace(/'[^']*'|"[^"]*"/g, '""')
39    .replace(/\d?>&\d|[\d&]?>\s*\/dev\/null/g, '')
40    .replace(/^\s*cd\s+[^&;|]+&&/, '')
41  if (/[;&>`$]/.test(bare)) return 'tool'
42  const words = bare.split('|').map(seg => seg.trim().split(/\s+/)[0] ?? '')
43  return LOOK_CMDS.has(words[0] ?? '') && words.every(w => PIPE_CMDS.has(w)) ? 'reading' : 'tool'
44}
45
46const callAct = (e: { tool: string; command?: unknown }): Act =>
47  e.tool === 'Bash' && typeof e.command === 'string' ? bashAct(e.command) : toolAct(e.tool)
48
49const warned = new Set<string>()
50
51type Reading = Pick<Meter, 'context' | 'rateLimits' | 'cost'>
52
53let gen = 0
54
55const newer = (old: number | null, cost: number | null) => (old != null && cost != null ? Math.max(old, cost) : cost)
56
57async function setMeter($: EngineInterface, r: Reading) {
58  const g = gen
59  const now = await $.clock.now()
60  await update($, meter, old => {
61    const base = { ...old.base }
62    for (const l of r.rateLimits) {
63      const b = base[l.kind]
64      if (!b || b.resetsAt !== l.resetsAt || l.percentUsed < b.p) {
65        base[l.kind] = { t: now, p: l.percentUsed, resetsAt: l.resetsAt }
66      }
67    }
68    return { ...r, cost: g === gen ? newer(old.cost, r.cost) : old.cost, base }
69  })
70  if ((await $.session.surfaces()).includes('desktop')) for (const l of r.rateLimits) warn($, l)
71}
72
73function warn($: EngineInterface, l: Limit) {
74  for (const at of [80, 95]) {
75    const tag = `${l.kind}:${at}:${l.resetsAt ?? ''}`
76    if (l.percentUsed >= at && !warned.has(tag)) {
77      warned.add(tag)
78      $.ui.toast(`${limitName(l)} 额度已用 ${l.percentUsed}%`)
79    }
80  }
81}
82
83type Call = { tool: string; agent?: string; input: string; act: Act; asking: boolean }
84
85const calls = new Map<string, Call>()
86const jobs = new Set<string>()
87let base: Act = 'idle'
88let compacting = 0
89let epoch = 0
90let inTurn = false
91let stepActs: Act[] = []
92let shown: Act | null = null
93let queue: Promise<void> = Promise.resolve()
94let nap: { cancel: () => void } | null = null
95let recount: { cancel: () => void } | null = null
96
97const argsKey = (v: unknown) =>
98  JSON.stringify(v, (_, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort()) : x))
99
100const current = (): Act => {
101  const list = [...calls.values()]
102  if (list.some(c => c.asking)) return 'waiting'
103  if (compacting > 0) return 'compacting'
104  return list.filter(c => !c.agent).at(-1)?.act ?? base
105}
106
107function refresh($: EngineInterface) {
108  queue = queue
109    .then(async () => {
110      const next = current()
111      if (next === shown) return
112      await update($, act, () => next)
113      shown = next
114    })
115    .catch(() => {
116      shown = null
117    })
118  return queue
119}
120
121async function setBase($: EngineInterface, next: Act) {
122  base = next
123  await refresh($)
124}
125
126function scheduleNap($: EngineInterface) {
127  nap?.cancel()
128  nap = $.clock.after(SLEEP_AFTER, () => void setBase($, 'sleep'))
129}
130
131async function countHelpers($: EngineInterface) {
132  const n = (await $.agent.list()).filter(a => a.status === 'running').length
133  await update($, helpers, () => n)
134}
135
136async function countJobs($: EngineInterface) {
137  await update($, chores, () => jobs.size)
138}
139
140function recountSoon($: EngineInterface) {
141  recount?.cancel()
142  recount = $.clock.after(2000, () => void countHelpers($))
143}
144
145async function reset($: EngineInterface) {
146  epoch++
147  base = 'idle'
148  calls.clear()
149  jobs.clear()
150  compacting = 0
151  inTurn = false
152  stepActs = []
153  shown = null
154  await refresh($)
155  await countJobs($)
156  await countHelpers($)
157  scheduleNap($)
158}
159
160export const register: Register = on => {
161  on('session.start', async ($, e, next) => {
162    const g = gen
163    const u = await $.session.usage()
164    if (g === gen) await setMeter($, { context: u.context, rateLimits: u.rateLimits, cost: u.cost?.usd ?? null })
165    await reset($)
166    return next(e)
167  })
168
169  on('session.measure', async ($, e, next) => {
170    await setMeter($, { context: e.context, rateLimits: e.rateLimits, cost: e.cost?.usd ?? null })
171    return next(e)
172  })
173
174  on('session.end', async ($, e, next) => {
175    if (e.reason === 'clear' || e.reason === 'resume') {
176      gen++
177      await update($, meter, m => ({ ...m, cost: null }))
178      await reset($)
179    }
180    return next(e)
181  })
182
183  on('tool.call', async ($, e, next) => {
184    const { tool, tool_use_id, agentId, consent, ...args } = e
185    const ep = epoch
186    try {
187      const a = callAct(e)
188      if (!agentId) stepActs.push(a)
189      calls.set(tool_use_id, { tool, agent: agentId, input: argsKey(args), act: a, asking: tool === 'AskUserQuestion' })
190      await refresh($)
191      const r = await next(e)
192      if (!agentId && tool === 'Bash' && args.run_in_background === true && !('deny' in r) && !('isError' in r && r.isError) && ep === epoch) {
193        jobs.add(tool_use_id)
194        await countJobs($)
195      }
196      return r
197    } finally {
198      calls.delete(tool_use_id)
199      await refresh($)
200    }
201  })
202
203  on('classic.PermissionRequest', async ($, e, next) => {
204    const key = argsKey(e.tool_input)
205    const open = [...calls.values()].filter(c => c.tool === e.tool_name && c.agent === e.agent_id && !c.asking)
206    const exact = open.filter(c => c.input === key)
207    const hit = exact.length === 1 ? exact[0] : exact.length === 0 && open.length === 1 ? open[0] : undefined
208    if (hit) {
209      hit.asking = true
210      await refresh($)
211    }
212    return next(e)
213  })
214
215  on('session.compact', async ($, e, next) => {
216    if (e.agentId || e.trigger === 'precompute') return next(e)
217    nap?.cancel()
218    if (base === 'sleep') base = 'idle'
219    const ep = epoch
220    compacting++
221    try {
222      await refresh($)
223      return await next(e)
224    } finally {
225      if (ep === epoch) {
226        compacting--
227        await refresh($)
228        if (!inTurn) scheduleNap($)
229      }
230    }
231  })
232
233  on('classic.SubagentStart', async ($, e, next) => {
234    const r = await next(e)
235    await countHelpers($)
236    recountSoon($)
237    return r
238  })
239
240  on('classic.SubagentStop', async ($, e, next) => {
241    const r = await next(e)
242    await countHelpers($)
243    recountSoon($)
244    return r
245  })
246
247  on('classic.Stop', async ($, e, next) => {
248    if (e.background_tasks && !e.background_tasks.some(t => t.type === 'shell')) {
249      jobs.clear()
250      await countJobs($)
251    }
252    await countHelpers($)
253    return next(e)
254  })
255
256  on('prompt.submit', async ($, e, next) => {
257    if (e.origin.kind === 'task-notification') {
258      const id = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(e.text)?.[1]
259      if (id && jobs.delete(id)) await countJobs($)
260    }
261    return next(e)
262  })
263
264  on('turn.start', async ($, e, next) => {
265    nap?.cancel()
266    inTurn = true
267    stepActs = []
268    await setBase($, 'thinking')
269    await countHelpers($)
270    return next(e)
271  })
272
273  on('turn.step', async function* ($, e, next) {
274    if (e.agentId) return yield* next(e)
275    let mulling: Act = stepActs.length > 0 && stepActs.every(a => a === 'reading') ? 'reading' : 'thinking'
276    stepActs = []
277    await setBase($, mulling)
278    for await (const c of next(e)) {
279      if (c.kind === 'thinking') await setBase($, mulling)
280      if (c.kind === 'tool' || c.kind === 'text') mulling = 'thinking'
281      if (c.kind === 'tool') await setBase($, toolAct(c.name))
282      if (c.kind === 'text') await setBase($, 'responding')
283      yield c
284    }
285  })
286
287  on('turn.complete', async ($, e, next) => {
288    if (e.agentId) {
289      const g = gen
290      const cost = (await $.session.usage()).cost?.usd ?? null
291      await update($, meter, m => (g === gen ? { ...m, cost: newer(m.cost, cost) } : m))
292      return next(e)
293    }
294    if (e.usage) {
295      const at = await $.clock.now()
296      const u = e.usage
297      await update($, last, () => ({
298        at,
299        input: u.input_tokens,
300        output: u.output_tokens,
301        cacheRead: u.cache_read_input_tokens,
302        cacheWrite: u.cache_creation_input_tokens,
303        ms: e.durationMs,
304      }))
305    }
306    inTurn = false
307    await countHelpers($)
308    await setBase($, e.reason === 'error' ? 'error' : e.reason === 'aborted' ? 'aborted' : 'idle')
309    scheduleNap($)
310    return next(e)
311  })
312
313  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
314    if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
315    const m = await read($, meter)
316    const t = await read($, last)
317    const a = await read($, act)
318    const crew = await read($, helpers)
319    const shells = await read($, chores)
320
321    const { Box, Svg } = $.ui.resolve(e)
322    const parts = bandParts(m, t, await $.clock.now(), e.props.isWorking, a, crew, shells)
323    return (
324      <Box flexDirection="row" flexWrap="wrap" alignItems="center" justifyContent="space-between" width="100%">
325        {parts.map(p => (
326          <Svg source={p.source} width={p.width} height={p.height} alt={p.alt} />
327        ))}
328      </Box>
329    )
330  })
331}
332
hooks/draw.ts 334 lines
1import type { Act, Limit, Meter, TurnRecord } from '../types'
2
3export const fmt = (n: number) =>
4  n < 1000 ? `${n}` : n < 1e6 ? `${(n / 1000).toFixed(1)}k` : `${(n / 1e6).toFixed(2)}M`
5
6const span = (ms: number) => {
7  const mins = Math.max(0, Math.round(ms / 60000))
8  const d = Math.floor(mins / 1440)
9  const h = Math.floor((mins % 1440) / 60)
10  const m = mins % 60
11  return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : `${m}m`
12}
13
14const dur = (ms: number) => {
15  const s = Math.round(ms / 1000)
16  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${s % 60}s` : span(ms)
17}
18
19const usd = (n: number) => (n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`)
20
21const LIMIT_NAMES: Record<string, string> = {
22  five_hour: '5h',
23  seven_day: 'wk',
24  spend_limit: 'spend',
25}
26
27export const limitName = (l: Limit) => LIMIT_NAMES[l.kind] ?? l.kind
28
29export type Drawing = { source: string; width: number; height: number; alt: string }
30
31const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
32
33const textWidth = (s: string, size: number) =>
34  [...s].reduce((w, ch) => w + (/[⺀-￿]/.test(ch) ? size : size * 0.6), 0)
35
36const CLAWD = '#D77757'
37
38const STYLE = `<style>
39svg{--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#d6d5ce;--track:rgba(11,11,11,.09);
40--s1:#2a78d6;--s4:#eda100;--good:#0ca30c;--warn:#e09a00;--crit:#d03b3b;--goodt:#006300;--warnt:#9a6a00;
41font-family:ui-monospace,"SF Mono",Menlo,monospace}
42@media (prefers-color-scheme:dark){svg{--ink:#fff;--ink2:#c3c2b7;--grid:#383835;--track:rgba(255,255,255,.10);
43--s1:#3987e5;--s4:#c98500;--warn:#fab219;--goodt:#0ca30c;--warnt:#fab219}}
44text{fill:var(--ink)}.t2{fill:var(--ink2)}.mu{fill:var(--muted)}.v{font-size:13px;font-weight:700}
45.good{fill:var(--goodt)}.warn{fill:var(--warnt)}.crit{fill:var(--crit)}
46.pop{opacity:0;animation:pop 1ms linear forwards}@keyframes pop{to{opacity:1}}
47.bc{animation:bc 1s steps(1) infinite}@keyframes bc{50%{opacity:.2}}
48.bob{animation:bob 1.2s steps(1) infinite}.walk{animation:bob .32s steps(1) infinite}
49@keyframes bob{50%{transform:translateY(calc(var(--u) * -1))}}
50.legA{animation:legA .32s steps(1) infinite}@keyframes legA{50%{opacity:0}}
51.legB{animation:legB .32s steps(1) infinite}@keyframes legB{0%{opacity:0}50%{opacity:1}}
52.blink{opacity:0;animation:blink 4s steps(1) infinite}@keyframes blink{0%{opacity:0}92%{opacity:1}96%{opacity:0}}
53.sweat{animation:sweat 1.6s steps(4) infinite}@keyframes sweat{0%,25%{transform:none;opacity:1}100%{transform:translateY(calc(var(--u) * 3));opacity:0}}
54.kickA{animation:legA .6s steps(1) infinite}.kickB{animation:legB .6s steps(1) infinite}
55.zz{opacity:0;animation:zz 2.4s steps(6) infinite}@keyframes zz{0%{opacity:0;transform:none}15%{opacity:1}100%{opacity:0;transform:translate(calc(var(--u) * 3),calc(var(--u) * -4))}}
56.jump{animation:jump .5s steps(3) 3,bob 1.2s steps(1) 1.5s infinite}@keyframes jump{50%{transform:translateY(calc(var(--u) * -3))}}
57.spark{transform-box:fill-box;transform-origin:center;opacity:0;animation:spark .8s steps(4) 2}
58@keyframes spark{0%{opacity:0;transform:scale(.4)}50%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(1.4)}}
59.shake{animation:shake .2s steps(1) infinite}@keyframes shake{50%{transform:translateX(calc(var(--u) * .5))}}
60.glint{opacity:0;animation:glint 3.5s steps(var(--n)) infinite}
61@keyframes glint{0%{opacity:.55;transform:none}40%{opacity:.55;transform:translateX(var(--d))}41%,100%{opacity:0;transform:translateX(var(--d))}}
62.tw{transform-box:fill-box;transform-origin:center;animation:tw 1.6s steps(2) infinite}@keyframes tw{50%{transform:scale(.4);opacity:.5}}
63.think{animation:bob 2s steps(1) infinite}
64.q{animation:q 1.4s steps(1) infinite}@keyframes q{50%{transform:translateY(calc(var(--u) * -1))}}
65.hamA{animation:legA .5s steps(1) infinite}.hamB{animation:legB .5s steps(1) infinite}
66.ch{opacity:0;animation:ch 1.2s steps(4) infinite}@keyframes ch{0%{opacity:1;transform:none}100%{opacity:0;transform:translate(var(--u),calc(var(--u) * -5))}}
67.sway{animation:sway 1.2s steps(1) infinite}@keyframes sway{50%{transform:translateX(calc(var(--u) * .5))}}
68.orbit{animation:orbit 1.2s steps(1) infinite}
69@keyframes orbit{0%{transform:none}25%{transform:translate(calc(var(--u) * 3),var(--u))}50%{transform:translate(calc(var(--u) * 6),0px)}75%{transform:translate(calc(var(--u) * 3),calc(var(--u) * -1))}}
70.fall{transform-box:fill-box;transform-origin:bottom;animation:fall 1.4s steps(4) 1}
71@keyframes fall{0%{transform:none}15%,65%{transform:scaleY(.55)}100%{transform:none}}
72.bang{opacity:0;animation:bang 1.6s steps(1) 1}@keyframes bang{0%,70%{opacity:1}100%{opacity:0}}
73.penx{animation:penx 2s steps(4) infinite}@keyframes penx{0%{transform:none}80%,100%{transform:translateX(calc(var(--u) * 4))}}
74.ink{transform-box:fill-box;transform-origin:left;animation:ink 2s steps(4) infinite}@keyframes ink{0%{transform:scaleX(0)}80%,100%{transform:scaleX(1)}}
75.scrib{animation:bob .16s steps(1) infinite}
76.flip{transform-box:fill-box;transform-origin:calc(var(--u) * -.5) 0;opacity:0;animation:flip 3s steps(1) infinite}
77@keyframes flip{0%,70%{opacity:0;transform:none}72%{opacity:1;transform:none}80%{opacity:1;transform:scaleX(.4)}88%{opacity:1;transform:scaleX(-.4)}96%{opacity:1;transform:scaleX(-1)}100%{opacity:0;transform:scaleX(-1)}}
78.wave{animation:wave .4s steps(1) infinite}@keyframes wave{50%{transform:translateX(var(--u))}}
79.dot{opacity:.25;animation:dot 1.2s steps(1) infinite}@keyframes dot{0%,40%{opacity:1}41%,100%{opacity:.25}}
80.squash{transform-box:fill-box;transform-origin:bottom;animation:breathe .6s steps(2) infinite}
81.crush{transform-box:fill-box;transform-origin:center;animation:crush 1.5s steps(3) infinite}@keyframes crush{to{transform:scale(.3)}}
82.breathe{transform-box:fill-box;transform-origin:bottom;animation:breathe 2.4s steps(2) infinite}@keyframes breathe{50%{transform:scaleY(.88)}}
83</style>`
84
85const cellsPath = (cells: readonly (readonly [number, number])[], x: number, y: number, w: number, h: number) =>
86  cells.map(([cx, cy]) => `M${x + cx * w} ${y + cy * h}h${w}v${h}h${-w}z`).join('')
87
88const BODY: [number, number][] = []
89for (let cx = 3; cx <= 14; cx++) BODY.push([cx, 0])
90for (let cx = 3; cx <= 14; cx++) if (cx !== 5 && cx !== 12) BODY.push([cx, 1])
91for (let cx = 1; cx <= 16; cx++) BODY.push([cx, 2])
92for (let cx = 3; cx <= 14; cx++) BODY.push([cx, 3])
93const EYES: [number, number][] = [[5, 1], [12, 1]]
94const LEGS_A: [number, number][] = [[4, 4], [6, 4], [11, 4], [13, 4]]
95const LEGS_B: [number, number][] = [[5, 4], [7, 4], [10, 4], [12, 4]]
96
97type Pose = { mode: Act | 'flat'; level: number; celebrate: boolean }
98
99const flip = (cells: [number, number][]) => cells.map(([cx, cy]) => [cx, 4 - cy] as [number, number])
100
101const glyphCells = (rows: string[]) => {
102  const cells: [number, number][] = []
103  rows.forEach((row, ry) => [...row].forEach((bit, rx) => bit === '1' && cells.push([rx, ry])))
104  return cells
105}
106
107const Z = glyphCells(['111', '001', '010', '100', '111'])
108const Q = glyphCells(['111', '001', '011', '000', '010'])
109const BANG = glyphCells(['1', '1', '1', '0', '1'])
110const DROP = glyphCells(['00100', '00100', '01110', '11111', '11111', '11111', '01110'])
111
112const zzz = (u: number) =>
113  [0, 0.8, 1.6]
114    .map((d, i) => `<path class="zz" style="animation-delay:${d}s" d="${cellsPath(Z, 16 * u + i * u, -u - i * u, u * 0.8, u * 0.8)}" fill="var(--ink2)"/>`)
115    .join('')
116
117const extras = (mode: Pose['mode'], u: number) => {
118  if (mode === 'thinking') {
119    return `<path class="q" d="${cellsPath(Q, 15 * u, -4 * u, u * 0.8, u * 0.8)}" fill="var(--ink2)"/>`
120  }
121  if (mode === 'tool') {
122    return `<g class="hamA"><rect x="${17 * u}" y="${-2 * u}" width="${u}" height="${6 * u}" fill="#8a5a3c"/><rect x="${16 * u}" y="${-4 * u}" width="${3 * u}" height="${2 * u}" fill="var(--muted)"/></g>
123<g class="hamB"><rect x="${17 * u}" y="${3 * u}" width="${5 * u}" height="${u}" fill="#8a5a3c"/><rect x="${21 * u}" y="${u}" width="${2 * u}" height="${4 * u}" fill="var(--muted)"/><rect x="${24 * u}" y="0" width="${u}" height="${u}" fill="var(--s4)"/><rect x="${24 * u}" y="${5 * u}" width="${u}" height="${u}" fill="var(--s4)"/></g>`
124  }
125  if (mode === 'writing') {
126    return `<rect x="${18 * u}" y="${6 * u}" width="${6 * u}" height="${4 * u}" fill="var(--grid)"/><rect class="ink" x="${19 * u}" y="${8 * u}" width="${4 * u}" height="${u}" fill="var(--ink2)"/>
127<g class="penx"><g class="scrib"><rect x="${19 * u}" y="${8 * u}" width="${u}" height="${u}" fill="var(--ink2)"/><path d="${cellsPath([[20, 7], [21, 6]], 0, 0, u, u)}" fill="var(--s4)"/><rect x="${22 * u}" y="${5 * u}" width="${u}" height="${u}" fill="var(--crit)"/></g></g>`
128  }
129  if (mode === 'reading') {
130    const lines = [18.5, 22.5].flatMap(px => [4, 6].map(py => `<rect x="${px * u}" y="${py * u}" width="${2 * u}" height="${u / 2}" fill="var(--muted)"/>`)).join('')
131    return `<rect x="${17 * u}" y="${4 * u}" width="${9 * u}" height="${5 * u}" fill="var(--s1)"/><rect x="${18 * u}" y="${3 * u}" width="${3 * u}" height="${5 * u}" fill="#f4f1e8"/><rect x="${22 * u}" y="${3 * u}" width="${3 * u}" height="${5 * u}" fill="#f4f1e8"/>${lines}<rect class="flip" x="${22 * u}" y="${3 * u}" width="${3 * u}" height="${5 * u}" fill="#e2ddcf"/>`
132  }
133  if (mode === 'waiting') {
134    const dots = [20, 22, 24].map((dx, i) => `<rect class="dot" style="animation-delay:${i * 0.4}s" x="${dx * u}" y="${-3 * u}" width="${u}" height="${u}" fill="var(--ink2)"/>`).join('')
135    return `<rect x="${17 * u}" y="${-u}" width="${u}" height="${7 * u}"/><g class="wave"><rect x="${16 * u}" y="${-2 * u}" width="${u}" height="${u}"/><rect x="${18 * u}" y="${-2 * u}" width="${u}" height="${u}"/></g>${dots}`
136  }
137  if (mode === 'compacting') {
138    return `<g class="crush"><rect x="${18 * u}" y="${3 * u}" width="${6 * u}" height="${6 * u}" fill="#f4f1e8"/>${[4, 6].map(py => `<rect x="${19 * u}" y="${py * u}" width="${4 * u}" height="${u / 2}" fill="var(--muted)"/>`).join('')}</g>`
139  }
140  if (mode === 'responding') {
141    return [0, 0.4, 0.8]
142      .map((d, i) => `<rect class="ch" style="animation-delay:${d}s" x="${(16 + i) * u}" y="${u}" width="${i === 1 ? u : 2 * u}" height="${u}" fill="var(--ink2)"/>`)
143      .join('')
144  }
145  if (mode === 'error') {
146    return [0, 0.4, 0.8]
147      .map(d => `<rect class="orbit" style="animation-delay:-${d}s" x="${5 * u}" y="${-3 * u}" width="${u}" height="${u}" fill="var(--s4)"/>`)
148      .join('')
149  }
150  if (mode === 'aborted') {
151    return `<path class="bang" d="${cellsPath(BANG, 17 * u, -5 * u, u, u)}" fill="var(--crit)"/>`
152  }
153  if (mode === 'sleep') return zzz(u)
154  return ''
155}
156
157const clawd = (x: number, y: number, u: number, p: Pose) => {
158  const h = u * 2
159  const P = (cells: [number, number][], cls = '') => `<path${cls ? ` class="${cls}"` : ''} d="${cellsPath(cells, 0, 0, u, h)}"/>`
160  let body: string
161  if (p.mode === 'flat') {
162    body = `${P(flip(BODY))}${P(flip(EYES))}${P(flip(LEGS_A), 'kickA')}${P(flip(LEGS_B), 'kickB')}${zzz(u)}`
163  } else {
164    const moving = p.mode === 'tool' || p.mode === 'responding'
165    const legs = moving ? `${P(LEGS_A, 'legA')}${P(LEGS_B, 'legB')}` : P(LEGS_A)
166    const eyesShut = p.mode === 'sleep' || p.mode === 'error'
167    const eyes = P(EYES, eyesShut ? '' : 'blink')
168    const cls = p.celebrate
169      ? 'jump'
170      : ({ tool: 'walk', writing: 'think', reading: 'think', delegating: 'bob', waiting: 'bob', compacting: 'squash', responding: 'walk', thinking: 'think', error: 'sway', aborted: 'fall', sleep: 'breathe', idle: 'bob' } as const)[p.mode]
171    body = `<g class="${cls}">${P(BODY)}${eyes}${legs}</g>${extras(p.mode, u)}`
172    if (p.level >= 80 && p.mode !== 'sleep') {
173      body += `<g class="sweat"><path d="${cellsPath(DROP, -u, -2 * u, u / 2, u / 2)}" fill="var(--s1)"/><rect x="${-u / 2}" y="${-u / 2}" width="${u / 2}" height="${u / 2}" fill="#fff"/></g>`
174    }
175    if (p.celebrate) {
176      const spots: [number, number][] = [[-2, -1], [19, -1], [-1, 4], [18, 4]]
177      body += spots
178        .map(([sx, sy], i) => `<path class="spark" style="animation-delay:${i * 0.1}s" fill="var(--s4)" d="M${sx * u} ${sy * u}h${u}v${-u}h${u}v${u}h${u}v${u}h${-u}v${u}h${-u}v${-u}h${-u}z"/>`)
179        .join('')
180    }
181    if (p.level >= 90 && p.mode !== 'sleep') body = `<g class="shake">${body}</g>`
182  }
183  return `<g transform="translate(${x} ${y})" style="--u:${u}px" fill="${CLAWD}">${body}</g>`
184}
185
186const helper = (x: number, y: number, u: number) => {
187  const P = (cells: [number, number][], cls: string) => `<path class="${cls}" d="${cellsPath(cells, 0, 0, u, u * 2)}"/>`
188  return `<g transform="translate(${x} ${y})" style="--u:${u}px" fill="${CLAWD}"><g class="walk"><path d="${cellsPath(BODY, 0, 0, u, u * 2)}"/>${P(EYES, 'blink')}${P(LEGS_A, 'legA')}${P(LEGS_B, 'legB')}</g></g>`
189}
190
191const terminal = (x: number, y: number) =>
192  `<rect x="${x}" y="${y}" width="14" height="8" fill="var(--muted)"/><rect x="${x + 1}" y="${y + 1}" width="12" height="6" fill="#1d1d1b"/><path d="M${x + 2} ${y + 2}h1v1h1v1h-1v1h-1z" fill="#0ca30c"/><rect class="bc" x="${x + 5}" y="${y + 4}" width="3" height="1" fill="#0ca30c"/>`
193
194const level = (p: number) => (p >= 90 ? 'crit' : p >= 75 ? 'warn' : 'good')
195
196const filledCells = (p: number, n: number) => (p > 0 ? Math.max(1, Math.round((Math.min(100, p) / 100) * n)) : 0)
197
198const pixBar = (x: number, y: number, n: number, cw: number, ch: number, gap: number, p: number, delay = 0) => {
199  const filled = filledCells(p, n)
200  const fill = `var(--${level(p)})`
201  const out: string[] = []
202  for (let i = 0; i < n; i++) {
203    const rx = x + i * (cw + gap)
204    out.push(`<rect x="${rx}" y="${y}" width="${cw}" height="${ch}" fill="var(--track)"/>`)
205    if (i < filled) {
206      const cell = `<rect class="pop" style="animation-delay:${delay + i * 35}ms" x="${rx}" y="${y}" width="${cw}" height="${ch}" fill="${fill}"/>`
207      out.push(i === filled - 1 && p >= 90 ? `<g class="bc">${cell}</g>` : cell)
208    }
209  }
210  if (filled >= 2) {
211    out.push(`<rect class="glint" style="--n:${filled - 1};--d:${(filled - 1) * (cw + gap)}px;animation-delay:${delay + 1500}ms" x="${x}" y="${y}" width="${cw}" height="${ch}" fill="#fff"/>`)
212  }
213  return out.join('')
214}
215
216type Gauge = { name: string; p: number }
217
218const gauges = (m: Meter): Gauge[] => {
219  const list: Gauge[] = []
220  if (m.context?.percent !== undefined) list.push({ name: 'ctx', p: m.context.percent })
221  for (const l of m.rateLimits.slice(0, 2)) list.push({ name: limitName(l), p: l.percentUsed })
222  return list
223}
224
225type Pace = { l: Limit; reset: number; perHour?: number; exhaust?: number }
226
227const pace = (m: Meter, kind: string, now: number): Pace | null => {
228  const l = m.rateLimits.find(x => x.kind === kind)
229  if (!l) return null
230  const b = m.base[kind]
231  const reset = l.resetsAt ? Date.parse(l.resetsAt) : Infinity
232  if (!b || now - b.t < 10 * 60000 || l.percentUsed <= b.p) return { l, reset }
233  const perHour = (l.percentUsed - b.p) / ((now - b.t) / 3600000)
234  return { l, reset, perHour, exhaust: now + ((100 - l.percentUsed) / perHour) * 3600000 }
235}
236
237const hitRate = (t: TurnRecord) => {
238  const all = t.input + t.cacheRead + t.cacheWrite
239  return all === 0 ? 0 : Math.round((t.cacheRead / all) * 100)
240}
241
242const twinkle = (x: number, y: number) =>
243  `<path class="tw" fill="var(--s4)" d="M${x + 2} ${y}h2v2h2v2h-2v2h-2v-2h-2v-2h2z"/>`
244
245export type Tone = 'good' | 'warn' | 'crit' | ''
246
247type Stat = { label: string; value: string; tone: Tone; sep?: boolean; sparkle?: boolean }
248
249const stats = (m: Meter, last: TurnRecord | null, now: number): Stat[] => {
250  const list: Stat[] = []
251  const empty = m.rateLimits.find(l => l.percentUsed >= 100)
252  const p = pace(m, 'five_hour', now)
253  if (empty) {
254    list.push({ label: `${limitName(empty)} 用完·恢复`, value: empty.resetsAt ? span(Date.parse(empty.resetsAt) - now) : '-', tone: 'crit' })
255  } else if (p) {
256    const name = limitName(p.l)
257    const toReset = p.l.resetsAt ? span(p.reset - now) : '-'
258    if (p.perHour === undefined || p.exhaust === undefined) {
259      list.push({ label: `${name} 距重置`, value: toReset, tone: '' })
260    } else {
261      const leftMs = p.exhaust - now
262      list.push({ label: `${name} 速度`, value: `${p.perHour.toFixed(1)}%/h`, tone: '' })
263      list.push(
264        p.exhaust >= p.reset
265          ? { label: '撑到重置', value: toReset, tone: 'good' }
266          : { label: '预计用完', value: span(leftMs), tone: leftMs < 3600000 ? 'crit' : 'warn' },
267      )
268    }
269  }
270  if (list[0]) list[0].sep = true
271  if (m.cost != null) list.push({ label: '本会话', value: usd(m.cost), tone: '', sep: true })
272  if (!last) {
273    list.push({ label: '上轮', value: '-', tone: '', sep: true })
274    return list
275  }
276  const turn: Stat[] = [
277    ...(last.ms !== undefined ? [{ label: '上轮', value: dur(last.ms), tone: '' as const }] : []),
278    { label: '输出', value: fmt(last.output), tone: '' },
279    { label: '缓存', value: `${hitRate(last)}%`, tone: '', sparkle: hitRate(last) >= 90 },
280  ]
281  turn[0]!.sep = true
282  return [...list, ...turn]
283}
284
285const TILE_H = 30
286const LABEL_Y = 11
287const VALUE_Y = 27
288
289const svg = (w: number, h: number, body: string, alt: string): Drawing => {
290  const W = Math.ceil(w)
291  return {
292    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}" shape-rendering="crispEdges">${STYLE}${body}</svg>`,
293    width: W,
294    height: h,
295    alt,
296  }
297}
298
299const label = (s: string) => `<text x="0" y="${LABEL_Y}" font-size="10" class="mu">${esc(s)}</text>`
300
301const value = (s: string, x: number, tone: Tone) => `<text x="${x}" y="${VALUE_Y}" class="v${tone ? ` ${tone}` : ''}">${esc(s)}</text>`
302
303const divider = () => svg(2, TILE_H, `<rect x="0" y="5" width="2" height="22" fill="var(--grid)"/>`, '|')
304
305const bandPose = (m: Meter, last: TurnRecord | null, now: number, isWorking: boolean, act: Act): Pose => {
306  const empty = m.rateLimits.find(l => l.percentUsed >= 100)
307  const busy = act === 'thinking' || act === 'tool' || act === 'writing' || act === 'reading' || act === 'delegating' || act === 'responding'
308  const mode: Act = isWorking ? (act === 'idle' || act === 'sleep' ? 'thinking' : act) : busy ? 'idle' : act
309  return empty
310    ? { mode: 'flat', level: 100, celebrate: false }
311    : {
312        mode,
313        level: Math.max(0, ...gauges(m).map(g => g.p)),
314        celebrate: mode === 'idle' && act === 'idle' && !!last && now - last.at < 15000,
315      }
316}
317
318export const bandParts = (m: Meter, last: TurnRecord | null, now: number, isWorking: boolean, act: Act, helpers: number, chores: number): Drawing[] => {
319  const gs = gauges(m)
320  const pose = bandPose(m, last, now, isWorking, act)
321  const parts: Drawing[] = [svg(60, 40, clawd(4, 10, 2, pose) + (helpers > 0 ? helper(38, 30, 1) : '') + (chores > 0 ? terminal(6, 31) : ''), 'Clawd')]
322  gs.forEach((g, i) => {
323    const pct = `${g.p}%`
324    parts.push(svg(70 + textWidth('100%', 13), TILE_H, `${label(g.name)}${pixBar(0, 17, 10, 5, 10, 1.5, g.p, i * 120)}${value(pct, 70, '')}`, `${g.name} ${pct}`))
325  })
326  for (const s of stats(m, last, now)) {
327    if (s.sep) parts.push(divider())
328    const vw = textWidth(s.value, 13)
329    const spark = s.sparkle ? twinkle(vw + 1, 12) : ''
330    parts.push(svg(Math.max(textWidth(s.label, 10), vw + (s.sparkle ? 9 : 0)), TILE_H, `${label(s.label)}${value(s.value, 0, s.tone)}${spark}`, `${s.label} ${s.value}`))
331  }
332  return parts
333}
334
types/index.d.ts 28 lines
1export type Act = 'idle' | 'thinking' | 'tool' | 'writing' | 'reading' | 'delegating' | 'waiting' | 'compacting' | 'responding' | 'error' | 'aborted' | 'sleep'
2
3export type TurnRecord = {
4  at: number
5  input: number
6  output: number
7  cacheRead: number
8  cacheWrite: number
9  ms?: number
10}
11
12export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
13
14export type Sample = { t: number; p: number; resetsAt?: string }
15
16export type Meter = {
17  context: { tokens?: number; window: number; percent?: number } | null
18  rateLimits: Limit[]
19  cost: number | null
20  base: Record<string, Sample>
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'clawd-hud': { meter: Meter; last: TurnRecord | null; act: Act; helpers: number; chores: number }
26  }
27}
28