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.

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 ×
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:
/clear is still an option.| Where | What |
|---|---|
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 |
| Toast | Once per window and threshold (default 80% and 95%), across sessions |
/limits | The 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.
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.
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
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
display | auto | auto: 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 |
warnAt | 80,95 | Thresholds in percent, comma-separated. Each toasts once per window; in auto the first one also shows the line |
cacheTtlMinutes | 60 | Idle 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.
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.turn.complete stamps when the last turn ended, and the cold-cache check compares that with now.$.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.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.
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 .
Мод пишет строку над полем ввода, только когда с лимитами подписки надо что-то делать. В остальное время строка пустая:
/clear до отправки.На 80% и 95% приходит одно уведомление на окно, и новая сессия в том же окне его не повторяет. Полоски всё время — display: band. Команда /limits печатает полный отчёт. Мод ничего не отправляет в сеть.
MIT
hooks/register.tsx 251 lines1import { 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}
251hooks/logic.ts 347 lines1import 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}
347types/index.d.ts 26 lines1export 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