提示框上方顯示 token 用量與訂閱額度剩餘

Claude Code 的 mod:在提示框上方顯示一個用量面板。
╭──────────────────────────────────────────────────────────╮
│ ✦ Claude 用量 本次花費 $1.23 │
│ ◉ Context ████▓░░░░░░░░░░░░░░░ 23% 45k / 200k │
│ Σ Token 輸入 1.2M(快取讀 1.1M・寫 80k) 輸出 42k · 18 回合 │
│ ◷ 5 小時額度 ███████░░░░░░░░░░░░░ 剩 65% ↻ 13:00 │
│ ◫ 7 天額度 ██████████████████░░ 剩 7.5% ↻ 10/9 08:00 │
╰──────────────────────────────────────────────────────────╯
/clear 後歸零)在 Claude Code 的提示框輸入:
/plugin install usage-band --marketplace ArnoldChiou/claude-usage-band
出現 Add marketplace? 時按 y,再選安裝範圍(user 範圍會在之後每個 session 都啟用)。
claude plugin validate .
claude plugin test .hooks/register.tsx 193 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { Tokens, Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
7const EMPTY: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, since: 0 }
8const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, EMPTY)
9
10const LABELS: Record<string, { name: string; icon: string; color: string }> = {
11 five_hour: { name: '5 小時額度', icon: '◷', color: '#5FD7FF' },
12 seven_day: { name: '7 天額度', icon: '◫', color: '#AF87FF' },
13 spend_limit: { name: '花費上限', icon: '◈', color: '#FFAF5F' },
14}
15
16const BAR = 20
17
18// 剩餘量的漸層:綠 → 黃綠 → 黃 → 橘 → 紅(依格子位置上色)
19const HEAT = ['#5FD787', '#87D75F', '#AFD75F', '#D7D75F', '#FFD75F', '#FFAF5F', '#FF875F', '#FF5F5F']
20// Context 用藍紫漸層
21const COOL = ['#5FAFFF', '#5F87FF', '#875FFF', '#AF5FFF', '#D75FFF']
22
23const toUsage = (context: SessionContextUsage, rateLimits: SessionRateLimit[], cost?: SessionCost): Usage => ({
24 tokens: context.tokens,
25 window: context.window,
26 percent: context.percent,
27 limits: rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
28 usd: cost?.usd,
29})
30
31const fmtTokens = (n: number) =>
32 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
33
34// 重置時間:今天顯示 HH:MM,其他日子顯示 M/D HH:MM
35const fmtReset = (iso: string, now: number) => {
36 const d = new Date(iso)
37 if (Number.isNaN(d.getTime())) return ''
38 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
39 return new Date(now).toDateString() === d.toDateString() ? hm : `${d.getMonth() + 1}/${d.getDate()} ${hm}`
40}
41
42const pick = (palette: string[], ratio: number) =>
43 palette[Math.min(palette.length - 1, Math.floor(ratio * palette.length))] ?? palette[0]!
44
45const levelColor = (used: number) => (used >= 90 ? '#FF5F5F' : used >= 70 ? '#FFAF5F' : '#5FD787')
46
47export const register: Register = on => {
48 on('session.start', async ($, e, next) => {
49 const r = await next(e)
50 const u = await $.session.usage()
51 await update($, usage, () => toUsage(u.context, u.rateLimits, u.cost))
52 // 熱重載也會觸發 session.start,startedAt 沒變就保留累計
53 await update($, tokens, t => (t.since === u.startedAt ? t : { ...EMPTY, since: u.startedAt }))
54 return r
55 })
56
57 // 每個 turn(含子代理)結束時累加它的 token 用量
58 on('turn.complete', async ($, e, next) => {
59 const r = await next(e)
60 const t = e.usage
61 if (t) {
62 const { startedAt } = await $.session.usage()
63 await update($, tokens, prev => {
64 const base = prev.since === startedAt ? prev : { ...EMPTY, since: startedAt }
65 return {
66 ...base,
67 input: base.input + t.input_tokens,
68 output: base.output + t.output_tokens,
69 cacheRead: base.cacheRead + t.cache_read_input_tokens,
70 cacheWrite: base.cacheWrite + t.cache_creation_input_tokens,
71 turns: base.turns + 1,
72 }
73 })
74 }
75 return r
76 })
77
78 on('session.measure', async ($, e, next) => {
79 await update($, usage, () => toUsage(e.context, e.rateLimits, e.cost))
80 if (e.changed.includes('rateLimits')) {
81 for (const r of e.rateLimits) {
82 if (r.percentUsed >= 90) $.ui.toast(`${LABELS[r.kind]?.name ?? r.kind}已用 ${r.percentUsed}%`)
83 }
84 }
85 return next(e)
86 })
87
88 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89 const u = await read($, usage)
90 if (e.props.hasSurvey || u === null) return next(e)
91 const tk = await read($, tokens)
92
93 const { Box, Text } = $.ui.resolve(e)
94 const now = await $.clock.now()
95
96 // filled 格依位置套漸層,其餘為暗色底
97 const bar = (id: string, ratio: number, palette: string[]) => {
98 const filled = Math.round(Math.max(0, Math.min(1, ratio)) * BAR)
99 return (
100 <Box key={id}>
101 {Array.from({ length: BAR }, (_, i) =>
102 i < filled ? (
103 <Text key={`${id}-${i}`} color={pick(palette, i / BAR)}>
104 █
105 </Text>
106 ) : (
107 <Text key={`${id}-${i}`} color="#3A3A3A">
108 ░
109 </Text>
110 ),
111 )}
112 </Box>
113 )
114 }
115
116 const label = (icon: string, name: string, color: string) => (
117 <Box width={13}>
118 <Text color={color} bold>
119 {`${icon} ${name}`}
120 </Text>
121 </Box>
122 )
123
124 const ctxPct = u.percent ?? 0
125
126 return (
127 <Box flexDirection="column" borderStyle="round" borderColor="#875FFF" paddingX={1}>
128 <Box justifyContent="space-between">
129 <Text color="#D75FFF" bold>
130 ✦ Claude 用量
131 </Text>
132 {u.usd !== undefined ? (
133 <Text>
134 <Text color="#8A8A8A">本次花費 </Text>
135 <Text color="#FFD75F" bold>{`$${u.usd.toFixed(2)}`}</Text>
136 </Text>
137 ) : null}
138 </Box>
139
140 <Box columnGap={1}>
141 {label('◉', 'Context', '#5FAFFF')}
142 {bar('ctx', ctxPct / 100, COOL)}
143 <Box width={8}>
144 <Text color={levelColor(ctxPct)} bold>{` ${ctxPct}%`}</Text>
145 </Box>
146 <Text color="#8A8A8A">
147 {u.tokens === undefined ? `— / ${fmtTokens(u.window)}` : `${fmtTokens(u.tokens)} / ${fmtTokens(u.window)}`}
148 </Text>
149 </Box>
150
151 <Box columnGap={1}>
152 {label('Σ', 'Token', '#5FD7AF')}
153 {tk.turns === 0 ? (
154 <Text color="#8A8A8A" italic>
155 尚無回應
156 </Text>
157 ) : (
158 <Text>
159 <Text color="#8A8A8A">輸入 </Text>
160 <Text color="#87D7FF" bold>{fmtTokens(tk.input + tk.cacheRead + tk.cacheWrite)}</Text>
161 <Text color="#8A8A8A">{`(快取讀 ${fmtTokens(tk.cacheRead)}・寫 ${fmtTokens(tk.cacheWrite)}) 輸出 `}</Text>
162 <Text color="#FFD787" bold>{fmtTokens(tk.output)}</Text>
163 <Text color="#8A8A8A">{` · ${tk.turns} 回合`}</Text>
164 </Text>
165 )}
166 </Box>
167
168 {u.limits.length === 0 ? (
169 <Text color="#8A8A8A" italic>
170 ◌ 尚無訂閱額度資料(收到第一次回應後顯示)
171 </Text>
172 ) : null}
173
174 {u.limits.map(l => {
175 const meta = LABELS[l.kind] ?? { name: l.kind, icon: '◇', color: '#D7D7D7' }
176 const left = Math.max(0, Math.round((100 - l.percentUsed) * 10) / 10)
177 const reset = l.resetsAt ? fmtReset(l.resetsAt, now) : ''
178 return (
179 <Box key={l.kind} columnGap={1}>
180 {label(meta.icon, meta.name, meta.color)}
181 {bar(l.kind, l.percentUsed / 100, HEAT)}
182 <Box width={8}>
183 <Text color={levelColor(l.percentUsed)} bold>{`剩 ${left}%`}</Text>
184 </Box>
185 {reset ? <Text color="#8A8A8A">{`↻ ${reset}`}</Text> : null}
186 </Box>
187 )
188 })}
189 </Box>
190 )
191 })
192}
193types/index.d.ts 25 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Usage = {
3 tokens?: number
4 window: number
5 percent?: number
6 limits: Limit[]
7 usd?: number
8}
9// 本次 session 累計的 token 用量(每個 turn 的 usage 加總,含子代理)
10export type Tokens = {
11 input: number
12 output: number
13 cacheRead: number
14 cacheWrite: number
15 turns: number
16 // 累計起點(session 的 startedAt),/clear 後會變,用來歸零
17 since: number
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'usage-band': { usage: Usage | null; tokens: Tokens }
23 }
24}
25