SLOPSHOPPER

usage-band

Shows your 5-hour and 7-day Claude usage limits in a band above the prompt

newbandtimer
v0.5.0no licenseupdated 2026-10-08stef-bulteel/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 5h ━━━━━━━━━━━━ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ━━━━━━━━━━━━ 31%
Source 2 files
hooks/register.tsx 164 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4import type { Limits, Window } from '../types'
5
6const limits = atom({ plugin: 'usage-band', key: 'limits' } as const, [] as Limits)
7const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
8
9const BAR_CELLS = 12
10
11// The account-wide windows first, then any other window the engine reports
12// (a per-model one such as Fable's), in the order it reports them. A spend
13// limit is a gateway's budget, not a usage window, so it stays out.
14const ORDER = ['five_hour', 'seven_day']
15
16export function toLimits(rateLimits: SessionRateLimit[]): Limits {
17  return rateLimits
18    .filter(r => r.kind !== 'spend_limit')
19    .map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
20    .sort((a, b) => rank(a.kind) - rank(b.kind))
21}
22
23function rank(kind: string): number {
24  const i = ORDER.indexOf(kind)
25  return i === -1 ? ORDER.length : i
26}
27
28// 'five_hour' → '5h', 'seven_day' → '7d', 'seven_day_fable' → 'Fable'.
29export function labelOf(kind: string): string {
30  if (kind === 'five_hour') return '5h'
31  if (kind === 'seven_day') return '7d'
32  const model = kind.replace(/^(five_hour|seven_day)_?/, '')
33  return model ? model.charAt(0).toUpperCase() + model.slice(1).replace(/_/g, ' ') : kind
34}
35
36export function bar(percent: number): [string, string] {
37  const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS)))
38  return ['━'.repeat(filled), '━'.repeat(BAR_CELLS - filled)]
39}
40
41export function untilReset(resetsAt: string | undefined, nowMs: number): string {
42  if (!resetsAt || !nowMs) return ''
43  const mins = Math.max(0, Math.round((Date.parse(resetsAt) - nowMs) / 60000))
44  if (Number.isNaN(mins)) return ''
45  const d = Math.floor(mins / 1440)
46  const h = Math.floor((mins % 1440) / 60)
47  const m = mins % 60
48  if (d > 0) return `resets in ${d}d ${h}h`
49  if (h > 0) return `resets in ${h}h ${m}m`
50  return `resets in ${m}m`
51}
52
53// Claude's clay palette: a lighter-to-deeper gradient per severity.
54const FILLS: Record<string, [string, string]> = {
55  claude: ['#E8A07F', '#D97757'],
56  warning: ['#EBC07A', '#D4943A'],
57  error: ['#E88A7A', '#C6493A'],
58}
59
60// No viewBox: the pill stretches to whatever width its flex slot gives it,
61// while rx stays in real pixels so the rounded ends never distort.
62export function pillSvg(percent: number, height = 6): string {
63  const [from, to] = FILLS[tone(percent)]
64  const r = height / 2
65  const p = Math.max(0, Math.min(100, percent))
66  return (
67    `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${height}">` +
68    `<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs>` +
69    `<rect width="100%" height="${height}" rx="${r}" fill="#D97757" fill-opacity="0.16"/>` +
70    (p > 0 ? `<rect width="${p}%" style="width:max(${height}px, ${p}%)" height="${height}" rx="${r}" fill="url(#g)"/>` : '') +
71    `</svg>`
72  )
73}
74
75export function tone(percent: number): string {
76  if (percent >= 90) return 'error'
77  if (percent >= 75) return 'warning'
78  return 'claude'
79}
80
81export const register: Register = on => {
82  on('session.start', async ($, e, next) => {
83    const result = await next(e)
84    const usage = await $.session.usage()
85    await update($, limits, () => toLimits(usage.rateLimits))
86    const t0 = await $.clock.now()
87    await update($, now, () => t0)
88    $.clock.every(60_000, () => {
89      void (async () => {
90        const t = await $.clock.now()
91        await update($, now, () => t)
92      })()
93    })
94    return result
95  })
96
97  on('session.measure', async ($, e, next) => {
98    if (e.changed.includes('rateLimits')) {
99      await update($, limits, () => toLimits(e.rateLimits))
100      const t = await $.clock.now()
101      await update($, now, () => t)
102    }
103    return next(e)
104  })
105
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    if (e.props.hasSurvey) return next(e)
108
109    const windows = await read($, limits)
110    if (windows.length === 0) return next(e)
111
112    const nowMs = await read($, now)
113    const els = $.ui.resolve(e)
114    const { Box, Text } = els
115    const Svg = 'Svg' in els ? els.Svg : null
116
117    const meter = (w: Window) => {
118      const label = labelOf(w.kind)
119      const isGeneral = ORDER.includes(w.kind)
120      const pct = Math.round(w.percentUsed)
121      // The time left until the window resets stands in for its name.
122      const left = untilReset(w.resetsAt, nowMs).replace('resets in ', '')
123      // A model's own window keeps its name, so it is not mistaken for the 7d one.
124      const lead = isGeneral ? left || label : left ? `${label} ${left}` : label
125      let gauge
126      if (Svg) {
127        gauge = (
128          <Box flexGrow={1} flexShrink={1} minWidth={8}>
129            <Svg source={pillSvg(w.percentUsed)} alt={`${label} ${pct}% used`} height={6} />
130          </Box>
131        )
132      } else {
133        const [full, empty] = bar(w.percentUsed)
134        gauge = (
135          <Text>
136            <Text color={tone(w.percentUsed)}>{full}</Text>
137            <Text color="subtle">{empty}</Text>
138          </Text>
139        )
140      }
141      return (
142        <Box key={w.kind} flexDirection="row" alignItems="center" gap={2} flexGrow={1} flexShrink={1}>
143          <Box flexShrink={0}>
144            <Text color="subtle" wrap="truncate">{lead}</Text>
145          </Box>
146          {gauge}
147          <Box flexShrink={0}>
148            <Text color={tone(w.percentUsed)} bold wrap="truncate">{`${pct}%`}</Text>
149          </Box>
150        </Box>
151      )
152    }
153
154    return (
155      <Box flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={3} width="100%">
156        {windows.flatMap((w, i) => [
157          ...(i > 0 ? [<Text key={`sep-${w.kind}`} color="subtle">│</Text>] : []),
158          meter(w),
159        ])}
160      </Box>
161    )
162  })
163}
164
types/index.d.ts 9 lines
1export type Window = { kind: string; percentUsed: number; resetsAt?: string }
2export type Limits = Window[]
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-band': { limits: Limits; now: number }
7  }
8}
9