Keeps the 5-hour and weekly limit windows, their burn rate, a run-out projection, context fill, session cost and prompt-cache drops in a dim line above the…

Shows your 5-hour and weekly usage windows in a line above the Claude Code prompt: how much you've used, how fast it's climbing, and when you'll run out if that comes before the reset. It also counts prompt-cache drops.
⚠ 5h 41% +38%/h out in 1h33m · 7d 18% +6%/d (resets 4d2h) · ctx 22% · $14.10 · cache drops 3 (1.9M)
The line is dim. Only a window marked ⚠, one that runs out before it resets at the current pace, is drawn in the warning color. The line sits in the band above the prompt, alongside any other plugin's line there; collapse the band with its [-] or ctrl+x ctrl+a. In the VS Code extension and on a phone, which have no band, the line falls back to the plugin status line. That status shows everywhere, in its usual yellow, while such a surface is attached.
cache drops 3 (1.9M) appears after the first drop: 3 requests this session had to rebuild their prompt cache, 1.9M tokens rewritten in all. A request counts when it isn't that agent's first, its context is at least 100K, at least half of the context was written to the cache afresh, and more than 4 minutes passed since the same agent's previous request. That pattern means the cache expired, usually over a long wait; subagents get a 5-minute cache by default. The main thread, subagents and in-process teammates all count. The count starts over on /clear and when the plugin reloads.
| Setting | Default | What it does |
|---|---|---|
warnAtPercent | 90 | Toasts once when a window passes this percentage. |
rateWindowMinutes | 30 | Sets how far back the 5-hour burn rate looks (10 to 300). |
The main README covers installing, reading the line, and what it can't see.
hooks/register.tsx 299 lines1import type { EngineInterface, ModelUsage, Register, SessionRateLimit, SessionUsage } from 'claude-code'
2
3const HOUR = 3_600_000
4const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
5// a rolling rate needs at least this much history before it is shown
6const MIN_SPAN_MS = 5 * 60_000
7// the weekly pace is the window's own average, once it has run this long
8const MIN_WEEK_ELAPSED_MS = 6 * HOUR
9const WEEK_MS = 7 * 24 * HOUR
10// a resetsAt that moves less than this is the same window, reported again
11const SAME_WINDOW_MS = 10 * 60_000
12// a request is a cache drop when it rewrites at least this share of a context at least
13// this big, longer than this after the same agent's previous request
14const DROP_SHARE = 0.5
15const DROP_MIN_CONTEXT = 100_000
16const DROP_MIN_GAP_MS = 4 * 60_000
17// the surfaces that raise the AbovePrompt band; any other gets the line as a status
18const BAND_SURFACES: readonly string[] = ['terminal', 'desktop']
19
20export type Sample = { t: number; p: number }
21export type History = { resetsAt?: string; samples: Sample[] }
22
23// "2h14m" / "3d4h" / "12m"
24export function duration(ms: number): string {
25 const mins = Math.max(0, Math.round(ms / 60000))
26 const d = Math.floor(mins / 1440)
27 const h = Math.floor((mins % 1440) / 60)
28 const m = mins % 60
29 return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : `${m}m`
30}
31
32function sameWindow(a: string | undefined, b: string | undefined): boolean {
33 if (a === undefined || b === undefined) return a === b
34 return Math.abs(Date.parse(a) - Date.parse(b)) < SAME_WINDOW_MS
35}
36
37// adds a reading to a window's history; a new window (its reset time moved) starts afresh
38export function record(prev: History | undefined, limit: SessionRateLimit, now: number, keepMs: number): History {
39 const kept = prev !== undefined && sameWindow(prev.resetsAt, limit.resetsAt) ? prev.samples : []
40 const samples = [...kept, { t: now, p: limit.percentUsed }].filter(s => now - s.t <= keepMs)
41 return { resetsAt: limit.resetsAt, samples }
42}
43
44// percentage points per hour across the samples; undefined while the history is too short
45export function burnRate(samples: readonly Sample[]): number | undefined {
46 const first = samples[0]
47 const last = samples[samples.length - 1]
48 if (first === undefined || last === undefined || last.t - first.t < MIN_SPAN_MS) return undefined
49 return ((last.p - first.p) / (last.t - first.t)) * HOUR
50}
51
52// the pace a window is judged by, in points per hour: the weekly window by its own average
53// so far (nights and weekends included), the others by the recent rolling rate
54export function pace(limit: SessionRateLimit, samples: readonly Sample[], now: number): number | undefined {
55 if (limit.kind === 'seven_day') {
56 if (limit.resetsAt === undefined) return undefined
57 const elapsed = now - (Date.parse(limit.resetsAt) - WEEK_MS)
58 return elapsed >= MIN_WEEK_ELAPSED_MS ? (limit.percentUsed / elapsed) * HOUR : undefined
59 }
60 return burnRate(samples)
61}
62
63// "+38%/h", or per day for the weekly window; undefined when too small to matter
64export function rateText(kind: string, rate: number | undefined): string | undefined {
65 if (rate === undefined) return undefined
66 if (kind === 'seven_day') return rate * 24 >= 1 ? `+${Math.round(rate * 24)}%/d` : undefined
67 return rate >= 1 ? `+${Math.round(rate)}%/h` : undefined
68}
69
70// how long until the window hits 100% at this pace, when that comes before it resets
71export function runsOutIn(limit: SessionRateLimit, rate: number | undefined, now: number): number | undefined {
72 if (rateText(limit.kind, rate) === undefined || limit.percentUsed >= 100) return undefined
73 const outIn = ((100 - limit.percentUsed) / rate!) * HOUR
74 const resetIn = limit.resetsAt === undefined ? Infinity : Date.parse(limit.resetsAt) - now
75 return outIn < resetIn ? outIn : undefined
76}
77
78export function hasReset(limit: SessionRateLimit, now: number): boolean {
79 return limit.resetsAt !== undefined && Date.parse(limit.resetsAt) <= now
80}
81
82export function formatLimit(limit: SessionRateLimit, now: number, rate?: number): string {
83 const label = LABELS[limit.kind] ?? limit.kind
84 // readings only arrive with a response, so an idle session's last one can outlive its window
85 if (hasReset(limit, now)) return `${label} reset`
86 let text = `${label} ${Math.round(limit.percentUsed)}%`
87 const shown = rateText(limit.kind, rate)
88 if (shown !== undefined) text += ` ${shown}`
89 const out = runsOutIn(limit, rate, now)
90 if (out !== undefined) return `⚠ ${text} out in ${duration(out)}`
91 return limit.resetsAt === undefined ? text : `${text} (resets ${duration(Date.parse(limit.resetsAt) - now)})`
92}
93
94export type Drops = { count: number; tokens: number }
95
96// 955492 -> 955K, 1900000 -> 1.9M
97export function tokens(n: number): string {
98 return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`
99}
100
101// the line's parts, before the cache drops
102export function usageParts(
103 usage: Omit<SessionUsage, 'startedAt'>,
104 now: number,
105 rates: ReadonlyMap<string, number | undefined> = new Map(),
106): string[] {
107 const parts = usage.rateLimits.map(l => formatLimit(l, now, rates.get(l.kind)))
108 if (parts.length === 0) parts.push('limits: no reading yet')
109 if (usage.context.percent !== undefined) parts.push(`ctx ${Math.round(usage.context.percent)}%`)
110 if (usage.cost !== undefined) parts.push(`$${usage.cost.usd.toFixed(2)}`)
111 return parts
112}
113
114// "cache drops 3 (1.9M)" appended, or nothing while there are none
115export function withDrops(parts: readonly string[], drops: Drops): string[] {
116 return drops.count > 0 ? [...parts, `cache drops ${drops.count} (${tokens(drops.tokens)})`] : [...parts]
117}
118
119export function formatUsage(
120 usage: Omit<SessionUsage, 'startedAt'>,
121 now: number,
122 rates: ReadonlyMap<string, number | undefined> = new Map(),
123 drops: Drops = { count: 0, tokens: 0 },
124): string {
125 return withDrops(usageParts(usage, now, rates), drops).join(' · ')
126}
127
128// the tokens a request rebuilt when it is a cache drop, else undefined: not the agent's
129// first request (`prevAt` undefined), more than four minutes since its previous request
130// started, a context of 100K or more, and at least half of it written to the cache afresh
131export function cacheDrop(prevAt: number | undefined, now: number, usage: ModelUsage): number | undefined {
132 if (prevAt === undefined || now - prevAt <= DROP_MIN_GAP_MS) return undefined
133 const context = usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
134 if (context < DROP_MIN_CONTEXT || usage.cache_creation_input_tokens < context * DROP_SHARE) return undefined
135 return usage.cache_creation_input_tokens
136}
137
138// the line as runs of text: each part marked ⚠ a warning run of its own, the other
139// parts and the separators merged into dim runs
140export type Run = { text: string; isWarning: boolean }
141export function runs(parts: readonly string[]): Run[] {
142 const out: Run[] = []
143 parts.forEach((part, i) => {
144 const pieces: Run[] = i > 0 ? [{ text: ' · ', isWarning: false }] : []
145 pieces.push({ text: part, isWarning: part.startsWith('⚠') })
146 for (const piece of pieces) {
147 const last = out[out.length - 1]
148 if (last !== undefined && !last.isWarning && !piece.isWarning) last.text += piece.text
149 else out.push({ ...piece })
150 }
151 })
152 return out
153}
154
155// whether no surface the session draws on raises a band (or nothing draws at all, as
156// under -p or an SDK host), so the line goes out as a status instead; a phone or IDE
157// attached beside a terminal goes without, so the terminal keeps its quiet band alone
158export function needsStatus(surfaces: readonly string[]): boolean {
159 return !surfaces.some(s => BAND_SURFACES.includes(s))
160}
161
162// a configured number, or the default when unset or not a number, held within bounds
163export function setting(value: unknown, fallback: number, min: number, max: number): number {
164 const n = Number(value ?? fallback)
165 return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback
166}
167
168// module state, which a reload starts over: the cache drops so far, when each agent's
169// last request started, the line's parts before the drops, and the line the band draws
170let drops: Drops = { count: 0, tokens: 0 }
171const lastStepAt = new Map<string, number>()
172let base: string[] | undefined
173let line: string[] | undefined
174
175// the band draws the line where it is raised; the status carries it where it is not
176async function show($: EngineInterface, parts: string[]) {
177 base = parts
178 const shown = withDrops(parts, drops)
179 if (line === undefined || line.join(' · ') !== shown.join(' · ')) {
180 line = shown
181 $.ui.invalidate('ui.render')
182 }
183 $.ui.status(needsStatus(await $.session.surfaces()) ? shown.join(' · ') : undefined)
184}
185
186async function reshow($: EngineInterface) {
187 if (base !== undefined) await show($, base)
188}
189
190export const register: Register = (on, options) => {
191 const warnAt = setting(options.warnAtPercent, 90, 1, 100)
192 const rateWindowMs = setting(options.rateWindowMinutes, 30, 10, 300) * 60_000
193 const history = new Map<string, History>()
194 // one toast per window per reset per reason
195 const toasted = new Set<string>()
196 const rates = (limits: readonly SessionRateLimit[], now: number) => {
197 const out = new Map<string, number | undefined>()
198 for (const limit of limits) {
199 if (hasReset(limit, now)) {
200 history.delete(limit.kind)
201 continue
202 }
203 const h = record(history.get(limit.kind), limit, now, rateWindowMs)
204 history.set(limit.kind, h)
205 out.set(limit.kind, pace(limit, h.samples, now))
206 }
207 return out
208 }
209
210 on('session.start', async ($, e, next) => {
211 const result = await next(e)
212 const tick = async () => {
213 try {
214 const now = await $.clock.now()
215 const usage = await $.session.usage()
216 await show($, usageParts(usage, now, rates(usage.rateLimits, now)))
217 } catch {
218 // the next tick or measurement draws it again
219 }
220 }
221 await tick()
222 // keep the countdowns and rates moving between turns
223 $.clock.every(60_000, tick)
224 return result
225 })
226
227 on('session.end', { reason: 'clear' }, async ($, e, next) => {
228 drops = { count: 0, tokens: 0 }
229 lastStepAt.clear()
230 await reshow($)
231 return next(e)
232 })
233
234 // every model request of the main loop, a subagent or an in-process teammate
235 on('turn.step', async function* ($, e, next) {
236 const at = await $.clock.now()
237 const result = yield* next(e)
238 // a request that got no response read and wrote no cache
239 if (result.usage === null) return result
240 const id = e.agentId ?? 'main'
241 const rebuilt = cacheDrop(lastStepAt.get(id), at, result.usage)
242 lastStepAt.set(id, at)
243 if (rebuilt !== undefined) {
244 drops = { count: drops.count + 1, tokens: drops.tokens + rebuilt }
245 await reshow($)
246 }
247 return result
248 })
249
250 on('session.measure', async ($, e, next) => {
251 const now = await $.clock.now()
252 const r = rates(e.rateLimits, now)
253 await show($, usageParts(e, now, r))
254 for (const limit of e.rateLimits) {
255 if (hasReset(limit, now)) continue
256 const window = `${limit.kind}:${limit.resetsAt ?? ''}`
257 const label = LABELS[limit.kind] ?? limit.kind
258 const out = runsOutIn(limit, r.get(limit.kind), now)
259 if (out !== undefined && !toasted.has(`${window}:pace`)) {
260 toasted.add(`${window}:pace`)
261 $.ui.toast(`At this pace the ${label} limit runs out in ${duration(out)}, before it resets`)
262 }
263 if (limit.percentUsed >= warnAt && !toasted.has(`${window}:warn`)) {
264 toasted.add(`${window}:warn`)
265 $.ui.toast(`Usage: ${formatLimit(limit, now, r.get(limit.kind))}`)
266 }
267 }
268 return next(e)
269 })
270
271 // a phone or an IDE joining or leaving changes whether the status is needed
272 on('session.attach', async ($, e, next) => {
273 const result = await next(e)
274 await reshow($)
275 return result
276 })
277 on('session.detach', async ($, e, next) => {
278 const result = await next(e)
279 await reshow($)
280 return result
281 })
282
283 // one dim line in the band above the prompt, under what the plugins beneath drew;
284 // only the parts marked ⚠ take the warning color
285 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286 const below = await next(e)
287 if (e.props.hasSurvey || line === undefined) return below
288 const { Box, Text } = $.ui.resolve(e)
289 return (
290 <Box flexDirection="column">
291 {below}
292 <Text wrap="truncate-end">
293 {runs(line).map(r => (r.isWarning ? <Text color="warning">{r.text}</Text> : <Text dimColor>{r.text}</Text>))}
294 </Text>
295 </Box>
296 )
297 })
298}
299