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

Two small Claude Code mods (hooks-module plugins) that make the session easier to read and drive.
| Mod | What it does | Where it shows |
|---|---|---|
| context-meter | A color-coded context window meter right above the prompt | Terminal and VS Code extension |
| term-bar | Footer buttons: 🕐 recent conversations, model picker, effort picker | Terminal only |
Requires Claude Code 2.1.287 or newer (claude --version).
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
▼
/context category, each with a fixed color, so a category that drops out never shifts the others./model.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.[ 🕐 ] [ Sonnet ▾ ] [ medium ▾ ]
Notes:
/model or /effort, which leave a line in the transcript and also save your choice as the default for new sessions.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.
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.
hooks/register.tsx 187 lines1import { 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}
187hooks/meter.ts 62 lines1import 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))
62types/index.d.ts 25 lines1export 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