A mod that draws your context window as a stacked bar above the prompt, one color per category as /context breaks it down, with a legend. Live after every…

A mod that draws your context window as a stacked bar in the band above the prompt — one color per category, as /context breaks it down — with a legend underneath:
████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
40% System prompt 3% System tools 7% Messages 30% Autocompact buffer 10% Free space 50%
(Each run is drawn in its category's theme color — the same colors /context uses — solid █ for what's in use, ▒ for the compaction buffer, ░ for free space.)
It's live: Claude Code pushes a measurement after every turn, and the bar redraws from it. No polling, no timer, no token-count requests (the breakdown is the local summary estimate, the one /context starts from).
/plugin marketplace add anthonybaldwin/cc-plugins
/plugin install context-bar@cc-plugins
Requires Claude Code v2.1.287+ (mods). It draws in the terminal and in the Desktop app's Code tab; in the VS Code chat panel and claude -p the hooks run but nothing is drawn.
/context-bar | Toggle the bar. The choice is remembered across sessions. Works mid-turn. |
ctrl+x ctrl+a | Claude Code's own collapse for the band above the prompt. |
The band is shared with other mods and with Claude Code's surveys; a survey takes precedence while it's up.
The status line receives only the window's total. The per-category breakdown is a mods-API call ($.session.usage({ breakdown })), and the band above the prompt is the one place a plugin can draw a multi-row, theme-colored widget that updates on push. So the status line keeps its context gauge, and this is the detail view beside it. The two are independent — install either or both.
hooks/register.ts:
session.start registers /context-bar, restores the saved toggle from $.store, and takes a first measurement.session.measure (fired after each main-thread turn) re-reads $.session.usage({ breakdown: 'summary' }) whenever the context unit moved, and writes the snapshot into $.state. Writing the state redraws the band — the render hook subscribes to it.classic.SessionStart for clear / resume / fork / compact measures again and restores the toggle (those reset $.state).ui.render on AbovePrompt lays the categories out across bodyColumns cells with largest-remainder rounding (so the bar is always exactly the band's width and every non-empty category stays visible), skips deferred tool rows like /context's grid does, and fits as many legend entries as the width allows.Tests (claude plugin test plugins/context-bar) cover the layout arithmetic, drawing on both surfaces, the toggle round-trip through $.store, the /clear path, and the survey precedence.
hooks/register.ts 173 lines1// context-bar — a mod that draws the context window as a stacked bar above the prompt.
2//
3// One colour per category, as /context breaks the window down (system prompt, tools, memory
4// files, messages, …, then the compaction buffer and free space), scaled to the band's width, with
5// a legend underneath. It's live: Claude Code pushes `session.measure` after every turn and the
6// mod re-reads the breakdown then — no polling, no timer. `/context-bar` toggles it, and the
7// choice is remembered across sessions in $.store.
8//
9// WHY A MOD (and not a row in statusline-dashboard): the status line only gets the window's
10// total; the per-category breakdown is a mods-API call (`$.session.usage({ breakdown })`), and
11// the band is the one place a plugin can draw a multi-row, themed, live widget. The status line
12// keeps the gauge; this is the detail.
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { Segment, Snapshot } from '../types'
17
18const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null as Snapshot | null)
19const isVisible = atom({ plugin: 'context-bar', key: 'isVisible' } as const, true)
20
21/** $.store key for the remembered visibility (one boolean; absent = shown). */
22export const STORE_KEY = 'isVisible'
23
24/** The glyph each kind of segment is drawn with — used rows solid, the buffer hatched, free space light. */
25export const glyphFor = (kind: Segment['kind']): string => (kind === 'free' ? '░' : kind === 'buffer' ? '▒' : '█')
26
27/**
28 * Splits `width` cells across the segments in proportion to their tokens (largest-remainder
29 * rounding, so the cells always sum to exactly `width` and no non-empty segment rounds to zero
30 * while a cell can be found for it). Deferred rows are left out, as /context's grid leaves them.
31 * Pure, so the test can check the arithmetic without a drawing.
32 */
33export function layout(categories: Segment[], width: number): Array<Segment & { cells: number }> {
34 const rows = categories.filter((c) => c.kind !== 'deferred' && c.tokens > 0)
35 const total = rows.reduce((n, c) => n + c.tokens, 0)
36 if (width <= 0 || total <= 0 || rows.length === 0) return []
37 const exact = rows.map((c) => (c.tokens / total) * width)
38 const cells = exact.map((x) => Math.floor(x))
39 let left = width - cells.reduce((n, c) => n + c, 0)
40 // Hand the leftover cells to the largest fractional parts first.
41 const order = exact.map((x, i) => [x - cells[i], i] as const).sort((a, b) => b[0] - a[0])
42 for (const [, i] of order) {
43 if (left <= 0) break
44 cells[i] += 1
45 left -= 1
46 }
47 // A non-empty row that still rounded to nothing borrows one cell from the widest row, so every
48 // category present is at least visible.
49 for (let i = 0; i < cells.length; i++) {
50 if (cells[i] > 0) continue
51 const widest = cells.indexOf(Math.max(...cells))
52 if (cells[widest] > 1) {
53 cells[widest] -= 1
54 cells[i] = 1
55 }
56 }
57 return rows.map((c, i) => ({ ...c, cells: cells[i] }))
58}
59
60/** `name NN%` legend entries, only for rows worth a label, as many as fit in `width` two columns apart. */
61export function legend(categories: Segment[], maxTokens: number, width: number): Array<{ text: string; color: string }> {
62 const items = categories
63 .filter((c) => c.kind !== 'deferred' && c.tokens > 0)
64 .map((c) => ({ text: `${c.name} ${Math.max(1, Math.round((c.tokens / Math.max(1, maxTokens)) * 100))}%`, color: c.color }))
65 const out: typeof items = []
66 let used = 0
67 for (const it of items) {
68 const need = (out.length ? 2 : 0) + it.text.length
69 if (used + need > width) break
70 out.push(it)
71 used += need
72 }
73 return out
74}
75
76// Re-read the breakdown and publish it; the ui.render hook subscribes to `snapshot`, so the write
77// redraws the band. 'summary' estimates locally and sends no token-count requests. (Top-level
78// function declarations: the validator follows `$` only into functions declared at the top of
79// the module, so helpers that take it live here, not inside `register`.)
80async function measure($: EngineInterface): Promise<void> {
81 try {
82 const usage = await $.session.usage({ breakdown: 'summary' })
83 const b = usage.context.breakdown
84 if (!b) return
85 const snap: Snapshot = {
86 categories: b.categories.map((c) => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
87 totalTokens: b.totalTokens,
88 maxTokens: b.rawMaxTokens,
89 percentage: b.percentage,
90 model: b.model,
91 at: await $.clock.now(),
92 }
93 await update($, snapshot, () => snap)
94 } catch {
95 // No session bound yet (a `-p` run, the very first moments of a session): keep the last snapshot.
96 }
97}
98
99// The remembered toggle lives in $.store (across sessions); $.state resets on /clear, so copy it
100// back in on every start-like event, not just session.start.
101async function loadVisible($: EngineInterface): Promise<void> {
102 const saved = await $.store.get(STORE_KEY)
103 if (typeof saved === 'boolean') await update($, isVisible, () => saved)
104}
105
106export const register: Register = (on) => {
107 on('session.start', async ($, e, next) => {
108 await $.command.register({
109 name: 'context-bar',
110 description: 'Show or hide the context-window bar above the prompt',
111 immediate: true,
112 })
113 await loadVisible($)
114 await measure($)
115 return next(e)
116 })
117
118 // /clear, /resume, /branch and compaction reset or reshape the window: measure again, and bring
119 // the saved toggle back (those three reset $.state, and session.start doesn't fire for them).
120 on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
121 await loadVisible($)
122 await measure($)
123 return next(e)
124 })
125
126 // Pushed after each main-thread turn (and when a rate-limit window moves a point): the moment
127 // the fill may have changed.
128 on('session.measure', async ($, e, next) => {
129 if (e.changed.includes('context')) await measure($)
130 return next(e)
131 })
132
133 on('command.run', { command: 'context-bar' }, async ($) => {
134 const show = !(await read($, isVisible))
135 await update($, isVisible, () => show)
136 await $.store.set(STORE_KEY, show)
137 if (show) await measure($)
138 return { text: show ? 'Context bar shown.' : 'Context bar hidden — /context-bar shows it again.' }
139 })
140
141 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142 const snap = await read($, snapshot)
143 const shown = await read($, isVisible)
144 if (e.props.hasSurvey || !shown || !snap) return next(e)
145
146 const { Box, Text } = $.ui.resolve(e)
147 const width = Math.max(10, e.props.bodyColumns)
148 const bar = layout(snap.categories, width)
149 if (bar.length === 0) return next(e)
150
151 const head = `${snap.percentage}%`
152 const items = legend(snap.categories, snap.maxTokens, width - head.length - 2)
153
154 return Box({
155 flexDirection: 'column',
156 children: [
157 Box({
158 flexDirection: 'row',
159 children: bar.map((seg) => Text({ color: seg.color, children: [glyphFor(seg.kind).repeat(seg.cells)] })),
160 }),
161 Box({
162 flexDirection: 'row',
163 columnGap: 2,
164 children: [
165 Text({ bold: true, children: [head] }),
166 ...items.map((it) => Text({ color: it.color, children: [it.text] })),
167 ],
168 }),
169 ],
170 })
171 })
172}
173types/index.d.ts 34 lines1// The values this mod keeps in $.state (claude plugin validate checks every key the module names
2// against this contract) and the shapes they hold.
3
4/** One category of the context window, as /context lists it, minus what the bar doesn't need. */
5export type Segment = {
6 name: string
7 tokens: number
8 /** A theme colour key (`promptBorder`, `inactive`, `permission`, …) — what /context draws the row in. */
9 color: string
10 kind: 'used' | 'free' | 'buffer' | 'deferred'
11}
12
13/** The last breakdown the mod took, drawn until the next one replaces it. */
14export type Snapshot = {
15 categories: Segment[]
16 totalTokens: number
17 /** The window the breakdown measures against (the compaction window, which may be under the model's). */
18 maxTokens: number
19 /** `totalTokens` over `maxTokens`, whole percent; past 100 when over. */
20 percentage: number
21 model: string
22 /** `$.clock.now()` when taken. */
23 at: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'context-bar': {
29 snapshot: Snapshot | null
30 isVisible: boolean
31 }
32 }
33}
34