Band above the prompt showing plan usage against an even pace through the weekly and 5-hour windows

A Claude Code mod that shows, above the prompt, how your plan usage compares with an even pace through the weekly and the 5-hour windows.
Week 18% / 31% ◔ · Session 19% / 12% ◔
The first figure is how much of the window you have used: green while you are behind an even pace, yellow when close to it, red when ahead. The second, dim, is how much of the window has passed: the share you would have used by now at an even pace.
In the Claude desktop app's Code tab each window also gets a usage ring, with a tick at the even pace; hover it for the details. In a terminal the line stays plain text.
It comes from the alexwayfer marketplace. At the prompt of a Claude Code terminal session:
/plugin marketplace add AlexWayfer/claude-plugins
/plugin install limit-pace@alexwayfer
The line appears after the first answer of a session: that is when Claude Code first learns your limits. It needs a claude.ai Pro or Max plan.
The plugin's settings (/config in a terminal session, or the plugin's settings page) set the defaults. Press ⚙ at the right end of the line to change them from the line itself: a choice made there is kept in the plugin's own store across sessions, until you change that setting again.
| Setting | Values | Default | ||
|---|---|---|---|---|
| Week, Session | Always, When close (yellow or red only), Never | Always | ||
| Close within, % | How far below an even pace counts as close and turns yellow | 0 | ||
| Figures | Used / pace (18% / 31%) or Deviation (-13%) | Used / pace | ||
| Usage ring | Nowhere, Start, After figures, End | After figures | ||
| Show time to reset | Adds (4d 20h) | Off | ||
| Separator | `\ | · • / —` | `\ | ` |
It is a hooks-module mod: it draws the AbovePrompt band from the rate-limit windows Claude Code reports with each answer (session.measure, $.session.usage()). A window past its reset time is hidden until a fresh reading arrives.
What it reads and writes:
$.store), keyed choice.<setting>;Known limits of the desktop app: it hides the band while its own next-step card is shown, and its plugin page shows no settings for a plugin installed from the command line.
Limit pace collects no data and sends nothing anywhere. It reads the rate-limit figures Claude Code already holds for your session, and keeps the choices you make in its ⚙ pane in the plugin's local store on your machine. Nothing leaves your computer, and there is no account, analytics or tracking.
claude plugin validate .
claude plugin test .hooks/register.tsx 410 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit } from '../types'
5
6const HOUR = 60 * 60 * 1000
7const SETTINGS_PANE = 'limit-pace-settings'
8
9// Length of each rate-limit window, to know how much of it has elapsed
10const WINDOWS: { kind: string; label: string; length: number }[] = [
11 { kind: 'seven_day', label: 'Week', length: 7 * 24 * HOUR },
12 { kind: 'five_hour', label: 'Session', length: 5 * HOUR },
13]
14
15// Same lists as the manifest's options
16const ALWAYS = 'Always'
17const WHEN_CLOSE = 'When close'
18const NEVER = 'Never'
19const VISIBILITIES = [ALWAYS, WHEN_CLOSE, NEVER]
20const USED_OVER_PACE = 'Used / pace'
21const DEVIATION = 'Deviation'
22const FORMATS = [USED_OVER_PACE, DEVIATION]
23const RING_NOWHERE = 'Nowhere'
24const RING_START = 'Start'
25const RING_AFTER_FIGURES = 'After figures'
26const RING_END = 'End'
27const RING_PLACES = [RING_NOWHERE, RING_START, RING_AFTER_FIGURES, RING_END]
28const SEPARATORS = ['|', '·', '•', '/', '—']
29
30const limits = atom({ plugin: 'limit-pace', key: 'limits' } as const, [])
31const now = atom({ plugin: 'limit-pace', key: 'now' } as const, 0)
32const weekShown = atom({ plugin: 'limit-pace', key: 'week' } as const, ALWAYS)
33const sessionShown = atom({ plugin: 'limit-pace', key: 'session' } as const, ALWAYS)
34const threshold = atom({ plugin: 'limit-pace', key: 'threshold' } as const, 0)
35const format = atom({ plugin: 'limit-pace', key: 'format' } as const, USED_OVER_PACE)
36const separator = atom({ plugin: 'limit-pace', key: 'separator' } as const, '|')
37const showReset = atom({ plugin: 'limit-pace', key: 'showReset' } as const, false)
38const ringPlace = atom({ plugin: 'limit-pace', key: 'ring' } as const, RING_AFTER_FIGURES)
39
40const toLimits = (list: SessionRateLimit[]): Limit[] =>
41 list.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
42
43const toVisibility = (value: unknown) =>
44 typeof value === 'string' && VISIBILITIES.includes(value) ? value : ALWAYS
45
46const toThreshold = (value: unknown) =>
47 typeof value === 'number' && Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : 0
48
49// How far usage runs from an even pace: behind it by more than the threshold, within the
50// threshold of it, or ahead of it
51type Level = 'behind' | 'close' | 'ahead'
52
53const levelOf = (diff: number, margin: number): Level =>
54 diff > 0 ? 'ahead' : diff >= -margin ? 'close' : 'behind'
55
56const TEXT_COLORS = { behind: 'success', close: 'warning', ahead: 'error' } as const
57
58// Fixed colors: SVG markup does not follow the app theme, so pick ones readable on dark and light
59const RING_TRACK = '#8b8b8b55'
60const RING_COLORS = { behind: '#3fb950', close: '#d29922', ahead: '#e5534b' } as const
61const RING_PACE = '#c9c9c9'
62
63const escapeXml = (text: string) =>
64 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
65
66// A usage ring like the app's own: the arc is the used share, the tick marks an even pace
67const ring = (used: number, elapsed: number, level: Level, title: string) => {
68 const radius = 6
69 const circumference = 2 * Math.PI * radius
70 const arc = (Math.min(Math.max(used, 0), 100) / 100) * circumference
71 const angle = (elapsed / 100) * 2 * Math.PI - Math.PI / 2
72 const tick = (r: number) =>
73 `${(8 + r * Math.cos(angle)).toFixed(2)} ${(8 + r * Math.sin(angle)).toFixed(2)}`
74
75 return (
76 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">` +
77 `<title>${escapeXml(title)}</title>` +
78 `<circle cx="8" cy="8" r="${radius}" fill="none" stroke="${RING_TRACK}" stroke-width="2.5"/>` +
79 `<circle cx="8" cy="8" r="${radius}" fill="none" stroke="${RING_COLORS[level]}"` +
80 ` stroke-width="2.5" stroke-dasharray="${arc.toFixed(2)} ${circumference.toFixed(2)}"` +
81 ` transform="rotate(-90 8 8)"/>` +
82 `<path d="M ${tick(radius - 2)} L ${tick(radius + 2)}" stroke="${RING_PACE}" stroke-width="1.2" stroke-linecap="round"/>` +
83 `</svg>`
84 )
85}
86
87// Time left as its two largest units: 4d 3h, 2h 15m, 40m
88const formatLeft = (ms: number) => {
89 const minutes = Math.max(0, Math.round(ms / 60000))
90 const days = Math.floor(minutes / 1440)
91 const hours = Math.floor((minutes % 1440) / 60)
92 if (days > 0) return `${days}d ${hours}h`
93 if (hours > 0) return `${hours}h ${minutes % 60}m`
94 return `${minutes}m`
95}
96
97const toFormat = (value: unknown) => (value === DEVIATION ? DEVIATION : USED_OVER_PACE)
98
99const toSeparator = (value: unknown) =>
100 typeof value === 'string' && SEPARATORS.includes(value) ? value : '|'
101
102const toRingPlace = (value: unknown) =>
103 typeof value === 'string' && RING_PLACES.includes(value) ? value : RING_AFTER_FIGURES
104
105// A choice made in the ⚙ pane is kept in the plugin's own store, not written to the settings,
106// together with the setting's value it replaced: once that setting changes, the setting wins
107type Choice = { value: unknown; option: unknown }
108
109const sameOption = (one: unknown, other: unknown) =>
110 JSON.stringify(one ?? null) === JSON.stringify(other ?? null)
111
112const chosen = async ($: EngineInterface, field: string, option: unknown) => {
113 const stored = (await $.store.get(`choice.${field}`)) as Choice | undefined
114 return stored && sameOption(stored.option, option) ? stored.value : option
115}
116
117const choose = ($: EngineInterface, field: string, value: unknown, option: unknown) =>
118 $.store.set(`choice.${field}`, { value, option: option ?? null })
119
120export const register: Register = (on, options) => {
121 on('session.start', async ($, e, next) => {
122 const week = toVisibility(await chosen($, 'week', options.week))
123 const session = toVisibility(await chosen($, 'session', options.session))
124 const margin = toThreshold(await chosen($, 'threshold', options.threshold))
125 const figures = toFormat(await chosen($, 'format', options.format))
126 const mark = toSeparator(await chosen($, 'separator', options.separator))
127 const isResetShown = (await chosen($, 'show_reset', options.show_reset)) === true
128 const place = toRingPlace(await chosen($, 'ring', options.ring))
129 await update($, weekShown, () => week)
130 await update($, sessionShown, () => session)
131 await update($, threshold, () => margin)
132 await update($, format, () => figures)
133 await update($, separator, () => mark)
134 await update($, showReset, () => isResetShown)
135 await update($, ringPlace, () => place)
136 const usage = await $.session.usage()
137 await update($, limits, () => toLimits(usage.rateLimits))
138 const at = await $.clock.now()
139 await update($, now, () => at)
140 // Elapsed share of a window grows with time alone, so redraw every minute; the limits are
141 // read again too, so a reading the engine got without a turn of ours is not missed
142 $.clock.every(60000, () => {
143 void $.session.usage().then(fresh => update($, limits, () => toLimits(fresh.rateLimits)))
144 void $.clock.now().then(t => update($, now, () => t))
145 })
146
147 return next(e)
148 })
149
150 on('session.measure', async ($, e, next) => {
151 if (e.changed.includes('rateLimits')) {
152 await update($, limits, () => toLimits(e.rateLimits))
153 }
154
155 return next(e)
156 })
157
158 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
159 if (e.props.hasSurvey) return next(e)
160
161 const list = await read($, limits)
162 const at = await read($, now)
163 const isDeviation = (await read($, format)) === DEVIATION
164 const mark = await read($, separator)
165 const isResetShown = await read($, showReset)
166 const place = await read($, ringPlace)
167 const margin = await read($, threshold)
168 const visibility: Record<string, string> = {
169 seven_day: await read($, weekShown),
170 five_hour: await read($, sessionShown),
171 }
172
173 const measured = WINDOWS.flatMap(window => {
174 const limit = list.find(one => one.kind === window.kind)
175 if (!limit?.resetsAt) return []
176 const left = Date.parse(limit.resetsAt) - at
177 // Past its reset the reading is stale (the window is fresh now); wait for a new one
178 if (left <= 0) return []
179 const elapsed = Math.min(Math.max((1 - left / window.length) * 100, 0), 100)
180 // Positive: burning faster than an even pace; negative: reserve left. Taken from the
181 // rounded figures, so 30% / 30% is exactly on pace as drawn
182 const diff = Math.round(limit.percentUsed) - Math.round(elapsed)
183 const level = levelOf(diff, margin)
184
185 const details = isResetShown ? [formatLeft(left)] : []
186
187 const tooltip =
188 `${window.label}: used ${Math.round(limit.percentUsed)}%, ` +
189 `pace ${Math.round(elapsed)}%, resets in ${formatLeft(left)}`
190
191 return [
192 { kind: window.kind, label: window.label, diff, level, details, used: limit.percentUsed, elapsed, tooltip },
193 ]
194 })
195
196 if (measured.length === 0) return next(e)
197
198 const rows = measured.filter(row => {
199 const shown = visibility[row.kind] ?? ALWAYS
200 return shown === ALWAYS || (shown === WHEN_CLOSE && row.level !== 'behind')
201 })
202
203 const elements = $.ui.resolve(e)
204 const { Box, Button, Text } = elements
205
206 // Either the signed deviation from an even pace, or the used share (in the pace color)
207 // over the even-pace share (dim)
208 const paceText = (row: (typeof rows)[number]) => {
209 const color = TEXT_COLORS[row.level]
210
211 return isDeviation ? (
212 <Text>
213 {row.label}{' '}
214 <Text color={color}>
215 {row.diff > 0 ? '+' : ''}
216 {row.diff}%
217 </Text>
218 </Text>
219 ) : (
220 <Text>
221 {row.label}{' '}
222 <Text color={color}>{Math.round(row.used)}%</Text>
223 {` / ${Math.round(row.elapsed)}%`}
224 </Text>
225 )
226 }
227 const detailsText = (row: (typeof rows)[number]) =>
228 row.details.length > 0 ? `(${row.details.join(', ')})` : ''
229
230 // Desktop draws a usage ring per window; the terminal keeps one line of text
231 const Svg =
232 place !== RING_NOWHERE && e.surface === 'desktop' && 'Svg' in elements
233 ? elements.Svg
234 : undefined
235 const figures =
236 Svg ? (
237 <Box flexDirection="row" alignItems="center" gap="sm">
238 {rows.map((row, index) => {
239 const usageRing = (
240 <Svg
241 source={ring(row.used, row.elapsed, row.level, row.tooltip)}
242 alt={row.tooltip}
243 width={16}
244 height={16}
245 isInteractive
246 />
247 )
248
249 return (
250 <Box key={row.label} flexDirection="row" alignItems="center" gap="xs">
251 {index > 0 && <Text dimColor>{mark}</Text>}
252 {place === RING_START && usageRing}
253 <Text dimColor tabular>
254 {paceText(row)}
255 </Text>
256 {place === RING_AFTER_FIGURES && usageRing}
257 {row.details.length > 0 && (
258 <Text dimColor tabular>
259 {detailsText(row)}
260 </Text>
261 )}
262 {place === RING_END && usageRing}
263 </Box>
264 )
265 })}
266 </Box>
267 ) : (
268 <Text dimColor tabular>
269 {rows.map((row, index) => (
270 <Text>
271 {index > 0 ? ` ${mark} ` : ''}
272 {paceText(row)}
273 {row.details.length > 0 ? ` ${detailsText(row)}` : ''}
274 </Text>
275 ))}
276 </Text>
277 )
278
279 // With every window hidden the gear stays alone: the desktop has no other way to the settings
280 return (
281 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
282 {rows.length > 0 ? figures : <Text> </Text>}
283 <Button
284 key="settings"
285 label="⚙"
286 plain
287 dimColor
288 onPress={() => void $.ui.open({ id: SETTINGS_PANE, title: 'Limit pace', focus: true })}
289 />
290 </Box>
291 )
292 })
293
294 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => {
295 const elements = $.ui.resolve(e)
296 // The mobile app draws no Select yet
297 if (!('Select' in elements)) {
298 const { Text } = elements
299
300 return <Text dimColor>Settings are not available here.</Text>
301 }
302 const { Box, Button, Input, Select, Text } = elements
303 const week = await read($, weekShown)
304 const session = await read($, sessionShown)
305 const margin = await read($, threshold)
306 const figures = await read($, format)
307 const place = await read($, ringPlace)
308 const isResetShown = await read($, showReset)
309 const mark = await read($, separator)
310
311 // In the order the band draws what they change
312 return (
313 <Box flexDirection="column" gap="sm">
314 <Select
315 key="week"
316 label="Week"
317 options={VISIBILITIES.map(value => ({ value }))}
318 value={week}
319 onSelect={value => {
320 void update($, weekShown, () => value)
321 void choose($, 'week', value, options.week)
322 }}
323 />
324 <Select
325 key="session"
326 label="Session"
327 options={VISIBILITIES.map(value => ({ value }))}
328 value={session}
329 onSelect={value => {
330 void update($, sessionShown, () => value)
331 void choose($, 'session', value, options.session)
332 }}
333 />
334 {/* Input takes no width of its own: a narrow Box keeps a percent field from spanning the
335 pane. The desktop's width unit is about 4 px, the terminal's a cell */}
336 <Box key="threshold-row" flexDirection="row" alignItems="center" gap="sm">
337 <Text>Close within, %</Text>
338 <Box key="threshold-field" width={e.surface === 'desktop' ? 16 : 10}>
339 <Input
340 key="threshold"
341 placeholder="0"
342 value={String(margin)}
343 submitLabel="save"
344 onSubmit={text => {
345 const value = Number(text.trim().replace(',', '.'))
346 if (text.trim() === '' || !Number.isFinite(value) || value < 0 || value > 100) {
347 $.ui.toast('Limit pace: the threshold is a number from 0 to 100')
348 return
349 }
350 void update($, threshold, () => value)
351 void choose($, 'threshold', value, options.threshold)
352 }}
353 />
354 </Box>
355 </Box>
356 <Select
357 key="format"
358 label="Figures"
359 options={FORMATS.map(value => ({ value }))}
360 value={figures}
361 onSelect={value => {
362 void update($, format, () => value)
363 void choose($, 'format', value, options.format)
364 }}
365 />
366 <Select
367 key="ring"
368 label="Usage ring"
369 options={RING_PLACES.map(value => ({ value }))}
370 value={place}
371 onSelect={value => {
372 void update($, ringPlace, () => value)
373 void choose($, 'ring', value, options.ring)
374 }}
375 />
376 <Select
377 key="show_reset"
378 label="Show time to reset"
379 options={[
380 { value: 'on', label: 'On' },
381 { value: 'off', label: 'Off' },
382 ]}
383 value={isResetShown ? 'on' : 'off'}
384 onSelect={value => {
385 const isOn = value === 'on'
386 void update($, showReset, () => isOn)
387 void choose($, 'show_reset', isOn, options.show_reset)
388 }}
389 />
390 <Select
391 key="separator"
392 label="Separator"
393 options={SEPARATORS.map(value => ({ value }))}
394 value={mark}
395 onSelect={value => {
396 void update($, separator, () => value)
397 void choose($, 'separator', value, options.separator)
398 }}
399 />
400 <Button
401 key="close"
402 label="Close"
403 role="dismiss"
404 onPress={() => void $.ui.close({ id: SETTINGS_PANE })}
405 />
406 </Box>
407 )
408 })
409}
410types/index.d.ts 18 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'limit-pace': {
6 limits: Limit[]
7 now: number
8 week: string
9 session: string
10 threshold: number
11 format: string
12 separator: string
13 showReset: boolean
14 ring: string
15 }
16 }
17}
18