SLOPSHOPPER

usage-bar

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

newpanebandrowscommandtimer
v0.4.0no licenseupdated 2026-10-03ravipatel7/claude-mods/mods/usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ ◷ 5h ━━━━━━━━ 31% ↑ 6.4k ↓ 1.5k ≋ 9 │ ⏺ 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 │ │ › /usage │ ◷ 5h ━━━━━━━━ 31% ↑ 6.4k ↓ 1.5k ≋ 91.0k $ $0.42 │ │ ◷ 5h ━━━━━━━━ 31% ↑ 6.4k ↓ 1.5k ≋ 91.0k $ $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ 5h ━━━━━━━━ 31% ↑ 6.4k ↓ 1.5k ≋ 91.0k $ $0.42
Pane · Usage
◷ 5h ━━━━━━━━ 31% ↑ 6.4k ↓ 1.5k ≋ 91.0k $ $0.42
Command output
◷ 5h ━━━━━━━━ 31% ↑ 6.4k ↓ 1.5k ≋ 91.0k $ $0.42
README

usage-bar

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.

usage-bar in dark, light and narrow layouts

PillMeaning
gauge 5h5-hour rate-limit window: bar with % used, then time until it resets
calendar 7d7-day window, same layout
tick inside a barPace 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
uploadInput tokens sent this session (uncached + cache writes)
downloadOutput tokens generated this session
layersInput tokens served from the prompt cache this session
dollarSession 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.

Where it works

Claude Code surfaceSupportedHow 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)⚠️ textRun /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.

Install

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

Update

claude plugin marketplace update claude-mods
claude plugin update usage-bar@claude-mods

Uninstall

claude plugin uninstall usage-bar@claude-mods

Hide it for a moment without uninstalling: collapse the band with ctrl+x ctrl+a.

How it works

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.

Configure / develop

Constants at the top of hooks/register.tsx:

  • TINTS: light and dark background/accent pair per pill
  • ICONS: SVG paths
  • H, PAD, BAR_W, BAR_MAX: pill geometry
  • CELL_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-bar
Source 2 files
hooks/register.tsx 302 lines
1import { 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}
302
types/index.d.ts 14 lines
1export 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