SLOPSHOPPER

context-meter

A context window meter at the bottom right of the prompt; click it for the full breakdown

newpanespinnercommand
v0.1.0MITupdated 2026-10-04lucenity0/claude-code-mods/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ No breakdown yet. │ ⏺ 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 breakdown opened. Esc or /ctx closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits ctx ▰▰▰▰▱▱▱▱ 49%

Draws

Pane · Context
No breakdown yet.
README

claude-code-mods

Small plugins for Claude Code. Each mod is one TypeScript file of function hooks.

<img src="assets/hero.svg" alt="Claude Code with all four mods running: seatbelt blocking a dangerous rm, the session-dash pane, the turn-meter status line and a ding toast" width="900">

turn-meter     a turn timer in the status line
seatbelt       blocks destructive commands and edits to secrets
ding           a chime when a long turn finishes
session-dash   /dash, a live pane of what the session did
context-meter  a context window meter under the prompt; click it for the breakdown

Also: claude-familiar, a pixel companion that sits above the prompt and reacts to your session, and claude-readout, which names the files on the folded Read 3 files line.

&nbsp;

install

git clone https://github.com/lucenity0/claude-code-mods ~/claude-code-mods
claude --plugin-dir ~/claude-code-mods/seatbelt --plugin-dir ~/claude-code-mods/session-dash

To turn on function hooks and load the mods in every session, set this in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
    "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-mods/turn-meter:~/claude-code-mods/seatbelt:~/claude-code-mods/ding:~/claude-code-mods/session-dash:~/claude-code-mods/context-meter"
  }
}

These use function hooks, an early-access API that CLAUDE_CODE_ENABLE_FUNCTION_HOOKS turns on, and were tested on Claude Code 2.1.287.

&nbsp;

turn-meter

The status line counts up while Claude works. When the turn ends it shows the summary.

✓ 41s · 12.3k in · 1.8k out
on('turn.start', ($, e, next) => {
  ticker?.cancel()
  let seconds = 0
  $.ui.status('running 0s')
  ticker = $.clock.every(1000, () => {
    seconds += 1
    $.ui.status(`running ${formatDuration(seconds * 1000)}`)
  })

  return next(e)
})

&nbsp;

seatbelt

Checks every Bash, Edit and Write call before it runs, and denies the ones that would hurt.

● Bash(rm -rf /tmp/build/../../)
  ⎿  seatbelt: blocked `rm -rf /tmp/build/../../` (rm -rf on / or ~): it
     recursively deletes the filesystem root, a top-level folder or your home folder…
blocks                                  still allows
rm -rf /   rm -fr ~   rm -rf /x/../../  rm -rf node_modules
git push -f origin main                 git push --force origin my-branch
curl … | sh                             curl -o install.sh …
mkfs   dd of=/dev/…   chmod -R 777      chmod 755 script.sh
edits to .env  *.pem  ~/.ssh/*          .env.example

Each rule is a small object in rules.ts:

{
  name: 'curl | sh',
  reason: 'pipes a script from the internet straight into a shell',
  matches: command => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(command),
}

It's a guardrail against accidents, not a sandbox.

&nbsp;

ding

When a turn that ran longer than 30 seconds finishes, ding shows a toast and plays an original two-note chime. The sound plays on macOS only.

{ "pluginConfigs": { "ding": { "options": { "thresholdSeconds": 60, "sound": false } } } }

&nbsp;

session-dash

Type /dash to open the pane. Type /dash again, or press q, to close it.

╭─ Session ─────────────────────────────╮
│ 4 turns · 2m13s · 184k in · 9.2k out  │
│                                       │
│ Tools (23 calls)                      │
│ Bash  ████████████████████ 9          │
│ Read  ███████████████ 7               │
│ Edit  █████████ 4                     │
│                                       │
│ Turn time · max 1m12s                 │
│ ▂▄▃▆▁▅█▃▄▁▆▃                          │
│                                       │
│ Recent files                          │
│ src/server.ts                         │
│ README.md                             │
│                                       │
│ [ Reset ]  [ Close ]                  │
╰───────────────────────────────────────╯
on('tool.call', async ($, e, next) => {
  const ran = await next(e)
  await update($, stats, current =>
    countTool(current, e.tool, { isDenied: ran.deny !== undefined, isError: ran.isError === true }),
  )

  return ran
})

&nbsp;

context-meter

A meter sits at the bottom right of the prompt, under the input. It turns yellow at 60% and red at 80%.

ctx ▰▰▰▱▱▱▱▱ 42%

Click it, or type /ctx, to open the breakdown. Esc closes it.

42%  84k of 200k                                     claude-opus-5-5 · estimated
██████████████████████████▌█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒
83k free · compacts in 83k

● Messages                 62k  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   74%
● System tools             13k  ━━━━━━━━                                15%
● Skills                  4.9k  ━━━                                      6%
● System prompt           3.7k  ━━                                       4%
○ on demand                93k  MCP tools, not in the window

last request 84k in · 82k cached · 967 out

memory
  CLAUDE.md   ~/code/app                                             900

[ Count exactly ]  [ Refresh ]  [ Compact ]  [ Close ]

The breakdown is a local estimate. Count exactly counts it with the token-count API, as /context does.

on('session.measure', async ($, e, next) => {
  await update($, fill, () => e.context)
  if (e.changed.includes('context') && (await isPaneOpen($))) void loadDetails($, 'summary')

  return next(e)
})

&nbsp;

make one

hello/
├── .claude-plugin/plugin.json    { "name": "hello", "version": "0.1.0", "description": "…" }
└── hooks/
    ├── hooks.json                { "modules": ["./register.ts"] }
    └── register.ts
import type { Register } from 'claude-code'

export const register: Register = on => {
  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
    const result = await next(e)
    $.ui.toast(`edited ${e.file_path.split('/').pop()}`)
    return result
  })
}
claude plugin validate ./hello
claude plugin test ./hello

&nbsp;


<sub>MIT · built with Claude Code</sub>

Source 3 files
hooks/register.tsx 242 lines
1import { atom, read, update } from 'claude-code'
2import type { ContextBreakdownDetail, EngineInterface, Register } from 'claude-code'
3
4import type { Fill, Line } from '../types'
5import { fit, formatTokens, levelColor, meterBar, percentOf, segments, toDetails } from './context'
6
7const PANE = 'context-meter'
8const fill = atom({ plugin: 'context-meter', key: 'fill' } as const, { window: 0 } as Fill)
9const details = atom({ plugin: 'context-meter', key: 'details' } as const, null)
10const isLoading = atom({ plugin: 'context-meter', key: 'isLoading' } as const, false)
11
12async function refreshFill($: EngineInterface): Promise<void> {
13  const { context } = await $.session.usage()
14  await update($, fill, () => context)
15}
16
17async function loadDetails($: EngineInterface, detail: ContextBreakdownDetail): Promise<void> {
18  await update($, isLoading, () => true)
19  try {
20    const { context } = await $.session.usage({ breakdown: detail })
21    const { breakdown, ...rest } = context
22    await update($, fill, () => rest)
23    if (breakdown !== undefined) await update($, details, () => toDetails(breakdown, detail === 'full'))
24  } finally {
25    await update($, isLoading, () => false)
26  }
27}
28
29async function isPaneOpen($: EngineInterface): Promise<boolean> {
30  return (await $.ui.panes()).some(pane => pane.id === PANE)
31}
32
33async function togglePane($: EngineInterface): Promise<boolean> {
34  if (await isPaneOpen($)) {
35    await $.ui.close({ id: PANE })
36    return false
37  }
38
39  await $.ui.open({ id: PANE, title: 'Context', focus: true, closeOnEscape: true })
40  void loadDetails($, 'summary')
41  return true
42}
43
44export const register: Register = on => {
45  on('session.start', async ($, e, next) => {
46    await $.command.register({
47      name: 'ctx',
48      description: 'Toggle the context window breakdown',
49    })
50    const started = await next(e)
51    await refreshFill($)
52
53    return started
54  })
55
56  on('session.measure', async ($, e, next) => {
57    await update($, fill, () => e.context)
58    if (e.changed.includes('context') && (await isPaneOpen($))) void loadDetails($, 'summary')
59
60    return next(e)
61  })
62
63  on('session.compact', async ($, e, next) => {
64    const compacted = await next(e)
65    await refreshFill($)
66    if (await isPaneOpen($)) void loadDetails($, 'summary')
67
68    return compacted
69  })
70
71  on('command.run', { command: 'ctx' }, async $ => {
72    const isOpen = await togglePane($)
73
74    return { text: isOpen ? 'Context breakdown opened. Esc or /ctx closes it.' : 'Context breakdown closed.' }
75  })
76
77  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
78    const { Box, Text, Button } = $.ui.resolve(e)
79    const current = await read($, fill)
80    const label =
81      current.percent === undefined
82        ? `ctx ${meterBar(0, 8)} --`
83        : `ctx ${meterBar(current.percent, 8)} ${current.percent}%`
84    const color = levelColor(current.percent)
85
86    return (
87      <Box flexDirection="row" columnGap={2}>
88        {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')}</Text>}
89        <Button
90          key="meter"
91          plain
92          label={label}
93          {...(color === undefined ? { dimColor: true } : { color })}
94          onPress={() => togglePane($)}
95        />
96      </Box>
97    )
98  })
99
100  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
101    const { Box, Text, Button } = $.ui.resolve(e)
102    const current = await read($, details)
103    const loading = await read($, isLoading)
104    const live = await read($, fill)
105    const width = Math.max(32, e.props.bodyColumns)
106
107    if (current === null) {
108      return <Text dimColor>{loading ? 'Counting the context window…' : 'No breakdown yet.'}</Text>
109    }
110
111    const used = current.categories.filter(category => category.kind === 'used').sort((a, b) => b.tokens - a.tokens)
112    const deferred = current.categories.filter(category => category.kind === 'deferred')
113    const free = current.categories.find(category => category.kind === 'free')?.tokens ?? 0
114    const usedTotal = used.reduce((sum, category) => sum + category.tokens, 0)
115    const largest = used[0]?.tokens ?? 1
116
117    const nameWidth = Math.min(20, Math.max(8, ...used.map(category => category.name.length)))
118    const barWidth = Math.max(4, width - nameWidth - 18)
119    const accent = levelColor(current.percentage)
120
121    const compactNote = !current.isAutoCompactEnabled || current.autoCompactThreshold === undefined
122      ? 'auto-compact off'
123      : current.autoCompactThreshold > current.totalTokens
124        ? `compacts in ${formatTokens(current.autoCompactThreshold - current.totalTokens)}`
125        : 'compacts next turn'
126
127    const section = (title: string, lines: Line[]) =>
128      lines.length > 0 && (
129        <Box flexDirection="column" marginTop={1}>
130          <Text dimColor>{title}</Text>
131          {lines.map(line => (
132            <Box flexDirection="row" columnGap={1}>
133              <Box flexShrink={0}>
134                <Text>{`  ${line.name}`}</Text>
135              </Box>
136              <Box flexGrow={1} flexShrink={1}>
137                {line.note !== undefined && (
138                  <Text dimColor wrap="truncate-start">
139                    {line.note}
140                  </Text>
141                )}
142              </Box>
143              <Box flexShrink={0}>
144                <Text>{formatTokens(line.tokens).padStart(6)}</Text>
145              </Box>
146            </Box>
147          ))}
148        </Box>
149      )
150
151    const memory = current.memoryFiles.map(file => {
152      const path = file.name.replace(/^\/(Users|home)\/[^/]+/, '~')
153      const cut = path.lastIndexOf('/')
154      return { name: path.slice(cut + 1), tokens: file.tokens, note: path.slice(0, cut) }
155    })
156
157    return (
158      <Box flexDirection="column">
159        <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
160          <Text>
161            <Text bold {...(accent === undefined ? {} : { color: accent })}>
162              {current.percentage}%
163            </Text>
164            <Text>{`  ${formatTokens(current.totalTokens)}`}</Text>
165            <Text dimColor>{` of ${formatTokens(current.maxTokens)}`}</Text>
166          </Text>
167          <Text dimColor wrap="truncate-start">
168            {current.model}
169            {loading ? ' · counting…' : current.isExact ? ' · exact' : ' · estimated'}
170          </Text>
171        </Box>
172
173        <Text>
174          {segments(current.categories, current.maxTokens, width).map(part =>
175            part.kind === 'free' ? (
176              <Text dimColor>{'░'.repeat(part.cells)}</Text>
177            ) : (
178              <Text color={part.color}>{(part.kind === 'buffer' ? '▒' : '█').repeat(part.cells)}</Text>
179            ),
180          )}
181        </Text>
182        <Text dimColor>
183          {formatTokens(free)} free · {compactNote}
184        </Text>
185
186        <Box flexDirection="column" marginTop={1}>
187          {used.map(category => (
188            <Text>
189              <Text color={category.color}>●</Text>
190              {` ${fit(category.name, nameWidth)} `}
191              <Text dimColor>{formatTokens(category.tokens).padStart(6)}</Text>
192              {'  '}
193              <Text color={category.color}>
194                {'━'.repeat(Math.max(1, Math.round((category.tokens / largest) * barWidth)))}
195              </Text>
196              {' '.repeat(barWidth - Math.max(1, Math.round((category.tokens / largest) * barWidth)))}
197              <Text dimColor>{percentOf(category.tokens, usedTotal).padStart(5)}</Text>
198            </Text>
199          ))}
200          {deferred.length > 0 && (
201            <Text dimColor wrap="truncate-end">
202              {`○ ${fit('on demand', nameWidth)} `}
203              {formatTokens(deferred.reduce((sum, category) => sum + category.tokens, 0)).padStart(6)}
204              {`  ${deferred.map(category => category.name).join(', ')}, not in the window`}
205            </Text>
206          )}
207        </Box>
208
209        {live.tokens !== undefined && (
210          <Box marginTop={1}>
211            <Text dimColor wrap="truncate-end">
212              last request {formatTokens(live.tokens)} in
213              {current.lastResponse === null
214                ? ''
215                : ` · ${formatTokens(current.lastResponse.cacheRead)} cached · ${formatTokens(
216                    current.lastResponse.output,
217                  )} out`}
218            </Text>
219          </Box>
220        )}
221
222        {section('memory', memory)}
223        {section('mcp servers', current.mcpServers)}
224        {section(
225          'skills',
226          current.skills === undefined || current.skills.count === 0
227            ? []
228            : [{ name: `${current.skills.count} listed`, tokens: current.skills.tokens }],
229        )}
230        {section('agents', current.agents)}
231
232        <Box marginTop={1} columnGap={1}>
233          <Button key="exact" label="Count exactly" hotkey="e" onPress={() => loadDetails($, 'full')} />
234          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => loadDetails($, 'summary')} />
235          <Button key="compact" label="Compact" hotkey="c" onPress={() => $.session.compact()} />
236          <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
237        </Box>
238      </Box>
239    )
240  })
241}
242
hooks/context.ts 114 lines
1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { Category, Details, Line } from '../types'
4
5export type Segment = { color: string; cells: number; kind: string }
6
7export function formatTokens(tokens: number): string {
8  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`
9  if (tokens >= 10_000) return `${Math.round(tokens / 1000)}k`
10  if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
11  return String(tokens)
12}
13
14export function meterBar(percent: number, width: number): string {
15  const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
16  return '▰'.repeat(filled) + '▱'.repeat(width - filled)
17}
18
19// Theme keys, so the meter follows light and dark themes.
20export function levelColor(percent: number | undefined): string | undefined {
21  if (percent === undefined) return undefined
22  if (percent >= 80) return 'error'
23  if (percent >= 60) return 'warning'
24  return undefined
25}
26
27export function percentOf(tokens: number, of: number): string {
28  if (of <= 0 || tokens <= 0) return '0%'
29  const value = (tokens / of) * 100
30  return value < 1 ? '<1%' : `${Math.round(value)}%`
31}
32
33export function fit(text: string, width: number): string {
34  return text.length > width ? `${text.slice(0, width - 1)}…` : text.padEnd(width)
35}
36
37// The window as one row of cells: each used category in its colour, then the
38// free space, then the compaction buffer at the end. Every non-empty part
39// keeps at least one cell, and the cells always add up to `width`.
40export function segments(categories: Category[], window: number, width: number): Segment[] {
41  const cellsFor = (tokens: number) => (tokens > 0 ? Math.max(1, Math.round((tokens / window) * width)) : 0)
42  const used = categories
43    .filter(category => category.kind === 'used')
44    .sort((a, b) => b.tokens - a.tokens)
45    .map(category => ({ color: category.color, cells: cellsFor(category.tokens), kind: 'used' }))
46  const bufferTokens = categories.find(category => category.kind === 'buffer')?.tokens ?? 0
47  const buffer = { color: 'inactive', cells: cellsFor(bufferTokens), kind: 'buffer' }
48
49  let over = used.reduce((sum, part) => sum + part.cells, 0) + buffer.cells - width
50  while (over > 0) {
51    const largest = used.reduce((a, b) => (b.cells > a.cells ? b : a), used[0] ?? buffer)
52    if (largest.cells <= 1) break
53    largest.cells -= 1
54    over -= 1
55  }
56
57  const free = Math.max(0, -over)
58  return [...used, { color: 'inactive', cells: free, kind: 'free' }, buffer].filter(part => part.cells > 0)
59}
60
61function top(lines: Line[], count: number): Line[] {
62  return [...lines].sort((a, b) => b.tokens - a.tokens).slice(0, count)
63}
64
65export function toDetails(breakdown: SessionContextBreakdown, isExact: boolean): Details {
66  const servers = new Map<string, { tokens: number; tools: number }>()
67  for (const tool of breakdown.mcpTools) {
68    const server = servers.get(tool.serverName) ?? { tokens: 0, tools: 0 }
69    servers.set(tool.serverName, { tokens: server.tokens + tool.tokens, tools: server.tools + 1 })
70  }
71
72  const usage = breakdown.apiUsage
73
74  return {
75    model: breakdown.model,
76    totalTokens: breakdown.totalTokens,
77    maxTokens: breakdown.rawMaxTokens,
78    percentage: breakdown.percentage,
79    categories: breakdown.categories
80      .filter(category => category.tokens > 0 || category.kind === 'free')
81      .map(({ name, tokens, color, kind }) => ({ name: name.replace(/\s*\(deferred\)$/, ''), tokens, color, kind })),
82    ...(breakdown.autoCompactThreshold === undefined
83      ? {}
84      : { autoCompactThreshold: breakdown.autoCompactThreshold }),
85    isAutoCompactEnabled: breakdown.isAutoCompactEnabled,
86    memoryFiles: top(
87      breakdown.memoryFiles.map(file => ({ name: file.path, tokens: file.tokens, note: file.type })),
88      6,
89    ),
90    mcpServers: top(
91      [...servers].map(([name, server]) => ({
92        name,
93        tokens: server.tokens,
94        note: `${server.tools} ${server.tools === 1 ? 'tool' : 'tools'}`,
95      })),
96      6,
97    ),
98    ...(breakdown.skills === undefined
99      ? {}
100      : { skills: { count: breakdown.skills.includedSkills, tokens: breakdown.skills.tokens } }),
101    agents: top(breakdown.agents.map(agent => ({ name: agent.agentType, tokens: agent.tokens })), 6),
102    lastResponse:
103      usage === null
104        ? null
105        : {
106            input: usage.input_tokens,
107            cacheRead: usage.cache_read_input_tokens,
108            cacheWrite: usage.cache_creation_input_tokens,
109            output: usage.output_tokens,
110          },
111    isExact,
112  }
113}
114
types/index.d.ts 28 lines
1export type Fill = { tokens?: number; window: number; percent?: number }
2
3export type Category = { name: string; tokens: number; color: string; kind: string }
4
5export type Line = { name: string; tokens: number; note?: string }
6
7export type Details = {
8  model: string
9  totalTokens: number
10  maxTokens: number
11  percentage: number
12  categories: Category[]
13  autoCompactThreshold?: number
14  isAutoCompactEnabled: boolean
15  memoryFiles: Line[]
16  mcpServers: Line[]
17  skills?: { count: number; tokens: number }
18  agents: Line[]
19  lastResponse: { input: number; cacheRead: number; cacheWrite: number; output: number } | null
20  isExact: boolean
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'context-meter': { fill: Fill; details: Details | null; isLoading: boolean }
26  }
27}
28