SLOPSHOPPER

context-bar

Stacked bar of the context window above or below the prompt, coloured by /context category, with rate limits, room before compaction and session length…

newpanebandspinnercommandtimer
★ 1v0.4.1MITupdated 2026-10-08Akash001uts/claude-mods/plugins/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
│ ┃ context-details ✕ › fix the failing auth test and add an audit log call │ ┃ Counting tokens… │ ┃ [ Refresh ] ● context-bar: context-bar: the bar above the prompt shows how full y │ ● context-bar: Line 2 is the token count for each category; line 3 │ ⏺ 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. │ ● context-bar: /context-bar hides or shows it for this session. │ ● context-bar: /context-bar details opens a full token breakdown (m │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · context-details
Counting tokens… [ Refresh ]
README

context-bar

A bar above (or below) the prompt that shows how full your context window is, split into the same categories /context uses (system prompt, tools, memory files, messages and so on). I used to run /context every so often just to check how heavy a session was getting. Now it's always there, so I can wrap up before auto-compact kicks in, and I can spot what's eating my context, like MCP servers I forgot were still connected.

███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒ 30% 60.0k/200.0k
■ System prompt 4.0k  ■ Messages 56.0k
5h 42% (resets in 2h 14m)  ·  7d 18%  ·  100.0k to compact  ·  session 2h 5m

How it works

  • It turns on at the start of every session and updates after each turn.
  • The first time it runs (and after an update that adds something worth knowing) it prints a short rundown of the commands in the chat. /context-bar help shows it again.
  • The third line shows your 5-hour and 7-day rate limits (subscription accounts only), how many tokens are left before auto-compact, and how long the session has been running. It hides itself if there isn't room for it.
  • /context-bar hides or shows the bar for the current session.
  • /context-bar below moves the bar under the prompt box, and /context-bar above moves it back. You can also change it under "Bar position" in /config. Above is the default, and the desktop app always keeps it above.
  • /context-bar details opens a pane with the full breakdown: each category and its share of the window, every memory file, MCP servers (and how many of their tools are loaded), your biggest skills, custom agents, and the cache split of the last API call. Press r to refresh it.

What it runs and stores

  • It only reads the session's usage numbers from Claude Code, the same ones /context shows. It doesn't read your files or messages, and it doesn't send anything anywhere.
  • The details pane asks Claude Code for the full token count, which costs a few extra requests (same as running /context). So it only refreshes while the pane is open, after each turn or when you press r.
  • Whether the bar is on is kept for the current session only. The only things saved are where you put the bar (above or below), which goes in your Claude Code settings like any other plugin option, and a note in the plugin's own storage that you've seen the rundown.

Licence

MIT

Source 2 files
hooks/register.tsx 398 lines
1import { atom, read, update } from 'claude-code'
2import type { ConfigSetResult, EngineInterface, Register, ResolveInput, SessionRateLimit } from 'claude-code'
3
4import type { Details, Limit, Row, Segment, Snapshot } from '../types'
5
6// On at the start of every session; /context-bar hides it for that session only.
7const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, true)
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9// The time the band last read, bumped each minute so the session length and reset countdowns move.
10const now = atom({ plugin: 'context-bar', key: 'now' } as const, 0)
11const details = atom({ plugin: 'context-bar', key: 'details' } as const, null)
12const isDetailsOpen = atom({ plugin: 'context-bar', key: 'isDetailsOpen' } as const, false)
13
14const PANE = 'context-details'
15const GLYPH = { used: '█', buffer: '▒', free: '░' } as const
16const REFRESH_MS = 3000
17const TICK_MS = 60_000
18const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
19
20export const short = (n: number) =>
21  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
22
23export const span = (ms: number) => {
24  const m = Math.max(0, Math.floor(ms / 60_000))
25  if (m < 60) return `${m}m`
26  const hours = Math.floor(m / 60)
27  if (hours < 24) return `${hours}h ${m % 60}m`
28  return `${Math.floor(hours / 24)}d ${hours % 24}h`
29}
30
31// The band's third line: rate limits, room before compaction, session length.
32export const infoLine = (snap: Snapshot, at: number) => {
33  const parts = snap.limits.map(l => {
34    const label = LIMIT_LABEL[l.kind] ?? l.kind
35    const reset = l.resetsAt !== undefined && l.resetsAt > at ? ` (resets in ${span(l.resetsAt - at)})` : ''
36    return `${label} ${l.percentUsed}%${reset}`
37  })
38  if (snap.compactIn !== undefined) parts.push(`${short(snap.compactIn)} to compact`)
39  if (snap.startedAt > 0 && at >= snap.startedAt) parts.push(`session ${span(at - snap.startedAt)}`)
40  return parts.join('  ·  ')
41}
42
43// Split `width` cells across segments by largest remainder, giving every
44// non-empty used segment at least one cell so small categories stay visible.
45export const allocate = (segments: Segment[], max: number, width: number): number[] => {
46  if (max <= 0 || width <= 0) return segments.map(() => 0)
47  const exact = segments.map(s => (s.tokens / max) * width)
48  const cells = exact.map((x, i) => (segments[i]!.kind === 'used' && segments[i]!.tokens > 0 ? Math.max(1, Math.floor(x)) : Math.floor(x)))
49  let spare = width - cells.reduce((a, b) => a + b, 0)
50  const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
51  for (let k = 0; spare > 0 && order.length > 0; k = (k + 1) % order.length, spare--) cells[order[k]![1]]! += 1
52  // Over budget from the minimum-one rule: take cells back from the free space, then the largest.
53  while (spare < 0) {
54    const free = segments.findIndex((s, i) => s.kind === 'free' && cells[i]! > 0)
55    const i = free >= 0 ? free : cells.indexOf(Math.max(...cells))
56    cells[i]! -= 1
57    spare++
58  }
59  return cells
60}
61
62const toLimit = (l: SessionRateLimit): Limit => {
63  const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
64  return Number.isNaN(at) ? { kind: l.kind, percentUsed: l.percentUsed } : { kind: l.kind, percentUsed: l.percentUsed, resetsAt: at }
65}
66
67// The last two parts of a path, so memory files read as `.claude/CLAUDE.md`.
68const tail = (path: string) => {
69  const parts = path.split(/[\\/]+/).filter(Boolean)
70  return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : parts.join('/')
71}
72
73const byTokens = (a: Row, b: Row) => b.tokens - a.tokens
74
75let lastFetch = 0
76let isFetching = false
77let isFetchingDetails = false
78let tick: { cancel: () => void } | undefined
79
80async function refresh($: EngineInterface, force = false) {
81  const at = await $.clock.now()
82  if (isFetching || (!force && at - lastFetch < REFRESH_MS)) return
83  if (!(await read($, isOn))) return
84  isFetching = true
85  lastFetch = at
86  try {
87    const usage = await $.session.usage({ breakdown: 'summary' })
88    const b = usage.context.breakdown
89    if (!b) return
90    const segments: Segment[] = b.categories
91      .filter(c => c.kind !== 'deferred' && c.tokens > 0)
92      .map(c => ({ name: c.name, color: c.color, tokens: c.tokens, kind: c.kind as Segment['kind'] }))
93    const next: Snapshot = {
94      segments,
95      total: b.totalTokens,
96      max: b.rawMaxTokens,
97      percent: b.percentage,
98      startedAt: usage.startedAt,
99      limits: usage.rateLimits.map(toLimit),
100    }
101    if (b.isAutoCompactEnabled && b.autoCompactThreshold !== undefined) next.compactIn = Math.max(0, b.autoCompactThreshold - b.totalTokens)
102    // Time first, so the new snapshot never draws against a stale clock.
103    await update($, now, () => at)
104    await update($, snapshot, () => next)
105  } finally {
106    isFetching = false
107  }
108}
109
110// The full breakdown counts every tool and memory file, as /context does, so it
111// is read only for the details pane: on opening it and after each turn while it is open.
112async function refreshDetails($: EngineInterface) {
113  if (isFetchingDetails) return
114  isFetchingDetails = true
115  try {
116    const b = (await $.session.usage({ breakdown: 'full' })).context.breakdown
117    if (!b) return
118    const servers = new Map<string, { tokens: number; tools: number; loaded: number }>()
119    for (const t of b.mcpTools) {
120      const s = servers.get(t.serverName) ?? { tokens: 0, tools: 0, loaded: 0 }
121      servers.set(t.serverName, { tokens: s.tokens + t.tokens, tools: s.tools + 1, loaded: s.loaded + (t.isLoaded ? 1 : 0) })
122    }
123    const next: Details = {
124      model: b.model,
125      max: b.rawMaxTokens,
126      total: b.totalTokens,
127      categories: b.categories.filter(c => c.tokens > 0).map(c => ({ name: c.name, color: c.color, tokens: c.tokens, kind: c.kind })),
128      memoryFiles: b.memoryFiles.map(f => ({ name: tail(f.path), tokens: f.tokens, note: f.type })).sort(byTokens),
129      mcpServers: [...servers]
130        .map(([name, s]) => ({ name, tokens: s.tokens, note: `${s.loaded}/${s.tools} tools loaded` }))
131        .sort(byTokens),
132      agents: b.agents.map(a => ({ name: a.agentType, tokens: a.tokens, note: a.source })).sort(byTokens),
133      api: b.apiUsage && {
134        uncached: b.apiUsage.input_tokens,
135        cacheRead: b.apiUsage.cache_read_input_tokens,
136        cacheWrite: b.apiUsage.cache_creation_input_tokens,
137        output: b.apiUsage.output_tokens,
138      },
139    }
140    if (b.skills)
141      next.skills = {
142        total: b.skills.totalSkills,
143        included: b.skills.includedSkills,
144        tokens: b.skills.tokens,
145        top: b.skills.skillFrontmatter
146          .map(s => ({ name: s.name, tokens: s.tokens, note: s.pluginName ?? s.source }))
147          .sort(byTokens)
148          .slice(0, 10),
149      }
150    if (b.slashCommands)
151      next.commands = { total: b.slashCommands.totalCommands, included: b.slashCommands.includedCommands, tokens: b.slashCommands.tokens }
152    await update($, details, () => next)
153  } finally {
154    isFetchingDetails = false
155  }
156}
157
158async function openDetails($: EngineInterface) {
159  await update($, isDetailsOpen, () => true)
160  await $.ui.open({ id: PANE, title: 'Context details' })
161  await refreshDetails($).catch(() => {})
162}
163
164// The bar, its legend and (when there's room for it) the info line, laid out `columns` wide.
165async function drawBar($: EngineInterface, e: ResolveInput, snap: Snapshot, columns: number, showInfo: boolean) {
166  const { Box, Text } = $.ui.resolve(e)
167  const summary = ` ${snap.percent}% ${short(snap.total)}/${short(snap.max)}`
168  const width = Math.max(10, columns - summary.length)
169  const cells = allocate(snap.segments, snap.max, width)
170  const used = snap.segments.filter(s => s.kind === 'used')
171  const info = showInfo ? infoLine(snap, await read($, now)) : ''
172  return (
173    <Box flexDirection="column">
174      <Text wrap="truncate">
175        {snap.segments.map((s, i) =>
176          cells[i]! > 0 ? (
177            <Text color={s.color} dimColor={s.kind === 'free'}>
178              {GLYPH[s.kind].repeat(cells[i]!)}
179            </Text>
180          ) : null,
181        )}
182        <Text dimColor>{summary}</Text>
183      </Text>
184      <Text wrap="truncate">
185        {used.map(s => (
186          <Text>
187            <Text color={s.color}>■</Text>
188            <Text dimColor> {s.name} {short(s.tokens)}  </Text>
189          </Text>
190        ))}
191      </Text>
192      {info ? (
193        <Text wrap="truncate" dimColor>
194          {info}
195        </Text>
196      ) : null}
197    </Box>
198  )
199}
200
201// The bar to draw now, or null when it's hidden or there's nothing to show yet.
202async function shown($: EngineInterface) {
203  if (!(await read($, isOn))) return null
204  const snap = await read($, snapshot)
205  return snap && snap.segments.length > 0 ? snap : null
206}
207
208const moved = (saved: ConfigSetResult, where: string) =>
209  saved.deny ? `Couldn't move the bar: ${saved.deny}` : `Context bar moved ${where} the prompt.`
210const failed = (err: unknown): ConfigSetResult => ({ deny: String(err) })
211
212// Shown once after install, and again when INTRO_VERSION goes up for a feature worth telling people about.
213const INTRO_VERSION = '0.3'
214export const INTRO = [
215  'context-bar: the bar above the prompt shows how full your context window is, split by /context category.',
216  '  Line 2 is the token count for each category; line 3 shows rate limits, tokens left before auto-compact and session length.',
217  '  /context-bar hides or shows it for this session.',
218  '  /context-bar details opens a full token breakdown (memory files, MCP servers, skills, cache).',
219  '  /context-bar below moves the bar under the prompt box, /context-bar above moves it back (also under /config).',
220  '  /context-bar help shows this again.',
221]
222
223const showIntro = ($: EngineInterface) => INTRO.forEach(line => $.ui.log(line))
224
225export const register: Register = (on, options) => {
226  // Anything but "below" keeps the bar where it has always been.
227  const isBelow = String(options.position ?? 'above').trim().toLowerCase() === 'below'
228
229  on('session.start', async ($, e, next) => {
230    await $.command.register({
231      name: 'context-bar',
232      description: 'Toggle the context window bar; /context-bar details opens a token breakdown, /context-bar above|below moves it, /context-bar help explains it',
233    })
234    // Only a session that draws the band needs the minute ticker; a reload drops
235    // the old module's timers, and the cancel covers session.start firing twice in one load.
236    tick?.cancel()
237    if (e.isInteractive)
238      tick = $.clock.every(TICK_MS, () => {
239        void $.clock.now().then(at => update($, now, () => at))
240      })
241    if (e.isInteractive && (await $.store.get('introSeen').catch(() => INTRO_VERSION)) !== INTRO_VERSION) {
242      showIntro($)
243      await $.store.set('introSeen', INTRO_VERSION).catch(() => {})
244    }
245    // Draw the bar as soon as the session opens; never let a failed read block startup.
246    await refresh($, true).catch(() => {})
247    return next(e)
248  })
249
250  on('command.run', { command: 'context-bar' }, async ($, e) => {
251    const arg = e.args.trim().toLowerCase()
252    if (arg === 'details') {
253      await openDetails($)
254      return { text: 'Context details opened.' }
255    }
256    if (arg === 'help') {
257      showIntro($)
258      return { text: 'Context bar help shown above.' }
259    }
260    // Each settings call is written out in full, so the plugin directory can read what it sets.
261    if (arg === 'below') return { text: moved(await $.config.set({ key: 'context-bar.position', value: 'below' }).catch(failed), 'below') }
262    if (arg === 'above') return { text: moved(await $.config.set({ key: 'context-bar.position', value: 'above' }).catch(failed), 'above') }
263    const nextOn = !(await read($, isOn))
264    await update($, isOn, () => nextOn)
265    if (nextOn) await refresh($, true)
266    return { text: nextOn ? 'Context bar on.' : 'Context bar off.' }
267  })
268
269  on('ui.close', async ($, e, next) => {
270    if (e.id === PANE) await update($, isDetailsOpen, () => false)
271    return next(e)
272  })
273
274  on('session.measure', ($, e, next) => {
275    void refresh($)
276    return next(e)
277  })
278
279  on('turn.complete', async ($, e, next) => {
280    void refresh($, true)
281    if (await read($, isDetailsOpen)) void refreshDetails($).catch(() => {})
282    return next(e)
283  })
284
285  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286    // Below is a terminal layout; other surfaces keep the bar above.
287    if ((isBelow && e.surface === 'terminal') || e.props.hasSurvey) return next(e)
288    const snap = await shown($)
289    if (!snap) return next(e)
290    const bar = await drawBar($, e, snap, e.props.bodyColumns, e.props.maxRows >= 3)
291    // Whatever else draws above the prompt (another mod's band) stays, under the bar.
292    const beneath = await next(e)
293    const { Box } = $.ui.resolve(e)
294    return (
295      <Box flexDirection="column">
296        {bar}
297        {beneath}
298      </Box>
299    )
300  })
301
302  // Below the prompt: the bar takes the hint line's place, and the engine's hint line
303  // (shortcuts, esc to interrupt) draws under it.
304  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
305    if (!isBelow || e.surface !== 'terminal') return next(e)
306    const snap = await shown($)
307    if (!snap) return next(e)
308    const bar = await drawBar($, e, snap, (e.viewport?.columns ?? 80) - 2, true)
309    const hint = await next(e)
310    const { Box } = $.ui.resolve(e)
311    return (
312      <Box flexDirection="column">
313        {bar}
314        {hint}
315      </Box>
316    )
317  })
318
319  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
320    const { Box, Text, Button } = $.ui.resolve(e)
321    const d = await read($, details)
322    const refreshButton = <Button key="refresh" label="Refresh" hotkey="r" dimColor onPress={() => refreshDetails($).catch(() => {})} />
323    if (!d)
324      return (
325        <Box flexDirection="column">
326          <Text dimColor>Counting tokens…</Text>
327          {refreshButton}
328        </Box>
329      )
330
331    const pct = (n: number) => (d.max > 0 ? `${((n / d.max) * 100).toFixed(1)}%` : '')
332    const line = (r: Row, color?: string) => (
333      <Box flexDirection="row">
334        <Box flexGrow={1}>
335          <Text wrap="truncate">
336            {color ? <Text color={color}>■ </Text> : '  '}
337            {r.name}
338            {r.note ? <Text dimColor>  {r.note}</Text> : null}
339          </Text>
340        </Box>
341        <Text>
342          {' '}
343          {short(r.tokens).padStart(6)} <Text dimColor>{pct(r.tokens).padStart(6)}</Text>
344        </Text>
345      </Box>
346    )
347    const section = (title: string, rows: Row[], extra?: string) =>
348      rows.length === 0 ? null : (
349        <Box flexDirection="column" marginTop={1}>
350          <Text bold>
351            {title}
352            {extra ? <Text dimColor>  {extra}</Text> : null}
353          </Text>
354          {rows.map(r => line(r))}
355        </Box>
356      )
357
358    return (
359      <Box flexDirection="column">
360        <Text>
361          <Text bold>{d.model}</Text>
362          <Text dimColor>
363            {'  '}
364            {short(d.total)}/{short(d.max)} ({pct(d.total)})
365          </Text>
366        </Text>
367        <Box flexDirection="column" marginTop={1}>
368          <Text bold>Categories</Text>
369          {d.categories.map(c => line({ name: c.name, tokens: c.tokens, note: c.kind === 'deferred' ? 'loaded on demand' : undefined }, c.color))}
370        </Box>
371        {section('Memory files', d.memoryFiles)}
372        {section('MCP servers', d.mcpServers)}
373        {d.skills ? section('Skills', d.skills.top, `${d.skills.included}/${d.skills.total} listed, ${short(d.skills.tokens)} in all; largest shown`) : null}
374        {section('Custom agents', d.agents)}
375        {d.commands ? (
376          <Box flexDirection="column" marginTop={1}>
377            <Text bold>Slash commands</Text>
378            <Text dimColor>
379              {'  '}
380              {d.commands.included}/{d.commands.total} listed, {short(d.commands.tokens)} tokens
381            </Text>
382          </Box>
383        ) : null}
384        {d.api ? (
385          <Box flexDirection="column" marginTop={1}>
386            <Text bold>Last API call</Text>
387            <Text dimColor>
388              {'  '}cache read {short(d.api.cacheRead)}  ·  cache write {short(d.api.cacheWrite)}  ·  uncached {short(d.api.uncached)}  ·  output{' '}
389              {short(d.api.output)}
390            </Text>
391          </Box>
392        ) : null}
393        <Box marginTop={1}>{refreshButton}</Box>
394      </Box>
395    )
396  })
397}
398
types/index.d.ts 33 lines
1export type Segment = { name: string; color: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: number }
3export type Snapshot = {
4  segments: Segment[]
5  total: number
6  max: number
7  percent: number
8  startedAt: number
9  limits: Limit[]
10  // Tokens left before auto-compaction runs; absent when it is off.
11  compactIn?: number
12}
13
14export type Row = { name: string; tokens: number; note?: string }
15export type Details = {
16  model: string
17  max: number
18  total: number
19  categories: { name: string; color: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }[]
20  memoryFiles: Row[]
21  mcpServers: Row[]
22  agents: Row[]
23  skills?: { total: number; included: number; tokens: number; top: Row[] }
24  commands?: { total: number; included: number; tokens: number }
25  api: { uncached: number; cacheRead: number; cacheWrite: number; output: number } | null
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'context-bar': { isOn: boolean; snapshot: Snapshot | null; now: number; details: Details | null; isDetailsOpen: boolean }
31  }
32}
33