Claude 酱:贴在 Claude 桌面版右下角的像素记账大小姐,显示 5 小时 / 本周 / Fable 额度,点她会说话

贴在 Claude 桌面版窗口右下角的像素记账大小姐:橙发、抱着账本、头顶站着戴王冠的小 Clawd。气泡里显示 5 小时额度的重置倒计时和已用百分比、本周和 Fable 的进度条;点她会说话、跳一下、撒粒子、放音效。

角色原型来源于 B 站 UP 主 ZipZipPipe。本项目是该角色的 Q 版像素二创,加上了 Claude 的小 Clawd。
挂件不在会话界面里,而是一个独立的透明小窗,跟着 Claude 窗口走:Claude 在前台时浮在最上面,切到别的软件就退到 Claude 后面,最小化时隐藏。所以 Chat 页面也能看到。
Windows,需要 Python 3 和 Pillow(pip install pillow)。
/plugin install quota-chan --marketplace sanciyuandehundan/claude-chan
(插件 id 叫 quota-chan,因为 Claude Code 不允许第三方插件以 claude- 开头。)
在 Claude Code 终端里输入,回答 y 添加市场,再选一个安装范围(选 user 就每个会话都有)。
之后每次 Code 会话启动时,插件会写一次额度并拉起挂件(已经开着就不会重复开)。想让它开机就在,把 widget/widget.pyw 的快捷方式放进「启动」文件夹。
插件设置(/plugin 里这个插件的 Configure options):
| 路径 | 作用 |
|---|---|
hooks/register.ts | 每个会话启动时、之后每 60 秒、额度每动一个百分点时,把额度写到数据目录;启动时拉起挂件 |
widget/widget.pyw | 挂件本体(无控制台窗口) |
widget/art.py | 像素立绘、表情、小 Clawd、像素数字、粒子;python art.py 导出预览 |
widget/lines.py | 台词,按额度高低、时间段分池 |
widget/sounds.py / audio.py | 合成 8-bit 音效;自己开输出流混音播放,鼠标靠近时先叫醒蓝牙耳机 |
数据目录 ~/.claude/clawd-widget/(插件更新不会动它):
| 文件 | 作用 |
|---|---|
usage.json | 额度,插件写、挂件读 |
config.json | 位置、音量、静音、气泡常驻、缩放 |
assets/*.wav | 音效,缺了自动重新生成 |
widget.log | 出错记录 |
| 状态 | 表情 |
|---|---|
| 5 小时额度回满(<4%) | 得意 |
| 正常使用 | 微笑 |
| 用量过半 | 平静 |
| 快用完(≥80%) | 皱眉(><) |
| 额度耗尽 | 哭 |
| 刚跨过 80% / 95% / 100% 那几秒 | 惊讶 |
| 数据超过 10 分钟没刷新 | 打瞌睡,头顶飘 z |
| 点一下 | wink(偶尔开心) |
| 2.5 秒内连点 5 下 | 生气 |
每隔几秒眨一次眼(只换眼睛);头和小 Clawd 每秒上下错一格当呼吸。
scale:像素放大倍数,默认 2(按屏幕缩放再乘)offset:相对右下角的偏移volume:音效音量 0~1,默认 0.1muted / bubble:静音、气泡常驻claude plugin validate .
claude plugin test .
cd widget && python art.py # 导出 preview/hooks/register.ts 81 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Window = { label: string; percentUsed: number; resetsAt?: string }
4type Options = { autostart?: boolean; python?: string }
5
6// 挂件的数据目录:~/.claude/clawd-widget(额度、设置、日志、音效都在这,插件更新不会丢)。
7async function dataDir($: EngineInterface): Promise<string> {
8 const root = $.plugin.root
9 const sep = root.includes('\\') ? '\\' : '/'
10 const home = (await $.env.get('USERPROFILE').catch(() => undefined)) ?? (await $.env.get('HOME').catch(() => undefined))
11 if (home) return [home.replace(/[\\/]+$/, ''), '.claude', 'clawd-widget'].join(sep)
12 // 拿不到用户目录就按插件自己的位置推:它总在 ~/.claude 下面某处
13 const parts = root.split(/[\\/]+/).filter(p => p !== '')
14 const at = parts.lastIndexOf('.claude')
15 const head = root.startsWith(sep) ? sep : ''
16 return head + [...(at >= 0 ? parts.slice(0, at + 1) : parts.slice(0, -2)), 'clawd-widget'].join(sep)
17}
18
19// 桌面版的 get_usage 带每个模型单独的周额度(Fable);拿不到(终端会话、工具没接上)就退回引擎自己的
20// 5 小时 / 本周两个窗口,挂件至少能跟着动。
21async function fetchWindows($: EngineInterface): Promise<{ plan?: string; windows: Window[] } | null> {
22 const r = await $.tool.call({ tool: 'mcp__ccd_session_mgmt__get_usage' }).catch(() => null)
23 if (r && r.deny === undefined && !r.isError) {
24 const plan = JSON.parse(r.text ?? '{}').plan
25 if (plan?.status === 'ok') return { plan: plan.plan, windows: plan.windows }
26 }
27 const u = await $.session.usage().catch(() => null)
28 if (!u || u.rateLimits.length === 0) return null
29 const name: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly · all models' }
30 return {
31 windows: u.rateLimits.map(w => ({ label: name[w.kind] ?? w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
32 }
33}
34
35// 每个 Code 会话都会加载这个插件;别的会话一分钟内刚写过就不重复拉。
36async function refresh($: EngineInterface, force: boolean) {
37 const sep = $.plugin.root.includes('\\') ? '\\' : '/'
38 const file = (await dataDir($)) + sep + 'usage.json'
39 if (!force) {
40 const old = await $.fs.read(file).then(t => JSON.parse(t), () => null)
41 if (old !== null && Date.now() - Date.parse(old.fetchedAt) < 60_000) return
42 }
43 const got = await fetchWindows($)
44 if (got === null) return
45 await $.fs.write(file, JSON.stringify({ fetchedAt: new Date().toISOString(), ...got }))
46}
47
48// 把挂件拉成独立进程:经 PowerShell 的 Start-Process,不继承这边的管道,也不随插件重载被杀。
49// 挂件自己有互斥锁,已经开着就立刻退出,所以每次会话启动都拉一下没关系。只在 Windows 上做。
50async function launchWidget($: EngineInterface, options: Options) {
51 if (options.autostart === false || !$.plugin.root.includes('\\')) return
52 const widget = `${$.plugin.root}\\widget`
53 const py = (options.python ?? '').trim() || 'pythonw.exe'
54 const ps = `Start-Process -FilePath '${py}' -ArgumentList '"${widget}\\widget.pyw"' -WorkingDirectory '${widget}'`
55 const r = await $.process.run(
56 ['powershell.exe', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', ps],
57 { timeoutMs: 8_000 },
58 )
59 if (r.exitCode !== 0) {
60 const why = r.stderr.trim().split('\n')[0] ?? ''
61 $.ui.toast(`Claude 酱没拉起来:${why}(挂件要 Python 3 + Pillow,python 路径可在插件设置里改)`)
62 }
63}
64
65export const register: Register = (on, options) => {
66 const opts = (options ?? {}) as Options
67
68 on('session.start', async ($, e, next) => {
69 await refresh($, true).catch(() => undefined)
70 if (e.isInteractive) await launchWidget($, opts).catch(() => undefined)
71 $.clock.every(60_000, () => void refresh($, false).catch(() => undefined))
72 return next(e)
73 })
74
75 // 额度每动一个百分点就写一次
76 on('session.measure', async ($, e, next) => {
77 if (e.changed.includes('rateLimits')) await refresh($, true).catch(() => undefined)
78 return next(e)
79 })
80}
81