SLOPSHOPPER

usage-bar

Interactive band above the prompt: context fill, 5-hour and 7-day plan limits with resets, session cost, and a progress row for any subagents.

newbandcommandtoastprompttimer
★ 1v0.1.0no licenseupdated 2026-10-09satyamk4517/claude-mods/usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
› 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 › /usage-bar ⎿ usage-bar: usage-bar hidden. /usage-bar shows it. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

satyam-mods

Two Claude Code mods — plugins of function hooks that run inside Claude Code (terminal and the Desktop app's Code tab). Needs Claude Code 2.1.287 or later (claude --version).

ModWhat it does
blast-guardHolds risky shell commands (rm -r, Remove-Item -Recurse, force push, git reset --hard, git clean -f, git checkout ., git branch -D) and asks Cancel / Run it, with a dry-run report of what would be lost. Blocks Python heredocs carrying \1 / \x.. escapes (they write control characters into files). Refuses a 5th concurrent subagent.
cache-meterStatus line: prompt-cache hit rate and the 1-hour TTL countdown (context size lives in usage-bar). A toast whenever a request rebuilds the cache instead of reading it, with the token count, the extra cost, and what changed just before (model switch, effort change, idle past the TTL, or a prompt-prefix change). /cache-meter prints the session summary.
usage-barOne slim row above the prompt with solid bars for context fill and your 5-hour and 7-day plan limits (percent used, time to reset), plus the session cost. Bars are green under 60%, amber from 60%, red from 85%; at 90% the label turns into a bold red ⚠ warning and a toast fires once per reset window. more adds plain sentences (used, left, resets); × hides it. While subagents run, the row adds agent dots (pulsing while running, ✓ when done) and an agents button for savvy-progress's /agents-info. /usage-bar hides/shows, /usage-bar details expands and refreshes.

Install

In a Claude Code terminal session:

/plugin install blast-guard --marketplace satyamk4517/claude-mods
/plugin install cache-meter --marketplace satyamk4517/claude-mods
/plugin install usage-bar --marketplace satyamk4517/claude-mods

Answer y to add the marketplace, then pick a scope (user = every project).

Before you install

Mods run with your permissions and are not sandboxed. Read the two hook files first, or list what they do without running them:

claude plugin validate ./blast-guard
claude plugin validate ./cache-meter
claude plugin validate ./usage-bar

blast-guard calls $.process.run (git dry runs only), $.ui.ask and $.agent.list. cache-meter calls the clock, status line, toasts and registers one command; it makes no network or file calls. usage-bar calls $.session.usage (the status line's own figures, no network request), $.agent.list, the clock, its store and toasts, and runs /agents-info only when you press agents.

Notes

  • cache-meter prices Opus (USD 4 / 20 per MTok) and Sonnet (USD 2 / 10) at list rates, with a 1-hour cache write at 2× input and a read at 0.1×. Other models get token counts only. On a subscription, read the figures as relative weight, not a bill. Edit PRICES in cache-meter/hooks/register.ts if your rates differ.
  • The rebuild reason is what coincided with the rebuild, not a proven cause.
  • blast-guard reads command text, so a command hidden in $(...) or a script file walks past it. Pair it with deny permission rules for anything that must never run.
  • usage-bar shows the plan limits as Anthropic reports them: a percentage and a reset time. There is no token allowance to show, because the API reports none. The percentage is account-wide, so it includes your other sessions; it fills in after the first reply.
  • usage-bar's cost is what /cost totals, at API prices; on a subscription treat it as relative weight.
  • Tests: claude plugin test ./blast-guard, claude plugin test ./cache-meter and claude plugin test ./usage-bar.
Source 3 files
hooks/register.tsx 217 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, Register, SessionUsage } from 'claude-code'
3
4import type { AgentRow, UsageSnapshot, View } from '../types'
5import { isLive, limitLong, limitName, money, pctColor, tokens, until } from './format'
6
7const usage = atom({ plugin: 'usage-bar', key: 'usage' } as const, null)
8const agents = atom({ plugin: 'usage-bar', key: 'agents' } as const, [])
9const view = atom({ plugin: 'usage-bar', key: 'view' } as const, { isExpanded: false, isHidden: false })
10const now = atom({ plugin: 'usage-bar', key: 'now' } as const, 0)
11
12const LIVE = new Set(['pending', 'running', 'waiting'])
13// From here a limit is a warning: bold red label and a one-time toast.
14const WARN_AT = 90
15
16const snapshotOf = (u: SessionUsage): UsageSnapshot => ({
17  ctxTokens: u.context.tokens ?? 0,
18  ctxWindow: u.context.window,
19  ctxPct: u.context.percent ?? (u.context.window ? Math.round((100 * (u.context.tokens ?? 0)) / u.context.window) : 0),
20  limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed, resetsAt: r.resetsAt })),
21  usd: u.cost?.usd ?? null,
22})
23
24// The batch the band shows: agents started since the prompt, plus any still live.
25const rowsOf = (list: AgentInfo[], before: Set<string>): AgentRow[] =>
26  list
27    .filter(a => a.type !== 'teammate' && (LIVE.has(a.status) || !before.has(a.id)))
28    .map(a => ({ id: a.id, desc: a.description, type: a.type, status: a.status }))
29
30
31export const register: Register = on => {
32  let before = new Set<string>()
33  let isTurnRunning = false
34  const warned = new Set<string>()
35  const warnText = (u: UsageSnapshot, at: number) =>
36    u.limits
37      .filter(l => l.pct >= WARN_AT && !warned.has(l.kind + (l.resetsAt ?? '')))
38      .map(l => {
39        warned.add(l.kind + (l.resetsAt ?? ''))
40        return `${limitLong(l.kind)} at ${Math.round(l.pct)}%${l.resetsAt ? ', resets ' + until(l.resetsAt, at) : ''}`
41      })
42
43  on('session.start', async ($, e, next) => {
44    await $.command.register({ name: 'usage-bar', description: 'Show or hide the usage band; "/usage-bar details" expands it.' })
45    const saved = (await $.store.get('view')) as View | undefined
46    if (saved) await update($, view, () => saved)
47    // Reset countdowns and the plan limits move on their own: refresh twice a minute.
48    $.clock.every(30_000, async () => {
49      await update($, now, () => Date.now())
50      try {
51        const u = await $.session.usage()
52        await update($, usage, () => snapshotOf(u))
53      } catch {}
54    })
55    // While a turn runs, follow subagents closely.
56    $.clock.every(3000, async () => {
57      if (!isTurnRunning) return
58      try {
59        const list = await $.agent.list()
60        await update($, agents, () => rowsOf(list, before))
61      } catch {}
62    })
63    return next(e)
64  })
65
66  on('command.run', { command: 'usage-bar' }, async ($, e) => {
67    const wantsDetails = e.args.trim() === 'details'
68    try {
69      const u = await $.session.usage()
70      await update($, usage, () => snapshotOf(u))
71    } catch {}
72    await update($, view, v => (wantsDetails ? { isHidden: false, isExpanded: !v.isExpanded } : { ...v, isHidden: !v.isHidden }))
73    const v = await read($, view)
74    await $.store.set('view', v)
75    return { text: v.isHidden ? 'usage-bar hidden. /usage-bar shows it.' : v.isExpanded ? 'usage-bar: details shown.' : 'usage-bar shown.' }
76  })
77
78  on('prompt.submit', async ($, e, next) => {
79    try {
80      before = new Set((await $.agent.list()).filter(a => !LIVE.has(a.status)).map(a => a.id))
81    } catch {}
82    isTurnRunning = true
83    await update($, agents, list => list.filter(a => LIVE.has(a.status)))
84    return next(e)
85  }).catch(($, e, next) => next(e))
86
87  on('turn.step', async function* ($, e, next) {
88    const res = yield* next(e)
89    if (e.agentId === undefined) {
90      try {
91        const u = await $.session.usage()
92        const snap = snapshotOf(u)
93        await update($, usage, () => snap)
94        await update($, now, () => Date.now())
95        for (const w of warnText(snap, Date.now())) $.ui.toast('⚠ ' + w, { timeoutMs: 10000 })
96      } catch {}
97    }
98    return res
99  })
100
101  on('turn.complete', async ($, e, next) => {
102    const res = await next(e)
103    if (e.agentId === undefined) {
104      isTurnRunning = false
105      try {
106        const list = await $.agent.list()
107        await update($, agents, () => rowsOf(list, before))
108      } catch {}
109    }
110    return res
111  })
112
113  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114    const v = await read($, view)
115    const u = await read($, usage)
116    const list = await read($, agents)
117    if (e.props.hasSurvey || v.isHidden || (u === null && list.length === 0)) return next(e)
118
119    const below = await next(e)
120    const { Box, Text, Button } = $.ui.resolve(e)
121    const t = (await read($, now)) || Date.now()
122
123    const toggleDetails = async () => {
124      await update($, view, x => ({ ...x, isExpanded: !x.isExpanded }))
125      await $.store.set('view', await read($, view))
126    }
127    const hide = async () => {
128      await update($, view, x => ({ ...x, isHidden: true }))
129      await $.store.set('view', await read($, view))
130    }
131    const openPanel = async () => {
132      try {
133        await $.command.run({ command: 'agents-info', args: '' })
134      } catch {
135        $.ui.toast('usage-bar: the agents panel needs the savvy-progress mod.')
136      }
137    }
138
139    const buttons = (
140      <Box key="buttons">
141        <Button key="details" label={v.isExpanded ? 'less' : 'more'} plain onPress={toggleDetails} />
142        {list.length > 0 && <Text>  </Text>}
143        {list.length > 0 && <Button key="panel" label="agents" plain onPress={openPanel} />}
144        <Text>  </Text>
145        <Button key="hide" label="×" plain onPress={hide} />
146      </Box>
147    )
148
149    const details = u && v.isExpanded && (
150      <Box key="details-rows" flexDirection="column">
151        <Text dimColor>
152          Context: {u.ctxTokens.toLocaleString()} of {u.ctxWindow.toLocaleString()} tokens used, {tokens(Math.max(0, u.ctxWindow - u.ctxTokens))} left.
153        </Text>
154        {u.limits.map(l => (
155          <Text key={'det-' + l.kind} dimColor>
156            {limitLong(l.kind)}: {Math.round(l.pct)}% used, {Math.max(0, 100 - Math.round(l.pct))}% left, resets {until(l.resetsAt, t) || 'unknown'}.
157          </Text>
158        ))}
159        {u.limits.length === 0 && <Text dimColor>No plan limits reported yet (they arrive with the next reply, or the account has none).</Text>}
160        {u.usd !== null && <Text dimColor>Session cost as /cost totals it: {money(u.usd)} (API-equivalent on a subscription).</Text>}
161      </Box>
162    )
163
164    // One line of short smooth bars, drawn with the surface's own text (terminal and desktop alike).
165    // Each bar is two solid blocks (fill and track) drawn with the box's own
166    // background: a real bar on desktop and in the terminal, in any font.
167    const CELLS = 10
168    const TRACK = '#3d444d'
169    const mini = (key: string, label: string, pct: number, tail: string) => {
170      const used = Math.max(0, Math.min(CELLS, Math.round((CELLS * pct) / 100)))
171      const isWarn = pct >= WARN_AT
172      return (
173        <Box key={key} flexShrink={0} alignItems="center" marginRight={3}>
174          <Text bold={isWarn} color={isWarn ? 'red' : undefined} dimColor={!isWarn} wrap="truncate-end">
175            {isWarn ? '⚠ ' : ''}{label}{' '}
176          </Text>
177          <Box key="bar" flexShrink={0} width={CELLS} height={1}>
178            {used > 0 && <Box key="fill" width={used} height={1} backgroundColor={pctColor(pct)} />}
179            {used < CELLS && <Box key="track" width={CELLS - used} height={1} backgroundColor={TRACK} />}
180          </Box>
181          <Text bold color={pctColor(pct)} wrap="truncate-end"> {Math.round(pct)}%</Text>
182          {tail ? <Text dimColor wrap="truncate-end"> {tail}</Text> : null}
183        </Box>
184      )
185    }
186    const done = list.filter(a => !isLive(a)).length
187    return (
188      <Box flexDirection="column">
189        <Box key="line" flexWrap="nowrap" alignItems="center">
190          {u && mini('ctx', 'ctx', u.ctxPct, tokens(u.ctxTokens))}
191          {u &&
192            u.limits.slice(0, 2).map(l =>
193              mini('lim-' + l.kind, l.kind === 'five_hour' ? '5h' : l.kind === 'seven_day' ? '7d' : limitName(l.kind), l.pct, l.resetsAt ? '↻' + until(l.resetsAt, t).replace('in ', '') : ''),
194            )}
195          {u && u.usd !== null && (
196            <Box key="cost" flexShrink={0} marginRight={3}>
197              <Text dimColor>{money(u.usd)}</Text>
198            </Box>
199          )}
200          {list.length > 0 && (
201            <Text>
202              <Text dimColor>agents </Text>
203              {list.slice(0, 8).map(a => (
204                <Text key={'dot-' + a.id} color={isLive(a) ? 'yellow' : 'green'}>{isLive(a) ? '●' : '✓'}</Text>
205              ))}
206              <Text dimColor> {done}/{list.length}</Text>
207            </Text>
208          )}
209          {buttons}
210        </Box>
211        {details}
212        {below}
213      </Box>
214    )
215  })
216}
217
hooks/format.ts 116 lines
1// Pure helpers for the band: no engine calls, so tests can import them.
2
3export type Limit = { kind: string; pct: number; resetsAt?: string }
4export type Usage = { ctxTokens: number; ctxWindow: number; ctxPct: number; limits: Limit[]; usd: number | null }
5export type Agent = { id: string; desc: string; type: string; status: string }
6
7const LIVE = new Set(['pending', 'running', 'waiting'])
8export const isLive = (a: Agent) => LIVE.has(a.status)
9
10export const pctColor = (pct: number) => (pct >= 85 ? 'red' : pct >= 60 ? 'yellow' : 'green')
11const HEX = { green: '#3fb950', yellow: '#d29922', red: '#f85149' } as const
12export const pctHex = (pct: number) => HEX[pctColor(pct)]
13
14export const bar = (pct: number, cells: number) => {
15  const filled = Math.max(0, Math.min(cells, Math.round((cells * pct) / 100)))
16  return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
17}
18
19// A bar at eighth-of-a-cell resolution, padded with a light track.
20const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
21export const smoothBar = (pct: number, cells: number) => {
22  const eighths = Math.max(0, Math.min(cells * 8, Math.round((cells * 8 * pct) / 100)))
23  const full = Math.floor(eighths / 8)
24  const part = EIGHTHS[eighths % 8] ?? ''
25  const used = full + (part ? 1 : 0)
26  return { fill: '█'.repeat(full) + part, track: '░'.repeat(Math.max(0, cells - used)) }
27}
28
29export const tokens = (n: number) =>
30  n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
31
32// "in 2h10m", "in 3d4h", "now": relative, so no time zone is assumed.
33export const until = (iso: string | undefined, now: number) => {
34  if (!iso) return ''
35  const ms = Date.parse(iso) - now
36  if (!Number.isFinite(ms)) return ''
37  if (ms <= 0) return 'now'
38  const m = Math.round(ms / 60000)
39  const d = Math.floor(m / 1440)
40  const h = Math.floor((m % 1440) / 60)
41  return d > 0 ? `in ${d}d${h}h` : h > 0 ? `in ${h}h${String(m % 60).padStart(2, '0')}m` : `in ${m}m`
42}
43
44export const limitName = (kind: string) =>
45  kind === 'five_hour' ? '5-hour' : kind === 'seven_day' ? '7-day' : kind === 'spend_limit' ? 'Spend' : kind.replace(/_/g, ' ')
46
47export const limitLong = (kind: string) =>
48  kind === 'five_hour' ? '5-hour limit' : kind === 'seven_day' ? '7-day limit' : kind === 'spend_limit' ? 'Spend limit' : kind
49
50export const money = (usd: number) => '$' + usd.toFixed(2)
51
52const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
53const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
54
55// The desktop strip: one 30px row of labelled mini bars, cost and agent dots.
56export const svgCard = (u: Usage | null, agents: Agent[], now: number) => {
57  const H = 30
58  const parts: string[] = []
59  let x = 10
60  const seg = (label: string, pct: number, tail: string, tip: string) => {
61    const bw = 64
62    const fill = (Math.max(0, Math.min(100, pct)) / 100) * bw
63    parts.push(`<g><title>${esc(tip)}</title>
64  <text x="${x}" y="19" class="mute sm">${esc(label)}</text>
65  <rect x="${x + 44}" y="11" width="${bw}" height="8" rx="4" class="trackfill"/>
66  <rect x="${x + 44}" y="11" width="${fill.toFixed(1)}" height="8" rx="4" fill="${pctHex(pct)}"/>
67  <text x="${x + 50 + bw}" y="19" class="fg b">${Math.round(pct)}%</text>
68  ${tail ? `<text x="${x + 84 + bw}" y="19" class="mute sm">${esc(tail)}</text>` : ''}</g>`)
69    x += 84 + bw + (tail ? tail.length * 6 + 14 : 4)
70  }
71  if (u) {
72    seg('ctx', u.ctxPct, tokens(u.ctxTokens), `Context: ${u.ctxTokens.toLocaleString()} of ${u.ctxWindow.toLocaleString()} tokens, ${tokens(Math.max(0, u.ctxWindow - u.ctxTokens))} left`)
73    for (const l of u.limits.slice(0, 2)) {
74      const left = Math.max(0, 100 - Math.round(l.pct))
75      const when = until(l.resetsAt, now)
76      seg(l.kind === 'five_hour' ? '5h' : l.kind === 'seven_day' ? '7d' : limitName(l.kind), l.pct, when ? '↻' + when.replace('in ', '') : '',
77        `${limitLong(l.kind)}: ${Math.round(l.pct)}% used, ${left}% left${when ? ', resets ' + when : ''}`)
78    }
79    if (u.usd !== null) {
80      parts.push(`<g><title>Session cost as /cost totals it (API-equivalent on a subscription)</title><text x="${x}" y="19" class="fg b">${money(u.usd)}</text></g>`)
81      x += 58
82    }
83  }
84  if (agents.length) {
85    const done = agents.filter(a => !isLive(a)).length
86    parts.push(`<text x="${x}" y="19" class="mute sm">agents ${done}/${agents.length}</text>`)
87    x += 66
88    agents.slice(0, 8).forEach(a => {
89      const live = isLive(a)
90      parts.push(`<g><title>${esc(`${a.type}: ${a.desc} (${a.status})`)}</title><circle cx="${x}" cy="15" r="4" fill="${live ? HEX.yellow : HEX.green}">${live ? '<animate attributeName="opacity" values="1;0.25;1" dur="1.4s" repeatCount="indefinite"/>' : ''}</circle></g>`)
91      x += 12
92    })
93    x += 6
94  }
95  const W = Math.max(120, Math.ceil(x))
96  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="Segoe UI, system-ui, sans-serif">
97<style>
98  .panel{fill:#f6f8fa;stroke:#d0d7de} .fg{fill:#1f2328} .mute{fill:#656d76} .trackfill{fill:#d0d7de}
99  .b{font-size:12px;font-weight:600} .sm{font-size:11px}
100  @media (prefers-color-scheme: dark){ .panel{fill:#161b22;stroke:#30363d} .fg{fill:#e6edf3} .mute{fill:#8b949e} .trackfill{fill:#30363d} }
101</style>
102<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="8" class="panel"/>
103${parts.join('\n')}
104</svg>`
105}
106
107export const cardAlt = (u: Usage | null, agents: Agent[], now: number) =>
108  [
109    u ? `Context ${u.ctxPct}% (${tokens(u.ctxTokens)} of ${tokens(u.ctxWindow)})` : '',
110    ...(u ? u.limits.map(l => `${limitLong(l.kind)} ${Math.round(l.pct)}% used${l.resetsAt ? ', resets ' + until(l.resetsAt, now) : ''}`) : []),
111    u && u.usd !== null ? `session ${money(u.usd)}` : '',
112    agents.length ? `agents ${agents.filter(a => !isLive(a)).length}/${agents.length} done` : '',
113  ]
114    .filter(Boolean)
115    .join('; ')
116
types/index.d.ts 18 lines
1export type UsageSnapshot = {
2  ctxTokens: number
3  ctxWindow: number
4  ctxPct: number
5  limits: { kind: string; pct: number; resetsAt?: string }[]
6  usd: number | null
7}
8
9export type AgentRow = { id: string; desc: string; type: string; status: string }
10
11export type View = { isExpanded: boolean; isHidden: boolean }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'usage-bar': { usage: UsageSnapshot | null; agents: AgentRow[]; view: View; now: number }
16  }
17}
18