SLOPSHOPPER

usage-band

A band above the prompt with your Claude plan usage: 5-hour and 7-day bars with reset countdowns and a pace marker, plus the context window. No network calls…

newbandtoasttimer
v0.1.0MITupdated 2026-10-23wagnerporras/claude-mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 Usage 5h ▰▰▰▱▱▱▱▱▱▱ 31% · context ▰▰▰▰▰▱▱▱▱▱ 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Usage 5h ▰▰▰▱▱▱▱▱▱▱ 31% · context ▰▰▰▰▰▱▱▱▱▱ 49%
README

usage-band

A Claude Code mod that keeps your plan usage in a band above the prompt:

Usage  5h ▰▰▱▱▱│▱▱▱▱ 16% 2h13m · 7d ▰▰▰▰▰│▱▱▱▱ 48% 3d4h · context ▰▰▱▱▱▱▱▱▱▱ 22%

<img width="640" height="36" alt="image" src="https://github.com/user-attachments/assets/210d3bf7-f1a4-4a78-910a-8c2a4c98fa56" />

  • 5h / 7d: how much of the 5-hour and 7-day windows your account has used, then the time until each resets.
  • Bar: ten cells, one per 10%. Filled cells are green, amber from 70%, red from 90%, in your theme's colours.
  • │ pace marker: where usage would be if you spread it evenly through the window. Filled cells past the marker mean you are using it faster than that.
  • context: how full this session's context window is: the conversation Claude keeps in mind (messages, replies, files read, tool output). Per session, not part of your plan's limits; Claude Code compacts it when it gets full.
  • A toast, once per window, when 5h or 7d usage crosses the red threshold.

The figures are the ones the session itself reports (the same ones Claude Code's status line gets), so the mod makes no network calls and spends no tokens. Usage arrives with a session's first response; until then the band shows the last reading from an earlier session, dimmed. On an API key, with no plan limits, it shows the context bar alone. On a narrow terminal the bars drop out and the numbers stay. Collapse the band with ctrl+x ctrl+a.

Options

Set in /config (or pluginConfigs in settings):

OptionDefaultWhat
warnAt70A bar turns amber at this %
criticalAt90A bar turns red at this %, and 5h / 7d toast once when they cross it
showPacetrueThe pace marker in the 5h / 7d bars
showContexttrueThe context bar
toasttrueThe toast near the limit

Notes

  • It keeps the last reading per Claude config directory (CLAUDE_CONFIG_DIR) in the mod's own store, so several accounts on one machine don't mix.
  • It draws the band above the prompt, which shows one mod's band at a time.
  • Function-hook mods are early access; the API may change between Claude Code releases.

Install

At the prompt of a Claude Code terminal session:

/plugin install usage-band --marketplace wagnerporras/claude-mods

Try it from a folder

claude --plugin-dir <path to this folder>

Tests: claude plugin test <folder>; checks: claude plugin validate <folder>.

Source 2 files
hooks/register.tsx 222 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, ThemeKey } from 'claude-code'
3
4import type { Reading, UsageWindow } from '../types'
5
6// A band above the prompt with the session's plan usage: 5h and 7d windows
7// (bar, pace marker, percent, reset countdown) and the context window. Reads
8// only what the session reports; the last reading is kept per account so a new
9// session starts with it.
10
11const CELLS = 10
12// Below this many columns the bars drop out; the full line is about 82 wide.
13const NARROW = 88
14const WINDOWS = [
15  { kind: 'five_hour', label: '5h', ms: 5 * 3_600_000 },
16  { kind: 'seven_day', label: '7d', ms: 7 * 86_400_000 },
17] as const
18
19const reading = atom({ plugin: 'usage-band', key: 'reading' } as const, null)
20
21let config = { warnAt: 70, criticalAt: 90, showPace: true, showContext: true, toast: true }
22let tick: { cancel: () => void } | undefined
23
24export function filled(percent: number): number {
25  return Math.min(CELLS, Math.max(0, Math.round(percent / (100 / CELLS))))
26}
27
28export function level(percent: number, warnAt = 70, criticalAt = 90): ThemeKey {
29  return percent >= criticalAt ? 'error' : percent >= warnAt ? 'warning' : 'success'
30}
31
32// The cell an even pace through the window has reached, or undefined.
33export function paceCell(resetsAt: string | undefined, windowMs: number, now: number): number | undefined {
34  const end = resetsAt ? Date.parse(resetsAt) : NaN
35  if (Number.isNaN(end)) return undefined
36  const elapsed = Math.min(1, Math.max(0, 1 - (end - now) / windowMs))
37  return Math.min(CELLS - 1, Math.floor(elapsed * CELLS))
38}
39
40// Time left as its two largest units: 45m, 2h13m, 3d4h.
41export function countdown(ms: number): string {
42  const minutes = Math.max(0, Math.round(ms / 60_000))
43  const days = Math.floor(minutes / 1440)
44  const hours = Math.floor((minutes % 1440) / 60)
45  const mins = minutes % 60
46  if (days > 0) return `${days}d${hours}h`
47  if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`
48  return `${mins}m`
49}
50
51// The bar as runs of filled, empty and pace cells.
52export function runs(percent: number, pace?: number): { text: string; kind: 'fill' | 'empty' | 'pace' }[] {
53  const full = filled(percent)
54  const out: { text: string; kind: 'fill' | 'empty' | 'pace' }[] = []
55  for (let i = 0; i < CELLS; i++) {
56    const kind = i === pace ? 'pace' : i < full ? 'fill' : 'empty'
57    const text = kind === 'pace' ? '│' : kind === 'fill' ? '▰' : '▱'
58    const last = out[out.length - 1]
59    if (last && last.kind === kind) last.text += text
60    else out.push({ text, kind })
61  }
62  return out
63}
64
65const toWindows = (limits: readonly SessionRateLimit[]): UsageWindow[] =>
66  limits.flatMap(l =>
67    l.kind === 'five_hour' || l.kind === 'seven_day'
68      ? [{ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt }]
69      : [],
70  )
71
72const isCurrent = (w: UsageWindow, now: number) => !w.resetsAt || Date.parse(w.resetsAt) > now
73
74async function account($: EngineInterface): Promise<string> {
75  return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? 'default'
76}
77
78async function remember($: EngineInterface, windows: UsageWindow[]): Promise<void> {
79  if (windows.length) await $.store.set(`last:${await account($)}`, windows)
80}
81
82async function recall($: EngineInterface, now: number): Promise<UsageWindow[]> {
83  const kept = (await $.store.get(`last:${await account($)}`)) as UsageWindow[] | undefined
84  return (kept ?? []).filter(w => isCurrent(w, now))
85}
86
87// Toasts once per window when it crosses the red threshold.
88async function warn($: EngineInterface, windows: UsageWindow[], now: number): Promise<void> {
89  if (!config.toast) return
90  for (const w of windows) {
91    if (w.percent < config.criticalAt) continue
92    const key = `toasted:${await account($)}:${w.kind}`
93    if ((await $.store.get(key)) === (w.resetsAt ?? 'unknown')) continue
94    await $.store.set(key, w.resetsAt ?? 'unknown')
95    const label = w.kind === 'five_hour' ? '5-hour' : '7-day'
96    const left = w.resetsAt ? `, resets in ${countdown(Date.parse(w.resetsAt) - now)}` : ''
97    $.ui.toast(`${label} usage at ${Math.round(w.percent)}%${left}`)
98  }
99}
100
101async function measured($: EngineInterface, limits: readonly SessionRateLimit[], context?: number): Promise<void> {
102  const now = await $.clock.now()
103  const windows = toWindows(limits)
104  const prior = await read($, reading)
105  await update($, reading, () =>
106    windows.length
107      ? { windows, context: context ?? prior?.context, isStale: false, now }
108      : { windows: prior?.windows ?? [], context: context ?? prior?.context, isStale: prior?.isStale ?? true, now },
109  )
110  if (windows.length) {
111    await remember($, windows)
112    await warn($, windows, now)
113  }
114}
115
116async function start($: EngineInterface): Promise<void> {
117  const now = await $.clock.now()
118  const usage = await $.session.usage()
119  const live = toWindows(usage.rateLimits)
120  const windows = live.length ? live : await recall($, now)
121  await update($, reading, () => ({
122    windows,
123    context: usage.context.percent,
124    isStale: live.length === 0,
125    now,
126  }))
127  if (live.length) {
128    await remember($, live)
129    await warn($, live, now)
130  }
131}
132
133async function clock($: EngineInterface): Promise<void> {
134  const now = await $.clock.now()
135  await update($, reading, r => (r ? { ...r, now, windows: r.windows.filter(w => isCurrent(w, now)) } : r))
136}
137
138export const register: Register = (on, options) => {
139  config = {
140    warnAt: Number(options.warnAt ?? 70),
141    criticalAt: Number(options.criticalAt ?? 90),
142    showPace: options.showPace !== false,
143    showContext: options.showContext !== false,
144    toast: options.toast !== false,
145  }
146
147  on('session.start', async ($, e, next) => {
148    const started = await next(e)
149    try {
150      await start($)
151    } catch {
152      // Nothing to show until the session reports usage.
153    }
154    tick?.cancel()
155    // Countdowns and pace markers move with the clock.
156    tick = $.clock.every(60_000, () => void clock($).catch(() => undefined))
157    return started
158  })
159
160  on('session.measure', async ($, e, next) => {
161    if (e.changed.includes('rateLimits') || e.changed.includes('context')) {
162      await measured($, e.rateLimits, e.context.percent).catch(() => undefined)
163    }
164    return next(e)
165  })
166
167  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168    const r = await read($, reading)
169    const context = config.showContext ? r?.context : undefined
170    if (e.props.hasSurvey || !r || (r.windows.length === 0 && context === undefined)) return next(e)
171
172    const { Box, Text } = $.ui.resolve(e)
173    const isNarrow = e.props.bodyColumns < NARROW
174
175    const bar = (percent: number, pace?: number) =>
176      isNarrow ? null : (
177        <Text>
178          {runs(percent, config.showPace ? pace : undefined).map(run =>
179            run.kind === 'fill' ? (
180              <Text color={level(percent, config.warnAt, config.criticalAt)}>{run.text}</Text>
181            ) : run.kind === 'pace' ? (
182              <Text bold>{run.text}</Text>
183            ) : (
184              <Text dimColor>{run.text}</Text>
185            ),
186          )}{' '}
187        </Text>
188      )
189
190    const parts = WINDOWS.flatMap(spec => {
191      const w = r.windows.find(x => x.kind === spec.kind)
192      if (!w) return []
193      const left = w.resetsAt ? Date.parse(w.resetsAt) - r.now : NaN
194      return [
195        <Text>
196          {spec.label} {bar(w.percent, paceCell(w.resetsAt, spec.ms, r.now))}
197          <Text dimColor={r.isStale}>{Math.round(w.percent)}%</Text>
198          {Number.isNaN(left) ? '' : <Text dimColor> {countdown(left)}</Text>}
199        </Text>,
200      ]
201    })
202    if (context !== undefined) {
203      parts.push(
204        <Text>
205          context {bar(context)}
206          {Math.round(context)}%
207        </Text>,
208      )
209    }
210
211    return (
212      <Box>
213        <Text>
214          <Text bold>Usage</Text>
215          {'  '}
216          {parts.flatMap((part, i) => (i ? [<Text dimColor> · </Text>, part] : [part]))}
217        </Text>
218      </Box>
219    )
220  })
221}
222
types/index.d.ts 22 lines
1// One rate-limit window as the session reported it.
2export type UsageWindow = {
3  kind: 'five_hour' | 'seven_day'
4  percent: number
5  resetsAt?: string
6}
7
8// What the band draws. `isStale`: the windows are the last reading kept from an
9// earlier session, until this session's first response reports its own.
10export type Reading = {
11  windows: UsageWindow[]
12  context?: number
13  isStale: boolean
14  now: number
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'usage-band': { reading: Reading | null }
20  }
21}
22