Band above the prompt with context, plan limits and prompt-cache expiry

A Claude Code mod that keeps one line above the prompt with your context fill, plan limits and prompt-cache expiry:
Context 58% · Session 36% (resets at 23:30) · Weekly 81% (resets Oct 19 at 01:00) · Cache 13m left (until 22:09)
In the desktop app the plan limits and context refresh every 30 seconds from the app's own usage data, so work in other sessions shows up too. Elsewhere they refresh with each model response of the session.
Percentages turn yellow from 70% and red from 90%. The line works in the terminal and in the Code tab of the Claude desktop app.
In a terminal, start claude and type:
/plugin install usage-panel --marketplace alexmagnum2d0/usage-panel
Answer y to add the marketplace, then pick the user scope. The mod then loads in every new session, including the desktop app's Code tab. The command itself is only available in a terminal session.
| Option | Values | Default | What it does |
|---|---|---|---|
Prompt cache TTL (cacheTtl) | 1h, 5m | 1h | How long the prompt cache lives after the last response. Subscriptions get 1 hour; API keys and usage past the plan get 5 minutes. |
claude plugin validate .
claude plugin test .hooks/register.tsx 238 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput } from 'claude-code'
3
4import type { UsageLimit, UsageSnapshot } from '../types'
5
6const usage = atom({ plugin: 'usage-panel', key: 'usage' } as const, null)
7const lastResponseAt = atom({ plugin: 'usage-panel', key: 'lastResponseAt' } as const, null)
8
9const TTL_MS: Record<string, number> = { '1h': 60 * 60_000, '5m': 5 * 60_000 }
10const TICK_MS = 30_000
11
12const LIMIT_NAMES: Record<string, string> = {
13 five_hour: 'Session',
14 seven_day: 'Weekly',
15 spend_limit: 'Spend',
16}
17const LIMIT_ORDER = ['five_hour', 'seven_day', 'spend_limit']
18
19const DAY = new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short' })
20const TIME = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit' })
21
22type Item = { key: string; label: string; percent?: number; note?: string }
23
24// What the desktop app's get_usage tool answers: the account's limits as its
25// usage card shows them, and this session's context as the side panel does.
26type AppUsage = {
27 plan?: { status: string; windows?: { label: string; percentUsed: number; resetsAt?: string }[] }
28 context?: { status: string; tokensUsed?: number; contextWindow?: number; percentUsed?: number }
29}
30
31const APP_SERVER = 'ccd_session_mgmt'
32const APP_TOOL = 'get_usage'
33
34function snapshot(u: Omit<SessionMeasureInput, 'changed'>, compactWindow?: number): UsageSnapshot {
35 const window = compactWindow ?? u.context.window
36 const tokens = u.context.tokens
37 return {
38 contextTokens: tokens,
39 contextWindow: window,
40 contextPercent: tokens === undefined ? undefined : Math.round((tokens / window) * 100),
41 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
42 costUsd: u.cost?.usd,
43 }
44}
45
46// The side panel measures against the auto-compact window, not the model's limit.
47async function compactWindow($: EngineInterface) {
48 const { context } = await $.session.usage({ breakdown: 'summary' })
49 return context.breakdown?.rawMaxTokens
50}
51
52async function refresh($: EngineInterface) {
53 const next = snapshot(await $.session.usage(), await compactWindow($))
54 await update($, usage, () => next)
55}
56
57function limitKind(label: string) {
58 if (label.startsWith('5-hour')) return 'five_hour'
59 if (label === 'Weekly · all models') return 'seven_day'
60 return undefined
61}
62
63function fromApp(prev: UsageSnapshot | null, app: AppUsage): UsageSnapshot | null {
64 const fresh: UsageLimit[] = []
65 if (app.plan?.status === 'ok') {
66 for (const w of app.plan.windows ?? []) {
67 const kind = limitKind(w.label)
68 if (kind) fresh.push({ kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })
69 }
70 }
71 const ctx = app.context?.status === 'ok' ? app.context : undefined
72 if (fresh.length === 0 && !ctx) return prev
73
74 const kept = (prev?.limits ?? []).filter(l => !fresh.some(f => f.kind === l.kind))
75 return {
76 contextTokens: ctx?.tokensUsed ?? prev?.contextTokens,
77 contextWindow: ctx?.contextWindow ?? prev?.contextWindow ?? 0,
78 contextPercent: ctx?.percentUsed ?? prev?.contextPercent,
79 limits: [...kept, ...fresh],
80 costUsd: prev?.costUsd,
81 }
82}
83
84// Plan limits are the account's, so other sessions move them too; the engine
85// only hears of that with this session's next response. The desktop app reads
86// them live. Resolves false where the app's server is not connected.
87async function pollApp($: EngineInterface) {
88 try {
89 const { content, isError } = await $.mcp.call(APP_SERVER, APP_TOOL, {})
90 const text = content.find(block => block.type === 'text')?.text
91 if (isError || text === undefined) return false
92 const app = JSON.parse(text) as AppUsage
93 await update($, usage, prev => fromApp(prev, app))
94 return true
95 } catch {
96 return false
97 }
98}
99
100function colorFor(percent: number) {
101 if (percent >= 90) return 'error'
102 if (percent >= 70) return 'warning'
103 return 'success'
104}
105
106function isSameDay(a: number, b: number) {
107 return DAY.format(a) === DAY.format(b)
108}
109
110function resetNote(resetsAt: string | undefined, now: number) {
111 if (!resetsAt) return undefined
112 const at = Date.parse(resetsAt)
113 if (!Number.isFinite(at)) return undefined
114 if (at <= now) return '(resets now)'
115 const day = isSameDay(at, now) ? '' : `${DAY.format(at)} `
116 return `(resets ${day}at ${TIME.format(at)})`
117}
118
119function cacheItem(warmUntil: number | undefined, now: number): Item | undefined {
120 if (warmUntil === undefined) return undefined
121 const at = TIME.format(warmUntil)
122 const ms = warmUntil - now
123 if (ms <= 0) return { key: 'cache', label: 'Cache', note: `expired at ${at}` }
124 const minutes = Math.floor(ms / 60_000)
125 const left = minutes < 1 ? '<1m' : `${minutes}m`
126 return { key: 'cache', label: 'Cache', note: `${left} left (until ${at})` }
127}
128
129async function warmUntil($: EngineInterface, ttlMs: number) {
130 const at = await read($, lastResponseAt)
131 return at === null ? undefined : at + ttlMs
132}
133
134function redraw($: EngineInterface) {
135 $.ui.invalidate('ui.render')
136}
137
138export const register: Register = (on, options) => {
139 const ttlMs = TTL_MS[String(options.cacheTtl)] ?? TTL_MS['1h']!
140
141 let hasApp = true
142
143 on('session.start', async ($, e, next) => {
144 await refresh($)
145 hasApp = await pollApp($)
146 $.ui.status(undefined)
147 $.clock.every(TICK_MS, () => {
148 void (async () => {
149 if (hasApp) hasApp = await pollApp($)
150 redraw($)
151 })()
152 })
153
154 return next(e)
155 })
156
157 // Each model response refreshes the cache: one that asks for a tool here,
158 // the turn's last one at turn.complete. Subagents keep caches of their own.
159 on('tool.call', async ($, e, next) => {
160 if (e.agentId === undefined) {
161 const at = await $.clock.now()
162 await update($, lastResponseAt, () => at).catch(() => undefined)
163 }
164
165 return next(e)
166 })
167
168 on('turn.complete', async ($, e, next) => {
169 const done = await next(e)
170 if (e.agentId === undefined && e.usage !== undefined) {
171 const at = await $.clock.now()
172 await update($, lastResponseAt, () => at).catch(() => undefined)
173 }
174
175 return done
176 })
177
178 on('session.measure', async ($, e, next) => {
179 const next_ = snapshot(e, await compactWindow($).catch(() => undefined))
180 await update($, usage, () => next_)
181
182 return next(e)
183 })
184
185 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186 if (e.props.hasSurvey) {
187 return next(e)
188 }
189
190 const u = await read($, usage)
191 const now = await $.clock.now()
192 const limits = [...(u?.limits ?? [])].sort(
193 (a, b) => LIMIT_ORDER.indexOf(a.kind) - LIMIT_ORDER.indexOf(b.kind),
194 )
195 const cache = cacheItem(await warmUntil($, ttlMs), now)
196 const items: Item[] = [
197 ...(u?.contextPercent !== undefined
198 ? [{ key: 'context', label: 'Context', percent: u.contextPercent }]
199 : []),
200 ...limits.map(l => ({
201 key: `limit-${l.kind}`,
202 label: LIMIT_NAMES[l.kind] ?? l.kind,
203 percent: l.percentUsed,
204 note: resetNote(l.resetsAt, now),
205 })),
206 ...(cache ? [cache] : []),
207 ]
208
209 if (items.length === 0) {
210 return next(e)
211 }
212
213 const { Box, Text } = $.ui.resolve(e)
214
215 return (
216 <Box key="usage-band" flexWrap="wrap">
217 {items.map((item, i) => (
218 <Box key={item.key}>
219 <Text dimColor>
220 {i > 0 ? ' · ' : ''}
221 {item.label}{' '}
222 </Text>
223 {item.percent !== undefined && (
224 <Text color={colorFor(item.percent)}>{item.percent}%</Text>
225 )}
226 {item.note !== undefined && (
227 <Text dimColor>
228 {item.percent !== undefined ? ' ' : ''}
229 {item.note}
230 </Text>
231 )}
232 </Box>
233 ))}
234 </Box>
235 )
236 })
237}
238types/index.d.ts 19 lines1export type UsageLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type UsageSnapshot = {
4 contextTokens?: number
5 contextWindow: number
6 contextPercent?: number
7 limits: UsageLimit[]
8 costUsd?: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'usage-panel': {
14 usage: UsageSnapshot | null
15 lastResponseAt: number | null
16 }
17 }
18}
19