SLOPSHOPPER

pace-meter

A band above the prompt: a pace bar and a forecast for the 5-hour and weekly plan limits.

newbandprocesstimer
A shopper browsing a rack in a slop shop
README

pace-meter

A Claude Code mod that draws your plan's usage limits just above the prompt, as a pace bar and a forecast: will the limit last until it refills, and if not, when does it run dry?

pace-meter above the Claude Code prompt, with example figures

What it shows

One row per limit: the 5-hour window and the weekly one.

  • The bar fills with the share of the limit you have used.
  • The white post ┃ is the clock: how far through the window you are. Fill past the post means you are spending faster than the limit refills.
  • The sentence says it in words: on track for about 54% by Mon 2:00pm, or runs dry at 1:20pm, 1h 40m before the 3:00pm refill.
  • Mint means room to spare, amber that you would end at 85% or more, coral that it runs dry first.

The forecast assumes you keep going at your average pace so far in the window. In the first tenth of a window it says too early to tell instead.

Install

Inside Claude Code:

/plugin marketplace add Triyambak-CA/claude-mods
/plugin install pace-meter@triyambak-mods

Then start a new session. The band appears once Claude Code has a usage reading, usually straight away, otherwise after your first reply.

Needs and caveats

  • A Claude subscription. The figures are your plan's rate limits. On an API key there are none, and the band stays hidden.
  • Mods are early access. It was built and tested on Claude Code 2.1.287; a later update may change the API it uses.
  • macOS or Linux for local times. The mod sandbox has no time zone, so it asks the system once with date +%z. Where that command is missing (Windows), times show in UTC.

How it works

Claude Code pushes the usage figures to the mod after each reply (session.measure); the mod keeps them in its state and draws the rows in the band above the prompt (ui.render on AbovePrompt). A one-minute timer moves the clock, so the post and the forecast keep moving while you are idle.

  • hooks/register.tsx: the hooks.
  • hooks/pace.ts: the arithmetic, as plain data. At the refill you will have used used x window / elapsed; above 100% it runs dry (100 - used) x elapsed / used seconds from now.

Tests

claude plugin validate .
claude plugin test .

15 checks: the rows for running dry, on track, landing exactly on 100%, a run-dry time after midnight, the widest row (93 columns), too early, at the cap and already reset; plus the band itself through Claude Code's engine on terminal and desktop, the one-minute clock, and stepping aside for a survey.

Licence

MIT, see the repository's LICENSE.

Source 3 files
hooks/register.tsx 77 lines
1// pace-meter: a band above the Claude Code prompt with a pace bar and a forecast
2// for the plan's 5-hour and weekly limits.
3//
4//   5-hour  ██████┃██▋░░░░░░  60%  runs dry at 1:20pm, 1h 40m before the 3:00pm refill
5//   weekly  ████▊░░░┃░░░░░░░  30%  on track for about 54% by Mon 2:00pm
6//
7// The bar fills with the share used; the white post is the clock, how far through
8// the window it is. The engine pushes the figures through `session.measure`; a
9// one-minute timer moves the clock so the post and the sentence advance while idle.
10// Nothing shows until the first reading (a fresh session, or an API key).
11
12import { atom, read, update } from 'claude-code'
13import type { Register } from 'claude-code'
14
15import type { Limit } from '../types'
16import { paceRows, parseOffset } from './pace'
17
18const limitsAtom = atom({ plugin: 'pace-meter', key: 'limits' } as const, [] as Limit[])
19const nowAtom = atom({ plugin: 'pace-meter', key: 'now' } as const, 0)
20const offsetAtom = atom({ plugin: 'pace-meter', key: 'offset' } as const, 0)
21
22const KINDS = new Set(['five_hour', 'seven_day'])
23const keep = (limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] =>
24  limits.filter(l => KINDS.has(l.kind)).map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
25
26export const register: Register = on => {
27  on('session.start', async ($, e, next) => {
28    const result = await next(e)
29
30    // The sandbox has no time zone; ask the Mac once.
31    const zone = await $.process.run(['date', '+%z']).catch(() => undefined)
32    const offset = (zone && zone.exitCode === 0 && parseOffset(zone.stdout)) || 0
33    await update($, offsetAtom, () => offset)
34
35    // A reload mid-session still has the last reading.
36    const usage = await $.session.usage()
37    await update($, limitsAtom, () => keep(usage.rateLimits))
38    const now = await $.clock.now()
39    await update($, nowAtom, () => now)
40
41    $.clock.every(60_000, () => {
42      void $.clock.now().then(t => update($, nowAtom, () => t))
43    })
44    return result
45  })
46
47  on('session.measure', async ($, e, next) => {
48    if (e.changed.includes('rateLimits')) {
49      await update($, limitsAtom, () => keep(e.rateLimits))
50      const now = await $.clock.now()
51      await update($, nowAtom, () => now)
52    }
53    return next(e)
54  })
55
56  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
57    if (e.props.hasSurvey) return next(e)
58    const rows = paceRows(await read($, limitsAtom), await read($, nowAtom), await read($, offsetAtom))
59    if (rows.length === 0) return next(e)
60
61    const { Box, Text } = $.ui.resolve(e)
62    return (
63      <Box flexDirection="column">
64        {rows.map(row => (
65          <Box flexDirection="row">
66            {row.runs.map((run, i) => (
67              <Text color={run.color} bold={run.bold} wrap={i === row.runs.length - 1 ? 'truncate-end' : 'truncate'}>
68                {run.text}
69              </Text>
70            ))}
71          </Box>
72        ))}
73      </Box>
74    )
75  })
76}
77
hooks/pace.ts 136 lines
1// The pace bar and forecast, as plain data. It began as a jq status line script and
2// was ported line for line, so both draw the same rows for the same figures
3// (tests/pace.test.ts pins those rows).
4//
5//   at refill = used x window / elapsed      above 100% it runs dry, and when:
6//   runs dry  = (100 - used) x elapsed / used  seconds from now
7//
8// Times are local: the sandbox has no time zone, so callers pass the Mac's UTC
9// offset in seconds and every clock is read with UTC getters after adding it.
10
11import type { Limit } from '../types'
12
13export type Run = { text: string; color: string; bold?: boolean }
14export type State = 'reset' | 'cap' | 'early' | 'dry' | 'tight' | 'easy'
15export type Row = { kind: string; state: State; runs: Run[]; words: string }
16
17export const COLOR = {
18  fg: '#E6E6E6', dim: '#707070', track: '#3E3E3E',
19  mint: '#99FFE4', amber: '#FFC799', coral: '#FF8A8A', post: '#FFFFFF',
20} as const
21
22const WINDOW: Record<string, number> = { five_hour: 18000, seven_day: 604800 }
23const LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'weekly' }
24const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
25const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
26
27const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x))
28
29function wall(sec: number, offset: number) {
30  const d = new Date((sec + offset) * 1000)
31  return { day: d.getUTCDay(), date: d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate(),
32           h: d.getUTCHours(), m: d.getUTCMinutes() }
33}
34function clock(sec: number, offset: number) {
35  const w = wall(sec, offset)
36  return `${w.h % 12 || 12}:${String(w.m).padStart(2, '0')}${w.h >= 12 ? 'pm' : 'am'}`
37}
38const dayClock = (sec: number, offset: number) => `${DAYS[wall(sec, offset).day]} ${clock(sec, offset)}`
39/** A moment told after another: the day only when its date differs from that one. */
40const after = (sec: number, ref: number, offset: number) =>
41  wall(sec, offset).date === wall(ref, offset).date ? clock(sec, offset) : dayClock(sec, offset)
42
43function span(seconds: number) {
44  const s = Math.floor(seconds)
45  if (s < 60) return '<1m'
46  if (s >= 86400) return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`
47  if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
48  return `${Math.floor(s / 60)}m`
49}
50
51/** Green under half, amber to 80, coral from there: how close the share used is to the cap. */
52const tone = (pct: number) => (pct >= 80 ? COLOR.coral : pct >= 50 ? COLOR.amber : COLOR.mint)
53
54/** "+0530" (from `date +%z`) as seconds east of UTC; undefined when it does not parse. */
55export function parseOffset(text: string): number | undefined {
56  const m = /^([+-])(\d{2})(\d{2})/.exec(text.trim())
57  if (!m) return undefined
58  return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 3600 + Number(m[3]) * 60)
59}
60
61/** One window's row, or null for a window this band does not draw (a gateway spend limit). */
62export function paceRow(limit: Limit, nowSec: number, offset: number): Row | null {
63  const len = WINDOW[limit.kind]
64  const label = LABEL[limit.kind]
65  const resetsAt = limit.resetsAt === undefined ? NaN : Math.floor(Date.parse(limit.resetsAt) / 1000)
66  if (len === undefined || label === undefined || !Number.isFinite(resetsAt)) return null
67
68  const u = limit.percentUsed
69  const t = Math.floor(nowSec)
70  const left = resetsAt - t
71  const elapsed = len - left
72  const ef = elapsed / len
73  const fill = clamp(Math.floor((u * 128) / 100 + 0.5), 0, 128)
74  const post = clamp(Math.floor(ef * 16), 0, 15)
75  const refill = limit.kind === 'seven_day' ? dayClock(resetsAt, offset) : after(resetsAt, t, offset)
76
77  let state: State
78  let words: string
79  if (left <= 0) {
80    state = 'reset'; words = 'refilled · updates after the next reply'
81  } else if (u >= 100) {
82    state = 'cap'; words = `at the cap · refills ${refill}`
83  } else if (ef < 0.1) {
84    state = 'early'; words = `too early to tell · refills ${refill}`
85  } else {
86    const atRefill = (u * len) / elapsed
87    if (atRefill > 100) {
88      const dry = ((100 - u) * elapsed) / u
89      state = 'dry'
90      words = `runs dry at ${after(t + dry, t, offset)}, ${span(left - dry)} before the ${after(resetsAt, t + dry, offset)} refill`
91    } else {
92      state = atRefill >= 85 ? 'tight' : 'easy'
93      words = `on track for about ${Math.round(atRefill)}% by ${refill}`
94    }
95  }
96
97  const fillColor = { dry: COLOR.coral, cap: COLOR.coral, tight: COLOR.amber, easy: COLOR.mint,
98                      early: tone(Math.round(u)), reset: COLOR.track }[state]
99  const pct = state === 'reset' ? '--' : state === 'cap' ? '100%' : `${Math.round(u)}%`
100  const full = fill >> 3
101  const part = fill & 7
102
103  const cells: Run[] = [{ text: label.padEnd(8), color: COLOR.fg }]
104  for (let i = 0; i < 16; i++) {
105    if (state !== 'reset' && i === post) cells.push({ text: '┃', color: COLOR.post, bold: true })
106    else if (state !== 'reset' && i < full) cells.push({ text: '█', color: fillColor })
107    else if (state !== 'reset' && i === full && part > 0) cells.push({ text: EIGHTHS[part] ?? '', color: fillColor })
108    else cells.push({ text: '░', color: COLOR.track })
109  }
110  cells.push({ text: ' ' + pct.padStart(4), color: state === 'reset' ? COLOR.dim : tone(Math.round(u)) })
111  cells.push({ text: '  ', color: COLOR.fg })
112  cells.push({ text: words, color: state === 'early' || state === 'reset' ? COLOR.dim : fillColor })
113
114  // merge neighbours of one look, so a row is a handful of Text runs, not 20
115  const runs: Run[] = []
116  for (const c of cells) {
117    const last = runs[runs.length - 1]
118    if (last && last.color === c.color && !!last.bold === !!c.bold) last.text += c.text
119    else runs.push({ ...c })
120  }
121  return { kind: limit.kind, state, runs, words }
122}
123
124/** The rows to draw, 5-hour first; empty when there is nothing to show yet. */
125export function paceRows(limits: readonly Limit[], nowMs: number, offset: number): Row[] {
126  const order = ['five_hour', 'seven_day']
127  return order
128    .map(kind => limits.find(l => l.kind === kind))
129    .filter((l): l is Limit => l !== undefined)
130    .map(l => paceRow(l, nowMs / 1000, offset))
131    .filter((r): r is Row => r !== null)
132}
133
134/** A row's text with the colours removed, for tests and for logs. */
135export const plain = (row: Row) => row.runs.map(r => r.text).join('')
136
types/index.d.ts 16 lines
1/** One plan window as the engine reports it (SessionRateLimit), kept as received. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'pace-meter': {
7      /** The 5-hour and weekly windows from the latest measurement, 5-hour first. */
8      limits: Limit[]
9      /** Epoch milliseconds the band draws against; a timer moves it every minute. */
10      now: number
11      /** The Mac's offset from UTC in seconds (+19800 in India), read once at start. */
12      offset: number
13    }
14  }
15}
16