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.

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.

Works in the terminal and in the Claude desktop app's Code tab.
| Segment | Example | Meaning |
|---|---|---|
| Model | Sonnet 5.5 | The model the session is on. |
| Context | ███░░░░░░░ 3% 32.3k/1M | How full the context window is. Green, then amber from 50%, red from 80%. |
| 5-hour limit | 5h 9% resets 11m | Your 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% hit | Time left before the prompt cache expires, and how much of the last request came from cache. See below. |
| Cost | 💰 $0.13 | What the session would cost at API prices. |
| Button | Summarize & new | Has Haiku write a hand-off summary, runs /clear, and puts the summary in the prompt box of the fresh session. |
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:
1h or 5m).cold, new model appears after switching models: each model has its own cache.5m TTL appears if the API fell back to the 5-minute cache.claude plugin marketplace add Rishit30G/usage-band
claude plugin install usage-band@rishit-plugins
Then start a new session (or restart the app).
claude plugin marketplace update rishit-plugins
claude plugin update usage-band@rishit-plugins
claude plugin uninstall usage-band@rishit-plugins
claude plugin marketplace remove rishit-plugins
ui.render, $.session.usage). Older versions won't load it.sh, tail and grep. On Windows that segment stays hidden; the rest works.MIT
hooks/register.tsx 296 lines1import { 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}
296types/index.d.ts 21 lines1export 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