SLOPSHOPPER

limits

Never get surprised by a rate limit again. An always-on band above the prompt shows your 5-hour and weekly Claude usage with live bars, reset countdowns and…

newbandtoaststatusnetworktimer
★ 2v0.2.2MITupdated 2026-10-15Ryan-LT/claude-mods/limits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits
› 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 5-hour ━━────── 31% ◷ NaNd Context ━━━━──── 49% 97.4k/200k [ ⟳ now ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5-hour ━━────── 31% ◷ NaNd Context ━━━━──── 49% 97.4k/200k [ ⟳ now ]
README

limits

See your Claude usage limits before they bite. An always-on band above the Claude Code prompt:

limits band

  • 5-hour and Weekly: a bar, the percent used, and a countdown to the reset. The tick on each bar marks where an even pace would be by now.
  • Pace warning: the percent turns red when your current rate would use up the limit before it resets. You find out on Tuesday, not when you're blocked on Thursday.
  • Context: how full this chat's context window is (89.1k/1M).
  • Alerts: a toast at 75%, 90% and 100%.
  • The real numbers: every 2 minutes (or when you press refresh) it asks Anthropic for the same figures claude.ai's usage page shows, using your Claude login. No model request, so no tokens, and all open sessions share one ask, so 10 open chats still make only one request.
  • Works in the terminal and the desktop app.

Install

Run this in a terminal claude session:

/plugin install limits --marketplace Ryan-LT/claude-mods

Answer y to add the marketplace, then choose the user scope.

Develop

claude --plugin-dir ./limits
claude plugin test ./limits
Source 2 files
hooks/register.tsx 514 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionRateLimit } from 'claude-code'
3
4import type { Context, Limit, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'limits', key: 'snapshot' } as const, null)
7const now = atom({ plugin: 'limits', key: 'now' } as const, 0)
8const alerted = atom({ plugin: 'limits', key: 'alerted' } as const, {})
9const refreshing = atom({ plugin: 'limits', key: 'refreshing' } as const, false)
10const context = atom({ plugin: 'limits', key: 'context' } as const, null)
11
12const HOUR = 3_600_000
13const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 168 * HOUR }
14const LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly', spend_limit: '$' }
15const LONG_LABEL: Record<string, string> = {
16  five_hour: '5-hour',
17  seven_day: 'Weekly',
18  spend_limit: 'Spend',
19}
20const ALERT_AT = [75, 90, 100]
21const MINUTE = 60_000
22/**
23 * Auto refresh runs once per slot of this length, on the clock (:00, :02, :04 …), so its timing is
24 * steady; every open session shares one slot, so only one of them asks.
25 */
26const SLOT = 2 * MINUTE
27/** A main-thread turn this old is taken as over, should its end never have been heard. */
28const BUSY_MAX = 10 * MINUTE
29/** The refresh button's own floor between presses. */
30const PRESS_GAP = 30_000
31const STORE_LATEST = 'latest'
32const STORE_PINGED = 'lastPingAt'
33const STORE_SLOT = 'lastSlot'
34
35// ---------- formatting ----------
36
37const fmtTokens = (n: number): string => {
38  const short = (v: number, unit: string) => `${Number.isInteger(v) ? v : v.toFixed(1)}${unit}`
39  if (n >= 1_000_000) return short(Math.round(n / 100_000) / 10, 'M')
40  if (n >= 1_000) return short(Math.round(n / 100) / 10, 'k')
41  return String(n)
42}
43
44const fmtSpan = (ms: number): string => {
45  if (ms <= 0) return 'now'
46  const mins = Math.round(ms / 60_000)
47  if (mins < 60) return `${mins}m`
48  const hours = Math.floor(mins / 60)
49  if (hours < 24) return mins % 60 ? `${hours}h${mins % 60}m` : `${hours}h`
50  const days = Math.floor(hours / 24)
51  return hours % 24 ? `${days}d${hours % 24}h` : `${days}d`
52}
53
54const fmtPct = (p: number | undefined): string =>
55  p === undefined ? '—' : `${p < 10 && p % 1 !== 0 ? p.toFixed(1) : Math.round(p)}%`
56
57const tone = (p: number | undefined): string =>
58  p === undefined ? 'inactive' : p >= 85 ? 'error' : p >= 60 ? 'warning' : 'success'
59
60const labelOf = (kind: string): string => LABEL[kind] ?? kind.replace(/_/g, ' ').toUpperCase()
61const longLabelOf = (kind: string): string => LONG_LABEL[kind] ?? kind.replace(/_/g, ' ')
62
63const ORDER = ['five_hour', 'seven_day', 'spend_limit']
64const sortLimits = (limits: Limit[]): Limit[] =>
65  [...limits].sort((a, b) => {
66    const ia = ORDER.indexOf(a.kind)
67    const ib = ORDER.indexOf(b.kind)
68    return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib)
69  })
70
71/** How far into the window we are versus how much is used, and where that trends. */
72const paceOf = (limit: Limit, at: number) => {
73  const length = WINDOW_MS[limit.kind]
74  if (!length || !limit.resetsAt) return null
75  const left = Date.parse(limit.resetsAt) - at
76  if (!Number.isFinite(left)) return null
77  const elapsed = Math.min(length, Math.max(0, length - left))
78  const elapsedPct = (elapsed / length) * 100
79  if (elapsed < length * 0.02) return { elapsedPct, delta: 0, runsOutIn: undefined }
80  const rate = limit.percentUsed / elapsed // % per ms
81  const runsOutIn = rate > 0 && limit.percentUsed < 100 ? (100 - limit.percentUsed) / rate : undefined
82  return {
83    elapsedPct,
84    delta: limit.percentUsed - elapsedPct,
85    runsOutIn: runsOutIn !== undefined && runsOutIn < left ? runsOutIn : undefined,
86  }
87}
88
89// ---------- state ----------
90
91/** `isLive`: the figures came from a response just now, so they stamp `checkedAt`. */
92const toSnapshot = (rateLimits: SessionRateLimit[], at: number, isLive = false): Snapshot => ({
93  limits: rateLimits.map(r => ({
94    kind: r.kind,
95    percentUsed: r.percentUsed,
96    resetsAt: r.resetsAt,
97    ...(isLive ? { checkedAt: at } : {}),
98  })),
99  at,
100})
101
102/** A window whose reset time has passed starts over: show it empty, not stale. */
103const current = (l: Limit, at: number): Limit =>
104  l.resetsAt && Date.parse(l.resetsAt) <= at ? { kind: l.kind, percentUsed: 0 } : l
105
106const resetMs = (l: Limit) => (l.resetsAt ? Date.parse(l.resetsAt) : 0)
107
108/**
109 * Which of two readings of one window is current. The later window wins (its reset is later).
110 * Within one window, the latest live reading wins whatever its figure: a plan upgrade drops the
111 * percent mid-window. A figure that may be replayed (one a chat saw hours ago) never beats a live
112 * one; between two such figures the higher wins, since usage inside a window only climbs.
113 */
114const newer = (a: Limit, b: Limit): Limit => {
115  const gap = resetMs(b) - resetMs(a)
116  if (Math.abs(gap) > 30 * MINUTE) return gap > 0 ? b : a
117  const keep = (l: Limit) => ({ ...l, resetsAt: l.resetsAt ?? (l === a ? b : a).resetsAt })
118  if (a.checkedAt !== undefined || b.checkedAt !== undefined) {
119    return (b.checkedAt ?? -1) >= (a.checkedAt ?? -1) ? keep(b) : keep(a)
120  }
121  return b.percentUsed >= a.percentUsed ? keep(b) : a
122}
123
124/**
125 * One response may report only some windows (the weekly one, say, and not the 5-hour), so a
126 * reading updates the windows it names and keeps the others, unless their reset has passed.
127 */
128const merge = (prev: Snapshot | null | undefined, next: Snapshot): Snapshot => {
129  const byKind = new Map((prev?.limits ?? []).map(l => [l.kind, l]))
130  for (const l of next.limits) {
131    const had = byKind.get(l.kind)
132    byKind.set(l.kind, had ? newer(had, l) : l)
133  }
134  const limits = [...byKind.values()].filter(
135    l => next.limits.some(n => n.kind === l.kind) || !l.resetsAt || Date.parse(l.resetsAt) > next.at,
136  )
137  return { limits, at: Math.max(prev?.at ?? 0, next.at) }
138}
139
140const maybeAlert = async ($: EngineInterface, s: Snapshot) => {
141  const seen = await read($, alerted)
142  const next: Record<string, number> = { ...seen }
143  for (const l of s.limits) {
144    const crossed = ALERT_AT.filter(level => l.percentUsed >= level).pop() ?? 0
145    if (crossed > (seen[l.kind] ?? 0)) {
146      $.ui.toast(`${longLabelOf(l.kind)} limit at ${fmtPct(l.percentUsed)}`, { timeoutMs: 6000 })
147    }
148    next[l.kind] = crossed
149  }
150  await update($, alerted, () => next)
151}
152
153/** This session's context window: its own, never shared. */
154const applyContext = async ($: EngineInterface, c: { tokens?: number; window: number; percent?: number }) => {
155  const value: Context = { tokens: c.tokens, window: c.window, percent: c.percent }
156  await update($, context, () => value)
157}
158
159/** Show a reading here, and hand it to every other open session through the store. */
160const apply = async ($: EngineInterface, reading: Snapshot, isShared = true) => {
161  if (reading.limits.length === 0) return
162  const s = merge(await read($, snapshot), reading)
163  await update($, snapshot, () => s)
164  await update($, now, () => Date.now())
165  await maybeAlert($, s)
166  if (isShared) {
167    const latest = (await $.store.get(STORE_LATEST)) as Snapshot | undefined
168    await $.store.set(STORE_LATEST, merge(latest, reading))
169  }
170}
171
172/** Takes up a newer reading another session made. */
173const pullShared = async ($: EngineInterface) => {
174  const latest = (await $.store.get(STORE_LATEST)) as Snapshot | undefined
175  const mine = await read($, snapshot)
176  if (latest?.limits?.length && (!mine || JSON.stringify(merge(mine, latest).limits) !== JSON.stringify(mine.limits) || mine.at < latest.at)) {
177    await apply($, latest, false)
178  }
179}
180
181const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
182const WINDOWS = ['five_hour', 'seven_day']
183
184type UsageWindow = { utilization?: number | null; resets_at?: string | null } | null
185
186/**
187 * The figures claude.ai's usage page shows, asked of Anthropic with this session's login (the engine
188 * sets the credential; the mod never sees it). Null with no claude.ai login: an API key or a gateway.
189 */
190const fetchUsage = async ($: EngineInterface): Promise<SessionRateLimit[] | null> => {
191  const auth = await $.session.authorize()
192  if (auth?.kind !== 'bearer') return null
193  const res = await $.http.fetch(USAGE_URL, { headers: { 'anthropic-beta': 'oauth-2025-04-20' }, auth: auth.handle })
194  if (!res.ok) return null
195  const body = JSON.parse(res.text) as Record<string, UsageWindow>
196  return WINDOWS.flatMap(kind => {
197    const w = body[kind]
198    if (typeof w?.utilization !== 'number') return []
199    const resetsAt = w.resets_at ? new Date(w.resets_at).toISOString() : undefined
200    return [{ kind, percentUsed: Math.round(w.utilization * 10) / 10, ...(resetsAt ? { resetsAt } : {}) }]
201  })
202}
203
204/**
205 * Asks Anthropic for the real figures: no model request, so no tokens, and the slot and press gaps
206 * keep it to one ask per two minutes across every open session.
207 */
208const refresh = async ($: EngineInterface, why: 'press' | 'auto') => {
209  if (await read($, refreshing)) return
210  const at = await $.clock.now()
211  if (why === 'press') {
212    const lastPing = Number((await $.store.get(STORE_PINGED)) ?? 0)
213    if (at - lastPing < PRESS_GAP) return void (await pullShared($))
214  } else {
215    // One ask per clock slot across every session: the first to reach the slot takes it.
216    const slot = Math.floor(at / SLOT)
217    if (Number((await $.store.get(STORE_SLOT)) ?? -1) >= slot) return void (await pullShared($))
218    await $.store.set(STORE_SLOT, slot)
219  }
220  await $.store.set(STORE_PINGED, at)
221  await update($, refreshing, () => true)
222  try {
223    await applyContext($, (await $.session.usage()).context)
224    const limits = await fetchUsage($)
225    if (limits) await apply($, toSnapshot(limits, await $.clock.now(), true))
226  } catch {
227    // offline or refused: the last reading stays, with its age on the button
228  } finally {
229    await update($, refreshing, () => false)
230  }
231}
232
233/** When the running main-thread turn began, or 0: figures that arrive meanwhile ride on its replies. */
234let busySince = 0
235
236const tick = async ($: EngineInterface) => {
237  const at = await $.clock.now()
238  await update($, now, () => at)
239  await pullShared($)
240  // Replies bring figures too, but an idle chat can replay old ones: Anthropic's own answer settles it.
241  await refresh($, 'auto')
242}
243
244/**
245 * The 15-second ticker, started by whichever hook runs first in this module's life: a reload
246 * in an open chat raises no session.start, and without this the bar would never refresh there.
247 */
248let isTicking = false
249
250const ensureTicker = ($: EngineInterface) => {
251  if (isTicking) return
252  isTicking = true
253  $.clock.after(50, () => void tick($).catch(() => {}))
254  $.clock.every(15_000, () => void tick($).catch(() => {}))
255}
256
257// ---------- drawing ----------
258
259/** One limit, reduced to what the band shows. */
260type Meter = {
261  label: string
262  pct: number
263  color: string
264  reset?: string
265  /** Where "now" sits in the window, 0–1: fill past it means ahead of pace. */
266  elapsed?: number
267  runsOut?: string
268  /** Plain grey text after the percent, no icon (the context's token count). */
269  detail?: string
270  alt: string
271}
272
273const HEX: Record<string, string> = {
274  success: '#3fb950',
275  warning: '#d29922',
276  error: '#f85149',
277}
278// Mid-tone grey reads on both light and dark themes (the surface's theme is not exposed).
279const GREY = '#8c959f'
280const FONT = 12.5
281const CH = FONT * 0.62 // monospace advance; every run is pinned to it with textLength
282const BAR_W = 56
283const BAR_H = 4
284const GAP = 22
285const ICON = 11
286
287const toMeter = (raw: Limit, at: number): Meter => {
288  const l = current(raw, at)
289  const pace = paceOf(l, at)
290  const runsOut = pace?.runsOutIn !== undefined ? fmtSpan(pace.runsOutIn) : undefined
291  // On track to run out before the reset: the bar and percent go red, no extra text.
292  const level = runsOut ? 'error' : tone(l.percentUsed)
293  const reset = l.resetsAt ? fmtSpan(Date.parse(l.resetsAt) - at) : undefined
294  return {
295    label: labelOf(l.kind),
296    pct: l.percentUsed,
297    color: level,
298    reset,
299    elapsed: pace ? pace.elapsedPct / 100 : undefined,
300    runsOut,
301    alt:
302      `${longLabelOf(l.kind)} limit ${fmtPct(l.percentUsed)} used` +
303      (reset ? `, resets in ${reset}` : '') +
304      (pace ? `, ${Math.round(pace.elapsedPct)}% of window elapsed` : '') +
305      (runsOut ? `, runs out in about ${runsOut}` : ''),
306  }
307}
308
309const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
310
311/** One run of text at x, exactly `text.length * CH` wide whatever font the surface picks. */
312const run = (x: number, y: number, text: string, fill: string, bold = false) =>
313  `<text x="${x.toFixed(1)}" y="${y}" fill="${fill}"${bold ? ' font-weight="600"' : ''} ` +
314  `textLength="${(text.length * CH).toFixed(1)}" lengthAdjust="spacingAndGlyphs">${esc(text)}</text>`
315
316/** A small clock: "resets in". */
317const clockIcon = (x: number, cy: number, color: string) => {
318  const r = ICON / 2 - 0.75
319  const cx = x + ICON / 2
320  return (
321    `<g fill="none" stroke="${color}" stroke-width="1.3" stroke-linecap="round">` +
322    `<circle cx="${cx.toFixed(1)}" cy="${cy}" r="${r}"/>` +
323    `<path d="M${cx.toFixed(1)} ${cy - r + 2}V${cy}l${(r * 0.55).toFixed(1)} ${(r * 0.45).toFixed(1)}"/></g>`
324  )
325}
326
327const bandSvg = (meters: Meter[]) => {
328  const H = 18
329  const y = 13
330  const by = (H - BAR_H) / 2
331  let x = 0
332  let body = ''
333  meters.forEach((m, i) => {
334    if (i > 0) x += GAP
335    const color = HEX[m.color] ?? GREY
336    body += run(x, y, m.label, GREY)
337    x += m.label.length * CH + 6
338    const fill = Math.min(1, Math.max(0, m.pct / 100)) * BAR_W
339    body += `<rect x="${x.toFixed(1)}" y="${by}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${GREY}" fill-opacity=".25"/>`
340    if (fill > 0) {
341      body += `<rect x="${x.toFixed(1)}" y="${by}" width="${Math.max(BAR_H, fill).toFixed(1)}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>`
342    }
343    if (m.elapsed !== undefined) {
344      const tx = x + Math.min(BAR_W - 1, Math.max(0, m.elapsed * BAR_W))
345      body += `<rect x="${tx.toFixed(1)}" y="${by - 2}" width="1" height="${BAR_H + 4}" fill="${GREY}"/>`
346    }
347    x += BAR_W + 6
348    const pct = fmtPct(m.pct)
349    body += run(x, y, pct, color, true)
350    x += pct.length * CH
351    if (m.detail) {
352      body += run(x + CH, y, m.detail, GREY)
353      x += (m.detail.length + 1) * CH
354    }
355    if (m.reset) {
356      x += 8
357      body += clockIcon(x, H / 2, GREY)
358      x += ICON + 4
359      body += run(x, y, m.reset, GREY)
360      x += m.reset.length * CH
361    }
362  })
363  const width = Math.ceil(x + 2)
364  return {
365    source:
366      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}">` +
367      `<style>text{font-size:${FONT}px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre}</style>${body}</svg>`,
368    width,
369    height: H,
370  }
371}
372
373/** Terminal bar: thin line, with a tick at "now" in the window. */
374const barRuns = (m: Meter, width = 8): { text: string; color: string }[] => {
375  const filled = Math.min(width, Math.max(m.pct > 0 ? 1 : 0, Math.round((m.pct / 100) * width)))
376  const tick = m.elapsed === undefined ? -1 : Math.min(width - 1, Math.floor(m.elapsed * width))
377  const runs: { text: string; color: string }[] = []
378  for (let i = 0; i < width; i++) {
379    const [ch, color] = i === tick ? ['┃', 'inactive'] : i < filled ? ['━', m.color] : ['─', 'inactive']
380    const prev = runs[runs.length - 1]
381    if (prev && prev.color === color && i !== tick && i - 1 !== tick) prev.text += ch
382    else runs.push({ text: ch, color })
383  }
384  return runs
385}
386
387// ---------- hooks ----------
388
389export const register: Register = on => {
390  on('session.start', async ($, e, next) => {
391    const ran = await next(e)
392    $.ui.status(undefined)
393    try {
394      const usage = await $.session.usage()
395      await applyContext($, usage.context)
396      await apply($, toSnapshot(usage.rateLimits, Date.now()))
397    } catch {
398      // usage not available yet; session.measure will fill it in
399    }
400    await update($, refreshing, () => false)
401    ensureTicker($)
402
403    return ran
404  })
405
406  on('session.measure', async ($, e, next) => {
407    ensureTicker($)
408    if (e.changed.includes('context')) await applyContext($, e.context)
409    // Mid-turn, the figures ride on the reply that just came in; idle, they may be an old one replayed.
410    const isLive = busySince > 0 && Date.now() - busySince < BUSY_MAX
411    if (e.changed.includes('rateLimits')) await apply($, toSnapshot(e.rateLimits, Date.now(), isLive))
412
413    return next(e)
414  })
415
416  on('turn.start', ($, e, next) => {
417    ensureTicker($)
418    busySince = Date.now()
419
420    return next(e)
421  })
422
423  on('turn.complete', ($, e, next) => {
424    if (!e.agentId) busySince = 0
425
426    return next(e)
427  })
428
429  on('session.end', async ($, e, next) => {
430    if (e.reason === 'clear') {
431      await update($, alerted, () => ({}))
432      await update($, context, () => null)
433    }
434
435    return next(e)
436  })
437
438  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
439    ensureTicker($)
440    // Another mod's band beneath (quick replies) draws under this one.
441    const below = await next(e)
442    if (e.props.hasSurvey) return below
443    const withBelow = (tree: RenderElement) => {
444      if (below.type === 'engine') return tree
445      const { Box } = $.ui.resolve(e)
446      return (
447        <Box flexDirection="column" rowGap={1}>
448          {tree}
449          {below}
450        </Box>
451      )
452    }
453
454    const s = await read($, snapshot)
455    const at = (await read($, now)) || Date.now()
456    const ctx = await read($, context)
457    const meters = sortLimits(s?.limits ?? []).map(l => toMeter(l, at))
458    if (ctx?.percent !== undefined && ctx.tokens !== undefined) {
459      const detail = `${fmtTokens(ctx.tokens)}/${fmtTokens(ctx.window)}`
460      meters.push({
461        label: 'Context',
462        pct: ctx.percent,
463        color: tone(ctx.percent),
464        detail,
465        alt: `Context ${detail} tokens, ${fmtPct(ctx.percent)} of the window`,
466      })
467    }
468    const isRefreshing = await read($, refreshing)
469    const age = s?.at ? at - s.at : undefined
470    const ageText = isRefreshing ? '…' : age === undefined || age < MINUTE ? 'now' : fmtSpan(age)
471    type Resolved = typeof $.ui.resolve extends (e: never) => infer T ? T : never
472    const refreshButton = (Button: Resolved['Button']) => (
473      <Button key="refresh" label={`⟳ ${ageText}`} onPress={() => void refresh($, 'press')} />
474    )
475
476    if (e.surface !== 'terminal') {
477      const { Box, Button, Svg, Text } = $.ui.resolve(e)
478      if (meters.length === 0) return withBelow(<Text dimColor>Limits show after the first reply</Text>)
479      const band = bandSvg(meters)
480      return withBelow(
481        <Box flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={2}>
482          <Svg
483            source={band.source}
484            width={band.width}
485            height={band.height}
486            alt={meters.map(m => m.alt).join('; ')}
487          />
488          {refreshButton(Button)}
489        </Box>,
490      )
491    }
492
493    const { Box, Button, Text } = $.ui.resolve(e)
494    if (meters.length === 0) return withBelow(<Text dimColor>Limits show after the first reply</Text>)
495
496    return withBelow(
497      <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
498        {meters.map(m => (
499          <Box flexDirection="row">
500            <Text dimColor>{m.label} </Text>
501            {barRuns(m).map(r => (
502              <Text color={r.color}>{r.text}</Text>
503            ))}
504            <Text color={m.color} bold> {fmtPct(m.pct)}</Text>
505            {m.detail ? <Text dimColor> {m.detail}</Text> : null}
506            {m.reset ? <Text dimColor> ◷ {m.reset}</Text> : null}
507          </Box>
508        ))}
509        {refreshButton(Button)}
510      </Box>,
511    )
512  })
513}
514
types/index.d.ts 21 lines
1/** `checkedAt` (ms): when a live response reported it; absent when it may be an old figure replayed. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string; checkedAt?: number }
3
4/** The limits as one API response reported them, and when (ms). */
5export type Snapshot = { limits: Limit[]; at: number }
6
7/** This session's context window, as the last response filled it. */
8export type Context = { tokens?: number; window: number; percent?: number }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'limits': {
13      snapshot: Snapshot | null
14      now: number
15      alerted: Record<string, number>
16      refreshing: boolean
17      context: Context | null
18    }
19  }
20}
21