SLOPSHOPPER

usage-forecast

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

newbandtimer
v0.1.0NOASSERTIONupdated 2026-10-20thangdo19/claude-code-mods/usage-forecast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-forecast
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ◆ Opus 5.5 │ ☁ Cloudy 49% 97.4k / 200k ▄··········· ▲ +0 last turn Session ███▍ 31% ▲ <1% last turn · resets in NaNm (9:53am) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Opus 5.5 │ ☁ Cloudy 49% 97.4k / 200k ▄··········· ▲ +0 last turn Session ███▍ 31% ▲ <1% last turn · resets in NaNm (9:53am)
README

usage-forecast

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

The usage-forecast band above the Claude Code prompt

How to read it

Line 1: the context window (how much of Claude's memory this conversation fills)

PartMeaning
◆ Opus 5.5 highThe model answering you, and its effort (how hard it thinks). Effort shows after Claude's first reply.
☀ ClearThe weather for how full it is (see below).
8% 81.8k / 1MTokens 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 turnWhat your last turn added. ▼ −N compacted means a compaction shrank it.
WeatherFull
☀ Clearunder 25%
☁ Cloudyunder 50%
☂ Showersunder 75%
☇ Stormunder 90%
↯ Compact soon90% and up

Lines 2–3: your usage limits (the same bars as /usage)

PartMeaning
Session / WeekYour 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 turnWhat your last turn used.
+0.1% this turnWhat 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.

Install

claude plugin install usage-forecast --marketplace thangdo19/claude-code-mods

More ways (inside Claude Code, older versions): see the main README.

Develop

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.

Limits

  • The limit bars need a Claude subscription login. With an API key they stay hidden.
  • Limits come in whole points, so a small turn often reads <1%.
  • It only counts Claude Code on this computer. Using claude.ai or another computer at the same time makes the per-turn figures read a little high.

Details

  • Updates: after every reply from Claude, even mid-turn. The countdown ticks once a minute while idle. The chart and "last turn" figures move once per finished turn of yours. Helper agents (subagents) and turns cancelled before any reply don't count.
  • Several sessions at once: the % figures show this session's share, not the whole account's. The mod learns how many % each $1 of usage costs, then multiplies by what this session spent. Until it has learned that (each limit needs $1 spent and 2 points moved), it shows the account's change instead.
  • Messages you may see:
  • 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.
  • New session: the bars show the last readings saved by an earlier session until Claude replies.
  • /logout, or switching to an API key, hides the bars and deletes the saved readings.
Source 3 files
hooks/register.tsx 515 lines
1import { 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}
515
hooks/weather.ts 207 lines
1// 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  )
207
types/index.d.ts 55 lines
1/** 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