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.

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.

| Item | Meaning |
|---|---|
5h ▬ 26% · 2h 16m | 5-hour plan limit: share used, time until it resets |
7d ▬ 28% · 4d 2h | Weekly limit across all models |
Fable ▬ 12% · 4d 2h | A per-model weekly limit, shown when your plan has one |
↑ 1.2k | Input tokens this session (the part not served from the prompt cache) |
↓ 26.1k | Output tokens this session |
⟲ 5.00M | Prompt-cache tokens this session (read + written) |
$ 4.87 | Session 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.
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
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.
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
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:
| Hook | What it does |
|---|---|
session.start | Reads 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.measure | Copies the 5-hour and weekly percentages, their reset times and the session cost into the bar. Passes the event on unchanged. |
turn.complete | Adds 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.end | On /clear, sets the token totals back to zero. Passes the event on unchanged. |
command.run | Matches only /usage-bar, which it answers by hiding or showing the bar. It never sees, runs or changes any other command. |
ui.render | Matches 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. |
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.$.session.authorize(). That call returns an opaque handle, so your token never reaches the plugin.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.
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.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.Report bugs and ask questions in GitHub Issues. For security concerns, see SECURITY.md.
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
MIT. This is a community project, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 426 lines1import 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}
426types/index.d.ts 37 lines1export 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