SLOPSHOPPER

usage-band

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

newbandrowscommandtoasttimer
★ 8v1.0.6MITupdated 2026-10-03JetsonChan/CC-Usage-Band/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 ╭────────────────────────────────────────────╮ │ usage-band │ ⏺ Read(src/auth.ts) │ usage-band is on: your 5h and 7d limits, │ ⎿ Read 6 lines │ context window and cache hit rate now show │ ⏺ Update(src/auth.ts) │ above the prompt. The limits fill in after │ ⎿ 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 › /usage-band-preview USAGE_BAND_ICONS: auto → using nerd Ghostty 5h ■■■■■■■■ 31% · NaNm 󰌨 97.4K/200K 󰓾 93% iTerm2 / Warp / WezTerm / kitty 5h ■■■■■■■■ 31% · NaNm ≡ 97.4K/200K ● 93% macOS Terminal (256 colors) 5h ■■■■■■■■ 31% · NaNm ≡ 97.4K/200K ● 93% 5h ■■■■■■■■ 31% · NaNm 󰌨 97.4K/200K 󰓾 93% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ■■■■■■■■ 31% · NaNm 󰌨 97.4K/200K 󰓾 93%
Command output
USAGE_BAND_ICONS: auto → using nerd Ghostty 5h ■■■■■■■■ 31% · NaNm 󰌨 97.4K/200K 󰓾 93% iTerm2 / Warp / WezTerm / kitty 5h ■■■■■■■■ 31% · NaNm ≡ 97.4K/200K ● 93% macOS Terminal (256 colors) 5h ■■■■■■■■ 31% · NaNm ≡ 97.4K/200K ● 93% ascii fallback 5h ##------ 31% · NaNm ctx 97.4K/200K hit 93%
README

usage-band

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:

  • 5h / 7d — how much of your 5-hour and 7-day rate-limit windows is used, and when each resets
  • Context — tokens in the context window out of its size
  • Cache hit — how much of the last turn's input the prompt cache served

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.

How it looks

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.

Install

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

Settings

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.

Notes

  • The 5h / 7d figures come from your subscription's rate-limit headers, so they appear after the first response of a session and only on a subscription.
  • The cache hit rate is the last turn's cache reads over all its input (uncached + cache reads + cache writes), summed over the turn's requests.
  • The desktop text uses Inter when it is installed and falls back to SF Pro / the system UI font.

What it can reach

Mods run with the same access as Claude Code itself; they are not sandboxed. This one only:

  • reads the session's usage figures ($.session.usage, session.measure, turn.complete)
  • reads the TERM_PROGRAM and USAGE_BAND_ICONS environment variables to pick terminal icons
  • registers the /usage-band-preview command and draws the band

It reads no files, runs no processes and makes no network requests.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 528 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
528
types/index.d.ts 20 lines
1export 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