Band above the prompt: 5h/7d rate-limit bars with pace marker and reset countdowns, context window fill, session tokens in/out and cost

A Claude Code mod that draws a one-row usage band above the prompt in Claude Code Desktop:
<img alt="usage-band: 5h limit 38% resetting in 1h55m, 7d limit 71% resetting in 1d16h, context 14% of 1M, 84.3k tokens in and 21.7k out, cost $3.42" src="docs/band-light.svg">
1M, 200k).The band stays on one row at full text size. On a narrow window the rightmost pills are clipped instead of wrapping or shrinking. It follows the app's light and dark themes. On the terminal it is off by default (SHOW_ON_TERMINAL in hooks/register.tsx).
In Claude Code:
/plugin marketplace add DXMHXR/usage-band
/plugin install usage-band@usage-band
claude plugin validate .
claude plugin test .
bun scripts/screenshot.ts # re-render docs/band-*.svg after changing hooks/svg.tshooks/register.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { SessionContextUsage } from 'claude-code'
5import type { Limit, Snapshot, Tokens } from '../types'
6import { LABEL, barFor, contextPercent, formatCountdown, formatTokens, formatWindow } from './format'
7import { bandPills } from './svg'
8
9const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, { input: 0, output: 0 })
10const snapshot = atom({ plugin: 'usage-band', key: 'snapshot' } as const, { limits: [], usd: null, context: null })
11const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
12
13// The band draws on the desktop (and the other app surfaces) only; true brings back the terminal band.
14const SHOW_ON_TERMINAL = false
15
16// Terminal colors are theme keys, so the band follows /theme (light, dark, ansi).
17const C = {
18 bar: 'userMessageBackground',
19 ink: 'text',
20 dim: 'inactive',
21 divider: 'subtle',
22 track: 'rate_limit_empty',
23 fill: 'rate_limit_fill',
24 hot: 'warning',
25 marker: 'text',
26 limit5h: 'success',
27 limit7d: 'permission',
28 input: 'claude',
29 output: 'success',
30 context: 'suggestion',
31 cost: 'warning',
32}
33
34const toSnapshot = (
35 limits: readonly Limit[],
36 usd: number | undefined,
37 context: SessionContextUsage,
38): Snapshot => ({
39 limits: limits
40 .filter(l => l.kind in LABEL)
41 .map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
42 usd: usd ?? null,
43 context: { tokens: context.tokens ?? null, window: context.window, percent: context.percent ?? null },
44})
45
46const refresh = async ($: EngineInterface) => {
47 const u = await $.session.usage()
48 await update($, snapshot, () => toSnapshot(u.rateLimits, u.cost?.usd, u.context))
49 await update($, now, () => Date.now())
50}
51
52export const register: Register = on => {
53 let ticker: { cancel: () => void } | null = null
54
55 on('session.start', async ($, e, next) => {
56 const result = await next(e)
57 await refresh($)
58 ticker?.cancel()
59 // Countdowns and the pace marker move with the clock, not only with turns.
60 ticker = $.clock.every(30_000, () => void update($, now, () => Date.now()))
61 return result
62 })
63
64 on('session.measure', async ($, e, next) => {
65 await update($, snapshot, () => toSnapshot(e.rateLimits, e.cost?.usd, e.context))
66 await update($, now, () => Date.now())
67 return next(e)
68 })
69
70 on('turn.complete', async ($, e, next) => {
71 const usage = e.usage
72 if (usage) {
73 await update($, tokens, (t: Tokens) => ({
74 input: t.input + usage.input_tokens,
75 output: t.output + usage.output_tokens,
76 }))
77 }
78 return next(e)
79 })
80
81 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82 if (e.props.hasSurvey) return next(e)
83 if (e.surface === 'terminal' && !SHOW_ON_TERMINAL) return next(e)
84
85 const t = await read($, tokens)
86 const s = await read($, snapshot)
87 const at = (await read($, now)) || Date.now()
88
89 if (e.surface !== 'terminal') {
90 const { Box, Svg } = $.ui.resolve(e)
91 return (
92 // One row at full text size: a narrow window clips the rightmost pills instead of wrapping or scaling.
93 <Box flexWrap="nowrap" overflow="hidden">
94 {bandPills(t, s, at).map(p => (
95 <Box key={p.key} flexShrink={0}>
96 <Svg source={p.source} alt={p.alt} width={p.width} height={p.height} />
97 </Box>
98 ))}
99 </Box>
100 )
101 }
102
103 const { Box, Text } = $.ui.resolve(e)
104
105 const pill = (key: string, glyph: string, tone: string, children: RenderChildren) => (
106 <Box key={key} marginRight={2}>
107 <Text color={tone}>{glyph} </Text>
108 {children}
109 </Box>
110 )
111
112 const divider = (key: string) => (
113 <Box key={key} marginRight={2}>
114 <Text color={C.divider}>│</Text>
115 </Box>
116 )
117
118 const meter = (key: string, glyph: string, tone: string, label: string, percent: number, marker: number | null, tail: string | null) => {
119 const width = 10
120 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
121 const cells = Array.from({ length: width }, (_, i) => {
122 if (i === marker) return <Text color={C.marker}>┃</Text>
123 return <Text color={i < filled ? (percent >= 80 ? C.hot : C.fill) : C.track}>━</Text>
124 })
125 return pill(
126 key,
127 glyph,
128 tone,
129 <Box>
130 <Text color={C.dim}>{label} </Text>
131 {cells}
132 <Text color={C.ink} bold> {Math.round(percent)}%</Text>
133 {tail ? <Text color={C.dim}> {tail}</Text> : null}
134 </Box>,
135 )
136 }
137
138 const limitPill = (l: Limit) => {
139 const reset = l.resetsAt ? formatCountdown(Date.parse(l.resetsAt) - at) : null
140 const five = l.kind === 'five_hour'
141 return meter(l.kind, five ? '◔' : '▦', five ? C.limit5h : C.limit7d, LABEL[l.kind], l.percentUsed, barFor(l, at).marker, reset ? `↻ ${reset}` : null)
142 }
143
144 const order = ['five_hour', 'seven_day']
145 const limits = [...s.limits].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
146 const ctxPercent = contextPercent(s.context)
147
148 return (
149 <Box backgroundColor={C.bar} paddingX={1} flexWrap="wrap">
150 {limits.map(limitPill)}
151 {s.context && ctxPercent !== null
152 ? meter('context', '≋', C.context, 'ctx', ctxPercent, null, `${formatTokens(s.context.tokens ?? 0)}/${formatWindow(s.context.window)}`)
153 : pill('context', '≋', C.context, <Text color={C.dim}>—</Text>)}
154 {divider('meters-end')}
155 {pill('in', '↑', C.input, <Text color={C.ink}>{formatTokens(t.input)}</Text>)}
156 {pill('out', '↓', C.output, <Text color={C.ink}>{formatTokens(t.output)}</Text>)}
157 {s.usd !== null ? divider('tokens-end') : null}
158 {s.usd !== null ? pill('cost', '$', C.cost, <Text color={C.ink}>{s.usd.toFixed(2)}</Text>) : null}
159 </Box>
160 )
161 })
162}
163hooks/format.ts 56 lines1import type { Context, Limit } from '../types'
2
3export const WINDOW_MS: Record<string, number> = {
4 five_hour: 5 * 60 * 60 * 1000,
5 seven_day: 7 * 24 * 60 * 60 * 1000,
6}
7
8export const LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
9
10/** 15600 -> "15.6k", 954200 -> "954.2k", 1250000 -> "1.3M", 812 -> "812" */
11export const formatTokens = (n: number): string => {
12 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
13 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
14 return String(Math.round(n))
15}
16
17/** Time until reset: "2h 40m", "1d 7h", "12m", "now" */
18export const formatCountdown = (ms: number): string => {
19 if (ms <= 0) return 'now'
20 const minutes = Math.floor(ms / 60_000)
21 const days = Math.floor(minutes / 1440)
22 const hours = Math.floor((minutes % 1440) / 60)
23 const mins = minutes % 60
24 if (days > 0) return `${days}d ${hours}h`
25 if (hours > 0) return `${hours}h ${mins}m`
26 return `${mins}m`
27}
28
29export type Bar = { filled: number; marker: number | null; width: number }
30
31/**
32 * The bar's filled cells (percent used) and where the pace marker sits: how
33 * far through the window the clock is, so filled past the marker means the
34 * window is being spent faster than it refills.
35 */
36export const barFor = (limit: Limit, now: number, width = 10): Bar => {
37 const filled = Math.max(0, Math.min(width, Math.round((limit.percentUsed / 100) * width)))
38 const windowMs = WINDOW_MS[limit.kind]
39 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
40 if (!windowMs || Number.isNaN(resetsAt)) return { filled, marker: null, width }
41 const elapsed = Math.max(0, Math.min(1, 1 - (resetsAt - now) / windowMs))
42 return { filled, marker: Math.min(width - 1, Math.floor(elapsed * width)), width }
43}
44
45/** How full the context window is, 0..100: the engine's figure, else tokens / window; null when unknown. */
46export const contextPercent = (context: Context | null): number | null => {
47 if (!context) return null
48 if (context.percent !== null) return context.percent
49 if (context.tokens === null || context.window <= 0) return null
50 return Math.min(100, (context.tokens / context.window) * 100)
51}
52
53/** A context window's size, whole: 200000 -> "200k", 1000000 -> "1M" */
54export const formatWindow = (n: number): string =>
55 n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1_000)}k`
56hooks/svg.ts 245 lines1import type { Context, Limit, Snapshot, Tokens } from '../types'
2import { LABEL, WINDOW_MS, contextPercent, formatCountdown, formatTokens, formatWindow } from './format'
3
4/**
5 * The desktop band: rounded SVG pills with icons, their colors switched
6 * by `prefers-color-scheme` so it follows the app's light and dark themes.
7 */
8
9// The band stays one row, so width comes from spacing and bar length, never the font size.
10const FONT = 13
11const CHAR = 7.9 // SF Mono / Menlo advance at 13px
12const PILL_H = 26
13const PAD_X = 9
14const GAP = 6
15const GROUP_GAP = 12
16const ICON = 16
17const ICON_GAP = 6
18const BAR_W = 36
19const CLOCK = 12
20
21type Tone = 'limit5h' | 'limit7d' | 'input' | 'output' | 'context' | 'cost'
22
23const PALETTE = {
24 light: {
25 ink: '#1f1f1f', dim: '#6f6f6b', track: '#cfcfca', fill: '#8fb26b', hot: '#d9822b', marker: '#1f1f1f', divider: '#a9a9a4',
26 limit5h: ['#d7e9df', '#3f8a6c'], limit7d: ['#e4e0f4', '#7a5cc7'], input: ['#f3dad3', '#c4553a'],
27 output: ['#dbebd8', '#4e9a52'], context: ['#dee1f7', '#5865d6'], cost: ['#f0e7cd', '#b4861a'],
28 },
29 dark: {
30 ink: '#ececea', dim: '#a3a39e', track: '#4a4a46', fill: '#8fb26b', hot: '#e8964a', marker: '#ececea', divider: '#62625d',
31 limit5h: ['#1f3a2f', '#6fc4a1'], limit7d: ['#2e2a48', '#a991f0'], input: ['#45291f', '#f08a6e'],
32 output: ['#233a27', '#7cc77f'], context: ['#272c4c', '#8b96f5'], cost: ['#3e3520', '#e0b54a'],
33 },
34} as const
35
36const TONES: Tone[] = ['limit5h', 'limit7d', 'input', 'output', 'context', 'cost']
37
38const css = (p: (typeof PALETTE)['light']) =>
39 [
40 `.ink{fill:${p.ink}}`,
41 `.dim{fill:${p.dim}}`,
42 `.track{fill:${p.track}}`,
43 `.fill{fill:${p.fill}}`,
44 `.hot{fill:${p.hot}}`,
45 `.marker{fill:${p.marker}}`,
46 `.divider{fill:${p.divider}}`,
47 `.clock{stroke:${p.dim}}`,
48 ...TONES.map(t => `.bg-${t}{fill:${p[t][0]}}.fg-${t}{stroke:${p[t][1]};color:${p[t][1]}}`),
49 ].join('')
50
51const STYLE =
52 `text{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:${FONT}px;dominant-baseline:central}` +
53 `.icon{fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}` +
54 css(PALETTE.light) +
55 `@media (prefers-color-scheme:dark){${css(PALETTE.dark)}}`
56
57/** 16×16 stroke icons, drawn in the pill's tone. */
58const ICONS: Record<string, string> = {
59 gauge: '<path d="M2.5 11.5a5.5 5.5 0 1 1 11 0"/><path d="M8 10.5l2.6-3.4"/><circle cx="8" cy="10.5" r=".8" fill="currentColor"/>',
60 calendar:
61 '<rect x="2" y="3" width="12" height="11" rx="2"/><path d="M2 6.5h12M5.5 1.8v2.4M10.5 1.8v2.4"/>' +
62 '<text x="8" y="10.6" text-anchor="middle" stroke="none" fill="currentColor" style="font-size:6.5px;font-weight:700;font-family:-apple-system,sans-serif">7</text>',
63 clock: '<path d="M2.8 8a5.2 5.2 0 1 0 1.6-3.8"/><path d="M2.6 2.4v2.4H5"/><path d="M8 5.4V8l1.8 1.2"/>',
64 upload: '<path d="M2.5 10v2.5a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V10"/><path d="M8 10.5V2.5M5 5.5l3-3 3 3"/>',
65 download: '<path d="M2.5 10v2.5a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V10"/><path d="M8 2.5v8M5 7.5l3 3 3-3"/>',
66 layers: '<path d="M8 2l6 3-6 3-6-3z"/><path d="M2 8l6 3 6-3"/><path d="M2 11l6 3 6-3"/>',
67 coin:
68 '<circle cx="8" cy="8" r="6"/>' +
69 '<text x="8" y="8.4" text-anchor="middle" stroke="none" fill="currentColor" style="font-size:8.5px;font-weight:700;font-family:-apple-system,sans-serif">$</text>',
70}
71
72const icon = (name: string, tone: string, x: number, y: number, size = ICON) =>
73 `<g class="icon ${tone}" transform="translate(${x} ${y}) scale(${size / 16})">${ICONS[name]}</g>`
74
75const textWidth = (s: string) => s.length * CHAR
76
77type Piece = { width: number; draw: (x: number, y: number) => string }
78
79const pill = (tone: Tone, width: number, inner: (x: number, cy: number) => string): Piece => ({
80 width,
81 draw: (x, y) =>
82 `<rect class="bg-${tone}" x="${x}" y="${y}" width="${width}" height="${PILL_H}" rx="${PILL_H / 2}"/>` +
83 inner(x + PAD_X, y + PILL_H / 2),
84})
85
86/** Icon-and-label runs side by side in one pill, e.g. ↑ in then ↓ out. */
87const simplePill = (tone: Tone, runs: { iconName: string; label: string; tone?: Tone }[]): Piece => {
88 const runWidth = (label: string) => ICON + ICON_GAP + textWidth(label)
89 const inner = runs.reduce((w, r, i) => w + runWidth(r.label) + (i > 0 ? 10 : 0), 0)
90 return pill(tone, PAD_X + inner + PAD_X, (x, cy) => {
91 let cx = x
92 return runs
93 .map((r, i) => {
94 if (i > 0) cx += 10
95 const out =
96 icon(r.iconName, `fg-${r.tone ?? tone}`, cx, cy - ICON / 2) +
97 `<text class="ink" x="${cx + ICON + ICON_GAP}" y="${cy}">${r.label}</text>`
98 cx += runWidth(r.label)
99 return out
100 })
101 .join('')
102 })
103}
104
105/** How far through its window the clock is, 0..1, or null when unknown. */
106export const paceFor = (limit: Limit, now: number): number | null => {
107 const windowMs = WINDOW_MS[limit.kind]
108 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
109 if (!windowMs || Number.isNaN(resetsAt)) return null
110 return Math.max(0, Math.min(1, 1 - (resetsAt - now) / windowMs))
111}
112
113type Meter = {
114 tone: Tone
115 iconName: string
116 label: string
117 percent: number
118 /** Where the pace marker sits, 0..1; null draws none. */
119 pace: number | null
120 /** Dim text after the percent, with the clock icon before it when `clock`. */
121 tail: string | null
122 clock: boolean
123}
124
125const meterPill = (m: Meter): Piece => {
126 const percent = `${Math.round(m.percent)}%`
127 const used = Math.max(0, Math.min(1, m.percent / 100))
128 const tailIcon = m.clock ? CLOCK + 4 : 0
129
130 const width =
131 PAD_X + ICON + ICON_GAP + (m.label ? textWidth(m.label) + 7 : 0) + BAR_W + 7 + textWidth(percent) +
132 (m.tail ? 8 + tailIcon + textWidth(m.tail) : 0) + PAD_X
133
134 return pill(m.tone, width, (x, cy) => {
135 let cx = x
136 let out = icon(m.iconName, `fg-${m.tone}`, cx, cy - ICON / 2)
137 cx += ICON + ICON_GAP
138 if (m.label) {
139 out += `<text class="dim" x="${cx}" y="${cy}">${m.label}</text>`
140 cx += textWidth(m.label) + 7
141 }
142 out += `<rect class="track" x="${cx}" y="${cy - 3}" width="${BAR_W}" height="6" rx="3"/>`
143 if (used > 0) {
144 out += `<rect class="${m.percent >= 80 ? 'hot' : 'fill'}" x="${cx}" y="${cy - 3}" width="${Math.max(6, used * BAR_W)}" height="6" rx="3"/>`
145 }
146 if (m.pace !== null) {
147 out += `<rect class="marker" x="${cx + Math.min(BAR_W - 2, m.pace * BAR_W)}" y="${cy - 7}" width="2" height="14" rx="1"/>`
148 }
149 cx += BAR_W + 7
150 out += `<text class="ink" x="${cx}" y="${cy}" font-weight="700">${percent}</text>`
151 cx += textWidth(percent)
152 if (m.tail) {
153 cx += 8
154 if (m.clock) {
155 out += icon('clock', 'clock', cx, cy - CLOCK / 2, CLOCK)
156 cx += tailIcon
157 }
158 out += `<text class="dim" x="${cx}" y="${cy}">${m.tail}</text>`
159 }
160 return out
161 })
162}
163
164const limitPill = (l: Limit, now: number): Piece =>
165 meterPill({
166 tone: l.kind === 'five_hour' ? 'limit5h' : 'limit7d',
167 iconName: l.kind === 'five_hour' ? 'gauge' : 'calendar',
168 label: '',
169 percent: l.percentUsed,
170 pace: paceFor(l, now),
171 tail: l.resetsAt ? formatCountdown(Date.parse(l.resetsAt) - now).replace(' ', '') : null,
172 clock: true,
173 })
174
175const contextPill = (context: Context | null): Piece => {
176 const percent = contextPercent(context)
177 if (!context || percent === null) return simplePill('context', [{ iconName: 'layers', label: '—' }])
178 return meterPill({
179 tone: 'context',
180 iconName: 'layers',
181 label: '',
182 percent,
183 pace: null,
184 tail: formatWindow(context.window),
185 clock: false,
186 })
187}
188
189export type PillSvg = { key: string; source: string; width: number; height: number; alt: string }
190
191/**
192 * The band as one image per pill at a fixed pixel size, so the desktop never
193 * scales the text down. Each image carries its own spacing to the right (wider
194 * at a group's end), transparent, so the app's background shows through.
195 */
196export const bandPills = (tokens: Tokens, snapshot: Snapshot, now: number): PillSvg[] => {
197 const order = ['five_hour', 'seven_day']
198 const limits = [...snapshot.limits].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
199 const ctx = snapshot.context?.tokens
200 const ctxPercent = contextPercent(snapshot.context)
201
202 type Item = { key: string; piece: Piece; alt: string }
203 const groups: Item[][] = [
204 [
205 ...limits.map(l => ({
206 key: l.kind,
207 piece: limitPill(l, now),
208 alt: `${LABEL[l.kind] ?? l.kind} limit ${Math.round(l.percentUsed)}% used`,
209 })),
210 {
211 key: 'context',
212 piece: contextPill(snapshot.context),
213 alt: `context ${ctx == null ? 'unknown' : formatTokens(ctx)}${ctxPercent === null ? '' : ` (${Math.round(ctxPercent)}% full)`}`,
214 },
215 ],
216 [
217 {
218 key: 'tokens',
219 piece: simplePill('input', [
220 { iconName: 'upload', label: formatTokens(tokens.input) },
221 { iconName: 'download', label: formatTokens(tokens.output), tone: 'output' },
222 ]),
223 alt: `${formatTokens(tokens.input)} in, ${formatTokens(tokens.output)} out`,
224 },
225 ],
226 snapshot.usd === null
227 ? []
228 : [{ key: 'cost', piece: simplePill('cost', [{ iconName: 'coin', label: `$${snapshot.usd.toFixed(2)}` }]), alt: `cost $${snapshot.usd.toFixed(2)}` }],
229 ].filter(g => g.length > 0)
230
231 return groups.flatMap((group, gi) =>
232 group.map((item, i) => {
233 const end = i === group.length - 1
234 const width = Math.ceil(item.piece.width) + (end ? (gi < groups.length - 1 ? GROUP_GAP : 0) : GAP)
235 const height = PILL_H
236 const source =
237 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
238 `<style>${STYLE}</style>` +
239 item.piece.draw(0, 0) +
240 `</svg>`
241 return { key: item.key, source, width, height, alt: item.alt }
242 }),
243 )
244}
245types/index.d.ts 11 lines1export type Tokens = { input: number; output: number }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Context = { tokens: number | null; window: number; percent: number | null }
4export type Snapshot = { limits: Limit[]; usd: number | null; context: Context | null }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'usage-band': { tokens: Tokens; snapshot: Snapshot; now: number }
9 }
10}
11