Prompt cache countdown, session cost, rate limits and context window usage in the band above the prompt

Session figures in the band directly above the Claude Code prompt, on the terminal and in the desktop app's Code tab:

seconds_since_last_response), so the restored countdown is an estimate that can run long by up to that response's generation time and is shown followed by the word estimated in dim text, for example 4:00 estimated, until the next main-thread response that reads or writes the cache replaces it with the exact countdown. When Claude Code reports on resume that the prompt cache has likely expired, the row shows expired instead.showCredits to add their monthly credit spend as Credits, which is read when the session starts, so with it the row can show before any response./context's colors.Function hooks are an early-access Claude Code API that may change between releases without notice. CI tests this plugin on Claude Code 2.1.288. If the band does not appear, update Claude Code; a build where function hooks are still off by default needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in its environment.
claude plugin marketplace add crane-valley/claude-plugins
claude plugin install session-band@crane-valley
Set them with /plugin configure session-band@crane-valley, in /config, or with --config KEY=VALUE on claude plugin install. Unset options take the defaults below.
| Option | Default | Meaning |
|---|---|---|
cacheTtl | 5m | The prompt cache TTL your main thread uses (5m or 1h). The plugin cannot read it from Claude Code, so a wrong value shows a wrong countdown. |
showCost | true | Show the session's cost. On a subscription this is the API price of the usage, not what you are billed. |
showCredits | false | Add the account's monthly usage-credit spend to the Limits row, as Claude Code's /usage shows it under Usage credits. The plugin asks the same endpoint with your own login at most every 5 minutes, or sooner after a failed request (30 seconds, doubling back up to 5 minutes). The endpoint is undocumented, so when it changes the row is left out; failed requests keep the last figure for up to 30 minutes. It carries no reset time, so none is shown. It needs a claude.ai login: on Bedrock, Vertex, a gateway or an API key the plugin sends no request and shows no Credits. |
The band above the prompt is one slot shared by every plugin. session-band appends its rows to whatever the plugins beneath it drew, so other plugins' rows stay visible; a plugin that draws the band without calling next(e) hides the ones beneath it.
MIT
hooks/register.tsx 367 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3import type { CreditsReading, RateLimit } from '../types'
4
5const LABEL_WIDTH = 9
6const LIMIT_BAR_CELLS = 10
7const CONTEXT_BAR_CELLS = 20
8const TOP_CATEGORIES = 3
9const SECOND_MS = 1000
10const MINUTE_MS = 60 * SECOND_MS
11const TTL_MS: Record<string, number> = { '5m': 5 * MINUTE_MS, '1h': 60 * MINUTE_MS }
12// The endpoint behind Claude Code's /usage "Usage credits"; undocumented, so every field is checked.
13const CREDITS_URL = 'https://api.anthropic.com/api/oauth/usage'
14const CREDITS_REFRESH_MS = 5 * MINUTE_MS
15// A session that starts with an expired login fails its first ask; /login would otherwise wait out the full interval.
16const CREDITS_RETRY_MS = 30 * SECOND_MS
17// The wait doubles with each failure in a row, so a lasting 429 or outage is not asked every 30 seconds.
18const creditsWait = (prev: CreditsReading) =>
19 prev.requestId !== null
20 ? CREDITS_ABANDON_MS
21 : prev.failures === 0
22 ? CREDITS_REFRESH_MS
23 : Math.min(CREDITS_RETRY_MS * 2 ** (prev.failures - 1), CREDITS_REFRESH_MS)
24// $.http.fetch has no timeout and cannot be cancelled, so a request open this long is given up:
25// its late answer is dropped and the next refresh asks again.
26const CREDITS_ABANDON_MS = 30 * MINUTE_MS
27// Past this the figure is not a percentage the bar can draw.
28const CREDITS_MAX_PERCENT = 10_000
29
30const FULL = String.fromCharCode(0x2588)
31const EMPTY = String.fromCharCode(0x2591)
32const SWATCH = String.fromCharCode(0x25a0)
33
34const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Spend', usage_credits: 'Credits' }
35
36const cache = atom({ plugin: 'session-band', key: 'cache' } as const, null)
37const snapshot = atom({ plugin: 'session-band', key: 'snapshot' } as const, null)
38const credits = atom({ plugin: 'session-band', key: 'credits' } as const, null)
39
40const tokens = (n: number) => {
41 if (n >= 1_000_000) {
42 return `${Number((n / 1_000_000).toFixed(1))}M`
43 }
44 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
45}
46
47const duration = (ms: number) => {
48 const m = Math.max(0, Math.floor(ms / MINUTE_MS))
49 if (m < 60) {
50 return `${m}m`
51 }
52 const h = Math.floor(m / 60)
53 return h < 24 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`
54}
55
56const clock = (ms: number) => {
57 const s = Math.ceil(ms / SECOND_MS)
58 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
59}
60
61const bar = (percent: number) => {
62 const filled = Math.min(LIMIT_BAR_CELLS, Math.round((percent / 100) * LIMIT_BAR_CELLS))
63 return FULL.repeat(filled) + EMPTY.repeat(LIMIT_BAR_CELLS - filled)
64}
65
66// Largest-remainder split, so the segments always add up to the filled cells.
67const segments = (filled: number, weights: number[]) => {
68 const total = weights.reduce((a, b) => a + b, 0)
69 if (total === 0) {
70 return weights.map(() => 0)
71 }
72 const exact = weights.map(w => (w / total) * filled)
73 const cells = exact.map(Math.floor)
74 const order = exact.map((x, i) => ({ i, rest: x - Math.floor(x) })).sort((a, b) => b.rest - a.rest)
75 const missing = filled - cells.reduce((a, b) => a + b, 0)
76 for (let k = 0; k < missing; k++) {
77 cells[order[k]!.i]! += 1
78 }
79 return cells
80}
81
82const severity = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : undefined)
83
84const parseCredits = (text: string): number | null => {
85 try {
86 const extra: unknown = (JSON.parse(text) as { extra_usage?: unknown }).extra_usage
87 if (typeof extra !== 'object' || extra === null) {
88 return null
89 }
90 const { is_enabled, utilization, used_credits, monthly_limit } = extra as Record<string, unknown>
91 if (is_enabled !== true) {
92 return null
93 }
94 // An allowance with nothing spent yet answers utilization null; the two amounts share a unit.
95 const percent =
96 typeof utilization === 'number'
97 ? utilization
98 : typeof used_credits === 'number' && typeof monthly_limit === 'number' && monthly_limit > 0
99 ? (used_credits / monthly_limit) * 100
100 : NaN
101 if (!(percent >= 0 && percent <= CREDITS_MAX_PERCENT)) {
102 return null
103 }
104 return Math.round(percent * 10) / 10
105 } catch {
106 return null
107 }
108}
109
110// Credit-billed plans (Enterprise seats, for one) report no rate-limit window on responses;
111// their monthly credit spend is only on the usage endpoint.
112// failed marks a missing login or a refused request, which is asked again sooner than an answer.
113const fetchCredits = async ($: EngineInterface): Promise<{ percentUsed: number | null; failed: boolean }> => {
114 let text: string
115 try {
116 const auth = await $.session.authorize()
117 // Bedrock, Vertex and gateways hold no first-party credential, nor does a session whose login
118 // expired until /login; authorize is local, so asking again soon costs no request.
119 if (auth === null) {
120 return { percentUsed: null, failed: true }
121 }
122 // The endpoint takes only a claude.ai login: an API key would be refused on every poll.
123 if (auth.kind !== 'bearer') {
124 return { percentUsed: null, failed: false }
125 }
126 const r = await $.http.fetch(CREDITS_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
127 if (!r.ok) {
128 return { percentUsed: null, failed: true }
129 }
130 text = r.text
131 } catch {
132 return { percentUsed: null, failed: true }
133 }
134 return { percentUsed: parseCredits(text), failed: false }
135}
136
137const pollCredits = async ($: EngineInterface) => {
138 const now = await $.clock.now()
139 const requestId = Math.random().toString(36).slice(2)
140 let started = false
141 // update writes with ifVersion and retries, so of two refreshes racing here only one starts a request.
142 await update($, credits, prev => {
143 // refresh runs after every response; the endpoint is asked at most once per interval.
144 started = prev === null || now - prev.requestedAt >= creditsWait(prev)
145 // pendingSince keeps the first unanswered request's time when a given-up or failed one is asked again.
146 return started
147 ? { percentUsed: prev?.percentUsed ?? null, requestedAt: now, requestId, pendingSince: prev?.pendingSince ?? now, failures: prev?.failures ?? 0 }
148 : prev
149 })
150 if (!started) {
151 return
152 }
153 // The request runs on a timer of its own so a slow endpoint never holds up the response's hooks.
154 $.clock.after(0, async () => {
155 const { percentUsed, failed } = await fetchCredits($)
156 const answeredAt = await $.clock.now()
157 // A late answer to a request given up on, or one after /clear, no longer matches and is dropped.
158 // A failure keeps the last figure, so a passing error does not blink the row; the 30-minute
159 // pendingSince rule hides it once failures last.
160 await update($, credits, prev =>
161 prev === null || prev.requestId !== requestId
162 ? prev
163 : failed
164 ? // The wait runs from the failure, so a request that is slow to fail is not asked again at once.
165 { ...prev, requestedAt: answeredAt, requestId: null, failures: prev.failures + 1 }
166 : { ...prev, percentUsed, requestId: null, pendingSince: null, failures: 0 },
167 )
168 })
169}
170
171const refresh = async ($: EngineInterface, showCredits: boolean) => {
172 // 'full' sends a token-count request per MCP tool and memory file on every turn; 'summary' is local.
173 const usage = await $.session.usage({ breakdown: 'summary' })
174 const used = (usage.context.breakdown?.categories ?? [])
175 .filter(c => c.kind === 'used')
176 .sort((a, b) => b.tokens - a.tokens)
177 const limits: RateLimit[] = usage.rateLimits.map(l => {
178 const resetsAt = l.resetsAt === undefined ? NaN : Date.parse(l.resetsAt)
179 return { kind: l.kind, percentUsed: l.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
180 })
181 if (showCredits) {
182 await pollCredits($)
183 }
184 await update($, snapshot, () => ({
185 startedAt: usage.startedAt,
186 percent: usage.context.percent ?? null,
187 tokens: usage.context.tokens ?? null,
188 window: usage.context.window,
189 top: used.slice(0, TOP_CATEGORIES).map(({ name, tokens, color }) => ({ name, tokens, color })),
190 otherTokens: used.slice(TOP_CATEGORIES).reduce((sum, c) => sum + c.tokens, 0),
191 limits,
192 costUsd: usage.cost?.usd ?? null,
193 }))
194}
195
196export const register: Register = (on, options) => {
197 // The plugin cannot observe the TTL the engine requested, so the person states it.
198 const ttlMs = TTL_MS[String(options.cacheTtl)] ?? TTL_MS['5m']!
199 const showCost = options.showCost !== false
200 const showCredits = options.showCredits === true
201
202 on('session.start', async ($, e, next) => {
203 const result = await next(e)
204 await refresh($, showCredits)
205 $.clock.every(SECOND_MS, () => $.ui.invalidate('ui.render'))
206 return result
207 })
208
209 on('session.measure', async ($, e, next) => {
210 const result = await next(e)
211 await refresh($, showCredits)
212 return result
213 })
214
215 on('classic.SessionStart', async ($, e, next) => {
216 if (e.seconds_since_last_response !== undefined) {
217 const now = await $.clock.now()
218 // The payload times the end of the last response, not the start of its request, so the
219 // countdown can run long by that response's generation time until a live one replaces it.
220 const respondedAt = now - e.seconds_since_last_response * SECOND_MS
221 // The engine judges expiry against the TTL it requested, which the plugin is only told.
222 const sentAt = e.prompt_cache_likely_expired === true ? Math.min(respondedAt, now - ttlMs) : respondedAt
223 await update($, cache, () => ({ sentAt, estimated: true }))
224 }
225 const result = await next(e)
226 // A resumed or forked conversation starts with empty session state and no session.start.
227 await refresh($, showCredits)
228 return result
229 })
230
231 on('turn.step', async function* ($, e, next) {
232 // The cache lifetime runs from the start of the request, so generation time counts against it.
233 const sentAt = await $.clock.now()
234 const result = yield* next(e)
235 // Subagents cache their own prefixes; only the main thread's matters for the next prompt.
236 if (e.agentId === undefined && result.usage !== null) {
237 const cached = result.usage.cache_read_input_tokens + result.usage.cache_creation_input_tokens > 0
238 await update($, cache, () => (cached ? { sentAt, estimated: false } : null))
239 }
240 return result
241 })
242
243 // Compaction replaces the cached prefix. classic SessionStart(compact) also fires for a
244 // subagent's compaction with no agent fields (anthropics/claude-code#91910); this event has agentId.
245 on('session.compact', async ($, e, next) => {
246 const result = await next(e)
247 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
248 await update($, cache, () => null)
249 }
250 return result
251 })
252
253 // Each model keeps its own prompt cache, so the first request after a switch writes a new one.
254 on('classic.PostModelSwitch', async ($, e, next) => {
255 if (e.agent_id !== undefined) {
256 return next(e)
257 }
258 await update($, cache, () => null)
259 const result = await next(e)
260 // The new model may have another context window; session.measure waits for its first response.
261 await refresh($, showCredits)
262 return result
263 })
264
265 on('session.end', async ($, e, next) => {
266 if (e.reason === 'clear') {
267 await update($, cache, () => null)
268 await update($, snapshot, () => null)
269 await update($, credits, () => null)
270 }
271 return next(e)
272 })
273
274 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
275 const below = await next(e)
276 if (e.props.hasSurvey) {
277 return below
278 }
279 const c = await read($, cache)
280 const s = await read($, snapshot)
281 const cr = showCredits ? await read($, credits) : null
282 if (c === null && s === null) {
283 return below
284 }
285 const now = await $.clock.now()
286 const { Box, Text } = $.ui.resolve(e)
287 const label = (text: string) => (
288 <Box width={LABEL_WIDTH} flexShrink={0}>
289 <Text dimColor>{text}</Text>
290 </Box>
291 )
292 const rows: RenderElement[] = []
293
294 if (c !== null) {
295 const left = c.sentAt + ttlMs - now
296 rows.push(
297 <Box flexDirection="row" key="cache">
298 {label('Cache')}
299 <Text color={left > 0 ? undefined : 'warning'}>{left > 0 ? clock(left) : 'expired'}</Text>
300 {left > 0 && c.estimated ? <Text dimColor>{' estimated'}</Text> : null}
301 </Box>,
302 )
303 }
304
305 if (s !== null) {
306 rows.push(
307 <Box flexDirection="row" key="session">
308 {label('Session')}
309 <Text>
310 {showCost && s.costUsd !== null ? `$${s.costUsd.toFixed(2)} ` : ''}
311 <Text dimColor>{`${duration(now - s.startedAt)} elapsed`}</Text>
312 </Text>
313 </Box>,
314 )
315
316 const live = s.limits.filter(l => l.resetsAt === null || l.resetsAt > now)
317 // Once requests have gone unanswered or failed past the give-up time, the figure is hidden rather than shown stale.
318 const crHung = cr !== null && cr.pendingSince !== null && now - cr.pendingSince >= CREDITS_ABANDON_MS
319 if (cr !== null && cr.percentUsed !== null && !crHung) {
320 // The endpoint carries no reset time, and billing cycles differ by organization.
321 live.push({ kind: 'usage_credits', percentUsed: cr.percentUsed, resetsAt: null })
322 }
323 if (live.length > 0) {
324 rows.push(
325 <Box flexDirection="row" key="limits">
326 {label('Limits')}
327 <Box flexDirection="row" flexWrap="wrap" columnGap={4}>
328 {live.map(l => (
329 <Text>
330 {`${LIMIT_NAMES[l.kind] ?? l.kind} `}
331 <Text color={severity(l.percentUsed)} dimColor={severity(l.percentUsed) === undefined}>{bar(l.percentUsed)}</Text>
332 {` ${l.percentUsed}%`}
333 <Text dimColor>{l.resetsAt === null ? '' : ` resets in ${duration(l.resetsAt - now)}`}</Text>
334 </Text>
335 ))}
336 </Box>
337 </Box>,
338 )
339 }
340
341 const filled = s.percent === null ? 0 : Math.min(CONTEXT_BAR_CELLS, Math.round((s.percent / 100) * CONTEXT_BAR_CELLS))
342 const cells = segments(filled, [...s.top.map(c => c.tokens), s.otherTokens])
343 const unattributed = filled - cells.slice(0, s.top.length).reduce((a, b) => a + b, 0)
344 rows.push(
345 <Box flexDirection="row" key="context">
346 {label('Context')}
347 <Text wrap="truncate-end">
348 {s.top.map((c, i) => <Text color={c.color}>{FULL.repeat(cells[i] ?? 0)}</Text>)}
349 <Text dimColor>{FULL.repeat(unattributed) + EMPTY.repeat(CONTEXT_BAR_CELLS - filled)}</Text>
350 {s.percent === null || s.tokens === null ? ' --' : ` ${s.percent}% ${tokens(s.tokens)} / ${tokens(s.window)}`}
351 {s.top.map(c => (
352 <Text>
353 {' '}
354 <Text color={c.color}>{SWATCH}</Text>
355 <Text dimColor>{` ${c.name} ${tokens(c.tokens)}`}</Text>
356 </Text>
357 ))}
358 </Text>
359 </Box>,
360 )
361 }
362
363 // AbovePrompt is one band shared by every plugin; wrapping keeps their rows instead of replacing them.
364 return below ? <Box flexDirection="column">{below}{rows}</Box> : <Box flexDirection="column">{rows}</Box>
365 })
366}
367types/index.d.ts 42 lines1export type EpochMs = number
2
3export type RateLimit = {
4 kind: string
5 percentUsed: number
6 resetsAt: EpochMs | null
7}
8
9export type CacheCountdown = {
10 sentAt: EpochMs
11 estimated: boolean
12}
13
14export type CreditsReading = {
15 percentUsed: number | null
16 requestedAt: EpochMs
17 requestId: string | null
18 pendingSince: EpochMs | null
19 failures: number
20}
21
22export type SessionSnapshot = {
23 startedAt: EpochMs
24 percent: number | null
25 tokens: number | null
26 window: number
27 top: { name: string; tokens: number; color: string }[]
28 otherTokens: number
29 limits: RateLimit[]
30 costUsd: number | null
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'session-band': {
36 cache: CacheCountdown | null
37 snapshot: SessionSnapshot | null
38 credits: CreditsReading | null
39 }
40 }
41}
42