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

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.
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.
/usage or /usage-meter opens the pane. /usage builtin runs Claude Code's own /usage.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.
claude plugin update usage-meter
Then run /reload-plugins in any open session.
hooks/register.tsx 240 lines1import { 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}
240hooks/meter.ts 309 lines1// 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}
309hooks/view.tsx 233 lines1import 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}
233types/index.d.ts 68 lines1/** 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