SLOPSHOPPER

usage-band

A band above the prompt showing session usage: cost, prompts, rate limits and context fill.

newbandtimer
v0.3.0no licenseupdated 2026-10-14QuantiCZ/claude-code-mods/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 $0.42 1 prompt 5h 31% 97.4k / 200k 49% ━━━━━───── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
$0.42 1 prompt 5h 31% 97.4k / 200k 49% ━━━━━─────
README

usage-band

A Claude Code mod: a band above the prompt showing session usage: the cost, prompt count and rate limits on the left, the context gauge on the right. Based on the token-weather sample in anthropics/claude-code-playground. Needs Claude Code 2.1.287 or later.

usage-band on desktop: $0.17, 1 prompt, the 5-hour limit at 53% resetting in 39m, the weekly limit at 17% resetting in 4d 18h, and the context at 64.9k of 1M (6%)

  • Context fill: a meter and percentage (a rounded bar on desktop, a line in the terminal), coloured by the theme: success below 50%, warning from 50%, Claude orange from 75%, error from 90%.
  • Context: tokens in the live context window against the window size.
  • Cost: the session's cost so far, as /cost totals it (an estimate at API prices), where Claude Code reports one.
  • Prompts: how many prompts you have sent this session, counted from the transcript.
  • Rate limits: each window the account reports (5-hour, weekly, a gateway's spend limit) in a pill showing the share used and the time until it resets, as the Usage panel shows them. Absent off a subscription. The share updates with each response from the model; the countdown every minute.

On desktop the rate-limit pills have a slightly darker, recessed background; the rest is plain text.

Under 80 columns the band drops the prompt count.

The band reads usage as soon as the session starts, showing an animated placeholder until the first figures arrive (it gives up after 30 seconds without any). It refreshes when a turn finishes and steps aside while a survey is shown.

/plugin marketplace add QuantiCZ/claude-code-mods
/plugin install usage-band@quanti-mods

Develop

claude plugin validate plugins/usage-band
claude plugin test plugins/usage-band
Source 2 files
hooks/register.tsx 301 lines
1import type {
2  EngineInterface,
3  Register,
4  SessionContextUsage,
5  SessionCost,
6  SessionRateLimit,
7  Timer,
8} from 'claude-code'
9
10import {
11  bar,
12  colorFor,
13  dots,
14  limitLabel,
15  meterSvg,
16  resetIn,
17  shimmerSvg,
18  short,
19  skeletonSvg,
20  sortLimits,
21  spinner,
22  sweep,
23} from './format'
24
25type Limit = { kind: string; percent: number; resetsAt?: number }
26
27type Reading = {
28  percent: number
29  tokens: number
30  window: number
31  usd?: number
32  limits: Limit[]
33  prompts?: number
34}
35
36// One loading frame every FRAME_MS; a fresh reading is tried every
37// RETRY_FRAMES frames, and the band gives up after GIVE_UP_FRAMES.
38const FRAME_MS = 100
39const RETRY_FRAMES = 3
40const GIVE_UP_FRAMES = 300
41// The reset countdowns move on once a minute.
42const CLOCK_MS = 60_000
43// Below this width (in cells) the band drops the prompt count.
44const PROMPTS_MIN_COLUMNS = 80
45// The rate-limit pills' recessed background on desktop.
46const PILL_BG = '#0000002e'
47
48let reading: Reading | null = null
49let now = 0
50let frame = 0
51let ticker: Timer | null = null
52let clockTicker: Timer | null = null
53let isRetrying = false
54// Only the terminal's placeholder needs a redraw per frame; the desktop's
55// animates inside its SVG and would restart if redrawn.
56let animatesTerminal = false
57
58function toLimit(limit: SessionRateLimit): Limit {
59  const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
60  return {
61    kind: limit.kind,
62    percent: Math.round(limit.percentUsed),
63    resetsAt: Number.isNaN(resetsAt) ? undefined : resetsAt,
64  }
65}
66
67// Folds the usage figures into the reading; false when they have no window yet.
68function applyUsage(
69  context: SessionContextUsage | undefined,
70  cost: SessionCost | undefined,
71  rateLimits: readonly SessionRateLimit[] | undefined,
72): boolean {
73  if (!context || !context.window) {
74    return false
75  }
76  const tokens = context.tokens ?? 0
77  const percent = Math.round(context.percent ?? (tokens / context.window) * 100)
78  reading = {
79    percent,
80    tokens,
81    window: context.window,
82    usd: cost?.usd,
83    limits: sortLimits(rateLimits ?? []).map(toLimit),
84    prompts: reading?.prompts,
85  }
86  return true
87}
88
89async function takeReading($: EngineInterface): Promise<void> {
90  try {
91    const [usage, prompts, at] = await Promise.all([
92      $.session.usage(),
93      $.session.turns().catch(() => undefined),
94      $.clock.now().catch(() => now),
95    ])
96    now = at
97    if (!applyUsage(usage.context, usage.cost, usage.rateLimits)) {
98      return
99    }
100    if (reading !== null && prompts !== undefined) {
101      reading.prompts = prompts
102    }
103    stopLoading()
104    $.ui.invalidate('ui.render')
105  } catch {
106    // Keep the last reading.
107  }
108}
109
110function stopLoading(): void {
111  ticker?.cancel()
112  ticker = null
113}
114
115function startLoading($: EngineInterface): void {
116  if (ticker !== null) {
117    return
118  }
119  frame = 0
120  ticker = $.clock.every(FRAME_MS, () => {
121    frame += 1
122    if (frame > GIVE_UP_FRAMES) {
123      stopLoading()
124      $.ui.invalidate('ui.render')
125      return
126    }
127    if (frame % RETRY_FRAMES === 0 && !isRetrying) {
128      isRetrying = true
129      void takeReading($).finally(() => {
130        isRetrying = false
131      })
132    }
133    if (animatesTerminal) {
134      $.ui.invalidate('ui.render')
135    }
136  })
137}
138
139function startClock($: EngineInterface): void {
140  if (clockTicker !== null) {
141    return
142  }
143  clockTicker = $.clock.every(CLOCK_MS, () => {
144    void $.clock
145      .now()
146      .then(at => {
147        now = at
148        if (reading?.limits.some(limit => limit.resetsAt !== undefined)) {
149          $.ui.invalidate('ui.render')
150        }
151      })
152      .catch(() => {})
153  })
154}
155
156export const register: Register = on => {
157  on('session.start', async ($, e, next) => {
158    reading = null
159    animatesTerminal = false
160    startLoading($)
161    startClock($)
162    // Read right away rather than after the session has finished starting;
163    // a reading that is not ready yet is retried by the loading ticker.
164    void takeReading($)
165    const result = await next(e)
166    await takeReading($)
167
168    return result
169  })
170
171  on('turn.complete', async ($, e, next) => {
172    const result = await next(e)
173    await takeReading($)
174
175    return result
176  })
177
178  // Pushed when a figure moves, a rate-limit window among them.
179  on('session.measure', async ($, e, next) => {
180    const result = await next(e)
181    if (applyUsage(e.context, e.cost, e.rateLimits)) {
182      stopLoading()
183      $.ui.invalidate('ui.render')
184    }
185
186    return result
187  })
188
189  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
190    if (e.props.hasSurvey) {
191      return next(e)
192    }
193
194    const { Box, Text } = $.ui.resolve(e)
195    const current = reading
196
197    if (current === null) {
198      if (ticker === null) {
199        return next(e)
200      }
201      let spend
202      let figures
203      let meter
204      if (e.surface === 'desktop') {
205        const { Svg } = $.ui.resolve(e)
206        spend = <Svg source={skeletonSvg(36, 8)} alt="Loading cost" width={36} height={8} isInteractive />
207        figures = <Svg source={skeletonSvg(64, 8)} alt="Loading context" width={64} height={8} isInteractive />
208        meter = <Svg source={shimmerSvg()} alt="Loading context meter" width={72} height={6} isInteractive />
209      } else {
210        animatesTerminal = true
211        const { before, glow, after } = sweep(frame)
212        spend = <Text dimColor>{spinner(frame)}</Text>
213        figures = <Text dimColor>{dots(frame)}</Text>
214        meter = (
215          <Box key="meter" flexDirection="row">
216            <Text color="inactive">{before}</Text>
217            <Text color="subtle">{glow}</Text>
218            <Text color="inactive">{after}</Text>
219          </Box>
220        )
221      }
222
223      return (
224        <Box key="loading" flexDirection="row" justifyContent="space-between" alignItems="center" width="100%" paddingX={1}>
225          <Box key="spend" flexDirection="row">
226            {spend}
227          </Box>
228          <Box key="gauge" flexDirection="row" alignItems="center" gap={1}>
229            {figures}
230            {meter}
231          </Box>
232        </Box>
233      )
234    }
235
236    const columns = e.props.bodyColumns ?? 80
237    const showsPrompts = columns >= PROMPTS_MIN_COLUMNS && current.prompts !== undefined
238    const severity = colorFor(current.percent)
239
240    // A meter at any size: an SVG pill on desktop, a line of cells in the terminal.
241    const meterOf = (key: string, percent: number, alt: string, width: number, height: number, cells: number) => {
242      if (e.surface === 'desktop') {
243        const { Svg } = $.ui.resolve(e)
244        return <Svg source={meterSvg(percent, width, height)} alt={alt} width={width} height={height} />
245      }
246      const { fill, track } = bar(percent, cells)
247      return (
248        <Box key={key} flexDirection="row">
249          <Text color={colorFor(percent)}>{fill}</Text>
250          <Text color="inactive">{track}</Text>
251        </Box>
252      )
253    }
254
255    const { usd, prompts, limits } = current
256
257    // The rate limits sit in recessed pills, a translucent black that darkens
258    // any theme (desktop only; the terminal draws them on the band). Cost,
259    // prompts and the context gauge stay plain text.
260    const pillProps = e.surface === 'desktop' ? { backgroundColor: PILL_BG } : {}
261
262    return (
263      <Box flexDirection="row" justifyContent="space-between" alignItems="center" width="100%" paddingX={1}>
264        <Box key="left" flexDirection="row" alignItems="center" gap={2}>
265          {usd !== undefined ? (
266            <Box key="spend" flexDirection="row">
267              <Text>{`$${usd.toFixed(2)}`}</Text>
268            </Box>
269          ) : null}
270          {showsPrompts ? (
271            <Box key="prompts" flexDirection="row">
272              <Text dimColor>{`${prompts} ${prompts === 1 ? 'prompt' : 'prompts'}`}</Text>
273            </Box>
274          ) : null}
275          {limits.length > 0 ? (
276            <Box key="limits" flexDirection="row" alignItems="center" gap={1}>
277              {limits.map(limit => (
278                <Box key={`limit-${limit.kind}`} flexDirection="row" gap={1} paddingX={1} {...pillProps}>
279                  <Text dimColor>{limitLabel(limit.kind)}</Text>
280                  <Text color={colorFor(limit.percent)}>{`${limit.percent}%`}</Text>
281                  {limit.resetsAt !== undefined ? (
282                    <Text dimColor>{`↻ ${resetIn(limit.resetsAt - now)}`}</Text>
283                  ) : null}
284                </Box>
285              ))}
286            </Box>
287          ) : null}
288        </Box>
289        <Box key="gauge" flexDirection="row" alignItems="center" gap={1}>
290          <Box key="context" flexDirection="row">
291            <Text>{short(current.tokens)}</Text>
292            <Text dimColor>{` / ${short(current.window)}`}</Text>
293          </Box>
294          <Text bold color={severity}>{`${current.percent}%`}</Text>
295          {meterOf('meter', current.percent, `Context ${current.percent}% full`, 72, 6, 10)}
296        </Box>
297      </Box>
298    )
299  })
300}
301
hooks/format.ts 159 lines
1export const BAR_CELLS = 10
2
3export type Severity = 'success' | 'warning' | 'claude' | 'error'
4
5// The theme's own hues, for the SVG meter, which cannot read theme keys.
6const SEVERITY_HEX: Record<Severity, string> = {
7  success: '#4eba65',
8  warning: '#e8b339',
9  claude: '#d77757',
10  error: '#ef5a6f',
11}
12
13function oneDecimal(n: number): string {
14  return n.toFixed(1).replace(/\.0$/, '')
15}
16
17function clampPercent(percent: number): number {
18  return Math.max(0, Math.min(100, percent))
19}
20
21export function short(n: number): string {
22  if (n >= 1_000_000) {
23    return `${oneDecimal(n / 1_000_000)}M`
24  }
25  if (n >= 1_000) {
26    return `${oneDecimal(n / 1_000)}k`
27  }
28  return String(n)
29}
30
31export function colorFor(percent: number): Severity {
32  if (percent >= 90) {
33    return 'error'
34  }
35  if (percent >= 75) {
36    return 'claude'
37  }
38  if (percent >= 50) {
39    return 'warning'
40  }
41  return 'success'
42}
43
44// The terminal meter: a heavy line for the fill over a light line for the track,
45// at least one cell filled once anything is used, as the desktop meter's dot.
46export function bar(percent: number, cells = BAR_CELLS): { fill: string; track: string } {
47  const p = clampPercent(percent)
48  const filled = p === 0 ? 0 : Math.max(1, Math.round((p / 100) * cells))
49  return { fill: '━'.repeat(filled), track: '─'.repeat(cells - filled) }
50}
51
52const LIMIT_LABELS: Record<string, string> = {
53  five_hour: '5h',
54  seven_day: 'week',
55  spend_limit: 'spend',
56}
57const LIMIT_ORDER = ['five_hour', 'seven_day', 'spend_limit']
58
59// A rate-limit window's short name: `five_hour` is "5h", an unknown kind is spelled out.
60export function limitLabel(kind: string): string {
61  return LIMIT_LABELS[kind] ?? kind.replace(/_/g, ' ')
62}
63
64// The 5-hour window first, then the weekly one, then any other.
65export function sortLimits<T extends { kind: string }>(limits: readonly T[]): T[] {
66  const rank = (kind: string) => {
67    const at = LIMIT_ORDER.indexOf(kind)
68    return at === -1 ? LIMIT_ORDER.length : at
69  }
70  return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
71}
72
73// How long until a window resets: "37m", "4h 27m", "2d 5h"; "now" once it is due.
74export function resetIn(ms: number): string {
75  if (!(ms > 0)) {
76    return 'now'
77  }
78  const minutes = Math.ceil(ms / 60_000)
79  if (minutes < 60) {
80    return `${minutes}m`
81  }
82  const hours = Math.floor(minutes / 60)
83  if (hours < 24) {
84    return `${hours}h ${minutes % 60}m`
85  }
86  return `${Math.floor(hours / 24)}d ${hours % 24}h`
87}
88
89const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
90const DOTS = ['·  ', '·· ', '···', ' ··', '  ·', '   ']
91export const GLOW_CELLS = 3
92
93export function spinner(frame: number): string {
94  return SPINNER[frame % SPINNER.length] ?? ''
95}
96
97export function dots(frame: number): string {
98  return DOTS[Math.floor(frame / 2) % DOTS.length] ?? ''
99}
100
101// The terminal loading meter: a short heavy segment sweeping back and forth
102// over the light track.
103export function sweep(frame: number): { before: string; glow: string; after: string } {
104  const span = BAR_CELLS - GLOW_CELLS
105  const step = frame % (2 * span)
106  const at = step <= span ? step : 2 * span - step
107  return {
108    before: '─'.repeat(at),
109    glow: '━'.repeat(GLOW_CELLS),
110    after: '─'.repeat(span - at),
111  }
112}
113
114// A placeholder for a figure still loading: a grey pill that pulses.
115export function skeletonSvg(width: number, height: number): string {
116  const r = height / 2
117  return (
118    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
119    `<rect width="${width}" height="${height}" rx="${r}" fill="#808080" fill-opacity="0.2">` +
120    `<animate attributeName="fill-opacity" values="0.12;0.34;0.12" dur="1.4s" repeatCount="indefinite"/>` +
121    `</rect></svg>`
122  )
123}
124
125// The desktop meter while loading: the empty track with a highlight gliding across.
126export function shimmerSvg(width = 72, height = 6): string {
127  const r = height / 2
128  const glow = Math.round(width / 3)
129  return (
130    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
131    `<defs><clipPath id="t"><rect width="${width}" height="${height}" rx="${r}"/></clipPath>` +
132    `<linearGradient id="g"><stop offset="0" stop-color="#b0b0b0" stop-opacity="0"/>` +
133    `<stop offset="0.5" stop-color="#b0b0b0" stop-opacity="0.75"/>` +
134    `<stop offset="1" stop-color="#b0b0b0" stop-opacity="0"/></linearGradient></defs>` +
135    `<rect width="${width}" height="${height}" rx="${r}" fill="#80808047"/>` +
136    `<g clip-path="url(#t)"><rect x="${-glow}" width="${glow}" height="${height}" fill="url(#g)">` +
137    `<animate attributeName="x" from="${-glow}" to="${width}" dur="1.2s" repeatCount="indefinite"/>` +
138    `</rect></g></svg>`
139  )
140}
141
142// The desktop meter: a rounded pill, its fill never thinner than its height
143// once anything is used, so 1% still shows as a dot.
144export function meterSvg(percent: number, width = 72, height = 6): string {
145  const p = clampPercent(percent)
146  const r = height / 2
147  const fillWidth = p === 0 ? 0 : Math.max(height, Math.round((p / 100) * width))
148  const fill =
149    fillWidth > 0
150      ? `<rect width="${fillWidth}" height="${height}" rx="${r}" fill="${SEVERITY_HEX[colorFor(p)]}"/>`
151      : ''
152  return (
153    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
154    `<rect width="${width}" height="${height}" rx="${r}" fill="#80808047"/>` +
155    fill +
156    `</svg>`
157  )
158}
159