Context bar plus 5h and 7d rate limits with time left until each resets, above the prompt

Context bar plus your 5-hour and weekly rate limits, each with time left until it resets. Shown above the prompt; updates after every turn and ticks every 30s.
ctx ████░░░░░░ 41% 5h 42% · 27m 7d 6% · 6d 18h
/plugin install usage-limits --marketplace katipally/modz
Percentages turn yellow at 60% and red at 85%: the context bar is orange below that, the 5h and 7d values green. Labels and reset times stay uncolored; the time after each limit is how long until it resets. Rate limits show on subscription accounts only.
hooks/register.tsx 126 lines1import { atom, read, update } from 'claude-code'
2import type { Register, BoxProps, ElementConstructor, EngineInterface, RenderElement, RenderNode, SessionContextUsage, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Usage } from '../types'
5
6// Reset countdowns read in minutes, so a 30s tick keeps them honest.
7const TICK_MS = 30_000
8const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
9
10const usage = atom({ plugin: 'usage-limits', key: 'usage' } as const, { limits: [] })
11const now = atom({ plugin: 'usage-limits', key: 'now' } as const, 0)
12const shownPct = atom({ plugin: 'usage-limits', key: 'shownPct' } as const, 0)
13
14// The bar eases toward a new value over a few frames instead of jumping.
15const GROW_MS = 40
16let grower: Timer | undefined
17const grow = async ($: EngineInterface, target: number) => {
18 grower?.cancel()
19 grower = $.clock.every(GROW_MS, async () => {
20 const shown = await read($, shownPct)
21 const step = Math.sign(target - shown) * Math.max(0.5, Math.abs(target - shown) / 5)
22 const nextPct = Math.abs(target - shown) <= 0.5 ? target : shown + step
23 await update($, shownPct, () => nextPct)
24 if (nextPct === target) grower?.cancel()
25 })
26}
27
28const toUsage = (context: SessionContextUsage, limits: SessionRateLimit[]): Usage => ({
29 contextPercent: context.percent,
30 limits: limits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
31})
32
33// "6d 18h" past a day, "2h 5m" past an hour, else "27m".
34const span = (ms: number) => {
35 const m = Math.max(0, Math.round(ms / 60_000))
36 const [d, h] = [Math.floor(m / 1440), Math.floor((m % 1440) / 60)]
37 return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`
38}
39
40// Every modz mod places its tile in one shared band: a lead column (the bot), then a column of tiles, then an optional
41// right column. A column with no tiles is not drawn, and columns wrap under each other when the width runs out.
42const BAND = 'modz-band'
43const SLOTS: Record<string, 'lead' | 'left' | 'right'> = {
44 buddy: 'lead',
45 'usage-limits': 'left',
46 'cache-timer': 'left',
47 'model-info': 'left',
48 orchestrator: 'left',
49}
50const ORDER = Object.keys(SLOTS)
51const keyOf = (n: RenderNode) => (typeof n !== 'string' && n.type === 'Box' ? String(n.props?.key ?? '') : '')
52const rank = (n: RenderNode) => {
53 const i = ORDER.indexOf(keyOf(n))
54 return i < 0 ? ORDER.length : i
55}
56const pack = (Box: ElementConstructor<BoxProps>, below: RenderElement, tile: RenderElement) => {
57 const isEmpty = below.type === 'Box' && !below.children?.length
58 const tiles = keyOf(below) === BAND
59 ? (below.type === 'Box' ? below.children ?? [] : []).flatMap(col => (typeof col !== 'string' && col.type === 'Box' ? col.children ?? [] : [col]))
60 : isEmpty ? [] : [below]
61 const all = [...tiles, tile].sort((a, b) => rank(a) - rank(b))
62 const [lead, left, right] = (['lead', 'left', 'right'] as const).map(slot => all.filter(n => (SLOTS[keyOf(n)] ?? 'left') === slot))
63 return (
64 <Box key={BAND} flexDirection="row" flexWrap="wrap" columnGap={3}>
65 {lead!.length > 0 && <Box key="modz-lead" flexDirection="column" justifyContent="center">{lead}</Box>}
66 {left!.length > 0 && <Box key="modz-left" flexDirection="column" flexGrow={right!.length > 0 ? 1 : 0}>{left}</Box>}
67 {right!.length > 0 && <Box key="modz-right" flexDirection="column" alignItems="flex-end">{right}</Box>}
68 </Box>
69 )
70}
71
72// Each tile has its own Claude theme color; values turn yellow at 60% and red at 85% as they fill.
73const TINT = 'claude'
74const tone = (pct: number, calm: string) => (pct >= 85 ? 'error' : pct >= 60 ? 'warning' : calm)
75
76export const register: Register = on => {
77 on('session.start', async ($, e, next) => {
78 const u = await $.session.usage()
79 await update($, usage, () => toUsage(u.context, u.rateLimits))
80 if (u.context.percent !== undefined) await grow($, u.context.percent)
81 await update($, now, () => Date.now())
82 $.clock.every(TICK_MS, () => void update($, now, () => Date.now()))
83 return next(e)
84 })
85
86 on('session.measure', async ($, e, next) => {
87 await update($, usage, () => toUsage(e.context, e.rateLimits))
88 if (e.context.percent !== undefined) await grow($, e.context.percent)
89 return next(e)
90 })
91
92 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93 const below = await next(e)
94 if (e.props.hasSurvey) return below
95 const { Box, Text } = $.ui.resolve(e)
96 const [u, , shown] = await Promise.all([read($, usage), read($, now), read($, shownPct)])
97 const t = Date.now()
98 const pct = u.contextPercent
99 if (pct === undefined && u.limits.length === 0) return below
100
101 const width = Math.max(5, Math.min(10, Math.floor(e.props.bodyColumns / 12)))
102 const filled = pct === undefined ? 0 : Math.min(width, Math.max(0, Math.round((shown / 100) * width)))
103 const metric = (label: string, value: number, detail?: string) => (
104 <Text>
105 <Text dimColor>{label} </Text>
106 <Text bold color={tone(value, 'success')}>{Math.round(value)}%</Text>
107 {detail && <Text dimColor> · {detail}</Text>}
108 </Text>
109 )
110
111 return pack(Box, below, (
112 <Box key="usage-limits" flexDirection="row" flexWrap="wrap" columnGap={3}>
113 {pct !== undefined && (
114 <Text>
115 <Text color={TINT}>{'ctx'.padEnd(5)} </Text>
116 <Text color={tone(pct, TINT)}>{'█'.repeat(filled)}</Text>
117 <Text dimColor>{'░'.repeat(width - filled)} </Text>
118 <Text bold color={tone(pct, TINT)}>{Math.round(pct)}%</Text>
119 </Text>
120 )}
121 {u.limits.map(l => metric(LABELS[l.kind] ?? l.kind, l.percentUsed, l.resetsAt && span(Date.parse(l.resetsAt) - t)))}
122 </Box>
123 ))
124 })
125}
126types/index.d.ts 10 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = { contextPercent?: number; limits: Limit[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'usage-limits': { usage: Usage; now: number; shownPct: number }
8 }
9}
10