Live context window bar above the prompt, broken down by category like /context, with usage limits, session cost and a one-key handoff

A Claude Code mod that draws a live card above the prompt: how full the context window is, what fills it, how much of your usage limits is left, and what the session has cost. It works in the terminal and in the desktop app's Code tab.
/cost shows.h): writes a handoff document of the conversation, clears it, and starts a fresh conversation from that document. If any step fails, nothing is cleared and a toast says why.Run this in a terminal:
claude plugin install context-bar --marketplace iagodemacedo/context-bar
It installs at the user scope, so the card appears in every new session, in the terminal and in the desktop app's Code tab. Start a new session after installing.
From inside a terminal session you can also type /plugin install context-bar --marketplace iagodemacedo/context-bar, answer y to add the marketplace, and press Enter to keep the user scope. The /plugin command is not available in the desktop app's Code tab, so install from a terminal once and the desktop picks it up.
claude plugin update context-bar
claude plugin uninstall context-bar
/tmp.git clone https://github.com/iagodemacedo/context-bar && cd context-bar
claude --plugin-dir .
Saving a file reloads the mod in that session. Before a commit, run claude plugin validate . and claude plugin test ..
MIT
hooks/register.tsx 516 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ContextCategory,
4 EngineInterface,
5 Register,
6 SessionContextBreakdown,
7 SessionRateLimit,
8} from 'claude-code'
9
10import type { ContextSegment, ContextSnapshot, HandoffState, UsageLimit } from '../types'
11
12const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
13
14// Display names and colors per /context row. Unknown rows still show, lowercased,
15// with a fallback color, so a new category in a later build is never dropped.
16const LOOKS: Record<string, { label: string; hex: string }> = {
17 'System prompt': { label: 'system prompt', hex: '#6B9BD1' },
18 'System tools': { label: 'tools', hex: '#5EC4C9' },
19 'MCP tools': { label: 'mcp tools', hex: '#A78BFA' },
20 'Custom agents': { label: 'agents', hex: '#86C06C' },
21 'Memory files': { label: 'memory files', hex: '#E8C56A' },
22 Skills: { label: 'skills', hex: '#F4A6C0' },
23 Messages: { label: 'messages', hex: '#E0704F' },
24}
25const FALLBACK_HEX = ['#7FB3D5', '#C39BD3', '#F0B27A', '#76D7C4', '#F7DC6F']
26const TRACK_HEX = '#8B93A6'
27
28// Refresh cadence: events mark the snapshot stale, a 1s tick recomputes it.
29// The breakdown is a local estimate ('summary'), so a forced pass every 15s is cheap.
30const TICK_MS = 1000
31const FORCE_EVERY_TICKS = 15
32
33// Handoff: the button writes a handoff document with the mod's own skill
34// (skills/handoff), clears the conversation, then starts the fresh one from that
35// document. The run lives in module variables as well as $.state, since a
36// /clear starts a new session.
37const HANDOFF_COMMAND = 'context-bar:handoff'
38const HANDOFF_TIMEOUT_MS = 15 * 60_000
39const IDLE_HANDOFF: HandoffState = { phase: 'idle', path: null, startedAt: 0 }
40const handoff = atom({ plugin: 'context-bar', key: 'handoff' } as const, IDLE_HANDOFF)
41// Files a temp-dir handoff could be saved as, when the reply names another path.
42const TMP_MARKDOWN = /(?:\/private)?\/(?:tmp|var\/folders)\/[^\s`'"<>()[\]]+\.md/g
43
44let isTurnRunning = false
45let turnStartedAt = 0
46let lastTurn: { startedAt: number; answer: string; isOk: boolean } | null = null
47let isDriving = false
48
49let isDirty = true
50let burstTicks = 0
51let isFlushing = false
52let lastKey = ''
53
54type BarKind = ContextSegment['kind']
55const isInWindow = (c: ContextCategory): c is ContextCategory & { kind: BarKind } =>
56 c.kind !== 'deferred'
57
58// Account-wide windows first (they count every model), then any other window the
59// engine reports, so a per-model or spend window is shown rather than dropped.
60const LIMIT_ORDER = ['five_hour', 'seven_day']
61const limitLabel = (kind: string) =>
62 kind.replace('five_hour', '5h').replace('seven_day', 'weekly').replace('spend_limit', 'spend').replace(/_/g, ' ')
63
64function toLimits(limits: readonly SessionRateLimit[], now: number): UsageLimit[] {
65 const rank = (kind: string) => {
66 const i = LIMIT_ORDER.indexOf(kind)
67 return i === -1 ? LIMIT_ORDER.length : i
68 }
69 return [...limits]
70 .sort((a, b) => rank(a.kind) - rank(b.kind))
71 .map(l => {
72 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
73 // Rounded to the minute so the snapshot, and the redraw, changes at most once a minute.
74 const resetsInMin = Number.isNaN(at) ? null : Math.max(0, Math.round((at - now) / 60_000))
75 return { kind: l.kind, label: limitLabel(l.kind), percent: l.percentUsed, resetsInMin }
76 })
77}
78
79function toSnapshot(b: SessionContextBreakdown, limits: UsageLimit[], costUsd: number | null): ContextSnapshot {
80 let unknown = 0
81 const segments = b.categories.filter(isInWindow).map((c): ContextSegment => {
82 const look = LOOKS[c.name]
83 if (c.kind === 'free') return { label: 'free', tokens: c.tokens, kind: c.kind, hex: TRACK_HEX, theme: c.color }
84 if (c.kind === 'buffer') return { label: 'buffer', tokens: c.tokens, kind: c.kind, hex: TRACK_HEX, theme: c.color }
85 const hex = look?.hex ?? FALLBACK_HEX[unknown++ % FALLBACK_HEX.length] ?? TRACK_HEX
86 return { label: look?.label ?? c.name.toLowerCase(), tokens: c.tokens, kind: c.kind, hex, theme: c.color }
87 })
88 const compactAt = b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : null
89
90 return { used: b.totalTokens, max: b.rawMaxTokens, compactAt, segments, limits, costUsd }
91}
92
93async function flush($: EngineInterface): Promise<void> {
94 if (isFlushing) {
95 isDirty = true
96 return
97 }
98 isFlushing = true
99 try {
100 const [{ context, rateLimits, cost }, now] = await Promise.all([
101 $.session.usage({ breakdown: 'summary' }),
102 $.clock.now(),
103 ])
104 if (!context.breakdown) {
105 // No session bound yet: try again on the next tick.
106 isDirty = true
107 return
108 }
109 const snap = toSnapshot(context.breakdown, toLimits(rateLimits, now), cost?.usd ?? null)
110 const key = JSON.stringify(snap)
111 if (key === lastKey) return
112 lastKey = key
113 await update($, snapshot, () => snap)
114 } catch {
115 // Left for the next forced pass rather than retried every tick.
116 } finally {
117 isFlushing = false
118 }
119}
120
121const handoffArgs = (path: string) =>
122 `Next session: continue this work where it left off. Save the handoff document at exactly ${path}, then reply with that path.`
123
124async function findHandoff($: EngineInterface, expected: string, answer: string, since: number) {
125 for (const path of [expected, ...(answer.match(TMP_MARKDOWN) ?? [])]) {
126 const stat = await $.fs.stat(path).catch(() => undefined)
127 // Written during this run (a minute of slack for clock skew), not an old one.
128 if (stat?.kind === 'file' && stat.mtimeMs >= since - 60_000) return path
129 }
130 return null
131}
132
133// The press only records the request; the next tick (within a second) runs it,
134// outside the press's own dispatch.
135async function requestHandoff($: EngineInterface): Promise<void> {
136 await update($, handoff, h => (h.phase === 'idle' ? { ...IDLE_HANDOFF, phase: 'requested' as const } : h))
137}
138
139// One step of the handoff run per call; the ticks call it until it is idle.
140// Nothing is cleared unless the document was saved and read back non-empty.
141async function driveHandoff($: EngineInterface): Promise<void> {
142 if (isDriving) return
143 isDriving = true
144 const stop = async (why: string) => {
145 await update($, handoff, () => IDLE_HANDOFF)
146 $.ui.toast(`Handoff stopped: ${why}`)
147 }
148 try {
149 const h = await read($, handoff)
150 if (h.phase === 'idle') return
151 const now = await $.clock.now()
152
153 if (h.phase === 'requested') {
154 // Pressed mid-turn: start once that turn has ended.
155 if (isTurnRunning) return
156 const commands = await $.command.list()
157 if (!commands.some(c => c.name === HANDOFF_COMMAND)) return await stop(`/${HANDOFF_COMMAND} is not available`)
158 const path = `/tmp/claude-handoff-${now}.md`
159 lastTurn = null
160 await update($, handoff, () => ({ phase: 'writing' as const, path, startedAt: now }))
161 $.command
162 .run({ command: HANDOFF_COMMAND, args: handoffArgs(path) })
163 .catch(() => stop('the handoff command did not run, nothing was cleared'))
164 return
165 }
166
167 if (h.phase === 'writing' && h.path !== null) {
168 if (now - h.startedAt > HANDOFF_TIMEOUT_MS) return await stop('no handoff after 15 minutes, nothing was cleared')
169 // Wait for the turn the skill started, not one that ran before it.
170 if (lastTurn === null || lastTurn.startedAt < h.startedAt) return
171 if (!lastTurn.isOk) return await stop('the handoff turn did not finish, nothing was cleared')
172 const path = await findHandoff($, h.path, lastTurn.answer, h.startedAt)
173 if (path === null) return await stop('no handoff file was saved, nothing was cleared')
174 const doc = await $.fs.read(path)
175 if (doc.trim() === '') return await stop('the handoff file is empty, nothing was cleared')
176
177 await update($, handoff, () => ({ ...h, phase: 'clearing' as const }))
178 await $.command.run({ command: 'clear' })
179 // The fresh conversation's state starts over: redraw the bar from scratch.
180 lastKey = ''
181 isDirty = true
182 // The document joins the fresh conversation as a row the model reads and
183 // the transcript does not show as typed; the prompt itself stays one line.
184 // Refused, the document rides in the prompt instead.
185 const stored = await $.session
186 .append({ message: { type: 'user', content: [{ type: 'text', text: `Handoff document:\n\n${doc}` }] } })
187 .then(
188 r => r.deny === undefined,
189 () => false,
190 )
191 const lead = `Continue the work from the handoff document (saved at ${path})`
192 await $.prompt.submit({ text: stored ? `${lead}, above.` : `${lead}:\n\n${doc}` })
193 await update($, handoff, () => IDLE_HANDOFF)
194 $.ui.toast('Handoff sent to a fresh conversation')
195 }
196 } catch {
197 await stop('an error came up').catch(() => undefined)
198 } finally {
199 isDriving = false
200 }
201}
202
203function fmt(n: number): string {
204 const trim = (s: string) => s.replace(/\.0$/, '')
205 if (n < 1000) return String(Math.round(n))
206 if (n < 10_000) return `${trim((n / 1000).toFixed(1))}k`
207 if (n < 999_500) return `${Math.round(n / 1000)}k`
208 return `${trim((n / 1_000_000).toFixed(1))}M`
209}
210
211function fmtUsd(usd: number): string {
212 if (usd > 0 && usd < 0.01) return 'under $0.01'
213 const [whole = '0', cents = '00'] = usd.toFixed(2).split('.')
214 return `$${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${cents}`
215}
216
217// What a segment's hover label says.
218function segmentLabel(s: ContextSegment, max: number): string {
219 if (s.kind === 'free') return `free ${fmt(s.tokens)}`
220 if (s.kind === 'buffer') return `compaction buffer ${fmt(s.tokens)}`
221 return `${s.label} ${fmt(s.tokens)} · ${pct(s.tokens, max)}%`
222}
223
224function fmtReset(min: number): string {
225 if (min < 1) return 'under 1m'
226 if (min < 60) return `${min}m`
227 if (min < 1440) return `${Math.floor(min / 60)}h ${min % 60}m`
228 return `${Math.floor(min / 1440)}d ${Math.floor((min % 1440) / 60)}h`
229}
230
231const levelHex = (percent: number) => (percent >= 80 ? '#E0704F' : percent >= 50 ? '#E8C56A' : '#86C06C')
232
233const METER_CELLS = 10
234const meterCells = (percent: number) =>
235 percent > 0 ? Math.min(METER_CELLS, Math.max(1, Math.round((percent / 100) * METER_CELLS))) : 0
236
237function meterSvg(percent: number): string {
238 const w = Math.min(100, Math.max(0, percent))
239 return (
240 `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="12" viewBox="0 0 100 6" preserveAspectRatio="none">` +
241 `<defs><clipPath id="m"><rect width="100" height="6" rx="3" ry="3"/></clipPath></defs>` +
242 `<g clip-path="url(#m)"><rect width="100" height="6" fill="${TRACK_HEX}" fill-opacity="0.22"/>` +
243 `<rect width="${w.toFixed(1)}" height="6" fill="${levelHex(percent)}"/></g></svg>`
244 )
245}
246
247// Any row holding tokens shows at least 1%, so a small row never reads as empty.
248const pct = (tokens: number, max: number) =>
249 tokens > 0 && max > 0 ? Math.max(1, Math.round((tokens / max) * 100)) : 0
250
251// Splits `width` cells across the rows by share, every non-empty row getting at
252// least one cell, the rounding leftovers going to the largest remainders.
253function allocate(segs: ContextSegment[], width: number): number[] {
254 const total = segs.reduce((sum, s) => sum + s.tokens, 0)
255 if (total <= 0 || width <= 0) return segs.map(() => 0)
256 const raw = segs.map(s => (s.tokens / total) * width)
257 const cells = raw.map((r, i) => ((segs[i]?.tokens ?? 0) > 0 ? Math.max(1, Math.floor(r)) : 0))
258 let left = width - cells.reduce((a, b) => a + b, 0)
259 const order = raw
260 .map((r, i) => ({ i, rem: r - Math.floor(r) }))
261 .filter(({ i }) => (segs[i]?.tokens ?? 0) > 0)
262 .sort((a, b) => b.rem - a.rem)
263 for (let k = 0; left > 0 && order.length > 0; k++) {
264 const slot = order[k % order.length]
265 if (slot) cells[slot.i] = (cells[slot.i] ?? 0) + 1
266 left -= 1
267 }
268 while (left < 0) {
269 const widest = cells.indexOf(Math.max(...cells))
270 cells[widest] = (cells[widest] ?? 1) - 1
271 left += 1
272 }
273 return cells
274}
275
276// Desktop fill for one bar segment: a solid strip stretched to the segment's
277// box. A plain image, so the pointer over it is the enclosing Box's hover.
278function segmentSvg(fill: string, opacity: number): string {
279 return (
280 `<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="20" viewBox="0 0 100 10" preserveAspectRatio="none">` +
281 `<rect width="100" height="10" fill="${fill}" fill-opacity="${opacity}"/></svg>`
282 )
283}
284
285export const register: Register = on => {
286 on('session.start', async ($, e, next) => {
287 const ran = await next(e)
288 isDirty = true
289 // A reload loses the run's module variables: start it over rather than hang.
290 await update($, handoff, () => IDLE_HANDOFF)
291 let ticks = 0
292 $.clock.every(TICK_MS, () => {
293 void driveHandoff($)
294 ticks += 1
295 if (burstTicks > 0) burstTicks -= 1
296 if (isDirty || burstTicks > 0 || ticks % FORCE_EVERY_TICKS === 0) {
297 isDirty = false
298 void flush($)
299 }
300 })
301
302 return ran
303 })
304
305 // Mid-turn growth. A model request starts once the prompt or the last tool
306 // results are in, and ends with the response: mark stale on both edges. These
307 // hooks only flip a flag, so they never gate the prompt, a tool or a compaction.
308 on('turn.step', async function* ($, e, next) {
309 if (!e.agentId) isDirty = true
310 const ran = yield* next(e)
311 if (!e.agentId) isDirty = true
312 return ran
313 })
314
315 // Turns, for the handoff run: whether one is running, and the main loop's last answer.
316 on('turn.start', async ($, e, next) => {
317 isTurnRunning = true
318 turnStartedAt = await $.clock.now()
319 return next(e)
320 }).catch(($, e, next) => next(e))
321
322 on('turn.complete', ($, e, next) => {
323 if (!e.agentId) {
324 isTurnRunning = false
325 lastTurn = { startedAt: turnStartedAt, answer: e.answer, isOk: e.reason === 'answer' && !e.isAborted }
326 isDirty = true
327 }
328 return next(e)
329 })
330
331 // A compaction runs for a while: refresh on every tick for the next 60s.
332 on('session.compact', ($, e, next) => {
333 burstTicks = 60
334 return next(e)
335 }).catch(($, e, next) => next(e))
336
337 on('session.end', ($, e, next) => {
338 if (e.reason === 'clear') {
339 burstTicks = 10
340 lastKey = ''
341 }
342 return next(e)
343 })
344
345 // After every main-thread turn the engine measures; refresh right away.
346 on('session.measure', async ($, e, next) => {
347 await flush($)
348 return next(e)
349 })
350
351 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
352 if (e.props.hasSurvey) return next(e)
353 const snap = await read($, snapshot)
354 if (snap === null || snap.max <= 0) return next(e)
355
356 const { Box, Text, Button } = $.ui.resolve(e)
357 const run = await read($, handoff)
358 // Card border (2) and padding (2) come off the band's width.
359 const inner = Math.max(20, e.props.bodyColumns - 4)
360 const compact = snap.compactAt === null ? 'auto-compact off' : `compacts at ${pct(snap.compactAt, snap.max)}%`
361
362 const header = (
363 <Box justifyContent="space-between">
364 <Text bold>context</Text>
365 <Text>
366 <Text bold>{fmt(snap.used)}</Text>
367 <Text dimColor>
368 {' '}/ {fmt(snap.max)} · {pct(snap.used, snap.max)}% · {compact}
369 </Text>
370 </Text>
371 </Box>
372 )
373
374 // The bar: one keyed Box per segment, sized in cells, each its own hover
375 // scope. Its label is plain text with no background, so it sits on the blank
376 // row under the bar (the usage section's top margin), where it covers no
377 // other text; hidden until the pointer is over the segment, shifted left
378 // where it would run past the bar's end.
379 const cells = allocate(snap.segments, inner)
380 const barOf = (fill: (s: ContextSegment, n: number, label: string) => JSX.Element) => {
381 let start = 0
382 return (
383 <Box>
384 {snap.segments.map((s, i) => {
385 const n = cells[i] ?? 0
386 const at = start
387 start += n
388 if (n <= 0) return null
389 const label = segmentLabel(s, snap.max)
390 const left = Math.max(-at, Math.min(0, inner - (at + label.length)))
391 return (
392 <Box key={`segment-${i}`} width={n}>
393 {fill(s, n, label)}
394 <Box position="absolute" top={1} left={left} display="none" hover={{ display: 'flex' }}>
395 <Text>{label}</Text>
396 </Box>
397 </Box>
398 )
399 })}
400 </Box>
401 )
402 }
403
404 let bar
405 let meters
406 if (e.surface === 'desktop') {
407 const { Svg } = $.ui.resolve(e)
408 bar = barOf((s, _n, label) =>
409 s.kind === 'used' ? (
410 <Svg source={segmentSvg(s.hex, 1)} alt={label} height={10} />
411 ) : (
412 <Svg source={segmentSvg(TRACK_HEX, s.kind === 'buffer' ? 0.45 : 0.22)} alt={label} height={10} />
413 ),
414 )
415 meters = snap.limits.map(l => (
416 <Svg source={meterSvg(l.percent)} alt={`${l.label} limit ${l.percent}% used`} width={140} height={8} />
417 ))
418 } else {
419 bar = barOf((s, n) =>
420 s.kind === 'used' ? (
421 <Text color={s.hex}>{'█'.repeat(n)}</Text>
422 ) : (
423 <Text color={s.theme}>{(s.kind === 'buffer' ? '▒' : '░').repeat(n)}</Text>
424 ),
425 )
426 meters = snap.limits.map(l => {
427 const n = meterCells(l.percent)
428 return (
429 <Text>
430 <Text color={levelHex(l.percent)}>{'█'.repeat(n)}</Text>
431 <Text dimColor>{'░'.repeat(METER_CELLS - n)}</Text>
432 </Text>
433 )
434 })
435 }
436
437 // Usage as a small table: one row per window, then the session's spend.
438 const HEAD_W = 7
439 const limitW = Math.max(...snap.limits.map(l => l.label.length), 'session'.length) + 2
440 const heading = <Text bold>usage</Text>
441 const limitRows = snap.limits.map((l, i) => (
442 <Box alignItems="center">
443 <Box width={HEAD_W}>{i === 0 && heading}</Box>
444 <Box width={limitW}>
445 <Text dimColor>{l.label}</Text>
446 </Box>
447 {meters[i]}
448 <Box width={8} justifyContent="flex-end">
449 <Text bold>{l.percent}%</Text>
450 </Box>
451 {l.resetsInMin !== null && (
452 <Box marginLeft={3}>
453 <Text dimColor>resets in {fmtReset(l.resetsInMin)}</Text>
454 </Box>
455 )}
456 </Box>
457 ))
458 const noLimits = (
459 <Box>
460 <Box width={HEAD_W}>{heading}</Box>
461 <Box width={limitW}>
462 <Text dimColor>limits</Text>
463 </Box>
464 <Text dimColor>no reading yet</Text>
465 </Box>
466 )
467 const costRow = (
468 <Box>
469 <Box width={HEAD_W}>{null}</Box>
470 <Box width={limitW}>
471 <Text dimColor>session</Text>
472 </Box>
473 {snap.costUsd === null ? (
474 <Text dimColor>cost unavailable</Text>
475 ) : (
476 <Text>
477 <Text bold>{fmtUsd(snap.costUsd)}</Text>
478 <Text dimColor> estimated at API prices</Text>
479 </Text>
480 )}
481 </Box>
482 )
483 const usageTable = (
484 <Box flexDirection="column">
485 {snap.limits.length === 0 ? noLimits : limitRows}
486 {costRow}
487 </Box>
488 )
489 const status: Record<Exclude<HandoffState['phase'], 'idle'>, string> = {
490 requested: e.props.isWorking ? 'handoff queued, starts after this turn' : 'starting handoff…',
491 writing: 'writing handoff…',
492 clearing: 'clearing context…',
493 }
494 const handoffControl =
495 run.phase === 'idle' ? (
496 <Button key="handoff" label="handoff" hotkey="h" onPress={() => requestHandoff($)} />
497 ) : (
498 <Text dimColor>{status[run.phase]}</Text>
499 )
500 const usage = (
501 <Box marginTop={1} justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" columnGap={2}>
502 {usageTable}
503 {handoffControl}
504 </Box>
505 )
506
507 return (
508 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} width={e.props.bodyColumns}>
509 {header}
510 {bar}
511 {usage}
512 </Box>
513 )
514 })
515}
516types/index.d.ts 43 lines1export type ContextSegment = {
2 label: string
3 tokens: number
4 kind: 'used' | 'free' | 'buffer'
5 hex: string
6 theme: string
7}
8
9export type UsageLimit = {
10 kind: string
11 label: string
12 percent: number
13 // Minutes until the window resets, rounded, as of the last refresh; null when unknown.
14 resetsInMin: number | null
15}
16
17export type ContextSnapshot = {
18 used: number
19 max: number
20 compactAt: number | null
21 segments: ContextSegment[]
22 limits: UsageLimit[]
23 // The session's spend as /cost totals it (API list prices unless the organization set its own); null when the host keeps no ledger.
24 costUsd: number | null
25}
26
27// The handoff button's run: requested (waits for a running turn to end), the
28// skill writing the document, then the conversation clearing.
29export type HandoffPhase = 'idle' | 'requested' | 'writing' | 'clearing'
30
31export type HandoffState = {
32 phase: HandoffPhase
33 // Where the skill was asked to save the document.
34 path: string | null
35 startedAt: number
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 'context-bar': { snapshot: ContextSnapshot | null; handoff: HandoffState }
41 }
42}
43