SLOPSHOPPER

context-bar

Draws the context window as a stacked bar above the prompt, one color per /context category. Toggle with /context-bar.

newbandcommandtoast
v0.1.0no licenseupdated 2026-10-03Nooralwachi/context-bar
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 ╭─────────────────╮ │ context-bar │ ⏺ Read(src/auth.ts) │ Context bar off │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-bar

A Claude Code mod that draws your context window as one stacked bar above the prompt, split into the same categories and colors as /context.

████████████▓▓▓▓▓▓███████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒
48.2k/200k 24%  ■ System prompt 3.1k  ■ System tools 12.4k  ■ Memory files 2.0k  ■ Messages 30.7k
  • Solid blocks are tokens in use, one color per category (system prompt, system tools, MCP tools, memory files, custom agents, skills, messages).
  • ░ is free space and ▒ is the autocompact buffer.
  • The line under the bar gives the total, the percentage, and each category's size.

The bar updates after every turn, and after /clear, /resume, /branch and compaction.

Use

| Command | What it does | | :- | :- | | /context-bar | Hide the bar, or show it again | | /context-bar on | Show the bar | | /context-bar off | Hide the bar |

The command works while Claude is busy, and your choice is kept across sessions.

Install

Mods need Claude Code v2.1.287 or later.

git clone https://github.com/Nooralwachi/context-bar.git
claude --plugin-dir ./context-bar

The bar appears after Claude's first reply. To check that the mod loaded, run /plugin; it should say 1 mod active · context-bar.

To load it in every session, including the Code tab of the Claude desktop app, add its absolute path to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/context-bar"
  }
}

How it works

After each turn the mod calls $.session.usage({ breakdown: 'summary' }), which returns the same per-category breakdown /context shows. The summary mode estimates locally and sends no token-count requests, so the bar costs no API calls. The counts are estimates, so the total can differ slightly from the status line's percentage; run /context for exact numbers.

Each category gets a share of the band's width in proportion to its tokens, with at least one cell so small categories stay visible. Deferred tool schemas sit outside the window and are left out, as in /context's grid.

Limits

  • The band above the prompt is drawn in the terminal. The Claude desktop app may not draw it there.
  • The bar yields the band to Claude Code's surveys while one is showing.
Source 1 files
hooks/register.js 153 lines
1// context-bar: the context window as one stacked bar above the prompt,
2// split into the same categories and theme colors /context uses.
3
4// Whether the bar shows; saved in $.store so the toggle survives restarts
5let isOn = true
6// The last breakdown from $.session.usage, or null before the first one
7let breakdown = null
8// True while a breakdown request is in flight, so bursts fold into one
9let isMeasuring = false
10
11// Glyph per row kind: solid for what's used, light shade for free space,
12// medium shade for the autocompact buffer
13const GLYPH = { used: '█', free: '░', buffer: '▒' }
14
15// 12345 -> "12.3k", 1234567 -> "1.2M"
16function fmt(n) {
17  if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'
18  if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k'
19  return String(n)
20}
21
22// Split `width` cells across rows by tokens, largest remainder first.
23// Every row with tokens gets at least one cell, so small categories stay visible.
24function allocate(rows, width) {
25  const total = rows.reduce((sum, r) => sum + r.tokens, 0)
26  if (total <= 0 || width <= 0) return rows.map(() => 0)
27  const exact = rows.map((r) => (r.tokens / total) * width)
28  const cells = exact.map((x, i) => (rows[i].tokens > 0 ? Math.max(1, Math.floor(x)) : 0))
29  let left = width - cells.reduce((a, b) => a + b, 0)
30  const order = exact.map((x, i) => i).sort((a, b) => (exact[b] % 1) - (exact[a] % 1))
31  for (let k = 0; left > 0 && k < order.length * 2; k++) {
32    const i = order[k % order.length]
33    if (rows[i].tokens > 0) { cells[i] += 1; left -= 1 }
34  }
35  // The minimum of one cell can overshoot on a narrow band: take back from the widest
36  while (left < 0) {
37    const widest = cells.indexOf(Math.max(...cells))
38    if (cells[widest] <= 1) break
39    cells[widest] -= 1
40    left += 1
41  }
42  return cells
43}
44
45// Ask Claude Code for the /context breakdown. 'summary' estimates locally and
46// sends no token-count requests, so it's free to run after every turn.
47async function measure($) {
48  if (!isOn || isMeasuring) return
49  isMeasuring = true
50  try {
51    const usage = await $.session.usage({ breakdown: 'summary' })
52    breakdown = usage.context.breakdown ?? null
53    $.ui.invalidate('ui.render')
54  } finally {
55    isMeasuring = false
56  }
57}
58
59export function register(on) {
60  on('session.start', async ($, e, next) => {
61    const saved = await $.store.get('isOn')
62    if (typeof saved === 'boolean') isOn = saved
63    await measure($)
64    await $.command.register({
65      name: 'context-bar',
66      description: 'Show or hide the context bar above the prompt',
67      argumentHint: '[on|off]',
68      immediate: true,
69    })
70    return next(e)
71  })
72
73  // After each turn, and when the figures move
74  on('session.measure', async ($, e, next) => {
75    await measure($)
76    return next(e)
77  })
78
79  // After /clear, /resume, /branch and compaction the window starts over
80  on('classic.SessionStart', async ($, e, next) => {
81    breakdown = null
82    await measure($)
83    return next(e)
84  })
85
86  on('command.run', { command: 'context-bar' }, async ($, e) => {
87    const arg = (e.args || '').trim().toLowerCase()
88    isOn = arg === 'on' ? true : arg === 'off' ? false : !isOn
89    await $.store.set('isOn', isOn)
90    if (isOn) await measure($)
91    $.ui.invalidate('ui.render')
92    $.ui.toast('Context bar ' + (isOn ? 'on' : 'off'))
93    return {}
94  })
95
96  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97    // Hidden, nothing measured yet, or a survey wants the band: pass
98    if (!isOn || !breakdown || e.props.hasSurvey) return next(e)
99    const { Box, Text } = $.ui.resolve(e)
100
101    // Deferred tool schemas sit outside the window, as in /context's grid
102    const rows = breakdown.categories.filter((c) => c.kind !== 'deferred')
103    const width = Math.max(10, e.props.bodyColumns)
104    const cells = allocate(rows, width)
105
106    const bar = Box({
107      key: 'bar',
108      flexDirection: 'row',
109      children: rows
110        .map((r, i) =>
111          cells[i] > 0
112            ? Text({ key: 'seg-' + i, color: r.color, children: [(GLYPH[r.kind] ?? '█').repeat(cells[i])] })
113            : null,
114        )
115        .filter(Boolean),
116    })
117
118    // One line: the total, then a swatch, name and size per category
119    const used = rows.filter((r) => r.kind === 'used')
120    const legend = Box({
121      key: 'legend',
122      flexDirection: 'row',
123      flexWrap: 'wrap',
124      columnGap: 2,
125      children: [
126        Text({
127          key: 'total',
128          bold: true,
129          wrap: 'truncate',
130          children: [fmt(breakdown.totalTokens) + '/' + fmt(breakdown.rawMaxTokens) + ' ' + breakdown.percentage + '%'],
131        }),
132        ...used.map((r, i) =>
133          Box({
134            key: 'leg-' + i,
135            flexDirection: 'row',
136            flexShrink: 0,
137            children: [
138              Text({ key: 'sw', color: r.color, children: ['■ '] }),
139              Text({ key: 'nm', dimColor: true, wrap: 'truncate', children: [r.name + ' ' + fmt(r.tokens)] }),
140            ],
141          }),
142        ),
143      ],
144    })
145
146    const mine = Box({ key: 'context-bar', flexDirection: 'column', children: e.props.maxRows >= 2 ? [bar, legend] : [bar] })
147
148    // Keep whatever mods after this one draw in the band, under the bar
149    const theirs = await next(e)
150    return theirs ? Box({ flexDirection: 'column', children: [mine, theirs] }) : mine
151  })
152}
153