SLOPSHOPPER

usage-donuts

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

newbandcommandtimer
v0.3.0no licenseupdated 2026-10-26MichaelFu1998-create/claude-usage-donuts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-donuts
› 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 › /usage-donuts ⎿ usage-donuts: Usage donuts hidden. 5-hour session 31% · Weekly — · Weekly Fable — ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5-hour session 31% · Weekly — · Weekly Fable —
README

usage-donuts

A Claude Code mod that shows three usage cards in the band just above the chat input, each 40px high:

  • 5-hour session: the 5-hour rate-limit window
  • Weekly: the 7-day window
  • Weekly Fable: a separate Fable weekly window, when the account reports one

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.

Install

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.

Test

claude plugin test .
Source 2 files
hooks/register.tsx 173 lines
1import 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}
173
types/index.d.ts 8 lines
1export type Gauge = { percent: number | null; resetsAt?: string }
2
3export type Gauges = {
4  session: Gauge
5  weekly: Gauge
6  weeklyFable: Gauge
7}
8