5h/7d rate limits with pace markers, session tokens and cost: above the prompt, or in a /usage pane

A row of pills above the Claude Code prompt showing how much of your plan you've used, how fast, and what this session has cost. It stretches to the band's width and follows your light or dark theme.

| Pill | Meaning |
|---|---|
| gauge 5h | 5-hour rate-limit window: bar with % used, then time until it resets |
| calendar 7d | 7-day window, same layout |
| tick inside a bar | Pace marker: how much of the window's time has passed. Fill short of the tick means you're under pace; past it, you're using it faster than it refills |
| upload | Input tokens sent this session (uncached + cache writes) |
| download | Output tokens generated this session |
| layers | Input tokens served from the prompt cache this session |
| dollar | Session cost in USD (the same figure as /cost) |
Rate-limit pills appear only on a Claude subscription (Pro / Max); the cost pill only where Claude Code keeps a cost ledger.
| Claude Code surface | Supported | How it shows |
|---|---|---|
| Claude desktop app, Code tab | ✅ | Band above the prompt: SVG pills, colors follow the app's light/dark theme |
| Terminal (CLI), incl. VS Code / JetBrains integrated terminal | ✅ | Band above the prompt: text with Unicode glyphs, colored by your /theme |
| VS Code extension panel | ✅ | Pane, opened automatically when VS Code joins the session (the extension has no band above the prompt) |
| Claude mobile app (Remote Control) | ⚠️ text | Run /usage: shows a text summary (% used, pace, reset time, tokens, cost). A phone following a session over Remote Control mirrors the transcript as text and never asks mods to draw |
| Any surface | ✅ | /usage prints the summary; surfaces that draw mod UI (desktop, VS Code) replace it with live pills and also open the pane |
The mod runs inside Claude Code on the machine where it's installed. The desktop app and VS Code are remote surfaces: they ask that Claude Code what to draw, so there is nothing to install in the extension. Tested 2026-10-03: a phone following a desktop session over Remote Control does not attach as a drawing surface (it only mirrors transcript text), so /usage falls back to its text summary there.
Requires Claude Code 2.1.286 or newer.
Desktop app and terminal: run once in any shell; both read the same ~/.claude config:
claude plugin marketplace add ravipatel7/claude-mods
claude plugin install usage-bar@claude-mods
Then start a new Code session in the desktop app, or a new claude session in the terminal.
Inside a terminal session you can do the same with /plugin marketplace add ravipatel7/claude-mods and /plugin install usage-bar@claude-mods.
Try it without installing:
claude --plugin-dir /path/to/claude-mods/mods/usage-bar
claude plugin marketplace update claude-mods
claude plugin update usage-bar@claude-mods
claude plugin uninstall usage-bar@claude-mods
Hide it for a moment without uninstalling: collapse the band with ctrl+x ctrl+a.
hooks/register.tsx hooks four events:
session.start seeds rate limits and cost from $.session.usage() and starts a one-minute tick for the reset countdowns.session.measure: Claude Code pushes fresh rate-limit and cost figures after each turn.turn.complete adds each turn's token usage (subagents included) to the running totals.ui.render on AbovePrompt draws the row and steps aside while a survey uses the band.ui.render on the usage-bar Pane draws the same row; /usage (a registered command) opens it and draws the same live row in its own output row (CommandOutput, raised on every surface), and session.start / session.attach open it automatically when a VS Code client is attached.On the desktop the row is one SVG sized to the band: the usage bars stretch first, leftover space separates the limits / tokens / cost groups, and on a narrow band the row scales down rather than wrapping. If it would shrink by more than 15% (a phone), it stacks into two rows instead: limits on top, tokens and cost below, both rows at the same scale. Colors are CSS variables switched by prefers-color-scheme. Icons are from Lucide (ISC license).
Values live in $.state (declared in types/index.d.ts): totals survive a reload of the mod and reset with each new session.
Constants at the top of hooks/register.tsx:
TINTS: light and dark background/accent pair per pillICONS: SVG pathsH, PAD, BAR_W, BAR_MAX: pill geometryCELL_PX: px per column the desktop reports (7.8 by default). If the row stops short of, or overshoots, the band's right edge, adjust this.claude plugin validate mods/usage-barhooks/register.tsx 302 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Limit, Tokens } from '../types'
5
6const tokens = atom({ plugin: 'usage-bar', key: 'tokens' } as const, { input: 0, output: 0, cacheRead: 0 })
7const limits = atom({ plugin: 'usage-bar', key: 'limits' } as const, [])
8const usd = atom({ plugin: 'usage-bar', key: 'usd' } as const, null)
9const now = atom({ plugin: 'usage-bar', key: 'now' } as const, 0)
10
11// Lucide icons (ISC license), 24x24 stroke paths.
12const ICONS = {
13 gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
14 calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
15 history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
16 upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
17 download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
18 layers: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
19 dollar: '<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>',
20}
21type IconName = keyof typeof ICONS
22
23// Per-pill tint: [light bg, light accent, dark bg, dark accent].
24const TINTS = {
25 sage: ['#e4ebe5', '#5d7a64', '#263129', '#9cc2a5'],
26 violet: ['#e8e5f6', '#6f5fc4', '#2b2843', '#b4a9f2'],
27 peach: ['#f6e3dc', '#bf5f45', '#3b2a25', '#f0a48c'],
28 mint: ['#e1ede3', '#4a8a58', '#233128', '#8fd29e'],
29 blue: ['#e2e6f7', '#5466c2', '#262c40', '#a5b2f6'],
30 sand: ['#f1ebd4', '#9c8228', '#352f1e', '#e2c96f'],
31}
32type Tint = keyof typeof TINTS
33
34const WINDOWS = [
35 { kind: 'five_hour', label: '5h', icon: 'gauge', tint: 'sage', ms: 5 * 3600_000 },
36 { kind: 'seven_day', label: '7d', icon: 'calendar', tint: 'violet', ms: 7 * 86400_000 },
37] as const
38
39export const fmtTokens = (n: number) =>
40 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
41
42export const fmtLeft = (ms: number) => {
43 const m = Math.max(0, Math.round(ms / 60_000))
44 const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60
45 return d ? `${d}d ${h}h` : h ? `${h}h ${mm}m` : `${mm}m`
46}
47
48type Piece =
49 | { t: 'icon'; name: IconName }
50 | { t: 'text'; s: string; bold?: boolean; muted?: boolean }
51 | { t: 'bar'; pct: number; pace: number | null; w?: number }
52 | { t: 'sep' }
53
54// `group` starts a new cluster; leftover width goes into the gaps before clusters.
55type Pill = { key: string; tint: Tint; alt: string; pieces: Piece[]; group?: boolean }
56
57const buildPills = (t: Tokens, ls: Limit[], cost: number | null, at: number): Pill[] => {
58 const pills: Pill[] = []
59 for (const w of WINDOWS) {
60 const l = ls.find(x => x.kind === w.kind)
61 if (!l) continue
62 const resetAt = l.resetsAt ? Date.parse(l.resetsAt) : NaN
63 const left = Number.isNaN(resetAt) || !at ? null : resetAt - at
64 // Pace marker: share of the window's time already elapsed.
65 const pace = left === null ? null : Math.min(1, Math.max(0, 1 - left / w.ms))
66 const pct = Math.round(l.percentUsed)
67 const pieces: Piece[] = [
68 { t: 'icon', name: w.icon },
69 { t: 'text', s: w.label, muted: true },
70 { t: 'bar', pct: l.percentUsed, pace },
71 { t: 'text', s: `${pct}%`, bold: true },
72 ]
73 if (left !== null) pieces.push({ t: 'sep' }, { t: 'icon', name: 'history' }, { t: 'text', s: fmtLeft(left), muted: true })
74 pills.push({ key: w.kind, tint: w.tint, alt: `${w.label} limit ${pct}% used${left !== null ? `, resets in ${fmtLeft(left)}` : ''}`, pieces })
75 }
76 const stat = (key: string, tint: Tint, name: IconName, s: string, alt: string): Pill =>
77 ({ key, tint, alt, pieces: [{ t: 'icon', name }, { t: 'text', s }] })
78 pills.push(
79 { ...stat('in', 'peach', 'upload', fmtTokens(t.input), `${fmtTokens(t.input)} input tokens`), group: true },
80 stat('out', 'mint', 'download', fmtTokens(t.output), `${fmtTokens(t.output)} output tokens`),
81 stat('cache', 'blue', 'layers', fmtTokens(t.cacheRead), `${fmtTokens(t.cacheRead)} cached input tokens`),
82 )
83 if (cost !== null) pills.push({ ...stat('usd', 'sand', 'dollar', `$${cost.toFixed(2)}`, `$${cost.toFixed(2)} session cost`), group: true })
84 return pills
85}
86
87// --- Desktop: one SVG per pill; colors switch with prefers-color-scheme. ---
88
89const H = 24, PAD = 8, ICON = 14, GAP = 5, CW = 7.3, BAR_W = 44, BAR_MAX = 140, FONT = 12, PILL_GAP = 6
90// ponytail: desktop reports width in code-font cells, not px; calibrated by eye. Tune if the row over/undershoots.
91const CELL_PX = 7.8
92
93const pieceW = (p: Piece) =>
94 p.t === 'icon' ? ICON : p.t === 'text' ? p.s.length * CW : p.t === 'bar' ? p.w ?? BAR_W : 1
95
96// One pill as a group at x; its tint arrives as CSS vars on its class.
97const pillSvg = (tint: Tint, pieces: Piece[], x0: number) => {
98 const w = Math.ceil(PAD * 2 + pieces.reduce((a, p) => a + pieceW(p), 0) + GAP * (pieces.length - 1))
99 const mid = H / 2
100 let x = PAD
101 const body = pieces.map(p => {
102 const at = x
103 x += pieceW(p) + GAP
104 switch (p.t) {
105 case 'icon':
106 return `<svg x="${at}" y="${mid - ICON / 2}" width="${ICON}" height="${ICON}" viewBox="0 0 24 24" class="ic">${ICONS[p.name]}</svg>`
107 case 'text':
108 return `<text x="${at}" y="${mid}" class="${p.muted ? 'mu' : 'fg'}${p.bold ? ' b' : ''}">${p.s}</text>`
109 case 'sep':
110 return `<rect x="${at}" y="${mid - 6}" width="1" height="12" class="tr"/>`
111 case 'bar': {
112 const bw = p.w ?? BAR_W
113 const fill = (Math.max(0, Math.min(100, p.pct)) / 100) * bw
114 const mark = p.pace === null ? '' : `<rect x="${(at + p.pace * (bw - 2)).toFixed(1)}" y="${mid - 6}" width="2" height="12" rx="1" class="mk"/>`
115 return `<rect x="${at}" y="${mid - 2.5}" width="${bw}" height="5" rx="2.5" class="tr"/>` +
116 `<rect x="${at}" y="${mid - 2.5}" width="${fill.toFixed(1)}" height="5" rx="2.5" class="fi"/>${mark}`
117 }
118 }
119 }).join('')
120 return { width: w, body: `<g class="t-${tint}" transform="translate(${x0})"><rect class="pill" width="${w}" height="${H}" rx="${H / 2}"/>${body}</g>` }
121}
122
123const tintCss = (i: 0 | 2) =>
124 Object.entries(TINTS).map(([k, v]) => `.t-${k}{--bg:${v[i]};--ac:${v[i + 1]}}`).join('')
125
126// The whole row as one SVG: with no width the surface scales it to fit, so it never wraps.
127// The whole row as one SVG sized to `avail` px: bars stretch first (up to BAR_MAX),
128// the rest spreads into the gaps between clusters. Narrower than natural, it scales down.
129const naturalW = (pills: Pill[]) =>
130 pills.reduce((a, p) => a + pillSvg(p.tint, p.pieces, 0).width, 0) + PILL_GAP * (pills.length - 1)
131
132export const rowSvg = (pills: Pill[], avail = 0) => {
133 const natural = naturalW(pills)
134 let extra = Math.max(0, avail - natural)
135 const bars = pills.flatMap(p => p.pieces.filter(x => x.t === 'bar'))
136 const grow = bars.length ? Math.min(extra / bars.length, BAR_MAX - BAR_W) : 0
137 extra -= grow * bars.length
138 const breaks = pills.filter((p, i) => i > 0 && p.group).length
139 let x = 0
140 const groups = pills.map((p, i) => {
141 if (i > 0 && p.group && breaks) x += extra / breaks
142 const pieces = p.pieces.map(q => (q.t === 'bar' ? { ...q, w: BAR_W + grow } : q))
143 const g = pillSvg(p.tint, pieces, Math.round(x))
144 x += g.width + PILL_GAP
145 return g.body
146 }).join('')
147 const w = Math.max(1, Math.round(x - PILL_GAP))
148 const css =
149 `svg{--fg:#2a2d2b;--mu:#5f6661;--tr:rgba(0,0,0,.13);--mk:#3a3f3c}${tintCss(0)}` +
150 `@media (prefers-color-scheme:dark){svg{--fg:#ecebe8;--mu:#a9aea9;--tr:rgba(255,255,255,.18);--mk:#ecebe8}${tintCss(2)}}` +
151 `.pill{fill:var(--bg)}.ic{fill:none;stroke:var(--ac);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}` +
152 `text{font:500 ${FONT}px ui-monospace,"SF Mono",Menlo,monospace;dominant-baseline:central}` +
153 `.fg{fill:var(--fg)}.mu{fill:var(--mu)}.b{font-weight:700;fill:var(--fg)}.tr{fill:var(--tr)}.fi{fill:var(--ac)}.mk{fill:var(--mk)}`
154 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}"><style>${css}</style>${groups}</svg>`
155}
156
157// --- Terminal: text in theme color keys, so light and dark themes both read. ---
158
159const TERM_ICON: Record<IconName, string> = {
160 gauge: '◷', calendar: '▦', history: '↺', upload: '↑', download: '↓', layers: '≋', dollar: '$',
161}
162const TERM_COLOR: Record<Tint, string> = {
163 sage: 'success', violet: 'permission', peach: 'claude', mint: 'success', blue: 'suggestion', sand: 'warning',
164}
165const termBar = (pct: number) => {
166 const n = Math.round((Math.max(0, Math.min(100, pct)) / 100) * 8)
167 return ['━'.repeat(n), '━'.repeat(8 - n)]
168}
169
170// Markdown for surfaces that show the row's text, and for the model.
171export const summary = (t: Tokens, ls: Limit[], cost: number | null, at: number) => {
172 const lines = WINDOWS.flatMap(w => {
173 const l = ls.find(x => x.kind === w.kind)
174 if (!l) return []
175 const left = l.resetsAt && at ? Date.parse(l.resetsAt) - at : NaN
176 if (Number.isNaN(left)) return [`**${w.label}** ${Math.round(l.percentUsed)}%`]
177 const pace = Math.round(Math.min(1, Math.max(0, 1 - left / w.ms)) * 100)
178 const flag = l.percentUsed > pace ? ' · ahead of pace' : ''
179 return [`**${w.label}** ${Math.round(l.percentUsed)}% used · ${pace}% of window elapsed${flag} · resets in ${fmtLeft(left)}`]
180 })
181 const tok = `↑ ${fmtTokens(t.input)} in · ↓ ${fmtTokens(t.output)} out · ${fmtTokens(t.cacheRead)} cached`
182 lines.push(cost === null ? tok : `${tok} · **$${cost.toFixed(2)}**`)
183 return lines.join(' \n')
184}
185
186const PANE = 'usage-bar'
187const openPane = ($: { ui: { open: (a: { id: string; title: string }) => Promise<unknown> } }) =>
188 $.ui.open({ id: PANE, title: 'Usage' })
189
190type TermEls = ElementTable<'terminal'>
191const termTree = ({ Box, Text }: TermEls, pills: Pill[]) => (
192 <Box flexWrap="wrap" columnGap={3}>
193 {pills.map(p => (
194 <Box key={p.key}>
195 <Text>
196 {p.pieces.map(x => {
197 if (x.t === 'icon') return <Text color={TERM_COLOR[p.tint]}>{TERM_ICON[x.name]} </Text>
198 if (x.t === 'text') return <Text bold={x.bold} dimColor={x.muted}>{x.s} </Text>
199 if (x.t === 'sep') return <Text dimColor>· </Text>
200 const [full, empty] = termBar(x.pct)
201 return <Text><Text color={TERM_COLOR[p.tint]}>{full}</Text><Text dimColor>{empty}</Text> </Text>
202 })}
203 </Text>
204 </Box>
205 ))}
206 </Box>
207)
208
209// One row when it fits (or shrinks <15%); otherwise limits on top, tokens + cost below.
210type SvgEls = ElementTable<'desktop' | 'vscode' | 'mobile'>
211const svgTree = ({ Box, Svg }: SvgEls, pills: Pill[], columns: number) => {
212 const avail = Math.floor(columns * CELL_PX)
213 // Lay out at `layout` px, draw at `avail`: every row then shares one scale.
214 const row = (ps: Pill[], layout: number) =>
215 <Svg source={rowSvg(ps, layout)} alt={ps.map(p => p.alt).join('; ')} width={avail} />
216 const split = pills.findIndex(p => p.group)
217 if (split <= 0 || naturalW(pills) * 0.85 <= avail) return row(pills, avail)
218 const [top, bottom] = [pills.slice(0, split), pills.slice(split)]
219 const layout = Math.max(avail, naturalW(top), naturalW(bottom))
220 return (
221 <Box flexDirection="column" gap={1}>
222 {row(top, layout)}
223 {row(bottom, layout)}
224 </Box>
225 )
226}
227
228const currentPills = async ($: Parameters<typeof read>[0]) =>
229 buildPills(await read($, tokens), await read($, limits), await read($, usd), await read($, now))
230
231export const register: Register = on => {
232 on('session.start', async ($, e, next) => {
233 const u = await $.session.usage()
234 await update($, limits, () => u.rateLimits)
235 await update($, usd, () => u.cost?.usd ?? null)
236 const tick = async () => {
237 const ms = await $.clock.now()
238 await update($, now, () => ms)
239 }
240 await tick()
241 // ponytail: minute tick only drives the reset countdown; finer is wasted redraws.
242 $.clock.every(60_000, () => void tick())
243 await $.command.register({ name: 'usage', description: 'Show rate limits, tokens and cost in a pane' })
244 // VS Code has no band above the prompt, so the pane stands in for it there.
245 if ((await $.session.surfaces()).includes('vscode')) void openPane($)
246 return next(e)
247 })
248
249 on('session.attach', { surface: 'vscode' }, ($, e, next) => {
250 void openPane($)
251 return next(e)
252 })
253
254 // Surfaces that draw mod UI replace the row's text with live pills; the rest
255 // (Remote Control on a phone mirrors text only) show the markdown summary.
256 on('command.run', { command: 'usage' }, async $ => {
257 void openPane($)
258 return { text: summary(await read($, tokens), await read($, limits), await read($, usd), await read($, now)) }
259 })
260
261 on('ui.render', { component: 'CommandOutput', props: { command: 'usage' } }, async ($, e) => {
262 const pills = await currentPills($)
263 const columns = Math.max(20, (e.viewport?.columns ?? 80) - 4)
264 return e.surface === 'terminal'
265 ? termTree($.ui.resolve(e), pills)
266 : svgTree($.ui.resolve(e), pills, columns)
267 })
268
269 on('session.measure', async ($, e, next) => {
270 await update($, limits, () => e.rateLimits)
271 if (e.cost) await update($, usd, () => e.cost!.usd)
272 return next(e)
273 })
274
275 on('turn.complete', async ($, e, next) => {
276 const u = e.usage
277 if (u) {
278 await update($, tokens, (t: Tokens) => ({
279 input: t.input + u.input_tokens + u.cache_creation_input_tokens,
280 output: t.output + u.output_tokens,
281 cacheRead: t.cacheRead + u.cache_read_input_tokens,
282 }))
283 }
284 return next(e)
285 })
286
287 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
288 if (e.props.hasSurvey) return next(e)
289 const pills = await currentPills($)
290 return e.surface === 'terminal'
291 ? termTree($.ui.resolve(e), pills)
292 : svgTree($.ui.resolve(e), pills, e.props.bodyColumns)
293 })
294
295 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
296 const pills = await currentPills($)
297 return e.surface === 'terminal'
298 ? termTree($.ui.resolve(e), pills)
299 : svgTree($.ui.resolve(e), pills, e.props.bodyColumns)
300 })
301}
302types/index.d.ts 14 lines1export type Tokens = { input: number; output: number; cacheRead: number }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'usage-bar': {
7 tokens: Tokens
8 limits: Limit[]
9 usd: number | null
10 now: number
11 }
12 }
13}
14