SLOPSHOPPER

usage-meter

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

newbandcommandprocesstimer
v0.2.1no licenseupdated 2026-10-09danielalvesvm99/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-meter ⎿ usage-meter: Usage meter hidden. Run /usage-meter to show it again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

usage-meter

A band above the Claude Code prompt that shows:

  • Plan limits: the 5-hour and weekly windows (and any per-model weekly limit), with reset times and a tick marking how much of each window has passed
  • Tokens: this session's cache read, cache write, input and output, plus the estimated spend at API list price
  • System: CPU, RAM, GPU (NVIDIA, via nvidia-smi) and free disk space, sampled every 15 seconds

/usage-meter shows or hides it. compact shrinks it to one line.

Install

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.

Requirements

  • Claude Code 2.1.293 or later
  • Plan limits appear only on a Claude subscription (Pro, Max, Team, Enterprise)
  • System stats need Windows (PowerShell); the GPU meter needs an NVIDIA card. On other systems the system card says it is unavailable and the rest works.
Source 3 files
hooks/register.tsx 368 lines
1import { 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}
368
hooks/format.ts 323 lines
1import 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}
323
types/index.d.ts 45 lines
1export 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