SLOPSHOPPER

token-usage

5h / 7d usage, token totals and cost pills above the prompt

newbandtimer
v0.1.0no licenseupdated 2026-10-06letswritetw/claude-mod-token-usage/token-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage
› 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 5h ━━────── 31% ↻ NaNh NaNm │ ⇧ 6.4k ⇩ 1.5k ▤ 91.0k │ $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ━━────── 31% ↻ NaNh NaNm │ ⇧ 6.4k ⇩ 1.5k ▤ 91.0k │ $0.42
README

token-usage

Claude Desktop(Code 分頁)輸入框上方的用量條:5h / 7d 額度、token 用量、花費。

安裝

  • macOS:bash install.sh
  • Windows:在 PowerShell 執行 powershell -ExecutionPolicy Bypass -File install.ps1

完成後完全關閉並重開 Claude Desktop。腳本會備份並合併 ~/.claude/settings.json(只新增 env 內兩個變數)。

需求

  • 支援 mod 功能的新版 Claude Code / Claude Desktop
  • 5h / 7d 額度需訂閱帳號;第一次回應後才有資料

解除安裝

刪除 ~/.claude/mods/token-usage,並從 settings.json 的 env 移除 CLAUDE_CODE_PLUGIN_DIRS 與 CLAUDE_CODE_PLUGIN_DIR_WATCH。

Source 2 files
hooks/register.tsx 194 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Snap, Win } from '../types'
5
6const EMPTY: Snap = { five: null, seven: null, usd: null, tokIn: 0, tokOut: 0, tokCache: 0 }
7const snap = atom({ plugin: 'token-usage', key: 'snap' } as const, EMPTY)
8
9const tick = atom({ plugin: 'token-usage', key: 'tick' } as const, 0)
10
11const H = 3600_000
12const k = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`)
13
14const left = (iso: string | undefined, now: number) => {
15  if (!iso) return ''
16  const m = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
17  const d = Math.floor(m / 1440)
18  const h = Math.floor((m % 1440) / 60)
19  return d > 0 ? `${d}d ${h}h` : `${h}h ${String(m % 60).padStart(2, '0')}m`
20}
21
22// ---- desktop: one SVG strip of rounded pills (Svg is the desktop's picture leaf) ----
23
24const MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
25const CW = 7.9 // monospace advance at 13px
26
27type Icon = 'gauge' | 'cal' | 'clock' | 'up' | 'down' | 'layers' | 'coin'
28
29// 16x16 line icons, drawn with currentColor-ish stroke set by the caller
30const icon = (name: Icon, x: number, c: string) => {
31  const g: Record<Icon, string> = {
32    gauge: '<path d="M2.5 12.5a6 6 0 1 1 11 0" /><path d="M8 11l2.6-3.6" />',
33    cal: '<rect x="2.5" y="3.5" width="11" height="10" rx="2" /><path d="M2.5 6.5h11M5.5 2v3M10.5 2v3" />',
34    clock: '<circle cx="8" cy="8" r="5.5" /><path d="M8 5v3.2l2 1.2" />',
35    up: '<path d="M8 10V3M5.2 5.6L8 2.8l2.8 2.8M3 10v3h10v-3" />',
36    down: '<path d="M8 3v7M5.2 7.4L8 10.2l2.8-2.8M3 10v3h10v-3" />',
37    layers: '<path d="M8 2.5l5.5 2.8L8 8.1 2.5 5.3zM2.5 8.2L8 11l5.5-2.8M2.5 11L8 13.8l5.5-2.8" />',
38    coin: '<circle cx="8" cy="8" r="5.8" /><path d="M8 4.6v6.8M9.8 6.2c-.4-.6-1-.9-1.8-.9-1 0-1.8.5-1.8 1.3 0 1.8 3.6.8 3.6 2.6 0 .8-.8 1.3-1.8 1.3-.8 0-1.5-.3-1.9-.9" />',
39  }
40  return `<g transform="translate(${x} 6)" fill="none" stroke="${c}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${g[name]}</g>`
41}
42
43// slate developer-tool palette: dark tinted pills read on both dark and light app themes
44const CARD = '#1B2336'
45const FG = '#F8FAFC'
46const MUTED = '#94A3B8'
47const TRACK = '#334155'
48
49const level = (p: number, fg: string) => (p >= 90 ? '#EF4444' : p >= 75 ? '#FBBF24' : fg)
50
51// <title> is the native hover tooltip (works because the Svg is drawn isInteractive)
52const pill = (x: number, w: number, bg: string, body: string, tip: string) =>
53  `<g class="p"><title>${tip}</title><rect x="${x}" y="0" width="${w}" height="28" rx="14" fill="${CARD}"/>` +
54  `<rect class="h" x="${x + 0.5}" y=".5" width="${w - 1}" height="27" rx="13.5" fill="${bg}" fill-opacity=".14" stroke="${bg}" stroke-opacity=".38"/>${body}</g>`
55
56const longLeft = (iso: string | undefined, now: number) => {
57  if (!iso) return '重置時間未知'
58  const m = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
59  const d = Math.floor(m / 1440)
60  const h = Math.floor((m % 1440) / 60)
61  if (d > 0) return `${d} 天 ${h} 小時後重置`
62  if (h > 0) return `${h} 小時 ${m % 60} 分後重置`
63  return `${m} 分後重置`
64}
65
66const text = (x: number, s: string, c: string, bold = false) =>
67  `<text x="${x}" y="18.3" font-family="${MONO}" font-size="13" ${bold ? 'font-weight="700"' : ''} fill="${c}">${s}</text>`
68
69// returns [svg, width]
70const winPill = (x: number, label: string, title: string, ic: Icon, w: Win, span: number, now: number, bg: string, fg: string): [string, number] => {
71  const c = level(w.used, fg)
72  const pct = `${Math.round(w.used)}%`
73  const reset = left(w.resetsAt, now)
74  const BAR = 56
75  let cx = x + 11
76  let s = icon(ic, cx, fg)
77  cx += 16 + 6
78  s += text(cx, label, MUTED)
79  cx += label.length * CW + 7
80  const filled = Math.max(0, Math.min(1, w.used / 100)) * BAR
81  s += `<rect x="${cx}" y="11.5" width="${BAR}" height="5" rx="2.5" fill="${TRACK}"/>`
82  if (filled > 0) s += `<rect x="${cx}" y="11.5" width="${Math.max(5, filled)}" height="5" rx="2.5" fill="${c}"/>`
83  if (w.resetsAt) {
84    const el = Math.max(0, Math.min(1, 1 - (Date.parse(w.resetsAt) - now) / span))
85    s += `<rect x="${cx + el * (BAR - 2)}" y="8.5" width="2" height="11" rx="1" fill="${FG}"/>`
86  }
87  cx += BAR + 9
88  s += text(cx, pct, FG, true)
89  cx += pct.length * (CW + 0.5) + 8
90  s += `<rect x="${cx}" y="8" width="1" height="12" fill="${MUTED}" opacity=".35"/>`
91  cx += 9
92  s += icon('clock', cx, fg)
93  cx += 16 + 5
94  s += text(cx, reset, MUTED)
95  cx += reset.length * CW + 11
96  const tip = `${title}:已用 ${pct},${longLeft(w.resetsAt, now)}&#10;長條上的直線=這個時段已經過了多少時間`
97  return [pill(x, cx - x, bg, s, tip), cx - x]
98}
99
100const chip = (x: number, ic: Icon, label: string, bg: string, fg: string, tip: string): [string, number] => {
101  const w = 11 + 16 + 5 + label.length * CW + 11
102  const body = icon(ic, x + 11, fg) + text(x + 11 + 16 + 5, label, FG)
103  return [pill(x, w, bg, body, tip), w]
104}
105
106const strip = (v: Snap, now: number) => {
107  const parts: string[] = []
108  let x = 0
109  const add = ([s, w]: [string, number], gap = 8) => {
110    parts.push(s)
111    x += w + gap
112  }
113  if (v.five) add(winPill(x, '5h', '5 小時工作階段額度', 'gauge', v.five, 5 * H, now, '#22C55E', '#4ADE80'))
114  if (v.seven) add(winPill(x, '7d', '7 天每週額度', 'cal', v.seven, 7 * 24 * H, now, '#8B5CF6', '#A78BFA'), 18)
115  add(chip(x, 'up', k(v.tokIn), '#F43F5E', '#FB7185', `輸入 token:本工作階段送給模型的內容共 ${k(v.tokIn)}`))
116  add(chip(x, 'down', k(v.tokOut), '#10B981', '#34D399', `輸出 token:模型在本工作階段產生的內容共 ${k(v.tokOut)}`))
117  add(chip(x, 'layers', k(v.tokCache), '#6366F1', '#818CF8', `快取讀取 token:從提示快取重複使用 ${k(v.tokCache)},計費較一般輸入低`), 18)
118  if (v.usd !== null) add(chip(x, 'coin', `$${v.usd.toFixed(2)}`, '#F59E0B', '#FBBF24', `工作階段費用:依 API 牌價估算約 $${v.usd.toFixed(2)} 美元`))
119  const w = Math.ceil(x - 8)
120  return { w, svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="28" viewBox="0 0 ${w} 28"><style>:root{color-scheme:light dark;background:transparent}.p{cursor:help}.p .h{transition:fill-opacity .12s}.p:hover .h{fill-opacity:.26}</style>${parts.join('')}</svg>` }
121}
122
123// ---- terminal fallback: text chips ----
124
125const bar = (w: Win, span: number, now: number) => {
126  const n = 8
127  const cells = Array.from({ length: n }, (_, i) => (i < Math.round((w.used / 100) * n) ? '━' : '─'))
128  if (w.resetsAt) {
129    const el = 1 - (Date.parse(w.resetsAt) - now) / span
130    cells[Math.min(n - 1, Math.max(0, Math.floor(el * n)))] = '┃'
131  }
132  return cells.join('')
133}
134
135export const register: Register = on => {
136  // redraw every 5 min so the reset countdowns and time markers keep moving; no model call
137  on('session.start', async ($, e, next) => {
138    $.clock.every(5 * 60_000, () => update($, tick, n => n + 1))
139    return next(e)
140  })
141
142  on('session.measure', async ($, e, next) => {
143    const f = e.rateLimits.find(r => r.kind === 'five_hour')
144    const s = e.rateLimits.find(r => r.kind === 'seven_day')
145    await update($, snap, v => ({
146      ...v,
147      five: f ? { used: f.percentUsed, resetsAt: f.resetsAt } : v.five,
148      seven: s ? { used: s.percentUsed, resetsAt: s.resetsAt } : v.seven,
149      usd: e.cost ? e.cost.usd : v.usd,
150    }))
151    return next(e)
152  })
153
154  on('turn.complete', async ($, e, next) => {
155    const u = e.usage
156    if (u) {
157      await update($, snap, v => ({
158        ...v,
159        tokIn: v.tokIn + u.input_tokens + u.cache_creation_input_tokens,
160        tokOut: v.tokOut + u.output_tokens,
161        tokCache: v.tokCache + u.cache_read_input_tokens,
162      }))
163    }
164    return next(e)
165  })
166
167  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168    if (e.props.hasSurvey) return next(e)
169    await read($, tick)
170    const v = await read($, snap)
171    const now = await $.clock.now()
172    const t = $.ui.resolve(e)
173    const { Box, Text } = t
174
175    if (!v.five && !v.seven && v.usd === null && v.tokIn + v.tokOut === 0) {
176      return <Box><Text dimColor>token-usage:等待第一次回應後顯示額度…</Text></Box>
177    }
178
179    if ('Svg' in t) {
180      const { Svg } = t
181      const { w, svg } = strip(v, now)
182      return <Box><Svg source={svg} alt="5h / 7d 額度、token 與花費" width={w} height={28} isInteractive /></Box>
183    }
184
185    const line = [
186      v.five && `5h ${bar(v.five, 5 * H, now)} ${Math.round(v.five.used)}% ↻ ${left(v.five.resetsAt, now)}`,
187      v.seven && `7d ${bar(v.seven, 7 * 24 * H, now)} ${Math.round(v.seven.used)}% ↻ ${left(v.seven.resetsAt, now)}`,
188      `⇧ ${k(v.tokIn)}  ⇩ ${k(v.tokOut)}  ▤ ${k(v.tokCache)}`,
189      v.usd !== null && `$${v.usd.toFixed(2)}`,
190    ].filter(Boolean).join('  │  ')
191    return <Box><Text>{line}</Text></Box>
192  })
193}
194
types/index.d.ts 16 lines
1export type Win = { used: number; resetsAt?: string }
2export type Snap = {
3  five: Win | null
4  seven: Win | null
5  usd: number | null
6  tokIn: number
7  tokOut: number
8  tokCache: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'token-usage': { snap: Snap; tick: number }
14  }
15}
16