Four usage donuts above the chat input: context, session (5h), weekly, and weekly Fable limits

A Claude Code mod that shows three usage cards in the band just above the chat input, each 40px high:
Each card has a usage donut, green under 25%, yellow from 25%, orange from 50% and red above 75%. The 5-hour session and Weekly cards also have a blue countdown donut showing the time left until the window resets: hours and minutes for the 5-hour window, days and hours for the weekly ones, updated every minute. In the terminal the same figures show as one line of text.
Type /usage-donuts to hide the band, and again to show it.
At the prompt of a terminal session:
/plugin install usage-donuts --marketplace MichaelFu1998-create/claude-usage-donuts
From a shell:
claude plugin install usage-donuts --marketplace MichaelFu1998-create/claude-usage-donuts
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with MichaelFu1998-create/claude-usage-donuts, then Add plugin.
claude plugin test .hooks/register.tsx 173 lines1import type { Register, SessionRateLimit } from 'claude-code'
2
3import type { Gauge, Gauges } from '../types'
4
5const EMPTY: Gauge = { percent: null }
6
7// Kept in the module: session.start re-reads usage after a hot reload.
8let now: Gauges = { session: EMPTY, weekly: EMPTY, weeklyFable: EMPTY }
9// Hidden and shown again with /usage-donuts.
10let isClosed = false
11
12const MINUTE = 60_000
13const HOUR = 60 * MINUTE
14const DAY = 24 * HOUR
15
16// Each gauge, and the window its countdown donut runs over; Weekly Fable shows
17// usage alone.
18const GAUGES: { id: keyof Gauges; label: string; windowMs?: number }[] = [
19 { id: 'session', label: '5-hour session', windowMs: 5 * HOUR },
20 { id: 'weekly', label: 'Weekly', windowMs: 7 * DAY },
21 { id: 'weeklyFable', label: 'Weekly Fable' },
22]
23
24// The engine names windows `five_hour`, `seven_day` and `spend_limit`; a
25// model-specific weekly window arrives as another kind, matched loosely.
26function isFableWeekly(kind: string) {
27 return /fable/i.test(kind) || (/seven_day|weekly/i.test(kind) && kind !== 'seven_day')
28}
29
30function toGauges(limits: readonly SessionRateLimit[]): Gauges {
31 const pick = (match: (kind: string) => boolean): Gauge => {
32 const limit = limits.find(l => match(l.kind))
33 return limit ? { percent: limit.percentUsed, resetsAt: limit.resetsAt } : EMPTY
34 }
35
36 return {
37 session: pick(kind => kind === 'five_hour'),
38 weekly: pick(kind => kind === 'seven_day'),
39 weeklyFable: pick(isFableWeekly),
40 }
41}
42
43function colorFor(percent: number) {
44 if (percent > 75) return '#d9534f'
45 if (percent >= 50) return '#e8873a'
46 if (percent >= 25) return '#d4b62a'
47 return '#3fa66b'
48}
49
50function msLeft(gauge: Gauge, nowMs: number) {
51 if (!gauge.resetsAt) return null
52 const ms = Date.parse(gauge.resetsAt) - nowMs
53 return Number.isFinite(ms) && ms > 0 ? ms : null
54}
55
56// Hours and minutes inside the 5-hour window; days and hours for a weekly one.
57// `isCompact` is the form the countdown donut's centre has room for.
58function formatLeft(ms: number, windowMs: number, isCompact = false) {
59 if (windowMs <= 5 * HOUR) {
60 const total = Math.ceil(ms / MINUTE)
61 const [h, m] = [Math.floor(total / 60), total % 60]
62 if (isCompact) return h > 0 ? `${h}:${String(m).padStart(2, '0')}` : `${m}m`
63 return h > 0 ? `${h}h ${m}m` : `${m}m`
64 }
65 const total = Math.ceil(ms / HOUR)
66 const [d, h] = [Math.floor(total / 24), total % 24]
67 if (d === 0) return `${h}h`
68 return isCompact ? `${d}d${h}h` : `${d}d ${h}h`
69}
70
71function percentText(gauge: Gauge) {
72 return gauge.percent === null ? '—' : `${Math.round(gauge.percent)}%`
73}
74
75const H = 40
76const R = 13
77const C = 2 * Math.PI * R
78
79function ring(cx: number, fraction: number | null, color: string, centre: string) {
80 const arc =
81 fraction === null
82 ? ''
83 : `<circle cx="${cx}" cy="20" r="${R}" fill="none" stroke="${color}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${Math.max(0, Math.min(1, fraction)) * C} ${C}" transform="rotate(-90 ${cx} 20)"/>`
84 return `<circle class="k" cx="${cx}" cy="20" r="${R}" fill="none" stroke-width="4"/>${arc}<text class="c" x="${cx}" y="23" text-anchor="middle"${centre.length > 3 ? ' style="font-size:6.5px"' : ''}>${centre}</text>`
85}
86
87// One card, 40px high: the usage donut, the countdown donut when the window
88// resets, and the name with a line of detail beside them.
89function cardSvg(g: (typeof GAUGES)[number], gauge: Gauge, nowMs: number) {
90 const usage = ring(20, gauge.percent === null ? null : gauge.percent / 100, gauge.percent === null ? '#999' : colorFor(gauge.percent), percentText(gauge))
91 const left = g.windowMs ? msLeft(gauge, nowMs) : null
92 const countdown = g.windowMs ? ring(54, left === null ? null : left / g.windowMs, '#4a90d9', left === null ? '—' : formatLeft(left, g.windowMs, true)) : ''
93 const textX = g.windowMs ? 76 : 42
94 const detail =
95 gauge.percent === null
96 ? 'no reading yet'
97 : `${percentText(gauge)} used${left === null || !g.windowMs ? '' : ` · resets in ${formatLeft(left, g.windowMs)}`}`
98 const width = textX + Math.max(g.label.length * 6.6, detail.length * 5.4) + 6
99
100 return {
101 width: Math.ceil(width),
102 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.ceil(width)}" height="${H}" viewBox="0 0 ${Math.ceil(width)} ${H}">
103<style>.c{fill:#555;font:600 8px -apple-system,system-ui,sans-serif}.n{fill:#333;font:600 11px -apple-system,system-ui,sans-serif}.d{fill:#888;font:10px -apple-system,system-ui,sans-serif}.k{stroke:#e2e2e2}
104@media (prefers-color-scheme: dark){.c{fill:#ddd}.n{fill:#eee}.d{fill:#999}.k{stroke:#444}}</style>
105${usage}${countdown}
106<text class="n" x="${textX}" y="17">${g.label}</text>
107<text class="d" x="${textX}" y="30">${detail}</text>
108</svg>`,
109 alt: `${g.label}: ${detail}`,
110 }
111}
112
113function textLine(nowMs: number) {
114 return GAUGES.map(g => {
115 const gauge = now[g.id]
116 const left = g.windowMs ? msLeft(gauge, nowMs) : null
117 return `${g.label} ${percentText(gauge)}${left === null || !g.windowMs ? '' : ` (${formatLeft(left, g.windowMs)})`}`
118 }).join(' · ')
119}
120
121export const register: Register = on => {
122 on('session.start', async ($, e, next) => {
123 const result = await next(e)
124 await $.command.register({ name: 'usage-donuts', description: 'Show or hide the usage donuts above the prompt' })
125 const usage = await $.session.usage()
126 now = toGauges(usage.rateLimits)
127 $.ui.invalidate('ui.render')
128 // Redraw each minute so the countdowns move between replies.
129 $.clock.every(MINUTE, () => $.ui.invalidate('ui.render'))
130 return result
131 })
132
133 on('session.measure', async ($, e, next) => {
134 now = toGauges(e.rateLimits)
135 $.ui.invalidate('ui.render')
136 return next(e)
137 })
138
139 on('command.run', { command: 'usage-donuts' }, async $ => {
140 isClosed = !isClosed
141 $.ui.invalidate('ui.render')
142 return { text: isClosed ? 'Usage donuts hidden.' : 'Usage donuts shown.' }
143 })
144
145 // The band directly above the prompt: room for the full names, and the one
146 // site under the transcript where the desktop draws a mod's Svg.
147 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
148 if (e.props.hasSurvey || isClosed) return next(e)
149
150 const nowMs = await $.clock.now()
151 const ui = $.ui.resolve(e)
152
153 if (e.surface === 'desktop' && 'Svg' in ui) {
154 const { Box, Svg } = ui
155 return (
156 <Box flexDirection="row" flexWrap="wrap" justifyContent="flex-end" gap={2}>
157 {GAUGES.map(g => {
158 const card = cardSvg(g, now[g.id], nowMs)
159 return <Svg source={card.source} alt={card.alt} width={card.width} height={H} />
160 })}
161 </Box>
162 )
163 }
164
165 const { Box, Text } = ui
166 return (
167 <Box flexDirection="row" justifyContent="flex-end">
168 <Text dimColor>{textLine(nowMs)}</Text>
169 </Box>
170 )
171 })
172}
173types/index.d.ts 8 lines1export type Gauge = { percent: number | null; resetsAt?: string }
2
3export type Gauges = {
4 session: Gauge
5 weekly: Gauge
6 weeklyFable: Gauge
7}
8