SLOPSHOPPER

usage-band

Always-on band above the prompt: context fill and rate-limit windows

newbandtimer
v0.2.2MITupdated 2026-10-09zexion7873/usage-band/plugin
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 ⟨Claude Code's own drawing⟩ ctx 49% (97k/200k) · 5h 31% (NaNhNaNm) · $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ctx 49% (97k/200k) · 5h 31% (NaNhNaNm) · $0.42
README

📶 usage-band

CI: check

<img src="docs/band.svg" width="767" title="Drawn by the mod's own bar() from sample figures — not a screenshot." alt="The usage band, drawn by the mod's own bar() from sample figures rather than a screenshot: ctx at 42% with 84k of 200k tokens and a red tick at the 80% auto-compact point, 5h at 67% in amber with 1h48m to reset and a grey pace tick at 64%, wk at 89% in red with 2d21h to reset and a pace tick at 59%, then cache 83% and $1.23.">

Context fill and every rate-limit window, always on, right above the Claude Code prompt — in the desktop app's Code tab as well as the terminal.

License: MIT Surface: desktop | terminal Network: none

No status-line script. No network requests. No model calls. One mod reading Claude Code's own session usage.


🚀 Install

🤖 Hand it to your agent

Paste this and walk away:

Fetch and follow https://raw.githubusercontent.com/zexion7873/usage-band/main/llms-install.md

It installs from the CLI and verifies the install. Recipe in llms-install.md.

🧑 Or type it yourself

From inside a Claude Code terminal session:

/plugin marketplace add zexion7873/usage-band
/plugin install usage-band@usage-band

Pick the user scope. The band appears in that session at once. Installed at the user scope, it also loads in sessions the desktop app starts — the desktop Code tab cannot run /plugin itself, so install from a terminal once.

[!IMPORTANT] Requirements: a Claude Code build with function-hook plugins (mods). CI validates and tests against Claude Code 2.1.291; older builds are unmeasured.


📊 What it shows

| | Item | Meaning | |:-:|------|---------| | 🧠 | ctx | Context window fill, tokens/window beside it. A red tick marks where auto-compact triggers. | | ⏱️ | 5h, wk | Each rate-limit window: percent used and time until it resets. A grey tick marks the share of the window already elapsed — a fill past the tick is burning faster than an even pace. | | 💳 | spend | The spend limit, when the account has one. | | ♻️ | cache N% | Cache reads as a share of all input tokens in the last response. | | 💵 | $N.NN | Session cost. |

Percent turns amber at 60% and red at 85%.

🖥️ Desktop and terminal

On the desktop each meter is a bar, and a screen reader reads its detail: for ctx, the compact point and the three largest context categories; for a rate-limit window, the pace. The terminal draws the headline figures as one line of text:

ctx 42% (84k/200k) · 5h 67% (1h48m) · wk 89% (2d21h) · cache 83% · $1.23

Reset countdowns and pace ticks keep moving while the session sits idle — the band redraws once a minute rather than waiting for the next response.

🎯 Use cases

  • Compact on your terms. Before starting a long task, compare the ctx fill with its red tick. Close to the tick, run /compact or start a fresh session now, instead of having auto-compact fire halfway through the work.
  • Pace a rate-limit window. When the 5h fill runs past its grey tick, you are spending faster than an even pace and will hit the limit before it resets. The countdown beside it says how long the rest has to last.
  • See usage in the desktop app at all. In the Code tab, where status-line meters never run, glance above the prompt instead of opening the usage panel.

🖥️ Desktop and terminal

A statusLine command never runs in the desktop Code tab, so every status-line usage meter is invisible there. usage-band is a mod — a plugin of function hooks — and draws into the prompt area itself, which both surfaces render.

It steps aside while Claude Code shows a survey above the prompt, and keeps whatever other plugins drew beneath it.


🔧 How it works

flowchart LR
    Start["session.start<br/>usage + breakdown"]
    Measure["session.measure<br/>after every turn"]
    Step["turn.step<br/>ctx after every response"]
    Compact["session.compact<br/>size it leaves"]
    State[("usage atom<br/>plugin state")]
    Render["ui.render · AbovePrompt"]
    Desk["🖥️ desktop<br/>SVG bars"]
    Term["⌨️ terminal<br/>one line of text"]
    Clock["every 60 s"]

    Start --> State
    Measure --> State
    Step --> State
    Compact --> State
    State --> Render
    Render --> Desk
    Render --> Term
    Clock -.->|"invalidate"| Render

Every figure comes from Claude Code's own session usage, read locally. The mod writes no files, opens no ports, and makes no network requests or model calls — claude plugin validate plugin prints every engine call it makes.


🩺 Troubleshooting

SymptomCheck
No band at allclaude plugin list should show usage-band@usage-band as loaded. A session picks up plugins only when it starts, so open a new one after installing or updating. Mods also need a recent Claude Code — see Requirements.
Band missing for a momentIt steps aside while Claude Code shows a survey above the prompt, and draws nothing until the session has reported its first usage.
No 5h / wk / spendOnly the windows Claude Code reports for your account are drawn; spend appears only with a spend limit.
A figure looks wrongCompare it with Claude Code's own usage panel, then open a bug with both values and where you ran it.

🧹 Uninstall

/plugin uninstall usage-band@usage-band

The mod writes nothing to disk of its own, so there is nothing else to clean up.


🛠️ Develop

claude plugin validate --strict plugin
claude plugin test plugin

CONTRIBUTING.md has the dev loop and the traps worth knowing first; AGENTS.md has the rest.


⚖️ Disclaimer

Unofficial community project. Not affiliated with, endorsed by, or sponsored by Anthropic.

Source 3 files
hooks/register.tsx 205 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionContextBreakdown, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { UsageBand, UsageBandDetail } from '../types'
5import { BAR, bar, clampPct, COMPACT_TICK, PACE_TICK, tone } from './bar'
6
7const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
8
9
10const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
11const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
12
13const TEXT_COLOR = { calm: undefined, hot: 'warning', over: 'error' } as const
14
15const untilReset = (resetsAt: string | undefined, now: number) => {
16  if (resetsAt === undefined) return ''
17  const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60000))
18  const days = Math.floor(minutes / 1440)
19  const hours = Math.floor((minutes % 1440) / 60)
20  return days > 0 ? `${days}d${hours}h` : `${hours}h${minutes % 60}m`
21}
22
23// Share of the window already elapsed: usage above it is ahead of an even burn.
24const pace = (limit: SessionRateLimit, now: number) => {
25  const span = WINDOW_MS[limit.kind]
26  if (span === undefined || limit.resetsAt === undefined) return undefined
27  return Math.round(clampPct(100 * (1 - (Date.parse(limit.resetsAt) - now) / span)))
28}
29
30const tokens = (n: number) =>
31  n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
32
33const detailOf = (breakdown: SessionContextBreakdown | undefined, window: number): UsageBandDetail | undefined => {
34  if (breakdown === undefined) return undefined
35  const api = breakdown.apiUsage
36  const read = api?.cache_read_input_tokens ?? 0
37  const input = api === null ? 0 : read + api.input_tokens + api.cache_creation_input_tokens
38  return {
39    compactPercent:
40      breakdown.isAutoCompactEnabled && breakdown.autoCompactThreshold !== undefined
41        ? Math.round((breakdown.autoCompactThreshold / window) * 100)
42        : undefined,
43    cacheHitPercent: input > 0 ? Math.round((read / input) * 100) : undefined,
44    contextParts: breakdown.categories
45      .filter(c => c.kind === 'used')
46      .sort((a, b) => b.tokens - a.tokens)
47      .slice(0, 3)
48      .map(c => ({ name: c.name, tokens: c.tokens })),
49  }
50}
51
52const snapshot = (
53  context: SessionContextUsage,
54  rateLimits: SessionRateLimit[],
55  cost: SessionCost | undefined,
56  detail: UsageBandDetail | undefined,
57): UsageBand => ({
58  contextPercent: context.percent,
59  contextTokens: context.tokens,
60  contextWindow: context.window,
61  rateLimits,
62  costUsd: cost?.usd,
63  detail,
64})
65
66const withContext = (prev: UsageBand, tokens: number): UsageBand => ({
67  ...prev,
68  contextTokens: tokens,
69  contextPercent: Math.round(clampPct((100 * tokens) / prev.contextWindow)),
70})
71
72export const register: Register = on => {
73  on('session.start', async ($, e, next) => {
74    const { context, rateLimits, cost } = await $.session.usage({ breakdown: 'summary' })
75    await update($, usage, () => snapshot(context, rateLimits, cost, detailOf(context.breakdown, context.window)))
76    // Reset countdowns and pace ticks read the clock while drawing; without this an idle session freezes them.
77    $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
78    return next(e)
79  })
80
81  on('session.measure', async ($, e, next) => {
82    await update($, usage, prev => snapshot(e.context, e.rateLimits, e.cost, prev?.detail))
83    // Second write so a failed breakdown leaves the headline figures current.
84    if (e.changed.includes('context')) {
85      const { context } = await $.session.usage({ breakdown: 'summary' })
86      const detail = detailOf(context.breakdown, e.context.window)
87      await update($, usage, prev => prev && { ...prev, detail })
88    }
89    return next(e)
90  })
91
92  on('session.compact', async ($, e, next) => {
93    const result = await next(e)
94    // usage() keeps the last response's fill until a new response lands, and the next measure waits for the turn's end.
95    if (result.skip !== undefined || result.tokensAfter === undefined || e.agentId !== undefined || e.trigger === 'precompute')
96      return result
97    const after = result.tokensAfter
98    await update($, usage, prev => prev && withContext(prev, after))
99    const { context } = await $.session.usage({ breakdown: 'summary' })
100    const detail = detailOf(context.breakdown, context.window)
101    await update($, usage, prev => prev && { ...prev, detail })
102    return result
103  })
104
105  // session.measure waits for the turn's end; a long turn would otherwise hold ctx at its starting fill.
106  on('turn.step', async function* ($, e, next) {
107    const result = yield* next(e)
108    const u = result.usage
109    if (u === null || e.agentId !== undefined) return result
110    const fill = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
111    await update($, usage, prev => prev && withContext(prev, fill))
112    return result
113  })
114
115  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116    const below = await next(e)
117    const current = await read($, usage)
118    if (e.props.hasSurvey || current === null) return below
119
120    const now = await $.clock.now()
121    const detail = current.detail
122    const ctxPercent = current.contextPercent ?? 0
123    const ctxDetail =
124      current.contextTokens === undefined ? '' : `${tokens(current.contextTokens)}/${tokens(current.contextWindow)}`
125    const meters = [
126      {
127        key: 'ctx',
128        label: 'ctx',
129        percent: ctxPercent,
130        detail: ctxDetail,
131        extra: [
132          ...(detail?.compactPercent === undefined ? [] : [`compacts at ${detail.compactPercent}%`]),
133          ...(detail?.contextParts ?? []).map(p => `${p.name} ${tokens(p.tokens)}`),
134        ],
135        marks: detail?.compactPercent === undefined ? [] : [{ percent: detail.compactPercent, color: COMPACT_TICK }],
136      },
137      ...current.rateLimits.map(limit => {
138        const label = LABELS[limit.kind] ?? limit.kind
139        const reset = untilReset(limit.resetsAt, now)
140        const paceAt = pace(limit, now)
141        return {
142          key: limit.kind,
143          label,
144          percent: limit.percentUsed,
145          detail: reset,
146          extra: paceAt === undefined ? [] : [`pace ${paceAt}%`],
147          marks: paceAt === undefined ? [] : [{ percent: paceAt, color: PACE_TICK }],
148        }
149      }),
150    ]
151    const cache = detail?.cacheHitPercent === undefined ? '' : `cache ${detail.cacheHitPercent}%`
152    const cost = current.costUsd === undefined ? '' : `$${current.costUsd.toFixed(2)}`
153
154    if (e.surface === 'terminal') {
155      const { Box, Text } = $.ui.resolve(e)
156      return (
157        <Box flexDirection="column">
158          {below}
159          <Box>
160            {meters.map((m, i) => (
161              <Text key={m.key}>
162                <Text dimColor>{i > 0 ? ' · ' : ''}{m.label} </Text>
163                <Text color={TEXT_COLOR[tone(m.percent)]} dimColor={tone(m.percent) === 'calm'}>{m.percent}%</Text>
164                <Text dimColor>{m.detail && ` (${m.detail})`}</Text>
165              </Text>
166            ))}
167            {cache && <Text dimColor> · {cache}</Text>}
168            {cost && <Text dimColor> · {cost}</Text>}
169          </Box>
170        </Box>
171      )
172    }
173
174    const { Box, Text, Svg } = $.ui.resolve(e)
175    return (
176      <Box flexDirection="column">
177        {below}
178        <Box flexDirection="row" alignItems="center" justifyContent="space-between">
179          <Box flexDirection="row" alignItems="center" gap={3}>
180            {meters.map(m => (
181              <Box key={m.key} flexDirection="row" alignItems="center" gap={1}>
182                <Text dimColor>{m.label}</Text>
183                <Svg
184                  source={bar(m.percent, m.marks)}
185                  alt={[`${m.label} ${m.percent}%`, m.detail, ...m.extra].filter(Boolean).join(' · ')}
186                  width={BAR.width}
187                  height={BAR.tick}
188                />
189                {tone(m.percent) === 'calm' ? (
190                  <Text dimColor>{m.percent}%</Text>
191                ) : (
192                  <Text color={TEXT_COLOR[tone(m.percent)]}>{m.percent}%</Text>
193                )}
194                {m.detail && <Text dimColor>{m.detail}</Text>}
195              </Box>
196            ))}
197            {cache && <Text dimColor>{cache}</Text>}
198          </Box>
199          {cost && <Text dimColor>{cost}</Text>}
200        </Box>
201      </Box>
202    )
203  })
204}
205
hooks/bar.ts 21 lines
1// No 'claude-code' import here: tools/make-hero.mts runs this file under plain node.
2export const tone = (percent: number) => (percent >= 85 ? 'over' : percent >= 60 ? 'hot' : 'calm')
3
4// The bar is drawn as an image: theme keys don't reach it, so colors are literal (sampled from the app's usage panel).
5const FILL = { calm: '#4177d0', hot: '#bf882e', over: '#c04742' } as const
6export const PACE_TICK = '#9c9c9c'
7export const COMPACT_TICK = '#c04742'
8export const BAR = { width: 80, height: 8, tick: 12 }
9
10export const clampPct = (p: number) => Math.min(100, Math.max(0, p))
11const at = (percent: number) => Math.round((clampPct(percent) / 100) * BAR.width)
12
13const tick = (percent: number, color: string) =>
14  `<rect x="${Math.min(BAR.width - 2, Math.max(0, at(percent) - 1))}" width="2" height="${BAR.tick}" fill="${color}"/>`
15
16export const bar = (percent: number, marks: { percent: number; color: string }[]) => {
17  const r = BAR.height / 2
18  const y = (BAR.tick - BAR.height) / 2
19  return `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR.width}" height="${BAR.tick}"><rect y="${y}" width="${BAR.width}" height="${BAR.height}" rx="${r}" fill="#888" fill-opacity="0.25"/><rect y="${y}" width="${at(percent)}" height="${BAR.height}" rx="${r}" fill="${FILL[tone(percent)]}"/>${marks.map(m => tick(m.percent, m.color)).join('')}</svg>`
20}
21
types/index.d.ts 25 lines
1export type UsageBandLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type UsageBandPart = { name: string; tokens: number }
4
5export type UsageBandDetail = {
6  compactPercent?: number
7  cacheHitPercent?: number
8  contextParts: UsageBandPart[]
9}
10
11export type UsageBand = {
12  contextPercent?: number
13  contextTokens?: number
14  contextWindow: number
15  rateLimits: UsageBandLimit[]
16  costUsd?: number
17  detail?: UsageBandDetail
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'usage-band': { usage: UsageBand | null }
23  }
24}
25