Band above the prompt: plan usage bars with reset times, session tokens, estimated spend, and CPU/RAM/disk.

A band above the Claude Code prompt that shows:
nvidia-smi) and free disk space, sampled every 15 seconds/usage-meter shows or hides it. compact shrinks it to one line.
In a terminal Claude Code session (version 2.1.293 or later):
/plugin install usage-meter --marketplace danielalvesvm99/usage-meter
Answer y to add the marketplace, then pick the user scope so it loads in every session, including the Claude desktop app's Code tab.
hooks/register.tsx 368 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionRateLimit, SessionCost } from 'claude-code'
3
4import type { SysSnap, TokenTotals, UsageSnap } from '../types'
5import {
6 PALETTE,
7 SYS_SCRIPT,
8 elapsedShare,
9 fmtLocal,
10 fmtResetShort,
11 fmtTokens,
12 fmtUsd,
13 cardRows,
14 paceBar,
15 pickLayout,
16 systemRows,
17 parseSys,
18 segmentBar,
19 svgPace,
20 svgStack,
21 toneHex,
22 windowLabel,
23 windowOrder,
24} from './format'
25import type { Run } from './format'
26
27const EMPTY_TOKENS: TokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 }
28const SAMPLE_MS = 15_000
29
30const usage = atom({ plugin: 'usage-meter', key: 'usage' } as const, null)
31const tokens = atom({ plugin: 'usage-meter', key: 'tokens' } as const, EMPTY_TOKENS)
32const sys = atom({ plugin: 'usage-meter', key: 'sys' } as const, null)
33const sysError = atom({ plugin: 'usage-meter', key: 'sysError' } as const, null)
34const isHidden = atom({ plugin: 'usage-meter', key: 'isHidden' } as const, false)
35const isCompact = atom({ plugin: 'usage-meter', key: 'isCompact' } as const, false)
36
37function snapOf(rateLimits: SessionRateLimit[], cost: SessionCost | undefined, at: number): UsageSnap {
38 return {
39 windows: rateLimits
40 .map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
41 .sort((a, b) => windowOrder(a.kind) - windowOrder(b.kind)),
42 costUsd: cost?.usd,
43 at,
44 }
45}
46
47let isSampling = false
48
49async function sample($: EngineInterface) {
50 const now = await $.clock.now()
51 const current = await $.session.usage()
52 await update($, usage, () => snapOf(current.rateLimits, current.cost, now))
53
54 if (isSampling) return
55 isSampling = true
56 try {
57 const ran = await $.process.run(
58 ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', SYS_SCRIPT],
59 { timeoutMs: 20_000 },
60 )
61 const snap: SysSnap | null = ran.exitCode === 0 ? parseSys(ran.stdout, now) : null
62 if (snap) {
63 await update($, sys, () => snap)
64 await update($, sysError, () => null)
65 } else {
66 await update($, sysError, () => (ran.stderr.trim().split('\n')[0] || 'no output').slice(0, 120))
67 }
68 } catch (error) {
69 await update($, sysError, () => String(error).slice(0, 120))
70 } finally {
71 isSampling = false
72 }
73}
74
75const pct = (n: number) => `${Math.round(n)}%`
76
77export const register: Register = on => {
78 on('session.start', async ($, e, next) => {
79 await $.command.register({
80 name: 'usage-meter',
81 description: 'Show or hide the usage meter band above the prompt',
82 })
83 void sample($)
84 $.clock.every(SAMPLE_MS, () => void sample($))
85
86 return next(e)
87 })
88
89 on('command.run', { command: 'usage-meter' }, async $ => {
90 const hidden = await update($, isHidden, value => !value)
91
92 return { text: hidden ? 'Usage meter hidden. Run /usage-meter to show it again.' : 'Usage meter shown.' }
93 })
94
95 on('session.measure', async ($, e, next) => {
96 const now = await $.clock.now()
97 await update($, usage, () => snapOf(e.rateLimits, e.cost, now))
98
99 return next(e)
100 })
101
102 on('turn.complete', async ($, e, next) => {
103 const counted = e.usage
104 if (counted) {
105 await update($, tokens, total => ({
106 input: total.input + counted.input_tokens,
107 output: total.output + counted.output_tokens,
108 cacheRead: total.cacheRead + counted.cache_read_input_tokens,
109 cacheWrite: total.cacheWrite + counted.cache_creation_input_tokens,
110 turns: total.turns + 1,
111 }))
112 }
113
114 return next(e)
115 })
116
117 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
118 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
119
120 const els = $.ui.resolve(e)
121 const { Box, Button, Text } = els
122 // The terminal draws no Svg (and its table may still name one): bars there are text.
123 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : null
124 const snap = await read($, usage)
125 const totals = await read($, tokens)
126 const machine = await read($, sys)
127 const machineError = await read($, sysError)
128
129 const now = machine?.at ?? snap?.at ?? 0
130 const tz = machine?.tzOffsetMin ?? 0
131 const windows = snap?.windows ?? []
132 const allTokens = totals.input + totals.output + totals.cacheRead + totals.cacheWrite
133 const memPct = machine ? (machine.memUsedGB / Math.max(machine.memTotalGB, 0.001)) * 100 : 0
134 const cols = Math.max(40, e.props.bodyColumns)
135 const inner = Math.max(10, cols - 4)
136
137 const machineMeters = machine
138 ? [
139 { label: 'cpu', value: machine.cpu ?? 0, detail: 'load' },
140 { label: 'ram', value: memPct, detail: `${machine.memUsedGB.toFixed(1)} / ${machine.memTotalGB.toFixed(0)} GB` },
141 ...(machine.gpu
142 ? [
143 {
144 label: 'gpu',
145 value: machine.gpu.util,
146 detail: `${machine.gpu.memUsedGB.toFixed(1)} GB${machine.gpu.tempC === null ? '' : ` · ${machine.gpu.tempC}°C`}`,
147 },
148 ]
149 : []),
150 ...machine.disks.map(d => ({
151 label: d.id,
152 value: 100 - (d.freeGB / Math.max(d.sizeGB, 0.001)) * 100,
153 detail: `${d.freeGB.toFixed(0)} GB free`,
154 })),
155 ]
156 : []
157
158 // Layout: two cards above a full-width system card; stepping down to a
159 // slim one-row system card, then to one line in all, when rows run short.
160 const perRow = cols >= 86 ? 2 : 1
161 const cardW = Math.floor((cols - (perRow - 1)) / perRow)
162 const cardInner = Math.max(10, cardW - 4)
163 const meterGap = 3
164 const perLine = Math.max(1, Math.min(machineMeters.length || 1, Math.floor((inner + meterGap) / (18 + meterGap))))
165 const meterW = Math.floor((inner - meterGap * (perLine - 1)) / perLine)
166 const fitted = pickLayout(
167 cardRows(windows.length, perRow),
168 systemRows(machineMeters.length, perLine),
169 e.props.maxRows,
170 )
171 const layout = (await read($, isCompact)) ? 'compact' : fitted
172
173 const textBar = (runs: Run[]) =>
174 runs.map(r => (
175 <Text color={r.color} backgroundColor={r.backgroundColor}>
176 {r.text}
177 </Text>
178 ))
179 // One meter: an Svg sized to its box where the surface has one, cells of text otherwise.
180 const meter = (value: number, elapsed: number | null, cells: number, label: string) =>
181 Svg ? (
182 <Box width={cells} height={1} alignItems="center">
183 <Svg source={svgPace(value, elapsed, cells, toneHex(value))} alt={`${label}: ${pct(value)} used`} height={8} />
184 </Box>
185 ) : (
186 <Box flexDirection="row">{textBar(paceBar(value, elapsed, cells, toneHex(value)))}</Box>
187 )
188 const pill = (text: string, bg: string) => (
189 <Text backgroundColor={bg} color={PALETTE.ink} bold>
190 {` ${text} `}
191 </Text>
192 )
193 const diamond = <Text color={PALETTE.accent}>◆ </Text>
194 const updated = now ? `updated ${fmtLocal(now, tz, false)}` : 'sampling…'
195
196 const controls = (
197 <Box flexDirection="row" gap={2} flexShrink={0}>
198 <Button key="compact" plain label={layout === 'compact' ? 'expand' : 'compact'} onPress={() => update($, isCompact, v => !v)} />
199 <Button key="hide" plain role="dismiss" label="hide" onPress={() => update($, isHidden, () => true)} />
200 </Box>
201 )
202
203 // A row of small inline meters: the compact band, and the slim system card.
204 const inlineMeters = (items: { label: string; value: number; elapsed?: number | null }[]) =>
205 items.map(m => (
206 <Box flexDirection="row" gap={1} alignItems="center">
207 <Text dimColor>{m.label}</Text>
208 {meter(m.value, m.elapsed ?? null, 8, m.label)}
209 <Text color={toneHex(m.value)} bold>
210 {pct(m.value)}
211 </Text>
212 </Box>
213 ))
214
215 if (layout === 'compact') {
216 return (
217 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
218 <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center" flexShrink={1}>
219 <Box flexDirection="row">
220 {diamond}
221 <Text bold>usage</Text>
222 </Box>
223 {inlineMeters(
224 windows.map(w => ({
225 label: windowLabel(w.kind).toLowerCase(),
226 value: w.percentUsed,
227 elapsed: elapsedShare(w.kind, w.resetsAt, now),
228 })),
229 )}
230 <Box flexDirection="row" gap={1}>
231 <Text dimColor>tokens</Text>
232 <Text bold>{fmtTokens(allTokens)}</Text>
233 {snap?.costUsd !== undefined && <Text bold>{fmtUsd(snap.costUsd)}</Text>}
234 </Box>
235 {inlineMeters(machineMeters)}
236 </Box>
237 {controls}
238 </Box>
239 )
240 }
241
242 const header = (title: string, rest: string, right?: RenderChildren) => (
243 <Box flexDirection="row" justifyContent="space-between">
244 <Box flexDirection="row">
245 {diamond}
246 <Text bold>{title}</Text>
247 <Text dimColor>{rest}</Text>
248 </Box>
249 {right}
250 </Box>
251 )
252 const card = (key: string, width: number | string, children: RenderChildren[]) => (
253 <Box key={key} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} width={width} flexGrow={1}>
254 {children}
255 </Box>
256 )
257 // Label, value and detail on one line with the bar beneath: the row every card shares.
258 const labelRow = (label: string, value: number, detail: string) => (
259 <Box flexDirection="row" justifyContent="space-between">
260 <Box flexDirection="row">
261 <Text>{`${label} `}</Text>
262 <Text color={toneHex(value)} bold>
263 {pct(value)}
264 </Text>
265 </Box>
266 <Text dimColor wrap="truncate-end">
267 {detail}
268 </Text>
269 </Box>
270 )
271
272 const peak = windows.reduce((m, w) => Math.max(m, w.percentUsed), 0)
273 const limitsCard = card('card-limits', cardW, [
274 header(
275 'plan limits',
276 windows.length ? ' · tick = time elapsed' : '',
277 windows.length ? pill(pct(peak), toneHex(peak)) : undefined,
278 ),
279 windows.length === 0 && <Text dimColor>no reading yet · shows after the next reply</Text>,
280 ...windows.flatMap(w => [
281 labelRow(windowLabel(w.kind), w.percentUsed, fmtResetShort(w.resetsAt, now, tz)),
282 meter(w.percentUsed, elapsedShare(w.kind, w.resetsAt, now), cardInner, windowLabel(w.kind)),
283 ]),
284 ])
285
286 const parts = [
287 { label: 'cache read', value: totals.cacheRead, color: PALETTE.blue },
288 { label: 'cache write', value: totals.cacheWrite, color: PALETTE.violet },
289 { label: 'input', value: totals.input, color: PALETTE.teal },
290 { label: 'output', value: totals.output, color: PALETTE.orange },
291 ]
292 const half = Math.floor(cardInner / 2)
293 const legend = (p: (typeof parts)[number]) => (
294 <Box flexDirection="row" width={half}>
295 <Text color={p.color}>■ </Text>
296 <Text>{`${p.label} `}</Text>
297 <Text bold>{fmtTokens(p.value)}</Text>
298 <Text dimColor>{` ${allTokens ? pct((p.value / allTokens) * 100) : '0%'}`}</Text>
299 </Box>
300 )
301 const stack = Svg ? (
302 <Box width={cardInner} height={1} alignItems="center">
303 <Svg source={svgStack(parts, cardInner)} alt={`tokens: ${fmtTokens(allTokens)} in all`} height={10} />
304 </Box>
305 ) : (
306 <Box flexDirection="row">{textBar(segmentBar(parts, cardInner))}</Box>
307 )
308 const sessionCard = card('card-session', cardW, [
309 header(
310 'tokens',
311 ` ${fmtTokens(allTokens)} · ${totals.turns} turns`,
312 snap?.costUsd !== undefined ? pill(fmtUsd(snap.costUsd), PALETTE.accent) : undefined,
313 ),
314 stack,
315 <Box flexDirection="row">
316 {legend(parts[0]!)}
317 {legend(parts[1]!)}
318 </Box>,
319 <Box flexDirection="row">
320 {legend(parts[2]!)}
321 {legend(parts[3]!)}
322 </Box>,
323 <Text dimColor>spend ≈ API list price, not your plan bill</Text>,
324 ])
325
326 const machineBody = machine ? (
327 <Box flexDirection="row" flexWrap="wrap" columnGap={meterGap}>
328 {machineMeters.map(m => (
329 <Box flexDirection="column" width={meterW}>
330 {labelRow(m.label, m.value, m.detail)}
331 {meter(m.value, null, meterW, m.label)}
332 </Box>
333 ))}
334 </Box>
335 ) : (
336 <Text dimColor>{machineError ? `unavailable: ${machineError}` : 'sampling…'}</Text>
337 )
338 const systemCard = card('card-system', '100%', [header('system', ` · ${updated}`, controls), machineBody])
339
340 // The same card on one row of inline meters, for a slot too short for the full one.
341 const systemSlim = (
342 <Box key="card-system-slim" flexDirection="row" justifyContent="space-between" alignItems="center" borderStyle="round" borderColor="subtle" paddingX={1} width="100%">
343 <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center" flexShrink={1}>
344 <Box flexDirection="row">
345 {diamond}
346 <Text bold>system</Text>
347 </Box>
348 {machine ? inlineMeters(machineMeters) : <Text dimColor>{machineError ? `unavailable: ${machineError}` : 'sampling…'}</Text>}
349 </Box>
350 <Box flexDirection="row" gap={2} flexShrink={0}>
351 <Text dimColor>{updated}</Text>
352 {controls}
353 </Box>
354 </Box>
355 )
356
357 return (
358 <Box flexDirection="column">
359 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
360 {limitsCard}
361 {sessionCard}
362 </Box>
363 {layout === 'cards' ? systemCard : systemSlim}
364 </Box>
365 )
366 })
367}
368hooks/format.ts 323 lines1import type { SysSnap } from '../types'
2
3export const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
4export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
5
6export function bar(percent: number, width = 20): string {
7 const clamped = Math.max(0, Math.min(100, percent))
8 const filled = Math.round((clamped / 100) * width)
9
10 return '█'.repeat(filled) + '░'.repeat(width - filled)
11}
12
13export function tone(percent: number): 'success' | 'warning' | 'error' {
14 if (percent >= 85) return 'error'
15 if (percent >= 60) return 'warning'
16
17 return 'success'
18}
19
20// five_hour is the rolling session window (the closest thing to a "daily" bar);
21// seven_day is the weekly cap; seven_day_<model> is a per-model weekly cap.
22export function windowLabel(kind: string): string {
23 if (kind === 'five_hour') return '5-hour'
24 if (kind === 'seven_day') return 'Weekly'
25 if (kind === 'spend_limit') return 'Spend limit'
26 if (kind.startsWith('seven_day_')) {
27 const model = kind.slice('seven_day_'.length).replace(/_/g, ' ')
28
29 return `Weekly ${model.charAt(0).toUpperCase()}${model.slice(1)}`
30 }
31
32 return kind.replace(/_/g, ' ')
33}
34
35export function windowOrder(kind: string): number {
36 if (kind === 'five_hour') return 0
37 if (kind === 'seven_day') return 1
38 if (kind.startsWith('seven_day_')) return 2
39
40 return 3
41}
42
43export function fmtTokens(n: number): string {
44 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
45 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
46
47 return String(n)
48}
49
50export function fmtUsd(n: number): string {
51 return `$${n.toFixed(2)}`
52}
53
54export function fmtDuration(ms: number): string {
55 if (ms <= 0) return 'now'
56 const minutes = Math.round(ms / 60_000)
57 const days = Math.floor(minutes / 1440)
58 const hours = Math.floor((minutes % 1440) / 60)
59 const mins = minutes % 60
60 if (days > 0) return `${days}d ${hours}h`
61 if (hours > 0) return `${hours}h ${mins}m`
62
63 return `${mins}m`
64}
65
66// Local wall-clock time from UTC getters plus an explicit offset, so the result
67// does not depend on the time zone the hooks environment happens to run in.
68export function fmtLocal(ms: number, tzOffsetMin: number, withDate: boolean): string {
69 const d = new Date(ms + tzOffsetMin * 60_000)
70 const h24 = d.getUTCHours()
71 const h12 = h24 % 12 === 0 ? 12 : h24 % 12
72 const time = `${h12}:${String(d.getUTCMinutes()).padStart(2, '0')} ${h24 < 12 ? 'AM' : 'PM'}`
73 if (!withDate) return time
74
75 return `${DAYS[d.getUTCDay()]} ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${time}`
76}
77
78export function fmtReset(iso: string | undefined, nowMs: number, tzOffsetMin: number): string {
79 if (!iso) return 'reset time unknown'
80 const at = Date.parse(iso)
81 if (Number.isNaN(at)) return 'reset time unknown'
82 const withDate = at - nowMs > 20 * 3_600_000
83
84 return `resets ${fmtLocal(at, tzOffsetMin, withDate)} (in ${fmtDuration(at - nowMs)})`
85}
86
87// PowerShell one-liner that prints one JSON object with CPU, memory, fixed
88// disks and the local UTC offset. Run with no shell, so no extra quoting.
89export const SYS_SCRIPT = [
90 "$ErrorActionPreference='Stop'",
91 '$c=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average',
92 '$o=Get-CimInstance Win32_OperatingSystem',
93 "$d=@(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | ForEach-Object { [pscustomobject]@{ id=$_.DeviceID; free=[double]$_.FreeSpace; size=[double]$_.Size } })",
94 '$tz=[int][System.TimeZoneInfo]::Local.GetUtcOffset([datetime]::Now).TotalMinutes',
95 // GPU from nvidia-smi when it is installed (about 0.1 s); Windows' own GPU
96 // counters take seconds to read, so other GPUs are left out.
97 "$g=$null; try { $ns=Get-Command nvidia-smi -ErrorAction SilentlyContinue; if ($ns) { $l=@(& $ns.Source --query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader,nounits)[0]; if ($l) { $p=$l -split ',\\s*'; $g=[pscustomobject]@{ name=$p[0]; util=[double]$p[1]; memUsedMB=[double]$p[2]; memTotalMB=[double]$p[3]; tempC=[double]$p[4] } } } } catch { $g=$null }",
98 '[pscustomobject]@{ cpu=$c; freeKB=[double]$o.FreePhysicalMemory; totalKB=[double]$o.TotalVisibleMemorySize; disks=$d; tz=$tz; gpu=$g } | ConvertTo-Json -Compress -Depth 4',
99].join('; ')
100
101type RawSys = {
102 cpu?: number | null
103 freeKB?: number
104 totalKB?: number
105 disks?: { id: string; free: number; size: number }[] | { id: string; free: number; size: number }
106 tz?: number
107 gpu?: { name: string; util: number; memUsedMB: number; memTotalMB: number; tempC: number } | null
108}
109
110const GB = 1024 ** 3
111
112export function parseSys(stdout: string, now: number): SysSnap | null {
113 const text = stdout.trim()
114 if (!text) return null
115 let raw: RawSys
116 try {
117 raw = JSON.parse(text) as RawSys
118 } catch {
119 return null
120 }
121 const totalKB = raw.totalKB ?? 0
122 const freeKB = raw.freeKB ?? 0
123 const list = raw.disks === undefined ? [] : Array.isArray(raw.disks) ? raw.disks : [raw.disks]
124
125 return {
126 cpu: typeof raw.cpu === 'number' ? Math.round(raw.cpu) : null,
127 memTotalGB: (totalKB * 1024) / GB,
128 memUsedGB: ((totalKB - freeKB) * 1024) / GB,
129 disks: list.map(one => ({ id: one.id, freeGB: one.free / GB, sizeGB: one.size / GB })),
130 tzOffsetMin: typeof raw.tz === 'number' ? raw.tz : 0,
131 gpu:
132 raw.gpu && Number.isFinite(raw.gpu.util)
133 ? {
134 name: raw.gpu.name,
135 util: Math.round(raw.gpu.util),
136 memUsedGB: raw.gpu.memUsedMB / 1024,
137 memTotalGB: raw.gpu.memTotalMB / 1024,
138 tempC: Number.isFinite(raw.gpu.tempC) ? Math.round(raw.gpu.tempC) : null,
139 }
140 : null,
141 at: now,
142 }
143}
144
145// ── Bar drawing ───────────────────────────────────────────────────────────
146
147export const PALETTE = {
148 accent: '#E07B53',
149 blue: '#5B8DEF',
150 violet: '#9B7BEA',
151 teal: '#3FB6A8',
152 orange: '#E8955A',
153 green: '#6CC070',
154 amber: '#E3B341',
155 red: '#E5534B',
156 ink: '#0B0D12',
157 track: 'subtle',
158} as const
159
160export type Run = { text: string; color: string; backgroundColor?: string }
161
162export function toneHex(percent: number): string {
163 const t = tone(percent)
164 if (t === 'error') return PALETTE.red
165 if (t === 'warning') return PALETTE.amber
166
167 return PALETTE.green
168}
169
170// Merges neighbouring cells of the same paint into one run.
171function runsOf(cells: Run[]): Run[] {
172 const out: Run[] = []
173 for (const cell of cells) {
174 const last = out[out.length - 1]
175 if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor) {
176 last.text += cell.text
177 } else {
178 out.push({ ...cell })
179 }
180 }
181
182 return out
183}
184
185// Stacked bar: each part gets a share of `width` by largest remainder, and a
186// non-zero part keeps at least one cell while there is room for it.
187export function segmentBar(parts: { value: number; color: string }[], width: number): Run[] {
188 const total = parts.reduce((sum, p) => sum + Math.max(0, p.value), 0)
189 if (total <= 0 || width <= 0) return [{ text: '█'.repeat(Math.max(0, width)), color: PALETTE.track }]
190
191 const exact = parts.map(p => (Math.max(0, p.value) / total) * width)
192 const cells = exact.map(x => Math.floor(x))
193 parts.forEach((p, i) => {
194 if (p.value > 0 && cells[i] === 0) cells[i] = 1
195 })
196 let used = cells.reduce((a, b) => a + b, 0)
197 const order = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r)
198 for (let k = 0; used < width && k < order.length * 2; k++) {
199 const one = order[k % order.length]
200 if (one && (parts[one.i]?.value ?? 0) > 0) {
201 cells[one.i] = (cells[one.i] ?? 0) + 1
202 used++
203 }
204 }
205 while (used > width) {
206 const widest = cells.indexOf(Math.max(...cells))
207 cells[widest] = (cells[widest] ?? 1) - 1
208 used--
209 }
210
211 return runsOf(parts.flatMap((p, i) => Array.from({ length: cells[i] ?? 0 }, () => ({ text: '█', color: p.color }))))
212}
213
214// Usage bar on a solid track, with an optional pace marker at the share of
215// the window's time already elapsed.
216export function paceBar(percent: number, elapsed: number | null, width: number, fill: string): Run[] {
217 const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
218 const marker = elapsed === null ? -1 : Math.max(0, Math.min(width - 1, Math.round(elapsed * width)))
219 const cells: Run[] = Array.from({ length: width }, (_, i) => {
220 const under = i < filled ? fill : PALETTE.track
221 if (i === marker) return { text: '┃', color: i < filled ? PALETTE.ink : 'text', backgroundColor: under }
222
223 return { text: '█', color: under }
224 })
225
226 return runsOf(cells)
227}
228
229export function windowMs(kind: string): number | null {
230 if (kind === 'five_hour') return 5 * 3_600_000
231 if (kind === 'seven_day' || kind.startsWith('seven_day_')) return 7 * 86_400_000
232
233 return null
234}
235
236export function elapsedShare(kind: string, resetsAt: string | undefined, now: number): number | null {
237 const span = windowMs(kind)
238 const at = resetsAt ? Date.parse(resetsAt) : Number.NaN
239 if (span === null || Number.isNaN(at) || !now) return null
240
241 return Math.max(0, Math.min(1, 1 - (at - now) / span))
242}
243
244export function fmtResetShort(iso: string | undefined, nowMs: number, tzOffsetMin: number): string {
245 if (!iso) return 'reset unknown'
246 const at = Date.parse(iso)
247 if (Number.isNaN(at)) return 'reset unknown'
248 const withDate = at - nowMs > 20 * 3_600_000
249
250 return `${fmtLocal(at, tzOffsetMin, withDate)} · in ${fmtDuration(at - nowMs)}`
251}
252
253// ── Vector bars (surfaces with Svg) ───────────────────────────────────────
254// Drawn wider than the slot is likely to be so the surface scales them down
255// to fit it; preserveAspectRatio="none" keeps the requested pixel height.
256
257export const PX_PER_CELL = 8.5
258export const SVG_TRACK = 'rgba(128,128,128,0.25)'
259
260function svgFrame(width: number, height: number, body: string): string {
261 const r = height / 2
262
263 return (
264 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">` +
265 `<defs><clipPath id="c"><rect x="0" y="0" width="${width}" height="${height}" rx="${r}" ry="${r}"/></clipPath></defs>` +
266 `<g clip-path="url(#c)"><rect x="0" y="0" width="${width}" height="${height}" fill="${SVG_TRACK}"/>${body}</g></svg>`
267 )
268}
269
270export function svgPace(percent: number, elapsed: number | null, cells: number, fill: string, height = 8): string {
271 const width = Math.max(20, Math.round(cells * PX_PER_CELL))
272 const filled = (Math.max(0, Math.min(100, percent)) / 100) * width
273 let body = `<rect x="0" y="0" width="${filled.toFixed(1)}" height="${height}" fill="${fill}"/>`
274 if (elapsed !== null) {
275 const x = Math.max(1, Math.min(width - 3, elapsed * width - 1))
276 body += `<rect x="${x.toFixed(1)}" y="0" width="2.5" height="${height}" fill="#FFFFFF" stroke="rgba(0,0,0,0.55)" stroke-width="0.8"/>`
277 }
278
279 return svgFrame(width, height, body)
280}
281
282export function svgStack(parts: { value: number; color: string }[], cells: number, height = 10): string {
283 const width = Math.max(20, Math.round(cells * PX_PER_CELL))
284 const total = parts.reduce((sum, p) => sum + Math.max(0, p.value), 0)
285 if (total <= 0) return svgFrame(width, height, '')
286 const gap = 2
287 let x = 0
288 let body = ''
289 for (const p of parts) {
290 if (p.value <= 0) continue
291 const w = Math.max(3, (p.value / total) * width)
292 body += `<rect x="${x.toFixed(1)}" y="0" width="${Math.max(1, w - gap).toFixed(1)}" height="${height}" fill="${p.color}"/>`
293 x += w
294 }
295
296 return svgFrame(width, height, body)
297}
298
299// Rows each layout needs, so the band steps down (full system card, then a
300// slim one-row system card, then one line) when the slot above the prompt is too short.
301export type Layout = 'cards' | 'slim' | 'compact'
302
303export const SLIM_SYSTEM_ROWS = 3
304
305export function cardRows(windowCount: number, perRow: number): number {
306 const limits = 2 + 1 + 2 * Math.max(1, windowCount)
307 const session = 2 + 1 + 1 + 2 + 1
308
309 return perRow >= 2 ? Math.max(limits, session) : limits + session
310}
311
312// The system card: border, header, then two rows (label line + bar) per line of meters.
313export function systemRows(meterCount: number, perLine: number): number {
314 return 2 + 1 + 2 * Math.max(1, Math.ceil(meterCount / Math.max(1, perLine)))
315}
316
317export function pickLayout(top: number, system: number, maxRows: number): Layout {
318 if (top + system <= maxRows) return 'cards'
319 if (top + SLIM_SYSTEM_ROWS <= maxRows) return 'slim'
320
321 return 'compact'
322}
323types/index.d.ts 45 lines1export type Window = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type UsageSnap = { windows: Window[]; costUsd?: number; at: number }
4
5export type TokenTotals = {
6 input: number
7 output: number
8 cacheRead: number
9 cacheWrite: number
10 turns: number
11}
12
13export type Disk = { id: string; freeGB: number; sizeGB: number }
14
15export type Gpu = {
16 name: string
17 util: number
18 memUsedGB: number
19 memTotalGB: number
20 tempC: number | null
21}
22
23export type SysSnap = {
24 cpu: number | null
25 memUsedGB: number
26 memTotalGB: number
27 disks: Disk[]
28 tzOffsetMin: number
29 gpu: Gpu | null
30 at: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'usage-meter': {
36 usage: UsageSnap | null
37 tokens: TokenTotals
38 sys: SysSnap | null
39 sysError: string | null
40 isHidden: boolean
41 isCompact: boolean
42 }
43 }
44}
45