SLOPSHOPPER

usage-band

A live band above the Claude Code prompt: model, context, 5-hour plan limit, real prompt-cache status and session cost, plus a one-click Summarize & new.

newbandtoastmodelprocesstimer
v1.0.0MITupdated 2026-10-07Rishit30G/usage-band/plugins/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 Opus 5.5 █████░░░░░ 49% 97.4k/200k 5h 31% resets NaNs 💰 $0.42 [ Summarize & new ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 █████░░░░░ 49% 97.4k/200k 5h 31% resets NaNs 💰 $0.42 [ Summarize & new ]
README

usage-band

A live status band above the Claude Code prompt. It shows what you're spending and how close you are to your limits while you work, without running /cost or /context.

usage-band

Works in the terminal and in the Claude desktop app's Code tab.

What each part means

SegmentExampleMeaning
ModelSonnet 5.5The model the session is on.
Context███░░░░░░░ 3% 32.3k/1MHow full the context window is. Green, then amber from 50%, red from 80%.
5-hour limit5h 9% resets 11mYour plan's 5-hour usage window and when it resets. Adds a red ▲ full in 1h 20m when your current pace would hit the limit before the reset.
Prompt cache⏳ 59m 70% hitTime left before the prompt cache expires, and how much of the last request came from cache. See below.
Cost💰 $0.13What the session would cost at API prices.
ButtonSummarize & newHas Haiku write a hand-off summary, runs /clear, and puts the summary in the prompt box of the fresh session.

The prompt cache, briefly

Every message re-sends the whole conversation. Anthropic caches it, so while the cache is warm each message is faster and much cheaper; once it expires, the next message pays full price to rebuild it. Every request resets the timer.

The band reads this from the API's own usage data in the session transcript, not from a guess:

  • Time left counts from the last API request, using the TTL that request actually wrote (1h or 5m).
  • Hit % is the share of the last request served from cache. Red means it paid close to full price.
  • cold, new model appears after switching models: each model has its own cache.
  • An amber 5m TTL appears if the API fell back to the 5-minute cache.

Install

claude plugin marketplace add Rishit30G/usage-band
claude plugin install usage-band@rishit-plugins

Then start a new session (or restart the app).

Update

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

Uninstall

claude plugin uninstall usage-band@rishit-plugins
claude plugin marketplace remove rishit-plugins

Requirements and limits

  • A recent Claude Code. The band uses the plugin hooks API (ui.render, $.session.usage). Older versions won't load it.
  • macOS or Linux. The cache segment reads the transcript with sh, tail and grep. On Windows that segment stays hidden; the rest works.
  • Plan limits need a subscription. The 5-hour segment appears only when the API reports rate-limit data (Pro/Max plans). On API-key billing it's hidden.
  • Summarize & new uses a small Haiku call on your own quota.

License

MIT

Source 2 files
hooks/register.tsx 296 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Cache, Snap } from '../types'
5
6const snap = atom({ plugin: 'usage-band', key: 'snap' } as const, null)
7
8const TTL_MS = { '5m': 5 * 60 * 1000, '1h': 60 * 60 * 1000 }
9const CTX_WARN = 50
10const CTX_CRIT = 80
11const LIM_WARN = 60
12const LIM_CRIT = 85
13const WINDOW_SEC: Record<string, number> = { five_hour: 5 * 3600, seven_day: 7 * 86400 }
14const LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
15
16// claude-sonnet-5-5 -> Sonnet 5.5; claude-haiku-4-5-20251001 -> Haiku 4.5; unknown ids pass through
17const modelName = (id: string) => {
18  const m = id.match(/^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[.*\])?$/)
19  if (!m) return id
20  const [, fam, major, minor, suffix] = m
21  return `${fam[0].toUpperCase()}${fam.slice(1)} ${major}${minor ? `.${minor}` : ''}${suffix ?? ''}`
22}
23
24// one hue per segment so the band reads at a glance; green/amber/red are reserved for severity
25const C = {
26  claude: '#D97757',
27  sky: '#7cc4ff',
28  amber: '#ffb454',
29  teal: '#4fd1c5',
30  gold: '#f6d365',
31  rose: '#ff8fab',
32  green: '#7ee787',
33  red: '#ff6b6b',
34  muted: '#8b93a7',
35  line: '#4b5266',
36}
37
38const level = (pct: number, warn: number, crit: number) =>
39  pct >= crit ? C.red : pct >= warn ? C.amber : C.green
40
41const tok = (n: number) =>
42  n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${+(n / 1e3).toFixed(1)}k` : `${Math.round(n)}`
43
44const dur = (sec: number) => {
45  const s = Math.max(0, Math.floor(sec))
46  const d = Math.floor(s / 86400)
47  const h = Math.floor((s % 86400) / 3600)
48  const m = Math.floor((s % 3600) / 60)
49  return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m` : `${s}s`
50}
51
52const clock = (sec: number) => {
53  const s = Math.max(0, Math.floor(sec))
54  if (s >= 600) return dur(s)
55  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
56}
57
58const bar = (pct: number, width = 10) => {
59  const n = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width)
60  return '█'.repeat(n) + '░'.repeat(width - n)
61}
62
63// The plugin API doesn't report the prompt cache, but every API response in the
64// transcript does: when it was made, which model, the TTL it wrote and what it read.
65async function readCache($: any, prev: Cache | null): Promise<Cache | null> {
66  const id = await $.session.id()
67  if (!/^[0-9a-f-]{36}$/.test(id)) return prev
68  const r = await $.process.run([
69    'sh',
70    '-c',
71    `tail -n 400 "$HOME"/.claude/projects/*/${id}.jsonl 2>/dev/null | grep '"cache_creation"' | tail -n 30`,
72  ])
73  let last: any = null
74  let ttlMs: number | null = null
75  for (const line of r.stdout.split('\n').reverse()) {
76    let d: any
77    try {
78      d = JSON.parse(line)
79    } catch {
80      continue
81    }
82    const u = d?.message?.usage
83    if (d.type !== 'assistant' || d.isSidechain || !u) continue
84    last ??= d
85    // a pure cache hit writes nothing, so the TTL comes from the latest response that wrote
86    const cc = u.cache_creation ?? {}
87    if (cc.ephemeral_1h_input_tokens > 0) ttlMs = TTL_MS['1h']
88    else if (cc.ephemeral_5m_input_tokens > 0) ttlMs = TTL_MS['5m']
89    if (ttlMs !== null) break
90  }
91  if (!last) return prev
92  const u = last.message.usage
93  const read = u.cache_read_input_tokens ?? 0
94  const total = (u.input_tokens ?? 0) + read + (u.cache_creation_input_tokens ?? 0)
95  return {
96    at: Date.parse(last.timestamp),
97    ttlMs: ttlMs ?? prev?.ttlMs ?? TTL_MS['1h'],
98    model: last.message.model,
99    hitPct: total ? (read / total) * 100 : 0,
100  }
101}
102
103async function refresh($: any) {
104  try {
105    const u = await $.session.usage()
106    const prev = await read($, snap)
107    const now = await $.clock.now()
108    let model: string | null = null
109    try {
110      model = await $.session.model()
111    } catch {}
112    let cache = prev?.cache ?? null
113    try {
114      cache = await readCache($, cache)
115    } catch {}
116    const next: Snap = {
117      at: now,
118      model,
119      cache,
120      startedAt: u.startedAt,
121      ctxPercent: u.context.percent ?? null,
122      ctxTokens: u.context.tokens ?? null,
123      ctxWindow: u.context.window,
124      usd: u.cost?.usd ?? null,
125      limits: u.rateLimits,
126    }
127    await update($, snap, () => next)
128  } catch {
129    // session not bound yet; the next tick tries again
130  }
131}
132
133async function tick($: any) {
134  try {
135    const prev = await read($, snap)
136    if (!prev?.cache) return
137    if (prev.cache.at + prev.cache.ttlMs - prev.at < -1000) return // already expired, nothing moves
138    const now = await $.clock.now()
139    await update($, snap, (cur: any) => (cur ? { ...cur, at: now } : cur))
140  } catch {}
141}
142
143let isBusy = false
144
145async function summarizeAndRestart($: any) {
146  if (isBusy) return
147  isBusy = true
148  try {
149    $.ui.toast('Summarizing session…')
150    const msgs = await $.session.messages()
151    const transcript = msgs
152      .map((m: any) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.text}`)
153      .filter((t: string) => t.length > 12)
154      .join('\n\n')
155      .slice(-60000)
156    if (!transcript) {
157      $.ui.toast('Nothing to summarize yet')
158      return
159    }
160    const r = await $.model.complete({
161      model: 'haiku',
162      maxTokens: 1500,
163      timeoutMs: 90000,
164      system: 'You write concise hand-off notes so a fresh coding session can continue seamlessly.',
165      prompt:
166        'Summarize this session as a hand-off: goal, what was done (files, decisions), current state, open issues, and next steps. Be specific and brief.\n\n' +
167        transcript,
168    })
169    if (!r.isAnswered) {
170      $.ui.toast('Summary failed; session kept')
171      return
172    }
173    await $.command.run({ command: 'clear' })
174    await $.prompt.fill({
175      text: `Context from my previous session (summary):\n\n${r.text}\n\nContinue from here.`,
176    })
177    $.ui.toast('New session started. Summary is in the prompt box; press Enter to send.')
178  } catch {
179    $.ui.toast('Summarize failed; session kept')
180  } finally {
181    isBusy = false
182  }
183}
184
185export const register: Register = on => {
186  let stopFull: (() => void) | undefined
187  let stopTick: (() => void) | undefined
188
189  on('session.start', async ($, e, next) => {
190    stopFull?.()
191    stopTick?.()
192    stopFull = $.clock.every(10000, () => refresh($))
193    // cheap 1s tick so the cache countdown moves between full refreshes
194    stopTick = $.clock.every(1000, () => tick($))
195    await refresh($)
196    return next(e)
197  })
198
199  on('turn.complete', async ($, e, next) => {
200    const r = await next(e)
201    await refresh($)
202    return r
203  })
204
205  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
206    const s = await read($, snap)
207    if (e.props.hasSurvey || s === null) return next(e)
208
209    const { Box, Button, Text } = $.ui.resolve(e)
210    const sep = <Text>   </Text>
211    const parts: any[] = []
212
213    if (s.model) {
214      parts.push(
215        <Text color={C.claude} bold>{modelName(s.model)}</Text>,
216      )
217    }
218
219    if (s.ctxPercent !== null) {
220      const c = level(s.ctxPercent, CTX_WARN, CTX_CRIT)
221      parts.push(
222        <Text>
223          <Text color={c}>{bar(s.ctxPercent)}</Text>
224          <Text color={c} bold> {Math.round(s.ctxPercent)}%</Text>
225          <Text color={C.muted}>   {tok(s.ctxTokens ?? 0)}/{tok(s.ctxWindow)}</Text>
226        </Text>,
227      )
228    }
229
230    for (const l of s.limits) {
231      if (l.kind === 'seven_day') continue
232      const left = l.resetsAt ? Math.max(0, (Date.parse(l.resetsAt) - s.at) / 1000) : null
233      const win = WINDOW_SEC[l.kind]
234      let warn = ''
235      if (left !== null && win) {
236        const used = win - left
237        if (used > 60 && l.percentUsed > 0) {
238          const toFull = (100 - l.percentUsed) / (l.percentUsed / used)
239          if (toFull < left) warn = `full in ${dur(toFull)}`
240        }
241      }
242      const c = level(l.percentUsed, LIM_WARN, LIM_CRIT)
243      parts.push(
244        <Text>
245          <Text color={C.amber}>{LABEL[l.kind] ?? l.kind} </Text>
246          <Text color={c} bold>{Math.round(l.percentUsed)}%</Text>
247          {left !== null ? <Text color={C.muted}>   resets {dur(left)}</Text> : null}
248          {warn ? <Text color={C.red} bold> ▲ {warn}</Text> : null}
249        </Text>,
250      )
251    }
252
253    if (s.cache) {
254      const c = s.cache
255      const left = (c.at + c.ttlMs - s.at) / 1000
256      // each model has its own cache: after a switch the next request starts cold
257      const switched = !!s.model && modelName(s.model) !== modelName(c.model)
258      const hitColor = c.hitPct >= 80 ? C.green : c.hitPct >= 40 ? C.amber : C.red
259      parts.push(
260        <Text>
261          <Text color={C.teal}>⏳ </Text>
262          {e.props.isWorking ? (
263            <Text color={C.green} bold>warm</Text>
264          ) : switched ? (
265            <Text color={C.amber} bold>cold, new model</Text>
266          ) : left > 0 ? (
267            <Text color={left < 300 ? C.amber : C.green} bold>{clock(left)}</Text>
268          ) : (
269            <Text color={C.muted}>expired</Text>
270          )}
271          <Text color={hitColor}>   {Math.round(c.hitPct)}% hit</Text>
272          {c.ttlMs === TTL_MS['5m'] ? <Text color={C.amber}>   5m TTL</Text> : null}
273        </Text>,
274      )
275    }
276
277    if (s.usd !== null) {
278      parts.push(
279        <Text>
280          <Text>💰 </Text>
281          <Text color={C.gold} bold>${s.usd.toFixed(2)}</Text>
282        </Text>,
283      )
284    }
285
286    if (!parts.length) return next(e)
287
288    return (
289      <Box justifyContent="space-between">
290        <Text>{parts.flatMap((p, i) => (i ? [sep, p] : [p]))}</Text>
291        <Button key="summarize" label="Summarize & new" variant="primary" onPress={() => summarizeAndRestart($)} />
292      </Box>
293    )
294  })
295}
296
types/index.d.ts 21 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2// the last main-thread API response, read from the session's transcript
3export type Cache = { at: number; ttlMs: number; model: string; hitPct: number }
4export type Snap = {
5  at: number
6  model: string | null
7  cache: Cache | null
8  startedAt: number
9  ctxPercent: number | null
10  ctxTokens: number | null
11  ctxWindow: number
12  usd: number | null
13  limits: Limit[]
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'usage-band': { snap: Snap | null }
19  }
20}
21