SLOPSHOPPER

context-bar

Your context window as a stacked bar above the prompt, a color per category, with token counts and the compaction point. /context-bar shows or hides it.

newbandcommand
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 hidden. /context-bar shows it again ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

Source 3 files
hooks/register.tsx 195 lines
1// Context Bar: what is filling my context window?
2//   Above the prompt, the window as one stacked bar, a color per category as /context
3//   draws them (system prompt, tools, MCP tools, memory files, skills, messages, free),
4//   with a legend of tokens and shares and where auto-compaction runs. It refreshes after
5//   each turn. /context-bar shows or hides it, and the choice is kept across sessions.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { Reading, Slice } from '../types'
10import { bandsFile, parseOrder, stackBands } from './bands'
11
12const MIN_WIDTH = 20 // narrower than this, the bar is not drawn
13const SPLIT = '   '
14// /context's theme gives several rows the same grey, so each used row gets its own color, in order.
15const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64', '#c0caf5']
16const MESSAGES = '#d97757' // the row that grows, in the accent color
17const FREE = '#808080' // a mid grey thin line reads as empty on dark and light themes alike
18const BUFFER = '#808080'
19const GLYPH = { used: '█', free: '─', buffer: '░' } as const
20
21// Held by the host, so the bar survives a hot reload of this file.
22const reading = atom({ plugin: 'context-bar', key: 'reading' } as const, null as Reading | null)
23const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
24
25let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
26
27export const register: Register = on => {
28  on('session.start', async ($, e, next) => {
29    const r = await next(e)
30    await $.command.register({ name: 'context-bar', description: 'Show or hide the context window bar above the prompt' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
31    const hidden = (await $.store.get('isHidden').catch(() => undefined)) === true
32    await update($, isHidden, () => hidden)
33    void refresh($).catch(() => {})
34    return r
35  })
36
37  on('turn.complete', async ($, e, next) => {
38    const r = await next(e)
39    if (!e.agentId) await refresh($).catch(() => {}) // a subagent's turn fills its own window, not this one
40    return r
41  })
42
43  on('session.compact', async ($, e, next) => {
44    const r = await next(e)
45    if (!e.agentId && 'messages' in r) void refresh($).catch(() => {}) // a /compact empties the window without a turn ending
46    return r
47  })
48
49  on('command.run', { command: 'context-bar' }, async $ => {
50    const hidden = await update($, isHidden, h => !h)
51    await $.store.set('isHidden', hidden).catch(() => {})
52    if (!hidden) await refresh($).catch(() => {})
53    return { text: hidden ? 'Context bar hidden. /context-bar shows it again' : 'Context bar on' }
54  })
55
56  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
57    const rest = await next(e) // what other mods and Claude Code draw here stays, drawn above the box so one-row mods stay visible
58    const r = await read($, reading)
59    if (e.props.hasSurvey || (await read($, isHidden)) || !r) return rest
60    const { Box, Text } = $.ui.resolve(e)
61    const inner = e.props.bodyColumns - 4 // the border and padding take 4 cells
62    if (inner < MIN_WIDTH) return rest
63
64    const head = `${tokens(r.total)} of ${tokens(r.window)}${r.compactsAt ? ` · compacts at ${tokens(r.compactsAt)}` : ''}`
65    const pct = ` ${r.percent}% `
66    const level = r.compactsAt ? r.total / r.compactsAt : r.total / r.window
67    const mine = (
68      <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
69        <Box flexDirection="row" justifyContent="space-between">
70          <Text wrap="truncate-end">
71            <Text color="#d97757">{'◆ '}</Text>
72            <Text bold>context</Text>
73          </Text>
74          <Text wrap="truncate-start">
75            <Text dimColor>{`${head} `}</Text>
76            <Text bold color="black" backgroundColor={level >= 0.9 ? 'red' : level >= 0.7 ? 'yellow' : 'green'}>{pct}</Text>
77          </Text>
78        </Box>
79        <Text>
80          {cells(r, inner).map(c => (
81            <Text color={c.color}>{c.text}</Text>
82          ))}
83        </Text>
84        {legend(r, inner).map(line => (
85          <Text wrap="truncate-end">
86            {line.map((s, i) => (
87              <Text>
88                {i > 0 && <Text>{SPLIT}</Text>}
89                <Text color={s.color}>{s.kind === 'used' ? '■ ' : `${GLYPH[s.kind]} `}</Text>
90                <Text dimColor={s.kind !== 'used'}>{`${s.name} `}</Text>
91                <Text bold={s.kind === 'used'}>{tokens(s.tokens)}</Text>
92                {s.kind === 'used' && <Text dimColor>{` ${share(s.tokens, r.window)}`}</Text>}
93              </Text>
94            ))}
95          </Text>
96        ))}
97      </Box>
98    )
99    return stackBands(rest, 'context-bar', mine, 'below', await (bandOrder ??= readBandOrder($).catch(() => []))) // below what other mods draw, unless the band order says otherwise
100  })
101}
102
103// The band order file, as the claude-code-mods checklist saves it; [] on any error.
104async function readBandOrder($: EngineInterface): Promise<string[]> {
105  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
106  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
107}
108
109// Forgets the cached band order, for tests.
110export function resetBandOrder() {
111  bandOrder = undefined
112}
113
114// Asks the engine for /context's breakdown, estimated locally (no token-count calls).
115async function refresh($: EngineInterface) {
116  if (await read($, isHidden)) return
117  const usage = await $.session.usage({ breakdown: 'summary' })
118  const b = usage.context.breakdown
119  if (!b || !(b.rawMaxTokens > 0)) return // no window to measure against
120  await update($, reading, () => toReading(b))
121}
122
123export function toReading(b: {
124  categories: { name: string; tokens: number; color: string; kind: string }[]
125  totalTokens: number
126  rawMaxTokens: number
127  percentage: number
128  autoCompactThreshold?: number
129  isAutoCompactEnabled: boolean
130}): Reading {
131  const slices: Slice[] = b.categories
132    .filter(c => c.kind !== 'deferred' && c.tokens > 0)
133    .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] }))
134  const order = { used: 0, free: 1, buffer: 2 }
135  slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
136  let next = 0
137  for (const s of slices) {
138    s.color = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : s.name === 'messages' ? MESSAGES : PALETTE[next++ % PALETTE.length]!
139  }
140  return {
141    slices,
142    total: b.totalTokens,
143    window: b.rawMaxTokens,
144    percent: b.percentage,
145    compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
146  }
147}
148
149// The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
150export function cells(r: Reading, width: number) {
151  const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
152  // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
153  let diff = width - sizes.reduce((a, n) => a + n, 0)
154  const isUsed = (i: number) => r.slices[i]!.kind === 'used'
155  const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
156  for (const i of order) {
157    if (diff === 0) break
158    const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
159    diff -= size - sizes[i]!
160    sizes[i] = size
161  }
162  return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, text: GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
163}
164
165// The legend, packed into lines no wider than `width`.
166export function legend(r: Reading, width: number) {
167  const lines: Slice[][] = [[]]
168  let used = 0
169  for (const s of r.slices) {
170    const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
171    const line = lines.at(-1)!
172    if (line.length > 0 && used + SPLIT.length + size > width) {
173      lines.push([s])
174      used = size
175    } else {
176      used += (line.length > 0 ? SPLIT.length : 0) + size
177      line.push(s)
178    }
179  }
180  return lines.filter(l => l.length > 0)
181}
182
183export function tokens(n: number) {
184  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
185  if (n >= 10_000) return `${Math.round(n / 1000)}k`
186  if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
187  return String(n)
188}
189
190export function share(n: number, window: number) {
191  const p = (n / window) * 100
192  if (p > 0 && p < 0.1) return '<0.1%'
193  return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
194}
195
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
types/index.d.ts 16 lines
1export type Slice = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
2
3export type Reading = {
4  slices: Slice[]
5  total: number // tokens in use
6  window: number // the window measured against
7  percent: number
8  compactsAt?: number // where auto-compaction runs, when it is on
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'context-bar': { reading: Reading | null; isHidden: boolean }
14  }
15}
16