Band above the prompt: a context-window bar segmented by category, and what the context comprises

Three Claude Code mods in one marketplace:
theme changes in ~/.claude/settings.json to running sessions.A band above the prompt: a context-window bar split by category, and how many tokens each category holds.
▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 42% 84k / 200k effort high
● system 4k ● tools 20k ● memory 200 ● messages 59.8k
light* one, dark otherwise, and the band switches as soon as the theme changes (/config, or theme-sync)./effort, the settings' effortLevel, or the model's default) follows the token count.At a Claude Code prompt in a terminal:
/plugin install context-bar --marketplace kokko-ng/kokko-claude-mods
Answer y to add the marketplace, then pick the user scope.
Or in ~/.claude/settings.json:
{
"extraKnownMarketplaces": {
"kokko-claude-mods": {
"source": { "source": "github", "repo": "kokko-ng/kokko-claude-mods" }
}
},
"enabledPlugins": { "context-bar@kokko-claude-mods": true }
}
Mods (function-hook plugins) are an early-access Claude Code feature; this one is built and tested against Claude Code 2.1.292.
Claude Code reloads ~/.claude/settings.json when it changes but keeps the running session's theme. theme-sync checks the file once a second and, when its theme differs from the session's, sets it as /config would, with a toast. Edit the file from anywhere (a script that flips light-ansi and dark-ansi alongside the terminal theme, for instance) and every open session follows.
/plugin install theme-sync --marketplace kokko-ng/kokko-claude-mods
or "theme-sync@kokko-claude-mods": true under enabledPlugins.
Keeps the auto memory of every project (~/.claude/projects/<project>/memory/) in a git repo, so each machine sees the others' memories.
~/.claude/projects itself, with the git directory at ~/.claude/memory-sync.git; only each project's memory/ folder is tracked, never transcripts. Memories stay ordinary files where Claude Code reads and writes them, so nothing about recall or permissions changes.MEMORY.md indexes merge line by line (merge=union), so every machine's entries are kept. Any other memory both machines changed gets a normal three-way merge; if that conflicts, the version already pushed stays and this machine's is kept beside it as <name>.from-<host>.md (a git merge driver the mod installs), so nothing is lost or interleaved and the sync carries on. Reconcile those pairs when they appear. Only a memory deleted on one machine and changed on another stops the sync, with a toast saying how to resolve it.The repo defaults to kokko-ng/claude-memory; set CLAUDE_MEMORY_REPO to owner/name (GitHub, using your git credentials) or any git URL. Create it private and empty first:
gh repo create <owner>/claude-memory --private
/plugin install memory-sync --marketplace kokko-ng/kokko-claude-mods
Mods do not run where disableAllHooks is set, and a settings.local.json in ~/.claude sets it for sessions started in your home folder.
Run pre-commit install once per clone. It installs the pre-commit and commit-msg hooks: file hygiene, gitleaks, claude plugin validate --strict and claude plugin test for every mod (on a pinned Claude Code), and Conventional Commits messages checked by commitizen. CI runs the same hooks and checks every commit message.
Each mod is its own plugin under plugins/:
claude plugin validate plugins/context-bar
claude plugin test plugins/context-bar
claude plugin validate plugins/theme-sync
claude plugin test plugins/theme-sync
claude plugin validate plugins/memory-sync
claude plugin test plugins/memory-synchooks/register.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextSnapshot } from '../types'
5
6const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
7const isLight = atom({ plugin: 'context-bar', key: 'isLight' } as const, false)
8// The thinking effort of the main conversation's latest request (low ...
9// max, or a token budget), or the settings' effortLevel before the first one.
10const effort = atom({ plugin: 'context-bar', key: 'effort' } as const, null)
11
12// Gruvbox Material, medium contrast (sainnhe/gruvbox-material), as hex so the
13// bar can use the palette's orange, which the 16 ANSI colours lack. The light
14// palette follows a light-* Claude Code theme. Each category keeps its colour
15// across sessions, so the legend reads the same every time.
16export type Palette = {
17 red: string
18 orange: string
19 yellow: string
20 green: string
21 aqua: string
22 blue: string
23 purple: string
24 grey: string // labels
25 track: string // empty part of the bar
26}
27
28export const PALETTES: Record<'dark' | 'light', Palette> = {
29 dark: {
30 red: '#ea6962',
31 orange: '#e78a4e',
32 yellow: '#d8a657',
33 green: '#a9b665',
34 aqua: '#89b482',
35 blue: '#7daea3',
36 purple: '#d3869b',
37 grey: '#a89984',
38 track: '#504945',
39 },
40 light: {
41 red: '#c14a4a',
42 orange: '#c35e0a',
43 yellow: '#b47109',
44 green: '#6c782e',
45 aqua: '#4c7a5d',
46 blue: '#45707a',
47 purple: '#945e80',
48 grey: '#7c6f64',
49 track: '#ddccab',
50 },
51}
52
53// A Claude Code theme value (`light-ansi`, `dark`, ...) is light by its name;
54// `auto` and custom themes count as dark.
55export const isLightTheme = (theme: unknown): boolean =>
56 typeof theme === 'string' && theme.startsWith('light')
57
58const CATEGORY: Record<string, keyof Palette> = {
59 system: 'blue',
60 tools: 'aqua',
61 mcp: 'purple',
62 agents: 'red',
63 memory: 'yellow',
64 skills: 'green',
65 messages: 'orange',
66}
67
68const BAR_MAX = 48 // the bar never grows past this, however wide the terminal
69const BAR_MIN = 8 // below this the bar is dropped and only the percentage shows
70const LEGEND_MIN_COLUMNS = 40 // narrower than this, no legend line
71
72const LABELS: Record<string, string> = {
73 'System prompt': 'system',
74 'System tools': 'tools',
75 'MCP tools': 'mcp',
76 'Custom agents': 'agents',
77 'Memory files': 'memory',
78 'Skills': 'skills',
79 'Messages': 'messages',
80}
81
82// Lower three-quarter block: solid enough to read at a glance, with a gap
83// above it so the band does not merge into the prompt. Free space is the
84// same glyph in the track colour, so the bar keeps one shape end to end.
85const CELL = '▆'
86
87export type Segment = { text: string; color?: string; bold?: boolean }
88export type Layout = { bar: Segment[]; legend: Segment[] }
89
90export const formatTokens = (n: number): string =>
91 n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1).replace(/\.0$/, '')}k` : `${n}`
92
93export const label = (name: string): string => LABELS[name] ?? name.toLowerCase()
94
95const colorOf = (p: Palette, name: string): string => p[CATEGORY[label(name)] ?? 'grey']
96
97const widthOf = (segments: Segment[]): number =>
98 segments.reduce((n, s) => n + s.text.length, 0)
99
100// Cells per category by largest remainder: the used part of the bar is
101// proportional to the window's fill, split by each category's share of it.
102export const cellsPerRow = (s: ContextSnapshot, width: number): number[] => {
103 const total = s.rows.reduce((n, r) => n + r.tokens, 0)
104 const used = Math.min(width, Math.round((total / s.max) * width))
105 if (total === 0 || used === 0) return s.rows.map(() => 0)
106 const exact = s.rows.map(r => (r.tokens / total) * used)
107 const cells = exact.map(Math.floor)
108 let left = used - cells.reduce((a, b) => a + b, 0)
109 const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
110 for (const [, i] of order) {
111 if (left <= 0) break
112 cells[i] = (cells[i] ?? 0) + 1
113 left -= 1
114 }
115 return cells
116}
117
118const percentSegment = (p: Palette, percent: number): Segment => ({
119 text: `${percent}%`,
120 bold: true,
121 color: percent >= 80 ? p.red : percent >= 50 ? p.yellow : p.green,
122})
123
124// Everything the band draws, sized so no line is wider than `columns`.
125export const layout = (
126 s: ContextSnapshot,
127 columns: number,
128 p: Palette = PALETTES.dark,
129 thinking: string | null = null,
130): Layout => {
131 const pct = percentSegment(p, s.percent)
132 const tokens: Segment = { text: ` ${formatTokens(s.used)} / ${formatTokens(s.max)}`, color: p.grey }
133 const effortLabel: Segment = { text: ' effort ', color: p.grey }
134 const effortValue: Segment = { text: thinking ?? '', color: p.aqua, bold: true }
135
136 // Widest suffix that still leaves room for a bar, else the percentage alone.
137 // The effort goes first as the band narrows, then the token count.
138 const suffixes: Segment[][] = [
139 ...(thinking ? [[{ text: ' ' }, pct, tokens, effortLabel, effortValue]] : []),
140 [{ text: ' ' }, pct, tokens],
141 [{ text: ' ' }, pct],
142 ]
143 const suffix = suffixes.find(sfx => columns - widthOf(sfx) >= BAR_MIN)
144 if (!suffix) return { bar: columns >= pct.text.length ? [pct] : [], legend: [] }
145
146 const width = Math.min(BAR_MAX, columns - widthOf(suffix))
147 const cells = cellsPerRow(s, width)
148 const bar: Segment[] = s.rows
149 .map((r, i) => ({ text: CELL.repeat(cells[i] ?? 0), color: colorOf(p, r.name) }))
150 .filter(seg => seg.text.length > 0)
151 const free = width - widthOf(bar)
152 if (free > 0) bar.push({ text: CELL.repeat(free), color: p.track })
153 bar.push(...suffix)
154
155 if (columns < LEGEND_MIN_COLUMNS) return { bar, legend: [] }
156
157 // Largest categories first until the line is full, then shown in bar order
158 // so the colours read left to right like the bar.
159 const items = s.rows.map((r, i) => ({
160 i,
161 tokens: r.tokens,
162 text: `● ${label(r.name)} ${formatTokens(r.tokens)}`,
163 }))
164 const chosen = new Set<number>()
165 let used = 0
166 for (const item of [...items].sort((a, b) => b.tokens - a.tokens)) {
167 const cost = item.text.length + (chosen.size > 0 ? 2 : 0)
168 if (used + cost > columns) continue
169 chosen.add(item.i)
170 used += cost
171 }
172 const legend: Segment[] = []
173 for (const item of items) {
174 if (!chosen.has(item.i)) continue
175 if (legend.length > 0) legend.push({ text: ' ' })
176 const name = s.rows[item.i]?.name ?? ''
177 legend.push({ text: '●', color: colorOf(p, name) })
178 legend.push({ text: ` ${label(name)}`, color: p.grey })
179 legend.push({ text: ` ${formatTokens(item.tokens)}` })
180 }
181 return { bar, legend }
182}
183
184async function refresh($: EngineInterface): Promise<void> {
185 const usage = await $.session.usage({ breakdown: 'summary' })
186 const b = usage.context.breakdown
187 if (!b) return
188 const next: ContextSnapshot = {
189 percent: b.percentage,
190 used: b.totalTokens,
191 max: b.rawMaxTokens,
192 rows: b.categories
193 .filter(c => c.kind === 'used' && c.tokens > 0)
194 .map(c => ({ name: c.name, tokens: c.tokens })),
195 }
196 await update($, snapshot, () => next)
197}
198
199// Before the first request, the effort the settings ask for.
200async function readEffort($: EngineInterface): Promise<void> {
201 const settings = await $.settings.read().catch(() => ({}))
202 const level = (settings as { effortLevel?: unknown }).effortLevel
203 if (typeof level === 'string' && level) await update($, effort, () => level)
204}
205
206async function readTheme($: EngineInterface): Promise<void> {
207 const row = (await $.config.list()).find(r => r.key === 'theme')
208 await update($, isLight, () => isLightTheme(row?.value))
209}
210
211export const register: Register = on => {
212 on('session.start', async ($, e, next) => {
213 const result = await next(e)
214 await readTheme($)
215 await readEffort($)
216 await refresh($)
217 return result
218 })
219
220 // The effort each request of the main conversation is actually sent with
221 // (the session's setting, /effort, or the model's default). Subagents'
222 // requests (e.agentId) run at their own effort and are left out.
223 on('turn.step', async function* ($, e, next) {
224 if (e.agentId === undefined && e.effort !== undefined) {
225 const sent = String(e.effort)
226 await update($, effort, () => sent)
227 }
228 return yield* next(e)
229 })
230
231 // A theme change from /config or another plugin (theme-sync) redraws the
232 // band in the matching palette.
233 on('config.set', { key: 'theme' }, async ($, e, next) => {
234 const result = await next(e)
235 if (result.deny === undefined) await update($, isLight, () => isLightTheme(result.value))
236 return result
237 })
238
239 on('session.measure', async ($, e, next) => {
240 const result = await next(e)
241 if (e.changed.includes('context')) await refresh($)
242 return result
243 })
244
245 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
246 const s = await read($, snapshot)
247 if (s === null || e.props.hasSurvey) return next(e)
248
249 const palette = (await read($, isLight)) ? PALETTES.light : PALETTES.dark
250 const { bar, legend } = layout(s, e.props.bodyColumns, palette, await read($, effort))
251 if (bar.length === 0) return next(e)
252
253 const { Box, Text } = $.ui.resolve(e)
254 const line = (key: string, segments: Segment[]) => (
255 <Box key={key}>
256 {segments.map((seg, i) => (
257 <Text
258 key={`${key}-${i}`}
259 color={seg.color}
260 bold={seg.bold}
261 wrap="truncate-end"
262 >
263 {seg.text}
264 </Text>
265 ))}
266 </Box>
267 )
268
269 return (
270 <Box flexDirection="column">
271 {line('bar', bar)}
272 {legend.length > 0 ? line('legend', legend) : null}
273 </Box>
274 )
275 })
276}
277types/index.d.ts 15 lines1export type ContextRow = { name: string; tokens: number }
2
3export type ContextSnapshot = {
4 percent: number
5 used: number
6 max: number
7 rows: ContextRow[]
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'context-bar': { snapshot: ContextSnapshot | null; isLight: boolean; effort: string | null }
13 }
14}
15