SLOPSHOPPER

context-bar

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

newbandguardcommand
v0.1.0no licenseupdated 2026-10-06srepollock/dotfiles/.claude/skills/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 Claude Code mod that draws the context window as a stacked bar above the prompt, with one colour per /context category, so you can see what is filling the window without running /context.

What it does

  • Draws a bar above the prompt, sized to the terminal width, with a summary such as 40k/200k 20% (used tokens, window size, percentage).
  • Each /context category gets a share of the bar proportional to its tokens, in the category's own colour, and at least one cell so small categories stay visible. Glyphs distinguish used (█), free (░) and buffer (▒) space.
  • Draws a legend under the bar listing each category with its token count, for example █ Messages 25k. Deferred and zero-token categories are omitted.
  • Draws a dimmed usage row under the legend with your plan's rate limits: percent used and time until reset for each window the session reports, for example usage 5h 42% (2h15m) 7d 18% (4d6h). A spend limit shows as $. The row is hidden when the session reports no rate limits.
  • Refreshes after each turn, after compaction, on session start, when toggled on, and at most every 2 seconds on tool calls.
  • Yields to the feedback survey.
  • Visibility is persisted in the plugin store and restored on the next session.

Install

1. From the marketplace

/plugin install context-bar --marketplace srepollock/dotfiles

Answer y to add the marketplace, then pick a scope (user scope = every session). Hooks-only mods are active immediately.

2. Via the dotfiles repo

Clone srepollock/dotfiles and run .dotfile_scripts/claude_sync pull. It copies .claude/skills/context-bar/ into ~/.claude/skills/, where a plugin folder auto-loads in every new session.

3. Ad hoc

claude --plugin-dir /path/to/context-bar

Or list the absolute path in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Usage

CommandEffect
/context-barToggle the bar
/context-bar onShow the bar
/context-bar offHide the bar

The command replies Context bar on. or Context bar off.

Configuration

None. The only persisted setting is the on/off state set by /context-bar.

Development

claude plugin validate <folder>
claude plugin test <folder>

Files are hot-reloaded when the mod is loaded from ~/.claude/skills/ or --plugin-dir in an interactive session.

Requirements

Claude Code with the mod (function-hook plugin) API; built and tested against Claude Code 2.1.291.

Source 3 files
hooks/register.tsx 139 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { allocate, formatReset, formatTokens, glyphFor, rateLabel, summaryText, toSnapshot } from './bar'
5
6const COMMAND = 'context-bar'
7const STORE_KEY = 'isVisible'
8const TOOL_REFRESH_MS = 2_000
9const MIN_BAR_WIDTH = 10
10
11const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
12const isVisible = atom({ plugin: 'context-bar', key: 'isVisible' } as const, true)
13
14async function refresh($: EngineInterface): Promise<void> {
15  const usage = await $.session.usage({ breakdown: 'summary' })
16  const breakdown = usage.context.breakdown
17  if (!breakdown) return
18
19  await update($, snapshot, () => toSnapshot(breakdown, usage.rateLimits))
20}
21
22function parseToggle(args: string, current: boolean): boolean {
23  const word = args.trim().toLowerCase()
24  if (word === 'on') return true
25  if (word === 'off') return false
26  return !current
27}
28
29export const register: Register = on => {
30  let lastToolRefresh = 0
31
32  on('session.start', async ($, e, next) => {
33    const result = await next(e)
34
35    await $.command.register({
36      name: COMMAND,
37      description: 'Toggle the context window bar above the prompt',
38      argumentHint: '[on|off]',
39      immediate: true,
40    })
41
42    const stored = await $.store.get(STORE_KEY)
43    if (typeof stored === 'boolean') {
44      await update($, isVisible, () => stored)
45    }
46    await refresh($)
47
48    return result
49  })
50
51  on('command.run', { command: COMMAND }, async ($, e) => {
52    const shouldShow = parseToggle(e.args, await read($, isVisible))
53    await update($, isVisible, () => shouldShow)
54    await $.store.set(STORE_KEY, shouldShow)
55    if (shouldShow) {
56      await refresh($)
57    }
58
59    return { text: `Context bar ${shouldShow ? 'on' : 'off'}.` }
60  })
61
62  on('turn.complete', async ($, e, next) => {
63    const result = await next(e)
64    await refresh($)
65
66    return result
67  })
68
69  on('session.compact', async ($, e, next) => {
70    const result = await next(e)
71    await refresh($)
72
73    return result
74  })
75
76  on('tool.call', async ($, e, next) => {
77    const result = await next(e)
78    const now = await $.clock.now()
79    if (now - lastToolRefresh >= TOOL_REFRESH_MS) {
80      lastToolRefresh = now
81      await refresh($)
82    }
83
84    return result
85  })
86
87  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88    const current = await read($, snapshot)
89    if (e.props.hasSurvey || current === null || !(await read($, isVisible))) {
90      return next(e)
91    }
92
93    const { Box, Text } = $.ui.resolve(e)
94    const summary = summaryText(current)
95    const barWidth = Math.max(MIN_BAR_WIDTH, e.props.bodyColumns - summary.length)
96    const cells = allocate(current.segments, barWidth)
97    const now = await $.clock.now()
98
99    return (
100      <Box flexDirection="column">
101        <Box key="bar">
102          <Text>
103            {current.segments.map((segment, i) => (
104              <Text color={segment.color}>{glyphFor(segment.kind).repeat(cells[i] ?? 0)}</Text>
105            ))}
106          </Text>
107          <Text dimColor>
108            {summary}
109          </Text>
110        </Box>
111        <Box key="legend" flexWrap="wrap" columnGap={2}>
112          {current.segments.map(segment => (
113            <Text>
114              <Text color={segment.color}>{glyphFor(segment.kind)}</Text>
115              <Text dimColor>
116                {' '}
117                {segment.name} {formatTokens(segment.tokens)}
118              </Text>
119            </Text>
120          ))}
121        </Box>
122        {current.rateLimits.length > 0 && (
123          <Box key="usage" flexWrap="wrap" columnGap={2}>
124            <Text dimColor>usage</Text>
125            {current.rateLimits.map(limit => {
126              const reset = formatReset(limit.resetsAt, now)
127              return (
128                <Text dimColor>
129                  {rateLabel(limit.kind)} {Math.round(limit.percentUsed)}%{reset ? ` (${reset})` : ''}
130                </Text>
131              )
132            })}
133          </Box>
134        )}
135      </Box>
136    )
137  })
138}
139
hooks/bar.ts 120 lines
1import type { ContextCategory, SessionContextBreakdown, SessionRateLimit } from 'claude-code'
2
3import type { Segment, SegmentKind, Snapshot } from '../types'
4
5const GLYPHS: Record<SegmentKind, string> = { used: '█', free: '░', buffer: '▒' }
6
7const RATE_LABELS: Record<string, string> = {
8  five_hour: '5h',
9  seven_day: '7d',
10  spend_limit: '$',
11}
12
13export function glyphFor(kind: SegmentKind): string {
14  return GLYPHS[kind]
15}
16
17function isOnGrid(
18  category: ContextCategory,
19): category is ContextCategory & { kind: SegmentKind } {
20  return category.kind !== 'deferred' && category.tokens > 0
21}
22
23export function toSnapshot(
24  breakdown: SessionContextBreakdown,
25  rateLimits: readonly SessionRateLimit[],
26): Snapshot {
27  return {
28    segments: breakdown.categories.filter(isOnGrid).map(category => ({
29      name: category.name,
30      color: category.color,
31      tokens: category.tokens,
32      kind: category.kind,
33    })),
34    totalTokens: breakdown.totalTokens,
35    maxTokens: breakdown.rawMaxTokens,
36    percentage: breakdown.percentage,
37    rateLimits: rateLimits.map(limit => ({
38      kind: limit.kind,
39      percentUsed: limit.percentUsed,
40      resetsAt: limit.resetsAt,
41    })),
42  }
43}
44
45export function rateLabel(kind: string): string {
46  return RATE_LABELS[kind] ?? kind
47}
48
49/** A short "time until reset" from an ISO timestamp, e.g. `3h`, `2h15m`, `6d4h`. */
50export function formatReset(resetsAt: string | undefined, now: number): string {
51  if (!resetsAt) return ''
52  const ms = new Date(resetsAt).getTime() - now
53  if (!Number.isFinite(ms) || ms <= 0) return 'now'
54  const minutes = Math.round(ms / 60_000)
55  if (minutes < 60) return `${minutes}m`
56  const hours = Math.floor(minutes / 60)
57  const restMinutes = minutes % 60
58  if (hours < 24) return restMinutes ? `${hours}h${restMinutes}m` : `${hours}h`
59  const days = Math.floor(hours / 24)
60  return `${days}d${hours % 24}h`
61}
62
63/**
64 * Splits `width` cells across the segments in proportion to their tokens.
65 * The cells always sum to `width`, and every segment gets at least one cell
66 * while there are enough cells to go round, so a small category stays visible.
67 * Shares are taken against the segments' own sum rather than the window, so a
68 * breakdown that runs past the window still fills the bar exactly.
69 */
70export function allocate(segments: readonly Segment[], width: number): number[] {
71  const total = segments.reduce((sum, segment) => sum + segment.tokens, 0)
72  if (total === 0 || width <= 0) {
73    return segments.map(() => 0)
74  }
75
76  const exact = segments.map(segment => (segment.tokens / total) * width)
77  const cells = exact.map(Math.floor)
78
79  if (segments.length <= width) {
80    cells.forEach((count, i) => {
81      if (count === 0) cells[i] = 1
82    })
83  }
84
85  let remaining = width - sum(cells)
86  const byRemainder = exact
87    .map((value, i) => ({ i, remainder: value - Math.floor(value) }))
88    .sort((a, b) => b.remainder - a.remainder)
89  for (let k = 0; remaining > 0; k = (k + 1) % byRemainder.length) {
90    cells[byRemainder[k]!.i]! += 1
91    remaining -= 1
92  }
93
94  while (remaining < 0) {
95    const largest = cells.indexOf(Math.max(...cells))
96    cells[largest]! -= 1
97    remaining += 1
98  }
99
100  return cells
101}
102
103function sum(values: readonly number[]): number {
104  return values.reduce((a, b) => a + b, 0)
105}
106
107export function formatTokens(tokens: number): string {
108  if (tokens >= 1_000_000) return `${trim(tokens / 1_000_000)}M`
109  if (tokens >= 1_000) return `${trim(tokens / 1_000)}k`
110  return String(tokens)
111}
112
113function trim(value: number): string {
114  return value.toFixed(1).replace(/\.0$/, '')
115}
116
117export function summaryText(snapshot: Snapshot): string {
118  return ` ${formatTokens(snapshot.totalTokens)}/${formatTokens(snapshot.maxTokens)} ${snapshot.percentage}%`
119}
120
types/index.d.ts 29 lines
1export type SegmentKind = 'used' | 'free' | 'buffer'
2
3export type Segment = {
4  name: string
5  color: string
6  tokens: number
7  kind: SegmentKind
8}
9
10export type RateLimit = {
11  kind: string
12  percentUsed: number
13  resetsAt?: string
14}
15
16export type Snapshot = {
17  segments: Segment[]
18  totalTokens: number
19  maxTokens: number
20  percentage: number
21  rateLimits: RateLimit[]
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'context-bar': { snapshot: Snapshot | null; isVisible: boolean }
27  }
28}
29