SLOPSHOPPER

usage-band

Usage strip above the prompt: 5h and 7d limits, session tokens, context window and cost.

newbandtimer
v0.2.5MITupdated 2026-10-06brooklyn96/claude-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 ⏺ 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 󰊚 5h 69% ▏ 󰋚 NaNm 󰧑 97k $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
󰊚 5h 69% ▏ 󰋚 NaNm 󰧑 97k $0.42
README

claude-usage-band

A Claude Code plugin marketplace with one mod, usage-band: a band above the prompt showing quota, tokens, context and session cost.

Preview renders:

Usage band in the terminal

Usage band in the Desktop app Code tab

Pills

PillShows
5hPercent of the 5-hour quota left. The bar is the quota left, the tick is the time left in the window; shows the time until reset.
7dSame for the 7-day quota.
Tokens upInput tokens plus cache writes.
Tokens downOutput tokens.
Cache readsTokens read from the prompt cache.
ContextTokens of the last request. Bar and colour by fill: under 60% calm, 60-85% amber, over 85% red.
CostSession cost in US dollars.

When figures update

  • After every API response: quota, cost, context fill and token totals.
  • Reset countdowns redraw every 30 seconds.
  • Token totals and context start over on /clear and on resume.

Requirements

  • Claude Code v2.1.287 or later (mods).
  • Draws in the terminal and in the Desktop app Code tab.
  • Terminal icons and round pill ends need a Nerd Font (e.g. CaskaydiaMono Nerd Font). Without one, set glyphs to unicode.

Install

Inside Claude Code:

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

From a shell:

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

In an open session, run /reload-plugins to pick it up.

Update:

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

Options

glyphs: nerd (default) or unicode. Set it through /plugin (configure).

Narrow terminals

When the row does not fit, the cache pill drops first, then bars shorten and time labels go compact, then every pill wraps onto the next line at full size.

Development

claude plugin validate usage-band
claude plugin test usage-band

Type-check after the mod has loaded, since the engine writes usage-band/.claude-plugin/types on load:

npx -y -p typescript@5.6 tsc -p usage-band --noEmit

Security

A mod runs with your permissions. This one makes no network calls, writes no files and starts no processes.

License

MIT.

Source 3 files
hooks/register.tsx 170 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Tokens } from '../types'
5import { PILL_H, STRIP_BG, alt, isRate, pillSvg, pillsOf, termLayout } from './pills'
6import type { Pill, ValuePill } from './pills'
7
8const limitsAtom = atom({ plugin: 'usage-band', key: 'limits' } as const, [])
9const costAtom = atom({ plugin: 'usage-band', key: 'cost' } as const, null)
10const tokensAtom = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
11// `tokens` shape: the value changed from a bare percent to the token reading.
12const contextAtom = atom({ plugin: 'usage-band', key: 'context' } as const, null, { shape: 'tokens' })
13const nowAtom = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
14
15/** `context` only when its fill moved (or on a full reading); `tokens` is absent before the first response. */
16async function measured($: EngineInterface, rateLimits: readonly SessionRateLimit[], cost?: SessionCost, context?: SessionContextUsage) {
17  const limits = rateLimits.map(({ kind, percentUsed, resetsAt }): Limit => (resetsAt ? { kind, percentUsed, resetsAt } : { kind, percentUsed }))
18  await update($, limitsAtom, () => limits)
19  await update($, costAtom, () => cost?.usd ?? null)
20  if (context) {
21    const next = context.tokens === undefined ? null : { tokens: context.tokens, window: context.window, percent: context.percent }
22    await update($, contextAtom, () => next)
23  }
24}
25
26async function spent($: EngineInterface, u: ModelUsage | null | undefined) {
27  if (!u) return
28  await update($, tokensAtom, (t): Tokens => ({
29    input: (t?.input ?? 0) + u.input_tokens + u.cache_creation_input_tokens,
30    output: (t?.output ?? 0) + u.output_tokens,
31    cache: (t?.cache ?? 0) + u.cache_read_input_tokens,
32  }))
33}
34
35async function refresh($: EngineInterface) {
36  try {
37    const usage = await $.session.usage()
38    await measured($, usage.rateLimits, usage.cost, usage.context)
39    const now = await $.clock.now()
40    await update($, nowAtom, () => now)
41  } catch {
42    // The engine is shutting down or a hook refused the reading: the next tick tries again.
43  }
44}
45
46/** The /config theme row: `light`, `light-daltonized`, ... draw the light palette; anything else dark. */
47const isLight = ($: EngineInterface) =>
48  $.config.list().then(
49    rows => String(rows.find(row => row.key === 'theme')?.value ?? '').startsWith('light'),
50    () => false,
51  )
52
53// Timers die with the module, and a hot reload raises no session.start: whichever of
54// session.start or the first draw comes first starts the clock for this module.
55let started = false
56function start($: EngineInterface) {
57  if (started) return
58  started = true
59  $.clock.every(30_000, () => void refresh($))
60}
61
62export const register: Register = (on, options) => {
63  const glyphs = options.glyphs === 'unicode' ? 'unicode' : 'nerd'
64
65  on('session.start', async ($, e, next) => {
66    await refresh($)
67    start($)
68    return next(e)
69  })
70
71  on('session.measure', async ($, e, next) => {
72    await measured($, e.rateLimits, e.cost, e.changed.includes('context') ? e.context : undefined)
73    return next(e)
74  })
75
76  // Every priced response, main loop and subagents alike, as it comes back: the totals
77  // move with the cost, not at the turn's end (turn.complete's usage is these summed).
78  on('turn.step', async function* ($, e, next) {
79    const result = yield* next(e)
80    await spent($, result.usage)
81    return result
82  })
83
84  // The compaction summarizer is priced too, and raises no turn.
85  on('session.compact', async ($, e, next) => {
86    const result = await next(e)
87    if ('usage' in result) await spent($, result.usage)
88    return result
89  })
90
91  on('session.end', async ($, e, next) => {
92    // Cost follows the new session's ledger on both, so the token totals and the
93    // context fill start over too: the new session's first measure brings them back.
94    if (e.reason === 'clear' || e.reason === 'resume') {
95      await update($, tokensAtom, () => null)
96      await update($, contextAtom, () => null)
97    }
98    return next(e)
99  })
100
101  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102    if (!started) {
103      start($)
104      $.clock.after(0, () => void refresh($)) // first reading after a reload; a draw never writes
105    }
106    if (e.props.hasSurvey) return next(e)
107    await read($, nowAtom) // subscribes: the minute timer redraws the band
108    const pills = pillsOf(
109      await read($, limitsAtom),
110      await read($, tokensAtom),
111      await read($, costAtom),
112      await $.clock.now(),
113      await read($, contextAtom),
114    )
115    if (pills.length === 0) return next(e)
116
117    const left = pills.filter(p => p.id !== 'cost')
118    const cost = pills.find((p): p is ValuePill => p.id === 'cost')
119
120    if (e.surface === 'desktop') {
121      const { Box, Svg } = $.ui.resolve(e)
122      const pill = (p: Pill) => {
123        const { source, width } = pillSvg(p)
124        return <Svg key={p.id} source={source} alt={alt(p)} width={width} height={PILL_H} />
125      }
126      return (
127        <Box
128          flexDirection="row"
129          flexWrap="wrap"
130          alignItems="center"
131          backgroundColor={STRIP_BG}
132          borderStyle="round"
133          borderColor={STRIP_BG}
134          paddingX={2}
135          paddingY={1}
136          columnGap={1}
137          rowGap={1}
138        >
139          {left.filter(isRate).map(pill)}
140          {left.some(isRate) && left.some(p => !isRate(p)) ? <Box width={1} /> : null}
141          {left.filter(p => !isRate(p)).map(pill)}
142          <Box flexGrow={1} />
143          {cost ? pill(cost) : null}
144        </Box>
145      )
146    }
147
148    const { Box, Text } = $.ui.resolve(e)
149    const row = termLayout(pills, e.props.bodyColumns, glyphs, await isLight($))
150    const pill = ({ id, runs }: (typeof row.pills)[number]) => (
151      <Box key={id} flexDirection="row" flexShrink={0}>
152        {runs.map((r, i) => (
153          <Text key={String(i)} color={r.fg} backgroundColor={r.bg} bold={r.bold}>
154            {r.s}
155          </Text>
156        ))}
157      </Box>
158    )
159    const costPill = row.pills.find(p => p.id === 'cost')
160    // Wrapped or a tight row, no spacer: the cost pill follows on whichever line has room.
161    return (
162      <Box flexDirection="row" flexWrap={row.wrap ? 'wrap' : 'nowrap'} columnGap={1} width={e.props.bodyColumns}>
163        {row.pills.filter(p => p.id !== 'cost').map(pill)}
164        {costPill && row.spacer ? <Box flexGrow={1} /> : null}
165        {costPill ? pill(costPill) : null}
166      </Box>
167    )
168  })
169}
170
hooks/pills.ts 316 lines
1import type { ContextUsage, Limit, Tokens } from '../types'
2
3const MIN = 60_000
4const HOUR = 60 * MIN
5const DAY = 24 * HOUR
6const WINDOWS = { five_hour: 5 * HOUR, seven_day: 7 * DAY } as const
7
8/** `percent` is the quota remaining, `timeShare` the share of the window's time remaining. */
9export type RatePill = { id: '5h' | '7d'; percent: number; timeShare: number | null; left: string }
10export type ValuePill = { id: 'in' | 'out' | 'cache' | 'cost'; value: string }
11/** `tokens` is the last response's input side; `percent` is the context window USED (what auto-compact reacts to), unlike the quota pills. */
12export type ContextPill = { id: 'ctx'; tokens: number; percent: number }
13export type Pill = RatePill | ValuePill | ContextPill
14
15export const formatTokens = (n: number): string => {
16  if (n < 1000) return String(Math.round(n))
17  const k = Math.round(n / 100) / 10
18  if (k < 1000) return `${k.toFixed(1)}k`
19  return `${(Math.round(n / 100_000) / 10).toFixed(1)}M`
20}
21
22/** Context pill only: whole thousands, no decimal (`950`, `42k`, `210k`); one decimal of millions from 1M up (`1.0M`, `1.2M`). */
23export const formatCtxTokens = (n: number): string => {
24  if (n < 1000) return String(Math.round(n))
25  if (Math.round(n / 1000) < 1000) return `${Math.round(n / 1000)}k`
26  return `${(Math.round(n / 100_000) / 10).toFixed(1)}M`
27}
28
29export const formatLeft = (ms: number): string => {
30  const minutes = Math.max(0, Math.floor(ms / MIN))
31  const d = Math.floor(minutes / 1440)
32  const h = Math.floor((minutes % 1440) / 60)
33  const m = minutes % 60
34  if (d > 0) return `${d}d ${h}h`
35  if (h > 0) return `${h}h ${m}m`
36  return `${m}m`
37}
38
39/** (resetsAt - now) / window, clamped to 0..1; null when the reset is unknown. */
40export const remainingFraction = (limit: Limit, now: number): number | null => {
41  const window = WINDOWS[limit.kind as keyof typeof WINDOWS]
42  const resets = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
43  if (!window || Number.isNaN(resets)) return null
44  return Math.min(1, Math.max(0, (resets - now) / window))
45}
46
47export const pillsOf = (limits: Limit[], tokens: Tokens | null, cost: number | null, now: number, context: ContextUsage | null = null): Pill[] => {
48  const pills: Pill[] = []
49  for (const [kind, id] of [['five_hour', '5h'], ['seven_day', '7d']] as const) {
50    const limit = limits.find(l => l.kind === kind)
51    if (!limit) continue
52    const left = limit.resetsAt === undefined ? '' : formatLeft(Date.parse(limit.resetsAt) - now)
53    pills.push({ id, percent: Math.max(0, Math.round(100 - limit.percentUsed)), timeShare: remainingFraction(limit, now), left })
54  }
55  if (tokens) {
56    pills.push({ id: 'in', value: formatTokens(tokens.input) })
57    pills.push({ id: 'out', value: formatTokens(tokens.output) })
58    pills.push({ id: 'cache', value: formatTokens(tokens.cache) })
59  }
60  if (context !== null) {
61    const percent = context.percent ?? (context.window > 0 ? (context.tokens * 100) / context.window : 0)
62    pills.push({ id: 'ctx', tokens: context.tokens, percent: Math.min(100, Math.max(0, Math.round(percent))) })
63  }
64  if (cost !== null) pills.push({ id: 'cost', value: `$${cost.toFixed(2)}` })
65  return pills
66}
67
68export const isRate = (p: Pill): p is RatePill => p.id === '5h' || p.id === '7d'
69
70/** Context fill step: below 60% calm, 60-85% amber, above 85% red. */
71export const ctxLevel = (percent: number) => (percent > 85 ? 'high' : percent >= 60 ? 'warn' : 'ok')
72
73// Colours sampled from the reference (light theme).
74export const COLORS = {
75  '5h': { bg: '#dce8e4', fg: '#3d8c7d' },
76  '7d': { bg: '#e4e0f3', fg: '#7b5fd3' },
77  in: { bg: '#f4ddd9', fg: '#d0553f' },
78  out: { bg: '#dde9dc', fg: '#4c9a50' },
79  cache: { bg: '#dfe2f7', fg: '#4a5bd6' },
80  cost: { bg: '#f1ebd8', fg: '#c4952a' },
81} as const
82export const CTX_COLORS = {
83  ok: { bg: '#d6eaee', fg: '#26879c' },
84  warn: { bg: '#f7e3cb', fg: '#c46a12' },
85  high: { bg: '#f6d5d8', fg: '#c62f3e' },
86} as const
87export const STRIP_BG = '#f1f0ec'
88const INK = '#2b2b2b'
89const MUTED = '#6f6f6f'
90const FILL = '#8db36a'
91const TRACK = '#d9d9d6'
92const TICK = '#333333'
93const DIVIDER = '#b9c2c0'
94
95export const alt = (p: Pill): string => {
96  if (isRate(p)) {
97    const name = p.id === '5h' ? '5-hour' : '7-day'
98    return `${name} limit ${p.percent}% left${p.left ? `, resets in ${p.left}` : ''}`
99  }
100  if (p.id === 'ctx') return `context ${formatCtxTokens(p.tokens)} tokens (${p.percent}% of window)`
101  return { in: 'Input tokens', out: 'Output tokens', cache: 'Cache read', cost: 'Session cost' }[p.id] + ` ${p.value}`
102}
103
104// Icons: 24-unit stroke paths (Lucide-style), drawn at ICON px.
105const ICONS = {
106  gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
107  calendar:
108    '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>' +
109    '<text x="12" y="19.6" font-size="10" font-weight="700" text-anchor="middle" stroke="none" fill="currentColor">7</text>',
110  history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
111  in: '<path d="M12 3v11M7 8l5-5 5 5"/><rect x="3" y="15" width="18" height="6" rx="2"/>',
112  out: '<path d="M12 2v11M7 8l5 5 5-5"/><rect x="3" y="15" width="18" height="6" rx="2"/>',
113  cache:
114    '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/>' +
115    '<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
116  cost:
117    '<circle cx="12" cy="12" r="10" fill="#f3d98a"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>',
118  ctx:
119    '<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>' +
120    '<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>' +
121    '<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M12 5v13"/>',
122} as const
123
124// Sizes measured on the reference at 1x.
125export const PILL_H = 22
126const H = PILL_H
127const FONT = 12.5
128const CHAR = 7.5 // advance of a 12.5px monospace glyph (0.6em)
129const ICON = 14
130const BAR = 46
131const CTX_BAR = 30
132const FAMILY = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace"
133
134const icon = (name: keyof typeof ICONS, x: number, size: number, color: string) =>
135  `<g transform="translate(${x} ${(H - size) / 2}) scale(${size / 24})" fill="none" stroke="${color}" ` +
136  `stroke-width="2" stroke-linecap="round" stroke-linejoin="round" color="${color}">${ICONS[name]}</g>`
137
138const text = (s: string, x: number, color: string, bold = false) =>
139  `<text x="${x}" y="${H / 2}" dominant-baseline="central" fill="${color}"${bold ? ' font-weight="700"' : ''}>${s}</text>`
140
141/** One pill as an SVG document and its width in CSS pixels. */
142export const pillSvg = (p: Pill): { source: string; width: number } => {
143  const { bg, fg } = p.id === 'ctx' ? CTX_COLORS[ctxLevel(p.percent)] : COLORS[p.id]
144  const parts: string[] = []
145  let x = 9
146  if (isRate(p)) {
147    parts.push(icon(p.id === '5h' ? 'gauge' : 'calendar', x, ICON, fg))
148    x += ICON + 7
149    parts.push(text(p.id, x, MUTED))
150    x += 2 * CHAR + 8
151    const fill = (BAR * Math.min(100, Math.max(0, p.percent))) / 100
152    const y = (H - 4) / 2
153    parts.push(
154      `<rect x="${x}" y="${y}" width="${BAR}" height="4" rx="2" fill="${TRACK}"/>`,
155      `<rect x="${x}" y="${y}" width="${fill}" height="4" rx="2" fill="${FILL}"/>`,
156    )
157    if (p.timeShare !== null) {
158      const tick = Math.min(BAR - 1, Math.max(1, BAR * p.timeShare))
159      parts.push(`<rect x="${x + tick - 1}" y="${(H - 12) / 2}" width="2" height="12" rx="1" fill="${TICK}"/>`)
160    }
161    x += BAR + 8
162    const pct = `${p.percent}%`
163    parts.push(text(pct, x, INK, true))
164    x += pct.length * CHAR + 9
165    if (p.left) {
166      parts.push(`<line x1="${x}" y1="${(H - 14) / 2}" x2="${x}" y2="${(H + 14) / 2}" stroke="${DIVIDER}" stroke-width="1"/>`)
167      x += 9
168      parts.push(icon('history', x, 13, fg))
169      x += 13 + 6
170      parts.push(text(p.left, x, MUTED))
171      x += p.left.length * CHAR + 11
172    } else x += 2
173  } else if (p.id === 'ctx') {
174    parts.push(icon('ctx', x, ICON, fg))
175    x += ICON + 7
176    const y = (H - 4) / 2
177    parts.push(
178      `<rect x="${x}" y="${y}" width="${CTX_BAR}" height="4" rx="2" fill="${TRACK}"/>`,
179      `<rect x="${x}" y="${y}" width="${(CTX_BAR * p.percent) / 100}" height="4" rx="2" fill="${fg}"/>`,
180    )
181    x += CTX_BAR + 8
182    const figure = formatCtxTokens(p.tokens)
183    parts.push(text(figure, x, INK, true))
184    x += figure.length * CHAR + 10
185  } else {
186    parts.push(icon(p.id, x, ICON, fg))
187    x += ICON + 7.5
188    parts.push(text(p.value, x, INK))
189    x += p.value.length * CHAR + 10
190  }
191  const width = Math.ceil(x)
192  const source =
193    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}" ` +
194    `font-family="${FAMILY}" font-size="${FONT}">` +
195    `<rect width="${width}" height="${H}" rx="${H / 2}" fill="${bg}"/>${parts.join('')}</svg>`
196  return { source, width }
197}
198
199// Terminal: each pill a row of coloured runs between two caps drawn in the pill colour.
200export type Run = { s: string; fg?: string; bg?: string; bold?: boolean }
201export type Glyphs = 'nerd' | 'unicode'
202
203const GLYPHS = {
204  // Nerd Font: powerline round caps, Material Design icons (each one cell).
205  nerd: {
206    caps: ['', ''],
207    '5h': '\u{f029a}', // md-gauge
208    '7d': '\u{f00ed}', // md-calendar
209    left: '\u{f02da}', // md-history
210    in: '\u{f011d}', // md-tray_arrow_up
211    out: '\u{f0120}', // md-tray_arrow_down
212    cache: '\u{f0328}', // md-layers
213    cost: '', // fa-coins: md has no currency-usd circle
214    ctx: '\u{f09d1}', // md-brain
215  },
216  // Only glyphs Cascadia Mono has, all single width.
217  unicode: { caps: ['▐', '▌'], '5h': '◔', '7d': '▤', left: '◷', in: '↑', out: '↓', cache: '≡', cost: '◉', ctx: '◐' },
218} as const
219
220export const TERM_PALETTE = {
221  dark: {
222    pill: {
223      '5h': { bg: '#1d3b36', fg: '#5ed0b8' },
224      '7d': { bg: '#2d2850', fg: '#a892ff' },
225      in: { bg: '#47231e', fg: '#ff7e66' },
226      out: { bg: '#1f3d23', fg: '#72d37c' },
227      cache: { bg: '#242c55', fg: '#8796ff' },
228      cost: { bg: '#40331a', fg: '#f2c24e' },
229    },
230    ctx: {
231      ok: { bg: '#14353d', fg: '#4cc3d9' },
232      warn: { bg: '#48300f', fg: '#ff9f43' },
233      high: { bg: '#4d1a22', fg: '#ff5c6c' },
234    },
235    text: '#ececea',
236    muted: '#a9b1af',
237    track: '#4a504e',
238    fill: '#93d36d',
239    tick: '#ffffff',
240  },
241  light: { pill: COLORS, ctx: CTX_COLORS, text: INK, muted: MUTED, track: '#c5c8c4', fill: FILL, tick: TICK },
242} as const
243
244export const cells = (runs: Run[]): number => runs.reduce((n, r) => n + [...r.s].length, 0)
245
246/**
247 * One pill as runs, caps included, at ladder `step` (see termLayout): from step 3
248 * the bars are shorter, from step 4 the time labels compact (`2h40m`, no divider).
249 */
250export const termPill = (p: Pill, glyphs: Glyphs, light: boolean, step = 1): Run[] => {
251  const g = GLYPHS[glyphs]
252  const pal = TERM_PALETTE[light ? 'light' : 'dark']
253  const { bg, fg } = p.id === 'ctx' ? pal.ctx[ctxLevel(p.percent)] : pal.pill[p.id]
254  const runs: Run[] = []
255  const put = (s: string, color: string, extra: Partial<Run> = {}) => {
256    const run = { s, fg: color, bg, ...extra }
257    const last = runs[runs.length - 1]
258    if (last && last.fg === run.fg && last.bg === run.bg && !!last.bold === !!run.bold) last.s += s
259    else runs.push(run)
260  }
261  // Whole cells only: every bar cell is a space on the fill or track background, the tick the one glyph.
262  const bar = (percent: number, n: number, fill: string, tickAt = -1) => {
263    const filled = Math.min(n, Math.max(0, Math.round((percent / 100) * n)))
264    for (let i = 0; i < n; i++) {
265      const cellBg = i < filled ? fill : pal.track
266      if (i === tickAt) put('▎', pal.tick, { bg: cellBg, bold: true })
267      else put(' ', cellBg, { bg: cellBg })
268    }
269  }
270  runs.push({ s: g.caps[0], fg: bg })
271  if (isRate(p)) {
272    const n = step >= 3 ? 4 : 6
273    put(`${g[p.id]} `, fg)
274    put(`${p.id} `, pal.muted)
275    bar(p.percent, n, pal.fill, p.timeShare === null ? -1 : Math.min(n - 1, Math.floor(p.timeShare * n)))
276    put(' ', fg)
277    put(`${p.percent}%`, pal.text, { bold: true })
278    if (p.left && step >= 4) {
279      put(` ${g.left}`, fg)
280      put(p.left.replace(' ', ''), pal.muted)
281    } else if (p.left) {
282      put(' ▏ ', pal.muted)
283      put(`${g.left} `, fg)
284      put(p.left, pal.muted)
285    }
286  } else if (p.id === 'ctx') {
287    put(`${g.ctx} `, fg)
288    bar(p.percent, step >= 3 ? 3 : 4, fg)
289    put(' ', fg)
290    put(formatCtxTokens(p.tokens), pal.text, { bold: true })
291  } else {
292    put(`${g[p.id]} `, fg)
293    put(p.value, pal.text)
294  }
295  runs.push({ s: g.caps[1], fg: bg })
296  return runs
297}
298
299/**
300 * The terminal row at `columns`, the first step that fits: 1 every pill;
301 * 2 without the cache pill; 3 shorter bars (quota 6 -> 4 cells, context
302 * 4 -> 3); 4 compact time labels; 5 every pill, wrapped. Width counts one gap
303 * between pills; the cost pill's spacer (one more gap) shows only when it fits.
304 */
305export const termLayout = (pills: Pill[], columns: number, glyphs: Glyphs, light: boolean) => {
306  const build = (step: number) => {
307    const row = pills.filter(p => step < 2 || step > 4 || p.id !== 'cache').map(p => ({ id: p.id, runs: termPill(p, glyphs, light, step > 4 ? 1 : step) }))
308    return { pills: row, width: row.reduce((n, p) => n + cells(p.runs) + 1, -1), step }
309  }
310  for (let step = 1; step <= 4; step++) {
311    const row = build(step)
312    if (row.width <= columns) return { ...row, wrap: false, spacer: row.pills.some(p => p.id === 'cost') && row.width + 1 <= columns }
313  }
314  return { ...build(5), wrap: true, spacer: false }
315}
316
types/index.d.ts 18 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Tokens = { input: number; output: number; cache: number }
3/** The live context reading: the last response's input tokens, its window, and the engine's percent when given. */
4export type ContextUsage = { tokens: number; window: number; percent?: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'usage-band': {
9      limits: Limit[]
10      cost: number | null
11      tokens: Tokens | null
12      /** Live context window reading; null before the first response fills it. */
13      context: Shaped<ContextUsage | null>
14      now: number
15    }
16  }
17}
18