SLOPSHOPPER

usage-rings

Rings above the prompt showing the 5-hour, weekly and Fable weekly usage limits.

newbandtimer
v0.2.0no licenseupdated 2026-10-14moa-os/claude-mods/usage-rings
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-rings
› 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 ◔ 31% 5-hour ○ – Weekly ○ – Fable ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 31% 5-hour ○ – Weekly ○ – Fable
README

claude-mods

Mods for Claude Code: small plugins that change its interface.

Mods

usage-rings

Three rings above the prompt showing how much of your plan's usage limits you've used: the 5-hour limit, the weekly limit across all models, and the weekly Fable limit. Each shows the percent used and the time until it resets, and turns amber at 50% and red at 80%.

  • In the desktop app the rings are drawn as small circles. In a terminal they're a text row with circle symbols.
  • The 5-hour and weekly figures come from Claude Code itself. The Fable figure comes from the desktop app's usage card, so in a terminal-only session it may show "–".
  • Only plans with usage limits have these figures (not API keys, Bedrock, Vertex or gateways).

Install

In a Claude Code terminal session, run:

/plugin install usage-rings --marketplace moa-os/claude-mods

Answer y to add the marketplace, then pick the user scope. Once installed from a terminal, the mod also loads in the desktop app's sessions on that machine.

Update

After a new version is pushed here:

claude plugin update usage-rings

Then restart Claude Code, or run /reload-plugins in a terminal session.

Developing

Each mod is a folder listed in .claude-plugin/marketplace.json. To check one:

claude plugin validate usage-rings
claude plugin test usage-rings

To work on the mods from a clone, add the clone itself as a marketplace and install from it. Claude Code then reads the mods straight from the clone, so /reload-plugins picks up your edits:

claude plugin marketplace add <path to this clone>
claude plugin install usage-rings@claude-mods
Source 3 files
hooks/register.tsx 135 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey } from 'claude-code'
3
4import type { Entry, Level } from './usage'
5import {
6  EMPTY,
7  RINGS,
8  fromApp,
9  fromEngine,
10  glyphOf,
11  levelOf,
12  mergeAll,
13  ringSvg,
14  shownPercent,
15  timeLeft,
16} from './usage'
17
18const limits = atom({ plugin: 'usage-rings', key: 'limits' } as const, EMPTY)
19// Bumped once a minute so the reset countdowns redraw.
20const tick = atom({ plugin: 'usage-rings', key: 'tick' } as const, 0)
21
22// The desktop app's own MCP server; its usage card is the only source of the Fable weekly
23// window when the API's rate-limit headers don't carry it. Absent in a plain terminal.
24const APP_SERVER = 'ccd_session_mgmt'
25const APP_TOOL = 'get_usage'
26
27const MINUTE = 60_000
28const APP_POLL_MS = 2 * MINUTE
29const APP_POLL_AFTER_TURN_MS = 30_000
30
31const THEME: Record<Level, ThemeKey> = {
32  none: 'inactive',
33  low: 'success',
34  mid: 'warning',
35  high: 'error',
36}
37
38// Module variables are fine here: a hot reload starts them over and the first refresh polls anyway.
39let lastAppPoll = -Infinity
40let hadTurnSincePoll = false
41
42async function take($: EngineInterface, entries: Entry[]) {
43  if (entries.length === 0) return
44  const now = await $.clock.now()
45  await update($, limits, prev => mergeAll(prev ?? EMPTY, entries, now))
46}
47
48async function pollEngine($: EngineInterface) {
49  const { rateLimits } = await $.session.usage()
50  await take($, fromEngine(rateLimits))
51}
52
53async function pollApp($: EngineInterface) {
54  lastAppPoll = await $.clock.now()
55  hadTurnSincePoll = false
56  await take($, fromApp(await $.mcp.call(APP_SERVER, APP_TOOL)))
57}
58
59async function refresh($: EngineInterface, isFirst: boolean) {
60  const now = await $.clock.now()
61  const wait = hadTurnSincePoll ? APP_POLL_AFTER_TURN_MS : APP_POLL_MS
62  const polls = [pollEngine($)]
63  if (isFirst || now - lastAppPoll >= wait) polls.push(pollApp($))
64  await Promise.allSettled(polls)
65  await update($, tick, n => (n ?? 0) + 1)
66}
67
68export const register: Register = on => {
69  on('session.start', async ($, e, next) => {
70    const started = await next(e)
71    $.clock.after(1, () => void refresh($, true))
72    $.clock.every(MINUTE, () => void refresh($, false))
73    return started
74  })
75
76  // Pushed by the engine whenever a window moves a whole point.
77  on('session.measure', async ($, e, next) => {
78    await take($, fromEngine(e.rateLimits))
79    return next(e)
80  })
81
82  on('turn.complete', async ($, e, next) => {
83    hadTurnSincePoll = true
84    return next(e)
85  })
86
87  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88    if (e.props.hasSurvey) return next(e)
89
90    const current = await read($, limits)
91    await read($, tick)
92    const now = await $.clock.now()
93    const rings = RINGS.map(({ ring, label }) => {
94      const reading = current[ring]
95      const percent = shownPercent(reading, now)
96      return { ring, label, percent, left: timeLeft(reading, now) }
97    })
98
99    if (e.surface === 'terminal') {
100      const { Box, Text } = $.ui.resolve(e)
101      return (
102        <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
103          {rings.map(r => (
104            <Box key={r.ring} flexDirection="row" columnGap={1}>
105              <Text color={THEME[levelOf(r.percent)]} bold>
106                {glyphOf(r.percent)} {r.percent === null ? '–' : `${Math.round(r.percent)}%`}
107              </Text>
108              <Text>{r.label}</Text>
109              {r.left !== '' && <Text dimColor>{r.left}</Text>}
110            </Box>
111          ))}
112        </Box>
113      )
114    }
115
116    const { Box, Svg, Text } = $.ui.resolve(e)
117    return (
118      <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={3}>
119        {rings.map(r => (
120          <Box key={r.ring} flexDirection="row" alignItems="center" columnGap={1}>
121            <Svg
122              source={ringSvg(r.percent)}
123              alt={`${r.label}: ${r.percent === null ? 'no reading yet' : `${Math.round(r.percent)}% used`}`}
124              width={30}
125              height={30}
126            />
127            <Text bold>{r.label}</Text>
128            {r.left !== '' && <Text dimColor>{r.left}</Text>}
129          </Box>
130        ))}
131      </Box>
132    )
133  })
134}
135
hooks/usage.ts 154 lines
1import type { McpToolResult, SessionRateLimit } from 'claude-code'
2
3import type { Limits, Reading } from '../types'
4
5export type Ring = keyof Limits
6export type Entry = { ring: Ring; reading: Reading }
7export type Level = 'none' | 'low' | 'mid' | 'high'
8
9export const EMPTY: Limits = { fiveHour: null, weekly: null, fable: null }
10
11export const RINGS: readonly { ring: Ring; label: string }[] = [
12  { ring: 'fiveHour', label: '5-hour' },
13  { ring: 'weekly', label: 'Weekly' },
14  { ring: 'fable', label: 'Fable' },
15]
16
17const MINUTE = 60_000
18const HOUR = 60 * MINUTE
19const DAY = 24 * HOUR
20
21// The engine names windows after the API's rate-limit kinds (`five_hour`, `seven_day`,
22// `seven_day_fable`); the app's usage card labels them ("5-hour limit", "Weekly · Fable").
23// Fable is checked first because its label also says "Weekly".
24export function ringOf(name: string): Ring | null {
25  const n = name.toLowerCase()
26  if (n.includes('fable')) return 'fable'
27  if (n === 'five_hour' || n.includes('5-hour')) return 'fiveHour'
28  if (n === 'seven_day' || n.includes('all models')) return 'weekly'
29  return null
30}
31
32export function readingOf(percent: number, resetsAt: string | undefined): Reading {
33  const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
34  return { percent, resetsAt: Number.isFinite(at) ? at : null }
35}
36
37export function fromEngine(rateLimits: readonly SessionRateLimit[]): Entry[] {
38  const entries: Entry[] = []
39  for (const limit of rateLimits) {
40    const ring = ringOf(limit.kind)
41    if (ring !== null) entries.push({ ring, reading: readingOf(limit.percentUsed, limit.resetsAt) })
42  }
43  return entries
44}
45
46type AppWindow = { label?: unknown; percentUsed?: unknown; resetsAt?: unknown }
47
48// The desktop app's get_usage tool answers JSON in a text block:
49// { plan: { status: "ok", windows: [{ label, percentUsed, resetsAt }] } }.
50export function fromApp(result: McpToolResult): Entry[] {
51  if (result.isError) return []
52  const data = result.structuredContent ?? parseText(result)
53  const plan = (data as { plan?: { status?: unknown; windows?: unknown } } | undefined)?.plan
54  if (plan?.status !== 'ok' || !Array.isArray(plan.windows)) return []
55
56  const entries: Entry[] = []
57  for (const window of plan.windows as AppWindow[]) {
58    if (typeof window.label !== 'string' || typeof window.percentUsed !== 'number') continue
59    const ring = ringOf(window.label)
60    const resetsAt = typeof window.resetsAt === 'string' ? window.resetsAt : undefined
61    if (ring !== null) entries.push({ ring, reading: readingOf(window.percentUsed, resetsAt) })
62  }
63  return entries
64}
65
66function parseText(result: McpToolResult): unknown {
67  const text = result.content.find(block => block.type === 'text')?.text
68  if (text === undefined) return undefined
69  try {
70    return JSON.parse(text)
71  } catch {
72    return undefined
73  }
74}
75
76// Both sources report the same window at slightly different moments. Within one window usage
77// only grows, so the higher figure is the fresher one; a later reset time is a new window.
78export function merge(prev: Reading | null, next: Reading, now: number): Reading {
79  if (prev === null) return next
80  if (prev.resetsAt !== null && prev.resetsAt <= now) return next
81  if (prev.resetsAt === null || next.resetsAt === null) return next
82  if (next.resetsAt > prev.resetsAt + MINUTE) return next
83  if (next.resetsAt < prev.resetsAt - MINUTE) return prev
84  return next.percent >= prev.percent ? next : prev
85}
86
87export function mergeAll(limits: Limits, entries: readonly Entry[], now: number): Limits {
88  const merged = { ...limits }
89  for (const { ring, reading } of entries) merged[ring] = merge(merged[ring], reading, now)
90  return merged
91}
92
93/** The percent to draw: 0 once the window has reset, null with no reading yet. */
94export function shownPercent(reading: Reading | null, now: number): number | null {
95  if (reading === null) return null
96  if (reading.resetsAt !== null && reading.resetsAt <= now) return 0
97  return Math.max(0, Math.min(100, reading.percent))
98}
99
100export function levelOf(percent: number | null): Level {
101  if (percent === null) return 'none'
102  if (percent >= 80) return 'high'
103  if (percent >= 50) return 'mid'
104  return 'low'
105}
106
107/** Time until the window resets ("4h 59m"); empty with no reading or no reset time. */
108export function timeLeft(reading: Reading | null, now: number): string {
109  if (reading === null || reading.resetsAt === null) return ''
110  const ms = reading.resetsAt - now
111  if (ms <= 0) return 'reset'
112  if (ms >= DAY) return `${Math.floor(ms / DAY)}d ${Math.floor((ms % DAY) / HOUR)}h`
113  if (ms >= HOUR) return `${Math.floor(ms / HOUR)}h ${Math.floor((ms % HOUR) / MINUTE)}m`
114  return `${Math.max(1, Math.ceil(ms / MINUTE))}m`
115}
116
117// A quarter-step circle glyph for the terminal, which has no Svg.
118export function glyphOf(percent: number | null): string {
119  if (percent === null || percent < 12.5) return '○'
120  if (percent < 37.5) return '◔'
121  if (percent < 62.5) return '◑'
122  if (percent < 87.5) return '◕'
123  return '●'
124}
125
126const SVG_COLORS: Record<Level, string> = {
127  none: '#8a8a8a',
128  low: '#2fa36b',
129  mid: '#d99a00',
130  high: '#e5484d',
131}
132
133// Drawn as an image, so it can't follow the page's theme: the track is a translucent grey and
134// the arc and number use mid-tone colors that read on both light and dark backgrounds.
135export function ringSvg(percent: number | null): string {
136  const color = SVG_COLORS[levelOf(percent)]
137  const value = percent ?? 0
138  const label = percent === null ? '–' : `${Math.round(percent)}%`
139  // Drawn at about 30px, so the number takes most of the ring to stay readable; "100%" needs
140  // a smaller size to fit inside the track.
141  const fontSize = label.length > 3 ? 12 : 14
142  const arc =
143    value > 0
144      ? `<circle cx="20" cy="20" r="18" fill="none" stroke="${color}" stroke-width="3.5" stroke-linecap="round" pathLength="100" stroke-dasharray="${value} 100" transform="rotate(-90 20 20)"/>`
145      : ''
146  return (
147    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">` +
148    `<circle cx="20" cy="20" r="18" fill="none" stroke="#8a8a8a" stroke-opacity="0.3" stroke-width="3.5"/>` +
149    arc +
150    `<text x="20" y="20" text-anchor="middle" dominant-baseline="central" font-family="system-ui,-apple-system,'Segoe UI',sans-serif" font-size="${fontSize}" font-weight="700" fill="${color}">${label}</text>` +
151    `</svg>`
152  )
153}
154
types/index.d.ts 16 lines
1/** One limit window: how much is used (0 to 100) and when it resets (epoch ms). */
2export type Reading = { percent: number; resetsAt: number | null }
3
4/** The three windows the band draws; null until a source has reported one. */
5export type Limits = {
6  fiveHour: Reading | null
7  weekly: Reading | null
8  fable: Reading | null
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'usage-rings': { limits: Limits; tick: number }
14  }
15}
16