SLOPSHOPPER

usage-bars

Rounded gradient progress bars for context, 5h and 7d usage, plus session spend, above the Claude Code Desktop prompt

newband
★ 1v0.1.0MITupdated 2026-10-13Nelahia/claude-kit/mods/usage-bars
A shopper browsing a rack in a slop shop
README

usage-bars

Rounded, muted gradient progress bars above the prompt of Claude Code Desktop: context window, 5-hour window, 7-day window, and the spend of the current session.

usage-bars above the Claude Code Desktop prompt

Install

In a Claude Code terminal session:

/plugin install usage-bars --marketplace Nelahia/claude-kit

Answer y to add the marketplace, then pick the user scope. The mod is active right away, and in every session started afterwards, including the desktop app.

What it shows

ItemSourceNotes
contextthe model's context windowWith an API key, token counts show too (42k/200k).
5h / 7dsubscription usage limits↻ is the time left before the reset.
spenda gateway's spend limitOnly when the account has one.
spentcost of the session, as /cost totals itCovers the session, not the whole month.

A bar turns from its normal gradient to a warm tone (terracotta to brick red) past 85%.

Customize

Everything lives in hooks/:

  • register.tsx: BAR_WIDTH and BAR_HEIGHT size the bars, TRACK is the empty-track color.
  • gauge.ts: PALETTES and HOT hold each gauge's gradient. Thresholds and the reset format are there too.

The layout counts character cells (about 8 px each), not pixels. If you lengthen the bars, check that the block does not overflow.

Limits

  • Built for the desktop app: the bars are Svg elements. In a plain terminal the mod draws nothing.
  • The 5h and 7d windows only appear on a subscription, and only after the first API response.

Structure

.claude-plugin/   plugin.json
hooks/            gauge.ts (pure logic) · register.tsx (drawing)
types/            the mod's state contract
docs/             screenshot
Source 3 files
hooks/register.tsx 61 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { accentOf, barSvg, formatCost, formatReset, toGauges } from './gauge'
5
6const gauges = atom({ plugin: 'usage-bars', key: 'gauges' } as const, null)
7const cost = atom({ plugin: 'usage-bars', key: 'cost' } as const, null)
8const BAR_WIDTH = 88
9const BAR_HEIGHT = 10
10// The layout counts character cells (~8px each), not pixels: reserve them.
11const BAR_CELLS = Math.ceil(BAR_WIDTH / 8)
12const TRACK = '#2a2e3f'
13
14export const register: Register = on => {
15  on('session.measure', async ($, e, next) => {
16    await update($, gauges, () => toGauges(e))
17    await update($, cost, () => e.cost?.usd ?? null)
18    return next(e)
19  })
20
21  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
22    if (e.props.hasSurvey || e.surface === 'terminal') return next(e)
23
24    const usage = await $.session.usage()
25    const items = (await read($, gauges)) ?? toGauges(usage)
26    const spent = (await read($, cost)) ?? usage.cost?.usd ?? null
27    const now = await $.clock.now()
28    const { Box, Text, Svg } = $.ui.resolve(e)
29
30    return (
31      <Box gap={2} paddingX={1} alignItems="center">
32        {items.map(g => {
33          const reset = formatReset(g.resetsAt, now)
34          return (
35            <Box key={g.label} gap={1} alignItems="center">
36              <Text dimColor>{g.label}</Text>
37              <Box width={BAR_CELLS} flexShrink={0}>
38                <Svg
39                  source={barSvg(g, BAR_WIDTH, BAR_HEIGHT, TRACK)}
40                  alt={`${g.label} ${Math.round(g.percent)}%`}
41                  width={BAR_WIDTH}
42                  height={BAR_HEIGHT}
43                />
44              </Box>
45              <Text bold color={accentOf(g)}>{Math.round(g.percent)}%</Text>
46              {g.detail ? <Text dimColor>{g.detail}</Text> : null}
47              {reset ? <Text dimColor>↻{reset}</Text> : null}
48            </Box>
49          )
50        })}
51        {spent !== null ? (
52          <Box key="cost" gap={1}>
53            <Text dimColor>spent</Text>
54            <Text bold color="#cbb68c">{formatCost(spent)}</Text>
55          </Box>
56        ) : null}
57      </Box>
58    )
59  })
60}
61
hooks/gauge.ts 82 lines
1import type { Gauge } from '../types'
2
3type Usage = {
4  context: { percent?: number; tokens?: number; window: number }
5  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
6  cost?: { usd: number }
7}
8
9const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
10
11// Gradient start/end per gauge; any gauge past 85% turns to the hot palette.
12const PALETTES: Record<string, [string, string]> = {
13  context: ['#5a7ca3', '#86a3c7'],
14  '5h': ['#8379ab', '#a89ec8'],
15  '7d': ['#5f9488', '#8fb8aa'],
16  spend: ['#a89166', '#cbb68c'],
17}
18const HOT: [string, string] = ['#c58a5c', '#c3665f']
19
20const compact = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
21
22// Context always; subscription windows when the account reports them. Without
23// any window (API key), the context bar also carries its token counts.
24export const toGauges = (u: Usage): Gauge[] => {
25  const windows = u.rateLimits.filter(r => LABELS[r.kind])
26  const detail =
27    windows.length === 0 && u.context.tokens !== undefined
28      ? `${compact(u.context.tokens)}/${compact(u.context.window)}`
29      : undefined
30  return [
31    { label: 'context', percent: u.context.percent ?? 0, detail },
32    ...windows.map(r => ({ label: LABELS[r.kind], percent: r.percentUsed, resetsAt: r.resetsAt })),
33  ]
34}
35
36export const formatCost = (usd: number) => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
37
38const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
39
40const mix = (a: string, b: string, t: number) =>
41  '#' +
42  [0, 1, 2]
43    .map(i => Math.round(channel(a, i) + (channel(b, i) - channel(a, i)) * t).toString(16).padStart(2, '0'))
44    .join('')
45
46const paletteOf = (g: Gauge) => (g.percent >= 85 ? HOT : (PALETTES[g.label] ?? PALETTES.context))
47
48// One entry per dot: its gradient color when filled, null when empty.
49export const dotColors = (g: Gauge, count: number): (string | null)[] => {
50  const [from, to] = paletteOf(g)
51  const filled = Math.min(count, Math.max(g.percent > 0 ? 1 : 0, Math.round((g.percent / 100) * count)))
52  return Array.from({ length: count }, (_, i) =>
53    i < filled ? mix(from, to, count > 1 ? i / (count - 1) : 0) : null,
54  )
55}
56
57// Rounded pill bar: a track, and a gradient fill clipped to the same radius.
58export const barSvg = (g: Gauge, width: number, height: number, track: string) => {
59  const [from, to] = paletteOf(g)
60  const r = height / 2
61  const fillW = g.percent > 0 ? Math.max(height, (Math.min(100, g.percent) / 100) * width) : 0
62  const fill = fillW
63    ? `<rect width="${fillW}" height="${height}" rx="${r}" fill="url(#g)"/>`
64    : ''
65  return (
66    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
67    `<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${Math.max(fillW, 1)}" y2="0">` +
68    `<stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs>` +
69    `<rect width="${width}" height="${height}" rx="${r}" fill="${track}"/>${fill}</svg>`
70  )
71}
72
73export const accentOf =(g: Gauge) => paletteOf(g)[1]
74
75export const formatReset = (resetsAt: string | undefined, now: number) => {
76  if (!resetsAt) return ''
77  const mins = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60000))
78  if (mins >= 2880) return `${Math.round(mins / 1440)}j`
79  const h = Math.floor(mins / 60)
80  return h ? `${h}h${String(mins % 60).padStart(2, '0')}` : `${mins}min`
81}
82
types/index.d.ts 8 lines
1export type Gauge = { label: string; percent: number; resetsAt?: string; detail?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'usage-bars': { gauges: Gauge[] | null; cost: number | null }
6  }
7}
8