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

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.
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.
hooks/register.tsx 164 lines1import { 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}
164types/index.d.ts 8 lines1export 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