Sparklines of context-window fill and tokens added per turn under the prompt

Two small Claude Code mods (function-hook plugins) as a plugin marketplace.
One row under the prompt, refreshed after every turn:

| Group | Shows |
|---|---|
⎇ | git branch, ● uncommitted files, ↑/↓ commits ahead of / behind upstream, ✓ when clean |
◆ | the model the main loop runs and its effort level |
ctx | context-window fill over the last 8 turns, then the live % and tokens / window |
tok | what each turn cost (bars), then live session figures, subagents included: in tokens read (cached or not), out tokens generated, the latest request's cache hit rate (yellow below 90 %, red below 50 %: the cache went cold) and the session cost in $ as Claude Code prices it |
5h / 7d | your rate-limit windows: % used and time until reset (subscription accounts) |
Charts turn yellow from 60 % and red from 85 %. The row wraps on narrow terminals.
$ is an estimate. It is Claude Code's own figure (the one /cost shows): each response's reported token counts times the published per-model prices, so input, output, cache writes and cache reads are already weighted correctly. On the API that is close to your bill, but the Console's usage page is authoritative: server-tool fees (web search), 1-hour cache writes, fast mode, data-residency and other pricing modifiers, or Bedrock / Vertex prices can make the invoice differ.$ is only a relative gauge of which turns were heavy. The 5h / 7d percentages are the official figures; they come straight from Anthropic's rate-limit data.in is volume, not cost. It counts cached and uncached input alike; a cache read costs a fraction of a fresh input token (for example 0.05x on Opus 5.5) and output costs 5x input. Only $ applies those weights.in, out and hit count what the mod has seen since it loaded in a session; $ covers the whole session.When a subagent starts, a narrow pane docks beside the transcript and follows each agent live: type, elapsed time, tool count, its latest tool calls (› running, ✓ / ✗ done) and the tail of what it is writing. Finished agents collapse to one line; the pane closes itself 20 s after the last one ends.

The pane opens on its own from 144 terminal columns (a Claude Code rule for panes nobody asked for); on a narrower terminal run /agents-pane.
/plugin marketplace add AndreasOA/claude-code-mods
/plugin install usage-bars@ao-claude-mods
/plugin install agent-watch@ao-claude-mods
Restart Claude Code (or open a new session) to load them. /plugin marketplace update ao-claude-mods pulls new versions.
Each plugin is plugins/<name>/ with hooks/register.tsx, its state contract in types/index.d.ts and tests in tests/.
claude plugin validate plugins/usage-bars
claude plugin test plugins/usage-bars
claude --plugin-dir plugins/usage-bars # load a working copy, hot-reloaded on save
The images in docs/ are illustrations of the layout, not screenshots.
MIT
hooks/register.tsx 302 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, TurnUsage } from 'claude-code'
3
4import type { GitInfo, Limit, ModelInfo, Sample, Totals } from '../types'
5
6// How many readings the charts keep, one bar each
7const MAX_BARS = 20
8// How many of them the ctx chart draws: the fill climbs slowly, so the latest few say enough
9const CTX_BARS = 8
10
11// Eight bar heights, from the shortest to the tallest
12const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
13
14const samples = atom({ plugin: 'usage-bars', key: 'samples' } as const, [] as Sample[])
15const window = atom({ plugin: 'usage-bars', key: 'window' } as const, 0)
16const limits = atom({ plugin: 'usage-bars', key: 'limits' } as const, [] as Limit[])
17const current = atom({ plugin: 'usage-bars', key: 'current' } as const, null as Sample | null)
18const totals = atom({ plugin: 'usage-bars', key: 'totals' } as const, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as Totals)
19const turns = atom({ plugin: 'usage-bars', key: 'turns' } as const, [] as number[])
20const mark = atom({ plugin: 'usage-bars', key: 'mark' } as const, 0)
21const cost = atom({ plugin: 'usage-bars', key: 'cost' } as const, null as number | null)
22const hit = atom({ plugin: 'usage-bars', key: 'hit' } as const, null as number | null)
23const model = atom({ plugin: 'usage-bars', key: 'model' } as const, null as ModelInfo | null)
24const git = atom({ plugin: 'usage-bars', key: 'git' } as const, null as GitInfo | null)
25
26// How the rate-limit windows are labelled on the row
27const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
28
29// Draw each value as a bar, scaled so that `max` is the tallest bar
30function bars(values: number[], max: number): string {
31 if (max <= 0) return ''
32 return values
33 .map(v => {
34 const level = Math.round((Math.min(Math.max(v, 0), max) / max) * (BLOCKS.length - 1))
35 return BLOCKS[level]
36 })
37 .join('')
38}
39
40// 84200 -> "84.2k", 1200000 -> "1.2M"
41function short(n: number): string {
42 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
43 if (n >= 1000) return (n / 1000).toFixed(n >= 100_000 ? 0 : 1) + 'k'
44 return String(Math.round(n))
45}
46
47// Green while there is room, yellow as the window fills, red near the end
48function fillColor(percent: number): string {
49 if (percent >= 85) return 'red'
50 if (percent >= 60) return 'yellow'
51 return 'green'
52}
53
54// A fixed-width meter: 34% -> "▰▰▱▱▱"
55function meter(percent: number, width = 5): string {
56 const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
57 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
58}
59
60// Time left until an ISO timestamp: "2h10m", "3d", "45m"
61function until(iso: string | undefined): string {
62 if (!iso) return ''
63 const mins = Math.max(Math.round((Date.parse(iso) - Date.now()) / 60_000), 0)
64 if (mins >= 48 * 60) return Math.round(mins / (24 * 60)) + 'd'
65 if (mins >= 60) return Math.floor(mins / 60) + 'h' + String(mins % 60).padStart(2, '0') + 'm'
66 return mins + 'm'
67}
68
69// "claude-opus-5-5[1m]" -> "opus 5.5 1M", "claude-haiku-4-5-20251001" -> "haiku 4.5"
70function modelName(id: string): string {
71 const long = /\[1m\]$/i.test(id) ? ' 1M' : ''
72 return (
73 id
74 .replace(/\[.*\]$/, '')
75 .replace(/^claude-/, '')
76 .replace(/-\d{8}$/, '')
77 .replace(/-(\d+)-(\d+)$/, ' $1.$2')
78 .replace(/-(\d+)$/, ' $1') + long
79 )
80}
81
82// Read branch, changes and ahead/behind from `git status --porcelain=v2 --branch`
83async function readGit($: EngineInterface) {
84 const run = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 }).catch(() => null)
85 if (!run || run.exitCode !== 0) return update($, git, () => null)
86 const info: GitInfo = { branch: '', changes: 0, ahead: 0, behind: 0 }
87 for (const line of run.stdout.split('\n')) {
88 if (line.startsWith('# branch.head ')) info.branch = line.slice('# branch.head '.length)
89 else if (line.startsWith('# branch.ab ')) {
90 const [, ahead, behind] = /\+(\d+) -(\d+)/.exec(line) ?? []
91 info.ahead = Number(ahead ?? 0)
92 info.behind = Number(behind ?? 0)
93 } else if (line && !line.startsWith('#')) info.changes++
94 }
95 return update($, git, () => info)
96}
97
98// Refresh the live reading and the rate-limit windows
99async function measure($: EngineInterface) {
100 const { context, rateLimits, cost: spent } = await $.session.usage()
101 await update($, cost, () => spent?.usd ?? null)
102 await update($, limits, () => rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })))
103 if (context.tokens === undefined) return
104 const sample: Sample = { tokens: context.tokens, percent: context.percent ?? 0 }
105 await update($, window, () => context.window)
106 await update($, current, () => sample)
107}
108
109// Every input token the model read, from the cache or not
110function inputs(t: { input: number; cacheRead: number; cacheWrite: number }): number {
111 return t.input + t.cacheRead + t.cacheWrite
112}
113
114// Add one model request's usage to the session's totals
115async function count($: EngineInterface, u: TurnUsage) {
116 await update($, totals, t => ({
117 input: t.input + u.input_tokens,
118 output: t.output + u.output_tokens,
119 cacheRead: t.cacheRead + u.cache_read_input_tokens,
120 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
121 }))
122 const read = inputs({ input: u.input_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens })
123 if (read > 0) await update($, hit, () => u.cache_read_input_tokens / read)
124}
125
126// Close a turn: keep its reading as one bar, the newest MAX_BARS of them
127async function record($: EngineInterface) {
128 await measure($)
129 const sample = await read($, current)
130 if (sample) await update($, samples, list => [...list, sample].slice(-MAX_BARS))
131}
132
133// The classic hook events carry the effort level in force, after any downgrade; turn.step leaves the session's default out
134async function recordEffort($: EngineInterface, e: { agent_id?: string; effort?: { level: string } }) {
135 if (e.agent_id !== undefined || !e.effort) return
136 const level = e.effort.level
137 await update($, model, m => (m ? { ...m, effort: level } : m))
138}
139
140export const register: Register = on => {
141 // Start the charts with the current reading, so they aren't empty at launch
142 on('session.start', async ($, e, next) => {
143 await record($)
144 await readGit($)
145 const name = await $.session.model()
146 await update($, model, m => ({ name, effort: m?.effort ?? null }))
147 return next(e)
148 })
149
150 // Whenever the window's fill or the limits move: the live figures only
151 on('session.measure', async ($, e, next) => {
152 if (e.changed.length > 0) await measure($)
153 await readGit($)
154 return next(e)
155 })
156
157 // Every model request, a subagent's included, adds its usage as soon as it answers;
158 // the main loop's also carry the model and effort actually sent
159 on('turn.step', async function* ($, e, next) {
160 if (e.agentId === undefined) {
161 const effort = e.effort === undefined ? null : String(e.effort)
162 // A request names the bare id; keep the session's own spelling (its [1m] suffix) while it is the same model
163 await update($, model, m => ({ name: m && m.name.replace(/\[.*\]$/, '') === e.model ? m.name : e.model, effort: effort ?? m?.effort ?? null }))
164 }
165 // Pass every chunk on; the usage rides the stop chunk or the step's result
166 const stream = next(e)
167 let u: TurnUsage | null = null
168 for (let r = await stream.next(); ; r = await stream.next()) {
169 if (r.done) {
170 const result = r.value ?? (await stream.result)
171 u = result?.usage ?? u
172 if (u) await count($, u)
173 return result
174 }
175 if (r.value.kind === 'stop' && r.value.usage) u = r.value.usage
176 yield r.value
177 }
178 })
179
180 // A main-loop turn ended: one bar for it, what it cost since the last one (its subagents' included)
181 on('turn.complete', async ($, e, next) => {
182 const done = await next(e)
183 if (e.agentId === undefined) {
184 await record($)
185 const now = (await read($, cost)) ?? 0
186 const spent = now - Math.min(await read($, mark), now)
187 await update($, mark, () => now)
188 await update($, turns, list => [...list, spent].slice(-MAX_BARS))
189 }
190 return done
191 })
192
193 on('classic.PostToolUse', async ($, e, next) => {
194 await recordEffort($, e)
195 return next(e)
196 })
197 on('classic.Stop', async ($, e, next) => {
198 await recordEffort($, e)
199 return next(e)
200 })
201
202 // The hint line under the prompt: keep the engine's own line, add a row below it
203 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
204 const list = await read($, samples)
205 const windows = await read($, limits)
206 const live = await read($, current)
207 const active = await read($, model)
208 const repo = await read($, git)
209 // Nothing measured yet, so leave the line as it is
210 if (list.length === 0 && !live && windows.length === 0 && !active && !repo) return next(e)
211
212 const { Box, Text } = $.ui.resolve(e)
213 const size = await read($, window)
214 const latest = live ?? list[list.length - 1]
215
216 const sum = await read($, totals)
217 const perTurn = await read($, turns)
218 const spent = await read($, cost)
219 // The latest request's cache hit, rounded down so it never claims 100 % early; a cold cache shows in colour
220 const last = await read($, hit)
221 const hitPct = last === null ? null : Math.floor(last * 1000) / 10
222 const hitColor = hitPct === null || hitPct >= 90 ? undefined : hitPct >= 50 ? 'yellow' : 'red'
223
224 const engineLine = await next(e)
225
226 // One group per figure: a dim label, its chart, then its numbers
227 const group = (key: string, label: string, chart: RenderChildren, value: RenderChildren) => (
228 <Box key={key} flexDirection="row" columnGap={1}>
229 <Text dimColor>{label}</Text>
230 <Box flexDirection="row" columnGap={2}>
231 {chart}
232 {value}
233 </Box>
234 </Box>
235 )
236 const groups = [
237 repo &&
238 group(
239 'git',
240 '⎇',
241 <Text color="magenta">{repo.branch}</Text>,
242 <Text>
243 {repo.changes > 0 && <Text color="yellow">●{repo.changes} </Text>}
244 {repo.ahead > 0 && <Text color="green">↑{repo.ahead} </Text>}
245 {repo.behind > 0 && <Text color="red">↓{repo.behind}</Text>}
246 {repo.changes === 0 && repo.ahead === 0 && repo.behind === 0 && <Text dimColor>✓</Text>}
247 </Text>,
248 ),
249 active &&
250 group(
251 'model',
252 '◆',
253 <Text color="blue">{modelName(active.name)}</Text>,
254 active.effort ? <Text dimColor>{active.effort}</Text> : null,
255 ),
256 latest &&
257 group(
258 'ctx',
259 'ctx',
260 <Text color={fillColor(latest.percent)}>{bars(list.slice(-CTX_BARS).map(s => s.percent), 100)}</Text>,
261 <Text>
262 {latest.percent}% <Text dimColor>{short(latest.tokens)}/{short(size)}</Text>
263 </Text>,
264 ),
265 inputs(sum) + sum.output > 0 &&
266 group(
267 'tok',
268 'tok',
269 <Text color="cyan">{bars(perTurn, Math.max(...perTurn, 0.000001))}</Text>,
270 <Text>
271 <Text dimColor>in </Text>
272 {short(inputs(sum))}
273 <Text dimColor> out </Text>
274 {short(sum.output)}
275 {hitPct !== null && <Text dimColor> hit </Text>}
276 {hitPct !== null && <Text color={hitColor}>{hitPct.toFixed(1)}%</Text>}
277 {spent !== null && <Text> ${spent.toFixed(2)}</Text>}
278 </Text>,
279 ),
280 ...windows.map(w =>
281 group(
282 w.kind,
283 LIMIT_LABELS[w.kind] ?? w.kind,
284 <Text color={fillColor(w.percentUsed)}>{meter(w.percentUsed)}</Text>,
285 <Text>
286 {w.percentUsed}%{w.resetsAt && <Text dimColor> ↻{until(w.resetsAt)}</Text>}
287 </Text>,
288 ),
289 ),
290 ].filter(Boolean)
291
292 return (
293 <Box flexDirection="column">
294 {engineLine}
295 <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
296 {groups.flatMap((g, i) => (i === 0 ? [g] : [<Text key={'sep' + i} dimColor>│</Text>, g]))}
297 </Box>
298 </Box>
299 )
300 })
301}
302types/index.d.ts 43 lines1// One reading of the live context window, taken after a turn
2export type Sample = { tokens: number; percent: number }
3
4// One rate-limit window: five_hour, seven_day, or a gateway's spend_limit
5export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6
7// The model the main loop last asked, and how hard it asked it to think
8export type ModelInfo = { name: string; effort: string | null }
9
10// The working tree's branch, its uncommitted changes and its distance from upstream
11export type GitInfo = { branch: string; changes: number; ahead: number; behind: number }
12
13// Tokens summed over every model call of the session, subagents included
14export type Totals = { input: number; output: number; cacheRead: number; cacheWrite: number }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'usage-bars': {
19 // One reading per finished turn, oldest first
20 samples: Sample[]
21 // The live reading, refreshed whenever the window's fill moves
22 current: Sample | null
23 // The model's context window in tokens
24 window: number
25 // Token totals so far, and the US dollars each main turn cost
26 totals: Totals
27 turns: number[]
28 // The session cost when the last main turn closed
29 mark: number
30 // US dollars spent this session as the engine prices it, null until priced
31 cost: number | null
32 // Share of the latest request's input the prompt cache served, 0 to 1
33 hit: number | null
34 // The account's rate-limit windows, as last measured
35 limits: Limit[]
36 // The session's model and effort, null until known
37 model: ModelInfo | null
38 // The git state of the working directory, null outside a repository
39 git: GitInfo | null
40 }
41 }
42}
43