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

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:
claude-usage-bar (main); a detached HEAD shows the short hash, and a folder outside a git repository shows just its name);⎇ 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):
| Used | Color |
|---|---|
| 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%.
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.
ctrl+x ctrl+a, and it steps aside while a survey is shown.–, with a dim waiting for the first response hint at the end of the line.hooks/register.tsx (tone).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.
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.
MIT, see LICENSE.
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.
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.
hooks/register.tsx 351 lines1import 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}
351types/index.d.ts 22 lines1export 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