SLOPSHOPPER

context-bar

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

newband
A shopper browsing a rack in a slop shop
README

kokko-claude-mods

Three Claude Code mods in one marketplace:

  • context-bar: a context-window bar above the prompt.
  • theme-sync: applies theme changes in ~/.claude/settings.json to running sessions.
  • memory-sync: keeps every project's auto memory in a private git repo, synced across machines.

context-bar

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
  • Colours are Gruvbox Material (medium contrast), one fixed colour per category; the percentage turns yellow at 50% and red at 80%. The light palette is used while the Claude Code theme is a light* one, dark otherwise, and the band switches as soon as the theme changes (/config, or theme-sync).
  • The thinking effort of the conversation's latest request (/effort, the settings' effortLevel, or the model's default) follows the token count.
  • Sized to the band's width: as the terminal narrows it drops the effort, then the token count, then the legend's smallest categories, then the legend, then the bar, and never wraps.

Install

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.

theme-sync

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.

memory-sync

Keeps the auto memory of every project (~/.claude/projects/<project>/memory/) in a git repo, so each machine sees the others' memories.

  • The git work tree is ~/.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.
  • Session start pulls (waiting up to 15 s, so the session loads the latest memories), every turn that changed a memory commits and pushes, and an idle session pulls every ten minutes.
  • The first run on a machine imports its memories: it restores what the repo has that the machine lacks and commits the rest on top.
  • Machines never clash. 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.
  • Projects are matched by folder name, which Claude Code derives from the project's absolute path: use the same username and code folder on every machine for a project's memories to be shared.

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.

Development

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-sync
Source 2 files
hooks/register.tsx 277 lines
1import { 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}
277
types/index.d.ts 15 lines
1export 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