SLOPSHOPPER

usage-band

A band above the prompt showing context fill, tokens, cost and rate limits: stat tiles in the desktop app, a coloured row in the terminal

newband
v0.2.0no licenseupdated 2026-10-08WeaponizedLego/claude-code-mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 ✻ context ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k · $0.42 · ↑6.4k ↓1.5k · cached 91k · 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ context ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k · $0.42 · ↑6.4k ↓1.5k · cached 91k · 5h 31%
README

claude-code-mods

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.

ModWhat it does
usage-bandA band above the prompt showing context fill, tokens, cost and rate limits: stat tiles in the desktop app, a coloured row in the terminal
plan-progressReads a phased plan into a live progress tree: phases, steps, elapsed time and an estimate of what is left

Both mods share one look: dark violet cards with a lilac accent. In the desktop app's Code tab they draw as images (cards, tiles, gradients); in the terminal they use the same colours as text.

usage-band in the desktop app

Install

At the prompt of a terminal Claude Code session:

/plugin install usage-band --marketplace WeaponizedLego/claude-code-mods
/plugin install plan-progress --marketplace WeaponizedLego/claude-code-mods

Answer y to add the marketplace, then pick the user scope. Or from a shell:

claude plugin marketplace add WeaponizedLego/claude-code-mods
claude plugin install usage-band@claude-code-mods
claude plugin install plan-progress@claude-code-mods

Update later with claude plugin marketplace update claude-code-mods, then claude plugin update usage-band@claude-code-mods.

plan-progress

plan-progress in the desktop app

When a plan with several phases is approved in plan mode, the mod reads its phases and steps (## Phase 1: ... headings, other work headings, or a nested list) into a tree and opens it in a pane. In the terminal it looks like this:

Move auth to sessions                         20m in · ~40m left
▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 33%  2/6 steps · phase 2/3

✓ 1 Setup                                                 20m
  ├ ✓ Add the sessions table                              10m
  └ ✓ Write the session store                             10m
● 2 Switch over
  ├ ● Replace token checks in the middleware
  ├ ○ Update the login endpoint
  └ ○ Remove the old token helpers
○ 3 Verify
  └ ○ Run the auth test suite

The status line carries the short form (▰▰▱▱ 2/6 · phase 2/3: Switch over · 20m in · ~40m left). Steps move when Claude calls the mod's plan_mark tool, or by themselves when Claude's tasks or todos are named like a step. Without plan mode, ask Claude to lay the work out in phases and it calls plan_set. The estimate is the pace so far times the steps left.

/plan-progress opens the tree (it also works mid-turn); /plan-progress clear stops tracking.

Adding a mod

Put it in its own folder (<mod>/.claude-plugin/plugin.json, <mod>/hooks/...) and add an entry to .claude-plugin/marketplace.json. Check it with claude plugin validate ..

Source 3 files
hooks/register.tsx 178 lines
1import { atom, read, update } from 'claude-code'
2import type { Color, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { Snapshot, Tokens } from '../types'
5import { C, LIMIT_NAMES, TILE_H, bandSvg, compact, dollars, level } from './look'
6
7const snapshot = atom({ plugin: 'usage-band', key: 'snapshot' } as const, null)
8const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, { input: 0, output: 0, cacheRead: 0 })
9
10const ZERO: Tokens = { input: 0, output: 0, cacheRead: 0 }
11const BAR_CELLS = 10
12// Rounded cells, as the desktop's pills: the filled part in the level colour.
13const FILLED = '▰'
14const TRACK = '▱'
15// Roughly one cell of the desktop's code font, in CSS pixels.
16const CELL_PX = 8
17
18type Figures = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
19
20const toSnapshot = ({ context, rateLimits, cost }: Figures): Snapshot => ({
21  contextTokens: context.tokens ?? null,
22  window: context.window,
23  percent: context.percent ?? null,
24  costUsd: cost?.usd ?? null,
25  limits: rateLimits.map(({ kind, percentUsed }) => ({ kind, percentUsed })),
26})
27
28// One run of text in one colour: the band is laid out from these so its width
29// can be counted before it is drawn.
30type Run = { text: string; color?: Color; bold?: boolean }
31type Segment = { runs: Run[]; priority: number }
32
33const width = (runs: Run[]) => runs.reduce((sum, r) => sum + r.text.length, 0)
34
35function segments(snap: Snapshot | null, used: Tokens): Segment[] {
36  const out: Segment[] = []
37
38  const percent = snap?.percent
39  if (percent == null) {
40    out.push({ priority: 0, runs: [{ text: 'context ', color: C.muted }, { text: 'waiting for first reply', color: C.dim }] })
41  } else {
42    const filled = Math.min(BAR_CELLS, Math.round(percent / (100 / BAR_CELLS)))
43    out.push({
44      priority: 0,
45      runs: [
46        { text: 'context ', color: C.muted },
47        { text: FILLED.repeat(filled), color: level(percent) },
48        { text: TRACK.repeat(BAR_CELLS - filled), color: C.track },
49        { text: ` ${percent}%`, color: level(percent), bold: true },
50      ],
51    })
52    if (snap?.contextTokens != null) {
53      out.push({ priority: 3, runs: [{ text: ` ${compact(snap.contextTokens)}/${compact(snap.window)}`, color: C.muted }] })
54    }
55  }
56
57  if (snap?.costUsd != null) {
58    out.push({ priority: 1, runs: [{ text: dollars(snap.costUsd), color: C.text, bold: true }] })
59  }
60
61  if (used.input + used.output > 0) {
62    out.push({
63      priority: 2,
64      runs: [
65        { text: '↑', color: C.glow },
66        { text: `${compact(used.input)} `, color: C.soft },
67        { text: '↓', color: C.accent },
68        { text: compact(used.output), color: C.text, bold: true },
69      ],
70    })
71    if (used.cacheRead > 0) {
72      out.push({ priority: 5, runs: [{ text: `cached ${compact(used.cacheRead)}`, color: C.dim }] })
73    }
74  }
75
76  for (const limit of snap?.limits ?? []) {
77    out.push({
78      priority: 4,
79      runs: [
80        { text: `${LIMIT_NAMES[limit.kind] ?? limit.kind} `, color: C.muted },
81        { text: `${Math.round(limit.percentUsed)}%`, color: level(limit.percentUsed), bold: true },
82      ],
83    })
84  }
85
86  return out
87}
88
89const SEPARATOR: Run = { text: ' · ', color: C.dim }
90const MARK: Run = { text: '✻ ', color: C.accent }
91
92// Drops the least important segments until the row fits, keeping the order.
93function fit(all: Segment[], columns: number): Run[] {
94  let kept = all
95  const total = (s: Segment[]) => width([MARK]) + s.reduce((sum, seg) => sum + width(seg.runs), 0) + width([SEPARATOR]) * Math.max(0, s.length - 1)
96
97  while (kept.length > 1 && total(kept) > columns) {
98    const worst = Math.max(...kept.map(s => s.priority))
99    const at = kept.map(s => s.priority).lastIndexOf(worst)
100    kept = kept.filter((_, i) => i !== at)
101  }
102
103  const runs: Run[] = [MARK]
104  kept.forEach((seg, i) => {
105    // The context tokens ride right after the bar, without a separator.
106    if (i > 0 && !(seg.priority === 3 && kept[i - 1]?.priority === 0)) runs.push(SEPARATOR)
107    runs.push(...seg.runs)
108  })
109  return runs
110}
111
112export const register: Register = on => {
113  on('session.start', async ($, e, next) => {
114    const result = await next(e)
115    // Fill the band before the first turn: the window size and, on a resumed
116    // session, what it has cost so far.
117    const usage = await $.session.usage()
118    await update($, snapshot, () => toSnapshot(usage))
119    return result
120  })
121
122  on('session.measure', async ($, e, next) => {
123    await update($, snapshot, () => toSnapshot(e))
124    return next(e)
125  })
126
127  on('turn.complete', async ($, e, next) => {
128    const usage = e.usage
129    // Main-thread turns only: a subagent's tokens show up in the cost, not here.
130    if (usage && !e.agentId) {
131      await update($, tokens, t => ({
132        input: t.input + usage.input_tokens + usage.cache_creation_input_tokens,
133        output: t.output + usage.output_tokens,
134        cacheRead: t.cacheRead + usage.cache_read_input_tokens,
135      }))
136    }
137    return next(e)
138  })
139
140  on('session.end', async ($, e, next) => {
141    if (e.reason === 'clear') {
142      await update($, tokens, () => ZERO)
143      await update($, snapshot, () => null)
144    }
145    return next(e)
146  })
147
148  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149    if (e.props.hasSurvey) return next(e)
150
151    const snap = await read($, snapshot)
152    const used = await read($, tokens)
153
154    // Where the surface draws images and the band has the rows, a strip of stat tiles.
155    if (e.surface !== 'terminal' && e.props.maxRows >= 3) {
156      const { Box, Svg } = $.ui.resolve(e)
157      const strip = bandSvg(snap, used, e.props.bodyColumns * CELL_PX)
158      return (
159        <Box flexDirection="row" key="usage-band">
160          <Svg source={strip.source} alt={strip.alt} width={strip.width} height={TILE_H} />
161        </Box>
162      )
163    }
164
165    const runs = fit(segments(snap, used), e.props.bodyColumns)
166    const { Box, Text } = $.ui.resolve(e)
167    return (
168      <Box flexDirection="row" alignItems="center" key="usage-band">
169        {runs.map(r => (
170          <Text color={r.color} bold={r.bold}>
171            {r.text}
172          </Text>
173        ))}
174      </Box>
175    )
176  })
177}
178
hooks/look.ts 139 lines
1import type { Snapshot, Tokens } from '../types'
2
3// The palette: dark violet cards with a lilac accent, orange and red for the
4// levels to watch. An SVG is drawn as an image, so it takes real colours, not
5// theme keys; the terminal uses the same.
6export const C = {
7  card: '#23222b',
8  stroke: '#363541',
9  text: '#ececf1',
10  soft: '#c9c7d3',
11  muted: '#8e8c9a',
12  dim: '#5f5d6b',
13  accent: '#a98bff',
14  glow: '#c4b2ff',
15  track: '#3b3650',
16  warn: '#ff8a4c',
17  hot: '#ff5c7a',
18} as const
19
20// Lilac while there is room, orange from 70%, red past 80%.
21export const level = (percent: number): string => (percent > 80 ? C.hot : percent >= 70 ? C.warn : C.accent)
22
23export const compact = (n: number): string => {
24  if (n < 1000) return `${n}`
25  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
26  return `${(n / 1_000_000).toFixed(1)}M`
27}
28
29export const dollars = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
30
31export const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
32
33const FONT = `font-family="Inter, 'Segoe UI', system-ui, -apple-system, sans-serif"`
34const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
35
36type Tile = {
37  priority: number
38  label: string
39  value: string
40  valueColor: string
41  aside?: string
42  sub?: { lead?: string; rest?: string }
43  pills?: { filled: number; color: string }
44}
45
46const PILLS = 10
47
48// What a run of text measures at a font size, near enough to place the next one.
49const measure = (t: string, size: number) =>
50  [...t].reduce((w, ch) => w + (/[.,: ]/.test(ch) ? 0.3 : /[%$]/.test(ch) ? 0.8 : /[0-9]/.test(ch) ? 0.6 : 0.56), 0) * size
51
52function tiles(snap: Snapshot | null, used: Tokens): Tile[] {
53  const out: Tile[] = []
54  const percent = snap?.percent
55  out.push(
56    percent == null
57      ? { priority: 0, label: 'Context', value: '—', valueColor: C.muted, sub: { rest: 'waiting for first reply' } }
58      : {
59          priority: 0,
60          label: 'Context',
61          value: `${percent}%`,
62          valueColor: level(percent),
63          aside: snap?.contextTokens != null ? `${compact(snap.contextTokens)} / ${compact(snap.window)}` : undefined,
64          pills: { filled: Math.min(PILLS, Math.round(percent / (100 / PILLS))), color: level(percent) },
65        },
66  )
67  if (snap?.costUsd != null) {
68    out.push({ priority: 1, label: 'Cost', value: dollars(snap.costUsd), valueColor: C.text, sub: { rest: 'this session' } })
69  }
70  if (used.input + used.output > 0) {
71    out.push({
72      priority: 2,
73      label: 'Tokens',
74      value: compact(used.output),
75      valueColor: C.text,
76      aside: 'out',
77      sub: { lead: `${compact(used.input)} in`, rest: used.cacheRead > 0 ? ` · ${compact(used.cacheRead)} cached` : '' },
78    })
79  }
80  for (const limit of snap?.limits ?? []) {
81    const p = Math.round(limit.percentUsed)
82    out.push({
83      priority: 4,
84      label: `${LIMIT_NAMES[limit.kind] ?? limit.kind} limit`,
85      value: `${p}%`,
86      valueColor: level(p),
87      pills: { filled: Math.min(PILLS, Math.round(p / (100 / PILLS))), color: level(p) },
88    })
89  }
90  return out
91}
92
93export const TILE_H = 60
94const MIN_W = 118
95const MAX_W = 200
96const GAP = 8
97
98/** The band as one strip of stat tiles, the least important dropped to fit. */
99export function bandSvg(snap: Snapshot | null, used: Tokens, width: number): { source: string; width: number; alt: string } {
100  let kept = tiles(snap, used)
101  const fits = (n: number) => n * MIN_W + (n - 1) * GAP <= width
102  while (kept.length > 1 && !fits(kept.length)) {
103    const worst = Math.max(...kept.map(t => t.priority))
104    const at = kept.map(t => t.priority).lastIndexOf(worst)
105    kept = kept.filter((_, i) => i !== at)
106  }
107  const n = kept.length
108  const tileW = Math.max(MIN_W, Math.min(MAX_W, (width - (n - 1) * GAP) / n))
109  const W = Math.round(n * tileW + (n - 1) * GAP)
110
111  const parts = kept.map((t, i) => {
112    const x = i * (tileW + GAP)
113    const g: string[] = []
114    g.push(`<rect x="${(x + 0.5).toFixed(1)}" y="0.5" width="${(tileW - 1).toFixed(1)}" height="${TILE_H - 1}" rx="12" fill="${i === 0 ? 'url(#glow)' : C.card}" stroke="${C.stroke}"/>`)
115    g.push(`<text x="${x + 12}" y="17" font-size="10.5" fill="${C.muted}">${esc(t.label)}</text>`)
116    g.push(`<text x="${x + 12}" y="38" font-size="19" font-weight="600" fill="${t.valueColor}" letter-spacing="-0.3">${esc(t.value)}</text>`)
117    if (t.aside) {
118      g.push(`<text x="${(x + 12 + measure(t.value, 19) + 5).toFixed(1)}" y="37" font-size="10.5" fill="${C.muted}">${esc(t.aside)}</text>`)
119    }
120    if (t.pills) {
121      const pw = (tileW - 24 - (PILLS - 1) * 3) / PILLS
122      for (let k = 0; k < PILLS; k++) {
123        const fill = k < t.pills.filled ? t.pills.color : C.track
124        g.push(`<rect x="${(x + 12 + k * (pw + 3)).toFixed(1)}" y="45" width="${pw.toFixed(1)}" height="6" rx="3" fill="${fill}"/>`)
125      }
126    } else if (t.sub) {
127      const lead = t.sub.lead ? `<tspan fill="${C.accent}" font-weight="600">${esc(t.sub.lead)}</tspan>` : ''
128      const rest = t.sub.rest ? `<tspan fill="${C.muted}">${esc(t.sub.rest)}</tspan>` : ''
129      g.push(`<text x="${x + 12}" y="51" font-size="10">${lead}${rest}</text>`)
130    }
131    return g.join('')
132  })
133
134  const defs = `<defs><radialGradient id="glow" cx="0.15" cy="1" r="1.1"><stop offset="0" stop-color="#3d3260"/><stop offset="1" stop-color="${C.card}"/></radialGradient></defs>`
135  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${TILE_H}" viewBox="0 0 ${W} ${TILE_H}" ${FONT}>${defs}${parts.join('')}</svg>`
136  const alt = kept.map(t => `${t.label} ${t.value}${t.aside ? ` ${t.aside}` : ''}`).join(', ')
137  return { source, width: W, alt }
138}
139
types/index.d.ts 18 lines
1export type Limit = { kind: string; percentUsed: number }
2
3export type Snapshot = {
4  contextTokens: number | null
5  window: number
6  percent: number | null
7  costUsd: number | null
8  limits: Limit[]
9}
10
11export type Tokens = { input: number; output: number; cacheRead: number }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'usage-band': { snapshot: Snapshot | null; tokens: Tokens }
16  }
17}
18