Live usage meter with a mascot, plus model-switch recommendations based on your prompt

My collection of Claude Code mods: live panes, bands and hooks written as function-hook plugins.
Two function-hook mods for Claude Code (needs a recent build; 2.1.286+ here).
usage-buddy: live usage band (context, 5h, 7d) with the Claude mascot and model-switch hints per prompt.pit-wall: F1-style /pit pane tracking plan phases and GitHub pushes.git clone <this repo> ~/claude-mods
claude --plugin-dir ~/claude-mods/usage-buddy --plugin-dir ~/claude-mods/pit-wall
To load them in every session, set CLAUDE_CODE_PLUGIN_DIRS (colon-separated absolute paths) in your shell profile or in the env block of ~/.claude/settings.json.
pit-wall stores its board per machine and per project folder, and uses git, plus gh for CI status if installed.
hooks/register.tsx 291 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Rec, Usage } from '../types'
5
6const usageAtom = atom({ plugin: 'usage-buddy', key: 'usage' } as const, null)
7const recAtom = atom({ plugin: 'usage-buddy', key: 'rec' } as const, null)
8const collapsedAtom = atom({ plugin: 'usage-buddy', key: 'collapsed' } as const, false)
9
10const HARD = /\b(architect|design|refactor|migrate|migration|race condition|deadlock|security|audit|root cause|performance|optimi[sz]e|from scratch|entire|whole (repo|codebase)|trade-?offs?|plan)\b/i
11const EASY = /\b(typo|rename|format|lint|comment|commit message|what does|explain this line|add (a )?log|bump|change the (color|text)|ls|list)\b/i
12
13function recommend(text: string, pressure: number): Rec {
14 const t = text.trim()
15 const lines = t.split('\n').length
16 let rec: Rec
17 if (HARD.test(t) || t.length > 900 || lines > 25) {
18 rec = { model: 'Opus', id: 'opus', why: 'big or ambiguous task: deep reasoning pays off' }
19 } else if (EASY.test(t) || t.length < 90) {
20 rec = { model: 'Haiku', id: 'haiku', why: 'small, quick task: cheapest model is plenty' }
21 } else {
22 rec = { model: 'Sonnet', id: 'sonnet', why: 'everyday coding: best speed/quality balance' }
23 }
24 if (pressure >= 80 && rec.model === 'Opus') {
25 rec = { model: 'Sonnet', id: 'sonnet', why: `limits ${Math.round(pressure)}% used: save Opus for later` }
26 } else if (pressure >= 80 && rec.model === 'Sonnet') {
27 rec = { model: 'Haiku', id: 'haiku', why: `limits ${Math.round(pressure)}% used: go light` }
28 }
29 return rec
30}
31
32
33// css px per terminal cell on the desktop; tune if the card under/overshoots the band
34const CELL_PX = 8
35const BODY = 0xd97757
36const EYE = 0x2a1810
37const DEF = 0x01000000
38const SPRITE = [
39 '.#########.',
40 '.##.###.##.',
41 '.##.###.##.',
42 '###########',
43 '###########',
44 '.#########.',
45 '..#.#.#.#..',
46 '..#.#.#.#..',
47]
48
49function clawd(worried: boolean): string {
50 const cols = 11
51 const px = SPRITE.map(r => r.split(''))
52 const eyes = worried ? [2, 3] : [1, 2]
53 const eyeCols = [3, 7]
54 const color = (x: number, y: number) => {
55 if (eyeCols.includes(x) && eyes.includes(y)) return EYE
56 if (px[y][x] === '#') return BODY
57 return -1
58 }
59 const words = new Uint32Array(cols * 4 * 3)
60 for (let row = 0; row < 4; row++) {
61 for (let x = 0; x < cols; x++) {
62 const t = color(x, row * 2)
63 const b = color(x, row * 2 + 1)
64 let ch = 0x20, fg = DEF, bg = DEF
65 if (t >= 0 && b >= 0) { ch = 0x2580; fg = t; bg = b }
66 else if (t >= 0) { ch = 0x2580; fg = t }
67 else if (b >= 0) { ch = 0x2584; fg = b }
68 const i = (row * cols + x) * 3
69 words[i] = ch; words[i + 1] = fg; words[i + 2] = bg
70 }
71 }
72 return new Uint8Array(words.buffer).toBase64()
73}
74
75const bar = (p: number, w = 10) => {
76 const n = Math.max(0, Math.min(w, Math.round((p / 100) * w)))
77 return '█'.repeat(n) + '░'.repeat(w - n)
78}
79const tone = (p: number) => (p >= 80 ? 'red' : p >= 50 ? 'yellow' : 'green')
80
81async function refresh($: any) {
82 const u = await $.session.usage()
83 const find = (k: string) => u.rateLimits.find((r: any) => r.kind === k)?.percentUsed
84 const usage: Usage = {
85 contextPercent: u.context.percent ?? 0,
86 fiveHour: find('five_hour'),
87 sevenDay: find('seven_day'),
88 costUsd: u.cost?.usd,
89 }
90 await update($, usageAtom, () => usage)
91}
92
93
94const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
95const GRAD = { green: ['#7af0bd', '#2fbf86'], yellow: ['#ffe08a', '#f5a524'], red: ['#ff9a9a', '#ff4d6d'] } as const
96const toneKey = (p: number) => (p >= 80 ? 'red' : p >= 50 ? 'yellow' : 'green')
97
98const TYRE = { Haiku: ['S', '#ff3b3b'], Sonnet: ['M', '#ffd23f'], Opus: ['H', '#f2f2f2'] } as const
99const LABELS = { ctx: 'FUEL', '5h': 'TYRES', '7d': 'ENGINE' } as const
100
101function glassSvg(u: Usage, rec: Rec | null, worst: number, line: string, W: number): string {
102 const H = 62
103 const px = 4
104 const sprite = SPRITE.map((row, y) =>
105 row.split('').map((c, x) => {
106 const isEye = (x === 3 || x === 7) && (worst >= 80 ? [2, 3] : [1, 2]).includes(y)
107 if (isEye) return `<rect x="${x * px}" y="${y * px}" width="${px}" height="${px}" fill="#2a1810"/>`
108 if (c !== '#') return ''
109 const fill = y === 0 ? (x === 5 ? '#ffffff' : '#e10600') : 'url(#body)'
110 return `<rect x="${x * px}" y="${y * px}" width="${px + 0.4}" height="${px + 0.4}" fill="${fill}"/>`
111 }).join('')
112 ).join('')
113
114 const meters: [keyof typeof LABELS, number][] = [['ctx', u.contextPercent]]
115 if (u.fiveHour !== undefined) meters.push(['5h', u.fiveHour])
116 if (u.sevenDay !== undefined) meters.push(['7d', u.sevenDay])
117 const cw = (W - 78 - 120) / Math.max(1, meters.length)
118 const labelW = 62
119 const pctW = 44
120 const trackW = Math.max(60, cw - labelW - pctW - 8)
121 const N = 16
122 const segW = trackW / N
123 const rows = meters.map(([key, p], i) => {
124 const cx = 78 + i * cw
125 const tx = cx + labelW
126 const y = 9
127 const lit = Math.round((Math.min(100, p) / 100) * N)
128 const segs = Array.from({ length: N }, (_, k) => {
129 const col = k < 8 ? '#2bff88' : k < 13 ? '#ff2d2d' : '#9a5cff'
130 const on = k < lit
131 return `<rect x="${tx + k * segW}" y="${y}" width="${segW - 2}" height="12" rx="2" fill="${on ? col : '#fff'}" fill-opacity="${on ? 1 : 0.08}"${on ? ' filter="url(#glow)"' : ''}/>`
132 }).join('')
133 const hot = p >= 80
134 return `
135 <text x="${cx}" y="${y + 10}" font-size="11" font-weight="800" font-style="italic" letter-spacing=".6" fill="#fff" fill-opacity=".72">${LABELS[key]}</text>
136 ${segs}
137 <text x="${tx + trackW + 6}" y="${y + 11}" font-size="13" font-weight="800" font-style="italic" fill="${hot ? '#ff5a5a' : '#fff'}">${Math.round(p)}<tspan font-size="9" fill-opacity=".7">%</tspan></text>`
138 }).join('')
139
140 const tyre = rec ? TYRE[rec.model] : null
141 const recRow = rec && tyre ? `
142 <circle cx="90" cy="42" r="10" fill="#141414" stroke="${tyre[1]}" stroke-width="3"/>
143 <text x="90" y="46.5" font-size="11" font-weight="800" fill="#fff" text-anchor="middle">${tyre[0]}</text>
144 <text x="108" y="46" font-size="12" font-weight="800" font-style="italic" fill="#fff"><tspan fill="#ff3b3b">BOX BOX</tspan> → ${rec.model.toUpperCase()}<tspan font-weight="500" font-style="normal" fill-opacity=".7" font-size="11.5"> ${esc(rec.why)} · /model ${rec.id}</tspan></text>`
145 : `<text x="78" y="46" font-size="11.5" font-style="italic" fill="#fff" fill-opacity=".55">lights out: send a prompt and the pit wall will call a tyre</text>`
146
147 const checks = Array.from({ length: Math.ceil(W / 3) * 2 }, (_, k) => {
148 const col = k >> 1
149 const row = k & 1
150 return (col + row) % 2 ? '' : `<rect x="${col * 3}" y="${H - 6 + row * 3}" width="3" height="3" fill="#fff"/>`
151 }).join('')
152
153 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" font-family="-apple-system, 'SF Pro Text', system-ui, sans-serif">
154 <defs>
155 <linearGradient id="pane" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".16"/><stop offset=".5" stop-color="#fff" stop-opacity=".04"/><stop offset="1" stop-color="#fff" stop-opacity=".08"/></linearGradient>
156 <linearGradient id="rim" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ff4a3a" stop-opacity=".9"/><stop offset=".35" stop-color="#fff" stop-opacity=".1"/><stop offset=".7" stop-color="#fff" stop-opacity=".1"/><stop offset="1" stop-color="#ff4a3a" stop-opacity=".7"/></linearGradient>
157 <linearGradient id="body" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f09a78"/><stop offset="1" stop-color="#d97757"/></linearGradient>
158 <linearGradient id="stripe" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e10600" stop-opacity="0"/><stop offset=".15" stop-color="#e10600"/><stop offset=".85" stop-color="#e10600"/><stop offset="1" stop-color="#e10600" stop-opacity="0"/></linearGradient>
159 <radialGradient id="orb1"><stop offset="0" stop-color="#e10600" stop-opacity=".55"/><stop offset="1" stop-color="#e10600" stop-opacity="0"/></radialGradient>
160 <radialGradient id="orb2"><stop offset="0" stop-color="#ff7a3a" stop-opacity=".25"/><stop offset="1" stop-color="#ff7a3a" stop-opacity="0"/></radialGradient>
161 <pattern id="carbon" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="4" height="4" fill="#0c0c0e"/><rect width="2" height="2" fill="#17171b"/><rect x="2" y="2" width="2" height="2" fill="#17171b"/></pattern>
162 <filter id="glow" x="-30%" y="-60%" width="160%" height="220%"><feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
163 <clipPath id="card"><rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="16"/></clipPath>
164 </defs>
165 <g clip-path="url(#card)">
166 <rect width="${W}" height="${H}" fill="url(#carbon)"/>
167 <circle cx="38" cy="31" r="60" fill="url(#orb1)"/>
168 <circle cx="${W - 60}" cy="70" r="90" fill="url(#orb2)"/>
169 <rect width="${W}" height="${H}" fill="url(#pane)"/>
170 <rect y="0" width="${W}" height="2.5" fill="url(#stripe)"/>
171 <g opacity=".28">${checks}</g>
172 </g>
173 <rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="16" fill="none" stroke="url(#rim)" stroke-width="1.5"/>
174 <ellipse cx="38" cy="50" rx="17" ry="2.5" fill="#000" fill-opacity=".4"/>
175 <g transform="translate(16 14)" shape-rendering="crispEdges">${sprite}</g>
176 ${rows}${recRow}
177</svg>`
178}
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 await refresh($)
183 return next(e)
184 })
185
186 on('session.measure', async ($, e, next) => {
187 const find = (k: string) => e.rateLimits.find(r => r.kind === k)?.percentUsed
188 const prev = await read($, usageAtom)
189 await update($, usageAtom, () => ({
190 contextPercent: e.context.percent ?? 0,
191 fiveHour: find('five_hour'),
192 sevenDay: find('seven_day'),
193 costUsd: prev?.costUsd,
194 }))
195 return next(e)
196 })
197
198 on('prompt.submit', async ($, e, next) => {
199 const u = await read($, usageAtom)
200 const pressure = Math.max(u?.fiveHour ?? 0, u?.sevenDay ?? 0, u?.contextPercent ?? 0)
201 const rec = recommend(e.text, pressure)
202 await update($, recAtom, () => rec)
203 $.ui.status(`✦ try ${rec.model}: ${rec.why}`)
204 return next(e)
205 })
206
207 on('turn.complete', async ($, e, next) => {
208 await refresh($)
209 return next(e)
210 })
211
212 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213 const u = await read($, usageAtom)
214 if (e.props.hasSurvey || !u) return next(e)
215 const rec = await read($, recAtom)
216 const t = $.ui.resolve(e) as any
217 const { Box, Text, Button } = t
218
219 if (await read($, collapsedAtom)) {
220 const bits = [`FUEL ${Math.round(u.contextPercent)}%`]
221 if (u.fiveHour !== undefined) bits.push(`TYRES ${Math.round(u.fiveHour)}%`)
222 if (u.sevenDay !== undefined) bits.push(`ENGINE ${Math.round(u.sevenDay)}%`)
223 if (rec) bits.push(`BOX → ${rec.model}`)
224 return (
225 <Box flexDirection="row">
226 <Button key="expand" variant="secondary" label="▸ Show usage" onPress={() => update($, collapsedAtom, () => false)} />
227 <Text color="#e10600"> 🏁 </Text>
228 <Text dimColor>{bits.join(' · ')}</Text>
229 </Box>
230 )
231 }
232 const collapse = (
233 <Button key="collapse" variant="secondary" label="▾ Hide" onPress={() => update($, collapsedAtom, () => true)} />
234 )
235
236 const worst = Math.max(u.fiveHour ?? 0, u.sevenDay ?? 0, u.contextPercent)
237 const line = worst >= 80 ? 'whoa, easy!' : worst >= 50 ? 'pacing myself' : 'plenty left!'
238
239 if (e.surface !== 'terminal') {
240 const { Svg } = t
241 const width = Math.max(480, Math.min(1400, Math.floor(e.props.bodyColumns * CELL_PX) - 8))
242 return (
243 <Box position="relative">
244 <Svg
245 source={glassSvg(u, rec, worst, line, width)}
246 alt={`Usage: context ${Math.round(u.contextPercent)}%, 5h ${u.fiveHour ?? '-'}%, 7d ${u.sevenDay ?? '-'}%. ${rec ? 'Suggested model: ' + rec.model : ''}`}
247 width={width}
248 />
249 <Box position="absolute" top={1} right={2}>{collapse}</Box>
250 </Box>
251 )
252 }
253
254 const { Raster } = t
255 const meter = (label: string, p?: number) =>
256 p === undefined ? null : (
257 <Text key={label}>
258 <Text dimColor>{label} </Text>
259 <Text color={tone(p)}>{bar(p)} {Math.round(p)}%</Text>
260 <Text dimColor> </Text>
261 </Text>
262 )
263
264 return (
265 <Box flexDirection="row">
266 <Box flexDirection="column" marginRight={2}>
267 <Raster key="mascot" columns={11} rows={4} cells={clawd(worst >= 80)} />
268 <Text dimColor>{line}</Text>
269 </Box>
270 <Box flexDirection="column" flexGrow={1}>
271 <Text>
272 {meter('FUEL', u.contextPercent)}
273 {meter('TYRES', u.fiveHour)}
274 {meter('ENGINE', u.sevenDay)}
275 </Text>
276 {rec ? (
277 <Text>
278 <Text color="red" bold>BOX BOX → </Text>
279 <Text bold>{rec.model}</Text>
280 <Text dimColor> ({rec.why}) /model {rec.id}</Text>
281 </Text>
282 ) : (
283 <Text dimColor>lights out: send a prompt and the pit wall will call a tyre</Text>
284 )}
285 </Box>
286 {collapse}
287 </Box>
288 )
289 })
290}
291types/index.d.ts 14 lines1export type Usage = {
2 contextPercent: number
3 fiveHour?: number
4 sevenDay?: number
5 costUsd?: number
6}
7export type Rec = { model: 'Haiku' | 'Sonnet' | 'Opus'; id: string; why: string }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'usage-buddy': { usage: Usage | null; rec: Rec | null; collapsed: boolean }
12 }
13}
14