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

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.
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.
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)
})
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.
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 } } } }
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
})
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)
})
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
<sub>MIT · built with Claude Code</sub>
hooks/register.tsx 242 lines1import { 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}
242hooks/context.ts 114 lines1import 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}
114types/index.d.ts 28 lines1export 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