SLOPSHOPPER

limit-radar

Your 5h/7d subscription limits, a burn-pace forecast and a cold-cache warning, shown above the prompt only when there is something to act on.

newbandcommandtoaststatustimer
v0.2.0MITupdated 2026-10-06Jvrd97/claude-limit-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-radar
› 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 › /limits ⎿ limit-radar: 5h: ━━────── 31% ⎿ limit-radar: context: ━━━━──── 49% (97k tokens) ⎿ limit-radar: session cost: $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

limit-radar

A Claude Code mod that speaks up above the prompt only when your subscription limits need attention: a window is nearly used up, this pace runs out before the reset, or the next prompt lands on a cold cache.

7d 88% · resets in 3d 6h · at this pace runs out in ~2h — slow down or switch to Sonnet  ×
Cache likely cold · idle 1h 12m · next prompt re-reads ~118k tokens · /clear if switching tasks

The rest of the time the band draws nothing. With display set to band it shows the bars all the time:

5h ━━━━━─── 60% ↻49m  7d ━━━━━━━─ 88% ↻3d 6h  ctx 59%  $1.24  ×

Why

Limits are the loudest complaint about Claude Code, everywhere people talk about it:

/usage and the status line already show a percentage. What they don't tell you:

  • Will it last? 61% means nothing without a pace. The mod measures your burn over the last 30 minutes and says whether you hit 100% before the reset.
  • Is the cache cold? After a long pause the prompt cache expires. The next prompt then re-reads the whole context at full price, and that is how a "Hello" eats 4% of a window. The mod tells you before you send it, while /clear is still an option.
  • When should I care? It toasts once per window when usage crosses 80% and 95%, instead of you watching a number. A new session in the same window does not toast again.

What it does

WhereWhat
Band above the prompt (auto, the default)One line per thing to act on: a window past the first threshold, a pace that runs out before the reset, a cold cache. Nothing otherwise
Band above the prompt (band)Thin 5h and 7d bars, time to reset, context fill, session cost when above $0, plus the pace and cold-cache lines
ToastOnce per window and threshold (default 80% and 95%), across sessions
/limitsThe full report: every window, its pace in %/h, context in tokens, cost and cache state. It also brings back what you closed with ×
Status line (status or both)5h 61% · 7d 12% · ctx 59% · $1.24

Color marks only what needs attention: yellow from 70%, red from 90% or when the pace runs out first. Below 80 columns the lines get shorter: the bars drop the reset time and the cost, and an alert keeps only its most urgent time and a short advice.

× closes what the band shows. In auto it closes the current lines only: a worse state (the next threshold, a pace that starts to run out, a new cold-cache pause) shows again. In band it hides the band for the session.

Why the band is often empty

Most of the time nothing needs doing, and many people already have the percentages in a custom status line. So auto draws a line only when there is an action behind it: slow down, switch to a cheaper model, or /clear before the next prompt. If you want the numbers in view all the time, set display to band; for nothing above the prompt at all, off.

Install

You need Claude Code with mods (function hooks); tested on 2.1.285, 2.1.291 and 2.1.292. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

From the marketplace in this repo:

/plugin marketplace add Jvrd97/claude-limit-radar
/plugin install limit-radar@limit-radar

Or straight from disk, for one session:

git clone https://github.com/Jvrd97/claude-limit-radar.git
claude --plugin-dir ./claude-limit-radar

Settings

Change them in /config under the plugin, or in settings.json under pluginConfigs:

FieldDefaultMeaning
displayautoauto: a line only when there is something to act on. band: the bars always. status: one line in the status line, no band. both: band and status. off: only toasts and /limits
warnAt80,95Thresholds in percent, comma-separated. Each toasts once per window; in auto the first one also shows the line
cacheTtlMinutes60Idle time after which the cache counts as cold; 0 turns the warning off

The prompt cache lifetime depends on your plan and on what Anthropic ships, so the mod does not know it for sure. Set cacheTtlMinutes to 5 if you pay per token through the API.

How it works

  • session.measure fires when the context fill, a rate-limit window or the cost changes. The mod keeps up to 120 readings per session and drops a window's readings when the window resets.
  • The pace is the change between the first and last reading of the last 30 minutes. Less than 10 minutes of history counts as no trend, so one heavy prompt does not raise an alarm.
  • turn.complete stamps when the last turn ended, and the cold-cache check compares that with now.
  • Readings, the pace and what you closed stay in the session's own state.
  • Which thresholds already toasted is kept in the plugin's store ($.store, a JSON file of the plugin's own under your Claude Code config directory), one key per window kind and reset time, such as warned:seven_day:2026-10-10T00:00:00Z. Keys of windows that have reset are deleted at session start. A window without a reset time is remembered for the session only.
  • The mod sends nothing anywhere and writes no other files.

What it can't do: raise your limits, or know how Anthropic counts a token against a window. It reads the same figures the status line gets. Two sessions that cross a threshold at the same moment may both toast.

Develop

Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:

tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

По-русски

Мод пишет строку над полем ввода, только когда с лимитами подписки надо что-то делать. В остальное время строка пустая:

  • Окно почти выбрано. С 80% строка говорит, сколько использовано и когда сброс.
  • Не хватит до сброса. Мод считает темп расхода за последние 30 минут и советует сбавить темп или перейти на Sonnet.
  • Остыл кэш. После долгой паузы следующий промпт заново читает весь контекст по полной цене, поэтому мод советует /clear до отправки.

На 80% и 95% приходит одно уведомление на окно, и новая сессия в том же окне его не повторяет. Полоски всё время — display: band. Команда /limits печатает полный отчёт. Мод ничего не отправляет в сеть.

License

MIT

Source 3 files
hooks/register.tsx 251 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { LimitSnapshot, LimitWindow } from '../types'
5import {
6  addSamples,
7  alertsOf,
8  barCells,
9  cacheOf,
10  costText,
11  displayOf,
12  expiredWarnKeys,
13  labelOf,
14  newlyCrossed,
15  parseThresholds,
16  report,
17  resetIn,
18  shortLine,
19  toneOf,
20  warnKeyOf,
21  warnedIn,
22} from './logic'
23import type { Alert } from './logic'
24
25const COMMAND = 'limits'
26const TICK_MS = 60_000
27const WARN_TOAST_MS = 8_000
28const NARROW_COLUMNS = 80
29const CLOSE_LABEL = '×'
30
31const snapshot = atom({ plugin: 'limit-radar', key: 'snapshot' } as const, null)
32const samples = atom({ plugin: 'limit-radar', key: 'samples' } as const, [])
33const lastTurnEndAt = atom({ plugin: 'limit-radar', key: 'lastTurnEndAt' } as const, null)
34const tick = atom({ plugin: 'limit-radar', key: 'tick' } as const, 0)
35const isHidden = atom({ plugin: 'limit-radar', key: 'isHidden' } as const, false)
36const dismissed = atom({ plugin: 'limit-radar', key: 'dismissed' } as const, [])
37const sessionWarned = atom({ plugin: 'limit-radar', key: 'sessionWarned' } as const, {})
38
39type Figures = { context: SessionContextUsage; rateLimits: readonly SessionRateLimit[]; cost?: SessionCost }
40
41const snapshotOf = (figures: Figures, at: number): LimitSnapshot => {
42  const next: LimitSnapshot = {
43    at,
44    windows: figures.rateLimits.map(limit =>
45      limit.resetsAt === undefined
46        ? { kind: limit.kind, percentUsed: limit.percentUsed }
47        : { kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt },
48    ),
49  }
50
51  if (figures.context.percent !== undefined) {
52    next.contextPercent = figures.context.percent
53  }
54
55  if (figures.context.tokens !== undefined) {
56    next.contextTokens = figures.context.tokens
57  }
58
59  if (figures.cost !== undefined) {
60    next.costUsd = figures.cost.usd
61  }
62
63  return next
64}
65
66type Settings = { showsStatus: boolean; thresholds: readonly number[] }
67
68/**
69 * Toasts each threshold once per window. A window with a reset time is
70 * remembered in `$.store`, so the next session in the same window stays quiet;
71 * one without a reset time can only be remembered for this session.
72 */
73async function warnOnce($: EngineInterface, window: LimitWindow, thresholds: readonly number[], now: number): Promise<void> {
74  const key = warnKeyOf(window)
75  const isStored = window.resetsAt !== undefined
76  const already = isStored ? warnedIn(await $.store.get(key)) : ((await read($, sessionWarned))[key] ?? [])
77  const fresh = newlyCrossed(window, thresholds, already)
78
79  if (fresh.length === 0) {
80    return
81  }
82
83  const all = [...already, ...fresh]
84
85  if (isStored) {
86    await $.store.set(key, all)
87  } else {
88    await update($, sessionWarned, map => ({ ...map, [key]: all }))
89  }
90
91  const reset = resetIn(window, now)
92  const tail = reset === null ? '' : `, resets in ${reset}`
93  $.ui.toast(`${labelOf(window.kind)} limit at ${window.percentUsed}%${tail}`, { timeoutMs: WARN_TOAST_MS })
94}
95
96async function pruneWarned($: EngineInterface, now: number): Promise<void> {
97  for (const key of expiredWarnKeys(await $.store.keys(), now)) {
98    await $.store.delete(key)
99  }
100}
101
102async function record($: EngineInterface, figures: Figures, settings: Settings): Promise<void> {
103  const now = await $.clock.now()
104  const next = snapshotOf(figures, now)
105
106  await update($, snapshot, () => next)
107  await update($, samples, list => addSamples(list, next))
108
109  if (settings.showsStatus) {
110    $.ui.status(shortLine(next))
111  }
112
113  const lowest = settings.thresholds[0]
114
115  if (lowest === undefined) {
116    return
117  }
118
119  for (const window of next.windows) {
120    if (window.percentUsed >= lowest) {
121      await warnOnce($, window, settings.thresholds, now)
122    }
123  }
124}
125
126export const register: Register = (on, options) => {
127  const display = displayOf(options.display)
128  const thresholds = parseThresholds(String(options.warnAt ?? '80,95'))
129  const ttlMinutes = Number(options.cacheTtlMinutes ?? 60)
130  const isAlwaysOn = display === 'band' || display === 'both'
131  const drawsBand = isAlwaysOn || display === 'auto'
132  const settings: Settings = { showsStatus: display === 'status' || display === 'both', thresholds }
133
134  on('session.start', async ($, e, next) => {
135    await $.command.register({ name: COMMAND, description: 'Show subscription limits, burn pace, context and cache state' })
136    await pruneWarned($, await $.clock.now())
137    await record($, await $.session.usage(), settings)
138    $.clock.every(TICK_MS, () => {
139      void update($, tick, n => n + 1)
140    })
141
142    return next(e)
143  })
144
145  on('session.measure', async ($, e, next) => {
146    await record($, e, settings)
147
148    return next(e)
149  })
150
151  on('turn.complete', async ($, e, next) => {
152    const ended = await $.clock.now()
153    await update($, lastTurnEndAt, () => ended)
154
155    return next(e)
156  })
157
158  on('command.run', { command: COMMAND }, async $ => {
159    const now = await $.clock.now()
160    const current = snapshotOf(await $.session.usage(), now)
161    const cache = cacheOf(await read($, lastTurnEndAt), now, ttlMinutes, current.contextTokens)
162    await update($, isHidden, () => false)
163    await update($, dismissed, () => [])
164
165    return { text: report(current, await read($, samples), now, cache) }
166  })
167
168  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169    if (!drawsBand || e.props.hasSurvey) {
170      return next(e)
171    }
172
173    const current = await read($, snapshot)
174
175    if (current === null || (isAlwaysOn && (await read($, isHidden)))) {
176      return next(e)
177    }
178
179    await read($, tick)
180    const now = await $.clock.now()
181    const endedAt = e.props.isWorking ? null : await read($, lastTurnEndAt)
182    const cache = cacheOf(endedAt, now, ttlMinutes, current.contextTokens)
183    const isNarrow = e.props.bodyColumns < NARROW_COLUMNS
184    const alerts = alertsOf({
185      snapshot: current,
186      samples: await read($, samples),
187      now,
188      thresholds,
189      cache,
190      isNarrow,
191      includesHigh: !isAlwaysOn,
192    })
193    const { Box, Button, Text } = $.ui.resolve(e)
194
195    const alertLine = (alert: Alert, close: JSX.Element | null) => (
196      <Box key={alert.key}>
197        <Text color={alert.tone}>{alert.head}</Text>
198        {alert.rest !== '' && <Text wrap={isNarrow ? 'truncate' : 'wrap'}> · {alert.rest}</Text>}
199        {close !== null && <Text>{'  '}</Text>}
200        {close}
201      </Box>
202    )
203
204    if (!isAlwaysOn) {
205      const gone = await read($, dismissed)
206      const shown = alerts.filter(alert => !gone.includes(alert.key))
207
208      if (shown.length === 0) {
209        return next(e)
210      }
211
212      const keys = shown.map(alert => alert.key)
213      const close = (
214        <Button key="hide" label={CLOSE_LABEL} plain dimColor role="dismiss" onPress={() => update($, dismissed, list => [...list, ...keys])} />
215      )
216
217      return <Box flexDirection="column">{shown.map((alert, index) => alertLine(alert, index === 0 ? close : null))}</Box>
218    }
219
220    const windows = current.windows.map(window => {
221      const tone = toneOf(window.percentUsed)
222      const cells = barCells(window.percentUsed)
223      const reset = resetIn(window, now)
224
225      return (
226        <Box key={window.kind}>
227          <Text dimColor>{labelOf(window.kind)} </Text>
228          {cells.filled !== '' && <Text color={tone}>{cells.filled}</Text>}
229          {cells.empty !== '' && <Text dimColor>{cells.empty}</Text>}
230          <Text color={tone}> {Math.round(window.percentUsed)}%</Text>
231          {reset !== null && !isNarrow && <Text dimColor> ↻{reset}</Text>}
232          <Text>{'  '}</Text>
233        </Box>
234      )
235    })
236    const cost = isNarrow ? null : costText(current.costUsd)
237
238    return (
239      <Box flexDirection="column">
240        <Box>
241          {windows}
242          {current.contextPercent !== undefined && <Text dimColor>ctx {current.contextPercent}%{'  '}</Text>}
243          {cost !== null && <Text dimColor>{cost}{'  '}</Text>}
244          <Button key="hide" label={CLOSE_LABEL} plain dimColor role="dismiss" onPress={() => update($, isHidden, () => true)} />
245        </Box>
246        {alerts.map(alert => alertLine(alert, null))}
247      </Box>
248    )
249  })
250}
251
hooks/logic.ts 347 lines
1import type { LimitSample, LimitSnapshot, LimitWindow } from '../types'
2
3const MINUTE_MS = 60_000
4const HOUR_MS = 60 * MINUTE_MS
5const DAY_MS = 24 * HOUR_MS
6
7/** How far back the burn rate looks; older samples describe a different pace. */
8export const PACE_WINDOW_MS = 30 * MINUTE_MS
9/** A pace measured over less time than this is one prompt's spike, not a trend. */
10export const MIN_PACE_SPAN_MS = 10 * MINUTE_MS
11/** Samples kept per session; enough for the pace window at one sample a minute. */
12export const MAX_SAMPLES = 120
13const BAR_WIDTH = 8
14const BAR_FILLED = '━'
15const BAR_EMPTY = '─'
16const FULL_PERCENT = 100
17const YELLOW_AT = 70
18const RED_AT = 90
19const WARN_KEY_PREFIX = 'warned:'
20
21const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
22
23export const labelOf = (kind: string): string => LABELS[kind] ?? kind
24
25export const DISPLAYS = ['auto', 'band', 'status', 'both', 'off'] as const
26export type Display = (typeof DISPLAYS)[number]
27
28/** The `display` setting; anything unknown falls back to the quiet default. */
29export const displayOf = (value: unknown): Display => DISPLAYS.find(mode => mode === value) ?? 'auto'
30
31export type Tone = 'yellow' | 'red'
32
33/** Color is for the part that needs attention; below 70% a window stays uncolored. */
34export const toneOf = (percent: number): Tone | undefined =>
35  percent >= RED_AT ? 'red' : percent >= YELLOW_AT ? 'yellow' : undefined
36
37export const barCells = (percent: number, width = BAR_WIDTH): { filled: string; empty: string } => {
38  const clamped = Math.max(0, Math.min(FULL_PERCENT, percent))
39  const filled = Math.round((clamped / FULL_PERCENT) * width)
40
41  return { filled: BAR_FILLED.repeat(filled), empty: BAR_EMPTY.repeat(width - filled) }
42}
43
44export const bar = (percent: number, width = BAR_WIDTH): string => {
45  const cells = barCells(percent, width)
46
47  return cells.filled + cells.empty
48}
49
50export const duration = (ms: number): string => {
51  if (ms < MINUTE_MS) {
52    return '<1m'
53  }
54
55  if (ms < HOUR_MS) {
56    return `${Math.round(ms / MINUTE_MS)}m`
57  }
58
59  if (ms < DAY_MS) {
60    const hours = Math.floor(ms / HOUR_MS)
61    const minutes = Math.round((ms % HOUR_MS) / MINUTE_MS)
62
63    return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
64  }
65
66  const days = Math.floor(ms / DAY_MS)
67  const hours = Math.round((ms % DAY_MS) / HOUR_MS)
68
69  return hours === 0 ? `${days}d` : `${days}d ${hours}h`
70}
71
72export const compactTokens = (tokens: number): string =>
73  tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
74
75export const costText = (usd: number | undefined): string | null =>
76  usd === undefined || usd <= 0 ? null : `$${usd.toFixed(2)}`
77
78/** Adds one reading per window, dropping samples of a window that has since reset. */
79export const addSamples = (samples: readonly LimitSample[], snapshot: LimitSnapshot): LimitSample[] => {
80  const next = [...samples]
81
82  for (const window of snapshot.windows) {
83    const sample: LimitSample = { at: snapshot.at, kind: window.kind, percentUsed: window.percentUsed }
84
85    if (window.resetsAt !== undefined) {
86      sample.resetsAt = window.resetsAt
87    }
88
89    next.push(sample)
90  }
91
92  const current = new Map(snapshot.windows.map(window => [window.kind, window.resetsAt]))
93
94  return next
95    .filter(sample => !current.has(sample.kind) || current.get(sample.kind) === sample.resetsAt)
96    .slice(-MAX_SAMPLES)
97}
98
99export type Pace =
100  | { kind: 'unknown' }
101  | { kind: 'idle' }
102  | { kind: 'lasts'; percentPerHour: number }
103  | { kind: 'runs-out'; percentPerHour: number; inMs: number; beforeResetMs: number | null }
104
105/** Percent per millisecond over the pace window, from the first and last sample of one window. */
106export const paceOf = (samples: readonly LimitSample[], window: LimitWindow, now: number): Pace => {
107  const recent = samples.filter(
108    sample => sample.kind === window.kind && sample.resetsAt === window.resetsAt && now - sample.at <= PACE_WINDOW_MS,
109  )
110  const first = recent[0]
111  const last = recent[recent.length - 1]
112
113  if (first === undefined || last === undefined || last.at - first.at < MIN_PACE_SPAN_MS) {
114    return { kind: 'unknown' }
115  }
116
117  const perMs = (last.percentUsed - first.percentUsed) / (last.at - first.at)
118
119  if (perMs <= 0) {
120    return { kind: 'idle' }
121  }
122
123  const percentPerHour = perMs * HOUR_MS
124  const inMs = (FULL_PERCENT - window.percentUsed) / perMs
125  const resetAt = window.resetsAt === undefined ? Number.NaN : Date.parse(window.resetsAt)
126
127  if (Number.isNaN(resetAt)) {
128    return { kind: 'runs-out', percentPerHour, inMs, beforeResetMs: null }
129  }
130
131  const untilReset = resetAt - now
132
133  return inMs < untilReset
134    ? { kind: 'runs-out', percentPerHour, inMs, beforeResetMs: untilReset - inMs }
135    : { kind: 'lasts', percentPerHour }
136}
137
138export const resetIn = (window: LimitWindow, now: number): string | null => {
139  if (window.resetsAt === undefined) {
140    return null
141  }
142
143  const at = Date.parse(window.resetsAt)
144
145  return Number.isNaN(at) ? null : duration(Math.max(0, at - now))
146}
147
148/** Thresholds as the settings field spells them: "80,95" → [80, 95], invalid parts dropped. */
149export const parseThresholds = (text: string): number[] =>
150  text
151    .split(',')
152    .map(part => Number(part.trim()))
153    .filter(value => Number.isFinite(value) && value > 0 && value <= FULL_PERCENT)
154    .sort((a, b) => a - b)
155
156/** The thresholds a window crossed that were not warned about yet. */
157export const newlyCrossed = (window: LimitWindow, thresholds: readonly number[], warned: readonly number[]): number[] =>
158  thresholds.filter(threshold => window.percentUsed >= threshold && !warned.includes(threshold))
159
160/** One store key per window: its kind and when it resets, so the next window starts clean. */
161export const warnKeyOf = (window: LimitWindow): string => `${WARN_KEY_PREFIX}${window.kind}:${window.resetsAt ?? ''}`
162
163/** Warn keys whose window has reset; a key without a reset time is never pruned here. */
164export const expiredWarnKeys = (keys: readonly string[], now: number): string[] =>
165  keys.filter(key => {
166    if (!key.startsWith(WARN_KEY_PREFIX)) {
167      return false
168    }
169
170    const rest = key.slice(WARN_KEY_PREFIX.length)
171    const resetAt = Date.parse(rest.slice(rest.indexOf(':') + 1))
172
173    return !Number.isNaN(resetAt) && resetAt <= now
174  })
175
176/** The thresholds a stored value says were warned; anything malformed counts as none. */
177export const warnedIn = (value: unknown): number[] =>
178  Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) : []
179
180export type Cache = { isCold: false } | { isCold: true; idleMs: number; tokens: number | undefined }
181
182export const cacheOf = (lastTurnEndAt: number | null, now: number, ttlMinutes: number, tokens: number | undefined): Cache => {
183  if (lastTurnEndAt === null || ttlMinutes <= 0) {
184    return { isCold: false }
185  }
186
187  const idleMs = now - lastTurnEndAt
188
189  return idleMs > ttlMinutes * MINUTE_MS ? { isCold: true, idleMs, tokens } : { isCold: false }
190}
191
192export const paceText = (pace: Pace): string | null => {
193  switch (pace.kind) {
194    case 'runs-out':
195      return pace.beforeResetMs === null
196        ? `runs out in ~${duration(pace.inMs)}`
197        : `runs out in ~${duration(pace.inMs)}, ${duration(pace.beforeResetMs)} before reset`
198    case 'lasts':
199      return 'lasts until reset'
200    case 'idle':
201    case 'unknown':
202      return null
203  }
204}
205
206/**
207 * One line the band shows: `head` is the colored part, `rest` what follows it.
208 * `key` changes when the alert gets worse, so a dismissed alert comes back then.
209 */
210export type Alert = { key: string; tone: Tone; head: string; rest: string }
211
212export type AlertInput = {
213  snapshot: LimitSnapshot
214  samples: readonly LimitSample[]
215  now: number
216  thresholds: readonly number[]
217  cache: Cache
218  isNarrow: boolean
219  /** False on the always-on band, whose bars already show a high window. */
220  includesHigh: boolean
221}
222
223const windowAlert = (window: LimitWindow, input: AlertInput): Alert | null => {
224  const pace = paceOf(input.samples, window, input.now)
225  const runsOut = pace.kind === 'runs-out'
226  const level = input.thresholds.filter(threshold => window.percentUsed >= threshold).at(-1)
227  const isHigh = input.includesHigh && level !== undefined
228
229  if (!runsOut && !isHigh) {
230    return null
231  }
232
233  const reset = resetIn(window, input.now)
234  const parts: string[] = []
235
236  // A narrow line has room for one time; when the window runs out first, that is the one that matters.
237  if (reset !== null && !(input.isNarrow && runsOut)) {
238    parts.push(input.isNarrow ? `↻${reset}` : `resets in ${reset}`)
239  }
240
241  if (pace.kind === 'runs-out') {
242    const inText = duration(pace.inMs)
243    parts.push(
244      input.isNarrow
245        ? `runs out in ~${inText} — slow down`
246        : `at this pace runs out in ~${inText} — slow down or switch to Sonnet`,
247    )
248  }
249
250  return {
251    key: `${window.kind}:${window.resetsAt ?? ''}:${level ?? 0}${runsOut ? ':pace' : ''}`,
252    tone: runsOut ? 'red' : (toneOf(window.percentUsed) ?? 'yellow'),
253    head: `${labelOf(window.kind)} ${Math.round(window.percentUsed)}%`,
254    rest: parts.join(' · '),
255  }
256}
257
258const coldAlert = (cache: Extract<Cache, { isCold: true }>, now: number, isNarrow: boolean): Alert => {
259  const idle = `idle ${duration(cache.idleMs)}`
260  const tokens = cache.tokens === undefined ? 'the whole context' : `~${compactTokens(cache.tokens)} tokens`
261
262  return {
263    key: `cold:${now - cache.idleMs}`,
264    tone: 'yellow',
265    head: isNarrow ? 'Cache cold' : 'Cache likely cold',
266    rest: isNarrow ? `${idle} · /clear if new task` : `${idle} · next prompt re-reads ${tokens} · /clear if switching tasks`,
267  }
268}
269
270/** Everything worth acting on right now, worst windows first, the cold cache last. */
271export const alertsOf = (input: AlertInput): Alert[] => {
272  const alerts = input.snapshot.windows
273    .map(window => windowAlert(window, input))
274    .filter((alert): alert is Alert => alert !== null)
275
276  if (input.cache.isCold) {
277    alerts.push(coldAlert(input.cache, input.now, input.isNarrow))
278  }
279
280  return alerts
281}
282
283export const alertText = (alert: Alert): string => (alert.rest === '' ? alert.head : `${alert.head} · ${alert.rest}`)
284
285/** One short line for the status line. */
286export const shortLine = (snapshot: LimitSnapshot): string => {
287  const parts = snapshot.windows.map(window => `${labelOf(window.kind)} ${Math.round(window.percentUsed)}%`)
288
289  if (snapshot.contextPercent !== undefined) {
290    parts.push(`ctx ${snapshot.contextPercent}%`)
291  }
292
293  const cost = costText(snapshot.costUsd)
294
295  if (cost !== null) {
296    parts.push(cost)
297  }
298
299  return parts.join(' · ')
300}
301
302/** The whole picture as plain text, what /limits prints. */
303export const report = (snapshot: LimitSnapshot, samples: readonly LimitSample[], now: number, cache: Cache): string => {
304  const lines: string[] = []
305
306  if (snapshot.windows.length === 0) {
307    lines.push('No subscription limits reported (API key, or no response yet).')
308  }
309
310  for (const window of snapshot.windows) {
311    const reset = resetIn(window, now)
312    const pace = paceOf(samples, window, now)
313    const parts = [`${labelOf(window.kind)}: ${bar(window.percentUsed)} ${window.percentUsed}%`]
314
315    if (reset !== null) {
316      parts.push(`resets in ${reset}`)
317    }
318
319    if (pace.kind === 'lasts' || pace.kind === 'runs-out') {
320      parts.push(`pace ${pace.percentPerHour.toFixed(1)}%/h`)
321    }
322
323    const said = paceText(pace)
324
325    if (said !== null) {
326      parts.push(said)
327    }
328
329    lines.push(parts.join(' · '))
330  }
331
332  if (snapshot.contextPercent !== undefined) {
333    const tokens = snapshot.contextTokens === undefined ? '' : ` (${compactTokens(snapshot.contextTokens)} tokens)`
334    lines.push(`context: ${bar(snapshot.contextPercent)} ${snapshot.contextPercent}%${tokens}`)
335  }
336
337  if (snapshot.costUsd !== undefined) {
338    lines.push(`session cost: $${snapshot.costUsd.toFixed(2)}`)
339  }
340
341  if (cache.isCold) {
342    lines.push(alertText(coldAlert(cache, now, false)))
343  }
344
345  return lines.join('\n')
346}
347
types/index.d.ts 26 lines
1export type LimitWindow = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type LimitSnapshot = {
4  at: number
5  windows: LimitWindow[]
6  contextPercent?: number
7  contextTokens?: number
8  costUsd?: number
9}
10
11export type LimitSample = { at: number; kind: string; percentUsed: number; resetsAt?: string }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'limit-radar': {
16      snapshot: LimitSnapshot | null
17      samples: LimitSample[]
18      lastTurnEndAt: number | null
19      tick: number
20      isHidden: boolean
21      dismissed: string[]
22      sessionWarned: Record<string, number[]>
23    }
24  }
25}
26