SLOPSHOPPER

usage-band

Colorful usage band above the prompt: 5-hour and weekly limits with gradient bars, reset countdown and forecast

newbandtimer
★ 2v0.2.0MITupdated 2026-10-15tiger42/claude-mods/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 5-hour ██████████████▉ 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5-hour ██████████████▉ 31% ⟨Claude Code's own drawing⟩
README

claude-mods

Personal Claude Code mods, packaged as a plugin marketplace named local-mods.

Requires Claude Code 2.1.287 or later.

Setup

git clone https://github.com/tiger42/claude-mods.git ~/claude-mods
claude plugin marketplace add ~/claude-mods

The marketplace is a local folder, so Claude Code reads each mod straight from the clone. To update: git pull, then /reload-plugins in any open session.

Enable a mod

claude plugin install <mod>@local-mods --scope user     # all projects
claude plugin install <mod>@local-mods --scope project  # this repo, via .claude/settings.json
claude plugin disable <mod>@local-mods                  # turn off again

Or use /plugin in a session. Run /reload-plugins to load a mod into a session that is already open.

Mods

color-chooser

color-chooser: the 🎨 button in the band above the prompt and the picker with its swatches

A 🎨 button at the right end of the band above the prompt opens a picker with the session colors of /color as swatches, three to a row. A click on a swatch or its digit sets the color and closes the picker; Esc closes it unchanged. The current color carries a check, including one set with a typed /color. /colors opens the picker too, for when the band is collapsed.

While Claude is working, a color picked in the picker waits for the turn to end: Claude Code runs a mod's /color only once the session is idle, unlike a typed /color, which takes effect at once. Beside the 🎨 button the band says so (● red once Claude is done) until the color is set, a toast says it too, and in the picker its swatch carries ….

To skip the wait, check Via prompt in the picker (p; also under /config as the plugin's option). A color picked while Claude works then goes into the prompt as /color <color>, and Enter sets it at once; the band above the prompt (● Press Enter to set red) and the hint line under it say so. A draft you have typed is never replaced: with text in the prompt, the color waits for the turn as above. The last choice always stands: a /color typed while a pick waits is set again once the queue has run the pick.

The swatches are painted through the theme exactly as the prompt bar is, so they look the same in any color depth. With 256 colors or more the picker offers all eight colors and default. In 16 colors (TERM=xterm with no COLORTERM, typical over SSH) orange draws as red, blue as cyan and pink as purple, so it offers only red, green, yellow, purple, cyan and default; COLORTERM=truecolor brings the rest back if the terminal supports it. To switch it on, check that printf '\e[38;2;217;119;87m██ orange\e[0m\n' prints orange rather than red, then add export COLORTERM=truecolor to the shell profile (~/.bashrc) of the machine Claude Code runs on, the server when you connect over SSH, and start Claude Code anew. A session moved to the background (from the sessions overview) always runs in full color, whatever the terminal it was started from, so the picker there offers all eight.

Shares the band with other mods: the button sits beside whatever they draw there. Terminal only: the session color tints the terminal's prompt bar and nothing in the Desktop app, so the button and /colors stay out of other surfaces.

claude plugin install color-chooser@local-mods --scope user

usage-band

usage-band: 5-hour and weekly limit bars above the prompt (CLI)<br> <sub>Terminal</sub>

usage-band: 5-hour and weekly limit bars above the prompt (app)<br> <sub>Desktop app</sub>

usage-band: 5-hour and weekly limit bars above the prompt (VS Code)<br> <sub>VS Code extension</sub>

Band above the prompt showing the 5-hour and weekly rate limits of a Claude subscription. Each limit gets a gradient bar, a white tick for the time elapsed in the window, a countdown to the reset and a forecast at the current rate (→ ~75% at reset or ▲ full in 40m). In the terminal a light sweeps the bars while Claude works. A new session starts from the last reading of any other session on the same machine. What other mods draw in the band stays beside the bars, or moves under them when there is no room. Collapse the band with ctrl+x ctrl+a.

How to read it:

  • White tick: how much of the window has already passed. If the bar ends left of it, you are using the limit more slowly than it allows; right of it, faster.
  • Forecast: the pace so far, carried forward: used ÷ share of the window elapsed.

Draws in the terminal, in the Desktop app's Code tab and in the VS Code extension; outside the terminal the bars are drawn as SVG and grow with the window into the room beside the figures. Shows nothing for API-key accounts, which have no rate-limit windows.

claude plugin install usage-band@local-mods --scope user

Development

claude --plugin-dir ./<mod>      # load a working copy, hot-reloads on save
claude plugin validate ./<mod>   # manifest and static analysis of the hooks module
claude plugin test ./<mod>       # runs <mod>/tests/*.test.ts(x)

--plugin-dir writes type declarations to <mod>/.claude-plugin/types/ (git-ignored). Type-checking needs TypeScript 5.4 or later.

To add a mod, create its folder, list it under plugins in .claude-plugin/marketplace.json and add a section above.

License

MIT

Source 2 files
hooks/register.tsx 478 lines
1import { atom, read, update } from 'claude-code'
2import type { BoxProps, EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { UsageReading, UsageWindow } from '../types'
5
6// The latest reading this session knows, from its own responses or another session's
7const reading = atom({ plugin: 'usage-band', key: 'reading' } as const, null)
8
9// Every session writes its readings here, so a fresh session starts with the last one
10const STORE_KEY = 'reading'
11
12const MINUTE = 60_000
13const HOUR = 60 * MINUTE
14const DAY = 24 * HOUR
15const STALE_AFTER = 15 * MINUTE
16
17// How long each window runs: places the pace marker and drives the forecast
18const SPANS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
19const ORDER = ['five_hour', 'seven_day']
20const PILLS: Record<string, { label: string; color: string }> = {
21  five_hour: { label: ' 5-hour ', color: '#7c3aed' },
22  seven_day: { label: ' Weekly ', color: '#0369a1' },
23}
24
25// The bar's colors from empty to full: cyan, green, yellow, orange, rose, fuchsia
26const STOPS: [number, number][] = [
27  [0, 0x22d3ee],
28  [0.3, 0x4ade80],
29  [0.55, 0xfacc15],
30  [0.75, 0xfb923c],
31  [0.9, 0xf43f5e],
32  [1, 0xd946ef],
33]
34const TRACK = 0x30303c
35const MARKER = 0xffffff
36const DEFAULT = 0x01000000
37const SPACE = 0x20
38const BLOCK = 0x2588
39const TICK = 0x2502
40// ▏▎▍▌▋▊▉: the cell the fill ends in
41const EIGHTHS = [0x258f, 0x258e, 0x258d, 0x258c, 0x258b, 0x258a, 0x2589]
42
43// Column widths of a row: pill, percent, countdown, forecast
44const PILL_W = 8
45const PCT_W = 4
46const RESET_W = 8
47const PACE_W = 22
48const MAX_BAR = 48
49
50// Outside the terminal the bar is an SVG: its height in CSS pixels and the track's inset from it
51const BAR_H = 14
52const BAR_INSET = 2
53
54// The desktop and VS Code lay the row out themselves: the bar grows from its least width into the room the figures
55// leave. Their bodyColumns can count wider cells than the layout's (VS Code), so it only decides whether the forecast fits
56const SVG_GAP = 1
57const SVG_MIN_BAR = 10
58// A proportional "100%" runs wider than four cells
59const SVG_PCT_W = PCT_W + 1
60const SVG_RESET_W = 10
61const SVG_PACE_W = 18
62// The row's columns at their least widths, in order: the forecast shows where they all fit with the gaps between them
63const SVG_COLUMNS = [PILL_W, SVG_MIN_BAR, SVG_PCT_W, SVG_RESET_W, SVG_PACE_W]
64const SVG_PACE_FROM = SVG_COLUMNS.reduce((sum, w) => sum + w, 0) + (SVG_COLUMNS.length - 1) * SVG_GAP
65// They draw an SVG with no width of their own 300 px wide, and one wider than its box as wide as the box. That width
66// goes on the element: a width in the markup makes them scale the drawing, which blurs its pixel lengths
67const SVG_WIDEST = 4096
68
69type Bar = { key: string; width: number; used: number; marker: number | null }
70type Row = {
71  kind: string
72  label: string
73  pill: string
74  used: number
75  tip: string
76  reset: string
77  elapsed: number | null
78  pace: { text: string; color: string } | null
79}
80
81// What the band drew last, for the shimmer to repaint
82let site: { requestId: string; bars: Bar[] } | null = null
83let shimmer: Timer | null = null
84let frame = 0
85const turns = new Set<string>()
86
87export const register: Register = on => {
88  on('session.start', async ($, e, next) => {
89    await sync($)
90    // Picks up readings of other sessions and moves the countdowns on
91    $.clock.every(30_000, () => void tick($))
92
93    return next(e)
94  })
95
96  on('session.measure', async ($, e, next) => {
97    if (e.rateLimits.length > 0) {
98      const fresh: UsageReading = {
99        at: await $.clock.now(),
100        windows: e.rateLimits.map(w => ({
101          kind: w.kind,
102          percentUsed: w.percentUsed,
103          ...(w.resetsAt === undefined ? {} : { resetsAt: w.resetsAt }),
104        })),
105      }
106      await update($, reading, () => fresh)
107      await $.store.set(STORE_KEY, fresh)
108    }
109
110    return next(e)
111  })
112
113  // A light runs along the filled part of the bars while Claude works
114  on('turn.start', async ($, e, next) => {
115    turns.add(e.turnId)
116    if (shimmer === null) {
117      frame = 0
118      shimmer = $.clock.every(80, () => void shine($))
119    }
120
121    return next(e)
122  })
123
124  on('turn.complete', async ($, e, next) => {
125    turns.delete(e.turnId)
126    if (turns.size === 0 && shimmer !== null) {
127      shimmer.cancel()
128      shimmer = null
129      // A redraw with unchanged cells keeps the last shimmer frame: paint the plain bars back
130      await settle($)
131    }
132
133    return next(e)
134  })
135
136  // The band is shared: what the plugins beneath draw there stays, beside the bars or under them where there is no room
137  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
138    const data = await read($, reading)
139    if (e.props.hasSurvey || data === null || data.windows.length === 0) {
140      site = null
141
142      return next(e)
143    }
144
145    const below = await next(e)
146    const now = await $.clock.now()
147    const rows = sorted(data.windows).map(w => describe(w, now))
148    const { Box, Text } = $.ui.resolve(e)
149    const age = now - data.at
150    let lines: RenderElement[]
151    let column: BoxProps = {}
152
153    if (e.surface === 'terminal') {
154      const { Raster } = $.ui.resolve(e)
155      const hasPace = e.props.bodyColumns - (PILL_W + PCT_W + RESET_W + PACE_W + 4) >= 12
156      const fixed = hasPace ? PILL_W + PCT_W + RESET_W + PACE_W + 4 : PILL_W + PCT_W + RESET_W + 3
157      const width = Math.max(6, Math.min(MAX_BAR, e.props.bodyColumns - fixed))
158      const drawn = rows.map(row => ({
159        row,
160        bar: {
161          key: `bar-${row.kind}`,
162          width,
163          used: row.used,
164          marker: row.elapsed === null ? null : Math.min(width - 1, Math.floor(row.elapsed * width)),
165        },
166      }))
167      // Only the terminal's Rasters take the shimmer's frames
168      site = { requestId: e.requestId, bars: drawn.map(line => line.bar) }
169      lines = drawn.map(({ row, bar }) => (
170        <Box flexDirection="row" gap={1}>
171          <Text backgroundColor={row.pill} color="#ffffff" bold>
172            {row.label}
173          </Text>
174          <Raster key={bar.key} columns={width} rows={1} cells={cells(bar, null)} />
175          <Text color={row.tip} bold>
176            {`${Math.round(row.used)}%`.padStart(PCT_W)}
177          </Text>
178          <Text dimColor>{row.reset.padEnd(RESET_W)}</Text>
179          {hasPace && row.pace !== null && <Text color={row.pace.color}>{row.pace.text}</Text>}
180        </Box>
181      ))
182    } else {
183      // Every other surface (the desktop, VS Code) draws the bar as an SVG
184      const { Svg } = $.ui.resolve(e)
185      const hasPace = e.props.bodyColumns >= SVG_PACE_FROM
186      // The rows span the band, so the bars have room to grow into
187      column = { flexGrow: 1, alignItems: 'stretch' }
188      lines = rows.map(row => (
189        // The desktop and VS Code set a proportional font and fold runs of spaces: boxes of fixed width keep the columns
190        <Box flexDirection="row" alignItems="center" gap={SVG_GAP}>
191          <Box width={PILL_W} justifyContent="center" backgroundColor={row.pill}>
192            <Text color="#ffffff" bold>
193              {row.label.trim()}
194            </Text>
195          </Box>
196          {/* The SVG fills its box, and both boxes grow alike, as the boxes beside them are equally wide */}
197          <Box key={`bar-${row.kind}`} width={SVG_MIN_BAR} flexGrow={1}>
198            <Svg source={svg(row)} alt={describeBar(row)} width={SVG_WIDEST} height={BAR_H} />
199          </Box>
200          <Box minWidth={SVG_PCT_W} justifyContent="flex-end">
201            <Text color={row.tip} bold>{`${Math.round(row.used)}%`}</Text>
202          </Box>
203          <Box minWidth={SVG_RESET_W}>
204            <Text dimColor>{row.reset}</Text>
205          </Box>
206          {hasPace && (
207            <Box minWidth={SVG_PACE_W}>
208              {row.pace !== null && <Text color={row.pace.color}>{row.pace.text}</Text>}
209            </Box>
210          )}
211        </Box>
212      ))
213    }
214
215    return (
216      <Box flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={1}>
217        <Box flexDirection="column" {...column}>
218          {lines}
219          {age > STALE_AFTER && (
220            <Text dimColor italic>
221              As of {duration(age)} ago · updates with the next response
222            </Text>
223          )}
224        </Box>
225        {below}
226      </Box>
227    )
228  })
229}
230
231async function tick($: EngineInterface) {
232  await sync($)
233  $.ui.invalidate('ui.render')
234}
235
236// Takes the stored reading when it is newer than the one this session holds
237async function sync($: EngineInterface) {
238  const stored = asReading(await $.store.get(STORE_KEY))
239  const current = await read($, reading)
240  if (stored !== null && (current === null || stored.at > current.at)) {
241    await update($, reading, () => stored)
242  }
243}
244
245async function shine($: EngineInterface) {
246  if (site === null) {
247    return
248  }
249
250  frame += 1
251  const { requestId, bars } = site
252  for (const [index, bar] of bars.entries()) {
253    const fill = (Math.min(bar.used, 100) / 100) * bar.width
254    if (fill < 1) {
255      continue
256    }
257
258    // The turn may have ended while the bar before was painted
259    if (shimmer === null) {
260      return
261    }
262
263    const sweep = Math.ceil(fill) + 10
264    const head = ((frame + index * 5) % sweep) - 4
265    await $.ui.blit({ requestId, key: bar.key, cells: cells(bar, head), columns: bar.width, rows: 1 })
266  }
267}
268
269async function settle($: EngineInterface) {
270  if (site === null) {
271    return
272  }
273
274  const { requestId, bars } = site
275  for (const bar of bars) {
276    await $.ui.blit({ requestId, key: bar.key, cells: cells(bar, null), columns: bar.width, rows: 1 })
277  }
278}
279
280function asReading(value: unknown): UsageReading | null {
281  if (typeof value !== 'object' || value === null) {
282    return null
283  }
284
285  const { at, windows } = value as Partial<UsageReading>
286  if (typeof at !== 'number' || !Array.isArray(windows)) {
287    return null
288  }
289
290  return { at, windows: windows.filter(w => typeof w?.kind === 'string' && typeof w.percentUsed === 'number') }
291}
292
293function sorted(windows: UsageWindow[]): UsageWindow[] {
294  const rank = (kind: string) => (ORDER.includes(kind) ? ORDER.indexOf(kind) : ORDER.length)
295
296  return [...windows].sort((a, b) => rank(a.kind) - rank(b.kind))
297}
298
299function describe(w: UsageWindow, now: number): Row {
300  const pill = PILLS[w.kind] ?? {
301    label: ` ${w.kind.replace(/_/g, ' ').slice(0, PILL_W - 2).padEnd(PILL_W - 2)} `,
302    color: '#475569',
303  }
304  const left = w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt) - now
305  const hasReset = left <= 0
306  const used = hasReset ? 0 : w.percentUsed
307  const span = SPANS[w.kind]
308  const elapsed =
309    span === undefined || Number.isNaN(left) || hasReset ? null : Math.min(1, Math.max(0, 1 - left / span))
310
311  return {
312    kind: w.kind,
313    label: pill.label,
314    pill: pill.color,
315    used,
316    tip: hex(colorAt(Math.min(used, 100) / 100)),
317    reset: hasReset ? '↻ reset' : Number.isNaN(left) ? '' : `↻ ${duration(left)}`,
318    elapsed,
319    pace: hasReset ? null : forecast(used, elapsed, span),
320  }
321}
322
323// Where the window ends up at the current rate: the share at reset, or when it runs full
324function forecast(used: number, elapsed: number | null, span: number | undefined) {
325  if (used >= 100) {
326    return { text: '■ Limit reached', color: '#f43f5e' }
327  }
328
329  if (elapsed === null || span === undefined || elapsed < 0.03 || used < 1) {
330    return null
331  }
332
333  const projected = used / elapsed
334  if (projected <= 100) {
335    return { text: `→ ~${Math.round(projected)}% at reset`, color: projected < 80 ? '#4ade80' : '#facc15' }
336  }
337
338  return { text: `▲ full in ${duration(((100 - used) / used) * elapsed * span)}`, color: '#fb923c' }
339}
340
341function duration(ms: number): string {
342  const minutes = Math.max(1, Math.round(ms / MINUTE))
343  if (minutes < 60) {
344    return `${minutes}m`
345  }
346
347  const hours = Math.floor(minutes / 60)
348  if (hours < 24) {
349    return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
350  }
351
352  return `${Math.floor(hours / 24)}d ${hours % 24}h`
353}
354
355// One row of Raster cells: the gradient up to the fill, the track after it, the pace marker
356function cells(bar: Bar, head: number | null): string {
357  const words = new Uint32Array(bar.width * 3)
358  const fill = (Math.min(bar.used, 100) / 100) * bar.width
359  const whole = Math.floor(fill)
360  const eighths = Math.round((fill - whole) * 8)
361
362  for (let i = 0; i < bar.width; i++) {
363    const color = glow(colorAt(bar.width > 1 ? i / (bar.width - 1) : 1), i, head)
364    let glyph = SPACE
365    let fg = DEFAULT
366    let bg = TRACK
367
368    if (i < whole || (i === whole && eighths === 8)) {
369      glyph = BLOCK
370      fg = color
371      bg = color
372    } else if (i === whole && eighths > 0) {
373      glyph = EIGHTHS[eighths - 1] ?? BLOCK
374      fg = color
375    }
376
377    if (i === bar.marker) {
378      glyph = TICK
379      fg = MARKER
380    }
381
382    words.set([glyph, fg, bg], i * 3)
383  }
384
385  return base64(new Uint8Array(words.buffer))
386}
387
388// The SVG bar: the gradient up to the fill over a track, the pace marker a white line with a dark edge that shows in either theme.
389// With no viewBox it is drawn unscaled at the width its box gives it: lengths along the bar in percent, the rest in
390// pixels that stay sharp
391function svg(row: Row): string {
392  const track = BAR_H - 2 * BAR_INSET
393  const stops = STOPS.map(([at, color]) => `<stop offset="${at}" stop-color="${hex(color)}"/>`).join('')
394  // A nested svg places the marker at a share of the width and draws its lines in pixels from there, shifted so that
395  // its 3 px move along the width less 3 px: whole at either end
396  const marker =
397    row.elapsed === null
398      ? ''
399      : `<svg x="${percent(row.elapsed)}" overflow="visible">` +
400        `<rect x="${round(-3 * row.elapsed)}" width="3" height="${BAR_H}" rx="1" fill="#1e1e2a" fill-opacity="0.6"/>` +
401        `<rect x="${round(1 - 3 * row.elapsed)}" y="1" width="1" height="${BAR_H - 2}" fill="#ffffff"/></svg>`
402
403  return (
404    `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${BAR_H}">` +
405    `<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100%" y2="0">${stops}</linearGradient>` +
406    `<clipPath id="c"><rect y="${BAR_INSET}" width="100%" height="${track}" rx="${track / 2}"/></clipPath></defs>` +
407    `<g clip-path="url(#c)"><rect width="100%" height="${BAR_H}" fill="#8b8ba0" fill-opacity="0.3"/>` +
408    `<rect width="${percent(Math.min(row.used, 100) / 100)}" height="${BAR_H}" fill="url(#g)"/></g>${marker}</svg>`
409  )
410}
411
412function describeBar(row: Row): string {
413  const elapsed = row.elapsed === null ? '' : `, ${Math.round(row.elapsed * 100)}% of the window elapsed`
414
415  return `${row.label.trim()}: ${Math.round(row.used)}% used${elapsed}`
416}
417
418function percent(share: number): string {
419  return `${round(share * 100)}%`
420}
421
422function round(n: number): number {
423  return Math.round(n * 10) / 10
424}
425
426function colorAt(t: number): number {
427  let from = 0
428  let start = 0
429  for (const [to, end] of STOPS) {
430    if (t <= to) {
431      return to === from ? end : mix(start, end, (t - from) / (to - from))
432    }
433    from = to
434    start = end
435  }
436
437  return start
438}
439
440function glow(color: number, i: number, head: number | null): number {
441  if (head === null) {
442    return color
443  }
444
445  const distance = Math.abs(i - head)
446
447  return distance >= 3 ? color : mix(color, 0xffffff, (1 - distance / 3) * 0.6)
448}
449
450function mix(a: number, b: number, t: number): number {
451  const channel = (shift: number) => {
452    const from = (a >> shift) & 0xff
453    const to = (b >> shift) & 0xff
454
455    return Math.round(from + (to - from) * t) << shift
456  }
457
458  return channel(16) | channel(8) | channel(0)
459}
460
461function hex(color: number): string {
462  return `#${color.toString(16).padStart(6, '0')}`
463}
464
465const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
466
467function base64(bytes: Uint8Array): string {
468  let out = ''
469  for (let i = 0; i < bytes.length; i += 3) {
470    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
471    out += ALPHABET.charAt((n >> 18) & 63) + ALPHABET.charAt((n >> 12) & 63)
472    out += i + 1 < bytes.length ? ALPHABET.charAt((n >> 6) & 63) : '='
473    out += i + 2 < bytes.length ? ALPHABET.charAt(n & 63) : '='
474  }
475
476  return out
477}
478
types/index.d.ts 12 lines
1// One rate-limit window as the last API response reported it
2export type UsageWindow = { kind: string; percentUsed: number; resetsAt?: string }
3
4// The latest reading of all windows and when it was taken (ms since epoch)
5export type UsageReading = { at: number; windows: UsageWindow[] }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'usage-band': { reading: UsageReading | null }
10  }
11}
12