SLOPSHOPPER

usage-bars

Sparklines of context-window fill and tokens added per turn under the prompt

newspinnerprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bars
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ⎇ ●2 │ ◆ opus 5.5 │ ctx ▄▄ 49% 97.4k/200k │ 5h ▰▰▱▱▱ 31% ↻NaNm

Draws

Prompt hint
⟨Claude Code's own drawing⟩ ⎇ ●2 │ ◆ opus 5.5 │ ctx ▄▄ 49% 97.4k/200k │ 5h ▰▰▱▱▱ 31% ↻NaNm
README

claude-code-mods

Two small Claude Code mods (function-hook plugins) as a plugin marketplace.

usage-bars

One row under the prompt, refreshed after every turn:

usage-bars

GroupShows
⎇git branch, ● uncommitted files, ↑/↓ commits ahead of / behind upstream, ✓ when clean
◆the model the main loop runs and its effort level
ctxcontext-window fill over the last 8 turns, then the live % and tokens / window
tokwhat each turn cost (bars), then live session figures, subagents included: in tokens read (cached or not), out tokens generated, the latest request's cache hit rate (yellow below 90 %, red below 50 %: the cache went cold) and the session cost in $ as Claude Code prices it
5h / 7dyour rate-limit windows: % used and time until reset (subscription accounts)

Charts turn yellow from 60 % and red from 85 %. The row wraps on narrow terminals.

What the numbers mean (and don't)

  • $ is an estimate. It is Claude Code's own figure (the one /cost shows): each response's reported token counts times the published per-model prices, so input, output, cache writes and cache reads are already weighted correctly. On the API that is close to your bill, but the Console's usage page is authoritative: server-tool fees (web search), 1-hour cache writes, fast mode, data-residency and other pricing modifiers, or Bedrock / Vertex prices can make the invoice differ.
  • On a subscription (Pro / Max) you are not billed per token. Anthropic does not publish how the 5-hour and weekly limits are counted, so $ is only a relative gauge of which turns were heavy. The 5h / 7d percentages are the official figures; they come straight from Anthropic's rate-limit data.
  • in is volume, not cost. It counts cached and uncached input alike; a cache read costs a fraction of a fresh input token (for example 0.05x on Opus 5.5) and output costs 5x input. Only $ applies those weights.
  • in, out and hit count what the mod has seen since it loaded in a session; $ covers the whole session.

agent-watch

When a subagent starts, a narrow pane docks beside the transcript and follows each agent live: type, elapsed time, tool count, its latest tool calls (› running, ✓ / ✗ done) and the tail of what it is writing. Finished agents collapse to one line; the pane closes itself 20 s after the last one ends.

agent-watch

The pane opens on its own from 144 terminal columns (a Claude Code rule for panes nobody asked for); on a narrower terminal run /agents-pane.

Install

/plugin marketplace add AndreasOA/claude-code-mods
/plugin install usage-bars@ao-claude-mods
/plugin install agent-watch@ao-claude-mods

Restart Claude Code (or open a new session) to load them. /plugin marketplace update ao-claude-mods pulls new versions.

Develop

Each plugin is plugins/<name>/ with hooks/register.tsx, its state contract in types/index.d.ts and tests in tests/.

claude plugin validate plugins/usage-bars
claude plugin test plugins/usage-bars
claude --plugin-dir plugins/usage-bars   # load a working copy, hot-reloaded on save

The images in docs/ are illustrations of the layout, not screenshots.

License

MIT

Source 2 files
hooks/register.tsx 302 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, TurnUsage } from 'claude-code'
3
4import type { GitInfo, Limit, ModelInfo, Sample, Totals } from '../types'
5
6// How many readings the charts keep, one bar each
7const MAX_BARS = 20
8// How many of them the ctx chart draws: the fill climbs slowly, so the latest few say enough
9const CTX_BARS = 8
10
11// Eight bar heights, from the shortest to the tallest
12const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
13
14const samples = atom({ plugin: 'usage-bars', key: 'samples' } as const, [] as Sample[])
15const window = atom({ plugin: 'usage-bars', key: 'window' } as const, 0)
16const limits = atom({ plugin: 'usage-bars', key: 'limits' } as const, [] as Limit[])
17const current = atom({ plugin: 'usage-bars', key: 'current' } as const, null as Sample | null)
18const totals = atom({ plugin: 'usage-bars', key: 'totals' } as const, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as Totals)
19const turns = atom({ plugin: 'usage-bars', key: 'turns' } as const, [] as number[])
20const mark = atom({ plugin: 'usage-bars', key: 'mark' } as const, 0)
21const cost = atom({ plugin: 'usage-bars', key: 'cost' } as const, null as number | null)
22const hit = atom({ plugin: 'usage-bars', key: 'hit' } as const, null as number | null)
23const model = atom({ plugin: 'usage-bars', key: 'model' } as const, null as ModelInfo | null)
24const git = atom({ plugin: 'usage-bars', key: 'git' } as const, null as GitInfo | null)
25
26// How the rate-limit windows are labelled on the row
27const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
28
29// Draw each value as a bar, scaled so that `max` is the tallest bar
30function bars(values: number[], max: number): string {
31  if (max <= 0) return ''
32  return values
33    .map(v => {
34      const level = Math.round((Math.min(Math.max(v, 0), max) / max) * (BLOCKS.length - 1))
35      return BLOCKS[level]
36    })
37    .join('')
38}
39
40// 84200 -> "84.2k", 1200000 -> "1.2M"
41function short(n: number): string {
42  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
43  if (n >= 1000) return (n / 1000).toFixed(n >= 100_000 ? 0 : 1) + 'k'
44  return String(Math.round(n))
45}
46
47// Green while there is room, yellow as the window fills, red near the end
48function fillColor(percent: number): string {
49  if (percent >= 85) return 'red'
50  if (percent >= 60) return 'yellow'
51  return 'green'
52}
53
54// A fixed-width meter: 34% -> "▰▰▱▱▱"
55function meter(percent: number, width = 5): string {
56  const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
57  return '▰'.repeat(filled) + '▱'.repeat(width - filled)
58}
59
60// Time left until an ISO timestamp: "2h10m", "3d", "45m"
61function until(iso: string | undefined): string {
62  if (!iso) return ''
63  const mins = Math.max(Math.round((Date.parse(iso) - Date.now()) / 60_000), 0)
64  if (mins >= 48 * 60) return Math.round(mins / (24 * 60)) + 'd'
65  if (mins >= 60) return Math.floor(mins / 60) + 'h' + String(mins % 60).padStart(2, '0') + 'm'
66  return mins + 'm'
67}
68
69// "claude-opus-5-5[1m]" -> "opus 5.5 1M", "claude-haiku-4-5-20251001" -> "haiku 4.5"
70function modelName(id: string): string {
71  const long = /\[1m\]$/i.test(id) ? ' 1M' : ''
72  return (
73    id
74      .replace(/\[.*\]$/, '')
75      .replace(/^claude-/, '')
76      .replace(/-\d{8}$/, '')
77      .replace(/-(\d+)-(\d+)$/, ' $1.$2')
78      .replace(/-(\d+)$/, ' $1') + long
79  )
80}
81
82// Read branch, changes and ahead/behind from `git status --porcelain=v2 --branch`
83async function readGit($: EngineInterface) {
84  const run = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 }).catch(() => null)
85  if (!run || run.exitCode !== 0) return update($, git, () => null)
86  const info: GitInfo = { branch: '', changes: 0, ahead: 0, behind: 0 }
87  for (const line of run.stdout.split('\n')) {
88    if (line.startsWith('# branch.head ')) info.branch = line.slice('# branch.head '.length)
89    else if (line.startsWith('# branch.ab ')) {
90      const [, ahead, behind] = /\+(\d+) -(\d+)/.exec(line) ?? []
91      info.ahead = Number(ahead ?? 0)
92      info.behind = Number(behind ?? 0)
93    } else if (line && !line.startsWith('#')) info.changes++
94  }
95  return update($, git, () => info)
96}
97
98// Refresh the live reading and the rate-limit windows
99async function measure($: EngineInterface) {
100  const { context, rateLimits, cost: spent } = await $.session.usage()
101  await update($, cost, () => spent?.usd ?? null)
102  await update($, limits, () => rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })))
103  if (context.tokens === undefined) return
104  const sample: Sample = { tokens: context.tokens, percent: context.percent ?? 0 }
105  await update($, window, () => context.window)
106  await update($, current, () => sample)
107}
108
109// Every input token the model read, from the cache or not
110function inputs(t: { input: number; cacheRead: number; cacheWrite: number }): number {
111  return t.input + t.cacheRead + t.cacheWrite
112}
113
114// Add one model request's usage to the session's totals
115async function count($: EngineInterface, u: TurnUsage) {
116  await update($, totals, t => ({
117    input: t.input + u.input_tokens,
118    output: t.output + u.output_tokens,
119    cacheRead: t.cacheRead + u.cache_read_input_tokens,
120    cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
121  }))
122  const read = inputs({ input: u.input_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens })
123  if (read > 0) await update($, hit, () => u.cache_read_input_tokens / read)
124}
125
126// Close a turn: keep its reading as one bar, the newest MAX_BARS of them
127async function record($: EngineInterface) {
128  await measure($)
129  const sample = await read($, current)
130  if (sample) await update($, samples, list => [...list, sample].slice(-MAX_BARS))
131}
132
133// The classic hook events carry the effort level in force, after any downgrade; turn.step leaves the session's default out
134async function recordEffort($: EngineInterface, e: { agent_id?: string; effort?: { level: string } }) {
135  if (e.agent_id !== undefined || !e.effort) return
136  const level = e.effort.level
137  await update($, model, m => (m ? { ...m, effort: level } : m))
138}
139
140export const register: Register = on => {
141  // Start the charts with the current reading, so they aren't empty at launch
142  on('session.start', async ($, e, next) => {
143    await record($)
144    await readGit($)
145    const name = await $.session.model()
146    await update($, model, m => ({ name, effort: m?.effort ?? null }))
147    return next(e)
148  })
149
150  // Whenever the window's fill or the limits move: the live figures only
151  on('session.measure', async ($, e, next) => {
152    if (e.changed.length > 0) await measure($)
153    await readGit($)
154    return next(e)
155  })
156
157  // Every model request, a subagent's included, adds its usage as soon as it answers;
158  // the main loop's also carry the model and effort actually sent
159  on('turn.step', async function* ($, e, next) {
160    if (e.agentId === undefined) {
161      const effort = e.effort === undefined ? null : String(e.effort)
162      // A request names the bare id; keep the session's own spelling (its [1m] suffix) while it is the same model
163      await update($, model, m => ({ name: m && m.name.replace(/\[.*\]$/, '') === e.model ? m.name : e.model, effort: effort ?? m?.effort ?? null }))
164    }
165    // Pass every chunk on; the usage rides the stop chunk or the step's result
166    const stream = next(e)
167    let u: TurnUsage | null = null
168    for (let r = await stream.next(); ; r = await stream.next()) {
169      if (r.done) {
170        const result = r.value ?? (await stream.result)
171        u = result?.usage ?? u
172        if (u) await count($, u)
173        return result
174      }
175      if (r.value.kind === 'stop' && r.value.usage) u = r.value.usage
176      yield r.value
177    }
178  })
179
180  // A main-loop turn ended: one bar for it, what it cost since the last one (its subagents' included)
181  on('turn.complete', async ($, e, next) => {
182    const done = await next(e)
183    if (e.agentId === undefined) {
184      await record($)
185      const now = (await read($, cost)) ?? 0
186      const spent = now - Math.min(await read($, mark), now)
187      await update($, mark, () => now)
188      await update($, turns, list => [...list, spent].slice(-MAX_BARS))
189    }
190    return done
191  })
192
193  on('classic.PostToolUse', async ($, e, next) => {
194    await recordEffort($, e)
195    return next(e)
196  })
197  on('classic.Stop', async ($, e, next) => {
198    await recordEffort($, e)
199    return next(e)
200  })
201
202  // The hint line under the prompt: keep the engine's own line, add a row below it
203  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
204    const list = await read($, samples)
205    const windows = await read($, limits)
206    const live = await read($, current)
207    const active = await read($, model)
208    const repo = await read($, git)
209    // Nothing measured yet, so leave the line as it is
210    if (list.length === 0 && !live && windows.length === 0 && !active && !repo) return next(e)
211
212    const { Box, Text } = $.ui.resolve(e)
213    const size = await read($, window)
214    const latest = live ?? list[list.length - 1]
215
216    const sum = await read($, totals)
217    const perTurn = await read($, turns)
218    const spent = await read($, cost)
219    // The latest request's cache hit, rounded down so it never claims 100 % early; a cold cache shows in colour
220    const last = await read($, hit)
221    const hitPct = last === null ? null : Math.floor(last * 1000) / 10
222    const hitColor = hitPct === null || hitPct >= 90 ? undefined : hitPct >= 50 ? 'yellow' : 'red'
223
224    const engineLine = await next(e)
225
226    // One group per figure: a dim label, its chart, then its numbers
227    const group = (key: string, label: string, chart: RenderChildren, value: RenderChildren) => (
228      <Box key={key} flexDirection="row" columnGap={1}>
229        <Text dimColor>{label}</Text>
230        <Box flexDirection="row" columnGap={2}>
231          {chart}
232          {value}
233        </Box>
234      </Box>
235    )
236    const groups = [
237      repo &&
238        group(
239          'git',
240          '⎇',
241          <Text color="magenta">{repo.branch}</Text>,
242          <Text>
243            {repo.changes > 0 && <Text color="yellow">●{repo.changes} </Text>}
244            {repo.ahead > 0 && <Text color="green">↑{repo.ahead} </Text>}
245            {repo.behind > 0 && <Text color="red">↓{repo.behind}</Text>}
246            {repo.changes === 0 && repo.ahead === 0 && repo.behind === 0 && <Text dimColor>✓</Text>}
247          </Text>,
248        ),
249      active &&
250        group(
251          'model',
252          '◆',
253          <Text color="blue">{modelName(active.name)}</Text>,
254          active.effort ? <Text dimColor>{active.effort}</Text> : null,
255        ),
256      latest &&
257        group(
258          'ctx',
259          'ctx',
260          <Text color={fillColor(latest.percent)}>{bars(list.slice(-CTX_BARS).map(s => s.percent), 100)}</Text>,
261          <Text>
262            {latest.percent}% <Text dimColor>{short(latest.tokens)}/{short(size)}</Text>
263          </Text>,
264        ),
265      inputs(sum) + sum.output > 0 &&
266        group(
267          'tok',
268          'tok',
269          <Text color="cyan">{bars(perTurn, Math.max(...perTurn, 0.000001))}</Text>,
270          <Text>
271            <Text dimColor>in </Text>
272            {short(inputs(sum))}
273            <Text dimColor>  out </Text>
274            {short(sum.output)}
275            {hitPct !== null && <Text dimColor>  hit </Text>}
276            {hitPct !== null && <Text color={hitColor}>{hitPct.toFixed(1)}%</Text>}
277            {spent !== null && <Text>  ${spent.toFixed(2)}</Text>}
278          </Text>,
279        ),
280      ...windows.map(w =>
281        group(
282          w.kind,
283          LIMIT_LABELS[w.kind] ?? w.kind,
284          <Text color={fillColor(w.percentUsed)}>{meter(w.percentUsed)}</Text>,
285          <Text>
286            {w.percentUsed}%{w.resetsAt && <Text dimColor> ↻{until(w.resetsAt)}</Text>}
287          </Text>,
288        ),
289      ),
290    ].filter(Boolean)
291
292    return (
293      <Box flexDirection="column">
294        {engineLine}
295        <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
296          {groups.flatMap((g, i) => (i === 0 ? [g] : [<Text key={'sep' + i} dimColor>│</Text>, g]))}
297        </Box>
298      </Box>
299    )
300  })
301}
302
types/index.d.ts 43 lines
1// One reading of the live context window, taken after a turn
2export type Sample = { tokens: number; percent: number }
3
4// One rate-limit window: five_hour, seven_day, or a gateway's spend_limit
5export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6
7// The model the main loop last asked, and how hard it asked it to think
8export type ModelInfo = { name: string; effort: string | null }
9
10// The working tree's branch, its uncommitted changes and its distance from upstream
11export type GitInfo = { branch: string; changes: number; ahead: number; behind: number }
12
13// Tokens summed over every model call of the session, subagents included
14export type Totals = { input: number; output: number; cacheRead: number; cacheWrite: number }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'usage-bars': {
19      // One reading per finished turn, oldest first
20      samples: Sample[]
21      // The live reading, refreshed whenever the window's fill moves
22      current: Sample | null
23      // The model's context window in tokens
24      window: number
25      // Token totals so far, and the US dollars each main turn cost
26      totals: Totals
27      turns: number[]
28      // The session cost when the last main turn closed
29      mark: number
30      // US dollars spent this session as the engine prices it, null until priced
31      cost: number | null
32      // Share of the latest request's input the prompt cache served, 0 to 1
33      hit: number | null
34      // The account's rate-limit windows, as last measured
35      limits: Limit[]
36      // The session's model and effort, null until known
37      model: ModelInfo | null
38      // The git state of the working directory, null outside a repository
39      git: GitInfo | null
40    }
41  }
42}
43