SLOPSHOPPER

usage-buddy

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

newbandstatusprompt
v0.1.0no licenseupdated 2026-10-06rhealaloo45/pit-lane-claude-mods/usage-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-buddy
› 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 ▀▀▀▀▀▀▀▀▀ FUEL █████░░░░░ 49% TYRES ███░░░░░░░ 31% [ ▾ Hide ] ▄▀▀▀▀▀▀▀▀▀▄ BOX BOX → Opus (big or ambiguous task: deep reasoning pays off) /model opus ▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀ plenty left! ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-buddy: ✦ try Opus: big or ambiguous task: deep reasoning pays off

Draws

Band
▀▀▀▀▀▀▀▀▀ FUEL █████░░░░░ 49% TYRES ███░░░░░░░ 31% [ ▾ Hide ] ▄▀▀▀▀▀▀▀▀▀▄ BOX BOX → Opus (big or ambiguous task: deep reasoning pays off) /model opus ▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀ plenty left!
README

pit-lane-claude-mods

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.

Use on any device

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.

Source 2 files
hooks/register.tsx 291 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
291
types/index.d.ts 14 lines
1export 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