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…

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
▲ 7d limit 84% · resets in 45h
/jrlimits details opens every window in a pane, as gradient gauges with the exact reset time.| Command | |
|---|---|
/jrlimits | Hide the band above the prompt, or bring it back |
/jrlimits hide · show | Hide it or show it explicitly (off and on work too) |
/jrlimits details | Every window in a pane, as gauges with its exact reset time |
/config)| Option | Default | |
|---|---|---|
| Show the band | on | The one-row band above the prompt |
/plugin install jr-limits --marketplace jrlprost/claude-code-mods
Reads the session's rate-limit figures, the same ones the status line uses. Writes nothing, runs nothing, sends nothing over the network.
hooks/register.tsx 184 lines1import { 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}
184hooks/jr/draw.tsx 35 lines1// 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)
35hooks/jr/gradient.ts 75 lines1// 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}
75hooks/limits.ts 42 lines1import 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)
42types/index.d.ts 11 lines1/** 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