SLOPSHOPPER

usage-meter

Shows your plan's session (5-hour) and weekly usage % above the message box, with a heads-up at 90%

bandguardtoastprompttimer
v0.1.0MITupdated 2026-10-18marco-droyd/usage-meter/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 S e s s i o n █ █ █ █ █ █ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ 3 1 % r e s e t t i n g ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
S e s s i o n █ █ █ █ █ █ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ 3 1 % r e s e t t i n g
README

usage-meter

A Claude Code mod that shows your plan's usage right above the message box:

  • Session (the 5-hour window) and Week, each with a bar, a percentage and when it resets
  • Black band with purple bars. The bar and its label turn yellow in the 80s and red from 90%
  • A one-time pop-up warning when a window reaches 90%
  • Task: how far Claude is through its current task list, as the share of steps checked off. It counts steps, not effort, and clears when you send your next message after a finished task. The task list comes from the Claude desktop app's Code tab, so Task only shows there. After the first step it adds a time-left guess (average time per step so far x steps left). It's all worked out on your computer: no extra tokens.

Works in Claude Code in the terminal and in the Code tab of the Claude desktop app. It needs a Claude subscription (the usage numbers come from your plan's rate limits).

Install

At the prompt of a Claude Code terminal session:

/plugin install usage-meter --marketplace marco-droyd/usage-meter

Answer y to add the marketplace, then pick the user scope.

From a shell:

claude plugin install usage-meter --marketplace marco-droyd/usage-meter

In the Claude desktop app (where /plugin doesn't run): click + next to the prompt box, then Plugins, Add marketplace with marco-droyd/usage-meter, then Add plugin.

Changing the colours

The colours live in one line near the top of usage-meter/hooks/register.tsx:

const MIDNIGHT = { bg: '#0b0b0b', purple: '#9d7bff', yellow: '#ffd84d', red: '#ff5c5c', ... }

License

MIT, see LICENSE.

Source 2 files
hooks/register.tsx 216 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, StepStatus, TaskTiming } from '../types'
5
6// The plan's rate-limit windows, as the engine reports them with each response.
7const limits = atom({ plugin: 'usage-meter', key: 'limits' } as const, [] as Limit[])
8// Windows already warned about at 90%, so the toast shows once per window.
9const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, [] as string[])
10// When `limits` was read. Readings are shared through $.store, so every session on this
11// computer shows the newest one, even a session that has been idle for hours.
12const readAt = atom({ plugin: 'usage-meter', key: 'readAt' } as const, 0)
13const LATEST = 'latest'
14type Latest = { at: number; limits: Limit[] }
15// Claude's task list in the desktop app (update_plan), one status per step.
16const steps = atom({ plugin: 'usage-meter', key: 'steps' } as const, [] as StepStatus[])
17// When the task list started and when its latest step was checked off, for the time-left guess.
18const timing = atom({ plugin: 'usage-meter', key: 'timing' } as const, { startedAt: 0, lastDoneAt: 0, done: 0, base: 0 } as TaskTiming)
19
20const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
21const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
22
23const NAMES: Record<string, string> = { five_hour: 'Session', seven_day: 'Week', spend_limit: 'Spend' }
24const ORDER = ['five_hour', 'seven_day', 'spend_limit']
25// "Midnight" style: black band, purple as the normal colour. Each label takes its bar's colour.
26const MIDNIGHT = { bg: '#0b0b0b', purple: '#9d7bff', yellow: '#ffd84d', red: '#ff5c5c', muted: '#777777', divider: '#555555' }
27
28function untilText(resetsAt: string | undefined, now: number): string {
29  if (!resetsAt) return ''
30  const ms = Date.parse(resetsAt) - now
31  if (!(ms > 0)) return 'resetting'
32  const mins = Math.round(ms / 60000)
33  const d = Math.floor(mins / 1440)
34  const h = Math.floor((mins % 1440) / 60)
35  const m = mins % 60
36  if (d > 0) return `${d}d ${h}h`
37  if (h > 0) return `${h}h ${m}m`
38  return `${m}m`
39}
40
41function bar(pct: number, cells = 10): string {
42  const filled = Math.max(0, Math.min(cells, Math.round((pct / 100) * cells)))
43  return '█'.repeat(filled) + '░'.repeat(cells - filled)
44}
45
46// Time left = average time per finished step so far x steps left, minus time already spent on
47// the current step. A guess: steps differ in size, so it firms up as more steps finish.
48function leftText(plan: StepStatus[], t: TaskTiming, now: number): string {
49  const left = plan.filter(s => s !== 'completed').length
50  if (left === 0) return ''
51  // Only steps finished since the list started count; ones already ticked when it arrived don't.
52  const finished = t.done - t.base
53  if (finished <= 0) return 'estimating'
54  const perStep = (t.lastDoneAt - t.startedAt) / finished
55  const ms = perStep * left - (now - t.lastDoneAt)
56  const mins = Math.round(ms / 60000)
57  if (mins < 1) return '1m left'
58  if (mins < 60) return `${mins}m left`
59  return `${Math.floor(mins / 60)}h ${mins % 60}m left`
60}
61
62type PlanCall = {
63  items?: { status: StepStatus }[]
64  advance?: boolean
65  done?: number[]
66  start?: number
67}
68
69// Applies one update_plan call to the step list, the way the app reads it.
70function applyPlan(prev: StepStatus[], call: PlanCall): StepStatus[] {
71  if (call.items) return call.items.map(i => i.status)
72  const next = [...prev]
73  if (call.advance) {
74    const cur = next.indexOf('in_progress')
75    if (cur >= 0) next[cur] = 'completed'
76    const pending = next.indexOf('pending')
77    if (pending >= 0) next[pending] = 'in_progress'
78  }
79  for (const n of call.done ?? []) if (next[n - 1]) next[n - 1] = 'completed'
80  if (call.start && next[call.start - 1]) {
81    const cur = next.indexOf('in_progress')
82    if (cur >= 0 && cur !== call.start - 1) next[cur] = 'pending'
83    next[call.start - 1] = 'in_progress'
84  }
85  return next
86}
87
88async function store($: EngineInterface, list: Limit[]): Promise<void> {
89  if (list.length === 0) return
90  const clean = list.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
91  const at = await $.clock.now()
92  await update($, limits, () => clean)
93  await update($, readAt, () => at)
94  await $.store.set(LATEST, { at, limits: clean } as Latest)
95  const already = await read($, warned)
96  for (const l of clean) {
97    const tag = `${l.kind}@${l.resetsAt ?? ''}`
98    if (l.percentUsed >= 90 && !already.includes(tag)) {
99      $.ui.toast(`Heads up: ${NAMES[l.kind] ?? l.kind} usage is at ${l.percentUsed}%`)
100      await update($, warned, w => [...w, tag])
101    }
102  }
103}
104
105// Uses the reading in $.store when it is newer than this session's own.
106async function adoptNewest($: EngineInterface): Promise<void> {
107  const saved = (await $.store.get(LATEST)) as Latest | undefined
108  if (!saved || !Array.isArray(saved.limits)) return
109  if (saved.at > (await read($, readAt))) {
110    await update($, limits, () => saved.limits)
111    await update($, readAt, () => saved.at)
112  }
113}
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    const r = await next(e)
118    try {
119      const u = await $.session.usage()
120      await store($, u.rateLimits)
121    } catch {
122      // no reading yet; session.measure fills it in after the first reply
123    }
124    await adoptNewest($)
125    // Every 30 s: take a newer reading another session saved, and redraw so the reset
126    // countdowns and the task's time left keep moving while you're away. Local only: no model calls.
127    $.clock.every(30000, () => {
128      void adoptNewest($).then(() => $.ui.invalidate('ui.render'))
129    })
130    return r
131  })
132
133  on('session.measure', async ($, e, next) => {
134    await store($, e.rateLimits)
135    return next(e)
136  })
137
138  on('tool.call', { tool: PLAN_TOOL }, async ($, e, next) => {
139    const r = await next(e)
140    if (r.deny === undefined && r.isError !== true) {
141      const prev = await read($, steps)
142      const now = await $.clock.now()
143      const fresh = prev.length === 0 || prev.every(s => s === 'completed')
144      const nextSteps = applyPlan(prev, e as unknown as PlanCall)
145      const doneNow = nextSteps.filter(s => s === 'completed').length
146      await update($, timing, t =>
147        fresh ? { startedAt: now, lastDoneAt: now, done: doneNow, base: doneNow }
148        : doneNow > t.done ? { ...t, lastDoneAt: now, done: doneNow }
149        : t)
150      await update($, steps, () => nextSteps)
151    }
152    return r
153  })
154
155  // Wrapping up checks off the whole list.
156  on('tool.call', { tool: WRAP_TOOL }, async ($, e, next) => {
157    const r = await next(e)
158    await update($, steps, prev => prev.map(() => 'completed' as StepStatus))
159    return r
160  })
161
162  // A finished list clears when you send the next message.
163  on('prompt.submit', async ($, e, next) => {
164    const list = await read($, steps)
165    if (list.length > 0 && list.every(s => s === 'completed')) await update($, steps, () => [])
166    return next(e)
167  })
168
169  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170    const list = await read($, limits)
171    const plan = await read($, steps)
172    if (list.length === 0 && plan.length === 0) return next(e)
173    const { Box, Text } = $.ui.resolve(e)
174    const now = await $.clock.now()
175    const sorted = [...list].sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind))
176    const taskPct = plan.length ? Math.round((plan.filter(s => s === 'completed').length / plan.length) * 100) : 0
177    const taskLeft = plan.length ? leftText(plan, await read($, timing), now) : ''
178    // Each segment gets an equal share of the band: halves without a task, thirds with one.
179    // Task goes last, so Session and Week keep their order when a task starts or ends.
180    const segs = sorted.map(l => {
181      // Past its reset time the window has started over, whatever the last reading said.
182      const isReset = l.resetsAt !== undefined && Date.parse(l.resetsAt) <= now
183      const pct = isReset ? 0 : l.percentUsed
184      const color = pct >= 90 ? MIDNIGHT.red : pct >= 80 ? MIDNIGHT.yellow : MIDNIGHT.purple
185      return { key: l.kind, label: NAMES[l.kind] ?? l.kind, pct, color, extra: isReset ? '' : untilText(l.resetsAt, now) }
186    })
187    if (plan.length > 0) segs.push({ key: 'task', label: 'Task', pct: taskPct, color: MIDNIGHT.purple, extra: taskLeft })
188    // Halves get bars twice as long as thirds, so two segments fill the band like three do.
189    const cells = segs.length <= 2 ? 20 : 10
190    // Equal-width segments (flex basis 0, grow 1), each centred between its dividers or the band's edge.
191    const ours = (
192      <Box key="usage" backgroundColor={MIDNIGHT.bg} paddingX={1} width="100%" flexDirection="row">
193        {segs.flatMap((s, i) => [
194          ...(i > 0 ? [<Text key={`div-${s.key}`} color={MIDNIGHT.divider}>|</Text>] : []),
195          <Box key={s.key} width={0} flexGrow={1} flexDirection="row" justifyContent="center">
196            <Text>
197              <Text weight="bold" color={s.color}>{s.label} </Text>
198              <Text color={s.color}>{bar(s.pct, cells)} {s.pct}%</Text>
199              {s.extra ? <Text color={MIDNIGHT.muted}>{' ' + s.extra}</Text> : null}
200            </Text>
201          </Box>,
202        ])}
203      </Box>
204    )
205    if (!e.props.hasSurvey) return ours
206    // A survey or question box holds the band: keep it, with the bar above it.
207    const held = await next(e)
208    return (
209      <Box key="usage-wrap" flexDirection="column" width="100%">
210        {ours}
211        {held}
212      </Box>
213    )
214  })
215}
216
types/index.d.ts 10 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type StepStatus = 'pending' | 'in_progress' | 'completed'
3export type TaskTiming = { startedAt: number; lastDoneAt: number; done: number; base: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'usage-meter': { limits: Limit[]; warned: string[]; steps: StepStatus[]; timing: TaskTiming; readAt: number }
8  }
9}
10