SLOPSHOPPER

context-bar

A context-window breakdown bar above the prompt, toggled with /context-bar

newbandcommandtimer
v0.2.0no licenseupdated 2026-10-05myUdav4iik/claude-mods/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 ⏺ 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

A live breakdown of the context window, drawn in the band above Claude Code's prompt.

In the terminal:

context-bar in the terminal: a context header at 18%, the category bar, its legend, and a usage row with 5h and 7d limits

In the desktop app:

context-bar in the Claude desktop app: a context header at 35%, the category bar on a dark free-space fill, and its legend

  • Header: the share of the window in use, as a badge that is green under 50%, yellow under 80% and red above. Then the tokens in use, the window size, and the point where auto-compaction runs.
  • Bar: one segment per category, in proportion to its share of the window, followed by a track for the free space.
  • Legend: each category with its token count, in the colours /context uses.
  • Usage: your plan's rate-limit windows (5h, 7d, or a gateway's spend limit), each with the share used, coloured like the context badge, and a countdown to when it resets. The row only appears on a subscription, where Claude Code receives these figures.

Usage

CommandDoes
/context-barShows or hides the bar
/context-bar onShows the bar
/context-bar offHides the bar

The command runs at once, even while Claude is working. The choice is remembered across sessions. Claude Code's own [-] control on the band collapses it as well.

How it works

HookWhat it does
session.startRegisters /context-bar, restores the saved on/off choice, takes a first reading, and starts a once-a-minute timer for the reset countdowns.
turn.completeTakes a reading after each turn of the main conversation. Subagent turns are skipped.
session.compactTakes a reading after the conversation is compacted.
session.measureUpdates the usage row when a rate-limit window moves, between turns too.
ui.render on AbovePromptDraws the band. It steps aside while Claude Code shows a survey there.

Readings come from $.session.usage({ breakdown: 'summary' }), the same per-category breakdown /context shows. The summary mode estimates locally, so a reading sends no requests and costs nothing. The same call returns the rate-limit windows from the last API response; the usage row shows those, so it sends no requests either.

The bar is built from flex boxes, each growing by its share of the window, so it splits in exact proportion at any width and never wraps. Small categories still get at least one cell. Free space is drawn differently per surface:

  • Terminal: a dim line, because Claude Code's colour for free space there is bright enough to read as used.
  • Desktop app: a dark fill. The desktop wraps long runs of text rather than clipping them, so a line of characters doesn't work there.

The compaction reserve is drawn as part of the free track; the header shows where compaction runs.

Limitations

  • Estimates: the category figures are estimates, as in /context, so the total can differ slightly from the status line.
  • Once per turn: the bar updates after each turn, not while Claude is working.
  • Usage after a reset: the percentages come from the last API response. Once a window's reset time passes, the row shows "resetting" until the next response brings a new figure.
  • Before the first reply: the bar shows "waiting for the first reading…".
  • Shared band: the band above the prompt holds one mod at a time. If another installed mod draws there, only one of them shows.

Tests

tests/bar.test.tsx feeds the mod a sample breakdown, draws the band on the terminal and desktop surfaces, and fails if either surface refuses the drawing. Claude Code drops a drawing it can't validate without an error you'd see, so run the tests after changing how the bar is drawn:

claude plugin test .
Source 2 files
hooks/register.tsx 288 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Segment, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
7const isShown = atom({ plugin: 'context-bar', key: 'isShown' } as const, true)
8const limits = atom({ plugin: 'context-bar', key: 'limits' } as const, [] as Limit[])
9// The time the reset countdowns are measured from, moved on once a minute.
10const now = atom({ plugin: 'context-bar', key: 'now' } as const, 0)
11
12// Persisted across sessions, so the bar stays hidden once turned off.
13const SHOWN_KEY = 'isShown'
14const ACCENT = '#D97757'
15// Longer than any band is wide; the free track's box clips it to its share.
16const FREE_FILL = '─'.repeat(400)
17
18// Shorter legend labels for /context's category names; others show as given.
19const LABELS: Record<string, string> = {
20  'system prompt': 'system',
21  'system tools': 'tools',
22  'mcp tools': 'mcp',
23  'mcp server instructions': 'mcp instr',
24  'custom agents': 'agents',
25  'memory files': 'memory',
26}
27
28// Short names for the rate-limit windows; others show with spaces for underscores.
29const LIMIT_LABELS: Record<string, string> = {
30  five_hour: '5h',
31  seven_day: '7d',
32  spend_limit: 'spend',
33}
34const TICK_MS = 60_000
35
36const short = (n: number) => {
37  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
38  if (n >= 1_000) return `${+(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`
39  return String(n)
40}
41
42// A segment's share of the window as a flex weight, in hundredths of a
43// percent: the surfaces cap flexGrow at 10000.
44const growOf = (tokens: number, max: number) => Math.min(10_000, (tokens / max) * 10_000)
45
46const badgeColor = (percent: number) =>
47  percent < 50 ? 'success' : percent < 80 ? 'warning' : 'error'
48
49// Time left as `45m`, `2h 14m` or `3d 4h`, rounded up to the minute; null once
50// it has passed.
51const until = (ms: number) => {
52  const minutes = Math.ceil(ms / 60_000)
53  if (minutes <= 0) return null
54  if (minutes < 60) return `${minutes}m`
55  const hours = Math.floor(minutes / 60)
56  if (hours < 24) return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`
57  const days = Math.floor(hours / 24)
58  return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`
59}
60
61const toLimits = (windows: readonly SessionRateLimit[]): Limit[] =>
62  windows.map(w => {
63    const resetsAt = w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt)
64    return { kind: w.kind, percent: w.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
65  })
66
67const setLimits = async ($: EngineInterface, windows: readonly SessionRateLimit[]) => {
68  const time = await $.clock.now()
69  await update($, now, () => time)
70  await update($, limits, () => toLimits(windows))
71}
72
73// Moves the countdowns on; skipped while there is nothing on screen to move.
74const tick = async ($: EngineInterface) => {
75  if (!(await read($, isShown)) || !(await read($, limits)).some(l => l.resetsAt !== null)) {
76    return
77  }
78  const time = await $.clock.now()
79  await update($, now, () => time)
80}
81
82const refresh = async ($: EngineInterface) => {
83  try {
84    const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
85    await setLimits($, rateLimits)
86    const b = context.breakdown
87    if (!b) {
88      return
89    }
90    const segments: Segment[] = b.categories
91      .filter(c => c.kind !== 'deferred')
92      .map(c => ({
93        name: c.name.toLowerCase(),
94        tokens: c.tokens,
95        color: c.color,
96        kind: c.kind === 'free' ? 'free' : c.kind === 'buffer' ? 'buffer' : 'used',
97      }))
98    const next: Snapshot = {
99      total: b.totalTokens,
100      max: b.rawMaxTokens,
101      threshold: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
102      segments,
103    }
104    await update($, snapshot, () => next)
105  } catch {
106    // No reading this time; the bar keeps the last one.
107  }
108}
109
110const setShown = async ($: EngineInterface, value: boolean) => {
111  await $.store.set(SHOWN_KEY, value)
112  await update($, isShown, () => value)
113  if (value) {
114    await refresh($)
115  }
116}
117
118export const register: Register = on => {
119  on('session.start', async ($, e, next) => {
120    const result = await next(e)
121    await $.command.register({
122      name: 'context-bar',
123      description: 'Toggle the context-window bar above the prompt',
124      argumentHint: '[on|off]',
125      immediate: true,
126    })
127    const stored = await $.store.get(SHOWN_KEY)
128    await update($, isShown, () => stored !== false)
129    $.clock.after(0, () => void refresh($))
130    $.clock.every(TICK_MS, () => void tick($))
131
132    return result
133  })
134
135  on('command.run', { command: 'context-bar' }, async ($, e) => {
136    const arg = e.args.trim().toLowerCase()
137    if (arg !== '' && arg !== 'on' && arg !== 'off') {
138      return { text: 'Usage: /context-bar [on|off]' }
139    }
140    const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isShown))
141    await setShown($, value)
142
143    return { text: value ? 'Context bar on.' : 'Context bar off.' }
144  })
145
146  on('turn.complete', async ($, e, next) => {
147    const result = await next(e)
148    if (e.agentId === undefined) {
149      $.clock.after(0, () => void refresh($))
150    }
151    return result
152  })
153
154  on('session.compact', async ($, e, next) => {
155    const result = await next(e)
156    if (e.agentId === undefined) {
157      $.clock.after(0, () => void refresh($))
158    }
159    return result
160  })
161
162  // Rate-limit windows move between turns too; the engine pushes them here.
163  on('session.measure', async ($, e, next) => {
164    const result = await next(e)
165    if (e.changed.includes('rateLimits')) {
166      await setLimits($, e.rateLimits)
167    }
168    return result
169  })
170
171  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
172    if (e.props.hasSurvey || !(await read($, isShown))) {
173      return next(e)
174    }
175    const { Box, Text } = $.ui.resolve(e)
176    const snap = await read($, snapshot)
177    const windows = await read($, limits)
178    const time = await read($, now)
179
180    // One row of plan usage, each window with its share used and when it
181    // resets. Absent off a subscription, where no window is reported.
182    const usage = windows.length > 0 && (
183      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
184        <Box key="usage-head" flexDirection="row">
185          <Text color={ACCENT}>◆ </Text>
186          <Text bold>usage</Text>
187        </Box>
188        {windows.map((l, i) => {
189          const percent = Math.round(l.percent)
190          const left = l.resetsAt === null ? null : until(l.resetsAt - time)
191          return (
192            <Box key={`lim${i}`} flexDirection="row">
193              <Text dimColor>{LIMIT_LABELS[l.kind] ?? l.kind.replace(/_/g, ' ')} </Text>
194              <Text bold color={badgeColor(percent)}>{`${percent}%`}</Text>
195              {l.resetsAt !== null && (
196                <Text dimColor>{left === null ? ' · resetting' : ` · resets in ${left}`}</Text>
197              )}
198            </Box>
199          )
200        })}
201      </Box>
202    )
203
204    if (snap === null || snap.max <= 0) {
205      return (
206        <Box flexDirection="column" paddingX={1}>
207          <Box flexDirection="row">
208            <Text color={ACCENT}>◆ </Text>
209            <Text bold>context</Text>
210            <Text dimColor>  waiting for the first reading…</Text>
211          </Box>
212          {usage}
213        </Box>
214      )
215    }
216
217    const percent = Math.round((snap.total / snap.max) * 100)
218    const used = snap.segments.filter(s => s.kind === 'used' && s.tokens > 0)
219    const free = snap.segments.find(s => s.kind === 'free')
220    const isTerminal = e.surface === 'terminal'
221    // Everything past the used segments, the compaction reserve included, is
222    // drawn as one free track; the header says where compaction runs.
223    const rest = Math.max(0, snap.max - used.reduce((sum, s) => sum + s.tokens, 0))
224
225    // Each segment grows by its token count from a zero basis, so the row
226    // splits in exact proportion (the surfaces accept only whole-number
227    // percentage widths).
228    // The badge sits beside the totals, not at the row's right edge, where the
229    // engine draws the band's own [-] control over it.
230    return (
231      <Box flexDirection="column" paddingX={1}>
232        <Box flexDirection="row">
233          <Text color={ACCENT}>◆ </Text>
234          <Text bold>context </Text>
235          <Text bold color="black" backgroundColor={badgeColor(percent)}>
236            {` ${percent}% `}
237          </Text>
238          <Text bold> {short(snap.total)}</Text>
239          <Text dimColor>/{short(snap.max)}</Text>
240          {snap.threshold !== null && <Text dimColor> · compacts {short(snap.threshold)}</Text>}
241        </Box>
242        <Box flexDirection="row" height={1} overflow="hidden">
243          {used.map((s, i) => (
244            <Box
245              key={`seg${i}`}
246              width={0}
247              flexGrow={growOf(s.tokens, snap.max)}
248              minWidth={1}
249              height={1}
250              backgroundColor={s.color}
251            />
252          ))}
253          {/* On the terminal the free track is a dim line of text, not filled:
254              the engine's colour for free space there is bright enough to read
255              as used. Other surfaces wrap a long text run rather than clip it,
256              and draw that colour dark, so it is filled there. */}
257          {rest > 0 && (
258            isTerminal ? (
259              <Box key="free" width={0} flexGrow={growOf(rest, snap.max)} height={1} overflow="hidden">
260                <Text dimColor>{FREE_FILL}</Text>
261              </Box>
262            ) : (
263              <Box key="free" width={0} flexGrow={growOf(rest, snap.max)} height={1} backgroundColor={free?.color} />
264            )
265          )}
266        </Box>
267        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
268          {used.map((s, i) => (
269            <Box key={`leg${i}`} flexDirection="row">
270              <Text color={s.color}>■ </Text>
271              <Text dimColor>{LABELS[s.name] ?? s.name} </Text>
272              <Text>{short(s.tokens)}</Text>
273            </Box>
274          ))}
275          {free && free.tokens > 0 && (
276            <Box key="leg-free" flexDirection="row">
277              {isTerminal ? <Text dimColor>─ </Text> : <Text color={free.color}>■ </Text>}
278              <Text dimColor>free </Text>
279              <Text>{short(free.tokens)}</Text>
280            </Box>
281          )}
282        </Box>
283        {usage}
284      </Box>
285    )
286  })
287}
288
types/index.d.ts 21 lines
1export type SegmentKind = 'used' | 'free' | 'buffer'
2
3export type Segment = { name: string; tokens: number; color: string; kind: SegmentKind }
4
5export type Snapshot = {
6  total: number
7  max: number
8  threshold: number | null
9  segments: Segment[]
10}
11
12// One plan rate-limit window: `five_hour`, `seven_day`, or a gateway's
13// `spend_limit`. `resetsAt` is in epoch milliseconds, null when not reported.
14export type Limit = { kind: string; percent: number; resetsAt: number | null }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'context-bar': { snapshot: Snapshot | null; isShown: boolean; limits: Limit[]; now: number }
19  }
20}
21