SLOPSHOPPER

sub-meter

Your subscription usage at the bottom right of Claude Code: the 5-hour and weekly windows, as a bar that takes turns.

newspinnertimer
★ 2v0.1.0MITupdated 2026-10-15midudev/better-claude/plugins/sub-meter
A shopper browsing a rack in a slop shop
README

Sub Meter

A Claude Code mod: your subscription usage, always at the bottom right, on the same line as the permission mode.

⏵⏵ auto mode on                                   5h ━━━━━━──── 58% · 2h 10m

Every 8 seconds it switches between the 5-hour window and the weekly one. The bar turns yellow from 75% and red from 90%. After it the time until that window resets.

There is no monthly window: Claude Code only reports the 5-hour and the weekly ones. Off a subscription (API key billing) there is nothing to show, so the footer stays as it is.

While it is on, it also drops the (shift+tab to cycle) hint after the permission mode, to leave room for the meter. Turn that off in /config (hideCycleHint).

Install

/plugin marketplace add midudev/better-claude
/plugin install sub-meter@better-claude

How it works

One hooks module (hooks/register.tsx):

  • session.measure hands it the rate-limit windows after each response (five_hour, seven_day); $.session.usage() gives the first reading at start.
  • The last reading is kept in $.store, so a new session shows it before its first response.
  • A ui.render hook on SessionMode wraps what the engine draws there (its mode labels) and adds the meter after it; $.clock.every switches the window.
  • A ui.render hook on PromptHint rewrites the hint line without (shift+tab to cycle); the engine still draws it, and the mode label before it.

Development

claude plugin validate ./sub-meter   # from the repository root
claude plugin test ./sub-meter       # runs hooks/register.test.tsx against the engine

MIT © Miguel Ángel Durán (midudev)

Source 2 files
hooks/register.tsx 102 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit } from '../types'
5
6const limits = atom({ plugin: 'sub-meter', key: 'limits' } as const, [] as Limit[])
7const view = atom({ plugin: 'sub-meter', key: 'view' } as const, 0)
8
9const WINDOWS = ['five_hour', 'seven_day']
10const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'week' }
11const ROTATE_MS = 8000
12const CELLS = 10
13const CYCLE_HINT = /\s*\([^()]+ to cycle\)/
14
15/** The subscription windows the meter shows, in order, from what the engine measured. */
16const windowsOf = (rateLimits: readonly SessionRateLimit[]): Limit[] =>
17  WINDOWS.flatMap(kind => {
18    const found = rateLimits.find(limit => limit.kind === kind)
19    return found ? [{ kind, percentUsed: found.percentUsed, resetsAt: found.resetsAt }] : []
20  })
21
22const colorFor = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'claude')
23
24/** "42m", "3h 5m", "2d 4h": how long until the window resets. */
25function until (ms: number) {
26  const minutes = Math.max(1, Math.round(ms / 60000))
27  if (minutes < 60) return `${minutes}m`
28  const hours = Math.floor(minutes / 60)
29  if (hours < 24) return `${hours}h ${minutes % 60}m`
30  return `${Math.floor(hours / 24)}d ${hours % 24}h`
31}
32
33/** Keeps the latest reading for the meter, and for the next session until a response reports again. */
34async function remember ($: EngineInterface, found: Limit[]) {
35  if (found.length === 0) return
36  await update($, limits, () => found)
37  await $.store.set('limits', found)
38}
39
40export const register: Register = (on, options) => {
41  const hideCycleHint = options.hideCycleHint !== false
42
43  on('session.start', async ($, e, next) => {
44    const saved = await $.store.get('limits')
45    if (Array.isArray(saved)) await update($, limits, () => saved as Limit[])
46
47    const usage = await $.session.usage()
48    await remember($, windowsOf(usage.rateLimits))
49
50    $.clock.every(ROTATE_MS, () => { void update($, view, n => n + 1) })
51    return next(e)
52  })
53
54  on('session.measure', async ($, e, next) => {
55    await remember($, windowsOf(e.rateLimits))
56    return next(e)
57  })
58
59  // Bottom right of the prompt footer: the engine's mode labels, then the meter.
60  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
61    const all = await read($, limits)
62    if (all.length === 0) return next(e)
63
64    const now = await $.clock.now()
65    const shown = all[(await read($, view)) % all.length]
66    const resetsIn = shown.resetsAt === undefined ? undefined : Date.parse(shown.resetsAt) - now
67    const hasReset = resetsIn !== undefined && resetsIn <= 0
68    const percent = hasReset ? 0 : shown.percentUsed
69    const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * CELLS)
70    // The engine's own labels (and any other plugin's) stay as drawn, before the meter.
71    const labels = e.props.modes.length > 0 ? await next(e) : null
72    const { Box, Text } = $.ui.resolve(e)
73
74    return (
75      <Box flexDirection="row">
76        {labels}
77        {labels ? <Text dimColor> · </Text> : null}
78        <Text dimColor>{LABELS[shown.kind] ?? shown.kind} </Text>
79        <Text color={colorFor(percent)}>{'━'.repeat(filled)}</Text>
80        <Text dimColor>{'─'.repeat(CELLS - filled)}</Text>
81        <Text> {Math.round(percent)}%</Text>
82        {resetsIn !== undefined && !hasReset ? <Text dimColor> · {until(resetsIn)}</Text> : null}
83      </Box>
84    )
85  })
86
87  // The engine draws the mode label itself; the hint line after it starts with "(shift+tab to cycle)".
88  if (hideCycleHint) {
89    on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
90      if (!CYCLE_HINT.test(e.props.hint)) return next(e)
91
92      const hint = e.props.hint
93        .replace(CYCLE_HINT, ' · ')
94        .split('·')
95        .map(part => part.trim())
96        .filter(Boolean)
97        .join(' · ')
98      return next({ ...e, props: { ...e.props, hint } })
99    })
100  }
101}
102
types/index.d.ts 9 lines
1/** One rate-limit window of the subscription, as the last API response reported it. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'sub-meter': { limits: Limit[]; view: number }
7  }
8}
9