SLOPSHOPPER

usage-band

Context window and subscription-limit usage (5-hour, weekly) above the prompt

newbandnetworktimer
★ 1v0.3.0MITupdated 2026-10-09Chappo29/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 ✻ context · used 49% ▰▰▰▰▰▱▱▱▱▱ 97k of 200k 5 hours · used 31% ▰▰▰▱▱▱▱▱▱▱ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ context · used 49% ▰▰▰▰▰▱▱▱▱▱ 97k of 200k 5 hours · used 31% ▰▰▰▱▱▱▱▱▱▱
README

usage-band

A Claude Code mod that shows, right above the prompt, how much of the context window and of your subscription limits you have used.

<img width="796" height="66" alt="usage-band above the prompt: context 23% used, 5 hours 6%, week 52%" src="assets/usage-band.png" />

  • Context: the share of the model's window the conversation takes, with token counts.
  • 5 hours / week: subscription limits as Claude's usage page shows them (rounded up), with a countdown to the reset.
  • A figure turns orange at 70% and flips to an orange pill at 90%.
  • In the desktop app the band is an SVG with its own typography; in the terminal it is drawn in cells, in your theme's colors.

Limits are refreshed once a minute from api.anthropic.com/api/oauth/usage with the session's own credential (Claude Code attaches it; the mod never sees the token), so usage from other windows and claude.ai shows up too. Without a subscription login only the context is shown.

Requirements

A Claude Code build with mods (function hooks), which are in early access: verified on 2.1.289 and 2.1.293. Older builds without the mods API (2.1.207, for one) do not load it: if nothing shows up, check claude --version. The limits need a Claude subscription login; with an API key only the context is shown.

Install

In a Claude Code terminal:

/plugin install usage-band --marketplace Chappo29/usage-band

Answer y to add the marketplace and pick the user scope: the mod then loads in every session, including the desktop app's Code tab.

Or from a clone, without a marketplace: put the folder's path in env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, or run claude --plugin-dir <folder>.

Language

English by default, Russian available. Switch it in the config menu (usage-band → Language), or in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "usage-band@usage-band": { "options": { "language": "ru" } }
  }
}

The key is the plugin's id: usage-band@usage-band when installed from this marketplace, usage-band@inline when loaded from a folder.

Development

claude plugin validate .
claude plugin test .

По-русски

Мод для Claude Code: над полем ввода — сколько израсходовано контекста и лимитов подписки (5 часов и неделя) с отсчётом до сброса. Лимиты обновляются раз в минуту с учётными данными сессии, поэтому видна и трата в других окнах и на claude.ai.

Нужна сборка Claude Code с модами (проверено на 2.1.289 и 2.1.293), на старых мод не загрузится.

Установка: /plugin install usage-band --marketplace Chappo29/usage-band в терминале Claude Code. Русский язык включается в меню настроек (usage-band → Language → ru) или через pluginConfigs в ~/.claude/settings.json, как показано выше.

Source 5 files
hooks/register.tsx 146 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { svg } from './draw'
6import { describe, lang, parts } from './format'
7import type { Part } from './format'
8import { parseUsage, USAGE_URL } from './poll'
9import type { PollOutcome } from './poll'
10
11const snap = atom({ plugin: 'usage-band', key: 'snap' } as const, null)
12const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
13
14const BAR = 10
15const POLL_MS = 60_000
16
17function toSnapshot(u: Pick<SessionUsage, 'context' | 'rateLimits'>, prev: Snapshot | null): Snapshot {
18  const limits = u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
19  return {
20    ctxPercent: u.context.percent,
21    ctxTokens: u.context.tokens,
22    ctxWindow: u.context.window,
23    // a measurement with no reading keeps what the poll brought
24    limits: limits.length > 0 ? limits : (prev?.limits ?? []),
25  }
26}
27
28async function tick($: EngineInterface) {
29  const t = await $.clock.now()
30  await update($, now, () => t)
31}
32
33async function refresh($: EngineInterface, u: Pick<SessionUsage, 'context' | 'rateLimits'>) {
34  await update($, snap, prev => toSnapshot(u, prev))
35  await tick($)
36}
37
38async function fetchLimits($: EngineInterface): Promise<PollOutcome> {
39  const auth = await $.session.authorize()
40  if (auth === null) return { skip: 'no-login' }
41  try {
42    const r = await $.http.fetch(USAGE_URL, {
43      auth: auth.handle,
44      headers: { 'anthropic-beta': 'oauth-2025-04-20' },
45    })
46    if (r.status === 429) return { skip: 'throttled' }
47    const limits = r.ok ? parseUsage(r.text) : null
48    return limits ? { limits } : { skip: 'failed' }
49  } catch {
50    return { skip: 'failed' }
51  }
52}
53
54async function poll($: EngineInterface): Promise<PollOutcome> {
55  const outcome = await fetchLimits($)
56  if ('limits' in outcome) {
57    const { limits } = outcome
58    await update($, snap, prev => prev && { ...prev, limits })
59    await tick($)
60  }
61  return outcome
62}
63
64function bar(fill: number | undefined): [string, string] {
65  const lit = fill === undefined ? 0 : Math.round((fill / 100) * BAR)
66  return ['▰'.repeat(lit), '▱'.repeat(BAR - lit)]
67}
68
69export const register: Register = (on, options) => {
70  const l = lang(options.language)
71
72  on('session.start', async ($, e, next) => {
73    const result = await next(e)
74    await refresh($, await $.session.usage())
75    void poll($)
76    // every minute: the account's limits from /usage, and the reset countdowns
77    let pause = 0
78    $.clock.every(POLL_MS, async () => {
79      if (pause > 0) {
80        pause -= 1
81        return tick($)
82      }
83      const outcome = await poll($)
84      if ('skip' in outcome) {
85        // throttled: wait five minutes; no Claude login: nothing to ask for
86        pause = outcome.skip === 'throttled' ? 4 : outcome.skip === 'no-login' ? Infinity : 0
87        await tick($)
88      }
89    })
90    return result
91  })
92
93  on('session.measure', async ($, e, next) => {
94    await refresh($, e)
95    return next(e)
96  })
97
98  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99    const s = await read($, snap)
100    if (e.props.hasSurvey || s === null) {
101      return next(e)
102    }
103
104    const ps = parts(s, (await read($, now)) || (await $.clock.now()), l)
105
106    // desktop draws an SVG: its own type and the gauge as ticks
107    if (e.surface !== 'terminal') {
108      const { Box, Svg } = $.ui.resolve(e)
109      // no width: the drawing keeps its own size and shrinks to a narrower slot
110      return (
111        <Box paddingX={1} justifyContent="center">
112          <Svg key="usage" source={svg(ps)} alt={describe(ps, l)} />
113        </Box>
114      )
115    }
116
117    // terminal: the same hierarchy in cells — dim label, bold figure, orange gauge
118    const { Box, Text } = $.ui.resolve(e)
119    const figure = (p: Part) => {
120      const n = p.value === undefined ? '—' : `${p.value}%`
121      if (p.value === undefined || p.level === 'ok') return <Text bold>{n}</Text>
122      if (p.level === 'warn') return <Text bold color="claude">{n}</Text>
123      return <Text bold inverse color="claude">{` ${n} `}</Text>
124    }
125
126    return (
127      <Box flexWrap="wrap" columnGap={3} justifyContent="center">
128        <Text color="claude">✻</Text>
129        {ps.map(p => {
130          const [lit, unlit] = bar(p.value)
131          return (
132            <Text key={p.key}>
133              <Text dimColor>{p.label} </Text>
134              {figure(p)}
135              <Text> </Text>
136              <Text color="claude">{lit}</Text>
137              <Text color="subtle">{unlit}</Text>
138              {p.caption ? <Text dimColor italic>{` ${p.caption}`}</Text> : null}
139            </Text>
140          )
141        })}
142      </Box>
143    )
144  })
145}
146
hooks/draw.ts 72 lines
1import type { Part } from './format'
2
3// Claude's palette: orange fills the gauges with what is used, ink on the figures; a
4// figure turns orange as the reserve runs low and inverts when it is nearly gone.
5const SERIF = `'Copernicus','Tiempos Headline','Iowan Old Style','Palatino Linotype',Georgia,serif`
6const SANS = `'Styrene B','Segoe UI Variable Text','Segoe UI',system-ui,sans-serif`
7const MONO = `'JetBrains Mono','Cascadia Code',Consolas,ui-monospace,monospace`
8
9const TICKS = 20
10const CELL = 222
11const LEAD = 36
12const H = 48
13const SCALE = 1 / 1.5
14
15const esc = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
16
17// Claude's spark: a hand-cut burst of tapered rays, uneven on purpose
18const RAYS = [12, 8.5, 10.5, 7.5, 11.5, 9, 12, 8, 10, 7.5, 11, 9.5]
19function spark(cx: number, cy: number): string {
20  const rays = RAYS.map((len, i) =>
21    `<path d="M0 -1.5 L${len} -0.3 L${len} 0.3 L0 1.5 Z" transform="rotate(${i * 30 - 84})"/>`,
22  ).join('')
23  return `<g class="o" transform="translate(${cx} ${cy})">${rays}<circle r="2.4"/></g>`
24}
25
26function gauge(x: number, y: number, fill: number | undefined): string {
27  const lit = fill === undefined ? 0 : Math.round((fill / 100) * TICKS)
28  return Array.from({ length: TICKS }, (_, i) =>
29    `<rect class="${i < lit ? 'o' : 't'}" x="${(x + i * 6.6).toFixed(1)}" y="${y}" width="4" height="10" rx="1"/>`,
30  ).join('')
31}
32
33function cell(p: Part, x: number): string {
34  const n = p.value === undefined ? '—' : String(p.value)
35  const tone = p.value === undefined || p.level === 'ok' ? 'ink' : p.level === 'warn' ? 'o' : 'inv'
36  const pill = tone === 'inv'
37    ? `<rect class="o" x="${x - 5}" y="8" width="${n.length * 16 + 22}" height="33" rx="7"/>`
38    : ''
39  const col = x + 74
40  return `<g>${pill}
41<text class="num ${tone}" x="${x}" y="35">${n}${p.value === undefined ? '' : '<tspan class="pct" dx="1.5">%</tspan>'}</text>
42<text class="lbl mut" x="${col}" y="13">${esc(p.label.toUpperCase())}</text>
43${gauge(col, 19, p.value)}
44<text class="cap mut" x="${col}" y="43">${esc(p.caption)}</text></g>`
45}
46
47export function svg(ps: Part[]): string {
48  const width = LEAD + ps.length * CELL - 16
49  const cells = ps.map((p, i) => cell(p, LEAD + i * CELL)).join('')
50  const rules = ps.slice(1).map((_, i) => {
51    const x = LEAD + (i + 1) * CELL - 16
52    return `<line class="rule" x1="${x}" y1="8" x2="${x}" y2="42"/>`
53  }).join('')
54  // drawn on a roomy grid, shown at two thirds of it
55  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(width * SCALE)}" height="${Math.round(H * SCALE)}" viewBox="0 0 ${width} ${H}">
56<style>
57.o{fill:#C15F3C}.t{fill:#E6E2D6}.ink{fill:#1F1E1D}.mut{fill:#73716A}.inv{fill:#FAF9F5}
58.rule{stroke:#1F1E1D;stroke-opacity:.12}
59.num{font-family:${SERIF};font-size:32px;font-style:italic;font-weight:500;letter-spacing:-.03em}
60.pct{font-size:14px;font-style:normal;letter-spacing:0}
61.lbl{font-family:${SANS};font-size:11px;font-weight:600;letter-spacing:.16em}
62.cap{font-family:${MONO};font-size:12px}
63@media (prefers-color-scheme: dark){
64.o{fill:#E2805C}.t{fill:#3A3935}.ink{fill:#F5F4EE}.mut{fill:#A09E96}.inv{fill:#1A1918}
65.rule{stroke:#F5F4EE;stroke-opacity:.14}
66}
67</style>
68${spark(14, 24)}${rules}${cells}
69</svg>`
70  return source
71}
72
hooks/format.ts 112 lines
1import type { Limit, Snapshot } from '../types'
2
3export type Lang = 'en' | 'ru'
4export type Level = 'ok' | 'warn' | 'low'
5export type Part = {
6  key: string
7  // what is measured ("context"); `label` adds that the figure is the used share
8  name: string
9  label: string
10  // the figure shown and the gauge's fill, 0..100: what is used, of the
11  // context and of each limit (as Claude's usage page shows it); absent
12  // before the first response
13  value?: number
14  caption: string
15  level: Level
16}
17
18const WORDS = {
19  en: {
20    context: 'context',
21    five_hour: '5 hours',
22    seven_day: 'week',
23    spend_limit: 'budget',
24    usedTag: 'used',
25    used: 'used',
26    of: 'of',
27    window: 'window',
28    resetIn: 'resets in',
29    resetNow: 'resets now',
30    d: 'd',
31    h: 'h',
32    m: 'm',
33  },
34  ru: {
35    context: 'контекст',
36    five_hour: '5 часов',
37    seven_day: 'неделя',
38    spend_limit: 'бюджет',
39    usedTag: 'исп.',
40    used: 'использовано',
41    of: 'из',
42    window: 'окно',
43    resetIn: 'сброс через',
44    resetNow: 'сброс сейчас',
45    d: 'д',
46    h: 'ч',
47    m: 'м',
48  },
49} as const
50
51export function lang(value: unknown): Lang {
52  return value === 'ru' ? 'ru' : 'en'
53}
54
55export function level(left: number): Level {
56  if (left <= 10) return 'low'
57  if (left <= 30) return 'warn'
58  return 'ok'
59}
60
61export function tokens(n: number): string {
62  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
63  if (n >= 1000) return `${Math.round(n / 1000)}k`
64  return String(n)
65}
66
67export function countdown(resetsAt: string | undefined, now: number, l: Lang = 'en'): string {
68  if (!resetsAt) return ''
69  const ms = Date.parse(resetsAt) - now
70  if (!Number.isFinite(ms)) return ''
71  const w = WORDS[l]
72  if (ms <= 0) return w.resetNow
73  const min = Math.ceil(ms / 60_000)
74  const d = Math.floor(min / 1440)
75  const h = Math.floor((min % 1440) / 60)
76  const m = min % 60
77  const span = d > 0 ? `${d}${w.d} ${h}${w.h}` : h > 0 ? `${h}${w.h} ${m}${w.m}` : `${m}${w.m}`
78  return `${w.resetIn} ${span}`
79}
80
81function named(key: string, name: string, l: Lang) {
82  return { key, name, label: `${name} · ${WORDS[l].usedTag}` }
83}
84
85export function contextPart(s: Snapshot, l: Lang = 'en'): Part {
86  const w = WORDS[l]
87  const window = `${w.window} ${tokens(s.ctxWindow)}`
88  if (s.ctxPercent === undefined) {
89    return { ...named('context', w.context, l), caption: window, level: 'ok' }
90  }
91  const used = Math.min(100, Math.max(0, s.ctxPercent))
92  const caption = s.ctxTokens === undefined ? window : `${tokens(s.ctxTokens)} ${w.of} ${tokens(s.ctxWindow)}`
93  return { ...named('context', w.context, l), value: used, caption, level: level(100 - used) }
94}
95
96export function limitPart(lim: Limit, now: number, l: Lang = 'en'): Part {
97  const w = WORDS[l]
98  const name = lim.kind === 'five_hour' || lim.kind === 'seven_day' || lim.kind === 'spend_limit' ? w[lim.kind] : lim.kind
99  // rounded up, as Claude's usage page rounds it: 51.4 used reads 52%
100  const used = Math.min(100, Math.ceil(lim.percentUsed))
101  return { ...named(lim.kind, name, l), value: used, caption: countdown(lim.resetsAt, now, l), level: level(100 - used) }
102}
103
104export function parts(s: Snapshot, now: number, l: Lang = 'en'): Part[] {
105  return [contextPart(s, l), ...s.limits.map(lim => limitPart(lim, now, l))]
106}
107
108export function describe(ps: Part[], l: Lang = 'en'): string {
109  const used = WORDS[l].used
110  return ps.map(p => `${p.name}: ${used} ${p.value ?? '—'}%${p.caption ? ` (${p.caption})` : ''}`).join('; ')
111}
112
hooks/poll.ts 30 lines
1import type { Limit } from '../types'
2
3// The account's limits as /usage and Claude's settings page read them. The
4// response headers only move when this session calls the model, so spending
5// elsewhere (another window, claude.ai) shows up here only through this.
6export const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
7
8const WINDOWS = ['five_hour', 'seven_day'] as const
9
10type Window = { utilization?: number | null; resets_at?: string | null } | null
11
12export function parseUsage(text: string): Limit[] | null {
13  let body: Record<string, Window>
14  try {
15    body = JSON.parse(text)
16  } catch {
17    return null
18  }
19  const limits: Limit[] = []
20  for (const kind of WINDOWS) {
21    const w = body[kind]
22    if (w && typeof w.utilization === 'number') {
23      limits.push({ kind, percentUsed: w.utilization, ...(w.resets_at ? { resetsAt: w.resets_at } : {}) })
24    }
25  }
26  return limits.length > 0 ? limits : null
27}
28
29export type PollOutcome = { limits: Limit[] } | { skip: 'no-login' | 'failed' | 'throttled' }
30
types/index.d.ts 14 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Snapshot = {
3  ctxPercent?: number
4  ctxTokens?: number
5  ctxWindow: number
6  limits: Limit[]
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'usage-band': { snap: Snapshot | null; now: number }
12  }
13}
14