SLOPSHOPPER

limit-pace

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

newpanebandtoasttimer
v0.2.1MITupdated 2026-10-25AlexWayfer/claude-limit-pace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-pace
│ ┃ limit-pace-settings ✕ › fix the failing auth test and add an audit log call │ ┃ Week: Always ▾ │ ┃ Session: Always ▾ ⏺ Read(src/auth.ts) │ ┃ Close within, %0 ⏎ save ⎿ Read 6 lines │ ┃ Figures: Used / pace ▾ ⏺ Update(src/auth.ts) │ ┃ Usage ring: After figures ▾ ⎿ Added 2 lines, removed 1 line │ ┃ Show time to reset: Off ▾ ⏺ Bash(bun test) │ ┃ Separator: | ▾ ⎿ 3 pass, 1 fail │ ┃ [ Close ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ Session 31% / NaN% ⚙ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Session 31% / NaN% ⚙
Pane · limit-pace-settings
Week: Always ▾ Session: Always ▾ Close within, %0 ⏎ save Figures: Used / pace ▾ Usage ring: After figures ▾ Show time to reset: Off ▾ Separator: | ▾ [ Close ]
README

Limit pace

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.

Install

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.

Settings

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.

SettingValuesDefault
Week, SessionAlways, When close (yellow or red only), NeverAlways
Close within, %How far below an even pace counts as close and turns yellow0
FiguresUsed / pace (18% / 31%) or Deviation (-13%)Used / pace
Usage ringNowhere, Start, After figures, EndAfter figures
Show time to resetAdds (4d 20h)Off
Separator`\ · • / —``\`

How it works

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:

  • reads the rate-limit figures and the reset times Claude Code already has;
  • keeps the choices made in the ⚙ pane in its own plugin store ($.store), keyed choice.<setting>;
  • changes no Claude Code settings, runs no commands, and sends nothing over the network.

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.

Privacy

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.

Similar plugins

  • Usage Bars: animated bars under the prompt with a pace mark, projection and styles.

Development

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 410 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
410
types/index.d.ts 18 lines
1export 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