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

Mods for Claude Code: small plugins that change its interface.
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 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.
After a new version is pushed here:
claude plugin update usage-rings
Then restart Claude Code, or run /reload-plugins in a terminal session.
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-modshooks/register.tsx 135 lines1import { 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}
135hooks/usage.ts 154 lines1import 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}
154types/index.d.ts 16 lines1/** 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