SLOPSHOPPER

Context Bar

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…

newbandcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-bar ⎿ context-bar: Context bar hidden — /context-bar shows it again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Context Bar

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).

Install

/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.

Use

/context-barToggle the bar. The choice is remembered across sessions. Works mid-turn.
ctrl+x ctrl+aClaude 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.

Why a mod, and why not a row in statusline-dashboard

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.

How it works

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.

Source 2 files
hooks/register.ts 173 lines
1// 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}
173
types/index.d.ts 34 lines
1// 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