SLOPSHOPPER

usage-band

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

newbandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-04joydeep-pm/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 › /kullanim ⎿ usage-band: 5h 31% · in ~6.4k · out ~1.5k · cache ~91.0k · cost $0.42 ◷ 5h ███░░░░░░░ 31% ↑ ~6.4k ↓ ~1.5k ≋ ~91.0k $ 0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ 5h ███░░░░░░░ 31% ↑ ~6.4k ↓ ~1.5k ≋ ~91.0k $ 0.42
README

usage-band

A Claude Code mod: a usage band above the prompt showing the 5-hour and 7-day limits, token totals (input, output, cache) and session cost.

Token totals come from a small script (scripts/tokens.mjs) that sums usage from the session's local transcript files under ~/.claude/projects. Nothing is sent anywhere.

Install

git clone https://github.com/joydeep-pm/usage-band ~/.claude/mods/usage-band
claude plugin validate ~/.claude/mods/usage-band

Load it for one session:

claude --plugin-dir ~/.claude/mods/usage-band

Or load it in every session by adding the folder to CLAUDE_CODE_PLUGIN_DIRS (colon separated) in the env block of ~/.claude/settings.json.

Development

tsconfig.json expects the engine's type declarations in .claude/types. Generate them with /plugin-types inside Claude Code. They are not committed.

Source 2 files
hooks/register.tsx 564 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type {
5  UsageContext,
6  UsageFallback,
7  UsageLimits,
8  UsageTokens,
9  UsageWindow,
10} from '../types'
11
12const limitsAtom = atom({ plugin: 'usage-band', key: 'limits' } as const, null)
13const costAtom = atom({ plugin: 'usage-band', key: 'cost' } as const, null)
14const contextAtom = atom({ plugin: 'usage-band', key: 'context' } as const, null)
15const tokensAtom = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
16const fallbackAtom = atom({ plugin: 'usage-band', key: 'fallback' } as const, {
17  uncachedInput: 0,
18  cacheWrite: 0,
19  output: 0,
20  cacheRead: 0,
21  turns: 0,
22})
23const hiddenAtom = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
24const nowAtom = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
25
26const FIVE_HOURS = 5 * 3600 * 1000
27const SEVEN_DAYS = 7 * 24 * 3600 * 1000
28const NODES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node']
29
30type Kind = 'h5' | 'd7' | 'in' | 'out' | 'cache' | 'cost'
31
32type Model = {
33  limits: UsageLimits | null
34  cost: number | null
35  context: UsageContext | null
36  tokens: UsageTokens | null
37  fallback: UsageFallback
38  now: number
39}
40
41// ---------- formatting ----------
42
43export const fmtTokens = (n: number): string => {
44  if (n < 1000) return String(Math.round(n))
45  if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
46  return `${(n / 1_000_000).toFixed(2)}M`
47}
48
49export const fmtLeft = (ms: number): string => {
50  const minutes = Math.max(0, Math.floor(ms / 60000))
51  const d = Math.floor(minutes / 1440)
52  const h = Math.floor((minutes % 1440) / 60)
53  const m = minutes % 60
54  if (d > 0) return `${d}d ${h}h`
55  if (h > 0) return `${h}h ${m}m`
56  return `${m}m`
57}
58
59const fmtUsd = (usd: number): string => `$${usd.toFixed(2)}`
60
61const fmtInt = (n: number): string => Math.round(n).toLocaleString('en-US')
62
63const level = (pct: number): 'ok' | 'warn' | 'bad' =>
64  pct >= 90 ? 'bad' : pct >= 70 ? 'warn' : 'ok'
65
66const elapsedFraction = (w: UsageWindow, length: number, now: number): number | null => {
67  if (w.resetsAt === null) return null
68  const left = Math.min(length, Math.max(0, w.resetsAt - now))
69  return 1 - left / length
70}
71
72const esc = (s: string): string =>
73  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
74
75// ---------- SVG pills (desktop) ----------
76
77const FONT = 12
78const CHAR = 7.3
79const H = 22
80const PAD = 8
81
82const STYLE = `
83.bg{rx:11}
84.h5 .bg{fill:#d3f1ea}.h5{color:#0d4a40}
85.d7 .bg{fill:#e9e1fb}.d7{color:#3b2775}
86.in .bg{fill:#fbe1dc}.in{color:#7a2317}
87.out .bg{fill:#dcf2db}.out{color:#1e5821}
88.cache .bg{fill:#dce8fb}.cache{color:#1a427f}
89.cost .bg{fill:#fbefc9}.cost{color:#664a00}
90text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:${FONT}px;fill:currentColor}
91.b{font-weight:700}
92.ic{fill:none;stroke:currentColor;stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round}
93.trk{fill:currentColor;opacity:.18}
94.sep{stroke:currentColor;opacity:.35}
95.tl{stroke:currentColor;stroke-width:1.4}
96.ok{fill:#2f9e5c}.warn{fill:#d39e00}.bad{fill:#d64545}
97@media (prefers-color-scheme: dark){
98.h5 .bg{fill:#103a34}.h5{color:#b5eee2}
99.d7 .bg{fill:#2a2146}.d7{color:#d8cbff}
100.in .bg{fill:#45201a}.in{color:#ffc8bd}
101.out .bg{fill:#1c3a1e}.out{color:#c1eec2}
102.cache .bg{fill:#19294a}.cache{color:#c3daff}
103.cost .bg{fill:#3d3112}.cost{color:#f4da8c}
104.ok{fill:#4cc27a}.warn{fill:#f0c419}.bad{fill:#ff6b6b}
105}`
106
107const ICONS: Record<string, string> = {
108  clock: '<circle class="ic" cx="6" cy="6" r="5"/><path class="ic" d="M6 3v3l2 1.5"/>',
109  calendar:
110    '<rect class="ic" x="1" y="2" width="10" height="9" rx="1.5"/><path class="ic" d="M1 5h10M4 0.8v2.4M8 0.8v2.4"/>',
111  hourglass: '<path class="ic" d="M3 1h6M3 11h6M3.5 1c0 3 5 3 5 5s-5 2-5 5M8.5 1c0 3-5 3-5 5s5 2 5 5"/>',
112  up: '<path class="ic" d="M6 11V1.5M2 5.5l4-4 4 4"/>',
113  down: '<path class="ic" d="M6 1v9.5M2 6.5l4 4 4-4"/>',
114  layers: '<path class="ic" d="M6 1l5 2.6-5 2.6-5-2.6z"/><path class="ic" d="M1 6.3l5 2.6 5-2.6M1 8.8l5 2.6 5-2.6"/>',
115  dollar:
116    '<path class="ic" d="M6 0.5v11M8.6 3.2C8.2 2.3 7.2 1.9 6 1.9c-1.5 0-2.6.8-2.6 1.9 0 2.6 5.3 1.4 5.3 4.2 0 1.1-1.1 2-2.7 2-1.3 0-2.4-.5-2.8-1.5"/>',
117}
118
119type Part =
120  | { t: 'icon'; name: string }
121  | { t: 'text'; s: string; bold?: boolean }
122  | { t: 'bar'; pct: number; elapsed: number | null }
123  | { t: 'sep' }
124
125const partWidth = (p: Part): number =>
126  p.t === 'icon' ? 12 : p.t === 'text' ? p.s.length * CHAR : p.t === 'bar' ? 44 : 9
127
128const pill = (kind: Kind, parts: Part[], tip: string): { source: string; width: number } => {
129  const GAP = 5
130  const inner = parts.reduce((sum, p) => sum + partWidth(p), 0) + GAP * (parts.length - 1)
131  const width = Math.ceil(inner + PAD * 2)
132  let x = PAD
133  const body: string[] = []
134  for (const p of parts) {
135    if (p.t === 'icon') {
136      body.push(`<g transform="translate(${x.toFixed(1)} 5)">${ICONS[p.name] ?? ''}</g>`)
137    } else if (p.t === 'text') {
138      body.push(
139        `<text x="${x.toFixed(1)}" y="15"${p.bold ? ' class="b"' : ''}>${esc(p.s)}</text>`,
140      )
141    } else if (p.t === 'bar') {
142      const w = 44
143      const fill = (Math.min(100, Math.max(0, p.pct)) / 100) * w
144      body.push(`<rect class="trk" x="${x.toFixed(1)}" y="8.5" width="${w}" height="5" rx="2.5"/>`)
145      if (fill > 0) {
146        body.push(
147          `<rect class="${level(p.pct)}" x="${x.toFixed(1)}" y="8.5" width="${Math.max(2.5, fill).toFixed(1)}" height="5" rx="2.5"/>`,
148        )
149      }
150      if (p.elapsed !== null) {
151        const tx = x + Math.min(1, Math.max(0, p.elapsed)) * w
152        body.push(`<line class="tl" x1="${tx.toFixed(1)}" y1="4" x2="${tx.toFixed(1)}" y2="16"/>`)
153      }
154    } else {
155      const sx = x + 4.5
156      body.push(`<line class="sep" x1="${sx.toFixed(1)}" y1="6" x2="${sx.toFixed(1)}" y2="16"/>`)
157    }
158    x += partWidth(p) + GAP
159  }
160  const source =
161    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}">` +
162    `<style>${STYLE}</style>` +
163    `<g class="${kind}"><title>${esc(tip)}</title>` +
164    `<rect class="bg" x="0" y="0" width="${width}" height="${H}" rx="11"/>` +
165    body.join('') +
166    '</g></svg>'
167  return { source, width }
168}
169
170// ---------- pill model (shared by both surfaces) ----------
171
172type PillSpec = {
173  kind: Kind
174  group: 0 | 1 | 2
175  parts: Part[]
176  tip: string
177  alt: string
178}
179
180const windowSpec = (
181  kind: 'h5' | 'd7',
182  w: UsageWindow,
183  now: number,
184): PillSpec => {
185  const isFive = kind === 'h5'
186  const length = isFive ? FIVE_HOURS : SEVEN_DAYS
187  const label = isFive ? '5h' : '7d'
188  const elapsed = elapsedFraction(w, length, now)
189  const left = w.resetsAt === null ? null : fmtLeft(w.resetsAt - now)
190  const pct = `${Math.round(w.percentUsed)}%`
191  const parts: Part[] = [
192    { t: 'icon', name: isFive ? 'clock' : 'calendar' },
193    { t: 'text', s: label },
194    { t: 'bar', pct: w.percentUsed, elapsed },
195    { t: 'text', s: pct, bold: true },
196  ]
197  if (left !== null) parts.push({ t: 'sep' }, { t: 'icon', name: 'hourglass' }, { t: 'text', s: left })
198  const resetAt = w.resetsAt === null ? 'unknown' : new Date(w.resetsAt).toLocaleString()
199  const tip = [
200    `${isFive ? '5-hour' : '7-day'} limit: ${w.percentUsed}% used`,
201    `Resets in ${left ?? '?'} (${resetAt})`,
202    elapsed === null ? 'Window elapsed: ?' : `Window elapsed: ${Math.round(elapsed * 100)}%`,
203  ].join('\n')
204  return { kind, group: 0, parts, tip, alt: `${label} ${pct}${left ? `, resets in ${left}` : ''}` }
205}
206
207const specs = (m: Model): PillSpec[] => {
208  const out: PillSpec[] = []
209  if (m.limits?.fiveHour) out.push(windowSpec('h5', m.limits.fiveHour, m.now))
210  if (m.limits?.sevenDay) out.push(windowSpec('d7', m.limits.sevenDay, m.now))
211
212  const t: UsageTokens | null =
213    m.tokens ??
214    (m.fallback.turns > 0
215      ? {
216          uncachedInput: m.fallback.uncachedInput,
217          cacheWrite: m.fallback.cacheWrite,
218          output: m.fallback.output,
219          cacheRead: m.fallback.cacheRead,
220          requests: m.fallback.turns,
221          isApprox: true,
222          dump: '',
223          subagentFiles: 0,
224        }
225      : null)
226  if (t && t.requests > 0) {
227    const pre = t.isApprox ? '~' : ''
228    const ctx = m.context
229    const ctxLine = ctx
230      ? `Context: ${ctx.percent === null ? '?' : `${Math.round(ctx.percent)}%`} of ${fmtTokens(ctx.window)}${ctx.tokens === null ? '' : ` (${fmtInt(ctx.tokens)} tokens)`}`
231      : 'Context: unknown'
232    const source = t.isApprox
233      ? `Source: turn.complete totals (${t.requests} turns, approximate)`
234      : `Dump: ${t.dump}${t.subagentFiles ? ` + ${t.subagentFiles} subagent file(s)` : ''}`
235    const reqLine = t.isApprox ? `Turns: ${t.requests}` : `Requests: ${t.requests}`
236    const input = t.uncachedInput + t.cacheWrite
237    out.push({
238      kind: 'in',
239      group: 1,
240      parts: [{ t: 'icon', name: 'up' }, { t: 'text', s: pre + fmtTokens(input) }],
241      tip: [
242        `Input tokens: ${fmtInt(input)}`,
243        `  uncached ${fmtInt(t.uncachedInput)} + cache writes ${fmtInt(t.cacheWrite)}`,
244        reqLine,
245        ctxLine,
246        source,
247      ].join('\n'),
248      alt: `input ${pre}${fmtTokens(input)}`,
249    })
250    out.push({
251      kind: 'out',
252      group: 1,
253      parts: [{ t: 'icon', name: 'down' }, { t: 'text', s: pre + fmtTokens(t.output) }],
254      tip: [`Output tokens: ${fmtInt(t.output)}`, reqLine, ctxLine, source].join('\n'),
255      alt: `output ${pre}${fmtTokens(t.output)}`,
256    })
257    out.push({
258      kind: 'cache',
259      group: 1,
260      parts: [{ t: 'icon', name: 'layers' }, { t: 'text', s: pre + fmtTokens(t.cacheRead) }],
261      tip: [`Cache read tokens: ${fmtInt(t.cacheRead)}`, reqLine, ctxLine, source].join('\n'),
262      alt: `cache read ${pre}${fmtTokens(t.cacheRead)}`,
263    })
264  }
265  if (m.cost !== null && out.length > 0) {
266    out.push({
267      kind: 'cost',
268      group: 2,
269      parts: [{ t: 'icon', name: 'dollar' }, { t: 'text', s: m.cost.toFixed(2) }],
270      tip: `Session cost: $${m.cost.toFixed(4)}\nat API list price (as /cost totals it)`,
271      alt: `cost ${fmtUsd(m.cost)}`,
272    })
273  }
274  return out
275}
276
277export const renderSvgPills = (m: Model) =>
278  specs(m).map(s => ({ ...s, ...pill(s.kind, s.parts, s.tip) }))
279
280// ---------- terminal ----------
281
282const TERM_COLOR: Record<Kind, string> = {
283  h5: '#2bb3a0',
284  d7: '#9b7be0',
285  in: '#e0705f',
286  out: '#4caf50',
287  cache: '#4a8fe0',
288  cost: '#d4a72c',
289}
290const LEVEL_COLOR = { ok: '#4caf50', warn: '#e0b000', bad: '#e05050' }
291const TERM_ICON: Record<string, string> = {
292  clock: '◷',
293  calendar: '▦',
294  hourglass: '⧗',
295  up: '↑',
296  down: '↓',
297  layers: '≋',
298  dollar: '$',
299}
300
301const termBar = (pct: number, elapsed: number | null): { fill: string; mark: number; rest: string } => {
302  const cells = 10
303  const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * cells)
304  const mark = elapsed === null ? -1 : Math.min(cells - 1, Math.round(elapsed * cells))
305  return { fill: '█'.repeat(filled), mark, rest: '░'.repeat(cells - filled) }
306}
307
308// ---------- data ----------
309
310const toWindow = (r: SessionRateLimit | undefined): UsageWindow | null => {
311  if (!r) return null
312  const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
313  return { percentUsed: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
314}
315
316const toLimits = (list: readonly SessionRateLimit[]): UsageLimits | null => {
317  const fiveHour = toWindow(list.find(r => r.kind === 'five_hour'))
318  const sevenDay = toWindow(list.find(r => r.kind === 'seven_day'))
319  return fiveHour || sevenDay ? { fiveHour, sevenDay } : null
320}
321
322type ScriptOut = {
323  uncachedInput: number
324  cacheWrite: number
325  output: number
326  cacheRead: number
327  requests: number
328  mainPath: string
329  subDir: string
330  subagentFiles: number
331  sig: string
332}
333
334let nodePath: string | null = null
335let lastSig: string | null = null
336let lastPaths: { mainPath: string; subDir: string } | null = null
337let busy = false
338
339const currentSig = async ($: EngineInterface): Promise<string | null> => {
340  if (!lastPaths) return null
341  try {
342    const main = await $.fs.stat(lastPaths.mainPath)
343    const parts = [`main:${main.size}:${Math.trunc(main.mtimeMs)}`]
344    if (await $.fs.exists(lastPaths.subDir)) {
345      const files = (await $.fs.list(lastPaths.subDir))
346        .filter(f => f.kind === 'file' && f.name.endsWith('.jsonl'))
347        .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
348      for (const f of files) parts.push(`${f.name}:${f.size}:${Math.trunc(f.mtimeMs)}`)
349    }
350    return parts.join('|')
351  } catch {
352    return null
353  }
354}
355
356const runScript = async ($: EngineInterface, id: string): Promise<ScriptOut | null> => {
357  const script = `${$.plugin.root}/scripts/tokens.mjs`
358  const candidates = nodePath ? [nodePath] : NODES
359  for (const node of candidates) {
360    try {
361      const ran = await $.process.run([node, script, id], { timeoutMs: 20000 })
362      if (ran.exitCode !== 0) return null
363      nodePath = node
364      return JSON.parse(ran.stdout.trim()) as ScriptOut
365    } catch {
366      continue
367    }
368  }
369  return null
370}
371
372const refreshTokens = async ($: EngineInterface, force = false): Promise<void> => {
373  if (busy) return
374  busy = true
375  try {
376    if (!force && lastSig !== null && (await currentSig($)) === lastSig) return
377    const id = await $.session.id()
378    const out = await runScript($, id)
379    if (!out) {
380      lastSig = null
381      await update($, tokensAtom, () => null)
382      return
383    }
384    lastSig = out.sig
385    lastPaths = { mainPath: out.mainPath, subDir: out.subDir }
386    const tokens: UsageTokens = {
387      uncachedInput: out.uncachedInput,
388      cacheWrite: out.cacheWrite,
389      output: out.output,
390      cacheRead: out.cacheRead,
391      requests: out.requests,
392      isApprox: false,
393      dump: out.mainPath.split('/').pop() ?? out.mainPath,
394      subagentFiles: out.subagentFiles,
395    }
396    await update($, tokensAtom, () => tokens)
397  } finally {
398    busy = false
399  }
400}
401
402const refreshUsage = async ($: EngineInterface): Promise<void> => {
403  const u = await $.session.usage()
404  await applyUsage($, u.rateLimits, u.cost?.usd ?? null, u.context)
405}
406
407const applyUsage = async (
408  $: EngineInterface,
409  rateLimits: readonly SessionRateLimit[],
410  usd: number | null,
411  ctx: { tokens?: number; window: number; percent?: number },
412): Promise<void> => {
413  const limits = toLimits(rateLimits)
414  if (limits) await update($, limitsAtom, () => limits)
415  await update($, costAtom, () => usd)
416  const context: UsageContext = {
417    tokens: ctx.tokens ?? null,
418    window: ctx.window,
419    percent: ctx.percent ?? null,
420  }
421  await update($, contextAtom, () => context)
422}
423
424const refreshAll = async ($: EngineInterface, force = false): Promise<void> => {
425  const now = await $.clock.now()
426  await update($, nowAtom, () => now)
427  await refreshUsage($)
428  await refreshTokens($, force)
429}
430
431const readModel = async ($: EngineInterface): Promise<Model> => ({
432  limits: await read($, limitsAtom),
433  cost: await read($, costAtom),
434  context: await read($, contextAtom),
435  tokens: await read($, tokensAtom),
436  fallback: await read($, fallbackAtom),
437  now: (await read($, nowAtom)) || (await $.clock.now()),
438})
439
440const summary = (m: Model): string => {
441  const parts: string[] = []
442  for (const s of specs(m)) {
443    const text = s.parts
444      .filter((p): p is Extract<Part, { t: 'text' }> => p.t === 'text')
445      .map(p => p.s)
446    if (s.kind === 'h5' || s.kind === 'd7') {
447      parts.push(`${text[0]} ${text[1]}${text[2] ? ` (${text[2]})` : ''}`)
448    } else {
449      const label = { in: 'in', out: 'out', cache: 'cache', cost: 'cost' }[s.kind]
450      parts.push(`${label} ${s.kind === 'cost' ? '$' : ''}${text[0]}`)
451    }
452  }
453  return parts.length ? parts.join(' · ') : 'No usage yet: figures appear after the first model response.'
454}
455
456// ---------- hooks ----------
457
458export const register: Register = on => {
459  on('session.start', async ($, e, next) => {
460    await $.command.register({
461      name: 'kullanim',
462      description: 'Usage band: refresh and summarize (gizle hides, goster shows)',
463    })
464    $.clock.every(30_000, () => void refreshAll($).catch(() => undefined))
465    void refreshAll($, true).catch(() => undefined)
466    return next(e)
467  })
468
469  on('session.measure', async ($, e, next) => {
470    await applyUsage($, e.rateLimits, e.cost?.usd ?? null, e.context)
471    void refreshTokens($).catch(() => undefined)
472    return next(e)
473  })
474
475  on('turn.complete', async ($, e, next) => {
476    const u = e.usage
477    if (u) {
478      await update($, fallbackAtom, f => ({
479        uncachedInput: f.uncachedInput + u.input_tokens,
480        cacheWrite: f.cacheWrite + u.cache_creation_input_tokens,
481        output: f.output + u.output_tokens,
482        cacheRead: f.cacheRead + u.cache_read_input_tokens,
483        turns: f.turns + 1,
484      }))
485    }
486    void refreshTokens($).catch(() => undefined)
487    return next(e)
488  })
489
490  on('command.run', { command: 'kullanim' }, async ($, e) => {
491    const arg = e.args.trim().toLowerCase()
492    if (arg === 'gizle') {
493      await update($, hiddenAtom, () => true)
494      return { text: 'Usage band hidden. /kullanim goster brings it back.' }
495    }
496    if (arg === 'goster' || arg === 'göster') {
497      await update($, hiddenAtom, () => false)
498      return { text: 'Usage band shown.' }
499    }
500    await refreshAll($, true)
501    return { text: summary(await readModel($)) }
502  })
503
504  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
505    if (e.props.hasSurvey || (await read($, hiddenAtom))) return next(e)
506    const m = await readModel($)
507    if (specs(m).length === 0) return next(e)
508
509    if (e.surface === 'terminal') {
510      const { Box, Text } = $.ui.resolve(e)
511      const groups: PillSpec[][] = [[], [], []]
512      for (const s of specs(m)) groups[s.group]?.push(s)
513      return (
514        <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
515          {groups
516            .filter(g => g.length > 0)
517            .map((g, gi) => (
518              <Box key={`g${gi}`} flexDirection="row" flexWrap="wrap" columnGap={2}>
519                {g.map(s => (
520                  <Text key={s.kind} color={TERM_COLOR[s.kind]}>
521                    {s.parts.map((p, i) => {
522                      const gap = i < s.parts.length - 1 ? ' ' : ''
523                      if (p.t === 'icon') return `${TERM_ICON[p.name] ?? ''}${gap}`
524                      if (p.t === 'sep') return `·${gap}`
525                      if (p.t === 'text') {
526                        return p.bold ? <Text bold>{p.s + gap}</Text> : p.s + gap
527                      }
528                      const b = termBar(p.pct, p.elapsed)
529                      const cells = (b.fill + b.rest).split('')
530                      return (
531                        <Text color={LEVEL_COLOR[level(p.pct)]}>
532                          {cells.map((c, ci) => (ci === b.mark ? '│' : c)).join('') + gap}
533                        </Text>
534                      )
535                    })}
536                  </Text>
537                ))}
538              </Box>
539            ))}
540        </Box>
541      )
542    }
543
544    const els = $.ui.resolve(e)
545    if (!('Svg' in els)) return next(e)
546    const { Box, Svg } = els
547    const pills = renderSvgPills(m)
548    const groups = [0, 1, 2]
549      .map(g => pills.filter(p => p.group === g))
550      .filter(g => g.length > 0)
551    return (
552      <Box flexDirection="row" flexWrap="wrap" columnGap={3} rowGap={1}>
553        {groups.map((g, gi) => (
554          <Box key={`g${gi}`} flexDirection="row" flexWrap="wrap" columnGap={1}>
555            {g.map(p => (
556              <Svg key={p.kind} source={p.source} alt={p.alt} width={p.width} height={H} isInteractive />
557            ))}
558          </Box>
559        ))}
560      </Box>
561    )
562  })
563}
564
types/index.d.ts 39 lines
1export type UsageWindow = { percentUsed: number; resetsAt: number | null }
2
3export type UsageLimits = { fiveHour: UsageWindow | null; sevenDay: UsageWindow | 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  isApprox: boolean
14  dump: string
15  subagentFiles: number
16}
17
18export type UsageFallback = {
19  uncachedInput: number
20  cacheWrite: number
21  output: number
22  cacheRead: number
23  turns: number
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'usage-band': {
29      limits: UsageLimits | null
30      cost: number | null
31      context: UsageContext | null
32      tokens: UsageTokens | null
33      fallback: UsageFallback
34      isHidden: boolean
35      now: number
36    }
37  }
38}
39