A color-coded context bar above the prompt: percent used until auto-compact, split by token type, with a legend.

Two small mods for Claude Code that draw in the band above the prompt.
| Mod | What it does | Platforms |
|---|---|---|
| context-bar | A color-coded bar showing how much of the context window is used before auto-compact, split by token type (system prompt, tools, MCP, memory files, skills, messages). Hover a segment for its size; hover the percent for the full legend. | Terminal and desktop app, any OS |
| download-bar | One live progress row per download that Claude Code's shells run (curl, wget, Python, PowerShell, pip, git, npm..., WSL too): bytes, percent and ETA when the size is known, speed. Past four downloads at once it shows a summary over the three fastest. | Windows (including downloads inside WSL) |
In a Claude Code terminal session, run:
/plugin install context-bar --marketplace Ayushmaniar/claude-mods
/plugin install download-bar --marketplace Ayushmaniar/claude-mods
Answer y to add the marketplace, then pick a scope (user scope makes it apply in every session, including the desktop app's Code tab).
Rows appear on their own when a shell command starts downloading. The /dlbar command controls it:
/dlbar shows the status and help/dlbar test [count] simulates downloads so you can see the bar without touching the network/dlbar on / /dlbar off turns it on or off/dlbar lookup on|off toggles the size lookup: a HEAD request to the download's URL, sent only once the download is already running, to get a percent and ETAHow it works: a small PowerShell sampler (sampler/sampler.ps1) watches processes descended from Claude Code's shells that hold an internet connection, and reads their write counters (a download writes what it receives). Downloads inside WSL are followed by sampler/wsl-sampler.sh, run in the distro the shell's wsl.exe used, which reads the same facts from /proc.
Each mod is a folder under plugins/. To run one from source:
claude --plugin-dir plugins/context-bar
Validate and test:
claude plugin validate plugins/download-bar
claude plugin test plugins/download-bar
Claude Code writes the SDK typings into each mod's .claude-plugin/types/ when the mod loads; after that, tsc -p plugins/<mod> type-checks it.
MIT
hooks/register.tsx 244 lines1import type { Register, SessionUsage } from 'claude-code'
2
3const STALE_PANE = 'context-bar'
4const FREE_COLOR = '#2b2d3a'
5const POLL_MS = 2500
6
7const PALETTE: [string, string][] = [
8 ['system prompt', '#7aa2f7'],
9 ['system tools', '#bb9af7'],
10 ['mcp tools', '#e0af68'],
11 ['mcp server instructions', '#c3e88d'],
12 ['custom agents', '#f7768e'],
13 ['memory files', '#9ece6a'],
14 ['skills', '#73daca'],
15 ['messages', '#ff9e64'],
16]
17const FALLBACK = ['#2ac3de', '#c0caf5', '#ff757f', '#e0def4']
18
19const colorFor = (name: string, index: number): string =>
20 PALETTE.find(([key]) => name.toLowerCase().startsWith(key))?.[1] ?? (FALLBACK[index % FALLBACK.length] as string)
21
22const fmt = (n: number) =>
23 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(Math.round(n))
24
25const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0)
26
27// Whole cells per value: each edge sits at the running total, so a value too small for a cell costs its neighbours nothing.
28const cellsOf = (values: number[], cells: number): number[] => {
29 const total = sum(values)
30 if (total <= 0) return values.map(() => 0)
31
32 let running = 0
33 let previous = 0
34
35 return values.map(v => {
36 running += v
37 const edge = Math.round((running / total) * cells)
38 const mine = edge - previous
39 previous = edge
40
41 return mine
42 })
43}
44
45type Part = { name: string; tokens: number; color: string; isFree: boolean }
46
47type Measure = {
48 parts: Part[]
49 usedTokens: number
50 limit: number
51 isCompactOn: boolean
52 percent: number
53}
54
55// The window as the engine counts it, one part per row of /context, with the free space last.
56const measure = (usage: SessionUsage): Measure | null => {
57 const breakdown = usage.context.breakdown
58 if (!breakdown) return null
59
60 const used = breakdown.categories
61 .filter(c => c.kind === 'used' && c.tokens > 0)
62 .map((c, i) => ({ name: c.name, tokens: c.tokens, color: colorFor(c.name, i), isFree: false }))
63 const usedTokens = sum(used.map(c => c.tokens))
64 const threshold = breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
65 const limit = threshold ?? breakdown.rawMaxTokens
66
67 return {
68 parts: [...used, { name: 'Free', tokens: Math.max(0, limit - usedTokens), color: FREE_COLOR, isFree: true }],
69 usedTokens,
70 limit,
71 isCompactOn: threshold !== undefined,
72 percent: Math.round((usedTokens / limit) * 100),
73 }
74}
75
76const escapeXml = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
77
78const percentColor = (percent: number) => (percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green')
79
80const pct = (m: Measure, tokens: number) => `${Math.round((tokens / m.limit) * 100)}%`
81const tipOf = (m: Measure, p: Part) => `${p.name} ${fmt(p.tokens)} · ${pct(m, p.tokens)} of limit`
82
83type Piece = { part: Part; grow: number; source: string }
84
85// The desktop's segments for one reading. The band redraws every few seconds and an unchanged reading is the same
86// picture, so each reading's SVG strings are built once, not once per redraw.
87const piecesCache = new WeakMap<Measure, Piece[]>()
88const piecesOf = (m: Measure): Piece[] => {
89 let pieces = piecesCache.get(m)
90 if (pieces) return pieces
91
92 const total = sum(m.parts.map(p => p.tokens))
93 pieces = m.parts.flatMap(p => {
94 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="14" viewBox="0 0 100 14" preserveAspectRatio="none"><style>rect:hover{filter:brightness(1.3)}</style><rect width="100" height="14" fill="${p.color}"><title>${escapeXml(tipOf(m, p))}</title></rect></svg>`
95
96 return p.tokens > 0 ? [{ part: p, grow: (p.tokens / total) * 1000, source }] : []
97 })
98 piecesCache.set(m, pieces)
99
100 return pieces
101}
102
103export const register: Register = on => {
104 // The last reading, kept so a draw never waits on the engine and never comes up empty.
105 let latest: Measure | null = null
106 let signature = ''
107
108 on('session.start', async ($, e, next) => {
109 // An earlier version drew a pane; one left up by a reload would stay blank.
110 await $.ui.close({ id: STALE_PANE })
111
112 // Measures off the draw path and redraws only when a number moved, so the band is not rebuilt for nothing.
113 const tick = async () => {
114 try {
115 const m = measure(await $.session.usage({ breakdown: 'summary' }))
116 if (!m) return
117
118 const key = JSON.stringify([m.limit, m.isCompactOn, m.parts.map(p => [p.name, Math.round(p.tokens / 100)])])
119 if (key === signature) return
120
121 signature = key
122 latest = m
123 $.ui.invalidate('ui.render')
124 } catch {
125 // The last reading stays on screen.
126 }
127 }
128 $.clock.every(POLL_MS, tick)
129 await tick()
130
131 return next(e)
132 })
133
134 // One row across the band: the engine's own categories, then free space, with the percent and the count at the end.
135 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136 const m = latest
137 if (e.props.hasSurvey || !m) return next(e)
138
139 // Whatever the plugins beneath draw in the band goes under the bar.
140 const below = await next(e)
141 const hasBelow = Boolean(below) && (below as { type?: string }).type !== 'engine'
142
143 const tip = (p: Part) => tipOf(m, p)
144 const percent = `${m.percent}%`
145 const amount = ` ${fmt(m.usedTokens)}/${fmt(m.limit)}`
146
147 // The desktop gives a vector frame a width of its own, whatever the band's, so the fill is layout boxes that always
148 // stretch to the row, each grown in proportion to its tokens (out of 1000, the engine takes no more than 10000).
149 // A frame is drawn at 450px unless told its width, so each is told one wider than any band and clipped to its box.
150 // The vector laid in each carries the tooltip and the hover highlight, and paints its own stretch's color, as the frame can turn opaque.
151 if (e.surface === 'desktop') {
152 const { Box, Svg, Text } = $.ui.resolve(e)
153 const pieces = piecesOf(m)
154
155 const bar = (
156 <Box flexDirection="row" alignItems="center" columnGap={1} width="100%">
157 <Box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
158 {pieces.map(piece => (
159 <Box width={0} minWidth={0} flexGrow={piece.grow} backgroundColor={piece.part.color} overflow="hidden">
160 <Svg source={piece.source} alt={tip(piece.part)} width={4000} height={14} isInteractive />
161 </Box>
162 ))}
163 </Box>
164 <Text>
165 <Text color={percentColor(m.percent)} bold>
166 {percent}
167 </Text>
168 <Text dimColor>{amount}</Text>
169 </Text>
170 </Box>
171 )
172
173 return hasBelow ? (
174 <Box flexDirection="column">
175 {bar}
176 {below}
177 </Box>
178 ) : (
179 bar
180 )
181 }
182
183 // The terminal draws it in cells; a stretch's hover reveals a line above the bar, which is the band's bottom row,
184 // so the line appearing never moves the bar from under the pointer.
185 const { Box, Text } = $.ui.resolve(e)
186 const width = Math.max(10, e.props.bodyColumns - percent.length - amount.length - 2)
187 const cells = cellsOf(
188 m.parts.map(p => p.tokens),
189 width,
190 )
191 const swatch = (p: Part) => (p.isFree ? <Text dimColor>■</Text> : <Text color={p.color}>■</Text>)
192
193 const bar = (
194 <Box flexDirection="column">
195 {m.parts.map((p, i) =>
196 (cells[i] ?? 0) > 0 ? (
197 <Box display="none" hover={{ scope: `seg-${i}`, display: 'flex' }}>
198 <Text wrap="truncate">
199 {swatch(p)}
200 <Text> {tip(p)}</Text>
201 </Text>
202 </Box>
203 ) : null,
204 )}
205 <Box display="none" flexDirection="row" flexWrap="wrap" columnGap={2} hover={{ scope: 'all', display: 'flex' }}>
206 {m.parts.map(p => (
207 <Text>
208 {swatch(p)}
209 <Text dimColor>
210 {' '}
211 {p.name} {fmt(p.tokens)}
212 </Text>
213 </Text>
214 ))}
215 </Box>
216 <Box>
217 {m.parts.map((p, i) =>
218 (cells[i] ?? 0) > 0 ? (
219 <Text backgroundColor={p.color} hover={{ scope: `seg-${i}` }}>
220 {' '.repeat(cells[i] ?? 0)}
221 </Text>
222 ) : null,
223 )}
224 <Text color={percentColor(m.percent)} bold hover={{ scope: 'all' }}>
225 {` ${percent}`}
226 </Text>
227 <Text dimColor hover={{ scope: 'all' }}>
228 {amount}
229 </Text>
230 </Box>
231 </Box>
232 )
233
234 return hasBelow ? (
235 <Box flexDirection="column">
236 {bar}
237 {below}
238 </Box>
239 ) : (
240 bar
241 )
242 })
243}
244