Shows 5h / 7d usage limits with reset countdowns above the prompt

Personal Claude Code mods, published as a local plugin marketplace.
claude plugin marketplace add ~/Desktop/Github/claude-mods
claude plugin install usage-limit@claude-mods
hooks/register.tsx 363 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Sample } from '../types'
5
6const limits = atom({ plugin: 'usage-limit', key: 'limits' } as const, [])
7const contextPercent = atom({ plugin: 'usage-limit', key: 'contextPercent' } as const, null)
8// five_hour readings over the last SAMPLE_SPAN, for the time-to-limit forecast
9const samples = atom({ plugin: 'usage-limit', key: 'samples' } as const, [])
10
11const HOUR = 3_600_000
12const WINDOWS: Record<string, { label: string; ms?: number; showsClock?: boolean }> = {
13 five_hour: { label: '5h', ms: 5 * HOUR, showsClock: true },
14 seven_day: { label: '7d', ms: 7 * 24 * HOUR },
15 spend_limit: { label: '$' },
16}
17const WARN_AT = [80, 90]
18const SAMPLE_SPAN = 30 * 60_000
19const MIN_SPAN = 3 * 60_000
20// refreshes countdowns and the elapsed marker between measurements
21const TICK_MS = 60_000
22// reset clock times are shown in UTC+8
23const CLOCK_OFFSET = 8 * HOUR
24
25// Layout
26const BAR_CELLS = 10
27const METER_GAP = 3
28const SVG_COLORS: Record<string, string> = {
29 green: '#4caf50',
30 yellow: '#e0a526',
31 red: '#e5534b',
32 track: 'rgba(128,128,128,0.3)',
33}
34
35// value is the percentage; reset (countdown) and clock (reset time of day) are drawn in their own colors
36type Meter = {
37 key: string
38 label: string
39 used: number
40 elapsed: number | null
41 tone: string
42 value: string
43 reset: string | null
44 clock: string | null
45 warning: string | null
46}
47
48let ticker: { cancel: () => void } | null = null
49
50function clamp(p: number) {
51 return Math.min(100, Math.max(0, p))
52}
53
54// Pace: how far usage runs ahead of the share of the window already gone
55function toneOf(used: number, elapsed: number | null) {
56 if (used >= 90) return 'red'
57 if (elapsed === null) return used >= 80 ? 'red' : used >= 50 ? 'yellow' : 'green'
58 const margin = elapsed - used
59 if (margin < -15) return 'red'
60 if (margin < 10 && used >= 10) return 'yellow'
61 return 'green'
62}
63
64function duration(ms: number) {
65 const minutes = Math.max(0, Math.ceil(ms / 60_000))
66 const d = Math.floor(minutes / 1440)
67 const h = Math.floor((minutes % 1440) / 60)
68 const m = minutes % 60
69 return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
70}
71
72function clock(ms: number) {
73 const t = new Date(ms + CLOCK_OFFSET)
74 return `${String(t.getUTCHours()).padStart(2, '0')}:${String(t.getUTCMinutes()).padStart(2, '0')}`
75}
76
77// ms until five_hour reaches 100% at the recent rate; null when flat or too few readings
78function timeToLimit(history: Sample[], percent: number) {
79 if (history.length < 2) return null
80 const first = history[0]
81 const last = history[history.length - 1]
82 const span = last.t - first.t
83 const rise = last.p - first.p
84 if (span < MIN_SPAN || rise <= 0) return null
85 return ((100 - percent) / rise) * span
86}
87
88async function recordSample($: EngineInterface, rateLimits: Limit[]) {
89 const five = rateLimits.find(l => l.kind === 'five_hour')
90 if (!five) return
91 const t = await $.clock.now()
92 await update($, samples, (prev: Sample[]) => {
93 const last = prev[prev.length - 1]
94 // a drop means the window reset: start over
95 const kept = last && five.percentUsed < last.p ? [] : prev.filter(s => t - s.t <= SAMPLE_SPAN)
96 return [...kept, { t, p: five.percentUsed }]
97 })
98}
99
100function limitMeter(limit: Limit, now: number, history: Sample[]): Meter {
101 const win = WINDOWS[limit.kind]
102 const label = win?.label ?? limit.kind
103 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : null
104 // the window has reset since the last reading: it starts again from zero
105 if (resetsAt !== null && resetsAt <= now) {
106 return { key: limit.kind, label, used: 0, elapsed: win?.ms ? 0 : null, tone: 'green', value: '0%', reset: null, clock: null, warning: null }
107 }
108 const resetIn = resetsAt === null ? null : resetsAt - now
109 const elapsed = win?.ms && resetIn !== null ? clamp(100 - (resetIn / win.ms) * 100) : null
110 const value = `${Math.round(limit.percentUsed)}%`
111 const reset = resetIn === null ? null : duration(resetIn)
112 const at = resetsAt !== null && win?.showsClock ? `(${clock(resetsAt)})` : null
113 const eta = limit.kind === 'five_hour' ? timeToLimit(history, limit.percentUsed) : null
114 // only worth showing when the limit would arrive before the reset
115 const warning = eta !== null && (resetIn === null || eta < resetIn) ? `⚠ ≈${duration(eta)} 撞线` : null
116 return { key: limit.kind, label, used: limit.percentUsed, elapsed, tone: toneOf(limit.percentUsed, elapsed), value, reset, clock: at, warning }
117}
118
119// Desktop chips: one SVG per meter (the band's Box has no rounded backgrounds), each a tinted
120// pill with an icon, label, thin bar, bold percentage and, after a divider, the reset countdown.
121// Light and dark come from prefers-color-scheme inside the SVG. Icons are Lucide paths (ISC).
122const CHIP_H = 20
123const CHIP_PAD = 9
124const ICON = 11
125const GAP = 7
126const LETTER = 0.6
127const BAR_H = 3
128const FONT = 11
129const BAR_W = 36
130const CHIP_THEMES: Record<string, { tint: string; accent: string; accentDark: string; icon: string }> = {
131 ctx: { tint: 'rgba(70,130,240,0.16)', accent: '#3d72d6', accentDark: '#7aa5f5', icon: 'layers' },
132 five_hour: { tint: 'rgba(76,175,80,0.16)', accent: '#3f8f43', accentDark: '#6cc070', icon: 'gauge' },
133 seven_day: { tint: 'rgba(124,92,230,0.16)', accent: '#6d4fd6', accentDark: '#a48cf5', icon: 'calendar' },
134}
135const ICONS: Record<string, string> = {
136 gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
137 calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
138 layers:
139 '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
140 history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
141}
142
143// Monospace advance: ASCII is 0.6em, anything wider (⚠, CJK) taken as a full em, plus letter-spacing
144function textWidth(text: string, size = FONT) {
145 return [...text].reduce((w, ch) => w + (ch.charCodeAt(0) < 128 ? 0.6 : 1) * size + LETTER, 0)
146}
147
148function esc(text: string) {
149 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
150}
151
152function icon(name: string, x: number, cls: string) {
153 const y = (CHIP_H - ICON) / 2
154 return `<g class="${cls}" transform="translate(${x} ${y}) scale(${ICON / 24})" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</g>`
155}
156
157function chipSvg(m: Meter) {
158 const theme = CHIP_THEMES[m.key] ?? CHIP_THEMES.ctx
159 const mid = CHIP_H / 2
160 const base = mid + FONT * 0.35
161 const parts: string[] = []
162 let x = CHIP_PAD
163
164 parts.push(icon(theme.icon, x, 'accent'))
165 x += ICON + 5
166 parts.push(`<text x="${x}" y="${base}" class="muted">${esc(m.label)}</text>`)
167 x += textWidth(m.label) + GAP
168
169 const barY = mid - BAR_H / 2
170 const fill = Math.round((clamp(m.used) / 100) * BAR_W)
171 parts.push(`<clipPath id="b"><rect x="${x}" y="${barY}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath>`)
172 parts.push(`<g clip-path="url(#b)"><rect x="${x}" y="${barY}" width="${BAR_W}" height="${BAR_H}" fill="${SVG_COLORS.track}"/>`)
173 if (fill > 0) parts.push(`<rect x="${x}" y="${barY}" width="${fill}" height="${BAR_H}" fill="${SVG_COLORS[m.tone]}"/>`)
174 parts.push('</g>')
175 if (m.elapsed !== null) {
176 const mx = x + Math.min(BAR_W - 2, Math.max(0, Math.round((m.elapsed / 100) * BAR_W) - 1))
177 parts.push(`<rect x="${mx}" y="${mid - 4}" width="1.5" height="8" rx="1" class="ink-fill"/>`)
178 }
179 x += BAR_W + GAP
180
181 // the percentage stays in ink while on pace, and takes the warning color once it isn't
182 const pctClass = m.tone === 'green' ? 'ink' : ''
183 const pctFill = m.tone === 'green' ? '' : ` fill="${SVG_COLORS[m.tone]}"`
184 parts.push(`<text x="${x}" y="${base}" font-weight="700" class="${pctClass}"${pctFill}>${esc(m.value)}</text>`)
185 x += textWidth(m.value)
186
187 if (m.reset) {
188 x += GAP
189 parts.push(`<rect x="${x}" y="${mid - 5}" width="1" height="10" class="divider"/>`)
190 x += 1 + GAP
191 parts.push(icon('history', x, 'accent'))
192 x += ICON + 5
193 parts.push(`<text x="${x}" y="${base}" class="accent-fill">${esc(m.reset)}</text>`)
194 x += textWidth(m.reset)
195 }
196 if (m.clock) {
197 x += 5
198 parts.push(`<text x="${x}" y="${base}" class="muted">${esc(m.clock)}</text>`)
199 x += textWidth(m.clock)
200 }
201 if (m.warning) {
202 x += GAP
203 parts.push(`<text x="${x}" y="${base}" font-weight="700" fill="${SVG_COLORS.red}">${esc(m.warning)}</text>`)
204 x += textWidth(m.warning)
205 }
206 const width = Math.ceil(x + CHIP_PAD)
207
208 const style =
209 `text{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:${FONT}px;letter-spacing:${LETTER}px}` +
210 `.ink{fill:#2b2b2b}.ink-fill{fill:#2b2b2b}.muted{fill:#6b6b6b}.divider{fill:rgba(0,0,0,0.15)}` +
211 `.accent{stroke:${theme.accent}}.accent-fill{fill:${theme.accent}}` +
212 `@media (prefers-color-scheme: dark){.ink,.ink-fill{fill:#ececec}.muted{fill:#a3a3a3}` +
213 `.divider{fill:rgba(255,255,255,0.18)}.accent{stroke:${theme.accentDark}}.accent-fill{fill:${theme.accentDark}}}`
214 return {
215 width,
216 source:
217 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${CHIP_H}" viewBox="0 0 ${width} ${CHIP_H}">` +
218 `<style>${style}</style><rect width="${width}" height="${CHIP_H}" rx="${CHIP_H / 2}" fill="${theme.tint}"/>` +
219 parts.join('') +
220 '</svg>',
221 }
222}
223
224// The terminal's bar as runs of cells: used in the tone color, the rest dim, the elapsed marker cyan
225function textBarRuns(used: number, elapsed: number | null) {
226 const filled = Math.round((clamp(used) / 100) * BAR_CELLS)
227 const marker = elapsed === null ? -1 : Math.min(BAR_CELLS - 1, Math.floor((elapsed / 100) * BAR_CELLS))
228 const runs: { kind: 'used' | 'rest' | 'marker'; text: string }[] = []
229 for (let i = 0; i < BAR_CELLS; i++) {
230 const kind = i === marker ? 'marker' : i < filled ? 'used' : 'rest'
231 const char = kind === 'marker' ? '┃' : kind === 'used' ? '█' : '░'
232 const last = runs[runs.length - 1]
233 if (last && last.kind === kind) last.text += char
234 else runs.push({ kind, text: char })
235 }
236 return runs
237}
238
239// Whether every meter fits on one line with its text bar
240function barsFit(meters: Meter[], columns: number) {
241 const width = meters.reduce(
242 (sum, m) => sum + m.label.length + 1 + BAR_CELLS + 1 + m.value.length + (m.reset ? m.reset.length + 3 : 0) + (m.clock ? m.clock.length + 1 : 0) + (m.warning ? m.warning.length + 2 : 0),
243 0,
244 )
245 return width + METER_GAP * (meters.length - 1) <= columns - 2
246}
247
248// Whether a render tree draws anything: text, or an element other than a bare Box or Text
249function hasContent(node: unknown): boolean {
250 if (node == null || node === false) return false
251 if (typeof node === 'string') return node.trim() !== ''
252 if (typeof node === 'number') return true
253 if (Array.isArray(node)) return node.some(hasContent)
254 const el = node as { type?: string; children?: unknown }
255 if (el.type !== 'Box' && el.type !== 'Text') return true
256 return hasContent(el.children)
257}
258
259export const register: Register = on => {
260 on('session.start', async ($, e, next) => {
261 const usage = await $.session.usage()
262 await update($, limits, () => usage.rateLimits)
263 await update($, contextPercent, () => usage.context.percent ?? null)
264 await recordSample($, usage.rateLimits)
265 ticker?.cancel()
266 ticker = $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
267 return next(e)
268 })
269
270 on('session.measure', async ($, e, next) => {
271 if (e.changed.includes('context')) {
272 await update($, contextPercent, () => e.context.percent ?? null)
273 }
274 if (e.changed.includes('rateLimits')) {
275 const before = await read($, limits)
276 for (const limit of e.rateLimits) {
277 const old = before.find(l => l.kind === limit.kind)?.percentUsed ?? 0
278 const crossed = WARN_AT.filter(t => old < t && limit.percentUsed >= t).pop()
279 if (crossed) {
280 $.ui.toast(`${WINDOWS[limit.kind]?.label ?? limit.kind} usage limit passed ${crossed}%`)
281 }
282 }
283 await update($, limits, () => e.rateLimits)
284 await recordSample($, e.rateLimits)
285 }
286 return next(e)
287 })
288
289 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
290 const current: Limit[] = await read($, limits)
291 const ctx = await read($, contextPercent)
292 if (e.props.hasSurvey || (current.length === 0 && ctx === null)) {
293 return next(e)
294 }
295
296 const now = await $.clock.now()
297 const history: Sample[] = await read($, samples)
298 const meters: Meter[] = []
299 if (ctx !== null) {
300 meters.push({ key: 'ctx', label: 'ctx', used: ctx, elapsed: null, tone: toneOf(ctx, null), value: `${ctx}%`, reset: null, clock: null, warning: null })
301 }
302 for (const limit of current) meters.push(limitMeter(limit, now, history))
303
304 const elements = $.ui.resolve(e)
305 const { Box, Text } = elements
306 const Svg = 'Svg' in elements ? elements.Svg : null
307 const gauge = barsFit(meters, e.props.bodyColumns) ? 'text' : 'none'
308
309 const line = Svg ? (
310 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
311 {meters.map(m => {
312 const chip = chipSvg(m)
313 return (
314 <Svg
315 key={`chip-${m.key}`}
316 source={chip.source}
317 alt={`${m.label} ${m.value}${m.reset ? `, resets in ${m.reset}` : ''}${m.elapsed === null ? '' : `, ${Math.round(m.elapsed)}% of the window gone`}${m.warning ? `, ${m.warning}` : ''}`}
318 width={chip.width}
319 height={CHIP_H}
320 />
321 )
322 })}
323 </Box>
324 ) : (
325 <Box flexDirection="row" columnGap={METER_GAP}>
326 {meters.map(m => (
327 <Box key={`meter-${m.key}`} flexDirection="row" columnGap={1} alignItems="center">
328 <Text>{m.label}</Text>
329 {gauge === 'text' ? (
330 <Text>
331 {textBarRuns(m.used, m.elapsed).map((run, i) =>
332 run.kind === 'marker' ? (
333 <Text key={i} color="cyan" bold>{run.text}</Text>
334 ) : run.kind === 'used' ? (
335 <Text key={i} color={m.tone}>{run.text}</Text>
336 ) : (
337 <Text key={i} dimColor>{run.text}</Text>
338 ),
339 )}
340 </Text>
341 ) : null}
342 <Text color={m.tone}>{m.value}</Text>
343 {m.reset ? <Text color="cyan">↻ {m.reset}</Text> : null}
344 {m.clock ? <Text dimColor>{m.clock}</Text> : null}
345 {m.warning ? <Text color="red" bold>{m.warning}</Text> : null}
346 </Box>
347 ))}
348 </Box>
349 )
350
351 // keep what later mods draw in the band
352 const rest = await next(e)
353 // an empty tree from below would still take the row gap, leaving a blank row under the meters
354 if (!hasContent(rest)) return line
355 return (
356 <Box flexDirection="column" rowGap={e.surface === 'desktop' ? 1 : 0}>
357 {line}
358 {rest}
359 </Box>
360 )
361 })
362}
363types/index.d.ts 9 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Sample = { t: number; p: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'usage-limit': { limits: Limit[]; contextPercent: number | null; samples: Sample[] }
7 }
8}
9