SLOPSHOPPER

Usage Bar

A one-line usage bar above the prompt: 5-hour, weekly and per-model plan limits with reset countdowns, plus the session's tokens and cost.

newbandcommandnetworktimer
v1.0.5MITupdated 2026-10-03muratkaragozgil/claude-code-usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
› fix the failing auth test and add an audit log call ⏺ 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-bar ⎿ usage-bar: Usage bar hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Usage Bar for Claude Code

A one-line usage bar above the Claude Code prompt. It keeps your plan limits in view (the 5-hour window, the weekly window, and per-model weekly limits such as Fable), each with its reset countdown, next to the tokens and cost of the current session.

The usage bar above the Claude Code prompt, dark theme

What it shows

ItemMeaning
5h ▬ 26% · 2h 16m5-hour plan limit: share used, time until it resets
7d ▬ 28% · 4d 2hWeekly limit across all models
Fable ▬ 12% · 4d 2hA per-model weekly limit, shown when your plan has one
↑ 1.2kInput tokens this session (the part not served from the prompt cache)
↓ 26.1kOutput tokens this session
⟲ 5.00MPrompt-cache tokens this session (read + written)
$ 4.87Session cost at API prices, the same figure /cost shows

The bar stays on a single line at any width. When space runs out it drops the least useful parts first: the per-model countdown, the weekly countdown, cache, input and output tokens, the 5-hour countdown, then cost. The bars and percentages always stay.

Install

You need Claude Code 2.1.287 or newer; mods are on by default from that version.

Run these two commands in a terminal:

claude plugin marketplace add MuratKaragozgil/claude-code-usage-bar
claude plugin install usage-bar@claude-code-usage-bar

Then start a new Claude Code session. The bar appears above the prompt, in the terminal and in the Code tab of the Claude desktop app.

You can also install from inside a terminal Claude Code session:

/plugin marketplace add MuratKaragozgil/claude-code-usage-bar
/plugin install usage-bar@claude-code-usage-bar
/reload-plugins

Use

There is nothing to configure. Type /usage-bar to hide the bar, and again to bring it back.

The plan limits need a claude.ai login (Pro, Max, Team or Enterprise). Signed in with an API key, the bar shows tokens and cost only.

Update or remove

claude plugin marketplace update claude-code-usage-bar
claude plugin update usage-bar@claude-code-usage-bar
claude plugin uninstall usage-bar@claude-code-usage-bar
claude plugin marketplace remove claude-code-usage-bar

How it works

The plugin is a Claude Code mod: one readable TypeScript module of function hooks, hooks/register.tsx.

Each hook, and what it does with what it sees:

HookWhat it does
session.startReads the session's usage figures from Claude Code and registers the /usage-bar command. Starts a timer that moves the countdowns every 30 seconds and refreshes the plan limits every five minutes, the first time right after the session starts. Passes the event on unchanged.
session.measureCopies the 5-hour and weekly percentages, their reset times and the session cost into the bar. Passes the event on unchanged.
turn.completeAdds the turn's token counts to the session totals, subagent turns included. It reads only the counts, never the text of the turn. Passes the event on unchanged.
session.endOn /clear, sets the token totals back to zero. Passes the event on unchanged.
command.runMatches only /usage-bar, which it answers by hiding or showing the bar. It never sees, runs or changes any other command.
ui.renderMatches only the AbovePrompt site, where it draws the bar: SVG bars on the desktop, text bars in the terminal. It leaves the rest of the screen to Claude Code.

What it fetches, sends and runs

  • Fetches: GET https://api.anthropic.com/api/oauth/usage, Anthropic's endpoint behind the desktop app's usage card and /usage. It returns the plan's usage limits, including the per-model weekly ones that the API's rate-limit headers don't carry.
  • When: when a session starts, every five minutes, and when a limit moves, at most once a minute.
  • Credential: the request carries Claude Code's own credential through $.session.authorize(). That call returns an opaque handle, so your token never reaches the plugin.
  • Sends: only that request, which has no body. None of what the plugin reads (usage figures, token counts, cost) leaves your machine, and nothing goes to any other host.
  • Runs: nothing. No processes, shell commands, tools, agents or MCP calls. It installs no packages and writes no files.
  • Keeps: everything it reads stays in Claude Code's memory for the session and is discarded when the session ends.

The full privacy policy is in PRIVACY.md.

The endpoint is undocumented and may change. If it fails, the 5-hour and weekly bars keep working from the session's own data, and a single line in the transcript says why the per-model limits are missing.

Troubleshooting

  • The bar doesn't appear. Check claude --version (2.1.287 or newer) and that claude plugin list shows usage-bar@claude-code-usage-bar as enabled. Then start a new session; a running session doesn't pick up a new install. If you typed /usage-bar earlier, the bar is hidden, so type it again.
  • No 5h or 7d bars. You're signed in with an API key, where plan limits don't apply. Or the session hasn't had a reply yet and the usage endpoint was unreachable; the bars appear after the first reply.
  • No per-model bar (Fable). Your plan may have no per-model limit right now. Otherwise, look for a transcript line starting usage-bar: per-model limits unavailable:
  • answered 401: sign in again with /login.
  • answered 429: the plugin waits ten minutes and tries again.
  • signed in with an API key: per-model limits apply to claude.ai plans only.
  • Countdowns or token counts are missing. The window is too narrow, so the bar dropped them to stay on one line. Widen the window.

Support

Report bugs and ask questions in GitHub Issues. For security concerns, see SECURITY.md.

Development

git clone https://github.com/MuratKaragozgil/claude-code-usage-bar
cd claude-code-usage-bar
claude --plugin-dir .                       # load it from disk for one session
claude plugin validate --strict ./.claude-plugin/plugin.json
claude plugin test .

The tests mount the band on the terminal and desktop surfaces, with the usage endpoint answering and refusing, at a wide and a narrow width. tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes, along with the API's type declarations, the first time it loads the plugin from your folder.

.claude-plugin/plugin.json        the plugin manifest
.claude-plugin/marketplace.json   makes this repository a one-plugin marketplace
hooks/hooks.json                  points Claude Code at the module
hooks/register.tsx                the whole mod
types/index.d.ts                  the types of the values it keeps in $.state
tests/render.test.ts              claude plugin test suite

License

MIT. This is a community project, not affiliated with or endorsed by Anthropic.

Source 2 files
hooks/register.tsx 426 lines
1import type { EngineInterface, On, SessionRateLimit, TurnUsage } from 'claude-code'
2
3import type { ModelWindow, Remote, Snapshot, Tokens, Window } from '../types'
4
5// Where the plugin keeps its values in $.state, one address per value.
6const SNAP = { plugin: 'usage-bar', key: 'snap' } as const
7const REMOTE = { plugin: 'usage-bar', key: 'remote' } as const
8const TOKENS = { plugin: 'usage-bar', key: 'tokens' } as const
9const NOW = { plugin: 'usage-bar', key: 'now' } as const
10const IS_HIDDEN = { plugin: 'usage-bar', key: 'isHidden' } as const
11
12const NO_SNAP: Snapshot = { fiveHour: null, sevenDay: null, costUsd: null }
13const NO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
14
15const TICK_MS = 30_000
16const REMOTE_EVERY_MS = 5 * 60_000
17const REMOTE_MIN_GAP_MS = 60_000
18const REMOTE_BACKOFF_MS = 10 * 60_000
19
20// Fetch bookkeeping only; nothing draws from it, so a reload may reset it.
21// Requests go out at least a minute apart (ten minutes after a refusal), and
22// the regular refresh is due five minutes after the last one.
23let nextAllowedAt = 0
24let nextDueAt = 0
25let isFetching = false
26let hasReportedFailure = false
27
28type Gauge = { pct: number; leftMs: number | null }
29
30type Kind = '5h' | '7d' | 'model' | 'in' | 'out' | 'cache' | 'cost'
31
32type Item = { kind: Kind; label: string; value: string; gauge?: Gauge }
33
34// What of an item is drawn: fitting the row to one line turns parts off.
35type Shown = Item & { hasLeft: boolean }
36
37function toWindow(limits: SessionRateLimit[], kind: string): Window | null {
38  const hit = limits.find(limit => limit.kind === kind)
39
40  return hit ? { percentUsed: hit.percentUsed, resetsAt: hit.resetsAt ?? null } : null
41}
42
43function toSnapshot(usage: { rateLimits: SessionRateLimit[]; cost?: { usd: number } }): Snapshot {
44  return {
45    fiveHour: toWindow(usage.rateLimits, 'five_hour'),
46    sevenDay: toWindow(usage.rateLimits, 'seven_day'),
47    costUsd: usage.cost?.usd ?? null,
48  }
49}
50
51function pick(value: unknown, key: string): unknown {
52  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined
53}
54
55// `utilization` and `percent` are whole percents, 0 to 100.
56function toRemoteWindow(value: unknown, field: 'utilization' | 'percent'): Window | null {
57  const pct = pick(value, field)
58  if (typeof pct !== 'number') return null
59  const resets = pick(value, 'resets_at')
60
61  return { percentUsed: pct, resetsAt: typeof resets === 'string' ? resets : null }
62}
63
64function parseRemote(body: unknown): Remote {
65  const models: ModelWindow[] = []
66  const isListed = (name: string) => models.some(m => m.name.toLowerCase() === name.toLowerCase())
67
68  const limits = pick(body, 'limits')
69  for (const row of Array.isArray(limits) ? limits : []) {
70    const name = pick(pick(pick(row, 'scope'), 'model'), 'display_name')
71    const w = toRemoteWindow(row, 'percent')
72    if (pick(row, 'kind') === 'weekly_scoped' && typeof name === 'string' && w && !isListed(name)) {
73      models.push({ name, ...w })
74    }
75  }
76  for (const [key, name] of [['seven_day_opus', 'Opus'], ['seven_day_sonnet', 'Sonnet']] as const) {
77    const w = toRemoteWindow(pick(body, key), 'utilization')
78    if (w && !isListed(name)) models.push({ name, ...w })
79  }
80
81  return {
82    fiveHour: toRemoteWindow(pick(body, 'five_hour'), 'utilization'),
83    sevenDay: toRemoteWindow(pick(body, 'seven_day'), 'utilization'),
84    models,
85  }
86}
87
88// Reads the clock into NOW, which keeps the reset countdowns moving.
89async function tick($: EngineInterface): Promise<void> {
90  const at = await $.clock.now()
91  await $.state.set(NOW, at)
92}
93
94// Adds one turn's token counts to the session totals. A subagent's turn and the
95// main one can finish together, so the write is compare-and-set.
96async function addTokens($: EngineInterface, usage: TurnUsage): Promise<void> {
97  for (let attempt = 0; attempt < 5; attempt++) {
98    const held = await $.state.get(TOKENS)
99    const sum = held.value ?? NO_TOKENS
100    const written = await $.state.set(
101      TOKENS,
102      {
103        input: sum.input + usage.input_tokens,
104        output: sum.output + usage.output_tokens,
105        cacheRead: sum.cacheRead + usage.cache_read_input_tokens,
106        cacheWrite: sum.cacheWrite + usage.cache_creation_input_tokens,
107      },
108      { ifVersion: held.version },
109    )
110    if (written.isSet) return
111  }
112}
113
114async function toggleHidden($: EngineInterface): Promise<boolean> {
115  const held = await $.state.get(IS_HIDDEN)
116  const isNowHidden = held.value !== true
117  await $.state.set(IS_HIDDEN, isNowHidden)
118
119  return isNowHidden
120}
121
122// Reads the plan's limits, per-model weekly ones (Fable) included, from the
123// endpoint behind the app's usage card and /usage. The API's rate-limit
124// headers carry only the 5-hour and weekly windows.
125async function refreshRemote($: EngineInterface, isScheduled: boolean): Promise<void> {
126  const at = await $.clock.now()
127  if (isFetching || at < nextAllowedAt || (isScheduled && at < nextDueAt)) return
128  isFetching = true
129  nextAllowedAt = at + REMOTE_MIN_GAP_MS
130  nextDueAt = at + REMOTE_EVERY_MS
131  let failure: string | null = null
132
133  try {
134    // A handle for the session's own login; the token never reaches this module.
135    const auth = await $.session.authorize()
136    if (auth?.kind === 'bearer') {
137      const res = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
138        headers: { 'Content-Type': 'application/json', 'anthropic-beta': 'oauth-2025-04-20' },
139        auth: auth.handle,
140      })
141      if (res.ok) {
142        await $.state.set(REMOTE, parseRemote(JSON.parse(res.text)))
143      } else {
144        if ([401, 403, 429].includes(res.status)) nextAllowedAt = nextDueAt = at + REMOTE_BACKOFF_MS
145        failure = `usage endpoint answered ${res.status}`
146      }
147    } else {
148      nextAllowedAt = nextDueAt = at + REMOTE_BACKOFF_MS
149      failure = auth ? 'signed in with an API key' : 'no claude.ai login'
150    }
151  } catch (error) {
152    // Network or parse trouble: keep the last reading and try again later.
153    failure = error instanceof Error ? error.message : String(error)
154  } finally {
155    isFetching = false
156  }
157
158  // Says once per load, in the transcript, why the per-model limits are missing.
159  if (failure !== null && !hasReportedFailure) {
160    hasReportedFailure = true
161    $.ui.log(`usage-bar: per-model limits unavailable (${failure}); 5h and 7d still come from the session`)
162  }
163}
164
165const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
166
167function fmtTokens(n: number): string {
168  if (n < 1000) return String(n)
169  if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
170
171  return `${(n / 1_000_000).toFixed(2)}M`
172}
173
174function fmtLeft(ms: number): string {
175  if (ms <= 0) return 'now'
176  // Not `h`: in a .tsx file that name is the JSX factory.
177  const total = Math.ceil(ms / 60_000)
178  const days = Math.floor(total / 1440)
179  const hours = Math.floor((total % 1440) / 60)
180  const minutes = total % 60
181  if (days > 0) return `${days}d ${hours}h`
182  if (hours > 0) return `${hours}h ${minutes}m`
183
184  return `${minutes}m`
185}
186
187function toGauge(w: Window, at: number): Gauge {
188  const resets = w.resetsAt ? Date.parse(w.resetsAt) : NaN
189
190  return {
191    pct: clamp(w.percentUsed, 0, 100),
192    leftMs: Number.isFinite(resets) ? resets - at : null,
193  }
194}
195
196// Monochrome bar for surfaces that draw SVG; follows the app's light/dark theme.
197function barSvg(pct: number): string {
198  const W = 44
199  const H = 10
200  const filled = (W * pct) / 100
201  const fill = filled > 0 ? `<rect y="3" width="${Math.max(filled, 4)}" height="4" rx="2" class="f"/>` : ''
202
203  return (
204    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
205    '<style>.t{fill:#000;fill-opacity:.12}.f{fill:#000;fill-opacity:.5}' +
206    '@media (prefers-color-scheme:dark){.t{fill:#fff;fill-opacity:.15}.f{fill:#fff;fill-opacity:.55}}</style>' +
207    `<rect y="3" width="${W}" height="4" rx="2" class="t"/>${fill}</svg>`
208  )
209}
210
211function barText(pct: number, cells = 8): { done: string; rest: string } {
212  const filled = Math.round((pct / 100) * cells)
213
214  return { done: '━'.repeat(filled), rest: '─'.repeat(cells - filled) }
215}
216
217const ITEM_GAP = 1
218const ROW_GAP = 1
219
220// How wide things draw, in cells (the unit `bodyColumns` counts): exact on the
221// terminal; on the desktop a cell is 8px, its text about 7px a character and
222// the bar 44px, as measured on the app's band.
223type Metrics = { char: number; bar: number }
224const TERMINAL: Metrics = { char: 1, bar: 8 }
225const DESKTOP: Metrics = { char: 0.9, bar: 5.5 }
226
227const hideLeft =
228  (...kinds: Kind[]) =>
229  (row: Shown[]) =>
230    row.map(it => (kinds.includes(it.kind) ? { ...it, hasLeft: false } : it))
231const drop = (kind: Kind) => (row: Shown[]) => row.filter(it => it.kind !== kind)
232
233// Least useful first: what goes, one step at a time, until the row fits.
234// The bars and the limits' percentages always stay; anything still too wide
235// is clipped at the right edge.
236const FIT_STEPS = [
237  hideLeft('model'),
238  hideLeft('7d'),
239  drop('cache'),
240  drop('in'),
241  drop('out'),
242  hideLeft('5h'),
243  drop('cost'),
244]
245
246function rowWidth(row: Shown[], m: Metrics): number {
247  const widths = row.map(it => {
248    let chars = it.label.length + it.value.length
249    let gaps = 1
250    let cells = 0
251    if (it.gauge) {
252      cells += m.bar
253      gaps += 1
254    }
255    if (it.hasLeft && it.gauge?.leftMs != null) {
256      chars += fmtLeft(it.gauge.leftMs).length + 2
257      gaps += 1
258    }
259
260    return chars * m.char + cells + gaps * ITEM_GAP
261  })
262
263  return widths.reduce((a, b) => a + b, 0) + Math.max(0, widths.length - 1) * ROW_GAP
264}
265
266function fitRow(items: Item[], room: number, m: Metrics): Shown[] {
267  let row: Shown[] = items.map(it => ({ ...it, hasLeft: it.gauge?.leftMs != null }))
268  for (const step of FIT_STEPS) {
269    if (rowWidth(row, m) <= room) break
270    row = step(row)
271  }
272
273  return row
274}
275
276export function register(on: On): void {
277  on('session.start', async ($, e, next) => {
278    const usage = await $.session.usage()
279    await $.state.set(SNAP, toSnapshot(usage))
280    await tick($)
281
282    // Every 30 seconds: move the countdowns, and refresh the plan limits when due.
283    $.clock.every(TICK_MS, async () => {
284      await tick($)
285      await refreshRemote($, true)
286    })
287
288    // The first refresh, right after the session starts, without holding it up.
289    $.clock.after(1, async () => {
290      await refreshRemote($, true)
291    })
292
293    // Last, as registering throws when another plugin already took the name.
294    await $.command.register({
295      name: 'usage-bar',
296      description: 'Show or hide the usage bar above the prompt',
297    })
298
299    return next(e)
300  })
301
302  on('session.measure', async ($, e, next) => {
303    await $.state.set(SNAP, toSnapshot(e))
304    await tick($)
305    const result = await next(e)
306
307    // A window moved: the per-model ones may have too (at most once a minute).
308    if (e.changed.includes('rateLimits')) {
309      await refreshRemote($, false)
310    }
311
312    return result
313  })
314
315  // Every turn, subagents' included, so the totals line up with the cost.
316  on('turn.complete', async ($, e, next) => {
317    if (e.usage) {
318      await addTokens($, e.usage)
319    }
320
321    return next(e)
322  })
323
324  on('session.end', async ($, e, next) => {
325    if (e.reason === 'clear') {
326      await $.state.set(TOKENS, NO_TOKENS)
327    }
328
329    return next(e)
330  })
331
332  on('command.run', { command: 'usage-bar' }, async ($, e) => {
333    const isNowHidden = await toggleHidden($)
334
335    return { text: isNowHidden ? 'Usage bar hidden.' : 'Usage bar shown.' }
336  })
337
338  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
339    const hidden = await $.state.get(IS_HIDDEN)
340    if (e.props.hasSurvey || hidden.value === true) {
341      return next(e)
342    }
343
344    const s = (await $.state.get(SNAP)).value ?? NO_SNAP
345    const r = (await $.state.get(REMOTE)).value ?? null
346    const t = (await $.state.get(TOKENS)).value ?? NO_TOKENS
347    const at = (await $.state.get(NOW)).value || (await $.clock.now())
348
349    // The engine's readings are the freshest (every response); the endpoint's
350    // stand in until the first one, and alone carry the per-model windows.
351    const windows: Array<[Kind, string, Window | null]> = [
352      ['5h', '5h', s.fiveHour ?? r?.fiveHour ?? null],
353      ['7d', '7d', s.sevenDay ?? r?.sevenDay ?? null],
354      ...(r?.models ?? []).map((m): [Kind, string, Window] => ['model', m.name, m]),
355    ]
356
357    const items: Item[] = []
358    for (const [kind, label, w] of windows) {
359      if (!w) continue
360      const g = toGauge(w, at)
361      items.push({ kind, label, value: `${Math.round(g.pct)}%`, gauge: g })
362    }
363    items.push(
364      { kind: 'in', label: '↑', value: fmtTokens(t.input) },
365      { kind: 'out', label: '↓', value: fmtTokens(t.output) },
366      { kind: 'cache', label: '⟲', value: fmtTokens(t.cacheRead + t.cacheWrite) },
367    )
368    if (s.costUsd !== null) {
369      items.push({ kind: 'cost', label: '$', value: s.costUsd.toFixed(2) })
370    }
371
372    const ui = $.ui.resolve(e)
373    const { Box, Text } = ui
374    // The terminal's table answers Svg too, but draws it empty: text bars there.
375    const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : null
376
377    const room = e.props.bodyColumns > 0 ? e.props.bodyColumns - 1 : Infinity
378    const row = fitRow(items, room, Svg ? DESKTOP : TERMINAL)
379
380    const bar = (g: Gauge) => {
381      if (Svg) {
382        return <Svg source={barSvg(g.pct)} alt={`${Math.round(g.pct)}% used`} width={44} height={10} />
383      }
384      const b = barText(g.pct)
385
386      return (
387        <Box flexDirection="row">
388          <Text>{b.done}</Text>
389          <Text dimColor>{b.rest}</Text>
390        </Box>
391      )
392    }
393
394    // One line whatever the width: no wrapping, items never shrink into two
395    // lines, and what the fitting could not save is clipped at the right edge.
396    return (
397      <Box
398        flexDirection="row"
399        flexWrap="nowrap"
400        overflow="hidden"
401        alignItems="center"
402        justifyContent="space-between"
403        width="100%"
404        columnGap={ROW_GAP}
405      >
406        {row.map(it => (
407          <Box flexDirection="row" flexShrink={0} alignItems="center" columnGap={ITEM_GAP}>
408            <Text dimColor wrap="truncate-end">
409              {it.label}
410            </Text>
411            {it.gauge && bar(it.gauge)}
412            <Text bold={it.gauge !== undefined} wrap="truncate-end">
413              {it.value}
414            </Text>
415            {it.hasLeft && it.gauge?.leftMs != null && (
416              <Text dimColor wrap="truncate-end">
417                · {fmtLeft(it.gauge.leftMs)}
418              </Text>
419            )}
420          </Box>
421        ))}
422      </Box>
423    )
424  })
425}
426
types/index.d.ts 37 lines
1export type Window = { percentUsed: number; resetsAt: string | null }
2
3export type Snapshot = {
4  fiveHour: Window | null
5  sevenDay: Window | null
6  costUsd: number | null
7}
8
9/** A per-model weekly limit (`Fable`), as the app's usage card lists it. */
10export type ModelWindow = Window & { name: string }
11
12/** What /api/oauth/usage answered: the account windows plus per-model ones. */
13export type Remote = {
14  fiveHour: Window | null
15  sevenDay: Window | null
16  models: ModelWindow[]
17}
18
19export type Tokens = {
20  input: number
21  output: number
22  cacheRead: number
23  cacheWrite: number
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'usage-bar': {
29      snap: Snapshot
30      remote: Remote | null
31      tokens: Tokens
32      now: number
33      isHidden: boolean
34    }
35  }
36}
37