SLOPSHOPPER

plan-limits

Your plan's 5-hour and weekly usage above the prompt, with the time left until each resets.

newbandspinnerstatustimer
v0.1.0MITupdated 2026-10-06santtisosa/plan-limits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-limits
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ Claude │ session █▌··· 31%

Draws

Prompt hint
⟨Claude Code's own drawing⟩ Claude │ session █▌··· 31%
README

plan-limits

A Claude Code mod that keeps your plan's usage in view: the 5-hour session window and the weekly window, each with a bar, the percentage used and the time left until it resets.

Claude │ session █▌··· 31% ↻3h 16m │ weekly ▍···· 7% ↻5d 18h
  • The bar and percentage turn yellow from 70% used, or when your pace points past 85% before the reset, and red from 90%, or when you are on pace to run out (it then says so).
  • ↻3h 16m is the time until that window resets.
  • The figures come from Claude Code itself ($.session.usage()), refresh after every reply and cost no tokens. Nothing leaves your machine.

Install

Requires Claude Code 2.1.287 or later.

/plugin marketplace add santtisosa/plan-limits
/plugin install plan-limits@plan-limits

Settings (/config)

SettingDefault
Placementhinthint: at the end of the hint line under the prompt. band: a row above the prompt. status: a pinned notice under the prompt.
Bar width5Cells per bar.
Normal / Warning / Critical colorgreen / yellow / redColors of the bar and percentage.

Accounts without plan limits (API keys) have no windows to show; with status placement the line reads "plan limits: none reported yet".

Develop

claude plugin validate .
claude plugin test .

hooks/limits.ts holds the pure logic (pace, levels, bar, durations); hooks/register.tsx wires it to Claude Code.

License

MIT

Source 3 files
hooks/register.tsx 113 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit } from '../types'
5import { describeLimit, DIVIDER, formatBar, formatLine, RESET_MARK, TITLE } from './limits'
6import type { Reading } from './limits'
7
8const REFRESH_MS = 60_000
9const NO_LIMITS = 'plan limits: none reported yet'
10
11const limits = atom({ plugin: 'plan-limits', key: 'limits' } as const, [] as Limit[])
12const now = atom({ plugin: 'plan-limits', key: 'now' } as const, 0)
13
14const readingsOf = (all: readonly Limit[], at: number): Reading[] =>
15  all.map(limit => describeLimit(limit, at)).filter(reading => reading !== undefined)
16
17type Display = { placement: string; isStatus: boolean; barWidth: number }
18
19async function refresh($: EngineInterface, display: Display, latest?: readonly Limit[]) {
20  if(latest !== undefined) {
21    await update($, limits, () => [...latest])
22  }
23
24  await update($, now, () => Date.now())
25
26  if(display.isStatus) {
27    const readings = readingsOf(await read($, limits), await read($, now))
28    $.ui.status(readings.length === 0 ? NO_LIMITS : formatLine(readings, display.barWidth))
29  }
30}
31
32type Elements = ReturnType<EngineInterface['ui']['resolve']>
33type Colors = Record<Reading['level'], string>
34
35const limitsTree = (readings: readonly Reading[], barWidth: number, colors: Colors, { Box, Text }: Elements) => (
36  <Box gap={1}>
37    <Text dimColor>{TITLE}</Text>
38    {readings.map(reading => {
39      const bar = formatBar(reading.percent, barWidth)
40
41      return (
42        <Box key={reading.label} gap={1}>
43          <Text dimColor>{DIVIDER}</Text>
44          <Text>{reading.label}</Text>
45          <Box>
46            <Text color={colors[reading.level]}>{bar.filled}</Text>
47            <Text dimColor>{bar.empty}</Text>
48          </Box>
49          <Text color={colors[reading.level]} bold>{`${reading.percent}%`}</Text>
50          {reading.resetsIn === undefined ? null : <Text dimColor>{`${RESET_MARK}${reading.resetsIn}`}</Text>}
51          {reading.isOnPaceToRunOut ? <Text color={colors.hot}>on pace to run out</Text> : null}
52        </Box>
53      )
54    })}
55  </Box>
56)
57
58export const register: Register = (on, options) => {
59  const placement = String(options.placement ?? 'hint')
60  const isStatus = placement === 'status'
61  const barWidth = Number(options.barWidth ?? 5)
62  const display = { placement, isStatus, barWidth }
63  const colors = {
64    ok: String(options.okColor ?? 'green'),
65    warn: String(options.warnColor ?? 'yellow'),
66    hot: String(options.hotColor ?? 'red'),
67  }
68
69  on('session.start', async ($, e, next) => {
70    const result = await next(e)
71
72    if(!isStatus) {
73      $.ui.status(undefined)
74    }
75
76    await refresh($, display, (await $.session.usage()).rateLimits)
77    $.clock.every(REFRESH_MS, () => void refresh($, display))
78
79    return result
80  })
81
82  on('session.measure', async ($, e, next) => {
83    if(e.changed.includes('rateLimits')) {
84      await refresh($, display, e.rateLimits)
85    }
86
87    return next(e)
88  })
89
90  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
91    const line = await next(e)
92    const readings = readingsOf(await read($, limits), await read($, now))
93
94    if(placement !== 'hint' || readings.length === 0) {
95      return line
96    }
97
98    const elements = $.ui.resolve(e)
99
100    return <elements.Box gap={2}>{line}{limitsTree(readings, barWidth, colors, elements)}</elements.Box>
101  })
102
103  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104    const readings = readingsOf(await read($, limits), await read($, now))
105
106    if(placement !== 'band' || e.props.hasSurvey || readings.length === 0) {
107      return next(e)
108    }
109
110    return limitsTree(readings, barWidth, colors, $.ui.resolve(e))
111  })
112}
113
hooks/limits.ts 97 lines
1import type { Limit } from '../types'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6const MIN_ELAPSED_FOR_PACE = 0.05
7const HOT_PERCENT = 90
8const WARN_PERCENT = 70
9const HOT_PACE = 100
10const WARN_PACE = 85
11
12const WINDOWS: Record<string, { label: string; ms: number }> = {
13  five_hour: { label: 'session', ms: 5 * HOUR },
14  seven_day: { label: 'weekly', ms: 7 * DAY },
15}
16
17export type Level = 'ok' | 'warn' | 'hot'
18
19export type Reading = {
20  label: string
21  percent: number
22  resetsIn?: string
23  level: Level
24  isOnPaceToRunOut: boolean
25}
26
27const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
28
29export const formatBar = (percent: number, width: number): { filled: string; empty: string } => {
30  const cells = Math.max(1, Math.round(width))
31  const eighths = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells * 8)
32  const full = Math.floor(eighths / 8)
33  const partial = EIGHTHS[eighths % 8] ?? ''
34  const used = full + (partial === '' ? 0 : 1)
35
36  return { filled: '█'.repeat(full) + partial, empty: '·'.repeat(cells - used) }
37}
38
39export const formatDuration = (ms: number): string => {
40  const minutes = Math.max(0, Math.round(ms / MINUTE))
41  const hours = Math.floor(minutes / 60)
42  const days = Math.floor(hours / 24)
43
44  if(days > 0) {
45    return `${days}d ${hours % 24}h`
46  }
47
48  return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
49}
50
51const projectedPercent = (percent: number, msLeft: number, windowMs: number): number => {
52  const elapsed = 1 - msLeft / windowMs
53
54  return elapsed > MIN_ELAPSED_FOR_PACE ? percent / elapsed : 0
55}
56
57const levelOf = (percent: number, projected: number): Level => {
58  if(percent >= HOT_PERCENT || projected >= HOT_PACE) {
59    return 'hot'
60  }
61
62  return percent >= WARN_PERCENT || projected >= WARN_PACE ? 'warn' : 'ok'
63}
64
65export const describeLimit = (limit: Limit, now: number): Reading | undefined => {
66  const window = WINDOWS[limit.kind]
67
68  if(window === undefined) {
69    return undefined
70  }
71
72  const msLeft = limit.resetsAt === undefined ? undefined : Date.parse(limit.resetsAt) - now
73  const hasReset = msLeft !== undefined && msLeft > 0
74  const projected = hasReset ? projectedPercent(limit.percentUsed, msLeft, window.ms) : 0
75
76  return {
77    label: window.label,
78    percent: Math.round(limit.percentUsed),
79    resetsIn: hasReset ? formatDuration(msLeft) : undefined,
80    level: levelOf(limit.percentUsed, projected),
81    isOnPaceToRunOut: projected >= HOT_PACE,
82  }
83}
84
85export const TITLE = 'Claude'
86export const DIVIDER = '│'
87export const RESET_MARK = '↻'
88
89export const formatLine = (readings: readonly Reading[], barWidth: number): string =>
90  [TITLE, ...readings.map(reading => {
91    const bar = formatBar(reading.percent, barWidth)
92    const resets = reading.resetsIn === undefined ? '' : ` ${RESET_MARK}${reading.resetsIn}`
93    const pace = reading.isOnPaceToRunOut ? ' · on pace to run out' : ''
94
95    return `${reading.label} ${bar.filled}${bar.empty} ${reading.percent}%${resets}${pace}`
96  })].join(` ${DIVIDER} `)
97
types/index.d.ts 8 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'plan-limits': { limits: Limit[]; now: number }
6  }
7}
8