SLOPSHOPPER

usage-band

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

newbandcommandtoast
v0.4.0no licenseupdated 2026-10-19ReesMehorst/claude-mods/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 ╭──────────────────────────────╮ │ usage-band │ ⏺ Read(src/auth.ts) │ ⚠ 5h limit: on pace for NaN% │ ⎿ 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 › /usage-band ⎿ usage-band: Usage band: compact PLAN ctx 49% · 5h 31%→NaN% · $0.42 · 99k tok ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
PLAN ctx 49% · 5h 31%→NaN% · $0.42 · 99k tok
README

claude-mods

Claude Code mods by ReesMehorst.

Install

/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.

Mods

usage-band

A band above the prompt that shows where your session stands.

  • Plan mode (Pro/Max subscription): 5-hour and weekly limits with pace projection (pace → 120%) and when you'll hit 100% at that pace (hits 100% ~16:40). Session cost is shown muted as "API value".
  • API mode (API credits): spend leads, with $/hr. Set a budget under /config → API budget (USD) for a budget bar and ETA.
  • Context fill, and a token breakdown (in / out / cache hit %).
  • Warnings as toasts: context ≥ 80%, a limit on pace past 100%, spend ≥ 90% of budget.
  • /usage-band toggles a compact one-line view (remembered across sessions).

github-pane

/github opens a live pane for the repo you're in (needs the gh CLI, signed in).

  • This branch: the PR for your checked-out branch, pinned above the tabs.
  • Tabs: Pull requests, Checks (workflow runs), Issues, For me (review requests, your PRs, issues assigned to you), Notifications (unread, this repo). Hotkeys 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.
  • Each row: CI status, title, branch → base, author, review state, age. In the desktop app a row is two lines, with review and CI as coloured pills; a selected row opens a card under it with its actions and details: the PR description as formatted markdown, reviewers with a state pill each, failed logs and checks in a code block.
  • Select a row for actions: open on GitHub, c checkout a PR, x rerun failed jobs, l show the failed-step log inline, e mark a notification read.
  • On a failed run or PR: 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.
  • On a PR: 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.
  • Desktop app: a full-width Filter field above the list (click it, then type; ctrl+U clears) narrows the tab by title, #n, branch or author; switching tabs clears it. An empty tab says why it is empty.
  • Refreshes every 60 s while the pane is open, with toasts when a PR's checks turn red, your review is requested, your PR is approved, or a run on your PR's branch finishes. Errors back off (up to 10 min); no repo or gh signed out pauses polling until r.

Develop

claude plugin validate ./usage-band
claude plugin test ./usage-band
Source 3 files
hooks/register.tsx 164 lines
1import { 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}
164
hooks/summarize.ts 206 lines
1import 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}
206
types/index.d.ts 20 lines
1export 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