Pixel Clawd usage band above the prompt

Claude Code 桌面端的 mod:在 Code 页面的输入框上方显示一行像素风用量条,带一只会跟着状态做动作的像素 Clawd。
?、调用工具敲锤子、写文件拿笔写字、读文件和搜索捧书看(读完想下一步时也继续捧着)、等你批准时举手、压缩上下文揉纸团、子代理干活时脚边跟着小 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 . 运行hooks/register.tsx 332 lines1import { 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}
332hooks/draw.ts 334 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
334types/index.d.ts 28 lines1export 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