SLOPSHOPPER

usage-meter

Your plan's 5-hour and 7-day usage as small bars above the prompt, with the reset countdown and the session's cost.

newbandtoasttimer
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 ⏱ 5h █▅▁▁▁ 31% · $0.42 session ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⏱ 5h █▅▁▁▁ 31% · $0.42 session ⟨Claude Code's own drawing⟩
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

Source 3 files
hooks/register.tsx 218 lines
1// Usage Meter: the plan's rate-limit windows and the session's cost, above the prompt.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, Timer } from 'claude-code'
4
5import type { UsageMeterSnapshot, UsageMeterWindow } from '../types'
6import { bandsFile, parseOrder, stackBands } from './bands'
7
8const CELLS = 5
9const BLOCKS = '▁▂▃▄▅▆▇█'
10const TICK_MS = 60_000
11const WARN_AT = 90
12const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
13const ORDER = ['five_hour', 'seven_day', 'spend_limit']
14
15// Held by the host, so they survive a hot reload of this file.
16const snapshot = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null as UsageMeterSnapshot | null)
17const tick = atom({ plugin: 'usage-meter', key: 'tick' } as const, 0)
18const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, {} as Record<string, number>)
19
20type Segment = { text: string; color?: string; dim?: boolean }
21type Part = { kind: 'window' | 'cost'; isPrimary: boolean; segments: Segment[] }
22
23let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
24
25// The band order file, as the claude-code-mods checklist saves it; [] on any error.
26async function readBandOrder($: EngineInterface): Promise<string[]> {
27  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
28  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
29}
30
31// Forgets the cached band order, for tests.
32export function resetBandOrder() {
33  bandOrder = undefined
34}
35
36export const register: Register = on => {
37  let timer: Timer | undefined // one countdown timer, even if session.start fires again
38
39  on('session.start', async ($, e, next) => {
40    const result = await next(e)
41    // Moves the reset countdown on while a window has a reset time.
42    timer?.cancel()
43    timer = $.clock.every(TICK_MS, () => {
44      void (async () => {
45        const snap = await read($, snapshot)
46        if (snap?.rateLimits.some(w => w.resetsAt)) await update($, tick, n => n + 1)
47      })().catch(() => {})
48    })
49    const usage = await $.session.usage().catch(() => null)
50    if (usage) await take($, usage.rateLimits, usage.cost?.usd).catch(() => {})
51    return result
52  })
53
54  on('session.measure', async ($, e, next) => {
55    const result = await next(e)
56    await take($, e.rateLimits, e.cost?.usd).catch(() => {})
57    return result
58  })
59
60  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61    const rest = await next(e) // what other mods and Claude Code draw here stays
62    await read($, tick) // redraws on each tick of the countdown
63    const snap = await read($, snapshot)
64    if (e.props.hasSurvey || !snap) return rest
65
66    const parts = fit(build(snap, await $.clock.now()), e.props.bodyColumns - 2)
67    if (parts.length === 0) return rest
68
69    const { Box, Text } = $.ui.resolve(e)
70    const segments: Segment[] = [{ text: '⏱ ', dim: true }]
71    parts.forEach((p, i) => {
72      if (i > 0) segments.push({ text: ' · ', dim: true })
73      segments.push(...p.segments)
74    })
75
76    const mine = (
77      <Box flexDirection="row" paddingX={1}>
78        {segments.map((s, i) => (
79          <Text key={String(i)} color={s.color} dimColor={s.dim} wrap="truncate">
80            {s.text}
81          </Text>
82        ))}
83      </Box>
84    )
85    bandOrder ??= readBandOrder($).catch(() => [])
86    return stackBands(rest, 'usage-meter', mine, 'above', await bandOrder) // above what other mods draw, unless the band order says otherwise
87  })
88}
89
90async function take($: EngineInterface, rateLimits: readonly UsageMeterWindow[], costUsd: number | undefined) {
91  const windows = rateLimits
92    .filter(w => Number.isFinite(w.percentUsed))
93    .map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
94  await update($, snapshot, () => ({ rateLimits: windows, costUsd }))
95  await warn($, windows)
96}
97
98// One toast per window per reset period, once it passes WARN_AT.
99async function warn($: EngineInterface, windows: UsageMeterWindow[]) {
100  const done = await read($, warned)
101  const now = await $.clock.now()
102  const fresh: Record<string, number> = {}
103  const rearm: string[] = []
104  for (const w of windows) {
105    const resetMs = resetTime(w)
106    if (w.percentUsed < WARN_AT) {
107      // With no reset time, dropping back under the line is the only sign of a new period.
108      if (resetMs === 0 && done[w.kind] !== undefined) rearm.push(w.kind)
109      continue
110    }
111    const last = done[w.kind]
112    // A reset time that moved on by more than half an hour is a new period.
113    if (last !== undefined && resetMs <= last + 30 * 60_000) continue
114    fresh[w.kind] = resetMs
115    const when = resetMs > 0 ? `, resets in ${countdown(resetMs - now)}` : ''
116    const what = w.kind === 'spend_limit' ? 'Spend limit' : `${label(w.kind)} usage limit`
117    $.ui.toast(`${what} at ${Math.round(w.percentUsed)}%${when}`)
118  }
119  if (Object.keys(fresh).length > 0 || rearm.length > 0) {
120    await update($, warned, old => {
121      const next = { ...old, ...fresh }
122      for (const kind of rearm) delete next[kind]
123      return next
124    })
125  }
126}
127
128export function build(snap: UsageMeterSnapshot, now: number): Part[] {
129  const windows = snap.rateLimits
130    .filter(w => !isStale(w, now)) // a window past its reset waits for a fresh reading
131    .sort((a, b) => rank(a.kind) - rank(b.kind))
132  const parts: Part[] = windows.map((w, i) => {
133    const color = colorFor(w.percentUsed)
134    const segments: Segment[] = [
135      { text: `${label(w.kind)} `, dim: true },
136      { text: bar(w.percentUsed), color },
137      { text: ` ${Math.round(w.percentUsed)}%`, color },
138    ]
139    const resetMs = resetTime(w)
140    // The first window always says when it resets; the others only once they run high.
141    if (resetMs > 0 && (i === 0 || w.percentUsed >= 75)) {
142      segments.push({ text: ` · resets ${countdown(resetMs - now)}`, dim: true })
143    }
144    return { kind: 'window', isPrimary: i === 0, segments }
145  })
146  if (snap.costUsd !== undefined && snap.costUsd > 0) {
147    parts.push({ kind: 'cost', isPrimary: false, segments: [{ text: `$${snap.costUsd.toFixed(2)} session`, dim: true }] })
148  }
149  return parts
150}
151
152// Drops the secondary windows first, then the cost, until the line fits.
153export function fit(parts: Part[], columns: number): Part[] {
154  let kept = [...parts]
155  const tooWide = () => width(kept) > columns
156  while (tooWide()) {
157    const i = findLastIndex(kept, p => p.kind === 'window' && !p.isPrimary)
158    if (i < 0) break
159    kept.splice(i, 1)
160  }
161  if (tooWide() && kept.some(p => p.isPrimary)) kept = kept.filter(p => p.kind !== 'cost')
162  return kept
163}
164
165function width(parts: Part[]) {
166  const text = parts.map(p => p.segments.map(s => s.text).join('')).join(' · ')
167  return 3 + [...text].length // the clock and its space: the glyph can draw two cells wide
168}
169
170function findLastIndex<T>(list: T[], test: (item: T) => boolean) {
171  for (let i = list.length - 1; i >= 0; i--) if (test(list[i] as T)) return i
172  return -1
173}
174
175export function bar(percent: number) {
176  const filled = (Math.min(Math.max(percent, 0), 100) / 100) * CELLS
177  let out = ''
178  for (let i = 0; i < CELLS; i++) {
179    const cell = Math.min(Math.max(filled - i, 0), 1)
180    out += BLOCKS[Math.round(cell * (BLOCKS.length - 1))]
181  }
182  return out
183}
184
185export function colorFor(percent: number) {
186  if (percent >= 90) return 'red'
187  if (percent >= 75) return 'yellow'
188  return 'green'
189}
190
191export function countdown(ms: number) {
192  const minutes = Math.max(0, Math.ceil(ms / 60_000))
193  if (minutes < 1) return 'now'
194  if (minutes < 60) return `${minutes}m`
195  const hours = Math.floor(minutes / 60)
196  if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}`
197  return `${Math.floor(hours / 24)}d${hours % 24}h`
198}
199
200function resetTime(w: UsageMeterWindow) {
201  const ms = w.resetsAt ? Date.parse(w.resetsAt) : NaN
202  return Number.isFinite(ms) ? ms : 0
203}
204
205function isStale(w: UsageMeterWindow, now: number) {
206  const resetMs = resetTime(w)
207  return resetMs > 0 && resetMs <= now
208}
209
210function label(kind: string) {
211  return LABELS[kind] ?? kind.replace(/_/g, ' ')
212}
213
214function rank(kind: string) {
215  const i = ORDER.indexOf(kind)
216  return i < 0 ? ORDER.length : i
217}
218
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
types/index.d.ts 13 lines
1export type UsageMeterWindow = { kind: string; percentUsed: number; resetsAt?: string }
2export type UsageMeterSnapshot = { rateLimits: UsageMeterWindow[]; costUsd?: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-meter': {
7      snapshot: UsageMeterSnapshot | null
8      tick: number
9      warned: Record<string, number>
10    }
11  }
12}
13