Warns as the 5-hour and weekly usage windows fill, with a circle per window above the prompt, a pace forecast and a status line

A Claude Code mod that warns as your subscription usage windows fill up.
5h 63% · resets in 1h12m · 100% by 13:40; click a circle to pin the details open, click again to hide themseven_day_opus draws as 7d opus)on pace for 100% at 13:40 when the current pace runs out before the reset. It uses the last hour of readings, and falls back to the average since the window opened5h 63% · resets in 1h12m · 100% by 13:40, refreshed every minute$.store), and a jump from 45% to 73% warns once, at 70%Set in /config, or under pluginConfigs in ~/.claude/settings.json.
| Setting | Default | Options |
|---|---|---|
| Start warning at | 50 | 30 40 50 60 70 80 |
| Warn every | 10 | 5 10 20 |
| Native notification from | 90 | off 70 80 90 95 |
| Toast seconds | 8 | 4 8 15 30 |
| Status line | on | on off |
| Usage circles | on | on off |
| Pace forecast | on | on off |
| 7-day window | on | on off |
| Sound | with notification | with notification every step off |
claude plugin validate ~/.claude/mods/usage-warn
claude plugin test ~/.claude/mods/usage-warnhooks/register.tsx 124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3import type { Reading } from '../types'
4import {
5 addSample,
6 circleColor,
7 forecastFull,
8 isTracked,
9 readSettings,
10 statusPart,
11 stepFor,
12 warningText,
13 type Sample,
14 type Settings,
15} from './logic'
16
17type Warned = { window: string; step: number }
18type Samples = { window: string; points: Sample[] }
19
20const isWarned = (v: unknown): v is Warned =>
21 typeof v === 'object' && v !== null && typeof (v as Warned).window === 'string' && typeof (v as Warned).step === 'number'
22
23const isSamples = (v: unknown): v is Samples =>
24 typeof v === 'object' && v !== null && typeof (v as Samples).window === 'string' && Array.isArray((v as Samples).points)
25
26const STATUS_REFRESH_MS = 60_000
27
28// The latest reading of each window, for the band and the status line; in $.state so a reload keeps them.
29const readings = atom({ plugin: 'usage-warn', key: 'readings' } as const, [] as Reading[])
30// Whether the band's info text is pinned open by a click on a circle.
31const isOpen = atom({ plugin: 'usage-warn', key: 'isOpen' } as const, false)
32
33async function drawStatus($: EngineInterface, s: Settings) {
34 if (!s.statusLine) return
35 const now = await $.clock.now()
36 const shown = (await read($, readings)).filter(r => r.percentUsed >= s.start)
37 $.ui.status(shown.length ? shown.map(r => statusPart(r, r.fullAt, now)).join(' · ') : undefined)
38}
39
40async function check($: EngineInterface, s: Settings, limits: readonly SessionRateLimit[]) {
41 const now = await $.clock.now()
42 const next: Reading[] = []
43
44 for (const limit of limits) {
45 const kind = limit.kind
46 if (!isTracked(kind, s.sevenDay)) continue
47 const window = limit.resetsAt ?? 'unknown'
48
49 // Samples feed the forecast; $.store is shared, so every session adds to the same history.
50 let fullAt: number | undefined
51 if (s.forecast) {
52 const stored = await $.store.get(`samples:${kind}`)
53 const points = isSamples(stored) && stored.window === window ? stored.points : []
54 const updated = addSample(points, [now, limit.percentUsed])
55 await $.store.set(`samples:${kind}`, { window, points: updated } satisfies Samples)
56 fullAt = forecastFull(limit, updated, now)
57 }
58 next.push({ kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt, fullAt })
59
60 const step = stepFor(limit.percentUsed, s)
61 if (step === 0) continue
62 const last = await $.store.get(`warned:${kind}`)
63 const warnedStep = isWarned(last) && last.window === window ? last.step : 0
64 if (step <= warnedStep) continue
65
66 await $.store.set(`warned:${kind}`, { window, step } satisfies Warned)
67 const text = warningText(limit, step, fullAt, now)
68 $.ui.toast(text, { timeoutMs: s.toastMs })
69
70 const isLoud = s.notifyAt !== undefined && step >= s.notifyAt
71 if (isLoud) await $.ui.notify(text, { title: 'Claude usage' }).catch(() => undefined)
72 if (s.sound === 'every' || (s.sound === 'notify' && isLoud)) {
73 await $.audio.play({ asset: 'sounds/alert.wav' }).catch(() => undefined)
74 }
75 }
76
77 await update($, readings, () => next)
78 await drawStatus($, s)
79}
80
81export const register: Register = (on, options) => {
82 const s = readSettings(options)
83
84 on('session.start', async ($, e, next) => {
85 const result = await next(e)
86 const usage = await $.session.usage().catch(() => undefined)
87 if (usage) await check($, s, usage.rateLimits)
88 if (s.statusLine) $.clock.every(STATUS_REFRESH_MS, () => void drawStatus($, s))
89 return result
90 })
91
92 on('session.measure', async ($, e, next) => {
93 if (e.changed.includes('rateLimits')) await check($, s, e.rateLimits)
94 return next(e)
95 })
96
97 // One circle per window in use, at the right end of the band above the prompt.
98 // Hovering the row reveals the details; a click on a circle pins them open.
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 if (!s.indicator || e.props.hasSurvey) return next(e)
101 const shown = (await read($, readings)).filter(r => r.percentUsed > 0)
102 if (shown.length === 0) return next(e)
103
104 const now = await $.clock.now()
105 const isPinned = await read($, isOpen)
106 const toggle = () => update($, isOpen, open => !open)
107 const revealOnHover = isPinned ? {} : { display: 'none' as const, hover: { display: 'flex' as const } }
108 const { Box, Button, Text } = $.ui.resolve(e)
109
110 return (
111 <Box key="usage" flexDirection="row" justifyContent="flex-end" gap={1}>
112 <Box {...revealOnHover}>
113 <Text dimColor>{shown.map(r => statusPart(r, r.fullAt, now)).join(' · ')}</Text>
114 </Box>
115 {shown.map(r => (
116 <Button key={`usage-${r.kind}`} plain onPress={toggle}>
117 <Text color={circleColor(r.percentUsed, s)}>●</Text>
118 </Button>
119 ))}
120 </Box>
121 )
122 })
123}
124hooks/logic.ts 146 lines1import type { PluginOptions, SessionRateLimit } from 'claude-code'
2
3const HOUR_MS = 60 * 60 * 1000
4
5/** The windows the mod tracks: the 5-hour one, and every weekly one (7d, 7d opus, ...). */
6export const isTracked = (kind: string, sevenDay: boolean) =>
7 kind === 'five_hour' || (sevenDay && kind.startsWith('seven_day'))
8
9/** 5h, 7d, or 7d opus for a model-scoped weekly window. */
10export const windowLabel = (kind: string) => {
11 if (kind === 'five_hour') return '5h'
12 if (kind === 'seven_day') return '7d'
13 if (kind.startsWith('seven_day_')) return `7d ${kind.slice('seven_day_'.length).replace(/_/g, ' ')}`
14 return kind
15}
16
17const windowLength = (kind: string) => {
18 if (kind === 'five_hour') return 5 * HOUR_MS
19 if (kind.startsWith('seven_day')) return 7 * 24 * HOUR_MS
20 return undefined
21}
22
23export type Settings = {
24 start: number
25 step: number
26 notifyAt: number | undefined
27 toastMs: number
28 statusLine: boolean
29 forecast: boolean
30 sevenDay: boolean
31 sound: 'off' | 'notify' | 'every'
32 indicator: boolean
33}
34
35const num = (value: unknown, fallback: number) => {
36 const n = Number(value)
37 return Number.isFinite(n) && n > 0 ? n : fallback
38}
39
40export const readSettings = (o: PluginOptions): Settings => ({
41 start: num(o.start, 50),
42 step: num(o.step, 10),
43 notifyAt: o.notifyAt === 'off' ? undefined : num(o.notifyAt, 90),
44 toastMs: num(o.toastSeconds, 8) * 1000,
45 statusLine: o.statusLine !== 'off',
46 forecast: o.forecast !== 'off',
47 sevenDay: o.sevenDay !== 'off',
48 sound: o.sound === 'off' ? 'off' : o.sound === 'every step' ? 'every' : 'notify',
49 indicator: o.indicator !== 'off',
50})
51
52/** The circle's theme color: green below the start, warning from it, error from the notification percent. */
53export const circleColor = (percent: number, s: Settings): 'success' | 'warning' | 'error' => {
54 if (percent >= (s.notifyAt ?? 90)) return 'error'
55 if (percent >= s.start) return 'warning'
56 return 'success'
57}
58
59/** The highest warning step reached, or 0 below the start. */
60export const stepFor = (percent: number, s: Settings) => {
61 if (percent < s.start) return 0
62 return s.start + Math.floor((percent - s.start) / s.step) * s.step
63}
64
65export type Sample = [time: number, percent: number]
66
67const SAMPLE_SPAN_MS = HOUR_MS
68const MIN_SPAN_MS = 10 * 60 * 1000
69
70/** Keeps the last hour of samples, at most one a minute. */
71export const addSample = (samples: readonly Sample[], next: Sample): Sample[] => {
72 const last = samples[samples.length - 1]
73 const kept = last && next[0] - last[0] < 60_000 ? samples.slice(0, -1) : samples
74 return [...kept, next].filter(([t]) => next[0] - t <= SAMPLE_SPAN_MS)
75}
76
77/**
78 * When the window hits 100% at the current pace, or undefined when that is
79 * after the reset or the pace is unknown. Uses the last hour's samples, and
80 * falls back to the average since the window opened.
81 */
82export const forecastFull = (
83 limit: SessionRateLimit,
84 samples: readonly Sample[],
85 now: number,
86): number | undefined => {
87 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
88 if (Number.isNaN(resetsAt) || limit.percentUsed >= 100) return undefined
89
90 let ratePerMs = 0
91 const first = samples[0]
92 if (first && now - first[0] >= MIN_SPAN_MS && limit.percentUsed > first[1]) {
93 ratePerMs = (limit.percentUsed - first[1]) / (now - first[0])
94 } else {
95 const length = windowLength(limit.kind)
96 const elapsed = length ? now - (resetsAt - length) : 0
97 if (elapsed >= MIN_SPAN_MS) ratePerMs = limit.percentUsed / elapsed
98 }
99 if (ratePerMs <= 0) return undefined
100
101 const fullAt = now + (100 - limit.percentUsed) / ratePerMs
102 return fullAt < resetsAt ? fullAt : undefined
103}
104
105const pad = (n: number) => String(n).padStart(2, '0')
106const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
107
108/** 14:30 today, or Thu 14:30 for another day. */
109export const clockTime = (ms: number, now: number) => {
110 const at = new Date(ms)
111 const hm = `${pad(at.getHours())}:${pad(at.getMinutes())}`
112 return at.toDateString() === new Date(now).toDateString() ? hm : `${DAYS[at.getDay()]} ${hm}`
113}
114
115/** 1h12m, 45m, or 3d4h. */
116export const duration = (ms: number) => {
117 const mins = Math.max(0, Math.round(ms / 60_000))
118 if (mins < 60) return `${mins}m`
119 const hours = Math.floor(mins / 60)
120 if (hours < 48) return `${hours}h${pad(mins % 60)}m`
121 return `${Math.floor(hours / 24)}d${hours % 24}h`
122}
123
124export const warningText = (
125 limit: SessionRateLimit,
126 step: number,
127 fullAt: number | undefined,
128 now: number,
129) => {
130 const label = windowLabel(limit.kind)
131 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
132 const parts = [`${label} usage at ${step}%`]
133 if (!Number.isNaN(resetsAt)) parts.push(`resets ${clockTime(resetsAt, now)}`)
134 if (fullAt !== undefined) parts.push(`on pace for 100% at ${clockTime(fullAt, now)}`)
135 return parts.join(' · ')
136}
137
138export const statusPart = (limit: SessionRateLimit, fullAt: number | undefined, now: number) => {
139 const label = windowLabel(limit.kind)
140 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
141 let text = `${label} ${Math.round(limit.percentUsed)}%`
142 if (!Number.isNaN(resetsAt)) text += ` · resets in ${duration(resetsAt - now)}`
143 if (fullAt !== undefined) text += ` · 100% by ${clockTime(fullAt, now)}`
144 return text
145}
146types/index.d.ts 14 lines1/** One usage window as last measured, kept for the band and the status line. */
2export type Reading = {
3 kind: string
4 percentUsed: number
5 resetsAt?: string
6 fullAt?: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'usage-warn': { readings: Reading[]; isOpen: boolean }
12 }
13}
14