SLOPSHOPPER

usage-gauge

Shows plan window usage, reset times and session cost in the band above the prompt.

newpanebandcommandtoasttimer
★ 2v1.3.0no licenseupdated 2026-10-22Abdallah-Abdelazim/claude-code-mods/plugins/usage-gauge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-gauge
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ 5h ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 31% ↻ NaNm │ ┃ context ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 49% ⏺ Read(src/auth.ts) │ ┃ session $0.42 (+$0.00) ⎿ 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-gauge │ ⎿ usage-gauge: Usage pane opened. │ │ ◔ USAGE 5h ▆▆▆▆▆▆ 31% ↻NaNm │ session $0.42 (+$0.00) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ USAGE 5h ▆▆▆▆▆▆ 31% ↻NaNm │ session $0.42 (+$0.00) ⟨Claude Code's own drawing⟩
Pane · Usage
5h ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 31% ↻ NaNm context ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 49% session $0.42 (+$0.00)
README

claude-code-mods

<img width="1096" height="808" alt="Screenshot 2026-10-19 at 8 25 29 PM" src="https://github.com/user-attachments/assets/b22cc708-5a70-4333-b3ba-79517a3b7603" />

Mods for Claude Code that add a band above the prompt, a side pane or a slash command. Each is its own plugin, so you can install only the ones you want.

The mods

PluginBandWhat it doesCommand
band-rulelineDraws a dim line above the bands to separate them from Claude's output. List it before the other mods (see below).none
cache-clockCACHEShows whether the prompt cache is still warm, the time left, % reused and the cold-rewrite cost, with a Compact button. Toasts shortly before the cache goes cold.`/cache-clock on \off \status`
usage-gaugeUSAGEPlan usage windows (5h, week, others) with reset times, the session cost and a pace warning. Toasts at 75% and 90%./usage-gauge (opens a pane), `on \off \warn <a> <b> \status`
usage-limitsfooterSession (5h) and week (7d) rate-limit bars in the prompt footer.none
auto-handoffCONTEXTWatches how full the chat is and saves a HANDOFF.md when it reaches a threshold, then offers to clear and resume.`/auto-handoff on \off \now \resume \threshold <n> \mode auto\ask \hide \show \status`
agent-watchAGENTSShows the main chat and helper agents with their models and tasks, plus the skills that ran this turn. Opens an Agents pane./agent-watch (opens the Agents pane), `on \off \clear \help`

Bands stack in the order their plugins appear in enabledPlugins in ~/.claude/settings.json, first on top. To reorder, move the lines and run /reload-plugins; claude plugin install adds new entries at the end. band-rule draws its line above the whole stack only when it is listed first. The author's order, top to bottom: band-rule, agent-watch, auto-handoff, cache-clock, usage-gauge, usage-limits. The last one draws in the footer, so its place doesn't matter. The Band column is the title each mod shows in its band.

Each band is one row. In a narrow terminal the end of the row is cut off with … instead of wrapping.

usage-gauge and usage-limits both show plan usage, so enable only one.

In auto mode, auto-handoff sends the handoff prompt as soon as the threshold is crossed, even while Claude is mid-task. The default ask mode only offers the button.

cache-clock has a cacheMinutes option (5 or 60, default 60). Other settings are set through each mod's commands and persist between sessions.

Install

Install a mod from the marketplace:

/plugin install cache-clock --marketplace Abdallah-Abdelazim/claude-code-mods

Run it once per mod, changing the plugin name. From a local clone, use --marketplace ~/path/to/claude-code-mods instead.

To try one without installing: claude --plugin-dir plugins/<name>.

Requirements

These mods use Claude Code's function-hook plugin API, which is in early access; they were written against Claude Code 2.1.303. Where an organization manages Claude Code, plugins you install don't run the session.compact, skill.prompt and prompt.compose hooks.

Develop

Each folder under plugins/ is a standalone mod: .claude-plugin/plugin.json, hooks/, tsconfig.json, a *.test.ts and, for a mod that keeps state, types/index.d.ts. See CLAUDE.md for the conventions and the release steps.

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

The engine writes .claude-plugin/types/ inside a mod when it loads it; the folder is git-ignored.

Source 3 files
hooks/register.tsx 239 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, SessionMeasureInput, SessionRateLimit } from 'claude-code'
3
4import type { Limit } from '../types'
5import { TRACK, bar, countdown, outIn, resetText, shortName, titleName, toneOf } from './format'
6
7const KEY = 'usage-gauge'
8const PANE = 'usage-gauge'
9const USAGE = 'Usage: /usage-gauge | on | off | warn <low> <high> | status'
10const TICK_MS = 30_000
11
12const isOnA = atom({ plugin: 'usage-gauge', key: 'isOn' } as const, true)
13const limitsA = atom({ plugin: 'usage-gauge', key: 'limits' } as const, [] as Limit[])
14const contextA = atom({ plugin: 'usage-gauge', key: 'context' } as const, null)
15const costA = atom({ plugin: 'usage-gauge', key: 'cost' } as const, null)
16const turnBaseA = atom({ plugin: 'usage-gauge', key: 'turnBase' } as const, null)
17const thresholdsA = atom({ plugin: 'usage-gauge', key: 'thresholds' } as const, { low: 75, high: 90 })
18const notifiedA = atom({ plugin: 'usage-gauge', key: 'notified' } as const, {})
19const paceA = atom({ plugin: 'usage-gauge', key: 'pace' } as const, null)
20
21const toLimits = (list: readonly SessionRateLimit[]): Limit[] =>
22  list.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt ?? null }))
23
24async function load($: Engine) {
25  const saved = (await $.store.get(KEY)) as { isOn?: boolean; low?: number; high?: number } | null
26  await update($, isOnA, () => saved?.isOn ?? true)
27  await update($, thresholdsA, t => ({ low: saved?.low ?? t.low, high: saved?.high ?? t.high }))
28}
29
30async function save($: Engine) {
31  const { low, high } = await read($, thresholdsA)
32  await $.store.set(KEY, { isOn: await read($, isOnA), low, high })
33}
34
35async function seed($: Engine) {
36  const usage = await $.session.usage().catch(() => null)
37  if (!usage) return
38  await update($, limitsA, () => toLimits(usage.rateLimits))
39  await update($, contextA, () => usage.context.percent ?? null)
40  await update($, costA, () => usage.cost?.usd ?? null)
41}
42
43async function record($: Engine, e: SessionMeasureInput) {
44  const now = await $.clock.now()
45  const limits = toLimits(e.rateLimits)
46  await update($, limitsA, () => limits)
47  await update($, contextA, () => e.context.percent ?? null)
48  const usd = e.cost?.usd
49  if (usd !== undefined) await update($, costA, () => usd)
50
51  const five = limits.find(l => l.kind === 'five_hour')
52  const fiveReset = five?.resetsAt
53  if (five && fiveReset) {
54    const pace = await read($, paceA)
55    if (pace?.resetsAt !== fiveReset) {
56      await update($, paceA, () => ({ at: now, percent: five.percent, resetsAt: fiveReset }))
57    }
58  }
59
60  const { low, high } = await read($, thresholdsA)
61  const isOn = await read($, isOnA)
62  for (const l of limits) {
63    const level = l.percent >= high ? high : l.percent >= low ? low : null
64    const prev = (await read($, notifiedA))[l.kind]
65    if (level === null || (prev && prev.resetsAt === l.resetsAt && prev.level >= level)) continue
66    await update($, notifiedA, n => ({ ...n, [l.kind]: { resetsAt: l.resetsAt, level } }))
67    if (!isOn) continue
68    const when = resetText(l.kind, l.resetsAt, now)
69    const reset = when ? ` — resets ${l.kind === 'five_hour' ? 'in ' : ''}${when}` : ''
70    $.ui.toast(`${titleName(l.kind)} limit at ${level}%${reset}`)
71  }
72}
73
74async function startTurn($: Engine) {
75  const cost = await read($, costA)
76  await update($, turnBaseA, () => cost)
77}
78
79async function summary($: Engine) {
80  const limits = await read($, limitsA)
81  const context = await read($, contextA)
82  const cost = await read($, costA)
83  const { low, high } = await read($, thresholdsA)
84  const now = await $.clock.now()
85  const lines = limits.map(
86    l => `${shortName(l.kind)} ${Math.round(l.percent)}%${l.resetsAt ? ` · resets ${resetText(l.kind, l.resetsAt, now)}` : ''}`,
87  )
88  if (context !== null) lines.push(`context ${Math.round(context)}%`)
89  if (cost !== null) lines.push(`session $${cost.toFixed(2)}`)
90  if (lines.length === 0) lines.push('No usage data yet.')
91  lines.push(`toasts at ${low}% and ${high}%`)
92  return lines.join('\n')
93}
94
95export const register: Register = on => {
96  on('session.start', async ($, e, next) => {
97    await $.command.register({
98      name: 'usage-gauge',
99      description: 'Show plan usage, reset times and session cost.',
100      argumentHint: 'on | off | warn <low> <high> | status',
101    })
102    await load($)
103    await seed($)
104    $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
105
106    return next(e)
107  })
108
109  on('session.measure', async ($, e, next) => {
110    await record($, e)
111
112    return next(e)
113  })
114
115  on('turn.start', async ($, e, next) => {
116    await startTurn($)
117
118    return next(e)
119  })
120
121  on('command.run', { command: 'usage-gauge' }, async ($, e) => {
122    const [cmd = '', a = '', b = ''] = e.args.trim().split(/\s+/)
123    if (cmd === '') {
124      await $.ui.open({ id: PANE, title: 'Usage' })
125      return { text: 'Usage pane opened.' }
126    }
127    if (cmd === 'on' || cmd === 'off') {
128      await update($, isOnA, () => cmd === 'on')
129      await save($)
130      return { text: `Usage gauge ${cmd}.` }
131    }
132    if (cmd === 'warn') {
133      const low = Number(a)
134      const high = Number(b)
135      if (!(Number.isInteger(low) && Number.isInteger(high) && low >= 1 && low < high && high <= 100)) {
136        return { text: 'Usage: /usage-gauge warn <low> <high>, whole numbers 1 to 100, low below high.' }
137      }
138      await update($, thresholdsA, () => ({ low, high }))
139      await save($)
140      return { text: `Toasts at ${low}% and ${high}%.` }
141    }
142    if (cmd === 'status') return { text: await summary($) }
143
144    return { text: USAGE }
145  })
146
147  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
148    const below = await next(e)
149    if (!(await read($, isOnA))) return below
150
151    const { Box, Text } = $.ui.resolve(e)
152    const limits = await read($, limitsA)
153    const context = await read($, contextA)
154    const cost = await read($, costA)
155    const base = await read($, turnBaseA)
156    const pace = await read($, paceA)
157    const now = await $.clock.now()
158    const five = limits.find(l => l.kind === 'five_hour')
159    const out = five && pace ? outIn(pace, five.percent, now) : null
160    const delta = cost !== null && base !== null ? Math.max(0, cost - base) : null
161
162    return (
163      <Box flexDirection="column">
164        <Box>
165          <Box flexShrink={0}><Text bold color="#0b0b0f" backgroundColor="#e0af68"> ◔ USAGE   </Text></Box>
166          <Text wrap="truncate-end">
167            {limits.map((l, i) => {
168              const cells = bar(l.percent, 6)
169              return (
170                <Text color={toneOf(l.percent)}>
171                  {i ? '  │ ' : ' '}
172                  {shortName(l.kind)} {cells.on}<Text color={TRACK}>{cells.off}</Text>{'  '}{Math.round(l.percent)}%
173                  {l.resetsAt ? ` ↻${resetText(l.kind, l.resetsAt, now)}` : ''}
174                </Text>
175              )
176            })}
177            {out !== null && <Text color="#f59e0b">{'  ⚠ at this pace: out in '}{countdown(out)}</Text>}
178            {limits.length === 0 && context !== null && (
179              <Text color={toneOf(context)}>
180                {' context '}{bar(context, 6).on}<Text color={TRACK}>{bar(context, 6).off}</Text>{'  '}{Math.round(context)}%
181              </Text>
182            )}
183            {cost !== null && (
184              <Text dimColor>
185                {'  │ session $'}
186                {cost.toFixed(2)}
187                {delta !== null ? ` (+$${delta.toFixed(2)})` : ''}
188              </Text>
189            )}
190            {limits.length === 0 && context === null && cost === null && (
191              <Text dimColor>{' no usage data yet'}</Text>
192            )}
193          </Text>
194        </Box>
195        {below}
196      </Box>
197    )
198  })
199
200  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201    const { Box, Text } = $.ui.resolve(e)
202    const limits = await read($, limitsA)
203    const context = await read($, contextA)
204    const cost = await read($, costA)
205    const base = await read($, turnBaseA)
206    const now = await $.clock.now()
207    const cells = Math.max(10, Math.min(60, e.props.bodyColumns - 34))
208    const delta = cost !== null && base !== null ? Math.max(0, cost - base) : null
209
210    return (
211      <Box flexDirection="column">
212        {limits.map(l => {
213          const row = bar(l.percent, cells)
214          return (
215            <Text color={toneOf(l.percent)}>
216              {shortName(l.kind).padEnd(7)} {row.on}<Text color={TRACK}>{row.off}</Text>{'  '}{Math.round(l.percent)}%
217              {l.resetsAt ? `  ↻ ${resetText(l.kind, l.resetsAt, now)}` : ''}
218            </Text>
219          )
220        })}
221        {context !== null && (
222          <Text color={toneOf(context)}>
223            {'context'.padEnd(7)} {bar(context, cells).on}<Text color={TRACK}>{bar(context, cells).off}</Text>{'  '}{Math.round(context)}%
224          </Text>
225        )}
226        {cost !== null && (
227          <Text dimColor>
228            {'session'.padEnd(7)} ${cost.toFixed(2)}
229            {delta !== null ? ` (+$${delta.toFixed(2)})` : ''}
230          </Text>
231        )}
232        {limits.length === 0 && context === null && cost === null && (
233          <Text dimColor>No usage data yet.</Text>
234        )}
235      </Box>
236    )
237  })
238}
239
hooks/format.ts 50 lines
1import type { Pace } from '../types'
2
3const SHORT: Record<string, string> = { five_hour: '5h', seven_day: 'week' }
4const TITLE: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
5const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
6const PACE_MIN_MS = 5 * 60_000
7
8export const shortName = (kind: string) => SHORT[kind] ?? kind
9export const titleName = (kind: string) => TITLE[kind] ?? kind
10
11// Green under 60%, amber 60 to 85, red over 85.
12export const toneOf = (pct: number) => (pct > 85 ? '#ef4444' : pct >= 60 ? '#f59e0b' : '#22c55e')
13
14// The empty part is the same block in TRACK gray, drawn as its own Text run.
15export const TRACK = '#52525b'
16
17export function bar(pct: number, cells: number) {
18  const on = Math.max(0, Math.min(cells, Math.round((cells * pct) / 100)))
19  return { on: '▆'.repeat(on), off: '▆'.repeat(cells - on) }
20}
21
22export function countdown(ms: number) {
23  const m = Math.max(0, Math.ceil(ms / 60_000))
24  const h = Math.floor(m / 60)
25  return h ? `${h}h${m % 60}m` : `${m}m`
26}
27
28export function dayTime(iso: string) {
29  const d = new Date(iso)
30  const h = d.getHours()
31  const min = d.getMinutes() ? `:${String(d.getMinutes()).padStart(2, '0')}` : ''
32  return `${DAYS[d.getDay()]} ${h % 12 || 12}${min}${h < 12 ? 'am' : 'pm'}`
33}
34
35// The five-hour window counts down; longer windows name their reset day.
36export function resetText(kind: string, iso: string | null, now: number) {
37  if (!iso) return ''
38  return kind === 'five_hour' ? countdown(Date.parse(iso) - now) : dayTime(iso)
39}
40
41// Ms until the window runs out at its current rate, if that comes before it resets.
42export function outIn(pace: Pace, percent: number, now: number) {
43  const elapsed = now - pace.at
44  const gained = percent - pace.percent
45  const resetAt = Date.parse(pace.resetsAt)
46  if (elapsed < PACE_MIN_MS || gained <= 0 || resetAt <= now) return null
47  const ms = ((100 - percent) * elapsed) / gained
48  return now + ms < resetAt ? ms : null
49}
50
types/index.d.ts 19 lines
1export type Limit = { kind: string; percent: number; resetsAt: string | null }
2export type Notice = { resetsAt: string | null; level: number }
3export type Pace = { at: number; percent: number; resetsAt: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'usage-gauge': {
8      isOn: boolean
9      limits: Limit[]
10      context: number | null
11      cost: number | null
12      turnBase: number | null
13      thresholds: { low: number; high: number }
14      notified: Record<string, Notice>
15      pace: Pace | null
16    }
17  }
18}
19