SLOPSHOPPER

usage-band

Context window, prompt cache hit rate and countdown, rate limits and cost in a band above the prompt

newbandtimer
v0.2.1no licenseupdated 2026-10-09was865/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 ● Context ███████████▊ 49% 97k/200k ◷ Cache 93% 59m ◔ 5h 31% $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Context ███████████▊ 49% 97k/200k ◷ Cache 93% 59m ◔ 5h 31% $0.42
README

usage-band

A Claude Code mod that puts your session's usage in one band above the prompt:

  • Context: how full the context window is, with tokens used and the window size
  • Cache: the prompt cache hit rate of the last reply, plus a countdown until the cache goes cold
  • 5h / 7d: subscription rate-limit windows, with time until reset
  • Cost: what the session has cost so far

usage-band preview, dark and light

The desktop Code tab draws it as a single SVG row: a dithered context bar, rings for the cache and the limits, and a cost pill. It follows light and dark mode and respects reduced motion. A terminal gets a text version with the same figures.

Colours change as you approach a limit: amber from 60%, red from 85%. The cache ring drains as the cache ages and turns amber when less than a fifth of its lifetime is left.

Install

At the prompt of a Claude Code terminal session:

/plugin install usage-band --marketplace was865/usage-band

Answer y to add the marketplace, then pick the user scope. New sessions show the band; it also works in the desktop app's Code tab.

Options

OptionValuesDefaultWhat it does
cacheTtl1h, 5m1hHow long the prompt cache stays warm. The countdown starts when the last reply finished.

Claude Code does not expose the cache's real expiry time, so the countdown is an estimate. Set cacheTtl to match your account (/plugin → usage-band → configure).

The hit rate counts the main conversation only; subagents are not included. All figures come from Claude Code's own session data, so the mod makes no extra API calls and costs no tokens.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 330 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Cache, Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
7const cache = atom({ plugin: 'usage-band', key: 'cache' } as const, null)
8// Bumped on a timer while the cache is warm, so the countdown redraws.
9const tick = atom({ plugin: 'usage-band', key: 'tick' } as const, 0)
10
11const TTL_MS: Record<string, number> = { '1h': 3_600_000, '5m': 300_000 }
12
13const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Spend' }
14
15// Calm below 60%, warm toward the limit, red past 85%.
16const CLAY = '#D97757'
17const AMBER = '#E0A030'
18const RED = '#E5484D'
19const BLUE = '#7C8CF8'
20const TEAL = '#3FB8A0'
21
22export const tone = (percent: number) => (percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green')
23const hue = (percent: number, calm: string) => (percent >= 85 ? RED : percent >= 60 ? AMBER : calm)
24
25export const kTokens = (n: number) =>
26  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
27
28export const resetIn = (iso: string | undefined, now: number) => {
29  if (!iso) return ''
30  const ms = Date.parse(iso) - now
31  if (!(ms > 0)) return ''
32  const h = Math.floor(ms / 3_600_000)
33  const m = Math.floor((ms % 3_600_000) / 60_000)
34  return h >= 24 ? `${Math.floor(h / 24)}d${h % 24}h` : h > 0 ? `${h}h${m}m` : `${m}m`
35}
36
37const pct = (n: number) => `${Math.round(n)}%`
38
39// Share of the last request's prompt read back from the cache.
40export const hitRate = (u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) => {
41  const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
42  return total > 0 ? (u.cache_read_input_tokens / total) * 100 : null
43}
44
45// What is left of the cache's lifetime: ms remaining and the share left, 0 once cold.
46export const cacheLeft = (c: Cache, ttl: number, now: number) => {
47  const ms = Math.max(0, c.at + ttl - now)
48  return { ms, share: ms / ttl }
49}
50
51export const countdown = (ms: number) => {
52  if (ms <= 0) return 'cold'
53  const m = Math.floor(ms / 60_000)
54  if (m >= 60) return `${Math.floor(m / 60)}h${m % 60 ? `${m % 60}m` : ''}`
55  return m >= 1 ? `${m}m` : '<1m'
56}
57
58// Eighth blocks give the terminal bar a smooth head.
59const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
60export const bar = (percent: number, width = 10) => {
61  const units = Math.max(0, Math.min(width * 8, Math.round((percent / 100) * width * 8)))
62  const full = Math.floor(units / 8)
63  const head = EIGHTHS[units % 8] ?? ''
64  return '█'.repeat(full) + head + ' '.repeat(width - full - (head ? 1 : 0))
65}
66
67const toUsage = (u: {
68  context: { tokens?: number; window: number; percent?: number }
69  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
70  cost?: { usd: number }
71}): Usage => ({
72  contextPercent: u.context.percent ?? null,
73  contextTokens: u.context.tokens ?? null,
74  contextWindow: u.context.window,
75  limits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
76  usd: u.cost?.usd ?? null,
77})
78
79// ---- Desktop: the whole row is one SVG, so nothing wraps. ----
80
81const H = 24
82const BAR_H = 16
83const CELL = 3
84const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
85
86const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
87
88// Rough advance of system UI text, enough to lay segments side by side.
89const textW = (s: string, size: number) =>
90  [...s].reduce(
91    (w, ch) => w + (/[\s.,:|il()]/.test(ch) ? 0.32 : /[A-Z%$@mw↻]/.test(ch) ? 0.82 : /[0-9]/.test(ch) ? 0.64 : 0.6) * size,
92    0,
93  )
94
95// Fixed noise so the dither holds still between redraws.
96const noise = (x: number, y: number) => {
97  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
98  return s - Math.floor(s)
99}
100
101const ring = (cx: number, cy: number, r: number, percent: number, color: string) => {
102  const c = 2 * Math.PI * r
103  const dash = (Math.min(100, Math.max(0, percent)) / 100) * c
104  return `<circle class="ub-ring" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="3"/>
105<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-dasharray="${dash.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})"/>`
106}
107
108export const rowSvg = (u: Usage, W: number, now: number, c: Cache | null = null, ttl = TTL_MS['1h']!) => {
109  const parts: string[] = []
110  const cy = H / 2
111
112  // Right side first: limits and cost take what they need, the bar gets the rest.
113  const right: { w: number; draw: (x: number) => string }[] = []
114  const showReset = W > 560
115  if (c) {
116    const { ms, share } = cacheLeft(c, ttl, now)
117    const isCold = ms <= 0
118    const hit = c.hit === null ? '—' : pct(c.hit)
119    const left = countdown(ms)
120    const color = share < 0.2 ? AMBER : TEAL
121    const w = 22 + textW('Cache', 11) + 4 + textW(hit, 12.5) + 6 + textW(`◷ ${left}`, 11)
122    right.push({
123      w,
124      draw: x => {
125        // The ring drains as the cache ages; a cold cache leaves only the track.
126        let s = isCold ? ring(x + 8, cy, 7, 0, color) : ring(x + 8, cy, 7, share * 100, color)
127        let tx = x + 22
128        s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11" font-weight="500">Cache</text>`
129        tx += textW('Cache', 11) + 4
130        s += `<text class="${isCold ? 'ub-m' : 'ub-t'}" x="${tx}" y="${cy + 4.5}" font-size="12.5" font-weight="600">${hit}</text>`
131        tx += textW(hit, 12.5) + 6
132        s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11"${isCold ? '' : ` fill="${color}" style="fill:${color}"`}>◷ ${left}</text>`
133        return s
134      },
135    })
136  }
137  for (const l of u.limits) {
138    const name = LABELS[l.kind] ?? l.kind
139    const reset = showReset ? resetIn(l.resetsAt, now) : ''
140    const value = pct(l.percentUsed)
141    const color = hue(l.percentUsed, BLUE)
142    const w = 22 + textW(name, 11) + 4 + textW(value, 12.5) + (reset ? 6 + textW(`↻ ${reset}`, 11) : 0)
143    right.push({
144      w,
145      draw: x => {
146        let s = ring(x + 8, cy, 7, l.percentUsed, color)
147        let tx = x + 22
148        s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11" font-weight="500">${xml(name)}</text>`
149        tx += textW(name, 11) + 4
150        s += `<text class="ub-t" x="${tx}" y="${cy + 4.5}" font-size="12.5" font-weight="600">${value}</text>`
151        tx += textW(value, 12.5) + 6
152        if (reset) s += `<text class="ub-m" x="${tx}" y="${cy + 4}" font-size="11">↻ ${reset}</text>`
153        return s
154      },
155    })
156  }
157  if (u.usd !== null) {
158    const text = `$${u.usd.toFixed(2)}`
159    const w = Math.round(textW(text, 12) + 18)
160    right.push({
161      w,
162      draw: x =>
163        `<rect class="ub-k" x="${x}" y="${(H - 18) / 2}" width="${w}" height="18" rx="9"/>` +
164        `<text class="ub-t" x="${x + w / 2}" y="${cy + 4.5}" text-anchor="middle" font-size="12" font-weight="600">${text}</text>`,
165    })
166  }
167
168  const GAP = 22
169  const rightW = right.reduce((s, r) => s + r.w, 0) + GAP * right.length
170
171  // Context: label, dithered bar, a pill riding the fill head, the percent.
172  const label = 'Context'
173  const p = u.contextPercent ?? 0
174  const color = hue(p, CLAY)
175  const percentText = u.contextPercent === null ? '—' : pct(p)
176  const BAR_X = Math.round(16 + textW(label, 13) + 12)
177  const BAR_W = Math.round(Math.max(80, W - BAR_X - rightW - 12 - textW('100%', 12.5)))
178  const fillW = Math.round(BAR_W * (p / 100))
179  const cols = Math.floor(fillW / CELL)
180  const rows = Math.floor(BAR_H / CELL)
181  const dots: string[] = []
182  for (let c = 0; c < cols; c++) {
183    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
184    for (let r = 0; r < rows; r++) {
185      if (noise(c, r) < density)
186        dots.push(`<rect class="ub-t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
187    }
188  }
189  const pillText = `${u.contextTokens === null ? '—' : kTokens(u.contextTokens)} / ${kTokens(u.contextWindow)}`
190  const pillW = Math.round(18 + pillText.length * 6.2)
191  // The pill only rides inside the fill once the fill can hold it; a short fill
192  // keeps its true width, gets a solid head, and the label sits in the empty track.
193  const inside = fillW >= pillW + 24
194  const tag = inside
195    ? `<rect x="${fillW - pillW}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
196<text x="${fillW - pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-size="11" font-weight="600" fill="#fff">${pillText}</text>`
197    : (p > 0 ? `<rect x="${Math.max(0, fillW - 3)}" width="3" height="${BAR_H}" rx="1.5" fill="${color}"/>` : '') +
198      `<text class="ub-m" x="${Math.min(fillW + 6, BAR_W - pillW + 12)}" y="${BAR_H / 2 + 4}" font-size="11" font-weight="600">${pillText}</text>`
199
200  parts.push(`<circle cx="5" cy="${cy}" r="4" fill="${color}"/>`)
201  parts.push(`<text class="ub-t" x="16" y="${cy + 4.5}" font-size="13" font-weight="500">${label}</text>`)
202  parts.push(`<g transform="translate(${BAR_X},${(H - BAR_H) / 2})">
203<rect class="ub-k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
204<g clip-path="url(#ub-clip)"><g fill="${color}">${dots.join('')}</g></g>
205${tag}
206</g>`)
207  parts.push(`<text class="ub-m" x="${BAR_X + BAR_W + 8}" y="${cy + 4.5}" font-size="12.5">${percentText}</text>`)
208
209  let x = W - rightW + GAP
210  for (const r of right) {
211    parts.push(`<line class="ub-sep" x1="${x - GAP / 2}" y1="6" x2="${x - GAP / 2}" y2="${H - 6}"/>`)
212    parts.push(r.draw(x))
213    x += r.w + GAP
214  }
215
216  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">
217<style>
218.ub-t{fill:#1f1f1f}.ub-m{fill:#8a8a8a}.ub-k{fill:#e4e4e2}.ub-ring{stroke:#e4e4e2}.ub-sep{stroke:#d8d8d4;stroke-width:1}
219@media (prefers-color-scheme: dark){.ub-t{fill:#ececec}.ub-m{fill:#9a9a9a}.ub-k{fill:#3a3a3a}.ub-ring{stroke:#3d3d3d}.ub-sep{stroke:#4a4a4a}}
220.ub-t0,.ub-t1,.ub-t2,.ub-t3{animation:ub-tw 2.6s ease-in-out infinite}
221.ub-t1{animation-duration:3.1s;animation-delay:-.7s}.ub-t2{animation-duration:2.2s;animation-delay:-1.3s}.ub-t3{animation-duration:3.6s;animation-delay:-.4s}
222@keyframes ub-tw{0%,100%{opacity:1}50%{opacity:.45}}
223@media (prefers-reduced-motion: reduce){.ub-t0,.ub-t1,.ub-t2,.ub-t3{animation:none}}
224</style>
225<defs><clipPath id="ub-clip"><rect width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
226${parts.join('\n')}
227</svg>`
228}
229
230export const register: Register = (on, options) => {
231  const ttl = TTL_MS[String(options.cacheTtl)] ?? TTL_MS['1h']!
232
233  on('session.start', async ($, e, next) => {
234    const result = await next(e)
235    const u = await $.session.usage()
236    await update($, usage, () => toUsage(u))
237    // Redraw the countdown twice a minute while the cache is warm or just went cold.
238    $.clock.every(30_000, async () => {
239      const c = await read($, cache)
240      if (c && (await $.clock.now()) - c.at < ttl + 60_000) await update($, tick, n => n + 1)
241    })
242    return result
243  })
244
245  on('turn.complete', async ($, e, next) => {
246    const result = await next(e)
247    if (!e.agentId && e.usage) {
248      const hit = hitRate(e.usage)
249      const at = await $.clock.now()
250      await update($, cache, () => ({ at, hit }))
251    }
252    return result
253  })
254
255  on('session.measure', async ($, e, next) => {
256    await update($, usage, () => toUsage(e))
257    return next(e)
258  })
259
260  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
261    const u = await read($, usage)
262    if (e.props.hasSurvey || u === null) return next(e)
263
264    const ui = $.ui.resolve(e)
265    await read($, tick)
266    const now = await $.clock.now()
267    const c = await read($, cache)
268
269    if (e.surface !== 'terminal' && 'Svg' in ui) {
270      const { Svg } = ui
271      const W = Math.max(320, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 16))
272      const alt = [
273        `Context ${u.contextPercent === null ? '—' : pct(u.contextPercent)}`,
274        ...(c ? [`Cache ${c.hit === null ? '—' : pct(c.hit)}, ${countdown(cacheLeft(c, ttl, now).ms)}`] : []),
275        ...u.limits.map(l => `${LABELS[l.kind] ?? l.kind} ${pct(l.percentUsed)}`),
276        u.usd !== null ? `$${u.usd.toFixed(2)}` : '',
277      ]
278        .filter(Boolean)
279        .join(', ')
280      return <Svg source={rowSvg(u, W, now, c, ttl)} alt={alt} width={W} height={H} />
281    }
282
283    const { Box, Text } = ui
284    const cols = e.props.bodyColumns || 80
285    const barW = Math.max(8, Math.min(24, cols - 60))
286    const p = u.contextPercent
287
288    return (
289      <Box flexDirection="row" gap={2}>
290        <Text>
291          <Text color={p === null ? 'gray' : tone(p)}>● </Text>
292          <Text bold>Context </Text>
293          <Text color={p === null ? 'gray' : tone(p)}>
294            {bar(p ?? 0, barW)}
295          </Text>
296          <Text bold> {p === null ? '—' : pct(p)}</Text>
297          <Text dimColor>
298            {' '}
299            {u.contextTokens === null ? '—' : kTokens(u.contextTokens)}/{kTokens(u.contextWindow)}
300          </Text>
301        </Text>
302        {c &&
303          (() => {
304            const { ms, share } = cacheLeft(c, ttl, now)
305            return (
306              <Text key="cache">
307                <Text color={ms <= 0 ? 'gray' : share < 0.2 ? 'yellow' : 'cyan'}>◷ </Text>
308                <Text dimColor>Cache </Text>
309                <Text bold>{c.hit === null ? '—' : pct(c.hit)}</Text>
310                <Text dimColor> {countdown(ms)}</Text>
311              </Text>
312            )
313          })()}
314        {u.limits.map(l => {
315          const reset = resetIn(l.resetsAt, now)
316          return (
317            <Text key={l.kind}>
318              <Text color={tone(l.percentUsed)}>{l.percentUsed >= 85 ? '◉' : l.percentUsed >= 50 ? '◑' : '◔'} </Text>
319              <Text dimColor>{LABELS[l.kind] ?? l.kind} </Text>
320              <Text bold>{pct(l.percentUsed)}</Text>
321              {reset ? <Text dimColor> ↻{reset}</Text> : null}
322            </Text>
323          )
324        })}
325        {u.usd !== null && <Text color="green">${u.usd.toFixed(2)}</Text>}
326      </Box>
327    )
328  })
329}
330
types/index.d.ts 17 lines
1export type Usage = {
2  contextPercent: number | null
3  contextTokens: number | null
4  contextWindow: number
5  limits: { kind: string; percentUsed: number; resetsAt?: string }[]
6  usd: number | null
7}
8
9/** When the main thread's last request landed and how much of its prompt the cache served. */
10export type Cache = { at: number; hit: number | null }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'usage-band': { usage: Usage | null; cache: Cache | null; tick: number }
15  }
16}
17