SLOPSHOPPER

usage-band

5h / weekly usage, context fill and prompt-cache warmth above the prompt

newbandtoasttimer
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 ◑ 49% 97k / 200k ◔ 31% 5h · resets NaNdNaNh ● Warm cache · 1h0m left ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◑ 49% 97k / 200k ◔ 31% 5h · resets NaNdNaNh ● Warm cache · 1h0m left
README

usage-band

Your Claude Code limits, context fill and prompt-cache warmth, right above the prompt.

usage-band: context, 5h, weekly and cache rings above the Claude Code prompt

Four small rings, always in view:

What it shows
ContextHow full the context window is: 92k / 1M
5hYour 5-hour usage limit, and when it resets
7dYour weekly limit, and when it resets
CacheWhether the prompt cache is Warm (and how long it has left) or Cold

Rings go green → amber → red as you approach a limit.

The big-context reminder

Above 400k tokens of context, a reminder row appears with one-click actions. It knows the state of the cache and suggests the cheapest move:

usage-band reminder row with Clear, Compact and Later buttons

SituationMessageSuggested
Cache is coldCache cold: next message re-reads 452k. Clear is free.Clear
Cache expires within 10 minCache expires in 6m: compact now while it's cheap.Compact
Cache is warm452k in context: each turn re-sends all of it.Compact

Later hides the row until the context grows by another 100k.

Why this matters: a cached prompt is read at about a tenth of the normal input price. Once the cache has expired, your next message pays to read the whole conversation again, and so does /compact. /clear costs nothing.

Terminal too

In terminal Claude Code it shows the same four items with text rings (○ ◔ ◑ ◕ ●):

usage-band in terminal Claude Code

Install

claude plugin marketplace add Vosssa/claude-usage-band
claude plugin install usage-band@claude-usage-band --scope user

Restart Claude Code. The band appears in every session, in both the desktop app and the terminal.

Update later with:

claude plugin marketplace update claude-usage-band
claude plugin update usage-band@claude-usage-band

Cost: zero tokens

claude plugin details usage-band@claude-usage-band reports Always-on: ~0 tok.

  • It adds nothing to the prompt and never calls the model.
  • The usage numbers are pushed to it by Claude Code after each response. It never polls for them.
  • It runs one local timer, once a minute, to keep the countdowns current.

Notes

  • The cache timer is checked against the real numbers. The countdown runs from your last response. After every message, the API's own cache counts show whether it was actually read from cache (✓ hit 98%) or paid in full (✗ missed 452k). An unexpected miss also shows a toast.
  • The TTL is learned. It starts by assuming 1 hour on a subscription and 5 minutes on an API key. When a message sent after a longer pause still hits, or misses, the band switches to the TTL it actually saw and remembers it.
  • It remembers between restarts. The limits and the cache timer show up right away when you open a session, not only after your first message. Limits are shared across projects: a session you switch back to picks up newer numbers within a minute.
  • The 5h and 7d rings need a subscription. On an API key, only Context and Cache are shown.

License

MIT

Source 2 files
hooks/register.tsx 295 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { Snap } from '../types'
5
6// Prompt-cache TTL guess: subscriptions (rate limits reported) get 1h, API keys 5m.
7// Replaced by what the API's own cache counts show once a gap proves it (s.ttl).
8const TTL_SUB = 60 * 60_000
9const TTL_API = 5 * 60_000
10// Below this many prompt tokens a hit or miss says nothing worth showing.
11const MIN_PROMPT = 20_000
12// Past this many context tokens, nudge toward /compact or /clear.
13const NAG = 400_000
14const NAG_HARD = 600_000
15
16const snap = atom({ plugin: 'usage-band', key: 'snap' } as const, {
17  ctxWin: 0, sub: false, lastAt: 0, now: 0,
18} as Snap)
19
20const k = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1e3)}k`)
21
22const left = (ms: number) => {
23  const m = Math.max(0, Math.ceil(ms / 60_000))
24  if (m < 60) return `${m}m`
25  const h = Math.floor(m / 60)
26  return h < 48 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`
27}
28
29const OK = '#4caf6a', WARN = '#d9a53a', BAD = '#e5534b', WARMC = '#e8823a', COOL = '#5aa9e6'
30const tone = (p: number) => (p >= 90 ? BAD : p >= 70 ? WARN : OK)
31
32type Cell = { icon: string; value: string; sub: string; frac: number; color: string; hot?: boolean }
33
34
35// Glyphs drawn inside each ring, centred on (12,14); stroked/filled in the ring's colour.
36const ICONS: Record<string, (c: string) => string> = {
37  ctx: c => `<path d="M8.6 11h6.8M8.6 14h6.8M8.6 17h4.2" stroke="${c}" stroke-width="1.5" stroke-linecap="round" fill="none"/>`,
38  clock: c => `<path d="M12 10.4V14l2.5 1.6" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
39  cal: c => `<g stroke="${c}" stroke-width="1.3" fill="none" stroke-linecap="round"><rect x="8.4" y="10.6" width="7.2" height="6.6" rx="1.2"/><path d="M10.2 9.4v2M13.8 9.4v2M8.4 13h7.2"/></g>`,
40  flame: c => `<path d="M12 9.2c1.7 1.9 3 3.4 3 5.3a3 3 0 0 1-6 0c0-1.2.6-2.1 1.4-2.8.1.9.5 1.5 1.1 1.7-.3-1.4 0-2.8.5-4.2z" fill="${c}"/>`,
41  snow: c => `<path d="M12 10v8M8.55 12l6.9 4M8.55 16l6.9-4" stroke="${c}" stroke-width="1.4" stroke-linecap="round"/>`,
42}
43
44// Just the ring + glyph; the text beside it is laid out by the app itself.
45const gauge = (c: Cell) => {
46  const R = 10.5, C = 2 * Math.PI * R, f = Math.max(0, Math.min(1, c.frac))
47  return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 24 28">
48<style>.t{fill:none;stroke:#000;stroke-opacity:.12;stroke-width:2}@media (prefers-color-scheme:dark){.t{stroke:#fff;stroke-opacity:.16}}</style>
49<circle cx="12" cy="14" r="${R}" class="t"/>
50${f ? `<circle cx="12" cy="14" r="${R}" fill="none" stroke="${c.color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${(f * C).toFixed(2)} ${C.toFixed(2)}" transform="rotate(-90 12 14)"/>` : ''}
51${ICONS[c.icon](c.color)}</svg>`
52}
53
54const ring = (f: number) => '○◔◑◕●'[Math.round(Math.max(0, Math.min(1, f)) * 4)]
55
56type Limits = readonly SessionRateLimit[]
57
58// An update without limits keeps the ones already shown.
59const withLimits = (s: Snap, rl: Limits): Snap => {
60  if (!rl.length) return s
61  const h5 = rl.find(r => r.kind === 'five_hour')
62  const wk = rl.find(r => r.kind === 'seven_day')
63  return {
64    ...s, sub: true,
65    h5: h5?.percentUsed, h5Reset: h5?.resetsAt,
66    wk: wk?.percentUsed, wkReset: wk?.resetsAt,
67  }
68}
69
70// Claude Code only learns the limits from a response; until then use the last known ones.
71const fold = (u: Pick<SessionUsage, 'context' | 'rateLimits'>, cached?: Limits) => (s: Snap): Snap => ({
72  ...withLimits(s, u.rateLimits.length ? u.rateLimits : cached ?? []),
73  ctxPct: u.context.percent, ctxTok: u.context.tokens, ctxWin: u.context.window,
74})
75
76// The limits are the account's, shared by every session: the newest response in
77// any project writes them, and every open session picks them up within a minute.
78type SavedLimits = { at: number; rl: Limits }
79const parseLimits = (v: unknown): SavedLimits | undefined =>
80  Array.isArray(v) ? { at: 0, rl: v as Limits }
81    : v && typeof v === 'object' && Array.isArray((v as SavedLimits).rl) ? (v as SavedLimits) : undefined
82
83// Stored limits whose window has since reset start that window over at 0%.
84const aged = (rl: Limits, now: number): Limits =>
85  rl.map(r => (r.resetsAt && Date.parse(r.resetsAt) <= now ? { kind: r.kind, percentUsed: 0 } : r))
86
87// When each conversation last got a response, kept across restarts so a resumed
88// session knows its cache is still warm. Newest 30 conversations only.
89type Seen = Record<string, number>
90const remember = async ($: EngineInterface, t: number) => {
91  const [id, got] = await Promise.all([$.session.id(), $.store.get('lastAt')])
92  const seen = { ...((got ?? {}) as Seen), [id]: t }
93  const keep = Object.entries(seen).sort((a, b) => b[1] - a[1]).slice(0, 30)
94  await $.store.set('lastAt', Object.fromEntries(keep))
95}
96
97// A conversation with nothing stored yet: its transcript was last written by the
98// last response, so the file's modification time stands in for it. One stat call.
99const transcriptAt = async ($: EngineInterface, id: string): Promise<number> => {
100  try {
101    const [cwd, cfg, profile, home] = await Promise.all([
102      $.session.cwd(), $.env.get('CLAUDE_CONFIG_DIR'), $.env.get('USERPROFILE'), $.env.get('HOME'),
103    ])
104    const base = cfg ?? `${profile ?? home}/.claude`
105    const st = await $.fs.stat(`${base}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`)
106    return st.kind === 'file' ? st.mtimeMs : 0
107  } catch {
108    return 0
109  }
110}
111
112// Last limits written to the store, so a response that moved nothing writes nothing.
113let storedLimits = ''
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    const [now, u, ttl, seen, limits, id] = await Promise.all([
118      $.clock.now(), $.session.usage(), $.store.get('ttl'), $.store.get('lastAt'), $.store.get('limits'), $.session.id(),
119    ])
120    const got = parseLimits(limits)
121    const cached = got && aged(got.rl, now)
122    // A restart loses the session's state; the stored time brings it back.
123    // A /clear starts a new conversation id with no transcript yet, so it starts cold.
124    const saved = ((seen ?? {}) as Seen)[id] || (u.context.tokens ? await transcriptAt($, id) : 0)
125    await update($, snap, s => ({
126      ...fold(u, cached)(s),
127      now,
128      limitsAt: u.rateLimits.length ? now : got?.at ?? 0,
129      ttl: ttl === '5m' || ttl === '1h' ? ttl : s.ttl,
130      lastAt: Math.max(s.lastAt, Math.min(saved, now)),
131    }))
132    // One redraw a minute keeps the countdowns honest; no model calls, no tokens.
133    // It also picks up limits another project's session saved since.
134    $.clock.every(60_000, async () => {
135      const [t, v] = await Promise.all([$.clock.now(), $.store.get('limits')])
136      const saved = parseLimits(v)
137      await update($, snap, s => (saved && saved.at > (s.limitsAt ?? 0)
138        ? { ...withLimits(s, aged(saved.rl, t)), limitsAt: saved.at, now: t }
139        : { ...s, now: t }))
140    })
141    return next(e)
142  })
143
144  // Pushed by the engine after each response / when a limit moves a point: no polling.
145  on('session.measure', async ($, e, next) => {
146    const now = await $.clock.now()
147    const isResponse = e.changed.includes('context')
148    const tok = e.context.tokens ?? 0
149    const prev = await read($, snap)
150    if (tok >= NAG && !prev.warned) {
151      $.ui.toast(`Context is at ${k(tok)} tokens: every turn re-sends all of it. Consider compacting or clearing.`)
152    }
153    await update($, snap, s => ({
154      ...fold(e)(s), now, lastAt: isResponse ? now : s.lastAt,
155      limitsAt: e.rateLimits.length ? now : s.limitsAt,
156      warned: tok >= NAG,
157      snooze: tok < NAG ? 0 : s.snooze,
158    }))
159    const rl = JSON.stringify(e.rateLimits)
160    if (e.rateLimits.length && rl !== storedLimits) {
161      storedLimits = rl
162      await $.store.set('limits', { at: now, rl: e.rateLimits })
163    }
164    return next(e)
165  })
166
167  on('turn.complete', async ($, e, next) => {
168    const now = await $.clock.now()
169    await update($, snap, s => ({ ...s, lastAt: now, now }))
170    await remember($, now)
171    return next(e)
172  })
173
174  // The first response of each turn says what the cache really did with the
175  // conversation sent after the idle gap: read from cache (hit) or paid in full (miss).
176  on('turn.step', async function* ($, e, next) {
177    const first = e.index === 0 && !e.agentId
178    const [before, sentAt] = first ? await Promise.all([read($, snap), $.clock.now()]) : [undefined, 0]
179    const r = yield* next(e)
180    const u = r.usage
181    if (!before || !u) return r
182    const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
183    if (total < MIN_PROMPT) return r
184    const hit = u.cache_read_input_tokens / total >= 0.5
185    const gap = before.lastAt ? sentAt - before.lastAt : -1
186    const sameModel = !before.model || before.model === u.model
187    // A gap longer than 5m that still hits proves the 1h cache; a miss inside
188    // the hour on the same model means the cache only lasted 5m.
189    let ttl = before.ttl
190    if (sameModel && hit && gap > 6 * 60_000) ttl = '1h'
191    else if (sameModel && !hit && gap > 6 * 60_000 && gap < 55 * 60_000 && u.cache_read_input_tokens / total < 0.2) ttl = '5m'
192    if (ttl && ttl !== before.ttl) await $.store.set('ttl', ttl)
193    const ttlMs = (ttl ?? (before.sub ? '1h' : '5m')) === '1h' ? TTL_SUB : TTL_API
194    const thoughtWarm = gap >= 0 && gap < ttlMs
195    if (!hit && thoughtWarm && sameModel && total >= 50_000) {
196      $.ui.toast(`Cache miss: that message re-read ${k(total - u.cache_read_input_tokens)} tokens at full price.`)
197    }
198    await update($, snap, s => ({
199      ...s, ttl, model: u.model, hit: u.cache_read_input_tokens / total,
200      missTok: hit ? undefined : total - u.cache_read_input_tokens,
201    }))
202    return r
203  })
204
205  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
206    if (e.props.hasSurvey) return next(e)
207    const s = await read($, snap)
208    const now = Math.max(s.now, s.lastAt)
209    const until = (iso?: string) => (iso ? `resets ${left(Date.parse(iso) - now).replace(' ', '')}` : '')
210
211    const cp = s.ctxPct ?? 0
212    const cells: Cell[] = [{
213      icon: 'ctx', value: `${cp}%`,
214      sub: s.ctxWin ? `${k(s.ctxTok ?? 0)} / ${k(s.ctxWin)}` : '',
215      frac: cp / 100,
216      color: (s.ctxTok ?? 0) >= NAG_HARD ? BAD : (s.ctxTok ?? 0) >= NAG ? (tone(cp) === BAD ? BAD : WARN) : tone(cp),
217    }]
218    const tok = s.ctxTok ?? 0
219    const nag = !e.props.isWorking && tok >= Math.max(NAG, s.snooze ?? 0)
220    // A window whose reset time has passed is back at 0% until the next response says more.
221    const limit = (icon: string, label: string, pct?: number, reset?: string) => {
222      if (pct === undefined) return
223      const isReset = !!reset && Date.parse(reset) <= now
224      const p = isReset ? 0 : pct
225      cells.push({ icon, value: `${p}%`, sub: isReset ? label : `${label} · ${until(reset)}`, frac: p / 100, color: tone(p) })
226    }
227    limit('clock', '5h', s.h5, s.h5Reset)
228    limit('cal', '7d', s.wk, s.wkReset)
229    const ttl = (s.ttl ?? (s.sub ? '1h' : '5m')) === '1h' ? TTL_SUB : TTL_API
230    const rem = s.lastAt ? s.lastAt + ttl - now : 0
231    // What the last message actually did: ✓ read from cache, ✗ paid full price.
232    const last = s.hit === undefined ? '' : s.missTok ? ` · ✗ missed ${k(s.missTok)}` : ` · ✓ hit ${Math.round(s.hit * 100)}%`
233    if (e.props.isWorking) cells.push({ icon: 'flame', value: 'Warm', sub: 'cache · active', frac: 1, color: WARMC, hot: true })
234    else if (rem > 0) cells.push({ icon: 'flame', value: 'Warm', sub: `cache · ${left(rem).replace(' ', '')} left${last}`, frac: rem / ttl, color: WARMC, hot: true })
235    else cells.push({ icon: 'snow', value: 'Cold', sub: s.lastAt ? 'cache · expired' : 'cache · no reply yet', frac: 0, color: COOL, hot: true })
236
237    const { Box, Text, Button } = $.ui.resolve(e)
238    // Three flavours: cache cold (Clear is free, anything else re-reads it all),
239    // cache about to expire (compact now while it's cheap), or just big.
240    const isCold = s.lastAt > 0 && rem <= 0
241    const isExpiring = !isCold && rem > 0 && rem <= 10 * 60_000
242    const compactBtn = <Button key="compact" label="Compact" variant={isCold ? 'secondary' : 'primary'} onPress={() => void $.session.compact()} />
243    const clearBtn = <Button key="clear" label="Clear" variant={isCold ? 'primary' : 'secondary'} onPress={() => void $.prompt.fill({ text: '/clear' })} />
244    const nagRow = nag ? (
245      <Box flexDirection="row" alignItems="center" gap={1} paddingX={1}>
246        <Text color={isCold ? COOL : tok >= NAG_HARD ? BAD : WARN}>●</Text>
247        <Text dimColor>
248          {isCold
249            ? `Cache cold: next message re-reads ${k(tok)}. Clear is free.`
250            : isExpiring
251              ? `Cache expires in ${left(rem)}: compact now while it's cheap.`
252              : `${k(tok)} in context: each turn re-sends all of it.`}
253        </Text>
254        {isCold ? clearBtn : compactBtn}
255        {isCold ? compactBtn : clearBtn}
256        <Button key="later" label="Later" dimColor onPress={() => update($, snap, x => ({ ...x, snooze: tok + 100_000 }))} />
257      </Box>
258    ) : null
259
260    if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
261      const { Svg } = $.ui.resolve(e) as { Svg: (p: { source: string; alt: string; width?: number; height?: number }) => unknown }
262      return (
263        <Box flexDirection="column" gap={1}>
264        <Box flexDirection="row" justifyContent="space-between" alignItems="center" paddingX={1}>
265          {cells.map(c => (
266            <Box key={c.icon} flexDirection="row" alignItems="center" gap={1}>
267              <Svg source={gauge(c)} alt={`${c.value} ${c.sub}`} width={24} height={28} />
268              <Text bold color={c.hot ? c.color : undefined}>{c.value}</Text>
269              <Text dimColor>{c.sub}</Text>
270            </Box>
271          ))}
272        </Box>
273        {nagRow}
274        </Box>
275      )
276    }
277
278    return (
279      <Box flexDirection="column">
280      <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
281        {cells.map(c => (
282          <Text key={c.icon}>
283            <Text color={c.color}>{ring(c.frac)} </Text>
284            <Text bold color={c.hot ? c.color : undefined}>{c.value}</Text>
285            
286            {c.sub ? <Text dimColor> {c.sub}</Text> : null}
287          </Text>
288        ))}
289      </Box>
290      {nagRow}
291      </Box>
292    )
293  })
294}
295
types/index.d.ts 33 lines
1export type Snap = {
2  h5?: number
3  h5Reset?: string
4  wk?: number
5  wkReset?: string
6  ctxPct?: number
7  ctxTok?: number
8  ctxWin: number
9  sub: boolean
10  lastAt: number
11  now: number
12  /** Token level the reminder row reappears at after "Later"; 0 = threshold. */
13  snooze?: number
14  /** Toast already shown for this crossing. */
15  warned?: boolean
16  /** Cache TTL proven by a hit or miss after an idle gap; unset = guess from plan. */
17  ttl?: '5m' | '1h'
18  /** Model of the last response; a switch starts a fresh cache. */
19  model?: string
20  /** Share of the last turn's prompt read from cache (first response of the turn). */
21  hit?: number
22  /** Tokens the last turn paid in full because the cache missed. */
23  missTok?: number
24  /** When the limits shown were reported; newer ones saved by any session replace them. */
25  limitsAt?: number
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'usage-band': { snap: Snap }
31  }
32}
33