SLOPSHOPPER

context-bar

A color-coded context bar above the prompt: percent used until auto-compact, split by token type, with a legend.

newbandtimer
A shopper browsing a rack in a slop shop
README

Claude Code mods

Two small mods for Claude Code that draw in the band above the prompt.

ModWhat it doesPlatforms
context-barA color-coded bar showing how much of the context window is used before auto-compact, split by token type (system prompt, tools, MCP, memory files, skills, messages). Hover a segment for its size; hover the percent for the full legend.Terminal and desktop app, any OS
download-barOne live progress row per download that Claude Code's shells run (curl, wget, Python, PowerShell, pip, git, npm..., WSL too): bytes, percent and ETA when the size is known, speed. Past four downloads at once it shows a summary over the three fastest.Windows (including downloads inside WSL)

Install

In a Claude Code terminal session, run:

/plugin install context-bar --marketplace Ayushmaniar/claude-mods
/plugin install download-bar --marketplace Ayushmaniar/claude-mods

Answer y to add the marketplace, then pick a scope (user scope makes it apply in every session, including the desktop app's Code tab).

Using download-bar

Rows appear on their own when a shell command starts downloading. The /dlbar command controls it:

  • /dlbar shows the status and help
  • /dlbar test [count] simulates downloads so you can see the bar without touching the network
  • /dlbar on / /dlbar off turns it on or off
  • /dlbar lookup on|off toggles the size lookup: a HEAD request to the download's URL, sent only once the download is already running, to get a percent and ETA

How it works: a small PowerShell sampler (sampler/sampler.ps1) watches processes descended from Claude Code's shells that hold an internet connection, and reads their write counters (a download writes what it receives). Downloads inside WSL are followed by sampler/wsl-sampler.sh, run in the distro the shell's wsl.exe used, which reads the same facts from /proc.

Developing

Each mod is a folder under plugins/. To run one from source:

claude --plugin-dir plugins/context-bar

Validate and test:

claude plugin validate plugins/download-bar
claude plugin test plugins/download-bar

Claude Code writes the SDK typings into each mod's .claude-plugin/types/ when the mod loads; after that, tsc -p plugins/<mod> type-checks it.

License

MIT

Source 1 files
hooks/register.tsx 244 lines
1import type { Register, SessionUsage } from 'claude-code'
2
3const STALE_PANE = 'context-bar'
4const FREE_COLOR = '#2b2d3a'
5const POLL_MS = 2500
6
7const PALETTE: [string, string][] = [
8  ['system prompt', '#7aa2f7'],
9  ['system tools', '#bb9af7'],
10  ['mcp tools', '#e0af68'],
11  ['mcp server instructions', '#c3e88d'],
12  ['custom agents', '#f7768e'],
13  ['memory files', '#9ece6a'],
14  ['skills', '#73daca'],
15  ['messages', '#ff9e64'],
16]
17const FALLBACK = ['#2ac3de', '#c0caf5', '#ff757f', '#e0def4']
18
19const colorFor = (name: string, index: number): string =>
20  PALETTE.find(([key]) => name.toLowerCase().startsWith(key))?.[1] ?? (FALLBACK[index % FALLBACK.length] as string)
21
22const fmt = (n: number) =>
23  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(Math.round(n))
24
25const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0)
26
27// Whole cells per value: each edge sits at the running total, so a value too small for a cell costs its neighbours nothing.
28const cellsOf = (values: number[], cells: number): number[] => {
29  const total = sum(values)
30  if (total <= 0) return values.map(() => 0)
31
32  let running = 0
33  let previous = 0
34
35  return values.map(v => {
36    running += v
37    const edge = Math.round((running / total) * cells)
38    const mine = edge - previous
39    previous = edge
40
41    return mine
42  })
43}
44
45type Part = { name: string; tokens: number; color: string; isFree: boolean }
46
47type Measure = {
48  parts: Part[]
49  usedTokens: number
50  limit: number
51  isCompactOn: boolean
52  percent: number
53}
54
55// The window as the engine counts it, one part per row of /context, with the free space last.
56const measure = (usage: SessionUsage): Measure | null => {
57  const breakdown = usage.context.breakdown
58  if (!breakdown) return null
59
60  const used = breakdown.categories
61    .filter(c => c.kind === 'used' && c.tokens > 0)
62    .map((c, i) => ({ name: c.name, tokens: c.tokens, color: colorFor(c.name, i), isFree: false }))
63  const usedTokens = sum(used.map(c => c.tokens))
64  const threshold = breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
65  const limit = threshold ?? breakdown.rawMaxTokens
66
67  return {
68    parts: [...used, { name: 'Free', tokens: Math.max(0, limit - usedTokens), color: FREE_COLOR, isFree: true }],
69    usedTokens,
70    limit,
71    isCompactOn: threshold !== undefined,
72    percent: Math.round((usedTokens / limit) * 100),
73  }
74}
75
76const escapeXml = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
77
78const percentColor = (percent: number) => (percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green')
79
80const pct = (m: Measure, tokens: number) => `${Math.round((tokens / m.limit) * 100)}%`
81const tipOf = (m: Measure, p: Part) => `${p.name} ${fmt(p.tokens)} · ${pct(m, p.tokens)} of limit`
82
83type Piece = { part: Part; grow: number; source: string }
84
85// The desktop's segments for one reading. The band redraws every few seconds and an unchanged reading is the same
86// picture, so each reading's SVG strings are built once, not once per redraw.
87const piecesCache = new WeakMap<Measure, Piece[]>()
88const piecesOf = (m: Measure): Piece[] => {
89  let pieces = piecesCache.get(m)
90  if (pieces) return pieces
91
92  const total = sum(m.parts.map(p => p.tokens))
93  pieces = m.parts.flatMap(p => {
94    const source = `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="14" viewBox="0 0 100 14" preserveAspectRatio="none"><style>rect:hover{filter:brightness(1.3)}</style><rect width="100" height="14" fill="${p.color}"><title>${escapeXml(tipOf(m, p))}</title></rect></svg>`
95
96    return p.tokens > 0 ? [{ part: p, grow: (p.tokens / total) * 1000, source }] : []
97  })
98  piecesCache.set(m, pieces)
99
100  return pieces
101}
102
103export const register: Register = on => {
104  // The last reading, kept so a draw never waits on the engine and never comes up empty.
105  let latest: Measure | null = null
106  let signature = ''
107
108  on('session.start', async ($, e, next) => {
109    // An earlier version drew a pane; one left up by a reload would stay blank.
110    await $.ui.close({ id: STALE_PANE })
111
112    // Measures off the draw path and redraws only when a number moved, so the band is not rebuilt for nothing.
113    const tick = async () => {
114      try {
115        const m = measure(await $.session.usage({ breakdown: 'summary' }))
116        if (!m) return
117
118        const key = JSON.stringify([m.limit, m.isCompactOn, m.parts.map(p => [p.name, Math.round(p.tokens / 100)])])
119        if (key === signature) return
120
121        signature = key
122        latest = m
123        $.ui.invalidate('ui.render')
124      } catch {
125        // The last reading stays on screen.
126      }
127    }
128    $.clock.every(POLL_MS, tick)
129    await tick()
130
131    return next(e)
132  })
133
134  // One row across the band: the engine's own categories, then free space, with the percent and the count at the end.
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const m = latest
137    if (e.props.hasSurvey || !m) return next(e)
138
139    // Whatever the plugins beneath draw in the band goes under the bar.
140    const below = await next(e)
141    const hasBelow = Boolean(below) && (below as { type?: string }).type !== 'engine'
142
143    const tip = (p: Part) => tipOf(m, p)
144    const percent = `${m.percent}%`
145    const amount = ` ${fmt(m.usedTokens)}/${fmt(m.limit)}`
146
147    // The desktop gives a vector frame a width of its own, whatever the band's, so the fill is layout boxes that always
148    // stretch to the row, each grown in proportion to its tokens (out of 1000, the engine takes no more than 10000).
149    // A frame is drawn at 450px unless told its width, so each is told one wider than any band and clipped to its box.
150    // The vector laid in each carries the tooltip and the hover highlight, and paints its own stretch's color, as the frame can turn opaque.
151    if (e.surface === 'desktop') {
152      const { Box, Svg, Text } = $.ui.resolve(e)
153      const pieces = piecesOf(m)
154
155      const bar = (
156        <Box flexDirection="row" alignItems="center" columnGap={1} width="100%">
157          <Box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
158            {pieces.map(piece => (
159              <Box width={0} minWidth={0} flexGrow={piece.grow} backgroundColor={piece.part.color} overflow="hidden">
160                <Svg source={piece.source} alt={tip(piece.part)} width={4000} height={14} isInteractive />
161              </Box>
162            ))}
163          </Box>
164          <Text>
165            <Text color={percentColor(m.percent)} bold>
166              {percent}
167            </Text>
168            <Text dimColor>{amount}</Text>
169          </Text>
170        </Box>
171      )
172
173      return hasBelow ? (
174        <Box flexDirection="column">
175          {bar}
176          {below}
177        </Box>
178      ) : (
179        bar
180      )
181    }
182
183    // The terminal draws it in cells; a stretch's hover reveals a line above the bar, which is the band's bottom row,
184    // so the line appearing never moves the bar from under the pointer.
185    const { Box, Text } = $.ui.resolve(e)
186    const width = Math.max(10, e.props.bodyColumns - percent.length - amount.length - 2)
187    const cells = cellsOf(
188      m.parts.map(p => p.tokens),
189      width,
190    )
191    const swatch = (p: Part) => (p.isFree ? <Text dimColor>■</Text> : <Text color={p.color}>■</Text>)
192
193    const bar = (
194      <Box flexDirection="column">
195        {m.parts.map((p, i) =>
196          (cells[i] ?? 0) > 0 ? (
197            <Box display="none" hover={{ scope: `seg-${i}`, display: 'flex' }}>
198              <Text wrap="truncate">
199                {swatch(p)}
200                <Text> {tip(p)}</Text>
201              </Text>
202            </Box>
203          ) : null,
204        )}
205        <Box display="none" flexDirection="row" flexWrap="wrap" columnGap={2} hover={{ scope: 'all', display: 'flex' }}>
206          {m.parts.map(p => (
207            <Text>
208              {swatch(p)}
209              <Text dimColor>
210                {' '}
211                {p.name} {fmt(p.tokens)}
212              </Text>
213            </Text>
214          ))}
215        </Box>
216        <Box>
217          {m.parts.map((p, i) =>
218            (cells[i] ?? 0) > 0 ? (
219              <Text backgroundColor={p.color} hover={{ scope: `seg-${i}` }}>
220                {' '.repeat(cells[i] ?? 0)}
221              </Text>
222            ) : null,
223          )}
224          <Text color={percentColor(m.percent)} bold hover={{ scope: 'all' }}>
225            {` ${percent}`}
226          </Text>
227          <Text dimColor hover={{ scope: 'all' }}>
228            {amount}
229          </Text>
230        </Box>
231      </Box>
232    )
233
234    return hasBelow ? (
235      <Box flexDirection="column">
236        {bar}
237        {below}
238      </Box>
239    ) : (
240      bar
241    )
242  })
243}
244