SLOPSHOPPER

limit-meter

Your plan's 5-hour and weekly usage limits above the prompt: percent used, time to reset, used today, with warnings at 75% and 90%.

newpanebandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-meter
│ ┃ Limits ✕ › fix the failing auth test and add an audit log call │ ┃ 5-hour limit │ ┃ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ This session: $0.42 ⏺ Update(src/auth.ts) │ ┃ Updated just now · from Claude Code's latest ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Refresh ] [ Close ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /limits │ ⎿ limit-meter: 5-hour 31% │ │ ⟨Claude Code's own drawing⟩ 5h ██░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 5h ██░░░░░░ 31%
Pane · Limits
5-hour limit 31% used ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ This session: $0.42 Updated just now · from Claude Code's latest response [ Refresh ] [ Close ]
Source 3 files
hooks/register.tsx 222 lines
1// Limit Meter: your plan's usage limits, live, like a usage counter for
2// Claude Code. One line above the prompt shows the 5-hour and weekly windows
3// (percent used, time to reset, how much of the week you used today), toasts
4// warn at 75%, 90% and 100%, and /limits opens a pane with the details.
5//
6// The readings are the ones Claude Code itself gets back with every response,
7// so they refresh as you work. With an API key there are no plan limits, and
8// only the session cost is shown.
9//
10// LIMIT_METER=status shows it in the status line instead; LIMIT_METER=off
11// hides the line (warnings and /limits keep working).
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { LimitDayStart, LimitReading } from '../types'
17import { ago, bar, color, crossed, day, label, order, pct, shortLabel, untilReset, when } from './format'
18
19const PANE = 'limit-meter'
20
21const limits = atom({ plugin: 'limit-meter', key: 'limits' } as const, [])
22const usd = atom({ plugin: 'limit-meter', key: 'usd' } as const, null)
23const updatedAt = atom({ plugin: 'limit-meter', key: 'updatedAt' } as const, 0)
24const now = atom({ plugin: 'limit-meter', key: 'now' } as const, 0)
25const dayStart = atom({ plugin: 'limit-meter', key: 'dayStart' } as const, null)
26
27type Display = 'band' | 'status' | 'off'
28// Each Claude Code session loads its own copy of this module, so one variable per module is per session.
29// (Keying a map by $ is not allowed: $ may only be used as $.noun.method(...), and the module refuses to load.)
30let display: Display = 'band'
31
32export const register: Register = on => {
33  on('session.start', async ($, e, next) => {
34    const result = await next(e)
35    const mode = ((await $.env.get('LIMIT_METER')) ?? 'band').toLowerCase()
36    display = mode === 'status' || mode === 'off' ? (mode as Display) : 'band'
37    const saved = await $.store.get('dayStart')
38    if (saved !== undefined && saved !== null) await update($, dayStart, () => saved as LimitDayStart)
39    try {
40      await $.command.register({ name: 'limits', description: 'Show your 5-hour and weekly usage limits' })
41    } catch (error) {
42      $.ui.log(`limit-meter: could not register /limits (${String(error)})`, { to: 'debug' })
43    }
44    await refresh($)
45    // Move the countdowns along, and pick up readings from background work.
46    $.clock.every(60_000, () => refresh($))
47    return result
48  })
49
50  on('turn.complete', async ($, e, next) => {
51    const result = await next(e)
52    await refresh($)
53    return result
54  })
55
56  on('command.run', { command: 'limits' }, async $ => {
57    await refresh($)
58    await $.ui.open({ id: PANE, title: 'Limits', focus: true, closeOnEscape: true })
59    const list = order(await read($, limits))
60    if (list.length === 0) return { text: 'No plan limits reported yet.' }
61    return { text: list.map(l => `${label(l.kind)} ${pct(l.percentUsed)}`).join(' · ') }
62  })
63
64  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
65    const below = await next(e)
66    if (display !== 'band' || e.props.hasSurvey) return below
67    const list = order(await read($, limits))
68    if (list.length === 0) return below
69    const t = await read($, now)
70    const start = await read($, dayStart)
71    const { Box, Text } = $.ui.resolve(e)
72    const roomy = e.props.bodyColumns >= 76
73
74    return (
75      <Box flexDirection="column">
76        {below}
77        <Box key="limit-meter" flexDirection="row" paddingX={1} gap={2}>
78          {list.map(l => {
79            const todayPct = l.kind === 'seven_day' ? today(l, start) : undefined
80            return (
81              <Box key={`lim-${l.kind}`} flexDirection="row">
82                <Text dimColor>{shortLabel(l.kind)} </Text>
83                {roomy && <Text color={color(l.percentUsed)}>{bar(l.percentUsed, 8)} </Text>}
84                <Text color={color(l.percentUsed)} bold>
85                  {pct(l.percentUsed)}
86                </Text>
87                {l.resetsAt !== undefined && <Text dimColor> · {untilReset(l.resetsAt, t)}</Text>}
88                {todayPct !== undefined && todayPct > 0 && (
89                  <Text dimColor> · today +{pct(todayPct)}</Text>
90                )}
91              </Box>
92            )
93          })}
94        </Box>
95      </Box>
96    )
97  })
98
99  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
100    const { Box, Text, Button } = $.ui.resolve(e)
101    const list = order(await read($, limits))
102    const t = await read($, now)
103    const start = await read($, dayStart)
104    const cost = await read($, usd)
105    const at = await read($, updatedAt)
106    const cells = Math.max(10, Math.min(40, e.props.bodyColumns - 12))
107
108    return (
109      <Box flexDirection="column" width={e.props.bodyColumns}>
110        {list.length === 0 ? (
111          <Box flexDirection="column">
112            <Text bold>No plan limits reported yet.</Text>
113            <Text dimColor>
114              They appear after Claude's first response when you sign in with a Claude subscription (Pro or Max). With an
115              API key there are no plan limits, only the cost below.
116            </Text>
117          </Box>
118        ) : (
119          list.map(l => {
120            const todayPct = l.kind === 'seven_day' ? today(l, start) : undefined
121            return (
122              <Box key={`pane-${l.kind}`} flexDirection="column" marginBottom={1}>
123                <Box flexDirection="row" justifyContent="space-between">
124                  <Text bold>{label(l.kind)} limit</Text>
125                  <Text color={color(l.percentUsed)} bold>
126                    {pct(l.percentUsed)} used
127                  </Text>
128                </Box>
129                <Text color={color(l.percentUsed)}>{bar(l.percentUsed, cells)}</Text>
130                {l.resetsAt !== undefined && (
131                  <Text dimColor>
132                    Resets in {untilReset(l.resetsAt, t)} · {when(l.resetsAt)}
133                  </Text>
134                )}
135                {todayPct !== undefined && todayPct > 0 && (
136                  <Text dimColor>Used today: +{pct(todayPct)} of the week</Text>
137                )}
138              </Box>
139            )
140          })
141        )}
142        {cost !== null && <Text>This session: ${cost.toFixed(2)}</Text>}
143        <Text dimColor>
144          Updated {at === 0 ? 'never' : ago(at, t)} · from Claude Code's latest response
145        </Text>
146        <Box flexDirection="row" marginTop={1} gap={1}>
147          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => { refresh($).catch(e => $.ui.log(String(e), { to: 'debug' })) }} />
148          <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
149        </Box>
150      </Box>
151    )
152  })
153}
154
155/** How much of the week was used since the first reading of the local day. */
156function today(weekly: LimitReading, start: LimitDayStart | null): number | undefined {
157  if (start === null || start.resetsAt !== weekly.resetsAt) return undefined
158  return Math.max(0, Math.round((weekly.percentUsed - start.percent) * 10) / 10)
159}
160
161async function refresh($: EngineInterface): Promise<void> {
162  const t = await $.clock.now()
163  await update($, now, () => t)
164  let usage
165  try {
166    usage = await $.session.usage()
167  } catch {
168    return
169  }
170  if (usage.cost !== undefined) await update($, usd, () => usage.cost!.usd)
171
172  const fresh: LimitReading[] = usage.rateLimits.map(r => {
173    const reading: LimitReading = { kind: r.kind, percentUsed: r.percentUsed }
174    const resets = r.resetsAt === undefined ? NaN : Date.parse(r.resetsAt)
175    if (!Number.isNaN(resets)) reading.resetsAt = resets
176    return reading
177  })
178  // No reading yet this session (or an API key): keep what we had.
179  if (fresh.length === 0) return
180  await update($, limits, () => fresh)
181  await update($, updatedAt, () => t)
182
183  await trackDay($, fresh, t)
184  await warn($, fresh, t)
185  if (display === 'status') {
186    await $.ui.status(order(fresh).map(l => `${shortLabel(l.kind)} ${pct(l.percentUsed)}`).join(' · '))
187  }
188}
189
190async function trackDay($: EngineInterface, fresh: readonly LimitReading[], t: number) {
191  const weekly = fresh.find(l => l.kind === 'seven_day')
192  if (weekly === undefined) return
193  const current = await read($, dayStart)
194  const date = day(t)
195  if (current !== null && current.date === date && current.resetsAt === weekly.resetsAt) return
196  const start: LimitDayStart = { date, percent: weekly.percentUsed }
197  if (weekly.resetsAt !== undefined) start.resetsAt = weekly.resetsAt
198  await update($, dayStart, () => start)
199  await $.store.set('dayStart', start)
200}
201
202async function warn($: EngineInterface, fresh: readonly LimitReading[], t: number) {
203  const stored = await $.store.get('warned')
204  const warned = new Set(Array.isArray(stored) ? (stored as string[]) : [])
205  let changed = false
206  for (const l of fresh) {
207    const levels = crossed(l.percentUsed)
208    const window = `${l.kind}|${l.resetsAt ?? ''}`
209    const fresher = levels.filter(level => !warned.has(`${window}|${level}`))
210    if (fresher.length === 0) continue
211    for (const level of fresher) warned.add(`${window}|${level}`)
212    changed = true
213    const top = fresher[fresher.length - 1]!
214    const reset = l.resetsAt !== undefined ? ` · resets in ${untilReset(l.resetsAt, t)}` : ''
215    $.ui.toast(
216      top >= 100
217        ? `Limit Meter: ${label(l.kind)} limit reached${reset}`
218        : `Limit Meter: ${label(l.kind)} limit at ${pct(l.percentUsed)}${reset}`,
219    )
220  }
221  if (changed) await $.store.set('warned', [...warned].slice(-60))
222}
hooks/format.ts 80 lines
1// Pure formatting for Limit Meter: labels, bars, colors and countdowns.
2
3import type { LimitReading } from '../types'
4
5export function label(kind: string): string {
6  if (kind === 'five_hour') return '5-hour'
7  if (kind === 'seven_day') return 'Weekly'
8  if (kind === 'spend_limit') return 'Spend'
9  const m = /^seven_day_(\w+)$/.exec(kind)
10  if (m) return `Weekly ${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)}`
11  return kind.replace(/_/g, ' ')
12}
13
14/** Short label for the one-line band. */
15export function shortLabel(kind: string): string {
16  if (kind === 'five_hour') return '5h'
17  if (kind === 'seven_day') return 'week'
18  if (kind === 'spend_limit') return 'spend'
19  const m = /^seven_day_(\w+)$/.exec(kind)
20  return m ? `week ${m[1]}` : kind.replace(/_/g, ' ')
21}
22
23export function color(percent: number): string {
24  if (percent >= 90) return 'red'
25  if (percent >= 75) return 'magenta'
26  if (percent >= 50) return 'yellow'
27  return 'green'
28}
29
30export function bar(percent: number, cells: number): string {
31  const filled = Math.max(0, Math.min(cells, Math.round((Math.min(percent, 100) / 100) * cells)))
32  return '█'.repeat(filled) + '░'.repeat(cells - filled)
33}
34
35export function pct(percent: number): string {
36  return `${Math.round(percent * 10) / 10}%`
37}
38
39/** "42m", "2h 13m", or past a day "Thu 09:00". */
40export function untilReset(resetsAt: number | undefined, now: number): string {
41  if (resetsAt === undefined) return ''
42  const ms = resetsAt - now
43  if (ms <= 0) return 'resetting'
44  const minutes = Math.ceil(ms / 60_000)
45  if (minutes < 60) return `${minutes}m`
46  if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
47  return when(resetsAt)
48}
49
50const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
51const pad = (n: number) => String(n).padStart(2, '0')
52
53/** "Thu 09:00" in local time. */
54export function when(ms: number): string {
55  const d = new Date(ms)
56  return `${DAYS[d.getDay()]} ${pad(d.getHours())}:${pad(d.getMinutes())}`
57}
58
59export function day(ms: number): string {
60  const d = new Date(ms)
61  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
62}
63
64export function ago(ms: number, now: number): string {
65  const s = Math.max(0, Math.round((now - ms) / 1000))
66  if (s < 60) return 'just now'
67  const m = Math.floor(s / 60)
68  return m < 60 ? `${m} min ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`
69}
70
71/** Short windows first: 5-hour, then weekly ones, then the rest. */
72export function order(limits: readonly LimitReading[]): LimitReading[] {
73  const rank = (k: string) => (k === 'five_hour' ? 0 : k === 'seven_day' ? 1 : k.startsWith('seven_day') ? 2 : 3)
74  return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind) || a.kind.localeCompare(b.kind))
75}
76
77/** The warning thresholds a reading has crossed, highest last. */
78export function crossed(percent: number): number[] {
79  return [75, 90, 100].filter(t => percent >= t)
80}
types/index.d.ts 26 lines
1export type LimitReading = {
2  /** `five_hour`, `seven_day`, `spend_limit`, or another window Claude Code reports. */
3  kind: string
4  /** 0 to 100 (past 100 on an exceeded spend limit). */
5  percentUsed: number
6  /** When the window resets, ms since the epoch. */
7  resetsAt?: number
8}
9
10/** The weekly reading at the start of the local day, for "used today". */
11export type LimitDayStart = { date: string; percent: number; resetsAt?: number }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'limit-meter': {
16      limits: LimitReading[]
17      /** Session cost in US dollars, when the host keeps a ledger. */
18      usd: number | null
19      /** When the readings were last refreshed. */
20      updatedAt: number
21      /** Ticks every minute so the countdowns move. */
22      now: number
23      dayStart: LimitDayStart | null
24    }
25  }
26}