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

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.

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.
| Item | Source | Notes |
|---|---|---|
context | the model's context window | With an API key, token counts show too (42k/200k). |
5h / 7d | subscription usage limits | ↻ is the time left before the reset. |
spend | a gateway's spend limit | Only when the account has one. |
spent | cost of the session, as /cost totals it | Covers the session, not the whole month. |
A bar turns from its normal gradient to a warm tone (terracotta to brick red) past 85%.
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.
Svg elements. In a plain terminal the mod draws nothing..claude-plugin/ plugin.json
hooks/ gauge.ts (pure logic) · register.tsx (drawing)
types/ the mod's state contract
docs/ screenshothooks/register.tsx 61 lines1import { 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}
61hooks/gauge.ts 82 lines1import 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}
82types/index.d.ts 8 lines1export 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