SLOPSHOPPER

usage-bars

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

newbandnetworktimer
v0.1.0MITupdated 2026-10-06a-bine/claude-mods/usage-bars
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bars
› 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 ctx ███████░░░░░░░ 49% 97K | 5h ████░░░░░░░░░░ 31% 09:53 | 7d ░░░░░░░░░░░░░░ -- dir /work/app | model claude-opus-5-5 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx ███████░░░░░░░ 49% 97K | 5h ████░░░░░░░░░░ 31% 09:53 | 7d ░░░░░░░░░░░░░░ -- dir /work/app | model claude-opus-5-5
README

claude-mods

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

usage-bars

usage-bars: context, 5h and 7d bars, then folder, model, effort and thinking

Two rows above the prompt:

  • Row 1: bars for the context window, the 5-hour limit and the 7-day limit, with the reset times. Each bar is colored by fill: blue when it is unused, green below 50%, yellow below 80%, red from 80% up.
  • Row 2: the current folder, the model, the effort level and thinking.

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.

copy-markdown

copy-markdown: the md button on a reply and the toast after a copy

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.

Install

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.

Develop

claude plugin validate <mod>
claude plugin test <mod>
claude --plugin-dir ./<mod>   # run a working copy

Notes

  • The usage endpoint (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.
  • These mods use the Claude Code mod API, which may change between releases. They were written against 2.1.291.

License

MIT

Source 2 files
hooks/register.tsx 308 lines
1import { 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}
308
types/index.d.ts 32 lines
1export 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