SLOPSHOPPER

jr-limits

Plan limits in one quiet row above the prompt: the 5-hour and 7-day fill and when the fullest resets, an alert from 80 %, a toast at 80 % and 95 %. /jrlimits…

newpanebandcommandtoasttimer
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-limits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-limits
│ ┃ jr-limits ✕ › fix the failing auth test and add an audit log call │ ┃ jr limits │ ┃ 5h ████████·················· 31% resets ⏺ Read(src/auth.ts) │ ┃ resets Invalid Date ⎿ 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 │ │ › /jrlimits │ ⎿ jr-limits: jr limits · hidden (/jrlimits to bring it back) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-limits
jr limits 5h ████████·················· 31% resets NaNm resets Invalid Date
README

jr-limits

Your plan limits in one quiet row above the prompt, aligned to the right: the fill of the 5-hour and 7-day windows, coloured green, amber or red, and when the fullest one resets.

                                                              5h 2% · 7d 48% ↻45h
  • From 80 % on any window, the row moves to the left as an alert:
▲ 7d limit 84% · resets in 45h
  • A toast when a window crosses 80 % and again at 95 %, once per window.
  • /jrlimits details opens every window in a pane, as gradient gauges with the exact reset time.
  • Countdowns refresh every minute.
  • Nothing is drawn when the session has no plan-limit figures (API-key sessions, or before the first reply).

Commands

Command
/jrlimitsHide the band above the prompt, or bring it back
/jrlimits hide · showHide it or show it explicitly (off and on work too)
/jrlimits detailsEvery window in a pane, as gauges with its exact reset time

Settings (/config)

OptionDefault
Show the bandonThe one-row band above the prompt

Install

/plugin install jr-limits --marketplace jrlprost/claude-code-mods

Access

Reads the session's rate-limit figures, the same ones the status line uses. Writes nothing, runs nothing, sends nothing over the network.

Source 5 files
hooks/register.tsx 184 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrLimitsView } from '../types'
5import { gauge, gradientText } from './jr/draw'
6import { GRADIENTS, colorAt } from './jr/gradient'
7import { countdown, crossed, fullest, labelOf, levelColor, shortCountdown, windowsOf } from './limits'
8
9const view = atom({ plugin: 'jr-limits', key: 'view' } as const, null)
10const bandHidden = atom({ plugin: 'jr-limits', key: 'isBandHidden' } as const, false)
11const band = { isLoaded: false }
12
13/** `hide`/`off`, `show`/`on`, or a bare command, which toggles; anything else is not about the band. */
14const bandVerb = (args: string) => {
15  const arg = args.trim().toLowerCase()
16  if (arg === 'hide' || arg === 'off') return 'hide' as const
17  if (arg === 'show' || arg === 'on') return 'show' as const
18  return arg === '' ? ('toggle' as const) : null
19}
20
21/** Hidden or shown is one choice for every session, kept across restarts. */
22async function isBandHidden($: EngineInterface) {
23  if (!band.isLoaded) {
24    band.isLoaded = true
25    if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
26  }
27  return read($, bandHidden)
28}
29
30async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
31  const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
32  await $.store.set('isBandHidden', hide)
33  await update($, bandHidden, () => hide)
34  return { text: hide ? 'jr limits · hidden (/jrlimits to bring it back)' : 'jr limits · shown' }
35}
36const PANE = 'jr-limits'
37const THRESHOLDS = [80, 95]
38const ALERT_AT = 80
39const TICK_MS = 60_000
40
41// Module state: which thresholds were already announced, per window and reset time.
42const live = { isTicking: false, warned: new Map<string, number[]>() }
43
44async function refresh($: EngineInterface) {
45  const usage = await $.session.usage()
46  const now = await $.clock.now()
47  const windows = windowsOf(usage.rateLimits)
48  await update($, view, () => ({ windows, now }))
49  for (const w of windows) {
50    const key = `${w.kind}:${w.resetsAt ?? ''}`
51    const before = live.warned.get(key) ?? []
52    const fresh = crossed(w.percent, before, THRESHOLDS)
53    if (fresh.length === 0) continue
54    live.warned.set(key, [...before, ...fresh])
55    $.ui.toast(`jr limits · ${labelOf(w.kind)} window at ${Math.round(w.percent)}%${w.resetsAt ? `, resets in ${countdown(w.resetsAt, now)}` : ''}`)
56  }
57}
58
59/** Starts the minute tick that keeps countdowns current; once per module load. */
60async function ensureTicking($: EngineInterface) {
61  if (live.isTicking) return
62  live.isTicking = true
63  await refresh($)
64  $.clock.every(TICK_MS, () => {
65    void refresh($)
66  })
67}
68
69type Elements = ReturnType<EngineInterface['ui']['resolve']>
70
71function drawWindow(Box: Elements['Box'], Text: Elements['Text'], w: JrLimitsView['windows'][number], now: number, width: number) {
72  const color = colorAt(GRADIENTS.heat, w.percent / 100)
73  return (
74    <Box>
75      <Text color="#6b7089">{`${labelOf(w.kind).padEnd(5)} `}</Text>
76      {gauge(Text, w.percent / 100, width, GRADIENTS.heat)}
77      <Text bold color={color}>{` ${String(Math.round(w.percent)).padStart(3)}%`}</Text>
78      {w.resetsAt !== null && <Text dimColor>{` resets ${countdown(w.resetsAt, now)}`}</Text>}
79    </Box>
80  )
81}
82
83export const register: Register = (on, options) => {
84  const showBand = options.showBand !== false
85
86  on('session.start', async ($, e, next) => {
87    const started = await next(e)
88    await $.command.register({ name: 'jrlimits', description: 'Show or hide the limits band; /jrlimits details opens the full view' })
89    await ensureTicking($)
90    return started
91  })
92
93  on('turn.complete', async ($, e, next) => {
94    const done = await next(e)
95    await ensureTicking($)
96    await refresh($)
97    return done
98  })
99
100  on('command.run', { command: 'jrlimits' }, async ($, e) => {
101    const verb = bandVerb(e.args)
102    if (verb) return setBand($, verb)
103    await ensureTicking($)
104    await refresh($)
105    const v = await read($, view)
106    if (!v || v.windows.length === 0) return { text: 'jr limits · no plan-limit figures yet (they arrive with the first reply on a subscription).' }
107    await $.ui.open({ id: PANE, title: 'jr limits', rows: v.windows.length * 2 + 3 })
108    return { text: 'jr limits opened.' }
109  })
110
111  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
112    const { Box, Text } = $.ui.resolve(e)
113    const v = await read($, view)
114    if (!v || v.windows.length === 0) return <Text dimColor>No plan-limit figures yet.</Text>
115    const width = Math.max(10, Math.min(48, (e.props.bodyColumns ?? 80) - 30))
116    return (
117      <Box flexDirection="column">
118        {gradientText(Text, 'jr limits', GRADIENTS.brand)}
119        {v.windows.map(w => (
120          <Box flexDirection="column">
121            {drawWindow(Box, Text, w, v.now, width)}
122            <Text dimColor>{w.resetsAt !== null ? `      resets ${new Date(w.resetsAt).toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' })}` : ''}</Text>
123          </Box>
124        ))}
125      </Box>
126    )
127  })
128
129  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130    const below = await next(e)
131    const v = await read($, view)
132    if (!v && !live.isTicking) {
133      $.clock.after(0, () => {
134        void ensureTicking($)
135      })
136    }
137    // Never write state while drawing: the saved choice is loaded just after.
138    if (!band.isLoaded) {
139      $.clock.after(0, () => {
140        void isBandHidden($)
141      })
142    }
143    if (await read($, bandHidden)) return below
144    if (!showBand || !v || v.windows.length === 0 || e.props.hasSurvey) return below
145    const { Box, Text } = $.ui.resolve(e)
146    const top = fullest(v.windows)!
147    const width = Math.max(20, e.props.bodyColumns)
148    // Something needs attention: one alert row on the left, the fullest window and its reset.
149    if (top.percent >= ALERT_AT) {
150      const color = top.percent >= 95 ? '#ef4565' : '#ff8c42'
151      const resets = top.resetsAt !== null ? ` · resets in ${shortCountdown(top.resetsAt, v.now)}` : ''
152      return (
153        <Box flexDirection="column">
154          <Box key="jr-limits-row" width={width}>
155            <Text wrap="truncate-end">
156              <Text color={color}>{`▲ ${labelOf(top.kind)} limit ${Math.round(top.percent)}%`}</Text>
157              <Text dimColor>{resets}</Text>
158            </Text>
159          </Box>
160          {below}
161        </Box>
162      )
163    }
164    // Otherwise one quiet row on the right: each window's fill, and when the fullest resets.
165    const shown = v.windows.slice(0, 2)
166    return (
167      <Box flexDirection="column">
168        <Box key="jr-limits-row" width={width} justifyContent="flex-end">
169          <Text wrap="truncate-end">
170            {shown.map((w, i) => (
171              <Text>
172                <Text dimColor>{`${i > 0 ? ' · ' : ''}${labelOf(w.kind)} `}</Text>
173                <Text color={levelColor(w.percent)}>{`${Math.round(w.percent)}%`}</Text>
174              </Text>
175            ))}
176            {top.resetsAt !== null && <Text dimColor>{` ↻${shortCountdown(top.resetsAt, v.now)}`}</Text>}
177          </Text>
178        </Box>
179        {below}
180      </Box>
181    )
182  })
183}
184
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
hooks/limits.ts 42 lines
1import type { JrLimitWindow } from '../types'
2
3const ORDER: Record<string, number> = { five_hour: 0, seven_day: 1, spend_limit: 2 }
4
5export const labelOf = (kind: string) =>
6  kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind === 'spend_limit' ? 'spend' : kind.replace(/_/g, ' ')
7
8/** The engine's rate-limit rows, sorted 5h, 7d, spend, then the rest. */
9export const windowsOf = (rows: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): JrLimitWindow[] =>
10  rows
11    .map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : null }))
12    .sort((a, b) => (ORDER[a.kind] ?? 9) - (ORDER[b.kind] ?? 9))
13
14/** "1h12", "42m", "3d 4h" until `at`. */
15export const countdown = (at: number | null, now: number) => {
16  if (at === null) return ''
17  const minutes = Math.max(0, Math.round((at - now) / 60_000))
18  if (minutes >= 48 * 60) return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
19  if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
20  return `${minutes}m`
21}
22
23/** Thresholds crossed by this reading that were not crossed before (e.g. [80] then [95]). */
24export const crossed = (percent: number, alreadyWarned: readonly number[], thresholds: readonly number[]) =>
25  thresholds.filter(t => percent >= t && !alreadyWarned.includes(t))
26
27/** The short countdown of the one-row band: "3d", "45h", "42m". */
28export const shortCountdown = (at: number | null, now: number) => {
29  if (at === null) return ''
30  const minutes = Math.max(0, Math.round((at - now) / 60_000))
31  if (minutes >= 48 * 60) return `${Math.floor(minutes / 1440)}d`
32  if (minutes >= 60) return `${Math.floor(minutes / 60)}h`
33  return `${minutes}m`
34}
35
36/** Green under 50 %, amber under 80 %, red from 80 %. */
37export const levelColor = (percent: number) => (percent >= 80 ? '#ef4565' : percent >= 50 ? '#f4d35e' : '#2cb67d')
38
39/** The window that matters most: the fullest one. */
40export const fullest = (windows: readonly JrLimitWindow[]) =>
41  windows.reduce<JrLimitWindow | null>((top, w) => (top === null || w.percent > top.percent ? w : top), null)
42
types/index.d.ts 11 lines
1/** One plan-limit window as drawn: how much is used and when it resets (epoch ms). */
2export type JrLimitWindow = { kind: string; percent: number; resetsAt: number | null }
3
4export type JrLimitsView = { windows: JrLimitWindow[]; now: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'jr-limits': { view: JrLimitsView | null; isBandHidden: boolean }
9  }
10}
11