Context window forecast plus 5h/7d rate-limit usage with reset countdowns, drawn above the prompt.

Claude Code mods (Claude Code ≥ 2.1.287), packaged as a plugin marketplace named salik-mods.

Band above the prompt: context window forecast, plus 5h/7d rate-limit meters with a pace icon, reset countdown, and an "out ~Xm" projection when usage is on track to run out before reset. Toasts at 80% and 90%. Nerd Font icons by default; set the icons option to plain otherwise.
Reads only what Claude Code already has ($.session.usage(), session.measure); makes no API calls.
Based on token-weather (Apache-2.0).
From GitHub:
claude plugin marketplace add imsalik/claude-mods
claude plugin install usage-weather@salik-mods --scope user
Or live from a clone, so edits hot-reload. Clone anywhere, then point the env block of ~/.claude/settings.json at the mod folder:
git clone https://github.com/imsalik/claude-mods.git <clone-dir>
"CLAUDE_CODE_PLUGIN_DIRS": "<clone-dir>/usage-weather"
Use one, not both.
cd usage-weather
claude plugin validate .
claude plugin test .hooks/register.tsx 219 lines1// Usage Weather: context forecast and rate-limit windows, above the prompt.
2// Based on token-weather from anthropics/claude-code-playground (Apache-2.0).
3
4import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
5
6type Reading = { tokens: number; window: number; percent: number }
7type IconSet = typeof ICONS.nerd
8type Pace = 'cool' | 'steady' | 'hot'
9
10const HISTORY = 12
11const BARS = '▁▂▃▄▅▆▇█'
12const TOAST_AT = [80, 90]
13const HOUR = 3_600_000
14const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 168 * HOUR }
15const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
16
17const ICONS = {
18 nerd: {
19 forecast: ['\u{F0599}', '\u{F0590}', '\u{F0596}', '\u{F0593}', '\u{F0898}'],
20 limit: { five_hour: '\u{F0150}', seven_day: '\u{F0A33}' } as Record<string, string>,
21 limitDefault: '\u{F029A}',
22 pace: { cool: '\u{F0CD7}', steady: '\u{F0F85}', hot: '\u{F0238}' },
23 reset: '\u{F051F}',
24 empty: '\u{F0083}',
25 },
26 plain: {
27 forecast: ['☀', '☁', '☂', '☇', '↯'],
28 limit: { five_hour: '◷', seven_day: '▦' } as Record<string, string>,
29 limitDefault: '◈',
30 pace: { cool: '◡', steady: '•', hot: '▲' },
31 reset: '↻',
32 empty: '!',
33 },
34}
35
36const FORECAST = [
37 { upTo: 25, word: 'Clear', color: 'yellow' },
38 { upTo: 50, word: 'Cloudy', color: 'cyan' },
39 { upTo: 75, word: 'Showers', color: 'blue' },
40 { upTo: 90, word: 'Storm', color: 'magenta' },
41 { upTo: Infinity, word: 'Compact soon', color: 'red' },
42] as const
43
44const PACE_COLOR: Record<Pace, string> = { cool: 'green', steady: 'yellow', hot: 'red' }
45
46let readings: Reading[] = []
47let limits: SessionRateLimit[] = []
48let toasted = new Set<string>()
49let hasSeeded = false
50let tick: { cancel: () => void } | undefined
51
52export const register: Register = (on, options) => {
53 const icons: IconSet = options.icons === 'plain' ? ICONS.plain : ICONS.nerd
54
55 on('session.start', async ($, e, next) => {
56 const result = await next(e)
57 readings = []
58 limits = []
59 toasted = new Set()
60 hasSeeded = false
61 tick?.cancel()
62 tick = $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
63 await seed($)
64 return result
65 })
66
67 on('session.measure', async ($, e, next) => {
68 const result = await next(e)
69 if (e.changed.includes('context')) addReading(e.context)
70 if (e.changed.includes('rateLimits')) {
71 limits = e.rateLimits
72 warn($)
73 }
74 hasSeeded = true
75 $.ui.invalidate('ui.render')
76 return result
77 })
78
79 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80 if (e.props.hasSurvey || (readings.length === 0 && limits.length === 0)) return next(e)
81 const { Box, Text } = $.ui.resolve(e)
82 const now = await $.clock.now()
83 const cols = e.props.bodyColumns
84 const gaugeCells = cols >= 80 ? 5 : 0
85 const last = readings[readings.length - 1]
86 const fi = last ? forecastIndex(last.percent) : 0
87 const f = FORECAST[fi] ?? FORECAST[0]
88 const trend = trendText()
89
90 return (
91 <Box flexDirection="row" paddingX={1}>
92 {last && (
93 <Box flexDirection="row">
94 <Text color={f.color} bold>{`${icons.forecast[fi]} ${f.word}`}</Text>
95 <Text>{` ${last.percent}% ctx`}</Text>
96 {cols >= 60 && <Text dimColor>{` ${short(last.tokens)}/${short(last.window)}`}</Text>}
97 {cols >= 100 && readings.length > 1 && <Text color={f.color}>{` ${chart()}`}</Text>}
98 {cols >= 100 && trend && <Text dimColor>{` ${trend}`}</Text>}
99 </Box>
100 )}
101 {limits.map((l, i) => {
102 const w = windowState(l, now)
103 const color = pctColor(l.percentUsed)
104 const g = gaugeCells > 0 ? gauge(l.percentUsed, gaugeCells) : undefined
105 return (
106 <Box key={l.kind} flexDirection="row">
107 {(last || i > 0) && <Text dimColor>{' │ '}</Text>}
108 <Text color={color}>{`${icons.limit[l.kind] ?? icons.limitDefault} `}</Text>
109 {g && <Text color={color}>{g.used}</Text>}
110 {g && <Text dimColor>{`${g.rest} `}</Text>}
111 <Text color={color} bold>{`${Math.round(l.percentUsed)}%`}</Text>
112 {w.pace && <Text color={PACE_COLOR[w.pace]}>{` ${icons.pace[w.pace]}`}</Text>}
113 {w.emptyInMs !== undefined ? (
114 <Text color="red">{` ${icons.empty} out ~${fmtDuration(w.emptyInMs)}`}</Text>
115 ) : (
116 w.resetInMs > 0 && <Text dimColor>{` ${icons.reset} ${fmtDuration(w.resetInMs)}`}</Text>
117 )}
118 </Box>
119 )
120 })}
121 </Box>
122 )
123 })
124}
125
126async function seed($: EngineInterface) {
127 try {
128 const usage = await $.session.usage()
129 addReading(usage.context)
130 limits = usage.rateLimits
131 for (const l of limits) for (const at of TOAST_AT) if (l.percentUsed >= at) toasted.add(toastKey(l, at))
132 $.ui.invalidate('ui.render')
133 } catch {
134 // No reading yet; the first session.measure fills it.
135 }
136}
137
138function warn($: EngineInterface) {
139 for (const l of limits) {
140 for (const at of TOAST_AT) {
141 const key = toastKey(l, at)
142 if (l.percentUsed < at || toasted.has(key)) continue
143 toasted.add(key)
144 if (hasSeeded) $.ui.toast(`${LIMIT_LABEL[l.kind] ?? l.kind} usage at ${Math.round(l.percentUsed)}%`, { timeoutMs: 8000 })
145 }
146 }
147}
148
149function toastKey(l: SessionRateLimit, at: number) {
150 return `${l.kind}:${l.resetsAt ?? ''}:${at}`
151}
152
153/** Elapsed share of the window, pace against it, and when usage hits 100% if that beats the reset. */
154function windowState(l: SessionRateLimit, now: number) {
155 const resetInMs = l.resetsAt ? Date.parse(l.resetsAt) - now : 0
156 const span = WINDOW_MS[l.kind]
157 if (!span || !(resetInMs > 0)) return { resetInMs, elapsed: undefined, pace: undefined, emptyInMs: undefined }
158 const elapsedMs = Math.max(0, span - resetInMs)
159 const elapsed = Math.min(1, elapsedMs / span)
160 if (elapsed < 0.05 || l.percentUsed <= 0) return { resetInMs, elapsed, pace: undefined, emptyInMs: undefined }
161 const ratio = l.percentUsed / 100 / elapsed
162 const pace: Pace = ratio > 1.25 ? 'hot' : ratio < 0.75 ? 'cool' : 'steady'
163 const toFull = l.percentUsed < 100 ? ((100 - l.percentUsed) / l.percentUsed) * elapsedMs : 0
164 const emptyInMs = ratio > 1 && toFull < resetInMs ? toFull : undefined
165 return { resetInMs, elapsed, pace, emptyInMs }
166}
167
168/** Block meter of `cells` whole cells; any usage fills at least one. */
169function gauge(pct: number, cells: number) {
170 const p = Math.min(100, Math.max(0, pct))
171 const full = p > 0 ? Math.max(1, Math.round((p / 100) * cells)) : 0
172 return { used: '█'.repeat(full), rest: '░'.repeat(cells - full) }
173}
174
175function addReading(context: SessionContextUsage) {
176 if (!context.window || !context.tokens) return
177 const percent = Math.round(context.percent ?? (context.tokens / context.window) * 100)
178 readings = [...readings, { tokens: context.tokens, window: context.window, percent }].slice(-HISTORY)
179}
180
181function forecastIndex(percent: number) {
182 const i = FORECAST.findIndex(b => percent < b.upTo)
183 return i === -1 ? FORECAST.length - 1 : i
184}
185
186function chart() {
187 const top = Math.max(...readings.map(r => r.tokens), 1)
188 return readings.map(r => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]).join('')
189}
190
191function trendText() {
192 const [prev, cur] = readings.slice(-2)
193 if (!prev || !cur) return ''
194 const delta = cur.tokens - prev.tokens
195 if (delta > 0) return `▲ +${short(delta)}`
196 if (delta < 0) return `▼ ${short(-delta)}`
197 return ''
198}
199
200function pctColor(p: number) {
201 return p >= 80 ? 'red' : p >= 50 ? 'yellow' : 'green'
202}
203
204function fmtDuration(ms: number) {
205 const s = Math.floor(ms / 1000)
206 const d = Math.floor(s / 86400)
207 const h = Math.floor((s % 86400) / 3600)
208 const m = Math.floor((s % 3600) / 60)
209 if (d > 0) return `${d}d${h}h`
210 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
211 return `${m}m`
212}
213
214function short(n: number) {
215 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
216 if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`
217 return String(n)
218}
219