SLOPSHOPPER

usage-bar

A two-line usage bar above the Claude Code prompt: model and effort, running subagents, context, 5-hour and weekly limits, cost and tokens.

bandtoaststatus
v0.6.0MITupdated 2026-10-23tjgazel/claude-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 app │ opus-5-5 ctx ▰▰▱▱▱ 49% 97.4k/200k │ 5h ▰▰▱▱▱ 31% ↻ NaNdNaNh │ week – │ $0.42 │ ↑6.4k (cache 91k) ↓1.5k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app │ opus-5-5 ctx ▰▰▱▱▱ 49% 97.4k/200k │ 5h ▰▰▱▱▱ 31% ↻ NaNdNaNh │ week – │ $0.42 │ ↑6.4k (cache 91k) ↓1.5k
README

claude-usage-bar

A Claude Code mod that draws a two-line usage bar above the prompt:

claude-usage-bar (main)  │  fable-5-1 · high  │  ⎇ Explore haiku-5-5 · low
ctx ▰▰▱▱▱ 38% 76k/200k  │  5h ▰▰▰▱▱ 55% ↻ 2h10  │  week ▰▰▰▰▱ 80% ↻ 3d  │  $1.50  │  ↑45k (cache 1.2M) ↓12.4k

Line 1:

  • the project folder and its git branch, dimmed (claude-usage-bar (main); a detached HEAD shows the short hash, and a folder outside a git repository shows just its name);
  • the main model and its reasoning effort, in bold;
  • every subagent that is running, with its type, model and effort, in bold (⎇ Explore haiku-5-5 · low; up to two are named, then +N).

Line 2:

  • ctx: how full the context window is, as a 5-block bar (▰▰▱▱▱), with tokens used / window size;
  • 5h and week: how much of the 5-hour and the weekly limit is used, as the same 5-block bar, with the time until each one resets (↻);
  • $: the cost of the session so far, as /cost totals it;
  • ↑ / ↓: input and output tokens of the session, subagents included; (cache …) is what the prompt cache served.

ctx, 5h and week change color with usage, following your theme (success, warning, error):

UsedColor
below 50%green
50% to 75%yellow
above 75%red

A toast also warns once per window when the 5-hour or the weekly limit passes 90%.

Install

At the prompt of a Claude Code terminal session:

/plugin install usage-bar --marketplace tjgazel/claude-usage-bar

Claude Code asks Add marketplace?: answer y, then pick a scope (user scope is the first option). It prints Installed usage-bar. Plugin is now active. and the bar shows up with no reload.

From a shell instead:

claude plugin install usage-bar --marketplace tjgazel/claude-usage-bar

The bar only shows in the terminal; installing the plugin in the desktop app has no visible effect.

To remove it: claude plugin uninstall usage-bar.

Notes

  • The bar is drawn in the band above the prompt, because that is the only place a mod can use colors. The status line under the prompt is plain text and always carries the mod's name, so it is not used.
  • The bar is for the terminal (CLI) only: the desktop app, the VS Code extension and the mobile surfaces draw nothing, and the 90% toast is not raised there either. You can collapse it with ctrl+x ctrl+a, and it steps aside while a survey is shown.
  • The 5-hour and weekly limits only exist on a subscription. Without one, or before the first response of a session, they show –, with a dim waiting for the first response hint at the end of the line.
  • The colors and the 50% / 75% thresholds are in hooks/register.tsx (tone).

Language

The data on the bar is always the same: English labels and US formats (week, $1.50, 12.4k), whatever the language. Only the messages follow the language setting of Claude Code: when it is Portuguese (portuguese, Português, pt, pt-BR), the waiting for the first response hint and the 90% limit toast are shown in Portuguese. Any other language falls back to English. The setting is read each time the bar is drawn.

Develop

claude --plugin-dir <path to this folder>
claude plugin validate .
claude plugin test .

The formatting (token abbreviation, thresholds, reset time, language) is covered by hooks/register.test.ts.

License

MIT, see LICENSE.


Português

Mod do Claude Code (só no CLI, não no desktop) que desenha acima do prompt uma barra de uso em duas linhas: modelo/esforço e subagentes em execução na primeira; contexto, limite de 5h, limite semanal, custo e tokens (entrada, saída e cache) na segunda. Contexto e limites mudam de cor: verde abaixo de 50%, amarelo de 50% a 75% e vermelho acima. Os dados da barra ficam sempre em inglês e no formato americano (week, $1.50, 12.4k); só as mensagens (o aviso de espera e o aviso dos 90%) seguem o idioma configurado no Claude Code, em português quando language é português.

Instalação

No prompt de uma sessão do Claude Code no terminal:

/plugin install usage-bar --marketplace tjgazel/claude-usage-bar

Responda y em Add marketplace? e escolha o escopo (o de usuário é a primeira opção). Quando aparecer Installed usage-bar. Plugin is now active., a barra já está ativa, sem precisar recarregar.

Pelo shell:

claude plugin install usage-bar --marketplace tjgazel/claude-usage-bar

A barra só aparece no terminal (CLI); instalar o plugin no app desktop não tem efeito visível.

Para remover: claude plugin uninstall usage-bar.

Source 2 files
hooks/register.tsx 351 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { AgentRun, Limit, Measure, ModelLabel, Totals } from '../types'
4
5const EMPTY: Totals = { input: 0, cacheRead: 0, output: 0 }
6const WARN_AT = 90
7const MAX_AGENTS_SHOWN = 2
8
9// The data on the bar is always English and US-formatted; only the messages follow the `language` setting
10export type Lang = 'en' | 'pt'
11
12const TEXT = {
13  en: {
14    waiting: 'waiting for the first response',
15    toast: (kind: string, percent: number) =>
16      `${kind === 'five_hour' ? '5h limit' : 'Weekly limit'}: ${percent}% used`,
17  },
18  pt: {
19    waiting: 'aguardando a 1ª resposta',
20    toast: (kind: string, percent: number) =>
21      `${kind === 'five_hour' ? 'Limite de 5h' : 'Limite semanal'}: ${percent}% usado`,
22  },
23} as const
24
25const decimal = (x: number) => (x >= 100 ? x.toFixed(0) : x.toFixed(1)).replace(/\.0$/, '')
26
27export const fmtTokens = (n: number) =>
28  n >= 1e6 ? `${decimal(n / 1e6)}M` : n >= 1e3 ? `${decimal(n / 1e3)}k` : String(n)
29
30export const fmtUsd = (usd: number) => `$${usd.toFixed(2)}`
31
32export type Tone = 'success' | 'warning' | 'error'
33
34// green below 50%, yellow from 50% to 75%, red above (the theme's own colors)
35export const tone = (percent: number): Tone =>
36  percent < 50 ? 'success' : percent <= 75 ? 'warning' : 'error'
37
38// a 5-block bar, used by the context and by both limits
39export const bar = (percent: number) => {
40  const filled = Math.max(0, Math.min(5, Math.round(percent / 20)))
41  return '▰'.repeat(filled) + '▱'.repeat(5 - filled)
42}
43
44export const fmtReset = (ms: number) => {
45  const minutes = Math.round(ms / 60000)
46  if (minutes <= 0) return 'now'
47  if (minutes < 60) return `${minutes}min`
48  const hours = Math.floor(minutes / 60)
49  if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}`
50  const days = Math.floor(hours / 24)
51  return hours % 24 === 0 ? `${days}d` : `${days}d${hours % 24}h`
52}
53
54export const shortModel = (model: string) => model.replace(/^claude-/, '')
55
56const fmtModel = ({ model, effort }: ModelLabel) =>
57  effort === undefined ? shortModel(model) : `${shortModel(model)} · ${effort}`
58
59export type Meter = { label: string; text: string; color?: Tone; isDim?: boolean }
60
61const resetTail = (limit: Limit, now: number) =>
62  limit.resetsAt === undefined ? '' : ` ↻ ${fmtReset(Date.parse(limit.resetsAt) - now)}`
63
64const limitMeter = (label: string, limit: Limit | undefined, now: number): Meter =>
65  limit === undefined
66    ? { label, text: `${label} –`, isDim: true }
67    : {
68        label,
69        color: tone(limit.percentUsed),
70        text: `${label} ${bar(limit.percentUsed)} ${Math.round(limit.percentUsed)}%${resetTail(limit, now)}`,
71      }
72
73const contextMeter = (context: Measure['context']): Meter =>
74  context.percent === undefined || context.tokens === undefined
75    ? { label: 'ctx', text: 'ctx –', isDim: true }
76    : {
77        label: 'ctx',
78        color: tone(context.percent),
79        text: `ctx ${bar(context.percent)} ${Math.round(context.percent)}% ${fmtTokens(context.tokens)}/${fmtTokens(context.window)}`,
80      }
81
82const tokensMeter = (totals: Totals): Meter => {
83  const cache = totals.cacheRead > 0 ? ` (cache ${fmtTokens(totals.cacheRead)})` : ''
84
85  return { label: 'tok', text: `↑${fmtTokens(totals.input)}${cache} ↓${fmtTokens(totals.output)}` }
86}
87
88// the colored second line: meters, cost and, last, the tokens; `lang` only picks the hint's language
89export const buildMeters = (
90  totals: Totals,
91  measure: Measure | null,
92  now: number,
93  lang: Lang = 'en',
94): Meter[] => {
95  const find = (kind: string) => measure?.rateLimits.find(l => l.kind === kind)
96  const isWaiting =
97    measure === null || (measure.context.percent === undefined && measure.rateLimits.length === 0)
98
99  return [
100    measure === null
101      ? { label: 'ctx', text: 'ctx –', isDim: true }
102      : contextMeter(measure.context),
103    limitMeter('5h', find('five_hour'), now),
104    limitMeter('week', find('seven_day'), now),
105    ...(measure?.usd === undefined ? [] : [{ label: 'usd', text: fmtUsd(measure.usd) }]),
106    tokensMeter(totals),
107    ...(isWaiting ? [{ label: 'wait', text: TEXT[lang].waiting, isDim: true }] : []),
108  ]
109}
110
111const fmtAgents = (agents: Record<string, AgentRun>) => {
112  const running = Object.values(agents)
113  if (running.length === 0) return undefined
114  const shown = running.slice(0, MAX_AGENTS_SHOWN).map(a => `${a.type} ${fmtModel(a)}`)
115  const more = running.length > MAX_AGENTS_SHOWN ? ` +${running.length - MAX_AGENTS_SHOWN}` : ''
116  return `⎇ ${shown.join(', ')}${more}`
117}
118
119export type Snapshot = {
120  main: ModelLabel | null
121  totals: Totals
122  agents: Record<string, AgentRun>
123  measure: Measure | null
124}
125
126// the first line: the project, the model and the running subagents, each drawn on its own
127export const buildHead = (s: Pick<Snapshot, 'main' | 'agents'> & { project?: Project | null }) => ({
128  project: s.project === undefined || s.project === null ? undefined : fmtProject(s.project),
129  model: s.main === null ? '…' : fmtModel(s.main),
130  agents: fmtAgents(s.agents),
131})
132
133export type Project = { name: string; branch?: string }
134
135// the last segment of a path, with either separator
136export const baseName = (path: string) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
137
138// the branch named by the contents of .git/HEAD; a detached HEAD gives the short hash
139export const branchOf = (head: string): string | undefined => {
140  const text = head.trim()
141  const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(text)
142  if (ref !== null) return ref[1]
143  return /^[0-9a-f]{40}$/i.test(text) ? text.slice(0, 7) : undefined
144}
145
146// where a worktree's `.git` file points (`gitdir: <path>`); a relative path is taken from `dir`
147export const gitdirOf = (content: string, dir: string): string | undefined => {
148  const path = /^gitdir:\s*(.+)$/m.exec(content)?.[1]?.trim()
149  if (path === undefined) return undefined
150  return /^([A-Za-z]:|[\\/])/.test(path) ? path : `${dir}/${path}`
151}
152
153export const fmtProject = ({ name, branch }: Project) =>
154  branch === undefined ? name : `${name} (${branch})`
155
156// whether a `language` setting (`portuguese`, `Português`, `pt-BR`) asks for Portuguese
157export const langOf = (language: unknown): Lang =>
158  typeof language === 'string' && /^(pt|port)/i.test(language.trim()) ? 'pt' : 'en'
159
160async function readLang($: EngineInterface): Promise<Lang> {
161  try {
162    return langOf((await $.settings.read()).language)
163  } catch {
164    return 'en'
165  }
166}
167
168// what the engine already knows before the first turn (a mod installed mid-session never gets session.start)
169async function initialMeasure($: EngineInterface): Promise<Measure> {
170  const { context, rateLimits, cost } = await $.session.usage()
171
172  return { context, rateLimits, usd: cost?.usd }
173}
174
175const PROJECT_TTL_MS = 2000
176
177type ProjectCache = { at: number; value: Project | null }
178
179// the branch of the repository that holds `root`, looking up to 8 folders above it
180// only what findBranch needs of the engine, so a test can hand it an in-memory disk
181type FsLike = {
182  fs: {
183    stat: (path: string) => Promise<{ kind: string }>
184    read: (path: string) => Promise<string>
185  }
186}
187
188export async function findBranch($: FsLike, root: string): Promise<string | undefined> {
189  let dir = root.replace(/[\\/]+$/, '')
190  for (let level = 0; level < 8; level++) {
191    const git = `${dir}/.git`
192    const stat = await $.fs.stat(git).catch(() => undefined)
193    if (stat?.kind === 'dir') return branchOf(await $.fs.read(`${git}/HEAD`))
194    if (stat?.kind === 'file') {
195      const gitdir = gitdirOf(await $.fs.read(git), dir)
196      return gitdir === undefined ? undefined : branchOf(await $.fs.read(`${gitdir}/HEAD`))
197    }
198    const cut = Math.max(dir.lastIndexOf('/'), dir.lastIndexOf('\\'))
199    if (cut <= 0) return undefined
200    dir = dir.slice(0, cut)
201  }
202  return undefined
203}
204
205// the project folder and its branch, read at most every PROJECT_TTL_MS: a redraw is frequent
206async function readProject($: EngineInterface, cache: ProjectCache): Promise<Project | null> {
207  const now = await $.clock.now()
208  if (now - cache.at < PROJECT_TTL_MS) return cache.value
209  try {
210    const root = await $.session.root()
211    cache.value = { name: baseName(root), branch: await findBranch($, root).catch(() => undefined) }
212  } catch {
213    cache.value = null
214  }
215  cache.at = now
216
217  return cache.value
218}
219
220type Live = Snapshot & { warned: string[] }
221
222async function refresh($: EngineInterface, s: Live) {
223  await Promise.all([
224    $.state.set({ plugin: 'usage-bar', key: 'main' }, s.main),
225    $.state.set({ plugin: 'usage-bar', key: 'totals' }, s.totals),
226    $.state.set({ plugin: 'usage-bar', key: 'agents' }, s.agents),
227    $.state.set({ plugin: 'usage-bar', key: 'measure' }, s.measure),
228    $.state.set({ plugin: 'usage-bar', key: 'warned' }, s.warned),
229  ])
230}
231
232export const register: Register = on => {
233  const s: Live = { main: null, totals: EMPTY, agents: {}, measure: null, warned: [] }
234  const projectCache: ProjectCache = { at: -Infinity, value: null }
235
236  on('session.start', async ($, e, next) => {
237    s.totals = (await $.state.get({ plugin: 'usage-bar', key: 'totals' })).value ?? EMPTY
238    s.agents = (await $.state.get({ plugin: 'usage-bar', key: 'agents' })).value ?? {}
239    s.measure = (await $.state.get({ plugin: 'usage-bar', key: 'measure' })).value ?? null
240    s.warned = (await $.state.get({ plugin: 'usage-bar', key: 'warned' })).value ?? []
241    s.main = (await $.state.get({ plugin: 'usage-bar', key: 'main' })).value ?? {
242      model: await $.session.model(),
243    }
244    $.ui.status(undefined)
245    await refresh($, s)
246
247    return next(e)
248  })
249
250  on('turn.step', async function* ($, e, next) {
251    const seen = { model: e.model, effort: e.effort === undefined ? undefined : String(e.effort) }
252
253    if (e.agentId === undefined) {
254      s.main = seen
255    } else if (s.agents[e.agentId] === undefined) {
256      const info = (await $.agent.list()).find(a => a.id === e.agentId)
257      s.agents = { ...s.agents, [e.agentId]: { type: info?.type ?? 'agent', ...seen } }
258    } else {
259      s.agents = { ...s.agents, [e.agentId]: { ...s.agents[e.agentId]!, ...seen } }
260    }
261    await refresh($, s)
262
263    return yield* next(e)
264  })
265
266  on('turn.complete', async ($, e, next) => {
267    if (e.usage !== undefined) {
268      s.totals = {
269        input: s.totals.input + e.usage.input_tokens + e.usage.cache_creation_input_tokens,
270        cacheRead: s.totals.cacheRead + e.usage.cache_read_input_tokens,
271        output: s.totals.output + e.usage.output_tokens,
272      }
273    }
274    if (e.agentId !== undefined) {
275      const { [e.agentId]: _done, ...rest } = s.agents
276      s.agents = rest
277    }
278    await refresh($, s)
279
280    return next(e)
281  })
282
283  on('session.measure', async ($, e, next) => {
284    s.measure = { context: e.context, rateLimits: e.rateLimits, usd: e.cost?.usd }
285
286    const isTerminal = (await $.session.surfaces()).includes('terminal')
287
288    for (const limit of e.rateLimits) {
289      const id = `${limit.kind}:${limit.resetsAt ?? ''}`
290      if (isTerminal && limit.percentUsed >= WARN_AT && !s.warned.includes(id)) {
291        s.warned = [...s.warned, id]
292        $.ui.toast(TEXT[await readLang($)].toast(limit.kind, Math.round(limit.percentUsed)))
293      }
294    }
295    await refresh($, s)
296
297    return next(e)
298  })
299
300  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
301    // the bar is for the terminal (CLI) only; the desktop app and the other surfaces get nothing
302    if (e.surface !== 'terminal' || e.props.hasSurvey) {
303      return next(e)
304    }
305
306    const { value: totals = EMPTY } = await $.state.get({ plugin: 'usage-bar', key: 'totals' })
307    const { value: stored = null } = await $.state.get({ plugin: 'usage-bar', key: 'measure' })
308    const { value: seen = null } = await $.state.get({ plugin: 'usage-bar', key: 'main' })
309    const measure = stored ?? (await initialMeasure($))
310    const main = seen ?? { model: await $.session.model() }
311    const { value: agents = {} } = await $.state.get({ plugin: 'usage-bar', key: 'agents' })
312    const head = buildHead({ main, agents, project: await readProject($, projectCache) })
313    const meters = buildMeters(totals, measure, await $.clock.now(), await readLang($))
314    const { Box, Text } = $.ui.resolve(e)
315
316    return (
317      <Box flexDirection="column" marginTop={1} marginBottom={0}>
318        <Text>
319          {head.project === undefined ? null : (
320            <Text dimColor>
321              {head.project}
322              {'  │  '}
323            </Text>
324          )}
325          <Text color="claude" bold>
326            {head.model}
327          </Text>
328          {head.agents === undefined ? null : (
329            <Text color="suggestion" bold>
330              {'  │  '}
331              {head.agents}
332            </Text>
333          )}
334        </Text>
335        <Text>
336          {meters.flatMap((m, i) => [
337            i > 0 ? (
338              <Text key={`${m.label}-sep`} dimColor>
339                {'  │  '}
340              </Text>
341            ) : null,
342            <Text key={m.label} color={m.color} dimColor={m.isDim === true}>
343              {m.text}
344            </Text>,
345          ])}
346        </Text>
347      </Box>
348    )
349  })
350}
351
types/index.d.ts 22 lines
1export type Totals = { input: number; cacheRead: number; output: number }
2export type ModelLabel = { model: string; effort?: string }
3export type AgentRun = ModelLabel & { type: string }
4export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5export type Measure = {
6  context: { tokens?: number; window: number; percent?: number }
7  rateLimits: Limit[]
8  usd?: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'usage-bar': {
14      totals: Totals
15      main: ModelLabel | null
16      agents: Record<string, AgentRun>
17      measure: Measure | null
18      warned: string[]
19    }
20  }
21}
22