SLOPSHOPPER

meter

Memory, plan and weekly limits as bars above the prompt; /tokens charts token usage by day, repo, branch and model.

newpanebandspinnercommandtoast
v0.3.0MITupdated 2026-10-02DominickGiordano/claude-mods/meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meter
│ ┃ Tokens ✕ › fix the failing auth test and add an audit log call │ ┃ Tokens per day, last 14 days (max 98.9k) │ ┃ cache hits: no requests yet ⏺ Read(src/auth.ts) │ ┃ ██ ⎿ Read 6 lines │ ┃ ██ ⏺ Update(src/auth.ts) │ ┃ ██ ⎿ Added 2 lines, removed 1 line │ ┃ ██ ⏺ Bash(bun test) │ ┃ ██ ⎿ 3 pass, 1 fail │ ┃ ██ │ ┃ 09-26 … 10-09 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ 1d ] [ 7d ] [ 30d ] │ ┃ 7d total 98.9k tok ✻ Worked for 42s · done 4:20 PM │ ┃ Repos │ ┃ 98.9k tok app › /tokens │ ┃ Branches │ ┃ 98.9k tok feat/auth-refresh (app) │ ┃ Models │ ┃ 98.9k tok claude-opus-5-5 │ memory 49% · plan 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
memory 49% · plan 31% ⟨Claude Code's own drawing⟩
Pane · Tokens
Tokens per day, last 14 days (max 98.9k) cache hits: no requests yet ██ ██ ██ ██ ██ ██ 09-26 … 10-09 [ 1d ] [ 7d ] [ 30d ] 7d total 98.9k tok Repos 98.9k tok app Branches 98.9k tok feat/auth-refresh (app) Models 98.9k tok claude-opus-5-5
README

meter

Above the prompt, one bar per limit:

memory ████████░░ 78%  ⚠ clear soon  [Compact]
plan   ███░░░░░░░ 31%  resets 2h 10m
week   ██░░░░░░░░ 22%
RowWhat it meansWhen to act
memoryHow full this conversation's context window is.60%: clear (/clear) when you next switch tasks. 80%: clear or press Compact soon.
planYour plan's 5-hour usage limit. resets says when it refills.90%: ⚠ near limit, slow down or wait for the reset.
weekYour plan's 7-day usage limit. Shows the reset day from 50%.Near 100%, save heavy work for after the reset.
cache hitsNot in the band. /tokens shows this session's cache hit rate.Nothing to do. Higher is better.
  • Bars go yellow at 50% and red at 80%. They are 10 cells wide, fewer on a narrow terminal.
  • While memory is under 60% and plan and week under 50%, the band is one dim line: memory 41% · plan 31% · week 22%. It opens to three rows when any crosses its threshold.
  • A figure Claude Code didn't report has no row, never 0%. With an API key there are no plan limits, so only memory shows. A limit whose reset time has passed shows reset? until the next reading.
  • Compact runs the same compaction as /compact. Mid-turn the button hides.
  • The spinner gains · 4.2k tokens: uncached input plus output tokens this turn.

"tok" in /tokens means everything a request read or wrote: input, output and cache.

Figures are read once at start, then pushed by Claude Code's session.measure after each turn. meter never shows a dollar figure.

/tokens

Opens a pane: tokens per day for the last 14 days, then the top repos, branches and models by tokens for 1, 7 or 30 days. The header shows this session's cache hits, the share of input tokens read from cache (higher is better).

Every finished turn that reports usage, subagents' included, books its tokens into $.store under meter:YYYY-MM-DD, by repo (the main checkout's folder name), git branch and model. Days recorded by 0.1.0 still load; the cost they carry is ignored.

Days older than 30 are deleted. The store is shared by every session on the machine and has no locking, so two sessions finishing a turn in the same instant can drop one of the two. A failed write shows at the top of the pane.

Source 3 files
hooks/register.ts 287 lines
1import type { EngineInterface, On, RenderElement, SessionRateLimit, SessionUsage } from 'claude-code'
2
3import { bar, barRows, cachePercent, levelColor, short, totalTokens, until } from './format'
4import type { CacheTotals } from './format'
5import { KEEP_DAYS, addTo, dayOf, isDay, isExpired, keyOf, merge, top, total } from './ledger'
6import type { Bucket, Day, Entry } from './ledger'
7
8const MEMORY_WARN = 60
9const LIMIT_WARN = 50
10const BAR_CELLS = 10
11const MIN_BAR_CELLS = 4
12const DAY = 86_400_000
13const CHART_DAYS = 14
14const CHART_ROWS = 6
15const TOP = 5
16const RANGES = [1, 7, 30] as const
17
18type Figures = Pick<SessionUsage, 'context' | 'rateLimits'>
19type Limit = { percent: number; resetAt: number | null; isPast: boolean }
20
21let figures: Figures | null = null
22const cache: CacheTotals = { read: 0, written: 0, uncached: 0 }
23const turn = { isRunning: false, tokens: 0 }
24let mainTurnId = ''
25const git = { turnId: '', branch: '' }
26let range: (typeof RANGES)[number] = 7
27let days: (Day | undefined)[] | null = null
28let ledgerFailure: string | null = null
29let writes: Promise<void> = Promise.resolve()
30
31export function register(on: On) {
32  on('session.start', async ($, e, next) => {
33    const result = await next(e)
34    await seed($)
35    await $.command.register({ name: 'tokens', description: 'Token usage by day, repo, branch and model' })
36    return result
37  })
38
39  on('command.run', { command: 'tokens' }, async ($, e) => {
40    days = null
41    await $.ui.open({ id: 'tokens', title: 'Tokens', focus: true, closeOnEscape: true })
42    return {}
43  })
44
45  // session.start doesn't fire again after /clear or /resume, so the next turn.start re-seeds.
46  on('session.end', { reason: ['clear', 'resume'] }, async ($, e, next) => {
47    figures = null
48    cache.read = cache.written = cache.uncached = 0
49    return next(e)
50  })
51
52  on('turn.start', async ($, e, next) => {
53    if (!figures) await seed($)
54    turn.isRunning = true
55    turn.tokens = 0
56    mainTurnId = e.turnId
57    return next(e)
58  })
59
60  on('turn.step', async function* ($, e, next) {
61    const result = yield* next(e)
62    const u = result.usage
63    if (!u) return result
64    cache.read += u.cache_read_input_tokens
65    cache.written += u.cache_creation_input_tokens
66    cache.uncached += u.input_tokens
67    turn.tokens += u.input_tokens + u.output_tokens
68    $.ui.invalidate('ui.render')
69    return result
70  })
71
72  on('turn.complete', async ($, e, next) => {
73    const result = await next(e)
74    if (!e.agentId) turn.isRunning = false
75    if (e.usage) await book($, { model: e.usage.model, tokens: totalTokens(e.usage) })
76    return result
77  })
78
79  on('session.measure', async ($, e, next) => {
80    figures = { context: e.context, rateLimits: e.rateLimits }
81    $.ui.invalidate('ui.render')
82    return next(e)
83  })
84
85  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
86    if (!turn.isRunning) return next(e)
87    const suffix = `${e.props.suffix} · ${short(turn.tokens)} tokens`
88    return next({ ...e, props: { ...e.props, suffix } })
89  })
90
91  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92    if (e.props.hasSurvey || !figures) return next(e)
93    const now = await $.clock.now()
94    const memory = figures.context.percent
95    const plan = limitOf(figures.rateLimits, 'five_hour', now)
96    const week = limitOf(figures.rateLimits, 'seven_day', now)
97    if (memory === undefined && !plan && !week) return next(e)
98    const { Box, Text, Button } = $.ui.resolve(e)
99
100    if ((memory ?? 0) < MEMORY_WARN && isCalm(plan) && isCalm(week)) {
101      const figure = (label: string, l: Limit | null) => (l ? [`${label} ${l.isPast ? 'reset?' : `${l.percent}%`}`] : [])
102      const line = [...(memory === undefined ? [] : [`memory ${memory}%`]), ...figure('plan', plan), ...figure('week', week)]
103      return Box({ flexDirection: 'column', children: [Text({ dimColor: true, children: line.join(' · ') }), await next(e)] })
104    }
105
106    const cells = Math.min(BAR_CELLS, Math.max(MIN_BAR_CELLS, e.props.bodyColumns - 50))
107    const dim = (text: string) => Text({ dimColor: true, children: text })
108    const label = (name: string) => dim(name.padEnd(7))
109    const row = (name: string, percent: number, hints: RenderElement[]) => {
110      const color = levelColor(percent)
111      const [fill, track] = bar(percent, cells)
112      return Box({
113        flexDirection: 'row',
114        children: [
115          label(name),
116          ...(fill ? [Text({ color, children: fill })] : []),
117          ...(track ? [dim(track)] : []),
118          Text({ bold: true, color, children: ` ${percent}%`.padStart(5) }),
119          ...hints.flatMap(h => [Text({ children: '  ' }), h]),
120        ],
121      })
122    }
123    const stale = (name: string) => Box({ flexDirection: 'row', children: [label(name), dim('reset?')] })
124
125    const rows: RenderElement[] = []
126    if (memory !== undefined) {
127      const hints: RenderElement[] = []
128      if (memory >= 80) {
129        hints.push(Text({ color: 'red', children: '⚠ clear soon' }))
130        if (!e.props.isWorking) hints.push(Button({ key: 'compact', label: 'Compact', onPress: () => compact($) }))
131      } else if (memory >= MEMORY_WARN) {
132        hints.push(dim('clear when you switch tasks'))
133      }
134      rows.push(row('memory', memory, hints))
135    }
136    if (plan?.isPast) rows.push(stale('plan'))
137    else if (plan) {
138      const hints: RenderElement[] = []
139      if (plan.percent >= 90) hints.push(Text({ color: 'red', children: '⚠ near limit' }))
140      if (plan.resetAt !== null) hints.push(dim(`resets ${until(plan.resetAt, now)}`))
141      rows.push(row('plan', plan.percent, hints))
142    }
143    if (week?.isPast) rows.push(stale('week'))
144    else if (week) {
145      const isSoon = week.resetAt !== null && week.percent >= LIMIT_WARN
146      rows.push(row('week', week.percent, isSoon ? [dim(`resets ${resetDay(week.resetAt!, now)}`)] : []))
147    }
148
149    return Box({ flexDirection: 'column', children: [...rows, await next(e)] })
150  })
151
152  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
153    if (e.requestId !== 'tokens') return next(e)
154    const { Box, Text, Button } = $.ui.resolve(e)
155    const now = await $.clock.now()
156    days ??= await load($, now)
157
158    const recent = days.slice(0, CHART_DAYS)
159    const daily = recent.map(d => total(d?.repos ?? {})).reverse()
160    const chart = recent.some(d => d)
161      ? barRows(daily, CHART_ROWS).map(l => Text({ color: 'cyan', children: l }))
162      : [Text({ dimColor: true, children: `No tokens recorded in the last ${CHART_DAYS} days.` })]
163
164    const picked = merge(days.slice(0, range))
165    const section = (title: string, buckets: Record<string, Bucket>) => {
166      const more = Object.keys(buckets).length - TOP
167      return [
168        Text({ bold: true, children: title }),
169        ...top(buckets, TOP).map(([name, b]) =>
170          Text({ wrap: 'truncate-end', children: `  ${short(b.tokens).padStart(6)} tok  ${name}` }),
171        ),
172        ...(more > 0 ? [Text({ dimColor: true, children: `  +${more} more` })] : []),
173      ]
174    }
175    const hit = cachePercent(cache)
176
177    return Box({
178      flexDirection: 'column',
179      children: [
180        ...(ledgerFailure ? [Text({ color: 'red', children: `ledger write failed: ${ledgerFailure}` })] : []),
181        Text({ bold: true, children: `Tokens per day, last ${CHART_DAYS} days (max ${short(Math.max(...daily))})` }),
182        Text({ dimColor: true, children: hit === null ? 'cache hits: no requests yet' : `cache hits ${hit}% (higher is better)` }),
183        ...chart,
184        Text({ dimColor: true, children: `${dayOf(now, CHART_DAYS - 1).slice(5)} … ${dayOf(now).slice(5)}` }),
185        Box({
186          flexDirection: 'row',
187          gap: 1,
188          children: RANGES.map(n =>
189            Button({
190              key: `${n}d`,
191              label: `${n}d`,
192              ...(n === range && { variant: 'primary' as const }),
193              onPress: () => {
194                range = n
195                $.ui.invalidate('ui.render')
196              },
197            }),
198          ),
199        }),
200        Text({ children: `${range}d total ${short(total(picked.repos))} tok` }),
201        ...section('Repos', picked.repos),
202        ...section('Branches', picked.branches),
203        ...section('Models', picked.models),
204      ],
205    })
206  })
207}
208
209function limitOf(limits: readonly SessionRateLimit[], kind: SessionRateLimit['kind'], now: number): Limit | null {
210  const l = limits.find(l => l.kind === kind && Number.isFinite(l.percentUsed))
211  if (!l) return null
212  const resetAt = Date.parse(l.resetsAt ?? '')
213  return { percent: Math.round(l.percentUsed), resetAt: Number.isFinite(resetAt) ? resetAt : null, isPast: resetAt <= now }
214}
215
216// A limit past its reset is stale, so it doesn't hold the band open.
217function isCalm(l: Limit | null): boolean {
218  return !l || l.isPast || l.percent < LIMIT_WARN
219}
220
221function resetDay(at: number, now: number): string {
222  return at - now < DAY ? until(at, now) : new Date(at).toLocaleDateString('en-US', { weekday: 'short' })
223}
224
225async function load($: EngineInterface, now: number): Promise<(Day | undefined)[]> {
226  const loaded: (Day | undefined)[] = []
227  for (let back = 0; back < KEEP_DAYS; back++) {
228    const stored = await $.store.get(keyOf(now, back))
229    loaded.push(isDay(stored) ? stored : undefined)
230  }
231  return loaded
232}
233
234// Serialized because interleaved read-modify-writes in this process drop a booking. Across sessions
235// $.store has no lock, so that race remains, rare and tolerated for a usage tally.
236function book($: EngineInterface, part: Omit<Entry, 'repo' | 'branch'>): Promise<void> {
237  writes = writes.then(() => write($, part))
238  return writes
239}
240
241async function write($: EngineInterface, part: Omit<Entry, 'repo' | 'branch'>) {
242  try {
243    const now = await $.clock.now()
244    const root = (await $.session.repo())?.root
245    const repo = (root ?? (await $.session.root())).split('/').filter(Boolean).at(-1) ?? '/'
246    const branch = root ? await branchOf($) : '(no git)'
247    const key = keyOf(now)
248    const stored = await $.store.get(key)
249    await $.store.set(key, addTo(isDay(stored) ? stored : undefined, { repo, branch, ...part }))
250    if (stored === undefined) {
251      for (const k of await $.store.keys()) if (isExpired(k, now)) await $.store.delete(k)
252    }
253    ledgerFailure = null
254  } catch (err) {
255    ledgerFailure = String(err)
256    $.ui.log(`meter: ledger write failed: ${ledgerFailure}`, { to: 'debug' })
257  }
258  days = null
259  $.ui.invalidate('ui.render')
260}
261
262// Runs in the session's cwd, not repo.root: for a worktree that root is the main checkout, on another branch.
263async function branchOf($: EngineInterface): Promise<string> {
264  if (git.turnId === mainTurnId && git.branch) return git.branch
265  const run = await $.process.run(['git', 'branch', '--show-current']).catch(() => null)
266  git.turnId = mainTurnId
267  git.branch = !run || run.exitCode !== 0 ? '(git failed)' : run.stdout.trim() || '(detached)'
268  return git.branch
269}
270
271async function seed($: EngineInterface) {
272  try {
273    figures = await $.session.usage()
274  } catch (err) {
275    $.ui.log(`meter: usage read failed, waiting for the next measure: ${String(err)}`, { to: 'debug' })
276  }
277  $.ui.invalidate('ui.render')
278}
279
280async function compact($: EngineInterface) {
281  const result = await $.session.compact().catch((err: unknown) => ({ skip: String(err) }))
282  $.ui.toast(result.skip === undefined ? 'meter: compacted' : `meter: compact skipped, ${result.skip}`)
283  // No measure follows a compaction until the next turn, so read the shrunken context now.
284  figures = await $.session.usage()
285  $.ui.invalidate('ui.render')
286}
287
hooks/format.ts 56 lines
1import type { ModelUsage } from 'claude-code'
2
3export function short(n: number): string {
4  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
5  if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`
6  return String(Math.round(n))
7}
8
9export function levelColor(percent: number): string {
10  if (percent >= 80) return 'red'
11  if (percent >= 50) return 'yellow'
12  return 'green'
13}
14
15const PARTIAL = ' ▏▎▍▌▋▊▉'
16
17// The filled run, ending in an eighth-cell partial, and the track after it.
18export function bar(percent: number, cells: number): [string, string] {
19  const eighths = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells * 8)
20  const full = Math.floor(eighths / 8)
21  const part = eighths % 8
22  return ['█'.repeat(full) + (part ? PARTIAL[part] : ''), '░'.repeat(cells - full - (part ? 1 : 0))]
23}
24
25export function until(at: number, now: number): string {
26  const minutes = Math.max(0, Math.round((at - now) / 60_000))
27  const h = Math.floor(minutes / 60)
28  return h > 0 ? `${h}h ${minutes % 60}m` : `${minutes}m`
29}
30
31// The one meaning of "tok" in the ledger: everything the request read or wrote, cache reads
32// included. The spinner counts only uncached input plus output, the part a turn adds.
33export function totalTokens(u: ModelUsage): number {
34  return u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
35}
36
37export type CacheTotals = { read: number; written: number; uncached: number }
38
39export function cachePercent(c: CacheTotals): number | null {
40  const all = c.read + c.written + c.uncached
41  return all === 0 ? null : Math.round((c.read / all) * 100)
42}
43
44const EIGHTHS = ' ▁▂▃▄▅▆▇█'
45
46// Rows top to bottom, bars two cells wide with one between, in eighth-cell steps.
47// A nonzero day gets at least one eighth so it never draws as an empty day.
48export function barRows(values: readonly number[], rows: number): string[] {
49  const top = Math.max(...values)
50  const heights = values.map(v => (v > 0 ? Math.max(1, Math.round((v / top) * rows * 8)) : 0))
51  return Array.from({ length: rows }, (_, r) => {
52    const floor = (rows - 1 - r) * 8
53    return heights.map(h => EIGHTHS[Math.max(0, Math.min(8, h - floor))]!.repeat(2)).join(' ')
54  })
55}
56
hooks/ledger.ts 73 lines
1export type Bucket = { tokens: number }
2export type Day = { repos: Record<string, Bucket>; branches: Record<string, Bucket>; models: Record<string, Bucket> }
3export type Entry = { repo: string; branch: string; model: string; tokens: number }
4
5export const KEEP_DAYS = 30
6const PREFIX = 'meter:'
7
8// Calendar arithmetic, not ms arithmetic, so a DST change never skips or repeats a day.
9export function dayOf(now: number, back = 0): string {
10  const d = new Date(now)
11  d.setDate(d.getDate() - back)
12  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
13}
14
15export function keyOf(now: number, back = 0): string {
16  return PREFIX + dayOf(now, back)
17}
18
19export function isExpired(key: string, now: number): boolean {
20  return /^meter:\d{4}-\d{2}-\d{2}$/.test(key) && key < keyOf(now, KEEP_DAYS - 1)
21}
22
23// Days written before 0.2.0 also carry a `usd` per bucket; it is ignored, never shown.
24function isBuckets(x: unknown): boolean {
25  return typeof x === 'object' && x !== null && Object.values(x).every(b => typeof b?.tokens === 'number')
26}
27
28export function isDay(x: unknown): x is Day {
29  if (typeof x !== 'object' || x === null) return false
30  const d = x as Partial<Day>
31  return isBuckets(d.repos) && isBuckets(d.branches) && isBuckets(d.models)
32}
33
34function bump(buckets: Record<string, Bucket>, name: string, entry: Entry): Record<string, Bucket> {
35  return { ...buckets, [name]: { tokens: (buckets[name]?.tokens ?? 0) + entry.tokens } }
36}
37
38export function addTo(day: Day | undefined, entry: Entry): Day {
39  const d = day ?? { repos: {}, branches: {}, models: {} }
40  return {
41    repos: bump(d.repos, entry.repo, entry),
42    branches: bump(d.branches, `${entry.branch} (${entry.repo})`, entry),
43    models: bump(d.models, entry.model, entry),
44  }
45}
46
47function sum(into: Record<string, Bucket>, from: Record<string, Bucket>) {
48  for (const [name, b] of Object.entries(from)) {
49    into[name] = { tokens: (into[name]?.tokens ?? 0) + b.tokens }
50  }
51}
52
53export function merge(days: readonly (Day | undefined)[]): Day {
54  const all: Day = { repos: {}, branches: {}, models: {} }
55  for (const d of days) {
56    if (!d) continue
57    sum(all.repos, d.repos)
58    sum(all.branches, d.branches)
59    sum(all.models, d.models)
60  }
61  return all
62}
63
64export function total(buckets: Record<string, Bucket>): number {
65  return Object.values(buckets).reduce((t, b) => t + b.tokens, 0)
66}
67
68export function top(buckets: Record<string, Bucket>, n = 5): [string, Bucket][] {
69  return Object.entries(buckets)
70    .sort(([, a], [, b]) => b.tokens - a.tokens)
71    .slice(0, n)
72}
73