A live forecast of your context window, plus your session and weekly usage limits, in the band above the prompt.

A live "weather report" above the prompt: how full Claude's memory is, and how much of your usage limits you've used.

Line 1: the context window (how much of Claude's memory this conversation fills)
| Part | Meaning |
|---|---|
◆ Opus 5.5 high | The model answering you, and its effort (how hard it thinks). Effort shows after Claude's first reply. |
☀ Clear | The weather for how full it is (see below). |
8% 81.8k / 1M | Tokens used out of the total. A token is a small piece of text, about ¾ of a word. |
▁▁▂▂▃▆······ | A chart of the last 12 turns. Dots are turns not taken yet. |
▲ +1.8k last turn | What your last turn added. ▼ −N compacted means a compaction shrank it. |
| Weather | Full |
|---|---|
| ☀ Clear | under 25% |
| ☁ Cloudy | under 50% |
| ☂ Showers | under 75% |
| ☇ Storm | under 90% |
| ↯ Compact soon | 90% and up |
Lines 2–3: your usage limits (the same bars as /usage)
| Part | Meaning |
|---|---|
Session / Week | Your 5-hour limit and your weekly limit. |
95% | How much of that limit you've used. The bar turns red at 90% and up. |
▲ +0.2% last turn | What your last turn used. |
+0.1% this turn | What the running turn has used so far (only while Claude works). |
resets in 37m (6:30pm) | Time left until the limit resets, then the clock time. |
claude plugin install usage-forecast --marketplace thangdo19/claude-code-mods
More ways (inside Claude Code, older versions): see the main README.
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # type check
hooks/weather.ts: formatting helpers (no Claude Code calls, easy to test).hooks/register.tsx: the hooks and the band above the prompt.types/index.d.ts: the shape of the saved state.<1%.waiting for first reply: a new session, before Claude has answered.measuring…: right after a compaction, /clear or /resume, until the next reply.↺ reset this turn / ↺ reset last turn: the limit reset during that turn.window reset: the limit's reset time has passed, until a new reading arrives.↻ switched from Opus 5.5: the model changed (/model, or an automatic fallback). Stays until one whole turn has run on the new model. Helper agents' models never count./logout, or switching to an API key, hides the bars and deletes the saved readings.hooks/register.tsx 515 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Weather } from '../types'
5import {
6 addSpend,
7 fmtLeft,
8 fmtLimitDelta,
9 fmtModel,
10 fmtPct,
11 fmtReset,
12 fmtTokens,
13 HOT_PCT,
14 isAlias,
15 LIMIT_LABELS,
16 percentOf,
17 pushHistory,
18 reviveLimits,
19 sameModel,
20 sparkline,
21 usageCells,
22 weatherFor,
23 learnRate,
24 turnDelta,
25 HISTORY,
26} from './weather'
27import type { Anchor, Spend } from './weather'
28
29const EMPTY: Weather = {
30 tokens: null,
31 window: 0,
32 isEstimate: false,
33 history: [],
34 lastDelta: null,
35 lastDeltaCompacted: false,
36 turnStartTokens: null,
37 compacted: false,
38 limits: [],
39 limitsAsOf: null,
40 turnStartLimits: null,
41 lastLimitDelta: {},
42 instanceId: '',
43 usd: null,
44 spent: 0,
45 turnStartSpent: null,
46 rates: {},
47 now: 0,
48 model: null,
49 effort: null,
50 switchedFrom: null,
51 switchSeen: false,
52}
53const weather = atom({ plugin: 'usage-forecast', key: 'weather' } as const, EMPTY)
54
55// Last live limits, kept across sessions so a new session can show them at once.
56const STORE_KEY = 'lastLimits'
57type Saved = { limits: Limit[]; at: number }
58// Each session's spend under its own key, so no two sessions write one key;
59// the learned rates beside them.
60const SPEND_PREFIX = 'spend.'
61const RATES_KEY = 'rates'
62type SpendEntry = { usd: number; at: number }
63type Rates = Record<string, Anchor>
64// A session idle this long has stopped: its entry goes.
65const SPEND_TTL_MS = 8 * 24 * 60 * 60_000
66// Another session's saved reading this fresh is a fair baseline for a turn.
67const FRESH_MS = 60_000
68
69// The account has no limits (any more): nothing to show or compare against.
70const NO_LIMITS = { limits: [], limitsAsOf: null, turnStartLimits: null, lastLimitDelta: {} } satisfies Partial<Weather>
71
72const TRACK = '#4a2e25'
73const LABEL_WIDTH = 9
74const MODEL_COLOR = 'permission'
75
76type Usage = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
77
78const copyLimits = (limits: readonly SessionRateLimit[]): Limit[] =>
79 limits.map(({ kind, percentUsed, resetsAt }) => (resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt }))
80
81const withUsage = (w: Weather, usage: Usage, now: number): Weather => {
82 // The last response's reading stays put after its window resets: fresh again.
83 const live = reviveLimits(copyLimits(usage.rateLimits), now)
84 return {
85 ...w,
86 ...(usage.cost ? addSpend(w, usage.cost.usd) : {}),
87 window: usage.context.window || w.window,
88 // No figure yet keeps what we had: the start estimate, or null after a compaction.
89 tokens: usage.context.tokens ?? w.tokens,
90 isEstimate: usage.context.tokens === undefined && w.isEstimate,
91 limits: live.length > 0 ? live : w.limits,
92 limitsAsOf: live.length > 0 ? null : w.limitsAsOf,
93 now,
94 }
95}
96
97/**
98 * The main loop now runs `id`. A real change (not an alias gaining its
99 * version) notes the model before it, kept from a switch no turn has run
100 * on yet; switching back to that one drops the note.
101 */
102const withModel = (w: Weather, id: string): Weather => {
103 const next = fmtModel(id)
104 if (next === null) return w
105 const prev = w.model === null ? null : fmtModel(w.model)
106 // An alias resolving ("opusplan" → "Opus 5.5") is no switch.
107 if (prev === null || (isAlias(prev) && !isAlias(next))) return { ...w, model: id }
108 // The same model: keep the fuller name ("Opus 5.5" over the alias "Opus").
109 if (sameModel(prev, next)) return next.length >= prev.length ? { ...w, model: id } : w
110 const from = w.switchedFrom !== null && !w.switchSeen ? w.switchedFrom : prev
111 const switched = sameModel(from, next) ? null : from
112 // The old effort may not apply to the new model: unknown until its first request.
113 return { ...w, model: id, effort: null, switchedFrom: switched, switchSeen: false }
114}
115
116async function saveLimits($: EngineInterface, usage: Usage, now: number) {
117 const live = copyLimits(usage.rateLimits)
118 if (live.length > 0) await $.store.set(STORE_KEY, { limits: live, at: now } satisfies Saved)
119}
120
121// Puts this session's spend where the other sessions can count it.
122async function saveSpend($: EngineInterface, w: Weather, now: number) {
123 if (w.instanceId) await $.store.set(SPEND_PREFIX + w.instanceId, { usd: w.spent, at: now } satisfies SpendEntry)
124}
125
126const ratesOf = (rates: Rates): Record<string, number> =>
127 Object.fromEntries(Object.entries(rates).flatMap(([kind, a]) => (a.rate === null ? [] : [[kind, a.rate]])))
128
129/**
130 * Learns each window's % per dollar from a live reading and every session's
131 * spend, and hands back the rates; with no reading, the rates as they stand.
132 */
133async function learn($: EngineInterface, live: readonly Limit[], now: number): Promise<Record<string, number>> {
134 const rates = ((await $.store.get(RATES_KEY)) ?? {}) as Rates
135 if (live.length === 0) return ratesOf(rates)
136
137 const spend: Spend = {}
138 for (const key of await $.store.keys()) {
139 if (!key.startsWith(SPEND_PREFIX)) continue
140 const entry = (await $.store.get(key)) as SpendEntry | undefined
141 if (!entry) continue
142 if (now - entry.at > SPEND_TTL_MS) await $.store.delete(key)
143 else spend[key.slice(SPEND_PREFIX.length)] = entry.usd
144 }
145 const next: Rates = { ...rates }
146 for (const limit of live) next[limit.kind] = learnRate(rates[limit.kind], limit, spend)
147 if (JSON.stringify(next) !== JSON.stringify(rates)) await $.store.set(RATES_KEY, next)
148 return ratesOf(next)
149}
150
151async function applyUsage($: EngineInterface, usage: Usage, persist: boolean) {
152 try {
153 const now = await $.clock.now()
154 let before = 0
155 const w = await update($, weather, cur => {
156 before = cur.spent
157 return withUsage(cur, usage, now)
158 })
159 if (persist || w.spent !== before) await saveSpend($, w, now)
160 if (persist) {
161 await saveLimits($, usage, now)
162 const rates = await learn($, copyLimits(usage.rateLimits), now)
163 await update($, weather, cur => ({ ...cur, rates }))
164 }
165 } catch {
166 // A failed refresh only skips one reading; never disturb the event.
167 }
168}
169
170// Each limit's change over the turn ending now, this session's share where a
171// rate is known; with no baseline and no rate, the last figures stay.
172const lastDeltas = (w: Weather): Record<string, number> => {
173 const spent = w.turnStartSpent === null ? null : w.spent - w.turnStartSpent
174 const deltas = Object.fromEntries(
175 w.limits.flatMap(l => {
176 const d = turnDelta(l.percentUsed, w.turnStartLimits?.[l.kind], spent, w.rates[l.kind])
177 return d === undefined ? [] : [[l.kind, d]]
178 }),
179 )
180 return Object.keys(deltas).length > 0 ? deltas : w.lastLimitDelta
181}
182
183export const register: Register = on => {
184 on('session.start', async ($, e, next) => {
185 const result = await next(e)
186
187 // Shows a window whose reset time has passed as fresh, live or saved,
188 // until the next reading.
189 $.clock.every(60_000, () => {
190 void $.clock
191 .now()
192 .then(at => update($, weather, cur => ({ ...cur, limits: reviveLimits(cur.limits, at), now: at })))
193 .catch(() => {})
194 })
195
196 try {
197 // A hot reload over state saved by an older version: its new fields start empty.
198 await update($, weather, cur => ({ ...EMPTY, ...cur }))
199 const now = await $.clock.now()
200 const w = await read($, weather)
201 if (!w.instanceId) {
202 const instanceId = crypto.randomUUID()
203 await update($, weather, cur => ({ ...cur, instanceId }))
204 }
205 // A hot reload fires session.start again: keep what this session measured.
206 if (w.window === 0) {
207 // Counted as /context counts it; the local estimate if that fails.
208 const usage = await $.session
209 .usage({ breakdown: 'full' })
210 .catch(() => $.session.usage({ breakdown: 'summary' }))
211 .catch(() => $.session.usage())
212 const estimate = usage.context.breakdown?.totalTokens
213 const saved = (await $.store.get(STORE_KEY)) as Saved | undefined
214 const live = copyLimits(usage.rateLimits)
215 const rates = await learn($, [], now).catch(() => ({}))
216 await update($, weather, cur => ({
217 ...cur,
218 ...(usage.cost ? addSpend(cur, usage.cost.usd) : {}),
219 rates,
220 window: usage.context.window,
221 tokens: usage.context.tokens ?? estimate ?? null,
222 isEstimate: usage.context.tokens === undefined && estimate !== undefined,
223 limits: live.length > 0 ? live : saved ? reviveLimits(saved.limits, now) : [],
224 limitsAsOf: live.length > 0 || !saved ? null : saved.at,
225 now,
226 }))
227 }
228 } catch {
229 // No first reading: the band fills in from the first response.
230 }
231
232 // Outside the gate above, so a hot reload of an older state fills it too.
233 try {
234 if ((await read($, weather)).model === null) {
235 const model = await $.session.model()
236 await update($, weather, cur => (cur.model === null ? withModel(cur, model) : cur))
237 }
238 } catch {
239 // No name yet: the first request names it.
240 }
241 return result
242 })
243
244 // A main-loop turn begins (a subagent's run raises none).
245 on('turn.start', async ($, e, next) => {
246 try {
247 const now = await $.clock.now()
248 const usage = await $.session.usage().catch(() => undefined)
249 const saved = (await $.store.get(STORE_KEY).catch(() => undefined)) as Saved | undefined
250 const fresh = saved && now - saved.at < FRESH_MS ? saved : undefined
251 const rates = await learn($, [], now).catch(() => undefined)
252 await update($, weather, cur => {
253 const w = usage ? withUsage(cur, usage, now) : cur
254 // Limits from an earlier session are not a fair baseline; another
255 // session's reading from just now is.
256 const base = w.limitsAsOf === null ? w.limits : fresh?.limits
257 return {
258 ...w,
259 rates: rates ?? w.rates,
260 turnStartTokens: w.tokens,
261 turnStartSpent: w.spent,
262 turnStartLimits: base ? Object.fromEntries(base.map(l => [l.kind, l.percentUsed])) : null,
263 // A turn on the new model has begun: its end clears the switch note.
264 switchSeen: w.switchedFrom !== null && e.agentId === undefined ? true : w.switchSeen,
265 }
266 })
267 } catch {
268 // Without a baseline this turn just shows no change.
269 }
270 return next(e)
271 })
272
273 // Live: every model response, mid-turn included, refreshes the figures.
274 on('turn.step', async function* ($, e, next) {
275 // The main loop's model and effort as sent; a subagent's are its own.
276 if (e.agentId === undefined) {
277 try {
278 const effort = typeof e.effort === 'string' ? e.effort : null
279 await update($, weather, cur => ({ ...withModel(cur, e.model), effort }))
280 } catch {
281 // The name stays as it was until the next request.
282 }
283 }
284 const result = yield* next(e)
285 try {
286 await applyUsage($, await $.session.usage(), false)
287 } catch {
288 // A failed read only skips one refresh; never disturb the turn.
289 }
290 return result
291 })
292
293 on('turn.complete', async ($, e, next) => {
294 const result = await next(e)
295 if (e.agentId !== undefined) return result
296
297 try {
298 const usage = await $.session.usage().catch(() => undefined)
299 const now = await $.clock.now()
300 // Ended before any response (Esc at once, an API error): not a turn to count.
301 const counts = e.reason === 'answer' || e.usage !== undefined
302 const spending = usage ? withUsage(await read($, weather), usage, now) : undefined
303 if (spending) await saveSpend($, spending, now)
304 const rates = await learn($, usage ? copyLimits(usage.rateLimits) : [], now).catch(() => undefined)
305 await update($, weather, cur => {
306 const w = { ...(usage ? withUsage(cur, usage, now) : cur), rates: rates ?? cur.rates }
307 const ended = { ...w, turnStartLimits: null, turnStartTokens: null, turnStartSpent: null }
308 if (!counts) return ended
309
310 const tokens = usage?.context.tokens
311 const last = w.history.at(-1)
312 const before = last ?? w.turnStartTokens
313 const measured = tokens !== undefined && before !== null
314 return {
315 ...ended,
316 history: tokens === undefined ? w.history : pushHistory(w.history, tokens),
317 lastDelta: measured ? tokens - before : w.lastDelta,
318 lastDeltaCompacted: measured ? w.compacted : w.lastDeltaCompacted,
319 compacted: measured ? false : w.compacted,
320 lastLimitDelta: lastDeltas(w),
321 // A whole turn ran on the new model: the switch note has been read.
322 ...(w.switchSeen ? { switchedFrom: null, switchSeen: false } : {}),
323 }
324 })
325 if (usage) await saveLimits($, usage, now)
326 } catch {
327 // A failed reading leaves the band as it was; the turn itself is done.
328 }
329 return result
330 })
331
332 // /model, the picker, a fallback: the band shows the new model at once.
333 on('classic.PostModelSwitch', async ($, e, next) => {
334 if (e.agent_id === undefined) await update($, weather, w => withModel(w, e.to_model))
335 return next(e)
336 // Only observes: a failure here must never block the switch.
337 }).catch(($, e, next) => next(e))
338
339 // Pushed after each main turn and whenever a limit moves a whole point.
340 on('session.measure', async ($, e, next) => {
341 if (e.changed.includes('rateLimits') && e.rateLimits.length === 0) {
342 // The limits left (an API key now, say): stop showing the last ones.
343 try {
344 await update($, weather, w => ({ ...w, ...NO_LIMITS }))
345 await $.store.delete(STORE_KEY)
346 } catch {
347 // Shown again at the next reading.
348 }
349 }
350 await applyUsage($, e, true)
351 return next(e)
352 })
353
354 on('session.compact', async ($, e, next) => {
355 const result = await next(e)
356 // A precompute only prepares a summary: the conversation is unchanged.
357 if (e.trigger !== 'precompute' && result.skip === undefined) {
358 // The fill is unknown until the next response reports it.
359 await update($, weather, w => ({ ...w, tokens: null, isEstimate: false, compacted: true }))
360 }
361 return result
362 // Only observes: a failure here must never block or alter the compaction.
363 }).catch(($, e, next) => next(e))
364
365 // /clear and /resume switch conversations with no session.start: drop the old
366 // turns, keep the window and the limits with their last-turn change (they
367 // belong to the account). /logout drops the limits too.
368 on('session.end', async ($, e, next) => {
369 if (e.reason === 'clear' || e.reason === 'resume') {
370 await update($, weather, w => ({
371 ...EMPTY,
372 window: w.window,
373 limits: w.limits,
374 limitsAsOf: w.limitsAsOf,
375 lastLimitDelta: w.lastLimitDelta,
376 instanceId: w.instanceId,
377 usd: w.usd,
378 spent: w.spent,
379 rates: w.rates,
380 now: w.now,
381 model: w.model,
382 effort: w.effort,
383 switchedFrom: w.switchedFrom,
384 switchSeen: w.switchSeen,
385 }))
386 } else if (e.reason === 'logout') {
387 await update($, weather, w => ({ ...w, ...NO_LIMITS }))
388 await $.store.delete(STORE_KEY)
389 }
390 return next(e)
391 }).catch(($, e, next) => next(e))
392
393 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
394 if (e.props.hasSurvey) return next(e)
395 const w = await read($, weather)
396 // Loose checks: state from an older version may lack these until session.start fills them.
397 const name = w.model == null ? null : fmtModel(w.model)
398 if (w.window === 0 && w.limits.length === 0 && name === null) return next(e)
399
400 const { Box, Text } = $.ui.resolve(e)
401 const working = e.props.isWorking
402
403 // Line 1 opens with the model, so a narrow terminal's cut never hides it.
404 const model = name && (
405 <Text color={MODEL_COLOR}>
406 <Text bold>◆ {name}</Text>
407 {w.effort && <Text>{` ${w.effort}`}</Text>}
408 </Text>
409 )
410
411 // Line 1: the context window.
412 const known = w.tokens !== null
413 const pct = known ? percentOf(w.tokens!, w.window) : 0
414 const level = weatherFor(pct)
415 const bars = sparkline(w.history, w.window)
416 let tail = null
417 // For one turn after a model change, that says more than any figure.
418 if (w.switchedFrom != null) tail = <Text color={MODEL_COLOR}>↻ switched from {w.switchedFrom}</Text>
419 // Unknown until the next response: right after a compaction, a /clear or a /resume.
420 else if (!known) tail = <Text dimColor>measuring…</Text>
421 else if (w.isEstimate) tail = <Text dimColor>waiting for first reply</Text>
422 else if (w.lastDelta !== null) {
423 if (w.lastDelta < 0 && w.lastDeltaCompacted)
424 tail = <Text color="green">▼ −{fmtTokens(-w.lastDelta)} compacted</Text>
425 else if (w.lastDelta < 0) tail = <Text dimColor>▼ −{fmtTokens(-w.lastDelta)} last turn</Text>
426 else
427 tail = (
428 <Text color={w.lastDelta > w.window * 0.2 ? 'red' : undefined} dimColor={w.lastDelta <= w.window * 0.2}>
429 ▲ +{fmtTokens(w.lastDelta)} last turn
430 </Text>
431 )
432 }
433
434 const context =
435 w.window === 0 ? (
436 model && <Text wrap="truncate">{model}</Text>
437 ) : (
438 <Text wrap="truncate">
439 {model}
440 {model && <Text dimColor>{' │ '}</Text>}
441 <Text color={level.color} bold>
442 {level.icon} {level.word}
443 </Text>
444 <Text color={level.color}>
445 {' '}
446 {known ? `${pct}%` : '—%'}
447 </Text>
448 <Text>
449 {' '}
450 {known ? fmtTokens(w.tokens!) : '—'} / {fmtTokens(w.window)}
451 {' '}
452 </Text>
453 {bars.map((bar, i) => (
454 <Text key={`b${i}`} color={bar.color}>
455 {bar.ch}
456 </Text>
457 ))}
458 <Text dimColor>{'·'.repeat(HISTORY - bars.length)}</Text>
459 {tail && <Text>{' '}</Text>}
460 {tail}
461 </Text>
462 )
463
464 // Lines 2–3: usage limits, /usage-style bars in Claude orange, saved
465 // readings from an earlier session included.
466 const width = Math.max(10, Math.min(30, e.props.bodyColumns - LABEL_WIDTH - 80))
467 const rows = (['five_hour', 'seven_day'] as const).flatMap(kind => {
468 const limit = w.limits.find(l => l.kind === kind)
469 if (!limit) return []
470 // This session's own share of the running turn, the account's change until a rate is learned.
471 const thisDelta = working
472 ? turnDelta(
473 limit.percentUsed,
474 w.turnStartLimits?.[kind],
475 w.turnStartSpent === null ? null : w.spent - w.turnStartSpent,
476 w.rates[kind],
477 )
478 : undefined
479 // Kept while the next turn runs, so the previous answer's cost stays readable.
480 const lastDelta = w.lastLimitDelta[kind]
481 const thisText = thisDelta === undefined ? '' : fmtLimitDelta(thisDelta, 'this')
482 const lastText = lastDelta === undefined ? '' : fmtLimitDelta(lastDelta, 'last')
483 // Counted from the last refresh; the minute timer keeps it current.
484 const at = limit.resetsAt ? fmtReset(limit.resetsAt, kind === 'seven_day') : ''
485 const reset = at ? `in ${fmtLeft(Date.parse(limit.resetsAt!) - w.now)} (${at})` : ''
486 // Nearly gone: the bar and its figure turn red, as the context line does.
487 const fill = limit.percentUsed >= HOT_PCT ? 'red' : 'claude'
488 return [
489 <Text key={kind} wrap="truncate">
490 <Text color="claude" bold>
491 {LIMIT_LABELS[kind].padEnd(LABEL_WIDTH)}
492 </Text>
493 <Text color={fill} backgroundColor={TRACK}>
494 {usageCells(limit.percentUsed, width)}
495 </Text>
496 <Text color={fill} bold>
497 {' '}
498 {fmtPct(limit.percentUsed)}%
499 </Text>
500 {thisText && <Text color="claude">{` ${thisText}`}</Text>}
501 {lastText && <Text color="claude">{` ${lastText}`}</Text>}
502 <Text dimColor>{reset ? ` · resets ${reset}` : ' · window reset'}</Text>
503 </Text>,
504 ]
505 })
506
507 return (
508 <Box flexDirection="column">
509 {context}
510 {rows}
511 </Box>
512 )
513 })
514}
515hooks/weather.ts 207 lines1// Pure helpers: no engine calls, so tests run them directly.
2
3export type Level = { icon: string; word: string; color: string }
4
5const LEVELS: readonly (Level & { below: number })[] = [
6 { below: 25, icon: '☀', word: 'Clear', color: 'yellow' },
7 { below: 50, icon: '☁', word: 'Cloudy', color: 'cyan' },
8 { below: 75, icon: '☂', word: 'Showers', color: 'blue' },
9 { below: 90, icon: '☇', word: 'Storm', color: 'magenta' },
10 { below: Infinity, icon: '↯', word: 'Compact soon', color: 'red' },
11]
12
13export const weatherFor = (pct: number): Level => {
14 const { icon, word, color } = LEVELS.find(level => pct < level.below)!
15 return { icon, word, color }
16}
17
18export const percentOf = (tokens: number, window: number): number =>
19 window > 0 ? Math.round((tokens / window) * 100) : 0
20
21/** 134400 → "134.4k", 200000 → "200k", 1000000 → "1M", 950 → "950". */
22export const fmtTokens = (n: number): string => {
23 const abs = Math.abs(n)
24 const trim = (x: number) => x.toFixed(1).replace(/\.0$/, '')
25 if (abs >= 1e6) return trim(n / 1e6) + 'M'
26 if (abs >= 1e3) return trim(n / 1e3) + 'k'
27 return String(Math.round(n))
28}
29
30/** At most one decimal, no trailing ".0": 4 → "4", 4.25 → "4.3". */
31export const fmtPct = (p: number): string => String(Math.round(p * 10) / 10)
32
33export const HISTORY = 12
34const SPARK = '▁▂▃▄▅▆▇█'
35
36/** One bar per value, scaled to the window (not the max), colored by its own weather. */
37export const sparkline = (values: readonly number[], window: number) =>
38 values.slice(-HISTORY).map(v => {
39 const pct = window > 0 ? (v / window) * 100 : 0
40 const ch = SPARK[Math.max(0, Math.min(7, Math.floor((pct / 100) * 8)))]
41 return { ch, color: weatherFor(pct).color }
42 })
43
44export const pushHistory = (history: readonly number[], tokens: number): number[] =>
45 [...history, tokens].slice(-HISTORY)
46
47const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
48
49/** A /usage-style bar body exactly `width` cells wide, filled from the left in eighths. */
50export const usageCells = (pct: number, width: number): string => {
51 const cells = (Math.max(0, Math.min(100, pct)) / 100) * width
52 const full = Math.floor(cells)
53 const part = full < width ? EIGHTHS[Math.floor((cells - full) * 8)] : ''
54 return ('█'.repeat(full) + part).padEnd(width, ' ')
55}
56
57export const LIMIT_LABELS = { five_hour: 'Session', seven_day: 'Week' } as const
58
59/** A limit used this much draws red. */
60export const HOT_PCT = 90
61
62const MODEL_MAX = 24
63const family = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
64
65/**
66 * A model id as a short name, any family: "claude-opus-5-5[1m]" → "Opus 5.5",
67 * an alias "opus" → "Opus", another id as given (cut to 24); null for none.
68 */
69export const fmtModel = (id: string): string | null => {
70 const raw = id.trim().replace(/\[[^\]]*\]$/, '')
71 const lower = raw.toLowerCase()
72 if (lower === '' || lower === 'default' || lower === '<synthetic>') return null
73 if (/^[a-z]+$/.test(lower)) return family(lower)
74 // claude-opus-5-5, claude-opus-4-20250514 (a date is never the minor), us.anthropic.…-v1:0, …@date.
75 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(lower)
76 if (m) return `${family(m[1]!)} ${m[2]}${m[3] ? '.' + m[3] : ''}`
77 // The older order: claude-3-5-sonnet-20241022.
78 const old = /claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)/.exec(lower)
79 if (old) return `${family(old[3]!)} ${old[1]}${old[2] ? '.' + old[2] : ''}`
80 const last = raw.split('/').at(-1) || raw
81 return last.length > MODEL_MAX ? last.slice(0, MODEL_MAX - 1) + '…' : last
82}
83
84/** A name with no version: an alias ("Opus", "Opusplan") the engine has yet to resolve. */
85export const isAlias = (name: string): boolean => !/\d/.test(name)
86
87/** The same model, an alias ("Opus") matching its full name ("Opus 5.5") too. */
88export const sameModel = (a: string, b: string): boolean =>
89 a === b || (isAlias(a) && b.startsWith(a + ' ')) || (isAlias(b) && a.startsWith(b + ' '))
90
91const pad2 = (n: number) => String(n).padStart(2, '0')
92const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
93
94/** As /usage writes it, in local time: "4:50am", or with the date "Oct 16 at 4am". */
95export const fmtReset = (iso: string, withDate: boolean): string => {
96 const d = new Date(iso)
97 if (Number.isNaN(d.getTime())) return ''
98 const h = d.getHours()
99 const time = `${h % 12 || 12}${d.getMinutes() ? ':' + pad2(d.getMinutes()) : ''}${h < 12 ? 'am' : 'pm'}`
100 return withDate ? `${MONTHS[d.getMonth()]} ${d.getDate()} at ${time}` : time
101}
102
103/** Time left, two units at most: "5d 3h", "2h 10m", "10m", "<1m". */
104export const fmtLeft = (ms: number): string => {
105 const m = Math.floor(ms / 60_000)
106 if (m < 1) return '<1m'
107 const d = Math.floor(m / 1440)
108 const h = Math.floor((m % 1440) / 60)
109 if (d > 0) return h ? `${d}d ${h}h` : `${d}d`
110 return h ? (m % 60 ? `${h}h ${m % 60}m` : `${h}h`) : `${m}m`
111}
112
113/**
114 * Limits saved by an earlier session: a window whose reset time has passed
115 * is fresh again, so it reads 0% with no known reset.
116 */
117export const reviveLimits = <L extends { percentUsed: number; resetsAt?: string }>(limits: readonly L[], now: number): L[] =>
118 limits.map(limit =>
119 limit.resetsAt !== undefined && Date.parse(limit.resetsAt) <= now
120 ? { ...limit, percentUsed: 0, resetsAt: undefined }
121 : limit,
122 )
123
124/**
125 * A limit's change over a turn, as the row writes it. A negative change means
126 * its window reset mid-turn. Readings come in whole points, so a finished turn
127 * that moved nothing reads "<1%"; a running one shows nothing until it moves.
128 */
129export const fmtLimitDelta = (delta: number, span: 'this' | 'last'): string => {
130 if (delta < 0) return `↺ reset ${span} turn`
131 if (delta > 0) return `${span === 'last' ? '▲ ' : ''}+${fmtPct(delta)}% ${span} turn`
132 return span === 'last' ? '▲ <1% last turn' : ''
133}
134
135/**
136 * This session's running spend after a new cost reading: `usd` is the
137 * session's own total, which can start over (a /clear), so `spent` only grows.
138 */
139export const addSpend = (prev: { usd: number | null; spent: number }, usd: number): { usd: number; spent: number } => {
140 if (prev.usd === null) return { usd, spent: prev.spent }
141 return { usd, spent: prev.spent + (usd >= prev.usd ? usd - prev.usd : usd) }
142}
143
144/** Every running session's spend, by instance id, as the store holds it. */
145export type Spend = Record<string, number>
146
147/** What all sessions spent since `snap`; a session missing from `snap` began after it. */
148export const spendSince = (snap: Readonly<Spend>, now: Readonly<Spend>): number =>
149 Object.entries(now).reduce((sum, [id, usd]) => sum + Math.max(0, usd - (snap[id] ?? 0)), 0)
150
151/** One window's learned % per dollar, and where its current measurement began. */
152export type Anchor = { rate: number | null; pct: number; resetsAt?: string; snap: Spend }
153
154const MIN_USD = 1
155const MIN_PCT = 2
156const SAME_RESET_MS = 30 * 60_000
157
158const sameWindow = (a: string | undefined, b: string | undefined) =>
159 a === b || (a !== undefined && b !== undefined && Math.abs(Date.parse(a) - Date.parse(b)) <= SAME_RESET_MS)
160
161/**
162 * Learns how many points of a window one dollar moves, from the account's
163 * reading and every session's spend since the window's first reading. A
164 * long span keeps the whole-point steps from skewing it; a new window keeps
165 * the old rate until it has moved enough to measure again.
166 */
167export const learnRate = (
168 prev: Anchor | undefined,
169 limit: { percentUsed: number; resetsAt?: string },
170 spend: Readonly<Spend>,
171): Anchor => {
172 if (!prev || limit.percentUsed < prev.pct || !sameWindow(prev.resetsAt, limit.resetsAt)) {
173 const anchor: Anchor = { rate: prev?.rate ?? null, pct: limit.percentUsed, snap: { ...spend } }
174 return limit.resetsAt === undefined ? anchor : { ...anchor, resetsAt: limit.resetsAt }
175 }
176 const usd = spendSince(prev.snap, spend)
177 const pct = limit.percentUsed - prev.pct
178 return usd >= MIN_USD && pct >= MIN_PCT ? { ...prev, rate: pct / usd } : prev
179}
180
181/**
182 * A limit's change over a turn: this session's own share when a rate is
183 * known (at least 0.1 once it spent anything), the account's change
184 * otherwise. A window that reset since `startPct` reads negative either way.
185 */
186export const turnDelta = (
187 percentUsed: number,
188 startPct: number | undefined,
189 spent: number | null,
190 rate: number | undefined,
191): number | undefined => {
192 if (startPct !== undefined && percentUsed < startPct) return Math.round((percentUsed - startPct) * 10) / 10
193 if (rate !== undefined && spent !== null) return spent > 0 ? Math.max(0.1, Math.round(spent * rate * 10) / 10) : 0
194 return startPct === undefined ? undefined : Math.round((percentUsed - startPct) * 10) / 10
195}
196
197/** Each limit's % change since `start`, one decimal; kinds missing from `start` are left out. */
198export const limitDeltas = (
199 limits: readonly { kind: string; percentUsed: number }[],
200 start: Readonly<Record<string, number>>,
201): Record<string, number> =>
202 Object.fromEntries(
203 limits
204 .filter(limit => start[limit.kind] !== undefined)
205 .map(limit => [limit.kind, Math.round((limit.percentUsed - (start[limit.kind] ?? 0)) * 10) / 10]),
206 )
207types/index.d.ts 55 lines1/** One usage-limit window as the last API response reported it. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4export type Weather = {
5 /** Context tokens in use; null when unknown (right after compaction). */
6 tokens: number | null
7 /** The model's context window in tokens; 0 before the first measurement. */
8 window: number
9 /** True while `tokens` is the start-of-session local estimate. */
10 isEstimate: boolean
11 /** Context tokens at the end of each of the last 12 main turns, oldest first. */
12 history: number[]
13 /** Tokens the last main turn added (negative when the context shrank). */
14 lastDelta: number | null
15 /** True when a compaction ran within `lastDelta`'s span. */
16 lastDeltaCompacted: boolean
17 /** Context tokens when the running turn began, for the first turn's delta. */
18 turnStartTokens: number | null
19 /** A compaction ran since the last main turn finished. */
20 compacted: boolean
21 limits: Limit[]
22 /** When `limits` were saved by an earlier session; null once live. */
23 limitsAsOf: number | null
24 /** Each limit's % when the running turn began; null between turns. */
25 turnStartLimits: Record<string, number> | null
26 /** Each limit's % change over the last finished turn; negative when its window reset. */
27 lastLimitDelta: Record<string, number>
28 /** This session's id in the store's shared spend list; '' before session.start. */
29 instanceId: string
30 /** The session's own $ total at the last reading; null before the first. */
31 usd: number | null
32 /** $ this session spent since it started here, growing even when `usd` starts over. */
33 spent: number
34 /** `spent` when the running turn began; null between turns. */
35 turnStartSpent: number | null
36 /** Each window's learned % per dollar, shared by every session. */
37 rates: Record<string, number>
38 /** Clock reading of the last refresh. */
39 now: number
40 /** The main loop's model id as the engine gave it; null before the first. */
41 model: string | null
42 /** The effort level of the last main request; null when unknown or the model takes none. */
43 effort: string | null
44 /** The model's name before a switch, shown until a whole turn ran after it. */
45 switchedFrom: string | null
46 /** A turn has started since the switch, so its end clears the note. */
47 switchSeen: boolean
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 'usage-forecast': { weather: Weather }
53 }
54}
55