Band above the prompt: context, plan limits with pace and ETA, or API spend vs budget, plus token breakdown and warnings.

Claude Code mods by ReesMehorst.
/plugin marketplace add ReesMehorst/claude-mods
/plugin install usage-band@reesmehorst
/plugin install github-pane@reesmehorst
Answer y to add the marketplace, then pick a scope.
A band above the prompt that shows where your session stands.
pace → 120%) and when you'll hit 100% at that pace (hits 100% ~16:40). Session cost is shown muted as "API value".$/hr. Set a budget under /config → API budget (USD) for a budget bar and ETA./usage-band toggles a compact one-line view (remembered across sessions)./github opens a live pane for the repo you're in (needs the gh CLI, signed in).
1–5, r refresh. In the desktop app the tabs are one segmented row with a count pill each (red when the tab has failing items); in a narrow pane they become a full-width View dropdown and rows drop the branch.c checkout a PR, x rerun failed jobs, l show the failed-step log inline, e mark a notification read.f Fix with Claude puts the failed log and a fix request in the prompt box as a draft. Nothing is sent until you press Enter.d details (diff stat, mergeable state, reviewers, description), a approve (not on your own PRs), y mark a draft ready, m squash-merge and delete the branch. Merge asks for a second m press; changing the selection or Close cancels it. In the desktop app the first press shows a confirm card with Merge and Cancel instead.#n, branch or author; switching tabs clears it. An empty tab says why it is empty.gh signed out pauses polling until r.claude plugin validate ./usage-band
claude plugin test ./usage-bandhooks/register.tsx 164 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { addUsage, bar, barSvg, compactLine, emptyTokens, emptyTrend, mode, recordTurn, segments, warnings } from './summarize'
6
7const snap = atom({ plugin: 'usage-band', key: 'snap' } as const, null)
8const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, emptyTokens)
9const trend = atom({ plugin: 'usage-band', key: 'trend' } as const, emptyTrend)
10const warned = atom({ plugin: 'usage-band', key: 'warned' } as const, [] as string[])
11const compact = atom({ plugin: 'usage-band', key: 'compact' } as const, false)
12
13const build = (u: SessionUsage): Snapshot => ({
14 contextPercent: u.context.percent ?? null,
15 contextTokens: u.context.tokens ?? null,
16 contextWindow: u.context.window,
17 costUsd: u.cost?.usd ?? null,
18 startedAt: u.startedAt,
19 rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
20})
21
22async function toggle($: EngineInterface) {
23 const isCompact = !(await read($, compact))
24 await $.store.set('compact', isCompact)
25 await update($, compact, () => isCompact)
26 return isCompact
27}
28
29export const register: Register = (on, options) => {
30 const budget = Number(options.budgetUsd ?? 0)
31 const turnWarn = Number(options.turnWarnUsd ?? 0)
32
33 on('session.start', async ($, e, next) => {
34 const r = await next(e)
35 await $.command.register({ name: 'usage-band', description: 'Toggle the usage band between full and compact' })
36 const isCompact = (await $.store.get('compact')) === true
37 await update($, compact, () => isCompact)
38 const s = build(await $.session.usage())
39 await update($, snap, () => s)
40 return r
41 })
42
43 on('session.measure', async ($, e, next) => {
44 const s = build(await $.session.usage())
45 await update($, snap, () => s)
46 const seen = await read($, warned)
47 const fresh = warnings(s, await $.clock.now(), budget, await read($, trend)).filter(w => !seen.includes(w.key))
48 for (const w of fresh) $.ui.toast(`⚠ ${w.text}`, { timeoutMs: 8000 })
49 if (fresh.length) await update($, warned, ks => [...ks, ...fresh.map(w => w.key)])
50 return next(e)
51 })
52
53 on('turn.complete', async ($, e, next) => {
54 const r = await next(e)
55 const u = e.usage
56 if (u) await update($, tokens, t => addUsage(t, u))
57 if (e.agentId) return r // ponytail: subagent turns would skew context growth
58 const s = build(await $.session.usage())
59 const t = await update($, trend, t => recordTurn(t, s.costUsd, s.contextTokens))
60 if (mode(s) === 'api' && turnWarn > 0 && (t.lastTurnUsd ?? 0) >= turnWarn)
61 $.ui.toast(`⚠ That turn cost $${t.lastTurnUsd!.toFixed(2)}`, { timeoutMs: 8000 })
62 return r
63 })
64
65 on('command.run', { command: 'usage-band' }, async $ => ({ text: `Usage band: ${(await toggle($)) ? 'compact' : 'full'}` }))
66
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 const s = await read($, snap)
69 if (s === null || e.props.hasSurvey) return next(e)
70 const m = mode(s)
71 const segs = segments(s, await read($, tokens), await $.clock.now(), budget, await read($, trend))
72
73 // Graphical surfaces (desktop, vscode, mobile): meters + buttons. Terminal keeps the text band below.
74 if (e.surface !== 'terminal') {
75 const { Box, Text, Button, Svg } = $.ui.resolve(e)
76 const isCompact = await read($, compact)
77 const worst = segs.find(x => x.level === 'error') ?? segs.find(x => x.level === 'warning')
78 const toggleBtn = (
79 <Button key="toggle" variant="secondary" onPress={() => toggle($)}>{isCompact ? 'Show' : 'Hide'}</Button>
80 )
81 const head = (
82 <Box flexDirection="row" alignItems="center" columnGap="sm">
83 <Text bold color={m === 'plan' ? 'claude' : 'suggestion'}>{m === 'plan' ? 'Plan usage' : 'API usage'}</Text>
84 {worst ? <Text color={worst.level}>●</Text> : null}
85 <Box flexGrow={1} />
86 {/* void: command.run is queued until the session is idle */}
87 <Button key="clear" variant="secondary" onPress={() => void $.command.run({ command: 'clear' })}>/clear</Button>
88 <Button key="compact" variant="secondary" onPress={() => void $.command.run({ command: 'compact' })}>/compact</Button>
89 <Button key="refresh" variant="secondary" onPress={async () => { const fresh = build(await $.session.usage()); await update($, snap, () => fresh) }}>Refresh</Button>
90 {toggleBtn}
91 </Box>
92 )
93 if (isCompact) {
94 return (
95 <Box flexDirection="row" alignItems="center" columnGap="sm" paddingX={1}>
96 <Text bold color={m === 'plan' ? 'claude' : 'suggestion'}>{m.toUpperCase()}</Text>
97 <Text dimColor wrap="truncate-end">{compactLine(m, segs).slice(m.length + 3)}</Text>
98 <Box flexGrow={1} />
99 {toggleBtn}
100 </Box>
101 )
102 }
103 return (
104 <Box flexDirection="column" rowGap="sm" paddingX={1} paddingY={1} borderStyle="round" borderColor="subtle">
105 {head}
106 <Box flexDirection="row" flexWrap="wrap" columnGap="lg" rowGap="md">
107 {segs.map(seg => {
108 const tint = seg.level === 'success' ? undefined : seg.level
109 return (
110 <Box key={seg.label} flexDirection="column" minWidth={20} rowGap="xs">
111 <Text dimColor size="caption">{seg.label.toUpperCase()}</Text>
112 <Text size="heading" weight="bold" tabular dimColor={seg.isMuted} color={tint}>{seg.value}</Text>
113 {seg.percent != null
114 ? <Svg source={barSvg(seg.percent, seg.level)} alt={`${seg.label} ${seg.value}`} width={120} height={6} />
115 : null}
116 <Text dimColor size="caption" color={tint}>{seg.detail || ' '}</Text>
117 </Box>
118 )
119 })}
120 </Box>
121 </Box>
122 )
123 }
124
125 const { Box, Text } = $.ui.resolve(e)
126 const badge = <Text inverse bold color={m === 'plan' ? 'claude' : 'suggestion'}>{` ${m.toUpperCase()} `}</Text>
127
128 if (await read($, compact)) {
129 const worst = segs.find(x => x.level === 'error') ?? segs.find(x => x.level === 'warning')
130 return (
131 <Box flexDirection="row" columnGap={1} paddingX={1}>
132 {badge}
133 <Text dimColor wrap="truncate-end">{compactLine(m, segs).slice(m.length + 3)}</Text>
134 {worst ? <Text color={worst.level}>●</Text> : null}
135 </Box>
136 )
137 }
138
139 return (
140 <Box flexDirection="row" flexWrap="wrap" columnGap={3} rowGap={1} alignItems="flex-start"
141 borderStyle="round" borderColor="subtle" paddingX={1}>
142 <Box flexDirection="column">
143 {badge}
144 <Text dimColor size="caption">/usage-band</Text>
145 </Box>
146 {segs.map(seg => {
147 const tint = seg.level === 'success' ? undefined : seg.level
148 const [full, rest] = seg.percent != null ? bar(seg.percent) : ['', '']
149 return (
150 <Box key={seg.label} flexDirection="column" minWidth={14}>
151 <Text dimColor size="caption">{seg.label.toUpperCase()}</Text>
152 <Text>
153 <Text weight="bold" tabular dimColor={seg.isMuted} color={tint}>{seg.value}</Text>
154 {seg.percent != null ? <Text> <Text color={seg.level}>{full}</Text><Text dimColor>{rest}</Text></Text> : null}
155 </Text>
156 <Text dimColor size="caption" color={tint}>{seg.detail || ' '}</Text>
157 </Box>
158 )
159 })}
160 </Box>
161 )
162 })
163}
164hooks/summarize.ts 206 lines1import type { Snapshot, Tokens, Trend } from '../types'
2
3export type Mode = 'plan' | 'api' | 'usage'
4export type Level = 'success' | 'warning' | 'error'
5export type Segment = {
6 label: string
7 value: string
8 detail: string
9 short: string // compact-line form
10 percent: number | null
11 level: Level
12 isMuted?: boolean
13}
14
15const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600e3, seven_day: 7 * 86400e3 }
16const LABEL: Record<string, string> = { five_hour: '5h limit', seven_day: 'Weekly', spend_limit: 'Spend limit' }
17const SHORT: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'limit' }
18
19export const emptyTokens: Tokens = { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 }
20export const emptyTrend: Trend = { lastCost: null, lastTurnUsd: null, ctxHistory: [] }
21
22export const num = (n: number) =>
23 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
24
25// Local wall-clock HH:MM; adds the weekday when it is not today.
26export const clock = (ms: number, now: number) => {
27 const d = new Date(ms)
28 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
29 return new Date(now).toDateString() === d.toDateString() ? hm : `${d.toLocaleDateString('en-US', { weekday: 'short' })} ${hm}`
30}
31
32const level = (p: number, warn: number, err: number): Level => (p >= err ? 'error' : p >= warn ? 'warning' : 'success')
33
34// rateLimits is also empty on a plan until the first response reports them, so
35// no windows only means API once something has been billed.
36export const mode = (s: Snapshot): Mode => (s.rateLimits.length ? 'plan' : (s.costUsd ?? 0) > 0 ? 'api' : 'usage')
37
38export const bar = (percent: number, width = 8) => {
39 const full = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
40 return ['━'.repeat(full), '─'.repeat(width - full)] as const
41}
42
43const FILL: Record<Level, string> = { success: '#3fb950', warning: '#d29922', error: '#f85149' }
44
45// Rounded meter for graphical surfaces; translucent grey track reads on light and dark.
46export const barSvg = (percent: number, level: Level, width = 120, height = 6) => {
47 const w = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
48 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
49 `<rect width="${width}" height="${height}" rx="${height / 2}" fill="#8b949e" fill-opacity="0.25"/>` +
50 (w > 0 ? `<rect width="${Math.max(w, height)}" height="${height}" rx="${height / 2}" fill="${FILL[level]}"/>` : '') +
51 `</svg>`
52}
53
54const elapsedFraction = (resetsAt: string | undefined, kind: string, now: number) => {
55 const dur = WINDOW_MS[kind]
56 if (!dur || !resetsAt) return null
57 const f = (dur - (Date.parse(resetsAt) - now)) / dur
58 return f < 0.02 ? null : f // ponytail: too early to extrapolate, noise
59}
60
61// Projected % at reset if the current pace holds.
62export const projected = (percentUsed: number, resetsAt: string | undefined, kind: string, now: number) => {
63 const f = elapsedFraction(resetsAt, kind, now)
64 return f == null ? null : Math.round(percentUsed / f)
65}
66
67// When the window hits 100% at the current pace; null when it resets first.
68export const eta = (percentUsed: number, resetsAt: string | undefined, kind: string, now: number) => {
69 const f = elapsedFraction(resetsAt, kind, now)
70 if (f == null || percentUsed <= 0 || !resetsAt) return null
71 if (percentUsed >= 100) return now
72 const elapsedMs = f * WINDOW_MS[kind]!
73 const at = now + ((100 - percentUsed) / percentUsed) * elapsedMs
74 return at < Date.parse(resetsAt) ? at : null
75}
76
77export const cacheHit = (t: Tokens) => {
78 const input = t.in + t.cacheRead + t.cacheWrite
79 return input ? Math.round((t.cacheRead / input) * 100) : null
80}
81
82export const addUsage = (t: Tokens, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): Tokens => ({
83 in: t.in + u.input_tokens,
84 out: t.out + u.output_tokens,
85 cacheRead: t.cacheRead + u.cache_read_input_tokens,
86 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
87})
88
89// Records one finished turn: its cost and the context it left. A drop of over
90// 30% is a /compact or /clear, so growth before it no longer predicts anything.
91export const recordTurn = (t: Trend, costUsd: number | null, ctxTokens: number | null): Trend => {
92 const last = t.ctxHistory.at(-1)
93 const hist = ctxTokens == null ? t.ctxHistory : last != null && ctxTokens < last * 0.7 ? [ctxTokens] : [...t.ctxHistory, ctxTokens].slice(-5)
94 return {
95 lastCost: costUsd ?? t.lastCost,
96 lastTurnUsd: costUsd != null && t.lastCost != null ? costUsd - t.lastCost : null,
97 ctxHistory: hist,
98 }
99}
100
101// Turns until context reaches 80% at the recent average growth; null without a trend.
102export const turnsLeft = (hist: number[], window: number) => {
103 if (hist.length < 3) return null
104 const avg = (hist.at(-1)! - hist[0]!) / (hist.length - 1)
105 return avg > 0 ? Math.max(0, Math.ceil((0.8 * window - hist.at(-1)!) / avg)) : null
106}
107
108const hours = (s: Snapshot, now: number) => (now - s.startedAt) / 3600e3
109
110export const segments = (s: Snapshot, t: Tokens, now: number, budgetUsd = 0, trend = emptyTrend): Segment[] => {
111 const out: Segment[] = []
112 const m = mode(s)
113
114 const ctx = s.contextPercent
115 const left = turnsLeft(trend.ctxHistory, s.contextWindow)
116 out.push({
117 label: 'Context',
118 value: ctx == null ? '–' : `${ctx}%`,
119 detail: [s.contextTokens == null ? `${num(s.contextWindow)} window` : `${num(s.contextTokens)} of ${num(s.contextWindow)}`,
120 left ? `~${left} turn${left === 1 ? '' : 's'} to 80%` : ''].filter(Boolean).join(' · '),
121 short: `ctx ${ctx ?? '–'}%`,
122 percent: ctx,
123 level: level(ctx ?? 0, 60, 80),
124 })
125
126 for (const r of s.rateLimits) {
127 const p = projected(r.percentUsed, r.resetsAt, r.kind, now)
128 const hit = eta(r.percentUsed, r.resetsAt, r.kind, now)
129 const when = hit != null ? `hits 100% ~${clock(hit, now)}` : r.resetsAt ? `resets ${clock(Date.parse(r.resetsAt), now)}` : ''
130 out.push({
131 label: LABEL[r.kind] ?? r.kind,
132 value: `${r.percentUsed}%`,
133 detail: [p == null ? '' : `pace → ${p}%`, when].filter(Boolean).join(' · '),
134 short: `${SHORT[r.kind] ?? r.kind} ${r.percentUsed}%${p == null ? '' : `→${p}%`}`,
135 percent: r.percentUsed,
136 // Colour by where the pace lands, not just what's used now.
137 level: level(Math.max(r.percentUsed, p ?? 0), 80, 100),
138 })
139 }
140
141 if (s.costUsd != null) {
142 const h = hours(s, now)
143 const rate = h > 0.05 ? s.costUsd / h : null
144 const usd = `$${s.costUsd.toFixed(2)}`
145 const turn = trend.lastTurnUsd ? `last +$${trend.lastTurnUsd.toFixed(2)}` : ''
146 if (m === 'plan') {
147 out.push({ label: 'API value', value: usd, detail: ['included in plan', turn].filter(Boolean).join(' · '), short: usd, percent: null, level: 'success', isMuted: true })
148 } else if (budgetUsd > 0) {
149 const pct = Math.round((s.costUsd / budgetUsd) * 100)
150 const rest = budgetUsd - s.costUsd
151 const etaMs = rate && rest > 0 ? now + (rest / rate) * 3600e3 : null
152 out.push({
153 label: 'Spend',
154 value: usd,
155 detail: [`of $${budgetUsd}`, rate ? `$${rate.toFixed(2)}/hr` : '', etaMs ? `budget ~${clock(etaMs, now)}` : rest <= 0 ? 'over budget' : '', turn].filter(Boolean).join(' · '),
156 short: `${usd}/$${budgetUsd}`,
157 percent: pct,
158 level: level(pct, 75, 90),
159 })
160 } else {
161 out.push({ label: 'Spend', value: usd, detail: [rate ? `$${rate.toFixed(2)}/hr` : 'this session', turn].filter(Boolean).join(' · '), short: usd, percent: null, level: 'success' })
162 }
163 }
164
165 const total = t.in + t.out + t.cacheRead + t.cacheWrite
166 if (total > 0) {
167 const hit = cacheHit(t)
168 out.push({
169 label: 'Tokens',
170 value: num(total),
171 detail: [`in ${num(t.in + t.cacheWrite)}`, `out ${num(t.out)}`, hit == null ? '' : `cache ${hit}%`].filter(Boolean).join(' · '),
172 short: `${num(total)} tok`,
173 percent: null,
174 level: 'success',
175 isMuted: true,
176 })
177 }
178
179 return out
180}
181
182export const compactLine = (m: Mode, segs: Segment[]) => [m.toUpperCase(), ...segs.map(s => s.short)].join(' · ')
183
184export type Warning = { key: string; text: string }
185
186// Keys carry the window's reset so a fresh window can warn again.
187export const warnings = (s: Snapshot, now: number, budgetUsd = 0, trend = emptyTrend): Warning[] => {
188 const out: Warning[] = []
189 const left = turnsLeft(trend.ctxHistory, s.contextWindow)
190 if ((s.contextPercent ?? 0) >= 80) out.push({ key: 'ctx', text: `Context ${s.contextPercent}% full: consider /compact` })
191 else if (left != null && left <= 3) out.push({ key: `ctx-soon:${trend.ctxHistory[0]}`, text: `Context ~${left} turn${left === 1 ? '' : 's'} from 80% at this rate: consider /compact` })
192 for (const r of s.rateLimits) {
193 const p = projected(r.percentUsed, r.resetsAt, r.kind, now)
194 if (Math.max(r.percentUsed, p ?? 0) < 100) continue
195 const hit = eta(r.percentUsed, r.resetsAt, r.kind, now)
196 const label = LABEL[r.kind] ?? r.kind
197 out.push({
198 key: `pace:${r.kind}:${r.resetsAt ?? ''}`,
199 text: r.percentUsed >= 100 ? `${label} reached` : `${label}: on pace for ${p}%${hit != null ? `, hits 100% ~${clock(hit, now)}` : ''}`,
200 })
201 }
202 if (mode(s) === 'api' && budgetUsd > 0 && (s.costUsd ?? 0) >= budgetUsd * 0.9)
203 out.push({ key: `budget:${budgetUsd}`, text: `Spend $${s.costUsd!.toFixed(2)} of $${budgetUsd} budget` })
204 return out
205}
206types/index.d.ts 20 lines1export type Snapshot = {
2 contextPercent: number | null
3 contextTokens: number | null
4 contextWindow: number
5 costUsd: number | null
6 startedAt: number
7 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
8}
9
10// lastCost: session cost at the last turn's end; ctxHistory: context tokens after the last few turns.
11export type Trend = { lastCost: number | null; lastTurnUsd: number | null; ctxHistory: number[] }
12
13export type Tokens = { in: number; out: number; cacheRead: number; cacheWrite: number }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'usage-band': { snap: Snapshot | null; tokens: Tokens; trend: Trend; warned: string[]; compact: boolean }
18 }
19}
20