SLOPSHOPPER

usage-band

Shows session (5h) and weekly (7d) usage above the prompt

band
v0.2.0no licenseupdated 2026-10-18ptomeccc/claude-usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 Context: 49% Session (5h): 31% · resets 09:53 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context: 49% Session (5h): 31% · resets 09:53
README

usage-band

A Claude Code mod that shows the conversation's context use plus your 5-hour session and 7-day weekly rate-limit usage in a band above the prompt, each with a small progress ring, and an animated pixel Claude mascot.

  • It remembers the last known values across sessions.
  • The rings and the animation show only in the Claude Code desktop app's Code tab (not Chat/Cowork). In the terminal you get text only.

Install

In a terminal Claude Code session (answer y to add the marketplace, then pick the user scope):

/plugin install usage-band --marketplace ptomeccc/claude-usage-band

From a shell:

claude plugin install usage-band --marketplace ptomeccc/claude-usage-band

In the desktop app: click + next to the prompt box → Plugins → Add marketplace ptomeccc/claude-usage-band → Add plugin.

Source 2 files
hooks/register.tsx 164 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4const limits = atom({ plugin: 'usage-band', key: 'limits' } as const, [] as SessionRateLimit[])
5const context = atom({ plugin: 'usage-band', key: 'context' } as const, null as number | null)
6
7const LABELS: Record<string, string> = { five_hour: 'Session (5h)', seven_day: 'Weekly' }
8const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
9const pad = (n: number) => String(n).padStart(2, '0')
10
11// Within 24h: "14:30"; later: "Mon 09:00". Local time.
12export function formatReset(iso: string | undefined, now: number): string {
13  const d = new Date(iso ?? '')
14  if (Number.isNaN(d.getTime())) return ''
15  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
16  return d.getTime() - now < 86_400_000 ? time : `${DAYS[d.getDay()]} ${time}`
17}
18
19// Small ring like the desktop's usage circle: a faint track and an arc filled to `percent`.
20export function pie(percent: number): string {
21  const c = 2 * Math.PI * 5
22  const filled = (Math.min(Math.max(percent, 0), 100) / 100) * c
23  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">` +
24    `<circle cx="7" cy="7" r="5" fill="none" stroke="#8a8a8a" stroke-opacity=".35" stroke-width="2.5"/>` +
25    `<circle cx="7" cy="7" r="5" fill="none" stroke="#4a90e2" stroke-width="2.5" ` +
26    `stroke-dasharray="${filled.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 7 7)"/></svg>`
27}
28
29export function formatLimit(l: SessionRateLimit, now: number): string {
30  const reset = formatReset(l.resetsAt, now)
31  return `${LABELS[l.kind] ?? l.kind}: ${Math.round(l.percentUsed)}%${reset ? ` · resets ${reset}` : ''}`
32}
33
34// Pixel Claude mascot: five scenes (wave, idea, sleep, cheer, coffee break) taking turns, CSS-only (sandboxed Svg frame).
35const D = '#1a1a1a'
36const W = '#fff3e6'
37const px = (x: number, y: number, w = 1, h = 1, fill?: string) =>
38  `<rect x="${x}" y="${y}" width="${w}" height="${h}"${fill ? ` fill="${fill}"` : ''}/>`
39const LEGS = `<g class="l1">${px(5, 13, 1, 2)}${px(12, 13, 1, 2)}</g><g class="l2">${px(7, 13, 1, 2)}${px(14, 13, 1, 2)}</g>`
40const SIDE_ARMS = px(2, 10, 2, 2) + px(16, 10, 2, 2)
41const EYES = px(7, 9, 1, 1, D) + px(12, 9, 1, 1, D)
42const crab = (cls: string, parts: string) => `<g class="${cls}">${px(4, 7, 12, 6)}${parts}</g>${LEGS}`
43const zed = (x: number, y: number, d = '') =>
44  `<g class="z ${d}" fill="#6b4bd8">${px(x, y, 3)}${px(x + 1, y + 1)}${px(x, y + 2, 3)}</g>`
45const spark = (x: number, y: number, d = '') =>
46  `<g class="tw ${d}" fill="#f5c542">${px(x + 1, y, 1, 3)}${px(x, y + 1, 3, 1)}</g>`
47
48const SCENES = [
49  // wave: "HI" bubble, left arm waving
50  px(9, 0, 7, 5, '#3ddc6e') + px(10, 5, 1, 1, '#3ddc6e') +
51    px(10, 1, 1, 3, W) + px(12, 1, 1, 3, W) + px(11, 2, 1, 1, W) + px(14, 1, 1, 3, W) +
52    crab('b', `<g class="wave">${px(2, 4, 2, 5)}</g>${px(16, 10, 2, 2)}${EYES}${px(9, 11, 2, 1, W)}`),
53  // idea: light bulb with twinkling rays, big smile
54  px(8, 0, 4, 1, '#f5c542') + px(7, 1, 6, 3, '#f5c542') + px(8, 4, 4, 1, '#f5c542') + px(8, 1, 1, 1, '#fff') +
55    px(9, 5, 2, 1, '#6b4bd8') +
56    `<g class="tw" fill="#f5c542">${px(5, 1)}${px(14, 1)}${px(5, 4)}${px(14, 4)}</g>` +
57    crab('b', SIDE_ARMS + EYES + px(8, 10, 1, 1, W) + px(9, 11, 2, 1, W) + px(11, 10, 1, 1, W)),
58  // sleep: closed eyes, slow breathing, floating Zs
59  zed(12, 3) + zed(15, 0, 'd1') +
60    crab('br', SIDE_ARMS + px(6, 9, 2, 1, D) + px(12, 9, 2, 1, D) + px(9, 11, 1, 1, W)),
61  // cheer: arms up, jumping, sparkles
62  spark(0, 0) + spark(17, 0, 'd2') + spark(8, 2, 'd3') +
63    crab('jump', px(2, 3, 2, 5) + px(16, 3, 2, 5) +
64      px(6, 10, 1, 1, D) + px(7, 9, 1, 1, D) + px(8, 10, 1, 1, D) +
65      px(11, 10, 1, 1, D) + px(12, 9, 1, 1, D) + px(13, 10, 1, 1, D) + px(9, 11, 2, 1, W)),
66  // coffee break: pause bubble, steaming mug lifted for a sip now and then, eyes shut in bliss
67  px(1, 0, 5, 5, '#4a7bd8') + px(4, 5, 1, 1, '#4a7bd8') + px(2, 1, 1, 3, W) + px(4, 1, 1, 3, W) +
68    `<g class="st" fill="#b8b8b8">${px(18, 6)}${px(17, 5)}</g><g class="st d1" fill="#b8b8b8">${px(19, 5)}${px(18, 4)}</g>` +
69    crab('br', px(2, 10, 2, 2) + px(6, 9, 2, 1, D) + px(12, 9, 2, 1, D) + px(9, 11, 2, 1, W) +
70      `<g class="sip">${px(16, 10, 1, 2)}${px(17, 8, 3, 4, '#3a8f5c')}${px(17, 8, 3, 1, '#6f4e37')}${px(17, 10, 3, 1, W)}</g>`),
71]
72const SCENE_S = 4
73
74// Each scene shows for SCENE_S; a negative delay per scene puts it on screen during its own slot.
75export const MASCOT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 16" shape-rendering="crispEdges">
76<style>
77.s{opacity:0;animation:show ${SCENE_S * SCENES.length}s linear infinite}
78@keyframes show{0%,${100 / SCENES.length - 1}%{opacity:1}${100 / SCENES.length}%,100%{opacity:0}}
79.b{animation:hop .8s steps(1) infinite}
80.jump{animation:hop .5s steps(1) infinite}
81.br{animation:breathe 2s steps(1) infinite}
82.l1{animation:hop .4s steps(1) infinite}
83.l2{animation:hop .4s steps(1) infinite reverse}
84.wave{animation:wave .5s steps(1) infinite}
85.tw{animation:tw .6s steps(1) infinite}
86.z{animation:zz 2s linear infinite}
87@keyframes hop{50%{transform:translateY(-.5px)}}
88@keyframes breathe{50%{transform:translateY(.5px)}}
89@keyframes wave{50%{transform:translateX(-1px)}}
90@keyframes tw{50%{opacity:0}}
91.st{animation:zz 1.6s linear infinite}
92.sip{animation:sip 4s steps(1) infinite}
93@keyframes sip{70%{transform:translateY(-2px)}90%{transform:none}}
94${SCENES.map((_, i) => `.s${i}{animation-delay:${i * SCENE_S - SCENE_S * SCENES.length}s}`).join('')}
95.d1{animation-delay:-1s}.d2{animation-delay:-.3s}.d3{animation-delay:-.15s}
96@keyframes zz{0%{transform:translate(0,1px);opacity:0}30%{opacity:1}100%{transform:translate(1px,-2px);opacity:0}}
97</style>
98<g fill="#D97757">${SCENES.map((s, i) => `<g class="s s${i}">${s}</g>`).join('')}</g></svg>`
99
100// Last known limits, saved across sessions, so a new chat shows them before its first reply.
101// A window whose reset time has passed has started over: it reads 0%.
102export function fromStore(raw: unknown, now: number): SessionRateLimit[] {
103  if (!Array.isArray(raw)) return []
104  return raw
105    .filter((l): l is SessionRateLimit => typeof l?.kind === 'string' && typeof l?.percentUsed === 'number')
106    .map(l => (l.resetsAt && Date.parse(l.resetsAt) <= now ? { kind: l.kind, percentUsed: 0 } : l))
107}
108
109export const register: Register = on => {
110  on('session.start', async ($, e, next) => {
111    const result = await next(e)
112    const { rateLimits, context: ctx } = await $.session.usage()
113    await update($, context, () => ctx.percent ?? null)
114    if (rateLimits.length > 0) {
115      await update($, limits, () => rateLimits)
116      await $.store.set('limits', rateLimits)
117    } else {
118      const saved = fromStore(await $.store.get('limits'), await $.clock.now())
119      await update($, limits, () => saved)
120    }
121    return result
122  })
123
124  on('session.measure', async ($, e, next) => {
125    if (e.changed.includes('context')) {
126      await update($, context, () => e.context.percent ?? null)
127    }
128    if (e.changed.includes('rateLimits') && e.rateLimits.length > 0) {
129      await update($, limits, () => e.rateLimits)
130      await $.store.set('limits', e.rateLimits)
131    }
132    return next(e)
133  })
134
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const all = await read($, limits)
137    const ctx = await read($, context)
138    if (e.props.hasSurvey || (all.length === 0 && ctx === null)) return next(e)
139
140    const els = $.ui.resolve(e)
141    const { Box, Text } = els
142    const now = await $.clock.now()
143    const items = [
144      ...(ctx === null ? [] : [{ key: 'context', percent: ctx, text: `Context: ${ctx}%` }]),
145      ...all.map(l => ({ key: l.kind, percent: l.percentUsed, text: formatLimit(l, now) })),
146    ]
147
148    return (
149      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
150        <Box flexDirection="row" alignItems="center" gap={3}>
151          {items.map(i => (
152            <Box key={i.key} flexDirection="row" alignItems="center" gap={1}>
153              {/* the terminal has no Svg; it shows the text alone */}
154              {'Svg' in els && <els.Svg source={pie(i.percent)} alt={`${Math.round(i.percent)}%`} width={14} height={14} />}
155              <Text dimColor>{i.text}</Text>
156            </Box>
157          ))}
158        </Box>
159        {'Svg' in els && <els.Svg source={MASCOT} alt="Claude" width={30} height={24} isInteractive />}
160      </Box>
161    )
162  })
163}
164
types/index.d.ts 8 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'usage-band': { limits: Limit[]; context: number | null }
6  }
7}
8