SLOPSHOPPER

context-meter

A color-coded context window meter above the prompt: usage by category, free space, model. Read-only.

newbandcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› 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 › /ctx ⎿ context-meter: Context meter hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Two small Claude Code mods (hooks-module plugins) that make the session easier to read and drive.

ModWhat it doesWhere it shows
context-meterA color-coded context window meter right above the promptTerminal and VS Code extension
term-barFooter buttons: 🕐 recent conversations, model picker, effort pickerTerminal only

Requires Claude Code 2.1.287 or newer (claude --version).

context-meter

Context 48.3k / 1m (5%) · 918.7k free · claude-sonnet-5-5
██▌█████▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
● System prompt 4.4k  ● System tools 33.6k  ● MCP tools 818  ● Memory files 129
● Skills 6.7k  ● Messages 1.9k  ● Autocompact buffer 33.0k  ● Free space 918.7k
                                                                              ▼
  • One bar segment and legend entry per /context category, each with a fixed color, so a category that drops out never shifts the others.
  • The title turns to the warning color at 80% or more.
  • Refreshes after every turn, on every prompt and right after /model.
  • ▼ slides it shut (only ▲ stays), /ctx toggles it.
  • Draws together with other plugins' bands (progress bars and the like) instead of replacing them.
  • Read-only: it measures with the local summary estimate, so it sends no network requests, writes no files and never touches tool calls. Numbers can differ slightly from /context, which counts with the token-count API.

term-bar (terminal only)

[ 🕐 ] [ Sonnet ▾ ] [ medium ▾ ]
  • 🕐 expands this folder's last 7 conversations above the prompt: the conversation's title (or its first message when it has none yet) and how long ago. Click one to resume it; empty conversations are left out. ✕ or 🕐 again closes it.
  • Model ▾ (Haiku → Sonnet → Opus → Fable, stronger to the right; Fable needs the Max plan) / effort ▾ expand the choices in place; the current one is highlighted.
  • The VS Code extension already has all three, so term-bar draws nothing there.

Notes:

  • The conversation list is read with PowerShell, so 🕐 is Windows only. The model and effort buttons work anywhere.
  • Changing the model or effort runs /model or /effort, which leave a line in the transcript and also save your choice as the default for new sessions.

Install

claude plugin marketplace add hyun8935181-ux/claude-code-mods
claude plugin install context-meter@claude-code-mods --scope user
claude plugin install term-bar@claude-code-mods --scope user

Then run /reload-plugins inside Claude Code (or start a new session). Install either one on its own; they do not depend on each other.

To update later: claude plugin marketplace update claude-code-mods, then /reload-plugins.

Develop

claude plugin validate context-meter
claude plugin test context-meter

Each mod is a hooks/register.tsx module plus a types/index.d.ts state contract. See the plugin-authoring docs in Claude Code for the API.

License

MIT

Source 3 files
hooks/register.tsx 187 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import type { Meter } from '../types'
5import { fmt, frames, legendRows, paint, widths } from './meter'
6
7const meter = atom({ plugin: 'context-meter', key: 'meter' } as const, null, {
8  shape: 'meter-1',
9})
10// Open from the start.
11const isOpen = atom({ plugin: 'context-meter', key: 'isOpen' } as const, true, {
12  shape: 'open-2',
13})
14const rows = atom({ plugin: 'context-meter', key: 'rows' } as const, null, {
15  shape: 'rows-1',
16})
17
18// The autocompact buffer is a dark fill, free space a light shade (colors come from paint).
19const GLYPH = { used: '█', buffer: '█', free: '░' } as const
20
21// Eases progress p (0-100) out and turns it into how many of `full` cells to show.
22const wipe = (p: number, full: number) => Math.max(1, Math.round(full * (1 - (1 - p / 100) ** 2)))
23
24// Read-only: `summary` estimates locally and sends no network request.
25const measure = async ($: Engine): Promise<Meter | null> => {
26  try {
27    const { context } = await $.session.usage({ breakdown: 'summary' })
28    const b = context.breakdown
29    if (!b) return null
30
31    return {
32      model: b.model,
33      total: b.totalTokens,
34      max: b.rawMaxTokens,
35      percent: b.percentage,
36      rows: b.categories.map(({ name, tokens, color, kind }) => ({ name, tokens, color, kind })),
37    }
38  } catch {
39    // A failed refresh must never affect the session.
40    return null
41  }
42}
43
44const refresh = async ($: Engine) => {
45  const next = await measure($)
46  if (next) await update($, meter, () => next)
47}
48
49export const register: Register = on => {
50  // Ignores presses mid-animation. Fine to lose on reload, so a module variable.
51  let isSliding = false
52
53  on('session.start', async ($, e, next) => {
54    await $.command.register({
55      name: 'ctx',
56      description: 'Show or hide the context meter',
57    })
58    // Clears an animation a reload cut short.
59    await update($, rows, () => null)
60    await refresh($)
61
62    return next(e)
63  })
64
65  on('command.run', { command: 'ctx' }, async $ => {
66    const wasOpen = await read($, isOpen)
67    await refresh($)
68    await update($, rows, () => null)
69    await update($, isOpen, () => !wasOpen)
70
71    return { text: wasOpen ? 'Context meter hidden.' : 'Context meter shown.' }
72  })
73
74  // After /model, update the model and window right away instead of after the next response.
75  on('command.run', { command: 'model' }, async ($, e, next) => {
76    const ran = await next(e)
77    await refresh($)
78
79    return ran
80  })
81
82  // The VS Code extension switches models by click, with no /model command: refresh on every prompt too.
83  on('prompt.submit', ($, e, next) => {
84    void refresh($)
85
86    return next(e)
87  })
88
89  on('session.measure', async ($, e, next) => {
90    if (e.changed.includes('context')) await refresh($)
91
92    return next(e)
93  })
94
95  // The band right above the prompt: unlike a pane, it never moves to a side panel on a wide terminal.
96  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97    // Right after /clear the stored value is empty: measure on the spot (a render cannot store).
98    const m = (await read($, meter)) ?? (await measure($))
99
100    if (!m || e.props.hasSurvey) return next(e)
101
102    // The band is one slot: draw what the plugins below drew (progress bars and so on) above ours.
103    const below = await next(e)
104    const open = await read($, isOpen)
105    const slide = await read($, rows)
106    const { Box, Button, Text } = $.ui.resolve(e)
107    const cols = Math.max(10, e.props.bodyColumns)
108
109    // Rows outside the window (deferred tool schemas) are left out.
110    const list = m.rows
111      .filter(r => r.kind !== 'deferred' && r.tokens > 0)
112      .map(r => ({ ...r, paint: paint(r.name) }))
113    const w = widths(list, m.max, cols)
114    const free = m.rows.find(r => r.kind === 'free')?.tokens ?? Math.max(0, m.max - m.total)
115    const items = list.map(r => `${r.name} ${fmt(r.tokens)}`)
116    const height = 2 + legendRows(items, cols) // title + bar + legend
117
118    // The arrow slides the content shut or open over a few frames.
119    // The final state (isOpen) is set on press; the frames in between live in rows only.
120    const toggle = async () => {
121      if (isSliding) return
122      isSliding = true
123      // Settle the final state first: opening and closing work even if the animation fails.
124      await update($, isOpen, () => !open)
125      try {
126        // rows = progress 0-100, ten frames; the render applies the easing.
127        for (const p of open ? frames(100, 0, 10) : frames(0, 100, 10)) {
128          await update($, rows, () => p)
129          await $.clock.sleep(25)
130        }
131      } catch {
132        // The animation is a nicety.
133      } finally {
134        await update($, rows, () => null)
135        isSliding = false
136      }
137    }
138
139    // ▼ collapses: bottom-right. Collapsed, only ▲ peeks out.
140    const arrow = (
141      <Box justifyContent="flex-end">
142        <Button key="toggle" plain label={open ? '▼' : '▲'} onPress={toggle} />
143      </Box>
144    )
145
146    const content = (
147      <Box flexDirection="column">
148        <Text weight="bold" color={m.percent >= 80 ? 'warning' : undefined}>
149          Context {fmt(m.total)} / {fmt(m.max)} ({m.percent}%) · {fmt(free)} free · {m.model}
150        </Text>
151        <Text>
152          {list.map((r, i) => (
153            <Text color={r.paint}>{GLYPH[r.kind as keyof typeof GLYPH].repeat(w[i])}</Text>
154          ))}
155        </Text>
156        <Box flexWrap="wrap" columnGap={2}>
157          {list.map((r, i) => (
158            <Box key={r.name} gap={1}>
159              <Text color={r.paint}>●</Text>
160              <Text>{items[i]}</Text>
161            </Box>
162          ))}
163        </Box>
164      </Box>
165    )
166
167    return (
168      <Box flexDirection="column">
169        {below}
170        {slide === null ? (
171          open && content
172        ) : (
173          slide > 0 && (
174            // Shrinks height and width together. The inner width stays fixed so text never rewraps.
175            <Box height={wipe(slide, height)} width={wipe(slide, cols)} overflow="hidden">
176              <Box width={cols} flexShrink={0}>
177                {content}
178              </Box>
179            </Box>
180          )
181        )}
182        {arrow}
183      </Box>
184    )
185  })
186}
187
hooks/meter.ts 62 lines
1import type { Row } from '../types'
2
3export const fmt = (n: number) =>
4  n >= 1e6 ? `${+(n / 1e6).toFixed(1)}m` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
5
6// A fixed color per category, matched on the engine's /context row name, so dropping one
7// category never shifts the others' colors. Mid-saturation hues read on light and dark themes.
8const COLORS: [RegExp, string][] = [
9  [/system prompt/i, '#ef4444'],
10  [/system tools/i, '#f97316'],
11  [/mcp.*(instruction|server)/i, '#22c55e'],
12  [/mcp/i, '#eab308'],
13  [/memory/i, '#06b6d4'],
14  [/skills/i, '#3b82f6'],
15  [/messages/i, '#8b5cf6'],
16  [/autocompact|buffer/i, '#475569'], // quiet dark grey
17  [/free/i, '#9ca3af'], // light grey
18]
19export const paint = (name: string) => COLORS.find(([re]) => re.test(name))?.[1] ?? '#ec4899' // unknown: pink
20
21// Bar cells per row. Cumulative rounding, so the total never exceeds cols.
22export const widths = (rows: Row[], max: number, cols: number) => {
23  let sum = 0
24  let prev = 0
25
26  return rows.map(r => {
27    sum += r.tokens
28    const end = max > 0 ? Math.min(cols, Math.round((sum / max) * cols)) : 0
29    const w = Math.max(0, end - prev)
30    prev = Math.max(prev, end)
31
32    return w
33  })
34}
35
36// Terminal cells: wide (CJK) characters take two.
37export const cells = (s: string) =>
38  [...s].reduce((n, c) => n + ((c.codePointAt(0) ?? 0) > 0x2e80 ? 2 : 1), 0)
39
40// How many rows the legend wraps to at cols ('● ' before each item, two cells between items).
41// An estimate for the slide animation's height.
42export const legendRows = (items: string[], cols: number) => {
43  let rows = 1
44  let used = 0
45
46  for (const s of items) {
47    const w = 2 + cells(s)
48    if (used > 0 && used + 2 + w > cols) {
49      rows += 1
50      used = w
51    } else {
52      used += (used > 0 ? 2 : 0) + w
53    }
54  }
55
56  return rows
57}
58
59// Animation frames from -> to in steps (the last one is exactly to).
60export const frames = (from: number, to: number, steps = 4) =>
61  Array.from({ length: steps }, (_, i) => Math.round(from + ((to - from) * (i + 1)) / steps))
62
types/index.d.ts 25 lines
1export type Row = {
2  name: string
3  tokens: number
4  color: string
5  kind: 'used' | 'free' | 'buffer' | 'deferred'
6}
7export type Meter = {
8  model: string
9  total: number
10  max: number
11  percent: number
12  rows: Row[]
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'context-meter': {
18      meter: Shaped<Meter | null>
19      isOpen: Shaped<boolean>
20      // Slide progress 0-100; null when not animating.
21      rows: Shaped<number | null>
22    }
23  }
24}
25