SLOPSHOPPER

usage-meter

Desktop and terminal usage meter: model, context %, plan limits, cost and branch above the prompt, and a /usage pane with the context breakdown.

newpanebandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-15creamcitycyber/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ claude-opus-5-5 cost $0.42 session 30m re │ ┃ ⏺ Read(src/auth.ts) │ ┃ Plan limits ⎿ Read 6 lines │ ┃ 5h ██████░░░░░░░░░░░░░░ 31% resets in ⏺ Update(src/auth.ts) │ ┃ wk n/a (the engine has no reading for this ⎿ Added 2 lines, removed 1 line │ ┃ window) ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Context │ ┃ ████████████████░░░░░░░░░░░░░░░░ 97k/200k ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 49% │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Breakdown of the used part │ ┃ ████████████████████████████████ 97k › /usage-meter │ ┃ ■ system 97k 100% est. ⎿ usage-meter: Usage pane opened. │ ┃ Estimate: the engine gave no breakdown, so │ ┃ this is usage_bars.py's split of the real │ ┃ used total (transcript at 3.7 chars a token, │ ┃ the rest as system). │ ┃ │ ┃ ⎇ feat/auth-refresh · +14 −3 │ claude-opus-5-5 │ ctx ████░░░░ 49% │ 5h ██░░░░ 31% ↻ │ wk n/a │ $0.42 │ ⎇ feat/auth-refresh +14 −3 usage ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
claude-opus-5-5 │ ctx ████░░░░ 49% │ 5h ██░░░░ 31% ↻ │ wk n/a │ $0.42 │ ⎇ feat/auth-refresh +14 −3us
Pane · Usage
claude-opus-5-5 cost $0.42 session 30m refresh Plan limits 5h ██████░░░░░░░░░░░░░░ 31% resets in wk n/a (the engine has no reading for this window) Context ████████████████░░░░░░░░░░░░░░░░ 97k/200k 49% Breakdown of the used part ████████████████████████████████ 97k ■ system 97k 100% est. Estimate: the engine gave no breakdown, so this is usage_bars.py's split of the real used total (transcript at 3.7 chars a token, the rest as system). ⎇ feat/auth-refresh · +14 −3
README

usage-meter

Claude Code mod for monitoring usage.

It puts the model, context used, 5-hour and weekly plan limits, session cost and git branch in a band above the prompt. The pane holds the full context breakdown, and /usage opens it. It works in the desktop Code tab and the terminal.

Install

Type this at the prompt of a terminal Claude Code session:

/plugin install usage-meter --marketplace creamcitycyber/usage-meter

Answer y to add the marketplace, then press Enter for the user scope. The install command only runs in a terminal, but once installed at the user scope the mod loads in desktop sessions too.

Using it

  • The band shows on its own once a response has come back.
  • /usage or /usage-meter opens the pane. /usage builtin runs Claude Code's own /usage.
  • Figures refresh when a turn ends and every minute.

Model, context, plan limits, cost and the breakdown rows come from Claude Code itself. The split of the conversation into you, Claude, tools and so on is an estimate from the transcript, and the pane marks it "est.". A value Claude Code doesn't report shows as n/a.

Updating

claude plugin update usage-meter

Then run /reload-plugins in any open session.

Source 4 files
hooks/register.tsx 240 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput } from 'claude-code'
3
4import type { Breakdown, Git, Scan, Snapshot } from '../types'
5import { fromEngine, fromEstimate, parseShortstat, scanChunk } from './meter'
6import type { EngineRow } from './meter'
7import { Band, Pane } from './view'
8
9export { cells, estimateSplit, filled, fromEngine, fromEstimate, percentOf, scanChunk, share, splitMessages, until } from './meter'
10
11const PANE = 'usage-meter'
12const TITLE = 'Usage'
13const POLL_MS = 60_000
14// One read of the transcript takes at most this many bytes (under $.process.run's 4 MiB cap).
15const CHUNK = 4_000_000
16const CHUNKS_PER_REFRESH = 8
17
18const snapAtom = atom({ plugin: 'usage-meter', key: 'snap' } as const, null)
19const gitAtom = atom({ plugin: 'usage-meter', key: 'git' } as const, null)
20const breakdownAtom = atom({ plugin: 'usage-meter', key: 'breakdown' } as const, null)
21const scanAtom = atom({ plugin: 'usage-meter', key: 'scan' } as const, null)
22const transcriptAtom = atom({ plugin: 'usage-meter', key: 'transcript' } as const, null)
23
24type $ = EngineInterface
25
26export const register: Register = on => {
27  on('session.start', async ($, e, next) => {
28    const ran = await next(e)
29    await $.command.register({
30      name: 'usage-meter',
31      description: 'Open the usage pane: plan limits, cost and the context breakdown',
32    })
33    await refresh($)
34    try {
35      $.clock.every(POLL_MS, () => void refresh($))
36    } catch {
37      // No timers here (a test host): figures still refresh when a turn ends.
38    }
39    return ran
40  })
41
42  // The engine pushes its own figures after each main-thread turn and when a plan window moves.
43  on('session.measure', async ($, e, next) => {
44    await writeSnap($, e)
45    return next(e)
46  })
47
48  // A turn ended: git and the breakdown move too. Done on a timer so the turn's end is not held.
49  on('turn.complete', async ($, e, next) => {
50    const done = await next(e)
51    if (e.agentId === undefined) {
52      try {
53        $.clock.after(0, () => void refresh($))
54      } catch {
55        await refresh($)
56      }
57    }
58    return done
59  })
60
61  // The transcript's path, from the classic hooks' envelope.
62  on('classic.SessionStart', async ($, e, next) => (await notePath($, e.transcript_path), next(e))).catch(($, e, next) => next(e))
63  on('classic.UserPromptSubmit', async ($, e, next) => (await notePath($, e.transcript_path), next(e))).catch(($, e, next) => next(e))
64  on('classic.Stop', async ($, e, next) => (await notePath($, e.transcript_path), next(e))).catch(($, e, next) => next(e))
65
66  // Claude Code ships /usage, so the name cannot be registered: answer the built-in instead.
67  // `/usage builtin` still runs Claude Code's own; if this hook fails, the built-in runs.
68  on('command.run', { command: 'usage' }, async ($, e, next) => {
69    if (e.args.trim() === 'builtin') return next({ ...e, args: '' })
70    await openPane($)
71    return { text: 'Usage pane opened. `/usage builtin` runs Claude Code\'s own /usage.' }
72  }).catch(($, e, next) => next(e))
73
74  on('command.run', { command: 'usage-meter' }, async $ => {
75    await openPane($)
76    return { text: 'Usage pane opened.' }
77  })
78
79  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80    if (e.props.hasSurvey) return next(e)
81    const { Box, Text, Button } = $.ui.resolve(e)
82    const data = await gather($, e.props.bodyColumns)
83    if (data.snap === null) return next(e)
84    return Band({ Box, Text, Button }, data, () => void openPane($))
85  })
86
87  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88    const { Box, Text, Button } = $.ui.resolve(e)
89    return Pane({ Box, Text, Button }, await gather($, e.props.bodyColumns), () => void refresh($))
90  })
91}
92
93let running: Promise<void> | null = null
94let again = false
95
96/** One refresh at a time; a call that lands mid-refresh runs one more after it. */
97function refresh($: $): Promise<void> {
98  if (running) {
99    again = true
100    return running
101  }
102  running = (async () => {
103    do {
104      again = false
105      await Promise.all([refreshSnap($), refreshGit($)]).catch(() => undefined)
106      await refreshBreakdown($).catch(() => undefined)
107    } while (again)
108  })().finally(() => {
109    running = null
110  })
111  return running
112}
113
114async function openPane($: $) {
115  const opened = await $.ui.open({ id: PANE, title: TITLE })
116  void refresh($)
117  return opened
118}
119
120/** The transcript's path, from the classic hooks' envelope (each fires with or without a settings hook). */
121async function notePath($: $, path: string | undefined) {
122  if (path && path !== (await read($, transcriptAtom))) await update($, transcriptAtom, () => path)
123}
124
125async function gather($: $, columns: number) {
126  return {
127    snap: await read($, snapAtom),
128    git: await read($, gitAtom),
129    breakdown: await read($, breakdownAtom),
130    now: await $.clock.now(),
131    columns,
132  }
133}
134
135async function refreshSnap($: $) {
136  await writeSnap($, await $.session.usage())
137}
138
139/** The engine's figures, as they are: a figure it has not got stays absent (limits empty, cost null). */
140async function writeSnap($: $, u: Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'> & { startedAt?: number }) {
141  const model = await $.session.model()
142  const startedAt = u.startedAt ?? (await read($, snapAtom))?.startedAt ?? (await $.session.usage()).startedAt
143  const snap: Snapshot = {
144    model,
145    window: u.context.window,
146    limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) })),
147    costUsd: u.cost ? u.cost.usd : null,
148    startedAt,
149    measuredAt: await $.clock.now(),
150    ...(u.context.tokens !== undefined ? { tokens: u.context.tokens } : {}),
151    ...(u.context.percent !== undefined ? { percent: u.context.percent } : {}),
152  }
153  await update($, snapAtom, () => snap)
154}
155
156async function refreshGit($: $) {
157  const repo = await $.session.repo()
158  if (repo === null) {
159    await update($, gitAtom, () => null)
160    return
161  }
162  const [head, diff] = await Promise.all([
163    $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD', '--show-toplevel'], { timeoutMs: 5000 }),
164    $.process.run(['git', 'diff', 'HEAD', '--shortstat'], { timeoutMs: 5000 }),
165  ])
166  const [branch = '', top = ''] = head.stdout.trim().split('\n')
167  if (head.exitCode !== 0 || !branch) return
168  // The engine's repo root is the main working tree's; a different top level is a linked worktree.
169  const worktree = top && top !== repo.root ? (top.split('/').pop() ?? null) : null
170  const git: Git = { branch: branch === 'HEAD' ? 'detached' : branch, worktree, ...parseShortstat(diff.stdout) }
171  await update($, gitAtom, () => git)
172}
173
174async function refreshBreakdown($: $) {
175  const counts = await scanTranscript($)
176  const usage = await $.session.usage({ breakdown: 'summary' })
177  const b = usage.context.breakdown
178  let next: Breakdown | null
179  if (b && b.categories.length) {
180    const rows: EngineRow[] = b.categories.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
181    next = fromEngine(rows, counts, b.rawMaxTokens, b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null)
182  } else if (usage.context.tokens) {
183    next = fromEstimate(counts, usage.context.tokens, usage.context.window)
184  } else {
185    next = null
186  }
187  await update($, breakdownAtom, () => next)
188}
189
190/** Where the transcript is: the classic hooks' envelope, else the engine's projects folder by session id. */
191async function transcriptPath($: $): Promise<string | null> {
192  const known = await read($, transcriptAtom)
193  if (known) return known
194  const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
195  const slug = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
196  const guess = `${home}/projects/${slug}/${await $.session.id()}.jsonl`
197  return (await $.fs.exists(guess)) ? guess : null
198}
199
200/**
201 * Characters per kind since the last compaction. Read only from where the last read stopped, and not
202 * at all when the file's size has not changed: usage_bars.py's cache, kept in the session's state.
203 */
204async function scanTranscript($: $): Promise<Record<string, number>> {
205  const path = await transcriptPath($)
206  if (!path) return {}
207  const prev = await read($, scanAtom)
208  let size: number
209  try {
210    size = (await $.fs.stat(path)).size
211  } catch {
212    return prev?.counts ?? {}
213  }
214  const base: Scan = prev && prev.path === path && prev.offset <= size ? prev : { path, offset: 0, counts: {} }
215  if (base.offset === size) return base.counts
216
217  const counts = { ...base.counts }
218  let offset = base.offset
219  for (let i = 0; i < CHUNKS_PER_REFRESH && offset < size; i++) {
220    const r = await $.process.run(['/bin/sh', '-c', 'tail -c +"$1" "$2" | head -c "$3"', 'sh', String(offset + 1), path, String(CHUNK)], {
221      timeoutMs: 10_000,
222    })
223    if (r.exitCode !== 0) break
224    const n = scanChunk(r.stdout, counts)
225    if (n > 0) {
226      offset += n
227      continue
228    }
229    // A single line longer than a chunk: step over it uncounted, or wait if it is still being written.
230    if (r.stdout.length < CHUNK - 4) break
231    const w = await $.process.run(['/bin/sh', '-c', 'tail -c +"$1" "$2" | head -n 1 | wc -c', 'sh', String(offset + 1), path])
232    const skip = Number(w.stdout.trim())
233    if (!skip) break
234    offset += skip
235  }
236  const scan: Scan = { path, offset, counts }
237  await update($, scanAtom, () => scan)
238  return counts
239}
240
hooks/meter.ts 309 lines
1// The sums behind the meter: a port of ~/.claude/statusline/usage_bars.py (classify, measure,
2// segments, until), plus the step that fits its transcript split to the engine's own Messages row.
3// Pure functions, so tests can hold them without an engine.
4
5import type { Breakdown, Limit, Part } from '../types'
6
7export const CHARS_PER_TOKEN = 3.7
8
9// usage_bars.py's 256-colour palette, as hex.
10export const COLORS = {
11  system: '#808080', // 244
12  rules: '#af87ff', // 141
13  skills: '#ff87ff', // 213
14  summary: '#87afaf', // 109
15  you: '#00afff', // 39
16  claude: '#ff8700', // 208
17  tools: '#ffd700', // 220
18  agents: '#5fd787', // 78
19  other: '#5f8787', // 66
20  green: '#5fd787', // 78
21  yellow: '#ffd700', // 220
22  red: '#ff0000', // 196
23} as const
24
25/** The kinds usage_bars.py sorts the transcript into, in its order. */
26export const KINDS = ['system', 'rules', 'skills', 'summary', 'you', 'claude', 'tools', 'agents', 'other'] as const
27export type Kind = (typeof KINDS)[number]
28
29/** Kinds the conversation itself holds: what the engine's Messages row is made of. */
30const CONVERSATION: readonly Kind[] = ['summary', 'you', 'claude', 'tools', 'agents', 'skills', 'other']
31
32// Attachment types -> count key. `listing` and `rules` are kept apart from `skills` so the engine's
33// own Skills and Memory files rows are not counted twice; with no engine breakdown they fold back
34// into usage_bars.py's kinds (listing -> skills).
35const ATTACH: Record<string, string> = {
36  skill_listing: 'listing',
37  invoked_skills: 'skills',
38  instructions: 'rules',
39  output_style: 'rules',
40  output_style_instructions: 'rules',
41  queued_command: 'agents',
42}
43const SKIP_ATTACH = new Set(['prompt_snapshot'])
44
45type Entry = Record<string, unknown>
46
47const size = (x: unknown): number => (typeof x === 'string' ? x.length : (JSON.stringify(x ?? null) ?? '').length)
48
49const add = (counts: Record<string, number>, key: string, n: number) => {
50  counts[key] = (counts[key] ?? 0) + n
51}
52
53/** Adds one transcript entry's characters to its kind, as usage_bars.py's classify() does. */
54export function classify(d: Entry, counts: Record<string, number>): void {
55  const t = d.type
56  if (t === 'attachment') {
57    const a = (d.attachment ?? {}) as Entry
58    const at = String(a.type ?? '')
59    if (SKIP_ATTACH.has(at)) return
60    add(counts, ATTACH[at] ?? 'other', size(a))
61    return
62  }
63  const msg = (d.message ?? {}) as Entry
64  const content = msg.content
65  if (t === 'user') {
66    let kind: string
67    if (d.isCompactSummary) {
68      kind = 'summary'
69    } else if (d.isMeta) {
70      const s = typeof content === 'string' ? content : (JSON.stringify(content) ?? '')
71      kind = s.includes('Base directory for this skill')
72        ? 'skills'
73        : s.includes('<agent-message') || s.includes('<task-notification')
74          ? 'agents'
75          : 'other'
76    } else if (Array.isArray(content) && content.some(b => isObj(b) && b.type === 'tool_result')) {
77      kind = 'tools'
78    } else {
79      kind = 'you'
80    }
81    add(counts, kind, size(content || ''))
82  } else if (t === 'assistant' && Array.isArray(content)) {
83    for (const b of content) {
84      if (!isObj(b)) continue
85      if (b.type === 'tool_use') {
86        add(counts, 'tools', size(b.input ?? {}))
87      } else {
88        const text = b.text || b.thinking || ''
89        add(counts, 'claude', typeof text === 'string' ? text.length : 0)
90      }
91    }
92  }
93}
94
95const isObj = (x: unknown): x is Entry => typeof x === 'object' && x !== null && !Array.isArray(x)
96
97const encoder = new TextEncoder()
98
99/**
100 * Reads the whole lines of a chunk of the transcript into `counts` and answers how many bytes they
101 * took, so the next read starts after them. A last line with no newline is still being written and
102 * is left for next time. A compaction boundary starts the counts over; subagent lines are skipped.
103 */
104export function scanChunk(text: string, counts: Record<string, number>): number {
105  let consumed = 0
106  let start = 0
107  for (;;) {
108    const nl = text.indexOf('\n', start)
109    if (nl < 0) break
110    const line = text.slice(start, nl)
111    consumed += encoder.encode(line).length + 1
112    start = nl + 1
113    let d: unknown
114    try {
115      d = JSON.parse(line)
116    } catch {
117      continue
118    }
119    if (!isObj(d)) continue
120    if (d.type === 'system' && d.subtype === 'compact_boundary') {
121      for (const k of Object.keys(counts)) delete counts[k]
122      continue
123    }
124    if (d.isSidechain) continue
125    classify(d, counts)
126  }
127  return consumed
128}
129
130const est = (counts: Record<string, number>, key: string) => (counts[key] ?? 0) / CHARS_PER_TOKEN
131
132const part = (key: Kind, tokens: number): Part => ({
133  key,
134  label: key,
135  tokens,
136  color: COLORS[key],
137  source: 'estimate',
138  kind: 'used',
139})
140
141/**
142 * Splits the engine's Messages row by kind: the transcript estimate's shape, scaled so the kinds add
143 * up to exactly the engine's figure. With nothing read yet, the row stays whole.
144 */
145export function splitMessages(counts: Record<string, number>, messages: number): Part[] {
146  const raw = CONVERSATION.map(k => [k, est(counts, k)] as const).filter(([, v]) => v > 0)
147  const total = raw.reduce((s, [, v]) => s + v, 0)
148  if (total <= 0 || messages <= 0) {
149    return messages > 0 ? [{ key: 'messages', label: 'messages', tokens: messages, color: COLORS.other, source: 'engine', kind: 'used' }] : []
150  }
151  const scale = messages / total
152  return raw.map(([k, v]) => part(k, v * scale))
153}
154
155/**
156 * usage_bars.py's measure(), for when the engine has no breakdown: the transcript estimate fitted
157 * to the real used total, the remainder being "system". An estimate that overshoots keeps its shape
158 * and leaves a sliver for the system prompt.
159 */
160export function estimateSplit(counts: Record<string, number>, used: number): Part[] {
161  const tokens: Partial<Record<Kind, number>> = {}
162  for (const k of KINDS) {
163    const v = est(counts, k) + (k === 'skills' ? est(counts, 'listing') : 0)
164    if (v > 0) tokens[k] = v
165  }
166  let sum = Object.values(tokens).reduce((s, v) => s + (v ?? 0), 0)
167  if (sum && sum > used * 0.95) {
168    const scale = (used * 0.9) / sum
169    for (const k of Object.keys(tokens) as Kind[]) tokens[k] = (tokens[k] ?? 0) * scale
170    sum = Object.values(tokens).reduce((s, v) => s + (v ?? 0), 0)
171  }
172  tokens.system = Math.max(used - sum, 0)
173  return KINDS.filter(k => (tokens[k] ?? 0) > 0).map(k => part(k, tokens[k] ?? 0))
174}
175
176/** An engine /context row, as the engine has it, in usage_bars.py's colours where it is one of its kinds. */
177export type EngineRow = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' | 'deferred' }
178
179// Engine rows -> usage_bars.py kinds, by the label /context prints. The engine's own theme colour
180// stands for any row not listed here.
181const ROWS: [RegExp, string, string][] = [
182  [/^system prompt/i, 'system', COLORS.system],
183  [/^system tools/i, 'system tools', '#949494'],
184  [/^mcp/i, 'mcp tools', '#6c6c6c'],
185  [/agent/i, 'custom agents', '#5faf87'],
186  [/memory/i, 'rules', COLORS.rules],
187  [/skill/i, 'skill listing', COLORS.skills],
188]
189
190/** Builds the breakdown from the engine's /context rows, splitting its Messages row by the transcript. */
191export function fromEngine(
192  rows: readonly EngineRow[],
193  counts: Record<string, number>,
194  window: number,
195  autoCompactAt: number | null,
196): Breakdown {
197  const parts: Part[] = []
198  for (const row of rows) {
199    if (row.kind === 'deferred' || row.tokens <= 0) continue
200    if (row.kind === 'used' && /message/i.test(row.name)) {
201      parts.push(...splitMessages(counts, row.tokens))
202      continue
203    }
204    const hit = ROWS.find(([re]) => re.test(row.name))
205    parts.push({
206      key: row.name,
207      label: row.kind === 'used' ? (hit?.[1] ?? row.name.toLowerCase()) : row.name.toLowerCase(),
208      tokens: row.tokens,
209      color: row.kind === 'used' ? (hit?.[2] ?? row.color) : 'inactive',
210      source: 'engine',
211      kind: row.kind,
212    })
213  }
214  const used = parts.filter(p => p.kind === 'used').reduce((s, p) => s + p.tokens, 0)
215  return { parts, used, window, autoCompactAt, basis: 'engine' }
216}
217
218/** The breakdown with no engine rows: usage_bars.py's split of the real used total. */
219export function fromEstimate(counts: Record<string, number>, used: number, window: number): Breakdown {
220  return { parts: estimateSplit(counts, used), used, window, autoCompactAt: null, basis: 'estimate' }
221}
222
223// ---- percent maths ----
224
225export const clampPct = (p: number) => Math.max(0, Math.min(100, Number.isFinite(p) ? p : 0))
226
227/** `part` over `whole` as a percentage; 0 for an empty whole. */
228export const percentOf = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0)
229
230/** A kind's share of the used part, as usage_bars.py prints it: whole percent, never under 1. */
231export const share = (tokens: number, used: number) => Math.max(1, Math.round(percentOf(tokens, used)))
232
233/** Filled cells of a `width`-cell bar at `pct`. */
234export const filled = (pct: number, width: number) => Math.round((clampPct(pct) / 100) * width)
235
236/**
237 * Cells per value, rounded by largest remainder so they add up to exactly `round(sum / scaleTo *
238 * width)` (never past `width`): usage_bars.py's segments().
239 */
240export function cells(values: readonly number[], scaleTo: number, width: number): number[] {
241  if (scaleTo <= 0 || width <= 0) return values.map(() => 0)
242  const exact = values.map(v => (Math.max(0, v) / scaleTo) * width)
243  const want = Math.min(width, Math.round(values.reduce((s, v) => s + Math.max(0, v), 0) / scaleTo * width))
244  const whole = exact.map(Math.floor)
245  const order = exact.map((c, i) => i).sort((a, b) => exact[b]! - whole[b]! - (exact[a]! - whole[a]!))
246  let missing = want - whole.reduce((s, n) => s + n, 0)
247  for (const i of order) {
248    if (missing <= 0) break
249    whole[i]! += 1
250    missing -= 1
251  }
252  return whole
253}
254
255/** usage_bars.py's colour for a fill: green under 50, yellow under 80, red past it. */
256export const pctColor = (p: number) => (p < 50 ? COLORS.green : p < 80 ? COLORS.yellow : COLORS.red)
257
258// ---- words ----
259
260export function short(n: number): string {
261  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
262  if (n >= 1000) return `${Math.round(n / 1000)}k`
263  return String(Math.trunc(n))
264}
265
266const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
267
268/** Time until a reset, as usage_bars.py's until(): `2h05m`, `40m`, `now`, or past a day `Fri 9am`. */
269export function until(resetsAt: string | undefined, now: number): string {
270  if (!resetsAt) return ''
271  const at = Date.parse(resetsAt)
272  if (Number.isNaN(at)) return ''
273  const s = (at - now) / 1000
274  if (s <= 0) return 'now'
275  const h = Math.floor(s / 3600)
276  const m = Math.floor((s % 3600) / 60)
277  if (h >= 24) {
278    const d = new Date(at)
279    const hr = d.getHours()
280    return `${DAYS[d.getDay()]} ${hr % 12 || 12}${hr < 12 ? 'am' : 'pm'}`
281  }
282  return h ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
283}
284
285/** Session clock, as ccstatusline's: `1h07m`, `12m`. */
286export function elapsed(ms: number): string {
287  const total = Math.max(0, Math.floor(ms / 60_000))
288  const h = Math.floor(total / 60)
289  const m = total % 60
290  return h ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
291}
292
293export const money = (usd: number | null) => (usd === null ? 'n/a' : `$${usd.toFixed(2)}`)
294
295/** The two plan windows the CLI shows, by the engine's kind; undefined where it has no reading. */
296export function planWindows(limits: readonly Limit[]): { label: string; limit: Limit | undefined }[] {
297  return [
298    { label: '5h', limit: limits.find(l => l.kind === 'five_hour') },
299    { label: 'wk', limit: limits.find(l => l.kind === 'seven_day') },
300  ]
301}
302
303/** `git diff --shortstat` -> lines added and removed. */
304export function parseShortstat(out: string): { added: number; removed: number } {
305  const added = /(\d+) insertion/.exec(out)
306  const removed = /(\d+) deletion/.exec(out)
307  return { added: added ? Number(added[1]) : 0, removed: removed ? Number(removed[1]) : 0 }
308}
309
hooks/view.tsx 233 lines
1import type { ElementTable } from 'claude-code'
2
3import type { Breakdown, Git, Part, Snapshot } from '../types'
4import { cells, COLORS, elapsed, filled, money, pctColor, percentOf, planWindows, share, short, until } from './meter'
5
6export type El = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
7
8export const MUTED = '#a8a8a8'
9const FREE = 'inactive'
10
11export type Data = {
12  snap: Snapshot | null
13  git: Git | null
14  breakdown: Breakdown | null
15  now: number
16  columns: number
17}
18
19/** A bar of `width` cells at `pct`, in the fill's colour. */
20function PctBar(el: El, pct: number, width: number) {
21  const { Text } = el
22  const n = filled(pct, width)
23  return (
24    <Text>
25      <Text color={pctColor(pct)}>{'█'.repeat(n)}</Text>
26      <Text color={FREE}>{'░'.repeat(width - n)}</Text>
27    </Text>
28  )
29}
30
31/** A bar of `width` cells, a colour per used part, scaled to `scaleTo` tokens: usage_bars.py's segments(). */
32function KindBar(el: El, parts: readonly Part[], scaleTo: number, width: number) {
33  const { Text } = el
34  const used = parts.filter(p => p.kind === 'used')
35  const n = cells(
36    used.map(p => p.tokens),
37    scaleTo,
38    width,
39  )
40  const rest = width - n.reduce((s, c) => s + c, 0)
41  return (
42    <Text>
43      {used.map((p, i) => (n[i] ? <Text color={p.color}>{'█'.repeat(n[i]!)}</Text> : null))}
44      <Text color={FREE}>{'░'.repeat(Math.max(0, rest))}</Text>
45    </Text>
46  )
47}
48
49/**
50 * The token count a window bar of the breakdown is scaled to, so its fill matches the engine's
51 * percent beside it: the breakdown's parts may add up to a little more or less than the engine's
52 * live figure, and measure against the compaction window rather than the model's.
53 */
54export const kindScale = (b: Breakdown, snap: Snapshot) => (snap.tokens ? (b.used * snap.window) / snap.tokens : b.window)
55
56const Sep = (el: El) => <el.Text color={MUTED}> │ </el.Text>
57
58/** The CLI's line 1, essentials first: model, context %, plan limits, cost, branch. Wider bands add bars, resets, changes and the clock. */
59export function Band(el: El, d: Data, onOpen: () => void) {
60  const { Box, Text, Button } = el
61  const { snap, git, breakdown, now, columns } = d
62  const wide = columns >= 120
63  const mid = columns >= 90
64  const pct = snap?.percent
65
66  const items = [
67    <Text color="cyan">{snap?.model ?? '…'}</Text>,
68    <Text>
69      ctx{' '}
70      {wide && breakdown && snap
71        ? [KindBar(el, breakdown.parts, kindScale(breakdown, snap), 12), ' ']
72        : mid && pct !== undefined
73          ? [PctBar(el, pct, 8), ' ']
74          : null}
75      {pct === undefined ? <Text color={MUTED}>–</Text> : <Text color={pctColor(pct)}>{pct}%</Text>}
76      {wide && snap?.tokens !== undefined ? <Text color={MUTED}> {short(snap.tokens)}/{short(snap.window)}</Text> : null}
77    </Text>,
78    ...planWindows(snap?.limits ?? []).map(({ label, limit }) =>
79      limit === undefined ? (
80        <Text color={MUTED}>{label} n/a</Text>
81      ) : (
82        <Text>
83          {label} {mid ? [PctBar(el, limit.percentUsed, 6), ' '] : null}
84          <Text color={pctColor(limit.percentUsed)}>{Math.round(limit.percentUsed)}%</Text>
85          {mid && limit.resetsAt ? <Text color={MUTED}> ↻{until(limit.resetsAt, now)}</Text> : null}
86        </Text>
87      ),
88    ),
89    <Text color={snap?.costUsd == null ? MUTED : undefined}>{snap ? money(snap.costUsd) : '$ n/a'}</Text>,
90    git ? (
91      <Text>
92        <Text color="magenta">⎇ {git.branch}</Text>
93        {wide && git.worktree ? <Text color={MUTED}> ({git.worktree})</Text> : null}
94        {mid && (git.added || git.removed) ? (
95          <Text color="yellow">
96            {' '}
97            +{git.added} −{git.removed}
98          </Text>
99        ) : null}
100      </Text>
101    ) : null,
102    wide && snap ? <Text color={MUTED}>{elapsed(now - snap.startedAt)}</Text> : null,
103  ].filter(x => x !== null)
104
105  return (
106    <Box flexDirection="row" flexWrap="wrap" justifyContent="space-between">
107      <Text wrap="truncate-end">{items.flatMap((x, i) => (i ? [Sep(el), x] : [x]))}</Text>
108      <Button key="open" label="usage" plain onPress={onOpen} />
109    </Box>
110  )
111}
112
113/** The full context breakdown, plan limits and git: what /usage opens. */
114export function Pane(el: El, d: Data, onRefresh: () => void) {
115  const { Box, Text, Button } = el
116  const { snap, git, breakdown, now, columns } = d
117  const barWidth = Math.max(10, Math.min(60, columns - 24))
118
119  const header = (
120    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
121      <Text bold color="cyan">
122        {snap?.model ?? '…'}
123      </Text>
124      <Text>
125        cost <Text color={snap?.costUsd == null ? MUTED : undefined}>{snap ? money(snap.costUsd) : 'n/a'}</Text>
126      </Text>
127      {snap ? <Text color={MUTED}>session {elapsed(now - snap.startedAt)}</Text> : null}
128      <Button key="refresh" label="refresh" plain onPress={onRefresh} />
129    </Box>
130  )
131
132  const limits = (
133    <Box flexDirection="column">
134      <Text bold>Plan limits</Text>
135      {planWindows(snap?.limits ?? []).map(({ label, limit }) => (
136        <Box flexDirection="row" columnGap={1}>
137          <Box width={3}>
138            <Text>{label}</Text>
139          </Box>
140          {limit === undefined ? (
141            <Text color={MUTED}>n/a (the engine has no reading for this window)</Text>
142          ) : (
143            <Text>
144              {PctBar(el, limit.percentUsed, 20)} <Text color={pctColor(limit.percentUsed)}>{limit.percentUsed.toFixed(0)}%</Text>
145              {limit.resetsAt ? <Text color={MUTED}> resets in {until(limit.resetsAt, now)}</Text> : null}
146            </Text>
147          )}
148        </Box>
149      ))}
150    </Box>
151  )
152
153  const context = snap ? (
154    <Box flexDirection="column">
155      <Text bold>Context</Text>
156      <Text>
157        {breakdown ? KindBar(el, breakdown.parts, kindScale(breakdown, snap), barWidth) : PctBar(el, snap.percent ?? 0, barWidth)}{' '}
158        {snap.tokens === undefined ? (
159          <Text color={MUTED}>no response yet</Text>
160        ) : (
161          <Text>
162            {short(snap.tokens)}/{short(snap.window)} <Text color={pctColor(snap.percent ?? 0)}>{snap.percent ?? 0}%</Text>
163          </Text>
164        )}
165      </Text>
166    </Box>
167  ) : (
168    <Text color={MUTED}>Waiting for the engine's first measurement.</Text>
169  )
170
171  return (
172    <Box flexDirection="column" rowGap={1}>
173      {header}
174      {limits}
175      {context}
176      {breakdown ? Breakdown_(el, breakdown, barWidth) : null}
177      {git ? (
178        <Text>
179          <Text color="magenta">⎇ {git.branch}</Text>
180          {git.worktree ? <Text color={MUTED}> · worktree {git.worktree}</Text> : null}
181          <Text color="yellow">
182            {' '}
183            · +{git.added} −{git.removed}
184          </Text>
185        </Text>
186      ) : null}
187    </Box>
188  )
189}
190
191function Breakdown_(el: El, b: Breakdown, barWidth: number) {
192  const { Box, Text } = el
193  const used = b.parts.filter(p => p.kind === 'used')
194  const other = b.parts.filter(p => p.kind !== 'used')
195  const hasEstimate = b.parts.some(p => p.source === 'estimate')
196
197  const row = (p: Part) => (
198    <Box flexDirection="row" columnGap={1}>
199      <Text color={p.kind === 'used' ? p.color : FREE}>■</Text>
200      <Box width={16}>
201        <Text color={p.kind === 'used' ? undefined : MUTED} wrap="truncate-end">
202          {p.label}
203        </Text>
204      </Box>
205      <Box width={7}>
206        <Text>{short(p.tokens)}</Text>
207      </Box>
208      <Box width={5}>
209        <Text color={MUTED}>{p.kind === 'used' ? `${share(p.tokens, b.used)}%` : `${Math.round(percentOf(p.tokens, b.window))}%`}</Text>
210      </Box>
211      {p.source === 'estimate' ? <Text color={MUTED}>est.</Text> : null}
212    </Box>
213  )
214
215  return (
216    <Box flexDirection="column">
217      <Text bold>Breakdown of the used part</Text>
218      <Text>
219        {KindBar(el, used, b.used, barWidth)} {short(b.used)}
220      </Text>
221      {used.map(row)}
222      {other.length ? <Text color={MUTED}>of the {short(b.window)} window:</Text> : null}
223      {other.map(row)}
224      {b.autoCompactAt ? <Text color={MUTED}>auto-compacts at {short(b.autoCompactAt)}</Text> : null}
225      <Text color={MUTED} wrap="wrap">
226        {b.basis === 'engine'
227          ? `Rows are the engine's /context counts (its own local estimate). ${hasEstimate ? 'Rows marked est. split its Messages row by kind from the transcript (3.7 chars a token), scaled to that row.' : ''}`
228          : 'Estimate: the engine gave no breakdown, so this is usage_bars.py\'s split of the real used total (transcript at 3.7 chars a token, the rest as system).'}
229      </Text>
230    </Box>
231  )
232}
233
types/index.d.ts 68 lines
1/** One plan-limit window as the engine reports it (`$.session.usage().rateLimits`). */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4/** The line-1 figures. Everything here is the engine's own value, or absent / null when it has none. */
5export type Snapshot = {
6  model: string
7  /** Input tokens the last response was answered over; absent before the first response. */
8  tokens?: number
9  /** The model's context window in tokens. */
10  window: number
11  /** tokens / window as a whole percentage; absent before the first response. */
12  percent?: number
13  /** Empty when the engine has no reading (off a subscription, or before the first response). */
14  limits: Limit[]
15  /** US dollars; null where the host keeps no cost ledger. */
16  costUsd: number | null
17  /** When the session began, epoch ms. */
18  startedAt: number
19  measuredAt: number
20}
21
22export type Git = {
23  branch: string
24  /** The linked worktree's folder name; null in the main working tree. */
25  worktree: string | null
26  added: number
27  removed: number
28}
29
30/** One row of the context breakdown, in tokens. */
31export type Part = {
32  key: string
33  label: string
34  tokens: number
35  /** Hex colour, from usage_bars.py's palette where the kind is one of its kinds. */
36  color: string
37  /** engine: the engine's /context row. estimate: the transcript split, fitted to the engine's Messages row (or, with no breakdown, to the used total). */
38  source: 'engine' | 'estimate'
39  /** used content, or the window left / the compaction reserve, which the used bar leaves out. */
40  kind: 'used' | 'free' | 'buffer'
41}
42
43export type Breakdown = {
44  parts: Part[]
45  /** Used tokens the parts add up to. */
46  used: number
47  /** The window the engine's breakdown measures against (the compaction window when one is set). */
48  window: number
49  autoCompactAt: number | null
50  /** engine: the engine's /context breakdown was available. estimate: it was not, and the split is usage_bars.py's alone. */
51  basis: 'engine' | 'estimate'
52}
53
54/** Characters per kind read from the transcript since the last compaction, cached by file size. */
55export type Scan = { path: string; offset: number; counts: Record<string, number> }
56
57declare module 'claude-code' {
58  interface PluginState {
59    'usage-meter': {
60      snap: Snapshot | null
61      git: Git | null
62      breakdown: Breakdown | null
63      scan: Scan | null
64      transcript: string | null
65    }
66  }
67}
68