SLOPSHOPPER

quota-chan

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

newtoastprocesstimer
v0.2.0no licenseupdated 2026-10-09sanciyuandehundan/claude-chan
A shopper browsing a rack in a slop shop
README

Claude 酱额度挂件

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

Claude 酱

角色原型来源于 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):

  • 会话启动时拉起挂件:关掉就只写额度,不拉窗口
  • pythonw.exe 路径:默认从 PATH 找

组成

路径作用
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 每秒上下错一格当呼吸。

操作

  • 左键点:换一句台词、跳一下、撒粒子、放音效
  • 按住拖动:挪位置(记住相对右下角的偏移)
  • 右键:静音、气泡常驻、隐藏 30 分钟、回到右下角、打开数据目录、退出

设置(config.json)

  • scale:像素放大倍数,默认 2(按屏幕缩放再乘)
  • offset:相对右下角的偏移
  • volume:音效音量 0~1,默认 0.1
  • muted / bubble:静音、气泡常驻

开发

claude plugin validate .
claude plugin test .
cd widget && python art.py   # 导出 preview/
Source 1 files
hooks/register.ts 81 lines
1import 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