A band above the prompt showing your 5-hour and 7-day limits, context window and prompt-cache hit rate, styled for the terminal and the desktop app

A Claude Code mod that puts a one-line band above the prompt with what you need to keep an eye on while you work:
Each metric has its own color and turns red when it needs attention: a limit or the context past 80%, or a cache hit rate under 50%. The limit bars carry a slow shine that sweeps left to right, in step across bars.
Terminal
5h ■■■■■■■■ 78% · 1h18m 7d ■■■■■■■■ 48% · 5d3h 398K/1M 100%
The line fits itself to the terminal width: on a narrow terminal it drops the bars first, then the countdowns.
Desktop app (Code tab)
A single centered row drawn as SVG: limit bars with the figure and reset time beside them, the context window as a 2×10 dot matrix (each dot is 5% of the window), and the cache hit rate. Hairlines separate the groups. It follows the app's light and dark mode.
/plugin marketplace add JetsonChan/CC-Usage-Band
/plugin install usage-band@cc-usage-band
Then open a new session (or run /reload-plugins). Nothing to configure. Best in Ghostty, which ships the icon font.
Terminal icons are picked automatically: Nerd Font icons in Ghostty, plain Unicode elsewhere. To override, set USAGE_BAND_ICONS in your shell profile to auto, nerd, unicode or ascii, e.g. export USAGE_BAND_ICONS=unicode.
Run /usage-band-preview to see the band in every terminal style side by side, including how a 256-color terminal shows the colors.
Mods run with the same access as Claude Code itself; they are not sandboxed. This one only:
$.session.usage, session.measure, turn.complete)TERM_PROGRAM and USAGE_BAND_ICONS environment variables to pick terminal icons/usage-band-preview command and draws the bandIt reads no files, runs no processes and makes no network requests.
claude plugin validate .
claude plugin test .
hooks/register.tsx 528 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Measure, Style, TurnTokens } from '../types'
5
6const measure = atom({ plugin: 'usage-band', key: 'measure' } as const, null)
7const turn = atom({ plugin: 'usage-band', key: 'turn' } as const, null)
8const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
9const phase = atom({ plugin: 'usage-band', key: 'phase' } as const, 0)
10const style = atom({ plugin: 'usage-band', key: 'style' } as const, 'unicode')
11
12// One hue per metric; RED takes over only when a metric is in trouble
13const HUE = { five: '#5cc4d6', seven: '#e8a25f', ctx: '#9aa5f5', cache: '#72cf9f' }
14const RED = '#e5685f'
15const TRACK = '#4a4f5c'
16const FRAME_MS = 200
17const PREVIEW = 'usage-band-preview'
18
19export const GLYPHS: Record<Style, { ctx: string; hit: string; fill: string; track: string }> = {
20 nerd: { ctx: '\u{F0328} ', hit: '\u{F04FE} ', fill: '■', track: '■' },
21 unicode: { ctx: '≡ ', hit: '● ', fill: '■', track: '■' },
22 ascii: { ctx: 'ctx ', hit: 'hit ', fill: '#', track: '-' },
23}
24
25// Terminals known to ship Nerd Font symbols without the user installing a font
26const NERD_BUILTIN = new Set(['ghostty'])
27
28export const detectStyle = (setting: string, termProgram: string | undefined): Style => {
29 if (setting === 'nerd' || setting === 'unicode' || setting === 'ascii') return setting
30 return NERD_BUILTIN.has((termProgram ?? '').toLowerCase()) ? 'nerd' : 'unicode'
31}
32
33export const fmtTokens = (n: number): string => {
34 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
35 if (n >= 1_000) return `${+(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}K`
36 return String(n)
37}
38
39export const fmtLeft = (ms: number): string => {
40 const mins = Math.max(0, Math.round(ms / 60_000))
41 const d = Math.floor(mins / 1440)
42 const h = Math.floor((mins % 1440) / 60)
43 const m = mins % 60
44 if (d > 0) return `${d}d${h}h`
45 if (h > 0) return `${h}h${m}m`
46 return `${m}m`
47}
48
49export const hitRate = (t: TurnTokens): number | null => {
50 const total = t.input + t.cacheRead + t.cacheWrite
51 return total === 0 ? null : Math.round((t.cacheRead / total) * 100)
52}
53
54// Blend a #rrggbb color toward white (t > 0) or black (t < 0) by |t|
55export const lighten = (hex: string, t: number): string => {
56 const n = parseInt(hex.slice(1), 16)
57 const target = t >= 0 ? 255 : 0
58 const ch = (v: number) => Math.round(v + (target - v) * Math.abs(t)).toString(16).padStart(2, '0')
59 return `#${ch((n >> 16) & 255)}${ch((n >> 8) & 255)}${ch(n & 255)}`
60}
61
62// Nearest xterm-256 color, to preview how a 256-color terminal shows the band
63export const to256 = (hex: string): string => {
64 const n = parseInt(hex.slice(1), 16)
65 const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const
66 const levels = [0, 95, 135, 175, 215, 255]
67 const nearest = (v: number) => levels.reduce((a, b) => (Math.abs(b - v) < Math.abs(a - v) ? b : a))
68 const cube = rgb.map(nearest)
69 const avg = (rgb[0] + rgb[1] + rgb[2]) / 3
70 const g = Math.min(238, Math.max(8, 8 + Math.round((avg - 8) / 10) * 10))
71 const dist = (c: readonly number[]) => c.reduce((s, v, i) => s + (v - rgb[i]!) ** 2, 0)
72 const pick = dist(cube) <= dist([g, g, g]) ? cube : [g, g, g]
73 return `#${pick.map(v => v.toString(16).padStart(2, '0')).join('')}`
74}
75
76export type Span = { text: string; color?: string; dim?: boolean }
77export type Look = { style: Style; colors: 'true' | '256' }
78
79const SEP: Span = { text: ' ' }
80
81export const width = (spans: Span[]) => spans.reduce((n, s) => n + [...s.text].length, 0)
82
83// A bar whose filled part runs from a deep shade of its color to a bright one,
84// with a soft highlight sweeping left to right on top
85export const bar = (pct: number, cells: number, color: string, frame: number, s: Style = 'unicode'): Span[] => {
86 const g = GLYPHS[s]
87 const filled = Math.max(pct > 0 ? 1 : 0, Math.min(cells, Math.round((pct / 100) * cells)))
88 // Same period for every bar, so all highlights travel in step
89 const pos = frame % (cells + 5)
90 const spans: Span[] = []
91 for (let i = 0; i < filled; i++) {
92 const shade = filled === 1 ? 0 : -0.15 + (0.35 * i) / (filled - 1)
93 const glow = i === pos ? 0.55 : i === pos - 1 || i === pos + 1 ? 0.25 : 0
94 spans.push({ text: g.fill, color: lighten(lighten(color, shade), glow) })
95 }
96 if (cells > filled) spans.push({ text: g.track.repeat(cells - filled), color: TRACK })
97 return spans
98}
99
100// detail 2: bars + countdowns; 1: no bars; 0: bare numbers
101export const layout = (
102 m: Measure | null,
103 t: TurnTokens | null,
104 at: number,
105 detail: 0 | 1 | 2,
106 frame = 0,
107 look: Look = { style: 'unicode', colors: 'true' },
108): Span[] => {
109 const g = GLYPHS[look.style]
110 const groups: Span[][] = []
111
112 const limit = (label: string, l: Limit | undefined, hue: string) => {
113 if (!l) return
114 const pct = Math.round(l.percentUsed)
115 const color = pct >= 80 ? RED : hue
116 const out: Span[] = [{ text: `${label} `, color }]
117 if (detail === 2) out.push(...bar(pct, 8, color, frame, look.style), { text: ' ' })
118 out.push({ text: `${pct}%`, color })
119 if (detail >= 1 && l.resetsAt) out.push({ text: ` · ${fmtLeft(Date.parse(l.resetsAt) - at)}`, dim: true })
120 groups.push(out)
121 }
122 limit('5h', m?.rateLimits.find(l => l.kind === 'five_hour'), HUE.five)
123 limit('7d', m?.rateLimits.find(l => l.kind === 'seven_day'), HUE.seven)
124
125 if (m) {
126 const pct = m.context.percent ?? 0
127 const color = pct >= 80 ? RED : HUE.ctx
128 groups.push([
129 { text: g.ctx, color },
130 { text: fmtTokens(m.context.tokens ?? 0), color },
131 { text: `/${fmtTokens(m.context.window)}`, dim: true },
132 ])
133 }
134
135 const hit = t ? hitRate(t) : null
136 if (hit !== null) {
137 const color = hit < 50 ? RED : HUE.cache
138 groups.push([
139 { text: g.hit, color },
140 { text: `${hit}%`, color },
141 ])
142 }
143
144 const spans = groups.flatMap((grp, i) => (i === 0 ? grp : [SEP, ...grp]))
145 return look.colors === '256' ? spans.map(s => (s.color ? { ...s, color: to256(s.color) } : s)) : spans
146}
147
148const fit = (m: Measure | null, t: TurnTokens | null, at: number, cols: number, frame: number, look: Look) =>
149 ([2, 1, 0] as const).map(d => layout(m, t, at, d, frame, look)).find(s => width(s) <= cols) ??
150 layout(m, t, at, 0, frame, look)
151
152
153// ---- Desktop: the band is one SVG drawn as a plain image. The SMIL shine runs in an image too,
154// and an image redraws in place, where an interactive (framed) SVG reloads its frame and blinks
155// the whole band on every redraw.
156
157const esc = (v: string) => v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
158
159// CAP: the band the figures' cap height occupies (Inter 13px on baseline 19.5);
160// icons, dots and the inner rule are sized to it so the row reads one height
161const CAP = { top: 9.75, h: 10 }
162const D = { h: 30, gap: 30, inner: 7, sm: 13, md: 13, base: 13, barW: 76, barH: 6 }
163// Advance widths in em for Inter with tabular figures (SF Pro, the fallback, runs within a few %).
164// Each text also sets textLength to this width, so a font that runs wider or narrower only
165// changes letter spacing and never pushes into the next element.
166const ADVANCE: Record<string, number> = { h: 0.58, d: 0.6, m: 0.9, K: 0.64, M: 0.84, '%': 0.84, '/': 0.36, '.': 0.27, ' ': 0.26 }
167// Extra space between letters, in px; textLength spreads it evenly across each string
168const TRACKING = 0.2
169const textW = (v: string, size: number) =>
170 [...v].reduce((w, c) => w + (c >= '0' && c <= '9' ? 0.62 : (ADVANCE[c] ?? 0.6)), 0) * size +
171 TRACKING * Math.max(0, [...v].length - 1)
172
173// Text tinted toward the hue: deeper on light backgrounds, lighter on dark ones
174const pinW = (v: string, size: number) => `textLength="${textW(v, size).toFixed(1)}" lengthAdjust="spacing"`
175const ink = (hue: string, x: number, y: number, v: string, size: number) =>
176 `<text x="${x}" y="${y}" font-size="${size}" ${pinW(v, size)} class="ink" style="--l:${lighten(hue, -0.38)};--d:${lighten(hue, 0.25)}">${esc(v)}</text>`
177const mute = (x: number, y: number, v: string, size: number) =>
178 `<text x="${x}" y="${y}" font-size="${size}" ${pinW(v, size)} class="mute">${esc(v)}</text>`
179
180// stat: type, icons and rules; motion: bars and dots with their shine
181type Layers = { stat: string; motion: string }
182type Group = { width: number; draw: (x: number) => Layers }
183
184// 5h 60% ▬▬▬▬── 1h49m: the figure first, so the state reads before the bar
185// 5h ▬▬▬▬▬▬──── 69% │ 1h31m: label, bar, figure, then when it resets
186const limitGroup = (id: string, label: string, l: Limit, hue: string, at: number): Group => {
187 const pct = Math.round(l.percentUsed)
188 const color = pct >= 80 ? RED : hue
189 const pctText = `${pct}%`
190 const left = l.resetsAt ? fmtLeft(Date.parse(l.resetsAt) - at) : ''
191 const labelW = textW(label, D.base) + D.inner
192 const pctW = textW(pctText, D.base)
193 const width = labelW + D.barW + D.inner + pctW + (left ? D.inner * 2 + 1 + textW(left, D.sm) : 0)
194 const fillW = Math.max(pct > 0 ? D.barH : 0, Math.min(D.barW, (D.barW * pct) / 100))
195 return {
196 width,
197 draw: x => {
198 const bx = x + labelW
199 const y = (D.h - D.barH) / 2
200 const r = D.barH / 2
201 const px = bx + D.barW + D.inner
202 const motion = [
203 `<defs><clipPath id="c-${id}"><rect x="${bx}" y="${y}" width="${fillW}" height="${D.barH}" rx="${r}"/></clipPath></defs>`,
204 `<rect x="${bx}" y="${y}" width="${D.barW}" height="${D.barH}" rx="${r}" class="track" style="--h:${color}"/>`,
205 `<rect x="${bx}" y="${y}" width="${fillW}" height="${D.barH}" rx="${r}" fill="${lighten(color, -0.12)}"/>`,
206 // The shine crosses the whole bar on one shared clock and shows only over the fill,
207 // so both bars' shines sit at the same spot at every moment
208 `<g clip-path="url(#c-${id})"><rect y="${y}" width="18" height="${D.barH}" fill="url(#shine)">` +
209 `<animate attributeName="x" values="${bx - 18};${bx + D.barW};${bx + D.barW}" keyTimes="0;0.62;1" dur="2.6s" repeatCount="indefinite"/></rect></g>`,
210 ]
211 const stat = [ink(color, x, 19.5, label, D.base), ink(color, px, 19.5, pctText, D.base)]
212 if (left) {
213 const rx = px + pctW + D.inner
214 stat.push(`<rect x="${rx}" y="${CAP.top}" width="1" height="${CAP.h}" class="rule"/>`, mute(rx + 1 + D.inner, 19.5, left, D.sm))
215 }
216 return { stat: stat.join(''), motion: motion.join('') }
217 },
218 }
219}
220
221// Three stacked sheets: the context window
222const layersIcon = (color: string) =>
223 `<g fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round">` +
224 `<path d="M5 0.6 L9.4 2.8 L5 5 L0.6 2.8 Z" fill="${color}" fill-opacity="0.25"/>` +
225 `<path d="M0.6 5.2 L5 7.4 L9.4 5.2"/><path d="M0.6 7.2 L5 9.4 L9.4 7.2"/></g>`
226
227// A target: how much of the prompt the cache hit
228const targetIcon = (color: string) =>
229 `<g fill="none" stroke="${color}" stroke-width="1.2">` +
230 `<circle cx="5" cy="5" r="4.4"/><circle cx="5" cy="5" r="2.1"/><circle cx="5" cy="5" r="0.7" fill="${color}"/></g>`
231
232// Icons are drawn in a CAP.h square, outer stroke edge included
233const ICON = CAP.h
234
235// icon · NUMBER suffix
236const typeGroup = (icon: (c: string) => string, hue: string, num: string, suffix: string): Group => {
237 const lw = ICON + 6
238 const nw = textW(num, D.md)
239 const sw = suffix ? textW(suffix, D.sm) + 1 : 0
240 return {
241 width: lw + nw + sw,
242 draw: x => {
243 const nx = x + lw
244 return {
245 stat:
246 `<g transform="translate(${x} ${CAP.top})">${icon(lighten(hue, -0.15))}</g>` +
247 ink(hue, nx, 19.5, num, D.md) +
248 (suffix ? mute(nx + nw + 1, 19.5, suffix, D.sm) : ''),
249 motion: '',
250 }
251 },
252 }
253}
254
255// Context as a 2×10 dot matrix: one dot per 1/20 of the window (50K of 1M), filling the top
256// row left to right before the bottom one, with the same shine over the lit dots
257// Rows sit so the dots' outer edges meet the CAP band: CAP.top + r and CAP.top + CAP.h - r
258const DOTS = { cols: 10, pitch: 5, r: 1.6, rows: [11.35, 18.15] }
259const ctxGroup = (hue: string, tokens: number, window: number, pct: number): Group => {
260 const lit = Math.min(DOTS.cols * 2, Math.round((pct / 100) * DOTS.cols * 2))
261 const num = fmtTokens(tokens)
262 const suffix = `/${fmtTokens(window)}`
263 const lw = ICON + 6
264 const matrixW = DOTS.cols * DOTS.pitch
265 return {
266 width: lw + matrixW + D.inner + textW(num, D.md) + 1 + textW(suffix, D.sm),
267 draw: x => {
268 const mx = x + lw
269 const dot = (i: number) =>
270 `<circle cx="${mx + DOTS.pitch / 2 + (i % DOTS.cols) * DOTS.pitch}" cy="${DOTS.rows[Math.floor(i / DOTS.cols)]}" r="${DOTS.r}"/>`
271 const on = Array.from({ length: lit }, (_, i) => dot(i)).join('')
272 const off = Array.from({ length: DOTS.cols * 2 - lit }, (_, i) => dot(lit + i)).join('')
273 const nx = mx + matrixW + D.inner
274 return {
275 stat:
276 `<g transform="translate(${x} ${CAP.top})">${layersIcon(lighten(hue, -0.15))}</g>` +
277 ink(hue, nx, 19.5, num, D.md) +
278 mute(nx + textW(num, D.md) + 1, 19.5, suffix, D.sm),
279 motion:
280 `<defs><clipPath id="c-ctx">${on}</clipPath></defs>` +
281 `<g class="track" style="--h:${hue}">${off}</g>` +
282 `<g fill="${lighten(hue, -0.12)}">${on}</g>` +
283 `<g clip-path="url(#c-ctx)"><rect y="8" width="18" height="14" fill="url(#shine)">` +
284 `<animate attributeName="x" values="${mx - 18};${mx + matrixW};${mx + matrixW}" keyTimes="0;0.62;1" dur="2.6s" repeatCount="indefinite"/></rect></g>`,
285 }
286 },
287 }
288}
289
290// An image takes its color scheme from the app; a frame (should the band ever be framed) whose color scheme differs from the app's
291// gets an opaque canvas behind it (white in a dark app). Declaring both schemes lets it follow the
292// app and stay transparent.
293const SVG_HEAD =
294 `<style>` +
295 `:root{color-scheme:light dark;background:transparent}` +
296 `text{font-family:Inter,"SF Pro Text",system-ui,-apple-system,"Segoe UI",sans-serif;font-weight:500;font-feature-settings:"tnum","cv05"}` +
297 `.track{fill:var(--h);fill-opacity:.22}` +
298 `.ink{fill:var(--l)}.mute{fill:#8b8f97;font-weight:400}.rule{fill:#000;fill-opacity:.1}.sep{fill:#000;fill-opacity:.2}` +
299 `@media (prefers-color-scheme:dark){.ink{fill:var(--d)}.mute{fill:#9aa0a8}.rule{fill:#fff;fill-opacity:.13}.sep{fill:#fff;fill-opacity:.22}}` +
300 `</style>` +
301 `<defs><linearGradient id="shine" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/>` +
302 `<stop offset=".5" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>`
303
304const wrap = (width: number, body: string) =>
305 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${D.h}" viewBox="0 0 ${width} ${D.h}" style="color-scheme:light dark;background:transparent">${SVG_HEAD}${body}</svg>`
306
307export type Part = Layers & { width: number; alt: string }
308
309export const desktopParts = (m: Measure | null, t: TurnTokens | null, at: number): Part[] => {
310 const groups: { g: Group; alt: string }[] = []
311 const five = m?.rateLimits.find(l => l.kind === 'five_hour')
312 const seven = m?.rateLimits.find(l => l.kind === 'seven_day')
313 if (five) groups.push({ g: limitGroup('5h', '5h', five, HUE.five, at), alt: `5-hour limit ${Math.round(five.percentUsed)}% used` })
314 if (seven) groups.push({ g: limitGroup('7d', '7d', seven, HUE.seven, at), alt: `7-day limit ${Math.round(seven.percentUsed)}% used` })
315 if (m) {
316 const pct = m.context.percent ?? 0
317 groups.push({
318 g: ctxGroup(pct >= 80 ? RED : HUE.ctx, m.context.tokens ?? 0, m.context.window, pct),
319 alt: `context ${fmtTokens(m.context.tokens ?? 0)} of ${fmtTokens(m.context.window)}`,
320 })
321 }
322 const hit = t ? hitRate(t) : null
323 if (hit !== null) groups.push({ g: typeGroup(targetIcon, hit < 50 ? RED : HUE.cache, `${hit}%`, ''), alt: `cache hit ${hit}%` })
324 return groups.map(({ g, alt }) => {
325 const width = Math.ceil(g.width + 2)
326 return { ...g.draw(1), width, alt }
327 })
328}
329
330// One SVG for the whole band, so every shine runs on the same clock. Groups sit D.gap apart
331// with a hairline centred in each gap; the one inside a limit group is shorter and fainter.
332export const desktopSvg = (m: Measure | null, t: TurnTokens | null, at: number) => {
333 let x = 0
334 const body: string[] = []
335 desktopParts(m, t, at).forEach((p, i) => {
336 if (i > 0) body.push(`<rect x="${Math.round(x - D.gap / 2)}" y="7" width="1" height="16" class="sep"/>`)
337 body.push(`<g transform="translate(${x} 0)">${p.stat}${p.motion}</g>`)
338 x += p.width + D.gap
339 })
340 const width = Math.max(1, Math.ceil(x - D.gap))
341 return { svg: wrap(width, body.join('')), width, height: D.h }
342}
343
344export const describe = (m: Measure | null, t: TurnTokens | null) => {
345 const parts: string[] = []
346 for (const l of m?.rateLimits ?? []) {
347 if (l.kind === 'five_hour') parts.push(`5-hour limit ${Math.round(l.percentUsed)}% used`)
348 if (l.kind === 'seven_day') parts.push(`7-day limit ${Math.round(l.percentUsed)}% used`)
349 }
350 if (m) parts.push(`context ${fmtTokens(m.context.tokens ?? 0)} of ${fmtTokens(m.context.window)}`)
351 const hit = t ? hitRate(t) : null
352 if (hit !== null) parts.push(`cache hit ${hit}%`)
353 return parts.join(', ')
354}
355
356// The desktop app redraws the band, and its frame blinks, on every write a drawing reads. So a
357// reading is only written when it changes what the band shows: a new token count that rounds to
358// the same figure, or a clock tick that leaves every countdown as it was, writes nothing.
359const shown = (m: Measure | null, t: TurnTokens | null, at: number) => desktopSvg(m, t, at).svg
360
361const tick = async ($: EngineInterface) => {
362 const at = await $.clock.now()
363 const [m, t, was] = [await read($, measure), await read($, turn), await read($, now)]
364 if (was && shown(m, t, was) === shown(m, t, at)) return
365 await update($, now, () => at)
366}
367
368const setMeasure = async ($: EngineInterface, next: Measure) => {
369 const [m, t, at] = [await read($, measure), await read($, turn), await read($, now)]
370 if (m && shown(m, t, at) === shown(next, t, at)) return
371 await update($, measure, () => next)
372}
373
374const setTurn = async ($: EngineInterface, next: TurnTokens) => {
375 const [m, t, at] = [await read($, measure), await read($, turn), await read($, now)]
376 if (t && shown(m, t, at) === shown(m, next, at)) return
377 await update($, turn, () => next)
378}
379
380// Shown by the preview when the session has no reading yet
381const SAMPLE_MEASURE: Measure = {
382 context: { tokens: 176_000, window: 1_000_000, percent: 18 },
383 rateLimits: [
384 { kind: 'five_hour', percentUsed: 42 },
385 { kind: 'seven_day', percentUsed: 43 },
386 ],
387}
388const SAMPLE_TURN: TurnTokens = { input: 900, output: 2_000, cacheRead: 170_000, cacheWrite: 1_000 }
389
390const PROFILES: { name: string; look: Look }[] = [
391 { name: 'Ghostty', look: { style: 'nerd', colors: 'true' } },
392 { name: 'iTerm2 / Warp / WezTerm / kitty', look: { style: 'unicode', colors: 'true' } },
393 { name: 'macOS Terminal (256 colors)', look: { style: 'unicode', colors: '256' } },
394 { name: 'ascii fallback', look: { style: 'ascii', colors: '256' } },
395]
396
397// Icon set override, read from USAGE_BAND_ICONS. It is an environment variable rather than a
398// plugin option so a fresh install has nothing to configure.
399const ICONS_ENV = 'USAGE_BAND_ICONS'
400
401export const WELCOME =
402 'usage-band is on: your 5h and 7d limits, context window and cache hit rate now show above the prompt. ' +
403 'The limits fill in after Claude’s first reply.'
404
405export const register: Register = on => {
406 let setting = 'auto'
407 // The terminal animates by redrawing; started by its first draw, so a desktop-only session
408 // never runs it. A reload drops the timer and this flag together.
409 let isAnimating = false
410
411 on('session.start', async ($, e, next) => {
412 const result = await next(e)
413 setting = ((await $.env.get('USAGE_BAND_ICONS')) ?? 'auto').trim().toLowerCase() || 'auto'
414 const term = await $.env.get('TERM_PROGRAM')
415 await update($, style, () => detectStyle(setting, term))
416 await $.command.register({
417 name: PREVIEW,
418 description: 'Preview how the usage band looks in different terminals',
419 })
420 // One welcome after install, so a new user knows what appeared above the prompt
421 if ((await $.store.get('welcomed')) !== true) {
422 await $.store.set('welcomed', true)
423 $.ui.toast(WELCOME, { timeoutMs: 12_000 })
424 }
425 const usage = await $.session.usage()
426 await setMeasure($, { context: usage.context, rateLimits: usage.rateLimits })
427 await tick($)
428 $.clock.every(60_000, () => {
429 void tick($)
430 })
431 return result
432 })
433
434 on('session.measure', async ($, e, next) => {
435 const m: Measure = {
436 context: { tokens: e.context.tokens, window: e.context.window, percent: e.context.percent },
437 rateLimits: e.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
438 }
439 await setMeasure($, m)
440 await tick($)
441 return next(e)
442 })
443
444 on('turn.complete', async ($, e, next) => {
445 // Main loop only; subagent runs raise their own turn.complete
446 if (e.agentId === undefined && e.usage) {
447 const u = e.usage
448 await setTurn($, {
449 input: u.input_tokens,
450 output: u.output_tokens,
451 cacheRead: u.cache_read_input_tokens,
452 cacheWrite: u.cache_creation_input_tokens,
453 })
454 }
455 return next(e)
456 })
457
458 on('command.run', { command: PREVIEW }, async () => ({
459 text: `usage-band style preview (${ICONS_ENV}: ${setting})`,
460 }))
461
462 on('ui.render', { component: 'CommandOutput', props: { command: PREVIEW } }, async ($, e) => {
463 const m = (await read($, measure)) ?? SAMPLE_MEASURE
464 const t = (await read($, turn)) ?? SAMPLE_TURN
465 const at = await read($, now)
466 const current = await read($, style)
467 const { Box, Text } = $.ui.resolve(e)
468 return (
469 <Box flexDirection="column">
470 <Text dimColor>
471 {ICONS_ENV}: {setting} → using {current}
472 </Text>
473 {PROFILES.map(p => (
474 <Box key={p.name} flexDirection="column" marginTop={1}>
475 <Text dimColor>{p.name}</Text>
476 <Box flexDirection="row">
477 {layout(m, t, at, 2, 3, p.look).map((s, i) => (
478 <Text key={`s${i}`} color={s.color} dimColor={s.dim}>
479 {s.text}
480 </Text>
481 ))}
482 </Box>
483 </Box>
484 ))}
485 </Box>
486 )
487 })
488
489 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
490 const m = await read($, measure)
491 const t = await read($, turn)
492 const at = await read($, now)
493 if (e.props.hasSurvey || (m === null && t === null)) return next(e)
494
495 // Desktop and mobile animate inside the SVG, so they never read the frame counter
496 if (e.surface === 'desktop' || e.surface === 'mobile') {
497 const { Box, Svg } = $.ui.resolve(e)
498 const { svg, width, height } = desktopSvg(m, t, at)
499 return (
500 <Box flexDirection="row" justifyContent="center" flexGrow={1} paddingX={1}>
501 <Svg source={svg} alt={describe(m, t)} width={width} height={height} />
502 </Box>
503 )
504 }
505
506 if (!isAnimating) {
507 isAnimating = true
508 $.clock.every(FRAME_MS, () => {
509 void update($, phase, f => (f ?? 0) + 1)
510 })
511 }
512 const frame = await read($, phase)
513 // The terminal font decides icons; other surfaces (vscode) get the plain set
514 const s = e.surface === 'terminal' ? await read($, style) : 'unicode'
515 const spans = fit(m, t, at, e.props.bodyColumns - 2, frame, { style: s, colors: 'true' })
516 const { Box, Text } = $.ui.resolve(e)
517 return (
518 <Box flexDirection="row" flexWrap="wrap" paddingX={1}>
519 {spans.map((sp, i) => (
520 <Text key={`s${i}`} color={sp.color} dimColor={sp.dim}>
521 {sp.text}
522 </Text>
523 ))}
524 </Box>
525 )
526 })
527}
528types/index.d.ts 20 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Measure = {
3 context: { tokens?: number; window: number; percent?: number }
4 rateLimits: Limit[]
5}
6export type TurnTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
7export type Style = 'nerd' | 'unicode' | 'ascii'
8
9declare module 'claude-code' {
10 interface PluginState {
11 'usage-band': {
12 measure: Measure | null
13 turn: TurnTokens | null
14 now: number
15 phase: number
16 style: Style
17 }
18 }
19}
20