Draws the context window as a stacked bar above the prompt, one colour per /context category; toggle with /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.
40k/200k 20% (used tokens, window size, percentage)./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.█ Messages 25k. Deferred and zero-token categories are omitted.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.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.
| Command | Effect |
|---|---|
/context-bar | Toggle the bar |
/context-bar on | Show the bar |
/context-bar off | Hide the bar |
The command replies Context bar on. or Context bar off.
None. The only persisted setting is the on/off state set by /context-bar.
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.
Claude Code with the mod (function-hook plugin) API; built and tested against Claude Code 2.1.291.
hooks/register.tsx 139 lines1import { 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}
139hooks/bar.ts 120 lines1import 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}
120types/index.d.ts 29 lines1export 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