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

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" />
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.
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.
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>.
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.
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, как показано выше.
hooks/register.tsx 146 lines1import { 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}
146hooks/draw.ts 72 lines1import 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}
72hooks/format.ts 112 lines1import 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}
112hooks/poll.ts 30 lines1import 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' }
30types/index.d.ts 14 lines1export 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