SLOPSHOPPER

usage-meter

Your 5-hour and weekly limits as pace bars above the prompt: how much is used against how far into the window you are.

newbandcommandtoaststatustimer
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: GREEN 5h: 31% used ⎿ usage-meter: This session: $0.42 ⟨Claude Code's own drawing⟩ Usg 5h ███████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Usg 5h ███████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
README

agentic-extensions

  • claude-mods — Claude Code mods: context-meter and usage-meter status lines. This repo is a Claude Code plugin marketplace.
Source 4 files
hooks/register.tsx 216 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Reading } from '../types'
5import { bar, brief, COLORS, detail, duration, isMain, pace, summary, type Level, type Pace } from './pace'
6import { fromAppUsage } from './source'
7
8const reading = atom({ plugin: 'usage-meter', key: 'reading' } as const, null)
9const clock = atom({ plugin: 'usage-meter', key: 'now' } as const, 0)
10
11const LABEL = 5
12/** Columns left free at the row's end, so a surface that measures a little narrower still fits it. */
13const RESERVE = 2
14const GAP = 2
15/** A bar never shrinks below this; on a narrow band the row wraps instead. */
16const MIN_BAR = 6
17const POLL_MS = 5 * 60_000
18const MIN_FETCH_MS = 60_000
19const RANK: Record<Level, number> = { green: 0, yellow: 1, orange: 2, red: 3 }
20
21const alerted = new Map<string, Level>()
22let fetchedAt = 0
23
24/**
25 * The desktop app's usage card (5-hour, weekly, per-model weekly) through its
26 * own tool; none where that server is absent (terminal, cloud) or says nothing.
27 */
28async function appLimits($: EngineInterface) {
29  try {
30    const result = await $.mcp.call('ccd_session_mgmt', 'get_usage', {})
31    if (result.isError) return []
32    const text = result.content.find(one => one.type === 'text')?.text
33
34    return text === undefined ? [] : fromAppUsage(text)
35  } catch {
36    return []
37  }
38}
39
40async function record($: EngineInterface, next: Reading) {
41  const now = await $.clock.now()
42  await update($, reading, () => next)
43  await update($, clock, () => now)
44
45  const paces = next.rateLimits.map(limit => pace(limit, now))
46  // The band above the prompt draws on desktop and terminal only; the mobile app
47  // gets the row as a status line under the prompt, pinned only while it is attached.
48  const onPhone = (await $.session.surfaces()).includes('mobile')
49  $.ui.status(onPhone && paces.length > 0 ? summary(paces) : undefined)
50
51  // One toast each time a window climbs into orange or red; quiet as it falls back.
52  for (const p of paces) {
53    const before = alerted.get(p.kind) ?? 'green'
54    if (RANK[p.level] > RANK[before] && RANK[p.level] >= RANK.orange) {
55      $.ui.toast(
56        p.level === 'red'
57          ? `${p.name} limit ${Math.round(p.used)}% used${p.resetsInMs === undefined ? '' : ` — resets in ${duration(p.resetsInMs)}`}`
58          : `${p.name} usage is on pace to hit the limit before it resets`,
59      )
60    }
61    alerted.set(p.kind, p.level)
62  }
63}
64
65/** Reads the app's card (at most once a minute), else the session's own readings. */
66async function refresh($: EngineInterface, session: Reading) {
67  const now = await $.clock.now()
68  if (now - fetchedAt < MIN_FETCH_MS) {
69    const kept = await read($, reading)
70    await record($, { rateLimits: kept?.rateLimits ?? session.rateLimits, cost: session.cost })
71
72    return
73  }
74  fetchedAt = now
75  const fromApp = await appLimits($)
76  await record($, { rateLimits: fromApp.length > 0 ? fromApp : session.rateLimits, cost: session.cost })
77}
78
79export const register: Register = on => {
80  on('session.start', async ($, e, next) => {
81    const result = await next(e)
82    await $.command.register({
83      name: 'usage-meter',
84      description: 'Show your 5-hour and weekly limits against where you are in each window',
85    })
86    const usage = await $.session.usage()
87    await refresh($, { rateLimits: usage.rateLimits, cost: usage.cost })
88    // Keep the pace marks and reset countdowns moving, and re-read the card, between turns.
89    $.clock.every(60_000, () => {
90      void $.clock.now().then(now => update($, clock, () => now))
91    })
92    $.clock.every(POLL_MS, () => {
93      void $.session.usage().then(usage => refresh($, { rateLimits: usage.rateLimits, cost: usage.cost }))
94    })
95
96    return result
97  })
98
99  on('session.measure', async ($, e, next) => {
100    await refresh($, { rateLimits: e.rateLimits, cost: e.cost })
101
102    return next(e)
103  })
104
105  // A phone joining or leaving the session pins or clears its status line.
106  on('session.attach', async ($, e, next) => {
107    const result = await next(e)
108    const kept = await read($, reading)
109    if (kept !== null) await record($, kept)
110
111    return result
112  })
113
114  on('session.detach', async ($, e, next) => {
115    const result = await next(e)
116    const kept = await read($, reading)
117    if (kept !== null) await record($, kept)
118
119    return result
120  })
121
122  on('command.run', { command: 'usage-meter' }, async $ => {
123    const now = await $.clock.now()
124    const latest = await read($, reading)
125    const paces = (latest?.rateLimits ?? []).map(limit => pace(limit, now))
126    if (paces.length === 0) {
127      return { text: 'No limit readings yet: they arrive with the first reply in a session on a Claude subscription.' }
128    }
129    const cost = latest?.cost === undefined ? '' : `\nThis session: $${latest.cost.usd.toFixed(2)}`
130
131    return { text: paces.map(p => `${p.level.toUpperCase().padEnd(6)} ${p.name}: ${detail(p)}`).join('\n') + cost }
132  })
133
134  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
135    const below = await next(e)
136    if (e.props.hasSurvey) return below
137
138    const latest = await read($, reading)
139    const now = await read($, clock)
140    const paces = (latest?.rateLimits ?? [])
141      .map(limit => pace(limit, now))
142      .filter(p => isMain(p) || p.level !== 'green')
143    if (paces.length === 0) return below
144
145    // The label takes the color of the worst window, as Ctx takes its fill's.
146    const worst = paces.reduce<Level>((most, p) => (RANK[p.level] > RANK[most] ? p.level : most), 'green')
147
148    // The bars share every column the label, names and figures leave, so the row spans the band.
149    const fixed =
150      LABEL +
151      paces.reduce((sum, p) => sum + p.name.length + 1 + 1 + brief(p).length, 0) +
152      GAP * (paces.length - 1)
153    const bars = paces.filter(isMain).length
154    const width = bars === 0 ? 0 : Math.max(MIN_BAR, Math.floor((e.props.bodyColumns - fixed - RESERVE) / bars))
155
156    const { Box, Text } = $.ui.resolve(e)
157    // A window's segment: its name, a bar filled to the use with ┃ at how far into
158    // the window you are, then used→projected at reset and the time to reset.
159    // One line, never wrapped: names and figures keep their width, and each bar, in a
160    // box clipped to one row, gives up columns first where the band is narrower.
161    const segment = (p: Pace, i: number) => (
162      <Box
163        key={`usage-${p.kind}`}
164        flexDirection="row"
165        flexWrap="nowrap"
166        flexGrow={isMain(p) ? 1 : 0}
167        flexShrink={isMain(p) ? 1 : 0}
168        minWidth={0}
169        marginRight={i < paces.length - 1 ? GAP : 0}
170      >
171        <Box flexShrink={0}>
172          <Text color={COLORS[p.level]} bold>
173            {p.name}{' '}
174          </Text>
175        </Box>
176        {isMain(p) && (
177          <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden">
178            <Text>
179              {bar(p, width).map(cell =>
180                cell.tone === 'empty' ? (
181                  <Text dimColor>{cell.text}</Text>
182                ) : (
183                  <Text color={cell.tone === 'mark' ? 'text' : COLORS[p.level]}>{cell.text}</Text>
184                ),
185              )}
186            </Text>
187          </Box>
188        )}
189        <Box flexShrink={0}>
190          <Text color={COLORS[p.level]}> {brief(p)}</Text>
191        </Box>
192      </Box>
193    )
194    const mine = (
195      <Box key="usage-meter" flexDirection="row" flexWrap="nowrap">
196        <Box flexShrink={0}>
197          <Text color={COLORS[worst]} bold>
198            {'Usg'.padEnd(LABEL)}
199          </Text>
200        </Box>
201        {paces.map(segment)}
202      </Box>
203    )
204
205    // Drawn under whatever else the band holds (the Ctx row).
206    return below ? (
207      <Box flexDirection="column">
208        {below}
209        {mine}
210      </Box>
211    ) : (
212      mine
213    )
214  })
215}
216
hooks/pace.ts 142 lines
1import type { Reading } from '../types'
2
3type SessionRateLimit = Reading['rateLimits'][number]
4
5export type Level = 'green' | 'yellow' | 'orange' | 'red'
6
7export type Pace = {
8  kind: string
9  name: string
10  /** Percent of the limit used, 0 to 100 (past 100 when blocked on spend). */
11  used: number
12  /** How far into the window you are, 0 to 1; absent without a reset time. */
13  elapsed?: number
14  /** Percent of the limit you will have used at reset, at the pace so far. */
15  projected?: number
16  resetsInMs?: number
17  level: Level
18}
19
20export const COLORS: Record<Level, string> = {
21  green: '#3fb950',
22  yellow: '#d29922',
23  orange: '#f0883e',
24  red: '#f85149',
25}
26
27
28const NAMES: Record<string, string> = { five_hour: '5h', seven_day: 'Wk', spend_limit: 'Spend' }
29
30/** `seven_day:<model>` is a per-model weekly window, named by its model. */
31const baseOf = (kind: string) => kind.split(':')[0] ?? kind
32const nameOf = (kind: string) => NAMES[kind] ?? (kind.includes(':') ? kind.slice(kind.indexOf(':') + 1) : kind)
33const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
34
35/** At or above this percent used, the window is red whatever the pace. */
36export const RED_FROM = 95
37/** Projected use at reset from which the pace counts as near the limit. */
38export const YELLOW_FROM = 85
39/** Projections from the first 5% of a window are too noisy; treat it as 5% in. */
40const MIN_ELAPSED = 0.05
41
42const levelOf = (used: number, projected: number | undefined): Level => {
43  if (used >= RED_FROM) return 'red'
44  // With no window to measure against, judge on use alone.
45  const measure = projected ?? used
46  if (measure > 100) return 'orange'
47  if (measure >= YELLOW_FROM) return 'yellow'
48
49  return 'green'
50}
51
52export const pace = (limit: SessionRateLimit, now: number): Pace => {
53  const used = limit.percentUsed
54  const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
55  const length = WINDOW_MS[baseOf(limit.kind)]
56  let elapsed: number | undefined
57  let projected: number | undefined
58  let resetsInMs: number | undefined
59
60  if (!Number.isNaN(resetsAt)) {
61    resetsInMs = Math.max(0, resetsAt - now)
62    if (length !== undefined) {
63      elapsed = Math.min(1, Math.max(0, 1 - resetsInMs / length))
64      projected = used / Math.max(elapsed, MIN_ELAPSED)
65    }
66  }
67
68  return {
69    kind: limit.kind,
70    name: nameOf(limit.kind),
71    used,
72    elapsed,
73    projected,
74    resetsInMs,
75    level: levelOf(used, projected),
76  }
77}
78
79export const duration = (ms: number) => {
80  const minutes = Math.round(ms / 60_000)
81  const days = Math.floor(minutes / 1440)
82  const hours = Math.floor((minutes % 1440) / 60)
83
84  return days > 0 ? `${days}d${hours}h` : hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
85}
86
87/** The windows the row always shows; any other (a model's weekly) shows once it leaves green. */
88export const isMain = (p: Pace) => p.kind === 'five_hour' || p.kind === 'seven_day'
89
90/** `used→projected` and the time to reset, as the row prints beside a bar. */
91export const brief = (p: Pace) => {
92  const projected = p.projected === undefined || p.level === 'red' ? '' : `→${Math.round(p.projected)}%`
93  const reset = p.resetsInMs === undefined ? '' : ` ↻${duration(p.resetsInMs)}`
94
95  return `${Math.round(p.used)}%${projected}${reset}`
96}
97
98export type Cell = { text: string; tone: 'fill' | 'empty' | 'mark' }
99
100/**
101 * A bar of `width` cells: filled to the percent used, with a mark at how far
102 * into the window you are. Fill left of the mark is on pace; fill past it is ahead.
103 */
104export const bar = (p: Pace, width: number): Cell[] => {
105  const filled = Math.round((Math.min(p.used, 100) / 100) * width)
106  const mark = p.elapsed === undefined ? -1 : Math.min(width - 1, Math.floor(p.elapsed * width))
107  const cells: Cell[] = []
108
109  for (let i = 0; i < width; i += 1) {
110    const tone = i === mark ? 'mark' : i < filled ? 'fill' : 'empty'
111    const text = tone === 'mark' ? '┃' : tone === 'fill' ? '█' : '░'
112    const last = cells[cells.length - 1]
113    if (last !== undefined && last.tone === tone) {
114      last.text += text
115    } else {
116      cells.push({ text, tone })
117    }
118  }
119
120  return cells
121}
122
123export const detail = (p: Pace) => {
124  const parts = [`${Math.round(p.used)}% used`]
125  if (p.elapsed !== undefined) parts.push(`${Math.round(p.elapsed * 100)}% through window`)
126  if (p.projected !== undefined) {
127    parts.push(p.projected > 100 ? `on pace to hit limit` : `→ ${Math.round(p.projected)}% at reset`)
128  }
129  if (p.resetsInMs !== undefined) parts.push(`resets in ${duration(p.resetsInMs)}`)
130
131  return parts.join(' · ')
132}
133
134export const DOTS: Record<Level, string> = { green: '🟢', yellow: '🟡', orange: '🟠', red: '🔴' }
135
136/** The row as one plain line, for the status line the mobile app draws under the prompt. */
137export const summary = (paces: Pace[]) =>
138  `Usg  ${paces
139    .filter(p => isMain(p) || p.level !== 'green')
140    .map(p => `${DOTS[p.level]} ${p.name} ${brief(p)}`)
141    .join(' · ')}`
142
hooks/source.ts 42 lines
1import type { Reading } from '../types'
2
3type Limit = Reading['rateLimits'][number]
4
5/**
6 * The desktop app's usage card, as its `get_usage` tool returns it: windows
7 * labelled `5-hour limit`, `Weekly · all models`, `Weekly · <model>`.
8 */
9type AppPlan = {
10  status?: string
11  windows?: { label?: string; percentUsed?: number; resetsAt?: string }[]
12}
13
14const kindOf = (label: string) => {
15  if (/^5-hour/i.test(label)) return 'five_hour'
16  const weekly = /^weekly\s*·\s*(.+)$/i.exec(label)
17  if (weekly === null) return undefined
18  const scope = weekly[1]?.trim() ?? ''
19
20  return /^all models$/i.test(scope) ? 'seven_day' : `seven_day:${scope}`
21}
22
23/** The limit windows in a `get_usage` result's JSON text; none when it has no plan. */
24export const fromAppUsage = (text: string): Limit[] => {
25  let plan: AppPlan | undefined
26  try {
27    plan = (JSON.parse(text) as { plan?: AppPlan }).plan
28  } catch {
29    return []
30  }
31  if (plan?.status !== 'ok') return []
32
33  const limits: Limit[] = []
34  for (const window of plan.windows ?? []) {
35    const kind = window.label === undefined ? undefined : kindOf(window.label)
36    if (kind === undefined || typeof window.percentUsed !== 'number') continue
37    limits.push({ kind, percentUsed: window.percentUsed, resetsAt: window.resetsAt })
38  }
39
40  return limits
41}
42
types/index.d.ts 11 lines
1export type Reading = {
2  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
3  cost?: { usd: number }
4}
5
6declare module 'claude-code' {
7  interface PluginState {
8    'usage-meter': { reading: Reading | null; now: number }
9  }
10}
11