SLOPSHOPPER

usage-band

Usage band above the prompt: 5h/7d limits, token totals and session cost

newbandcommandprocesstimer
v0.2.3MITupdated 2026-10-15MoonZhe/usage-band/plugins/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 › /usage-band ⎿ usage-band: 5h 31% · in ~6.4k · out ~1.5k · ctx 97.4k / 200k (49%) · $0.42 5h ███░░░░░░░ 31% ↑~6.4k ↓~1.5k ≡97.4k/200k 49% $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ███░░░░░░░ 31% ↑~6.4k ↓~1.5k ≡97.4k/200k 49% $0.42
README

usage-band

A Claude Code mod that shows a usage band above the prompt.

usage-band preview

Rendered from the mod's own drawing code with sample numbers. It follows your light or dark theme.

From left to right:

  • 5h / 7d: percent of your 5-hour and 7-day limits used, and time until each resets. The bar turns yellow then red as it fills; the tick marks how far through the window you are, so a bar ahead of its tick means you're burning faster than the window allows.
  • ↑ / ↓: input and output tokens for the session, subagents included
  • Context: tokens in the context window, out of its size
  • $: session cost at API list price (not what a subscription bills)

Hover a pill in the desktop app for details. In the terminal the band is drawn as colored text instead of pills. /usage-band hide and /usage-band show toggle it; /usage-band alone prints the readings.

Install

Requires Claude Code 2.1.286 or newer (check with claude --version, update with claude update) and Node.js on your PATH (used to total tokens from the session transcript).

claude plugin marketplace add MoonZhe/usage-band
claude plugin install usage-band@usage-band

Start a new session (or restart the desktop app) and the band appears above the prompt.

Update

claude plugin marketplace update usage-band
claude plugin update usage-band@usage-band

Uninstall

claude plugin uninstall usage-band@usage-band

Notes

  • Rate limits only arrive with API responses, so each session writes its latest reading to ~/.claude/usage-band/limits.json and every session shows the newest one. An idle or resumed session stays current that way.
  • Nothing leaves your machine: the mod only reads your own session data and that local file.

Credits

Inspired by an idea from the Claude Code community. Built with Claude Code.

License

MIT

Source 3 files
hooks/register.tsx 353 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { UsageContext, UsageLimit, UsageLimits, UsageTokens } from '../types'
5import {
6  buildGroups,
7  contextPercent,
8  fmtCount,
9  fmtPercent,
10  fmtUsd,
11  fmtWindow,
12  level,
13  limitView,
14  pillSvg,
15  pillWidth,
16  type BandInput,
17} from './pills'
18
19const limitsA = atom({ plugin: 'usage-band', key: 'limits' } as const, null)
20const contextA = atom({ plugin: 'usage-band', key: 'context' } as const, null)
21const costA = atom({ plugin: 'usage-band', key: 'costUsd' } as const, null)
22const tokensA = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
23const fallbackA = atom({ plugin: 'usage-band', key: 'fallback' } as const, null)
24const hiddenA = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
25const nowA = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
26
27const NODES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node']
28const ZERO: UsageTokens = { uncachedInput: 0, cacheWrite: 0, output: 0, cacheRead: 0, requests: 0, isApprox: true }
29
30type Rate = { kind: string; percentUsed: number; resetsAt?: string }
31
32function pickLimit(list: readonly Rate[], kind: string): UsageLimit | null {
33  const r = list.find(x => x.kind === kind)
34  return r ? { percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null } : null
35}
36
37function toLimits(list: readonly Rate[]): UsageLimits | null {
38  const fiveHour = pickLimit(list, 'five_hour')
39  const sevenDay = pickLimit(list, 'seven_day')
40  return fiveHour || sevenDay ? { fiveHour, sevenDay } : null
41}
42
43function toContext(c: { tokens?: number; window: number; percent?: number }): UsageContext {
44  return { tokens: c.tokens ?? null, window: c.window, percent: c.percent ?? null }
45}
46
47// Module variables only cache work; everything drawn lives in $.state.
48let lastSig = ''
49let nodeBin: string | null = null
50let isCounting = false
51
52const MINUTE = 60_000
53
54// Every write redraws the band, and an interactive Svg whose source changes
55// reloads its frame (a visible flash), so callers skip writes that change nothing.
56// (read/update stay at the call sites: the validator wants literal atoms there.)
57const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
58
59async function putLimits($: EngineInterface, v: UsageLimits | null) {
60  if (!same(await read($, limitsA), v)) await update($, limitsA, () => v)
61}
62
63async function putContext($: EngineInterface, v: UsageContext) {
64  if (!same(await read($, contextA), v)) await update($, contextA, () => v)
65}
66
67async function putCost($: EngineInterface, v: number | null) {
68  if (!same(await read($, costA), v)) await update($, costA, () => v)
69}
70
71async function putTokens($: EngineInterface, v: UsageTokens | null) {
72  if (!same(await read($, tokensA), v)) await update($, tokensA, () => v)
73}
74
75// Rate limits arrive with this session's own API responses only, so an idle or
76// resumed session would keep showing its last reading. Every session publishes
77// a fresh reading to one shared file and shows whichever reading is newest.
78type Reading = { at: number; limits: UsageLimits | null }
79let own: Reading = { at: 0, limits: null }
80
81async function sharedPath($: EngineInterface): Promise<string | null> {
82  const config = await $.env.get('CLAUDE_CONFIG_DIR')
83  if (config) return `${config}/usage-band/limits.json`
84  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
85  return home ? `${home}/.claude/usage-band/limits.json` : null
86}
87
88async function readShared($: EngineInterface): Promise<Reading | null> {
89  try {
90    const path = await sharedPath($)
91    if (!path) return null
92    const r = JSON.parse(await $.fs.read(path)) as Reading
93    return typeof r?.at === 'number' ? r : null
94  } catch {
95    return null
96  }
97}
98
99async function publish($: EngineInterface, reading: Reading) {
100  try {
101    const path = await sharedPath($)
102    const shared = await readShared($)
103    if (path && (!shared || shared.at < reading.at)) await $.fs.write(path, JSON.stringify(reading))
104  } catch {
105    // sharing is best effort; this session still shows its own reading
106  }
107}
108
109// A window whose reset time has passed starts over, whatever the last reading said.
110function expire(l: UsageLimit | null, now: number): UsageLimit | null {
111  if (!l?.resetsAt) return l
112  return Date.parse(l.resetsAt) <= now ? { percentUsed: 0, resetsAt: null } : l
113}
114
115async function showLimits($: EngineInterface, now: number) {
116  const shared = await readShared($)
117  const best = shared && shared.at > own.at ? shared : own
118  const l = best.limits
119  await putLimits($, l ? { fiveHour: expire(l.fiveHour, now), sevenDay: expire(l.sevenDay, now) } : null)
120}
121
122async function refreshUsage($: EngineInterface) {
123  const [u, now] = await Promise.all([$.session.usage(), $.clock.now()])
124  await showLimits($, now)
125  await putContext($, toContext(u.context))
126  await putCost($, u.cost?.usd ?? null)
127  // "time left" shows whole minutes, so the clock only needs minute steps
128  const minute = Math.floor(now / MINUTE) * MINUTE
129  if ((await read($, nowA)) !== minute) await update($, nowA, () => minute)
130}
131
132async function runScript($: EngineInterface, id: string): Promise<string | null> {
133  const script = `${$.plugin.root}/scripts/tokens.mjs`
134  for (const bin of nodeBin ? [nodeBin] : NODES) {
135    try {
136      const r = await $.process.run([bin, script, id, lastSig], { timeoutMs: 20_000 })
137      if (r.exitCode === 0) {
138        nodeBin = bin
139        return r.stdout
140      }
141      if (nodeBin) return null
142    } catch {
143      // this node could not start; try the next one
144    }
145  }
146  return null
147}
148
149async function refreshTokens($: EngineInterface) {
150  if (isCounting) return
151  isCounting = true
152  try {
153    const out = await runScript($, await $.session.id())
154    const parsed = out ? (JSON.parse(out) as Partial<UsageTokens> & { sig?: string; unchanged?: boolean; error?: string }) : null
155    if (parsed && !parsed.error && parsed.sig) {
156      lastSig = parsed.sig
157      if (parsed.unchanged) return
158      const t: UsageTokens = {
159        uncachedInput: parsed.uncachedInput ?? 0,
160        cacheWrite: parsed.cacheWrite ?? 0,
161        output: parsed.output ?? 0,
162        cacheRead: parsed.cacheRead ?? 0,
163        requests: parsed.requests ?? 0,
164        isApprox: false,
165      }
166      await putTokens($, t)
167      return
168    }
169    lastSig = ''
170    await putTokens($, await read($, fallbackA))
171  } catch {
172    lastSig = ''
173    await putTokens($, await read($, fallbackA))
174  } finally {
175    isCounting = false
176  }
177}
178
179async function refreshAll($: EngineInterface) {
180  await Promise.allSettled([refreshUsage($), refreshTokens($)])
181}
182
183export const register: Register = on => {
184  on('session.start', async ($, e, next) => {
185    await $.command.register({
186      name: 'usage-band',
187      description: 'Usage band: refresh and summarize (hide / show toggles the band)',
188      argumentHint: '[hide|show]',
189    })
190    $.clock.every(30_000, () => void refreshUsage($))
191    void refreshAll($)
192
193    return next(e)
194  })
195
196  on('session.measure', async ($, e, next) => {
197    const now = await $.clock.now()
198    if (e.rateLimits.length > 0) {
199      own = { at: now, limits: toLimits(e.rateLimits) }
200      await publish($, own)
201    }
202    await showLimits($, now)
203    await putContext($, toContext(e.context))
204    if (e.cost) await putCost($, e.cost.usd ?? null)
205
206    return next(e)
207  })
208
209  on('turn.complete', async ($, e, next) => {
210    const result = await next(e)
211    const u = e.usage
212    if (u) {
213      await update($, fallbackA, prev => {
214        const b = prev ?? ZERO
215        return {
216          uncachedInput: b.uncachedInput + u.input_tokens,
217          cacheWrite: b.cacheWrite + u.cache_creation_input_tokens,
218          output: b.output + u.output_tokens,
219          cacheRead: b.cacheRead + u.cache_read_input_tokens,
220          requests: b.requests + 1,
221          isApprox: true,
222        }
223      })
224    }
225    await refreshAll($)
226
227    return result
228  })
229
230  on('command.run', { command: 'usage-band' }, async ($, e) => {
231    const arg = e.args.trim().toLowerCase()
232    if (arg === 'hide') {
233      await update($, hiddenA, () => true)
234      return { text: 'Usage band hidden. /usage-band show brings it back.' }
235    }
236    if (arg === 'show') {
237      await update($, hiddenA, () => false)
238    }
239    lastSig = ''
240    await refreshAll($)
241    const limits = await read($, limitsA)
242    const tokens = await read($, tokensA)
243    const cost = await read($, costA)
244    const context = await read($, contextA)
245    const now = await $.clock.now()
246    const parts: string[] = []
247    if (limits?.fiveHour) {
248      const v = limitView('5h', limits.fiveHour, now)
249      parts.push(`5h ${fmtPercent(v.percent)}${v.left ? ` (${v.left} left)` : ''}`)
250    }
251    if (limits?.sevenDay) {
252      const v = limitView('7d', limits.sevenDay, now)
253      parts.push(`7d ${fmtPercent(v.percent)}${v.left ? ` (${v.left} left)` : ''}`)
254    }
255    if (tokens) {
256      const p = tokens.isApprox ? '~' : ''
257      parts.push(
258        `in ${p}${fmtCount(tokens.uncachedInput + tokens.cacheWrite)}`,
259        `out ${p}${fmtCount(tokens.output)}`,
260      )
261    }
262    if (context?.tokens != null) {
263      const pct = contextPercent(context)
264      parts.push(`ctx ${fmtCount(context.tokens)} / ${fmtWindow(context.window)}${pct != null ? ` (${fmtPercent(pct)})` : ''}`)
265    }
266    if (cost != null) parts.push(fmtUsd(cost))
267
268    return { text: parts.length ? parts.join(' · ') : 'No usage readings yet.' }
269  })
270
271  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
272    if (e.props.hasSurvey || (await read($, hiddenA))) return next(e)
273
274    const limits = await read($, limitsA)
275    const band: BandInput = {
276      now: Math.max(await read($, nowA), 0) || (await $.clock.now()),
277      fiveHour: limits?.fiveHour ?? null,
278      sevenDay: limits?.sevenDay ?? null,
279      context: await read($, contextA),
280      costUsd: await read($, costA),
281      tokens: await read($, tokensA),
282    }
283    const groups = buildGroups(band)
284    if (groups.length === 0) return next(e)
285
286    if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
287      const { Box, Svg } = $.ui.resolve(e)
288      return (
289        <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
290          {groups.map((g, gi) => (
291            <Box key={`g${gi}`} flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
292              {g.map(p => (
293                <Svg key={p.alt.split(' ')[0]} source={pillSvg(p)} alt={p.alt} width={pillWidth(p)} height={24} isInteractive />
294              ))}
295            </Box>
296          ))}
297        </Box>
298      )
299    }
300
301    const { Box, Text } = $.ui.resolve(e)
302    const TONE: Record<string, string> = {
303      teal: 'cyan',
304      violet: 'magenta',
305      red: 'red',
306      green: 'green',
307      blue: 'blue',
308      gold: 'yellow',
309    }
310    const LEVEL = { ok: 'green', warn: 'yellow', high: 'red' }
311
312    const lim = (['5h', '7d'] as const).flatMap(label => {
313      const l = label === '5h' ? band.fiveHour : band.sevenDay
314      if (!l) return []
315      const v = limitView(label, l, band.now)
316      const cells = 10
317      const filled = Math.round((Math.min(100, v.percent) / 100) * cells)
318      const mark = v.elapsed === null ? -1 : Math.min(cells - 1, Math.floor(v.elapsed * cells))
319      let bar = ''
320      for (let i = 0; i < cells; i++) bar += i === mark ? '│' : i < filled ? '█' : '░'
321      const tone = label === '5h' ? 'cyan' : 'magenta'
322      return [
323        <Text key={label}>
324          <Text color={tone}>{label} </Text>
325          <Text color={LEVEL[level(v.percent)]}>{bar}</Text>
326          <Text color={tone} bold> {fmtPercent(v.percent)}</Text>
327          {v.left ? <Text color={tone} dimColor> ⟲ {v.left}</Text> : null}
328        </Text>,
329      ]
330    })
331    const rest = groups
332      .flat()
333      .filter(p => p.tone !== 'teal' && p.tone !== 'violet')
334      .map(p => {
335        const glyph = p.tone === 'red' ? '↑' : p.tone === 'green' ? '↓' : p.tone === 'blue' ? '≡' : ''
336        const text = p.segments.flatMap(s => (s.kind === 'text' ? [s.text] : [])).join(' ')
337        return (
338          <Text key={p.tone} color={TONE[p.tone]}>
339            {glyph}
340            {text}
341          </Text>
342        )
343      })
344
345    return (
346      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
347        {lim}
348        {rest}
349      </Box>
350    )
351  })
352}
353
hooks/pills.ts 310 lines
1// Pure helpers: number/time formatting and the desktop pill SVGs. No engine imports,
2// so the same file renders the HTML preview under plain Node.
3
4export type Tone = 'teal' | 'violet' | 'red' | 'green' | 'blue' | 'gold'
5export type IconName = 'gauge' | 'countdown' | 'calendar' | 'up' | 'down' | 'layers' | 'dollar'
6
7export type Segment =
8  | { kind: 'icon'; name: IconName }
9  | { kind: 'text'; text: string; isBold?: boolean; isDim?: boolean }
10  | { kind: 'bar'; percent: number; elapsed: number | null }
11  | { kind: 'sep' }
12
13export type Pill = { tone: Tone; title: string; alt: string; segments: Segment[] }
14
15const HOUR = 3_600_000
16const DAY = 24 * HOUR
17export const WINDOW_MS = { fiveHour: 5 * HOUR, sevenDay: 7 * DAY }
18
19export function fmtCount(n: number): string {
20  if (n < 1000) return String(Math.round(n))
21  if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
22  return `${(n / 1_000_000).toFixed(2)}M`
23}
24
25/** A window size: "1M", "200k", "1.5M". */
26export function fmtWindow(n: number): string {
27  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(2)}M`
28  if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
29  return String(n)
30}
31
32/** The context fill as whole percent: the reading's own, else tokens / window. */
33export function contextPercent(c: { tokens: number | null; window: number; percent: number | null }): number | null {
34  if (c.percent != null) return c.percent
35  if (c.tokens == null || c.window <= 0) return null
36  return Math.round((c.tokens / c.window) * 100)
37}
38
39export function fmtUsd(n: number): string {
40  return `$${n.toFixed(2)}`
41}
42
43export function fmtPercent(n: number): string {
44  return `${Math.round(n)}%`
45}
46
47/** "2h 40m" under a day, "1d 7h" from a day up, "12m" under an hour. */
48export function fmtLeft(ms: number): string {
49  const m = Math.max(0, Math.floor(ms / 60_000))
50  const d = Math.floor(m / 1440)
51  const h = Math.floor((m % 1440) / 60)
52  const mm = m % 60
53  if (d > 0) return `${d}d ${h}h`
54  if (h > 0) return `${h}h ${mm}m`
55  return `${mm}m`
56}
57
58/** Share of the window already gone: 1 − left / window, clamped to 0..1. */
59export function elapsedShare(leftMs: number, windowMs: number): number {
60  return Math.min(1, Math.max(0, 1 - leftMs / windowMs))
61}
62
63export function level(percent: number): 'ok' | 'warn' | 'high' {
64  return percent >= 90 ? 'high' : percent >= 70 ? 'warn' : 'ok'
65}
66
67// Light: pastel ground, dark ink. Dark: deep tinted ground, light ink.
68const TONES: Record<Tone, { bg: string; fg: string; dbg: string; dfg: string }> = {
69  teal: { bg: '#d3efe6', fg: '#1d5546', dbg: '#163a31', dfg: '#9fe2cb' },
70  violet: { bg: '#e5dff8', fg: '#45358a', dbg: '#2b2548', dfg: '#c9befa' },
71  red: { bg: '#f9ddd6', fg: '#8a3122', dbg: '#40231d', dfg: '#f4b4a6' },
72  green: { bg: '#dcf0d4', fg: '#2c6622', dbg: '#20351a', dfg: '#b5e4a5' },
73  blue: { bg: '#dde4f9', fg: '#30448e', dbg: '#202944', dfg: '#b6c5f4' },
74  gold: { bg: '#f6eac4', fg: '#765608', dbg: '#3b3115', dfg: '#ecd38b' },
75}
76
77const ICONS: Record<IconName, string> = {
78  gauge: '<path d="M2.6 11.4a5.6 5.6 0 1 1 10.8 0"/><path d="M8 10.6 10.6 6.4"/><circle cx="8" cy="10.6" r=".9" class="f"/>',
79  countdown:
80    '<path d="M2.8 8a5.2 5.2 0 1 0 1.6-3.8"/><path d="M2.6 2.4v2.6h2.6"/><path d="M8 5.2V8l2 1.3"/>',
81  calendar: '<rect x="2.2" y="3.4" width="11.6" height="10.4" rx="2"/><path d="M2.2 7h11.6M5.2 1.9v3M10.8 1.9v3"/>',
82  up: '<path d="M8 11V2.8M4.6 6.2 8 2.8l3.4 3.4M3 13.6h10"/>',
83  down: '<path d="M8 2.8V11M4.6 7.6 8 11l3.4-3.4M3 13.6h10"/>',
84  layers: '<path d="M8 2.2 14 5.3 8 8.4 2 5.3Z"/><path d="M2 8.2l6 3.1 6-3.1M2 11.1l6 3.1 6-3.1"/>',
85  dollar:
86    '<circle cx="8" cy="8" r="6.2"/><path d="M10 6.1c-.4-.7-1.1-1.1-2-1.1-1.1 0-1.9.6-1.9 1.4 0 1.9 3.9 1 3.9 3 0 .9-.9 1.5-2 1.5-.9 0-1.7-.4-2.1-1.1M8 3.7v1.3M8 11v1.3"/>',
87}
88
89const CHAR_W = 7.25
90const H = 24
91const BAR_W = 34
92const ICON = 14
93const GAP = 4
94
95function esc(s: string): string {
96  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
97}
98
99function segWidth(s: Segment): number {
100  switch (s.kind) {
101    case 'icon':
102      return ICON
103    case 'text':
104      return s.text.length * CHAR_W
105    case 'bar':
106      return BAR_W
107    case 'sep':
108      return 1
109  }
110}
111
112export function pillWidth(p: Pill): number {
113  const inner = p.segments.reduce((w, s, i) => w + segWidth(s) + (i > 0 ? GAP : 0), 0)
114  return Math.ceil(inner + 7 + 7)
115}
116
117export function pillSvg(p: Pill): string {
118  const t = TONES[p.tone]
119  const w = pillWidth(p)
120  const mid = H / 2
121  let x = 7
122  const parts: string[] = []
123  p.segments.forEach((s, i) => {
124    if (i > 0) x += GAP
125    switch (s.kind) {
126      case 'icon':
127        parts.push(
128          `<g class="i" transform="translate(${x.toFixed(1)} ${mid - ICON / 2}) scale(${ICON / 16})">${ICONS[s.name]}</g>`,
129        )
130        break
131      case 'text':
132        parts.push(
133          `<text x="${x.toFixed(1)}" y="${mid + 4.2}" class="${s.isBold ? 't b' : s.isDim ? 't d' : 't'}">${esc(s.text)}</text>`,
134        )
135        break
136      case 'bar': {
137        const fill = Math.max(0, Math.min(100, s.percent)) / 100
138        parts.push(`<rect x="${x.toFixed(1)}" y="${mid - 2.5}" width="${BAR_W}" height="5" rx="2.5" class="tr"/>`)
139        if (fill > 0) {
140          parts.push(
141            `<rect x="${x.toFixed(1)}" y="${mid - 2.5}" width="${Math.max(3, BAR_W * fill).toFixed(1)}" height="5" rx="2.5" class="lv-${level(s.percent)}"/>`,
142          )
143        }
144        if (s.elapsed !== null) {
145          const mx = (x + BAR_W * s.elapsed).toFixed(1)
146          parts.push(`<path d="M${mx} ${mid - 6}V${mid + 6}" class="mk"/>`)
147        }
148        break
149      }
150      case 'sep':
151        parts.push(`<path d="M${(x + 0.5).toFixed(1)} ${mid - 6}V${mid + 6}" class="sp"/>`)
152        break
153    }
154    x += segWidth(s)
155  })
156
157  const k = `.u-${p.tone}`
158  const rules = (bg: string, fg: string, ok: string, warn: string, high: string) =>
159    `${k} .bg{fill:${bg}}${k} .t{fill:${fg}}${k} .i{stroke:${fg}}${k} .i .f{fill:${fg}}${k} .tr{fill:${fg}}` +
160    `${k} .mk{stroke:${fg}}${k} .sp{stroke:${fg}}${k} .lv-ok{fill:${ok}}${k} .lv-warn{fill:${warn}}${k} .lv-high{fill:${high}}`
161  const css =
162    `:root{color-scheme:light dark;background:transparent}html,body{margin:0;background:transparent}` +
163    `${k} .t{font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}${k} .b{font-weight:700}` +
164    `${k} .d{opacity:.7}${k} .i{fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}${k} .i .f{stroke:none}` +
165    `${k} .tr{opacity:.18}${k} .mk{stroke-width:1.6;stroke-linecap:round}${k} .sp{opacity:.3}` +
166    rules(t.bg, t.fg, '#2f9e63', '#d39b12', '#d64040') +
167    `@media (prefers-color-scheme:dark){${rules(t.dbg, t.dfg, '#43c07e', '#e8b030', '#ef5b5b')}}`
168
169  return (
170    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}" class="u-${p.tone}">` +
171    `<style>${css}</style>` +
172    `<g><title>${esc(p.title)}</title>` +
173    `<rect width="${w}" height="${H}" rx="${H / 2}" class="bg"/>${parts.join('')}</g></svg>`
174  )
175}
176
177// ---- the band's model, shared by both surfaces ----
178
179export type BandLimit = { percentUsed: number; resetsAt: string | null }
180export type BandInput = {
181  now: number
182  fiveHour: BandLimit | null
183  sevenDay: BandLimit | null
184  context: { tokens: number | null; window: number; percent: number | null } | null
185  costUsd: number | null
186  tokens: {
187    uncachedInput: number
188    cacheWrite: number
189    output: number
190    cacheRead: number
191    requests: number
192    isApprox: boolean
193  } | null
194}
195
196export type LimitView = {
197  label: '5h' | '7d'
198  percent: number
199  left: string | null
200  elapsed: number | null
201}
202
203export function limitView(label: '5h' | '7d', l: BandLimit, now: number): LimitView {
204  const windowMs = label === '5h' ? WINDOW_MS.fiveHour : WINDOW_MS.sevenDay
205  const resetAt = l.resetsAt ? Date.parse(l.resetsAt) : NaN
206  const leftMs = Number.isFinite(resetAt) ? Math.max(0, resetAt - now) : null
207  return {
208    label,
209    percent: l.percentUsed,
210    left: leftMs === null ? null : fmtLeft(leftMs),
211    elapsed: leftMs === null ? null : elapsedShare(leftMs, windowMs),
212  }
213}
214
215function contextLine(b: BandInput): string {
216  const c = b.context
217  if (!c || c.tokens == null) return 'Context: no reading yet'
218  const pct = contextPercent(c)
219  return `Context: ${fmtCount(c.tokens)} / ${fmtWindow(c.window)}${pct != null ? ` (${pct}%)` : ''}`
220}
221
222function hhmm(iso: string | null): string {
223  if (!iso) return ''
224  const d = new Date(iso)
225  if (Number.isNaN(d.getTime())) return ''
226  const pad = (n: number) => String(n).padStart(2, '0')
227  return ` (${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())})`
228}
229
230/** Pills grouped as [5h 7d] [in out context] [cost]; empty groups dropped. */
231export function buildGroups(b: BandInput): Pill[][] {
232  const limits: Pill[] = []
233  for (const [label, l, tone, icon, name] of [
234    ['5h', b.fiveHour, 'teal', 'gauge', '5-hour'],
235    ['7d', b.sevenDay, 'violet', 'calendar', '7-day'],
236  ] as const) {
237    if (!l) continue
238    const v = limitView(label, l, b.now)
239    const segs: Segment[] = [
240      { kind: 'icon', name: icon },
241      { kind: 'text', text: label },
242      { kind: 'bar', percent: v.percent, elapsed: v.elapsed },
243      { kind: 'text', text: fmtPercent(v.percent), isBold: true },
244    ]
245    if (v.left !== null) {
246      segs.push({ kind: 'sep' }, { kind: 'text', text: v.left })
247    }
248    limits.push({
249      tone,
250      alt: `${label} limit ${fmtPercent(v.percent)} used${v.left ? `, resets in ${v.left}` : ''}`,
251      title:
252        `${name} limit: ${l.percentUsed}% used\n` +
253        (v.left ? `Resets in ${v.left}${hhmm(l.resetsAt)}\n` : '') +
254        (v.elapsed !== null ? `Window elapsed: ${Math.round(v.elapsed * 100)}%` : ''),
255      segments: segs,
256    })
257  }
258
259  const tokens: Pill[] = []
260  const t = b.tokens
261  if (t) {
262    const p = t.isApprox ? '~' : ''
263    const tail = `\nRequests: ${t.requests}${t.isApprox ? '\n~ approximate: summed from turn ends (transcript not readable)' : ''}`
264    const input = t.uncachedInput + t.cacheWrite
265    tokens.push(
266      {
267        tone: 'red',
268        alt: `Input ${p}${fmtCount(input)} tokens`,
269        title:
270          `Input tokens: ${p}${fmtCount(input)}\n  uncached: ${fmtCount(t.uncachedInput)}\n  cache write: ${fmtCount(t.cacheWrite)}` +
271          `\n  cache read: ${fmtCount(t.cacheRead)}${tail}`,
272        segments: [{ kind: 'icon', name: 'up' }, { kind: 'text', text: p + fmtCount(input) }],
273      },
274      {
275        tone: 'green',
276        alt: `Output ${p}${fmtCount(t.output)} tokens`,
277        title: `Output tokens: ${p}${fmtCount(t.output)}${tail}`,
278        segments: [{ kind: 'icon', name: 'down' }, { kind: 'text', text: p + fmtCount(t.output) }],
279      },
280    )
281  }
282  const c = b.context
283  if (c && c.tokens != null) {
284    const pct = contextPercent(c) ?? 0
285    tokens.push({
286      tone: 'blue',
287      alt: `Context ${fmtCount(c.tokens)} of ${fmtWindow(c.window)} tokens, ${fmtPercent(pct)}`,
288      title: `${contextLine(b)}\nInput the last response was answered over`,
289      segments: [
290        { kind: 'icon', name: 'layers' },
291        { kind: 'text', text: `${fmtCount(c.tokens)}/${fmtWindow(c.window)}` },
292        { kind: 'sep' },
293        { kind: 'text', text: fmtPercent(pct), isBold: true },
294      ],
295    })
296  }
297
298  const cost: Pill[] = []
299  if (b.costUsd != null) {
300    cost.push({
301      tone: 'gold',
302      alt: `Session cost ${fmtUsd(b.costUsd)}`,
303      title: `Session cost: ${fmtUsd(b.costUsd)}\nAt API list price (not what a subscription bills)`,
304      segments: [{ kind: 'icon', name: 'dollar' }, { kind: 'text', text: fmtUsd(b.costUsd) }],
305    })
306  }
307
308  return [limits, tokens, cost].filter(g => g.length > 0)
309}
310
types/index.d.ts 30 lines
1export type UsageLimit = { percentUsed: number; resetsAt: string | null }
2
3export type UsageLimits = { fiveHour: UsageLimit | null; sevenDay: UsageLimit | null }
4
5export type UsageContext = { tokens: number | null; window: number; percent: number | null }
6
7export type UsageTokens = {
8  uncachedInput: number
9  cacheWrite: number
10  output: number
11  cacheRead: number
12  requests: number
13  /** True when counted from turn.complete instead of the transcript. */
14  isApprox: boolean
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'usage-band': {
20      limits: UsageLimits | null
21      context: UsageContext | null
22      costUsd: number | null
23      tokens: UsageTokens | null
24      fallback: UsageTokens | null
25      isHidden: boolean
26      now: number
27    }
28  }
29}
30