SLOPSHOPPER

usage-limits

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

newbandtimer
v0.2.0no licenseupdated 2026-10-10katipally/modz/mods/ui/usage-limits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-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 ctx ░░░░░░░░ 49% 5h 31% · NaNm ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx ░░░░░░░░ 49% 5h 31% · NaNm ⟨Claude Code's own drawing⟩
README

usage-limits

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

Install

/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.

Source 2 files
hooks/register.tsx 126 lines
1import { 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}
126
types/index.d.ts 10 lines
1export 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