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

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.
| Mod | What it does |
|---|---|
| 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 |
| plan-progress | Reads 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.

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.

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.
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 ..
hooks/register.tsx 178 lines1import { 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}
178hooks/look.ts 139 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
139types/index.d.ts 18 lines1export 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