Context, 5h and 7d usage bars above the prompt, colored by fill and refreshed from the account's live usage, plus folder, model, effort and thinking

Mods for Claude Code: plugins of function hooks that change the terminal UI.

Two rows above the prompt:
The limits are read every 2 minutes, and after each turn, from the account's usage endpoint (the figures /usage shows). They count every session and device, not only this session's last response. If that call fails, the bars fall back to the session's own readings. The last reading is cached, so the bars are never empty at startup. A reading older than 10 minutes is drawn dim, with its age.
The layout follows the terminal's width. On a wide window the bars are 20 cells, with reset times and token count. Narrower, the bars shrink and the extras go. On a very narrow one only the percentages are left.

A small ⧉ md button on each reply copies the whole reply as its original markdown to the clipboard: tables, code blocks, diffs and mermaid come through intact. It works on replies split by tool calls too. /copy-md [n] copies the n-th last reply from the keyboard.
Clicking needs mouse support, which you get with the fullscreen TUI ("tui": "fullscreen"). Without it, use /copy-md.
At a Claude Code prompt:
/plugin install usage-bars --marketplace a-bine/claude-mods
/plugin install copy-markdown --marketplace a-bine/claude-mods
Answer y to add the marketplace, then pick a scope.
usage-bars replaces a statusLine you may have in settings.json. Remove that setting so the figures are not shown twice.
claude plugin validate <mod>
claude plugin test <mod>
claude --plugin-dir ./<mod> # run a working copy
api.anthropic.com/api/oauth/usage) is not a documented API and may change. If it does, the bars keep working on the session's own readings. The request goes through $.session.authorize(), so the mod never sees the credential.MIT
hooks/register.tsx 308 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Env, Usage, Window } from '../types'
5
6// The account-wide figures /usage shows: every session's and device's spend, not
7// only what this session's last response saw.
8const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
9const POLL_MS = 120_000
10// A fetch after a turn is skipped when the last one is younger than this.
11const MIN_GAP_MS = 30_000
12const STALE_MS = 10 * 60_000
13const BAR_WIDTH = 20
14const MIN_BAR = 4
15const STORE_KEY = 'last'
16
17// Fill thresholds shared by the three bars: blue below LOW_FROM (unused), green
18// up to MEDIUM_FROM, yellow up to HIGH_FROM, red from there.
19export const LOW_FROM = 1
20export const MEDIUM_FROM = 50
21export const HIGH_FROM = 80
22
23const usage = atom({ plugin: 'usage-bars', key: 'usage' } as const, {} as Usage)
24const env = atom({ plugin: 'usage-bars', key: 'env' } as const, {} as Env)
25
26export const levelColor = (pct: number): 'blue' | 'green' | 'yellow' | 'red' => {
27 if (pct < LOW_FROM) return 'blue'
28 if (pct < MEDIUM_FROM) return 'green'
29 if (pct < HIGH_FROM) return 'yellow'
30
31 return 'red'
32}
33
34export type Layout = { barWidth: number; isCompact: boolean; hasExtras: boolean }
35
36// Cells of row 1 besides the three bars: labels, percents, separators and the
37// "(Nm fa)" suffix; "full" adds the token count and the reset times.
38const FULL_OVERHEAD = 4 + 10 + 3 + 11 + 3 + 11 + 6 + 9
39const SLIM_OVERHEAD = 4 + 5 + 3 + 5 + 3 + 5 + 6 + 9
40
41/** Sizes row 1 to `columns`: 20-cell bars, then shrinking, then no extras, then percentages only. */
42export const layoutFor = (columns: number): Layout => {
43 const full = Math.floor((columns - FULL_OVERHEAD) / 3)
44 if (full >= MIN_BAR) return { barWidth: Math.min(BAR_WIDTH, full), isCompact: false, hasExtras: true }
45 const slim = Math.floor((columns - SLIM_OVERHEAD) / 3)
46 if (slim >= MIN_BAR) return { barWidth: Math.min(BAR_WIDTH, slim), isCompact: false, hasExtras: false }
47
48 return { barWidth: 0, isCompact: true, hasExtras: false }
49}
50
51/** `~`-relative under `home`, then at most the last two segments. */
52export const shortDir = (cwd: string, home?: string): string => {
53 const norm = (x: string) => x.replace(/\\/g, '/').replace(/\/+$/, '')
54 const dir = norm(cwd)
55 const h = home ? norm(home) : ''
56 if (h !== '' && dir.toLowerCase() === h.toLowerCase()) return '~'
57 const isUnderHome = h !== '' && dir.toLowerCase().startsWith(`${h.toLowerCase()}/`)
58 const parts = (isUnderHome ? dir.slice(h.length + 1) : dir).split('/').filter(Boolean)
59 const tail = parts.slice(-2).join('/')
60 if (isUnderHome) return parts.length > 2 ? `~/…/${tail}` : `~/${tail}`
61
62 return parts.length > 2 ? `…/${tail}` : dir
63}
64
65export type Item = { label: string; value: string; color: string }
66
67/** Keeps the items that fit `columns`, in order; the folder's start is cut to fit what is left. */
68export const fitRow = (items: Item[], columns: number): Item[] => {
69 const out: Item[] = []
70 let used = 0
71 for (const item of items) {
72 const sep = out.length > 0 ? 3 : 0
73 const room = columns - used - sep - item.label.length - 1
74 if (room >= item.value.length) {
75 out.push(item)
76 used += sep + item.label.length + 1 + item.value.length
77 } else if (item.label === 'dir' && room >= 6) {
78 out.push({ ...item, value: `…${item.value.slice(item.value.length - (room - 1))}` })
79 used = columns
80 }
81 }
82
83 return out
84}
85
86/** Row 2's items: kept by priority (model, folder, effort, thinking), shown folder first. */
87export const envItems = (v: Env, columns: number): Item[] => {
88 const items: Item[] = []
89 if (v.model) items.push({ label: 'model', value: v.model, color: 'magenta' })
90 if (v.cwd) items.push({ label: 'dir', value: shortDir(v.cwd, v.home), color: 'cyan' })
91 if (v.effort) items.push({ label: 'effort', value: v.effort, color: 'yellow' })
92 if (v.thinking) items.push({ label: 'thinking', value: v.thinking, color: v.thinking === 'off' ? 'gray' : 'green' })
93 const order = ['dir', 'model', 'effort', 'thinking']
94
95 return fitRow(items, columns).sort((a, b) => order.indexOf(a.label) - order.indexOf(b.label))
96}
97
98export const bar = (pct: number, width = BAR_WIDTH): string => {
99 const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
100
101 return '█'.repeat(filled) + '░'.repeat(width - filled)
102}
103
104/** HH:MM in the machine's own time zone, or in `timeZone` when given. */
105export const hhmm = (iso?: string, timeZone?: string): string => {
106 if (!iso) return ''
107 const date = new Date(iso)
108 if (Number.isNaN(date.getTime())) return ''
109
110 return date.toLocaleTimeString(undefined, {
111 hour: '2-digit',
112 minute: '2-digit',
113 hourCycle: 'h23',
114 timeZone,
115 })
116}
117
118const toWindow = (raw: unknown): Window | undefined => {
119 if (!raw || typeof raw !== 'object') return undefined
120 const r = raw as Record<string, unknown>
121 const pct = r.utilization ?? r.used_percentage ?? r.percentUsed
122 if (typeof pct !== 'number') return undefined
123 const resetsAt = typeof r.resets_at === 'string' ? r.resets_at : undefined
124
125 return { pct, resetsAt }
126}
127
128/** Reads the usage API's body: `{ five_hour: { utilization, resets_at }, seven_day: ... }`. */
129export const parseApi = (text: string): Pick<Usage, 'fiveHour' | 'sevenDay'> | undefined => {
130 try {
131 const body = JSON.parse(text) as Record<string, unknown>
132 const fiveHour = toWindow(body.five_hour)
133 const sevenDay = toWindow(body.seven_day)
134
135 return fiveHour || sevenDay ? { fiveHour, sevenDay } : undefined
136 } catch {
137 return undefined
138 }
139}
140
141const fromSession = (limits: SessionRateLimit[]): Pick<Usage, 'fiveHour' | 'sevenDay'> => {
142 const pick = (kind: string): Window | undefined => {
143 const l = limits.find(x => x.kind === kind)
144
145 return l && { pct: l.percentUsed, resetsAt: l.resetsAt }
146 }
147
148 return { fiveHour: pick('five_hour'), sevenDay: pick('seven_day') }
149}
150
151let lastFetch = 0
152let isFetching = false
153let poll: { cancel: () => void } | undefined
154
155async function save($: EngineInterface, patch: Partial<Usage>) {
156 const next = await update($, usage, u => ({ ...u, ...patch }))
157 if (patch.fiveHour || patch.sevenDay) {
158 const { fiveHour, sevenDay, limitsAt } = await read($, usage)
159 await $.store.set(STORE_KEY, { fiveHour, sevenDay, limitsAt })
160 }
161
162 return next
163}
164
165async function refresh($: EngineInterface, force = false) {
166 const now = await $.clock.now()
167 if (isFetching || (!force && now - lastFetch < MIN_GAP_MS)) return
168 isFetching = true
169 lastFetch = now
170 try {
171 const auth = await $.session.authorize()
172 if (!auth) return
173 const res = await $.http.fetch(USAGE_URL, {
174 auth: auth.handle,
175 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
176 })
177 const parsed = res.ok ? parseApi(res.text) : undefined
178 if (parsed) await save($, { ...parsed, limitsAt: now, source: 'api' })
179 } catch {
180 // Offline or refused: the session's own readings stand.
181 } finally {
182 isFetching = false
183 }
184}
185
186// The session's own figures: the context on every response, and the limits its
187// last response carried, as fresh as the API's at that moment.
188async function measure($: EngineInterface, limits: SessionRateLimit[], ctx: { percent?: number; tokens?: number }) {
189 const patch: Partial<Usage> = { ctxPct: ctx.percent, ctxTokens: ctx.tokens }
190 if (limits.length > 0) {
191 Object.assign(patch, fromSession(limits), { limitsAt: await $.clock.now(), source: 'session' })
192 }
193 await save($, patch)
194}
195
196// Folder, model, effort and thinking: from the session, the /config rows and the
197// merged settings. Written to state only when something changed.
198async function readEnv($: EngineInterface) {
199 const prev = await read($, env)
200 const next: Env = { ...prev }
201 try {
202 next.cwd = await $.session.cwd()
203 next.model = await $.session.model()
204 next.home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
205 const rows = await $.config.list()
206 const settings = await $.settings.read()
207 const row = (re: RegExp) => rows.find(r => re.test(r.key))?.value
208 const effort = row(/effort/i) ?? settings.effortLevel
209 next.effort = typeof effort === 'string' || typeof effort === 'number' ? String(effort) : undefined
210 const thinking = row(/thinking/i) ?? settings.alwaysThinkingEnabled
211 if (typeof thinking === 'boolean') next.thinking = thinking ? 'on' : 'off'
212 else next.thinking = typeof thinking === 'string' && thinking !== '' ? thinking : undefined
213 } catch {
214 // Keep what was read so far.
215 }
216 if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, env, () => next)
217}
218
219export const register: Register = on => {
220 on('session.start', async ($, e, next) => {
221 // Bars from the last reading any session stored, so they never start empty.
222 const cached = (await $.store.get(STORE_KEY)) as Usage | undefined
223 if (cached && !(await read($, usage)).limitsAt) {
224 await update($, usage, u => ({ ...u, ...cached, source: 'cache' as const }))
225 }
226 await readEnv($)
227 const { context, rateLimits } = await $.session.usage()
228 await measure($, rateLimits, context)
229 void refresh($, true)
230 poll?.cancel()
231 poll = $.clock.every(POLL_MS, () => void refresh($, true))
232
233 return next(e)
234 })
235
236 on('turn.start', async ($, e, next) => {
237 await readEnv($)
238
239 return next(e)
240 })
241
242 on('config.set', async ($, e, next) => {
243 const result = await next(e)
244 await readEnv($).catch(() => undefined)
245
246 return result
247 })
248
249 on('session.measure', async ($, e, next) => {
250 await measure($, e.rateLimits, e.context)
251 void refresh($)
252
253 return next(e)
254 })
255
256 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
257 if (e.props.hasSurvey) return next(e)
258 const u = await read($, usage)
259 const v = await read($, env)
260 const { Box, Text } = $.ui.resolve(e)
261 const now = await $.clock.now()
262 const columns = Math.max(1, e.props.bodyColumns)
263 const lay = layoutFor(columns)
264 const isStale = !u.limitsAt || now - u.limitsAt > STALE_MS || u.source === 'cache'
265 const age = u.limitsAt ? Math.round((now - u.limitsAt) / 60_000) : undefined
266
267 const meter = (label: string, pct: number | undefined, extra: string) => {
268 const color = pct === undefined ? 'gray' : levelColor(pct)
269 const text = pct === undefined ? '--' : `${Math.round(pct)}%${extra}`
270
271 return (
272 <Text color={color} bold={!isStale && pct !== undefined} dimColor={isStale || pct === undefined}>
273 {label} {lay.isCompact ? '' : `${bar(pct ?? 0, lay.barWidth)} `}
274 {text}
275 </Text>
276 )
277 }
278 const reset = (w?: Window) => (lay.hasExtras && w?.resetsAt ? ` ${hhmm(w.resetsAt)}` : '')
279 const tokens = u.ctxTokens !== undefined && lay.hasExtras ? ` ${Math.round(u.ctxTokens / 1000)}K` : ''
280 const sep = <Text dimColor>{lay.isCompact ? ' · ' : ' | '}</Text>
281 const row2 = envItems(v, columns)
282
283 return (
284 <Box flexDirection="column">
285 <Box flexDirection="row">
286 {meter('ctx', u.ctxPct ?? 0, tokens)}
287 {sep}
288 {meter('5h', u.fiveHour?.pct, reset(u.fiveHour))}
289 {sep}
290 {meter('7d', u.sevenDay?.pct, reset(u.sevenDay))}
291 {isStale && age !== undefined ? <Text dimColor> ({age}m fa)</Text> : null}
292 </Box>
293 {row2.length > 0 ? (
294 <Box flexDirection="row">
295 {row2.map((it, k) => (
296 <Box key={it.label} flexDirection="row">
297 {k > 0 ? <Text dimColor> | </Text> : null}
298 <Text dimColor>{it.label} </Text>
299 <Text color={it.color}>{it.value}</Text>
300 </Box>
301 ))}
302 </Box>
303 ) : null}
304 </Box>
305 )
306 })
307}
308types/index.d.ts 32 lines1export type Window = { pct: number; resetsAt?: string }
2
3export type Usage = {
4 /** Context fill of the last response, 0-100; absent before the first one. */
5 ctxPct?: number
6 /** Input tokens of the last response. */
7 ctxTokens?: number
8 fiveHour?: Window
9 sevenDay?: Window
10 /** When the rate-limit figures were read, ms since the epoch. */
11 limitsAt?: number
12 /** Where they came from: the usage API, this session's last response, or the store of an earlier one. */
13 source?: 'api' | 'session' | 'cache'
14}
15
16/** What row 2 shows: where the session runs and how the model is set. */
17export type Env = {
18 cwd?: string
19 /** The user's home directory, to print the folder `~`-relative. */
20 home?: string
21 model?: string
22 effort?: string
23 /** `on`, `off`, or whatever the config row holds. */
24 thinking?: string
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'usage-bars': { usage: Usage; env: Env }
30 }
31}
32