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

Personal Claude Code mods, packaged as a plugin marketplace named local-mods.
Requires Claude Code 2.1.287 or later.
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.
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.

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
<br> <sub>Terminal</sub>
<br> <sub>Desktop app</sub>
<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:
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
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.
hooks/register.tsx 478 lines1import { 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}
478types/index.d.ts 12 lines1// 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