Usage band above the prompt: 5h/7d limits, token totals and session cost

A Claude Code mod that shows a usage band above the prompt.
Rendered from the mod's own drawing code with sample numbers. It follows your light or dark theme.
From left to right:
Hover a pill in the desktop app for details. In the terminal the band is drawn as colored text instead of pills. /usage-band hide and /usage-band show toggle it; /usage-band alone prints the readings.
Requires Claude Code 2.1.286 or newer (check with claude --version, update with claude update) and Node.js on your PATH (used to total tokens from the session transcript).
claude plugin marketplace add MoonZhe/usage-band
claude plugin install usage-band@usage-band
Start a new session (or restart the desktop app) and the band appears above the prompt.
claude plugin marketplace update usage-band
claude plugin update usage-band@usage-band
claude plugin uninstall usage-band@usage-band
~/.claude/usage-band/limits.json and every session shows the newest one. An idle or resumed session stays current that way.Inspired by an idea from the Claude Code community. Built with Claude Code.
hooks/register.tsx 353 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { UsageContext, UsageLimit, UsageLimits, UsageTokens } from '../types'
5import {
6 buildGroups,
7 contextPercent,
8 fmtCount,
9 fmtPercent,
10 fmtUsd,
11 fmtWindow,
12 level,
13 limitView,
14 pillSvg,
15 pillWidth,
16 type BandInput,
17} from './pills'
18
19const limitsA = atom({ plugin: 'usage-band', key: 'limits' } as const, null)
20const contextA = atom({ plugin: 'usage-band', key: 'context' } as const, null)
21const costA = atom({ plugin: 'usage-band', key: 'costUsd' } as const, null)
22const tokensA = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
23const fallbackA = atom({ plugin: 'usage-band', key: 'fallback' } as const, null)
24const hiddenA = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
25const nowA = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
26
27const NODES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node']
28const ZERO: UsageTokens = { uncachedInput: 0, cacheWrite: 0, output: 0, cacheRead: 0, requests: 0, isApprox: true }
29
30type Rate = { kind: string; percentUsed: number; resetsAt?: string }
31
32function pickLimit(list: readonly Rate[], kind: string): UsageLimit | null {
33 const r = list.find(x => x.kind === kind)
34 return r ? { percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null } : null
35}
36
37function toLimits(list: readonly Rate[]): UsageLimits | null {
38 const fiveHour = pickLimit(list, 'five_hour')
39 const sevenDay = pickLimit(list, 'seven_day')
40 return fiveHour || sevenDay ? { fiveHour, sevenDay } : null
41}
42
43function toContext(c: { tokens?: number; window: number; percent?: number }): UsageContext {
44 return { tokens: c.tokens ?? null, window: c.window, percent: c.percent ?? null }
45}
46
47// Module variables only cache work; everything drawn lives in $.state.
48let lastSig = ''
49let nodeBin: string | null = null
50let isCounting = false
51
52const MINUTE = 60_000
53
54// Every write redraws the band, and an interactive Svg whose source changes
55// reloads its frame (a visible flash), so callers skip writes that change nothing.
56// (read/update stay at the call sites: the validator wants literal atoms there.)
57const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
58
59async function putLimits($: EngineInterface, v: UsageLimits | null) {
60 if (!same(await read($, limitsA), v)) await update($, limitsA, () => v)
61}
62
63async function putContext($: EngineInterface, v: UsageContext) {
64 if (!same(await read($, contextA), v)) await update($, contextA, () => v)
65}
66
67async function putCost($: EngineInterface, v: number | null) {
68 if (!same(await read($, costA), v)) await update($, costA, () => v)
69}
70
71async function putTokens($: EngineInterface, v: UsageTokens | null) {
72 if (!same(await read($, tokensA), v)) await update($, tokensA, () => v)
73}
74
75// Rate limits arrive with this session's own API responses only, so an idle or
76// resumed session would keep showing its last reading. Every session publishes
77// a fresh reading to one shared file and shows whichever reading is newest.
78type Reading = { at: number; limits: UsageLimits | null }
79let own: Reading = { at: 0, limits: null }
80
81async function sharedPath($: EngineInterface): Promise<string | null> {
82 const config = await $.env.get('CLAUDE_CONFIG_DIR')
83 if (config) return `${config}/usage-band/limits.json`
84 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
85 return home ? `${home}/.claude/usage-band/limits.json` : null
86}
87
88async function readShared($: EngineInterface): Promise<Reading | null> {
89 try {
90 const path = await sharedPath($)
91 if (!path) return null
92 const r = JSON.parse(await $.fs.read(path)) as Reading
93 return typeof r?.at === 'number' ? r : null
94 } catch {
95 return null
96 }
97}
98
99async function publish($: EngineInterface, reading: Reading) {
100 try {
101 const path = await sharedPath($)
102 const shared = await readShared($)
103 if (path && (!shared || shared.at < reading.at)) await $.fs.write(path, JSON.stringify(reading))
104 } catch {
105 // sharing is best effort; this session still shows its own reading
106 }
107}
108
109// A window whose reset time has passed starts over, whatever the last reading said.
110function expire(l: UsageLimit | null, now: number): UsageLimit | null {
111 if (!l?.resetsAt) return l
112 return Date.parse(l.resetsAt) <= now ? { percentUsed: 0, resetsAt: null } : l
113}
114
115async function showLimits($: EngineInterface, now: number) {
116 const shared = await readShared($)
117 const best = shared && shared.at > own.at ? shared : own
118 const l = best.limits
119 await putLimits($, l ? { fiveHour: expire(l.fiveHour, now), sevenDay: expire(l.sevenDay, now) } : null)
120}
121
122async function refreshUsage($: EngineInterface) {
123 const [u, now] = await Promise.all([$.session.usage(), $.clock.now()])
124 await showLimits($, now)
125 await putContext($, toContext(u.context))
126 await putCost($, u.cost?.usd ?? null)
127 // "time left" shows whole minutes, so the clock only needs minute steps
128 const minute = Math.floor(now / MINUTE) * MINUTE
129 if ((await read($, nowA)) !== minute) await update($, nowA, () => minute)
130}
131
132async function runScript($: EngineInterface, id: string): Promise<string | null> {
133 const script = `${$.plugin.root}/scripts/tokens.mjs`
134 for (const bin of nodeBin ? [nodeBin] : NODES) {
135 try {
136 const r = await $.process.run([bin, script, id, lastSig], { timeoutMs: 20_000 })
137 if (r.exitCode === 0) {
138 nodeBin = bin
139 return r.stdout
140 }
141 if (nodeBin) return null
142 } catch {
143 // this node could not start; try the next one
144 }
145 }
146 return null
147}
148
149async function refreshTokens($: EngineInterface) {
150 if (isCounting) return
151 isCounting = true
152 try {
153 const out = await runScript($, await $.session.id())
154 const parsed = out ? (JSON.parse(out) as Partial<UsageTokens> & { sig?: string; unchanged?: boolean; error?: string }) : null
155 if (parsed && !parsed.error && parsed.sig) {
156 lastSig = parsed.sig
157 if (parsed.unchanged) return
158 const t: UsageTokens = {
159 uncachedInput: parsed.uncachedInput ?? 0,
160 cacheWrite: parsed.cacheWrite ?? 0,
161 output: parsed.output ?? 0,
162 cacheRead: parsed.cacheRead ?? 0,
163 requests: parsed.requests ?? 0,
164 isApprox: false,
165 }
166 await putTokens($, t)
167 return
168 }
169 lastSig = ''
170 await putTokens($, await read($, fallbackA))
171 } catch {
172 lastSig = ''
173 await putTokens($, await read($, fallbackA))
174 } finally {
175 isCounting = false
176 }
177}
178
179async function refreshAll($: EngineInterface) {
180 await Promise.allSettled([refreshUsage($), refreshTokens($)])
181}
182
183export const register: Register = on => {
184 on('session.start', async ($, e, next) => {
185 await $.command.register({
186 name: 'usage-band',
187 description: 'Usage band: refresh and summarize (hide / show toggles the band)',
188 argumentHint: '[hide|show]',
189 })
190 $.clock.every(30_000, () => void refreshUsage($))
191 void refreshAll($)
192
193 return next(e)
194 })
195
196 on('session.measure', async ($, e, next) => {
197 const now = await $.clock.now()
198 if (e.rateLimits.length > 0) {
199 own = { at: now, limits: toLimits(e.rateLimits) }
200 await publish($, own)
201 }
202 await showLimits($, now)
203 await putContext($, toContext(e.context))
204 if (e.cost) await putCost($, e.cost.usd ?? null)
205
206 return next(e)
207 })
208
209 on('turn.complete', async ($, e, next) => {
210 const result = await next(e)
211 const u = e.usage
212 if (u) {
213 await update($, fallbackA, prev => {
214 const b = prev ?? ZERO
215 return {
216 uncachedInput: b.uncachedInput + u.input_tokens,
217 cacheWrite: b.cacheWrite + u.cache_creation_input_tokens,
218 output: b.output + u.output_tokens,
219 cacheRead: b.cacheRead + u.cache_read_input_tokens,
220 requests: b.requests + 1,
221 isApprox: true,
222 }
223 })
224 }
225 await refreshAll($)
226
227 return result
228 })
229
230 on('command.run', { command: 'usage-band' }, async ($, e) => {
231 const arg = e.args.trim().toLowerCase()
232 if (arg === 'hide') {
233 await update($, hiddenA, () => true)
234 return { text: 'Usage band hidden. /usage-band show brings it back.' }
235 }
236 if (arg === 'show') {
237 await update($, hiddenA, () => false)
238 }
239 lastSig = ''
240 await refreshAll($)
241 const limits = await read($, limitsA)
242 const tokens = await read($, tokensA)
243 const cost = await read($, costA)
244 const context = await read($, contextA)
245 const now = await $.clock.now()
246 const parts: string[] = []
247 if (limits?.fiveHour) {
248 const v = limitView('5h', limits.fiveHour, now)
249 parts.push(`5h ${fmtPercent(v.percent)}${v.left ? ` (${v.left} left)` : ''}`)
250 }
251 if (limits?.sevenDay) {
252 const v = limitView('7d', limits.sevenDay, now)
253 parts.push(`7d ${fmtPercent(v.percent)}${v.left ? ` (${v.left} left)` : ''}`)
254 }
255 if (tokens) {
256 const p = tokens.isApprox ? '~' : ''
257 parts.push(
258 `in ${p}${fmtCount(tokens.uncachedInput + tokens.cacheWrite)}`,
259 `out ${p}${fmtCount(tokens.output)}`,
260 )
261 }
262 if (context?.tokens != null) {
263 const pct = contextPercent(context)
264 parts.push(`ctx ${fmtCount(context.tokens)} / ${fmtWindow(context.window)}${pct != null ? ` (${fmtPercent(pct)})` : ''}`)
265 }
266 if (cost != null) parts.push(fmtUsd(cost))
267
268 return { text: parts.length ? parts.join(' · ') : 'No usage readings yet.' }
269 })
270
271 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
272 if (e.props.hasSurvey || (await read($, hiddenA))) return next(e)
273
274 const limits = await read($, limitsA)
275 const band: BandInput = {
276 now: Math.max(await read($, nowA), 0) || (await $.clock.now()),
277 fiveHour: limits?.fiveHour ?? null,
278 sevenDay: limits?.sevenDay ?? null,
279 context: await read($, contextA),
280 costUsd: await read($, costA),
281 tokens: await read($, tokensA),
282 }
283 const groups = buildGroups(band)
284 if (groups.length === 0) return next(e)
285
286 if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
287 const { Box, Svg } = $.ui.resolve(e)
288 return (
289 <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
290 {groups.map((g, gi) => (
291 <Box key={`g${gi}`} flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
292 {g.map(p => (
293 <Svg key={p.alt.split(' ')[0]} source={pillSvg(p)} alt={p.alt} width={pillWidth(p)} height={24} isInteractive />
294 ))}
295 </Box>
296 ))}
297 </Box>
298 )
299 }
300
301 const { Box, Text } = $.ui.resolve(e)
302 const TONE: Record<string, string> = {
303 teal: 'cyan',
304 violet: 'magenta',
305 red: 'red',
306 green: 'green',
307 blue: 'blue',
308 gold: 'yellow',
309 }
310 const LEVEL = { ok: 'green', warn: 'yellow', high: 'red' }
311
312 const lim = (['5h', '7d'] as const).flatMap(label => {
313 const l = label === '5h' ? band.fiveHour : band.sevenDay
314 if (!l) return []
315 const v = limitView(label, l, band.now)
316 const cells = 10
317 const filled = Math.round((Math.min(100, v.percent) / 100) * cells)
318 const mark = v.elapsed === null ? -1 : Math.min(cells - 1, Math.floor(v.elapsed * cells))
319 let bar = ''
320 for (let i = 0; i < cells; i++) bar += i === mark ? '│' : i < filled ? '█' : '░'
321 const tone = label === '5h' ? 'cyan' : 'magenta'
322 return [
323 <Text key={label}>
324 <Text color={tone}>{label} </Text>
325 <Text color={LEVEL[level(v.percent)]}>{bar}</Text>
326 <Text color={tone} bold> {fmtPercent(v.percent)}</Text>
327 {v.left ? <Text color={tone} dimColor> ⟲ {v.left}</Text> : null}
328 </Text>,
329 ]
330 })
331 const rest = groups
332 .flat()
333 .filter(p => p.tone !== 'teal' && p.tone !== 'violet')
334 .map(p => {
335 const glyph = p.tone === 'red' ? '↑' : p.tone === 'green' ? '↓' : p.tone === 'blue' ? '≡' : ''
336 const text = p.segments.flatMap(s => (s.kind === 'text' ? [s.text] : [])).join(' ')
337 return (
338 <Text key={p.tone} color={TONE[p.tone]}>
339 {glyph}
340 {text}
341 </Text>
342 )
343 })
344
345 return (
346 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
347 {lim}
348 {rest}
349 </Box>
350 )
351 })
352}
353hooks/pills.ts 310 lines1// Pure helpers: number/time formatting and the desktop pill SVGs. No engine imports,
2// so the same file renders the HTML preview under plain Node.
3
4export type Tone = 'teal' | 'violet' | 'red' | 'green' | 'blue' | 'gold'
5export type IconName = 'gauge' | 'countdown' | 'calendar' | 'up' | 'down' | 'layers' | 'dollar'
6
7export type Segment =
8 | { kind: 'icon'; name: IconName }
9 | { kind: 'text'; text: string; isBold?: boolean; isDim?: boolean }
10 | { kind: 'bar'; percent: number; elapsed: number | null }
11 | { kind: 'sep' }
12
13export type Pill = { tone: Tone; title: string; alt: string; segments: Segment[] }
14
15const HOUR = 3_600_000
16const DAY = 24 * HOUR
17export const WINDOW_MS = { fiveHour: 5 * HOUR, sevenDay: 7 * DAY }
18
19export function fmtCount(n: number): string {
20 if (n < 1000) return String(Math.round(n))
21 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
22 return `${(n / 1_000_000).toFixed(2)}M`
23}
24
25/** A window size: "1M", "200k", "1.5M". */
26export function fmtWindow(n: number): string {
27 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(2)}M`
28 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
29 return String(n)
30}
31
32/** The context fill as whole percent: the reading's own, else tokens / window. */
33export function contextPercent(c: { tokens: number | null; window: number; percent: number | null }): number | null {
34 if (c.percent != null) return c.percent
35 if (c.tokens == null || c.window <= 0) return null
36 return Math.round((c.tokens / c.window) * 100)
37}
38
39export function fmtUsd(n: number): string {
40 return `$${n.toFixed(2)}`
41}
42
43export function fmtPercent(n: number): string {
44 return `${Math.round(n)}%`
45}
46
47/** "2h 40m" under a day, "1d 7h" from a day up, "12m" under an hour. */
48export function fmtLeft(ms: number): string {
49 const m = Math.max(0, Math.floor(ms / 60_000))
50 const d = Math.floor(m / 1440)
51 const h = Math.floor((m % 1440) / 60)
52 const mm = m % 60
53 if (d > 0) return `${d}d ${h}h`
54 if (h > 0) return `${h}h ${mm}m`
55 return `${mm}m`
56}
57
58/** Share of the window already gone: 1 − left / window, clamped to 0..1. */
59export function elapsedShare(leftMs: number, windowMs: number): number {
60 return Math.min(1, Math.max(0, 1 - leftMs / windowMs))
61}
62
63export function level(percent: number): 'ok' | 'warn' | 'high' {
64 return percent >= 90 ? 'high' : percent >= 70 ? 'warn' : 'ok'
65}
66
67// Light: pastel ground, dark ink. Dark: deep tinted ground, light ink.
68const TONES: Record<Tone, { bg: string; fg: string; dbg: string; dfg: string }> = {
69 teal: { bg: '#d3efe6', fg: '#1d5546', dbg: '#163a31', dfg: '#9fe2cb' },
70 violet: { bg: '#e5dff8', fg: '#45358a', dbg: '#2b2548', dfg: '#c9befa' },
71 red: { bg: '#f9ddd6', fg: '#8a3122', dbg: '#40231d', dfg: '#f4b4a6' },
72 green: { bg: '#dcf0d4', fg: '#2c6622', dbg: '#20351a', dfg: '#b5e4a5' },
73 blue: { bg: '#dde4f9', fg: '#30448e', dbg: '#202944', dfg: '#b6c5f4' },
74 gold: { bg: '#f6eac4', fg: '#765608', dbg: '#3b3115', dfg: '#ecd38b' },
75}
76
77const ICONS: Record<IconName, string> = {
78 gauge: '<path d="M2.6 11.4a5.6 5.6 0 1 1 10.8 0"/><path d="M8 10.6 10.6 6.4"/><circle cx="8" cy="10.6" r=".9" class="f"/>',
79 countdown:
80 '<path d="M2.8 8a5.2 5.2 0 1 0 1.6-3.8"/><path d="M2.6 2.4v2.6h2.6"/><path d="M8 5.2V8l2 1.3"/>',
81 calendar: '<rect x="2.2" y="3.4" width="11.6" height="10.4" rx="2"/><path d="M2.2 7h11.6M5.2 1.9v3M10.8 1.9v3"/>',
82 up: '<path d="M8 11V2.8M4.6 6.2 8 2.8l3.4 3.4M3 13.6h10"/>',
83 down: '<path d="M8 2.8V11M4.6 7.6 8 11l3.4-3.4M3 13.6h10"/>',
84 layers: '<path d="M8 2.2 14 5.3 8 8.4 2 5.3Z"/><path d="M2 8.2l6 3.1 6-3.1M2 11.1l6 3.1 6-3.1"/>',
85 dollar:
86 '<circle cx="8" cy="8" r="6.2"/><path d="M10 6.1c-.4-.7-1.1-1.1-2-1.1-1.1 0-1.9.6-1.9 1.4 0 1.9 3.9 1 3.9 3 0 .9-.9 1.5-2 1.5-.9 0-1.7-.4-2.1-1.1M8 3.7v1.3M8 11v1.3"/>',
87}
88
89const CHAR_W = 7.25
90const H = 24
91const BAR_W = 34
92const ICON = 14
93const GAP = 4
94
95function esc(s: string): string {
96 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
97}
98
99function segWidth(s: Segment): number {
100 switch (s.kind) {
101 case 'icon':
102 return ICON
103 case 'text':
104 return s.text.length * CHAR_W
105 case 'bar':
106 return BAR_W
107 case 'sep':
108 return 1
109 }
110}
111
112export function pillWidth(p: Pill): number {
113 const inner = p.segments.reduce((w, s, i) => w + segWidth(s) + (i > 0 ? GAP : 0), 0)
114 return Math.ceil(inner + 7 + 7)
115}
116
117export function pillSvg(p: Pill): string {
118 const t = TONES[p.tone]
119 const w = pillWidth(p)
120 const mid = H / 2
121 let x = 7
122 const parts: string[] = []
123 p.segments.forEach((s, i) => {
124 if (i > 0) x += GAP
125 switch (s.kind) {
126 case 'icon':
127 parts.push(
128 `<g class="i" transform="translate(${x.toFixed(1)} ${mid - ICON / 2}) scale(${ICON / 16})">${ICONS[s.name]}</g>`,
129 )
130 break
131 case 'text':
132 parts.push(
133 `<text x="${x.toFixed(1)}" y="${mid + 4.2}" class="${s.isBold ? 't b' : s.isDim ? 't d' : 't'}">${esc(s.text)}</text>`,
134 )
135 break
136 case 'bar': {
137 const fill = Math.max(0, Math.min(100, s.percent)) / 100
138 parts.push(`<rect x="${x.toFixed(1)}" y="${mid - 2.5}" width="${BAR_W}" height="5" rx="2.5" class="tr"/>`)
139 if (fill > 0) {
140 parts.push(
141 `<rect x="${x.toFixed(1)}" y="${mid - 2.5}" width="${Math.max(3, BAR_W * fill).toFixed(1)}" height="5" rx="2.5" class="lv-${level(s.percent)}"/>`,
142 )
143 }
144 if (s.elapsed !== null) {
145 const mx = (x + BAR_W * s.elapsed).toFixed(1)
146 parts.push(`<path d="M${mx} ${mid - 6}V${mid + 6}" class="mk"/>`)
147 }
148 break
149 }
150 case 'sep':
151 parts.push(`<path d="M${(x + 0.5).toFixed(1)} ${mid - 6}V${mid + 6}" class="sp"/>`)
152 break
153 }
154 x += segWidth(s)
155 })
156
157 const k = `.u-${p.tone}`
158 const rules = (bg: string, fg: string, ok: string, warn: string, high: string) =>
159 `${k} .bg{fill:${bg}}${k} .t{fill:${fg}}${k} .i{stroke:${fg}}${k} .i .f{fill:${fg}}${k} .tr{fill:${fg}}` +
160 `${k} .mk{stroke:${fg}}${k} .sp{stroke:${fg}}${k} .lv-ok{fill:${ok}}${k} .lv-warn{fill:${warn}}${k} .lv-high{fill:${high}}`
161 const css =
162 `:root{color-scheme:light dark;background:transparent}html,body{margin:0;background:transparent}` +
163 `${k} .t{font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}${k} .b{font-weight:700}` +
164 `${k} .d{opacity:.7}${k} .i{fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}${k} .i .f{stroke:none}` +
165 `${k} .tr{opacity:.18}${k} .mk{stroke-width:1.6;stroke-linecap:round}${k} .sp{opacity:.3}` +
166 rules(t.bg, t.fg, '#2f9e63', '#d39b12', '#d64040') +
167 `@media (prefers-color-scheme:dark){${rules(t.dbg, t.dfg, '#43c07e', '#e8b030', '#ef5b5b')}}`
168
169 return (
170 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}" class="u-${p.tone}">` +
171 `<style>${css}</style>` +
172 `<g><title>${esc(p.title)}</title>` +
173 `<rect width="${w}" height="${H}" rx="${H / 2}" class="bg"/>${parts.join('')}</g></svg>`
174 )
175}
176
177// ---- the band's model, shared by both surfaces ----
178
179export type BandLimit = { percentUsed: number; resetsAt: string | null }
180export type BandInput = {
181 now: number
182 fiveHour: BandLimit | null
183 sevenDay: BandLimit | null
184 context: { tokens: number | null; window: number; percent: number | null } | null
185 costUsd: number | null
186 tokens: {
187 uncachedInput: number
188 cacheWrite: number
189 output: number
190 cacheRead: number
191 requests: number
192 isApprox: boolean
193 } | null
194}
195
196export type LimitView = {
197 label: '5h' | '7d'
198 percent: number
199 left: string | null
200 elapsed: number | null
201}
202
203export function limitView(label: '5h' | '7d', l: BandLimit, now: number): LimitView {
204 const windowMs = label === '5h' ? WINDOW_MS.fiveHour : WINDOW_MS.sevenDay
205 const resetAt = l.resetsAt ? Date.parse(l.resetsAt) : NaN
206 const leftMs = Number.isFinite(resetAt) ? Math.max(0, resetAt - now) : null
207 return {
208 label,
209 percent: l.percentUsed,
210 left: leftMs === null ? null : fmtLeft(leftMs),
211 elapsed: leftMs === null ? null : elapsedShare(leftMs, windowMs),
212 }
213}
214
215function contextLine(b: BandInput): string {
216 const c = b.context
217 if (!c || c.tokens == null) return 'Context: no reading yet'
218 const pct = contextPercent(c)
219 return `Context: ${fmtCount(c.tokens)} / ${fmtWindow(c.window)}${pct != null ? ` (${pct}%)` : ''}`
220}
221
222function hhmm(iso: string | null): string {
223 if (!iso) return ''
224 const d = new Date(iso)
225 if (Number.isNaN(d.getTime())) return ''
226 const pad = (n: number) => String(n).padStart(2, '0')
227 return ` (${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())})`
228}
229
230/** Pills grouped as [5h 7d] [in out context] [cost]; empty groups dropped. */
231export function buildGroups(b: BandInput): Pill[][] {
232 const limits: Pill[] = []
233 for (const [label, l, tone, icon, name] of [
234 ['5h', b.fiveHour, 'teal', 'gauge', '5-hour'],
235 ['7d', b.sevenDay, 'violet', 'calendar', '7-day'],
236 ] as const) {
237 if (!l) continue
238 const v = limitView(label, l, b.now)
239 const segs: Segment[] = [
240 { kind: 'icon', name: icon },
241 { kind: 'text', text: label },
242 { kind: 'bar', percent: v.percent, elapsed: v.elapsed },
243 { kind: 'text', text: fmtPercent(v.percent), isBold: true },
244 ]
245 if (v.left !== null) {
246 segs.push({ kind: 'sep' }, { kind: 'text', text: v.left })
247 }
248 limits.push({
249 tone,
250 alt: `${label} limit ${fmtPercent(v.percent)} used${v.left ? `, resets in ${v.left}` : ''}`,
251 title:
252 `${name} limit: ${l.percentUsed}% used\n` +
253 (v.left ? `Resets in ${v.left}${hhmm(l.resetsAt)}\n` : '') +
254 (v.elapsed !== null ? `Window elapsed: ${Math.round(v.elapsed * 100)}%` : ''),
255 segments: segs,
256 })
257 }
258
259 const tokens: Pill[] = []
260 const t = b.tokens
261 if (t) {
262 const p = t.isApprox ? '~' : ''
263 const tail = `\nRequests: ${t.requests}${t.isApprox ? '\n~ approximate: summed from turn ends (transcript not readable)' : ''}`
264 const input = t.uncachedInput + t.cacheWrite
265 tokens.push(
266 {
267 tone: 'red',
268 alt: `Input ${p}${fmtCount(input)} tokens`,
269 title:
270 `Input tokens: ${p}${fmtCount(input)}\n uncached: ${fmtCount(t.uncachedInput)}\n cache write: ${fmtCount(t.cacheWrite)}` +
271 `\n cache read: ${fmtCount(t.cacheRead)}${tail}`,
272 segments: [{ kind: 'icon', name: 'up' }, { kind: 'text', text: p + fmtCount(input) }],
273 },
274 {
275 tone: 'green',
276 alt: `Output ${p}${fmtCount(t.output)} tokens`,
277 title: `Output tokens: ${p}${fmtCount(t.output)}${tail}`,
278 segments: [{ kind: 'icon', name: 'down' }, { kind: 'text', text: p + fmtCount(t.output) }],
279 },
280 )
281 }
282 const c = b.context
283 if (c && c.tokens != null) {
284 const pct = contextPercent(c) ?? 0
285 tokens.push({
286 tone: 'blue',
287 alt: `Context ${fmtCount(c.tokens)} of ${fmtWindow(c.window)} tokens, ${fmtPercent(pct)}`,
288 title: `${contextLine(b)}\nInput the last response was answered over`,
289 segments: [
290 { kind: 'icon', name: 'layers' },
291 { kind: 'text', text: `${fmtCount(c.tokens)}/${fmtWindow(c.window)}` },
292 { kind: 'sep' },
293 { kind: 'text', text: fmtPercent(pct), isBold: true },
294 ],
295 })
296 }
297
298 const cost: Pill[] = []
299 if (b.costUsd != null) {
300 cost.push({
301 tone: 'gold',
302 alt: `Session cost ${fmtUsd(b.costUsd)}`,
303 title: `Session cost: ${fmtUsd(b.costUsd)}\nAt API list price (not what a subscription bills)`,
304 segments: [{ kind: 'icon', name: 'dollar' }, { kind: 'text', text: fmtUsd(b.costUsd) }],
305 })
306 }
307
308 return [limits, tokens, cost].filter(g => g.length > 0)
309}
310types/index.d.ts 30 lines1export type UsageLimit = { percentUsed: number; resetsAt: string | null }
2
3export type UsageLimits = { fiveHour: UsageLimit | null; sevenDay: UsageLimit | null }
4
5export type UsageContext = { tokens: number | null; window: number; percent: number | null }
6
7export type UsageTokens = {
8 uncachedInput: number
9 cacheWrite: number
10 output: number
11 cacheRead: number
12 requests: number
13 /** True when counted from turn.complete instead of the transcript. */
14 isApprox: boolean
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'usage-band': {
20 limits: UsageLimits | null
21 context: UsageContext | null
22 costUsd: number | null
23 tokens: UsageTokens | null
24 fallback: UsageTokens | null
25 isHidden: boolean
26 now: number
27 }
28 }
29}
30