SLOPSHOPPER

context-bar

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

newbandguardcommandprompt
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 off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-bar

Draws your context window as a stacked bar above the prompt (illustrative output, not real figures):

█████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒
Context 42% · 419.0k/1.0M  ■ System prompt 3.7k  ■ System tools 34.6k  ■ Messages 380k
  • One segment per category, in the same colours /context uses. Used categories are solid, the autocompact buffer is shaded, and free space is dim.
  • Every non-empty category gets at least one cell, so small ones such as MCP instructions never vanish.
  • A legend line under the bar: percent used, tokens against the window, and each used category with its token count.

Use

/context-bar toggles it. It starts on in every session.

How it works

  • Reads $.session.usage({ breakdown: 'summary' }), the same breakdown /context shows. summary is a local estimate and sends no token-count requests.
  • Refreshes after each prompt, tool call (at most once every 1.5 s), finished turn and compaction.
  • Draws in the AbovePrompt band, on top of whatever other band mods draw there. It steps aside while a survey is showing.

Limits

  • Token counts are the engine's estimates, so the total can differ slightly from the live figure the status line shows.
  • With many used categories the legend line may wrap on a narrow terminal.
Source 2 files
hooks/register.tsx 156 lines
1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { Slice } from '../types'
5
6type Api = Parameters<Hook<'turn.complete'>>[0]
7
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, true)
10
11const GLYPH = { used: '█', buffer: '▒', free: '░' } as const
12
13// A usage read costs a local estimate, so a burst of tool calls shares one.
14const MIN_GAP_MS = 1500
15
16const fmt = (n: number) =>
17  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
18
19// Whole cells per slice that add up to `cells`: every used category that holds
20// tokens keeps at least one, and the rounding slack lands on the widest slice.
21function layout(slices: readonly Slice[], cells: number): number[] {
22  const total = slices.reduce((n, s) => n + s.tokens, 0) || 1
23  const widths = slices.map(s => {
24    const share = Math.round((s.tokens / total) * cells)
25
26    return s.kind === 'used' && s.tokens > 0 ? Math.max(1, share) : share
27  })
28  let slack = cells - widths.reduce((n, w) => n + w, 0)
29
30  while (slack !== 0) {
31    const step = slack > 0 ? 1 : -1
32    let pick = -1
33
34    widths.forEach((w, i) => {
35      const canShrink = step > 0 || w > 1
36      if (canShrink && (pick < 0 || w > widths[pick]!)) pick = i
37    })
38    if (pick < 0) break
39    widths[pick]! += step
40    slack -= step
41  }
42
43  return widths
44}
45
46let lastReadAt = 0
47
48async function refresh($: Api, force = false) {
49  const now = await $.clock.now()
50
51  if (!force && now - lastReadAt < MIN_GAP_MS) return
52  lastReadAt = now
53
54  try {
55    const usage = await $.session.usage({ breakdown: 'summary' })
56    const b = usage.context.breakdown
57
58    if (!b) return
59
60    await update($, snapshot, () => ({
61      window: b.rawMaxTokens,
62      total: b.totalTokens,
63      percent: b.percentage,
64      slices: b.categories
65        .filter(c => c.kind !== 'deferred' && c.tokens > 0)
66        .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] })),
67    }))
68  } catch {
69    // No session bound yet, or the read failed: keep the last bar.
70  }
71}
72
73export const register: Register = on => {
74  on('session.start', async ($, e, next) => {
75    await $.command.register({
76      name: 'context-bar',
77      description: 'Toggle the context window bar above the prompt',
78    })
79    await refresh($, true)
80
81    return next(e)
82  })
83
84  on('prompt.submit', async ($, e, next) => {
85    const result = await next(e)
86    await refresh($)
87
88    return result
89  })
90
91  on('tool.call', async ($, e, next) => {
92    const result = await next(e)
93    await refresh($)
94
95    return result
96  })
97
98  on('turn.complete', async ($, e, next) => {
99    await refresh($, true)
100
101    return next(e)
102  })
103
104  on('session.compact', async ($, e, next) => {
105    const result = await next(e)
106    await refresh($, true)
107
108    return result
109  })
110
111  on('command.run', { command: 'context-bar' }, async $ => {
112    const next = !(await read($, isOn))
113    await update($, isOn, () => next)
114
115    if (next) await refresh($, true)
116
117    return { text: `Context bar ${next ? 'on' : 'off'}.` }
118  })
119
120  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121    // Whatever another band hook drew stays: each hook draws on top of what is beneath it.
122    const below = await next(e)
123    const snap = await read($, snapshot)
124
125    if (e.props.hasSurvey || snap === null || !(await read($, isOn))) {
126      return below
127    }
128
129    const { Box, Text } = $.ui.resolve(e)
130    const widths = layout(snap.slices, Math.max(10, e.props.bodyColumns))
131    const used = snap.slices.filter(s => s.kind === 'used')
132
133    return (
134      <Box flexDirection="column">
135        {below}
136        <Text>
137          {snap.slices.map((s, i) => (
138            <Text key={s.name} color={s.color} dimColor={s.kind !== 'used'}>
139              {GLYPH[s.kind].repeat(widths[i]!)}
140            </Text>
141          ))}
142        </Text>
143        <Text dimColor>
144          {`Context ${Math.round(snap.percent)}% · ${fmt(snap.total)}/${fmt(snap.window)}  `}
145          {used.map(s => (
146            <Text key={s.name}>
147              <Text color={s.color}>■</Text>
148              {` ${s.name} ${fmt(s.tokens)}  `}
149            </Text>
150          ))}
151        </Text>
152      </Box>
153    )
154  })
155}
156
types/index.d.ts 23 lines
1export type Slice = {
2  name: string
3  tokens: number
4  /** The theme key /context draws this category in. */
5  color: string
6  kind: 'used' | 'free' | 'buffer'
7}
8
9export type Snapshot = {
10  /** The window the breakdown is measured against, in tokens. */
11  window: number
12  /** Tokens in use, as /context counts them. */
13  total: number
14  percent: number
15  slices: Slice[]
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'context-bar': { snapshot: Snapshot | null; isOn: boolean }
21  }
22}
23