A context-window pane: colour-coded bar and a colour legend for every category

Three Claude Code mods for keeping an eye on how much room you have left and what you spend: your context window, your plan usage limits, and a receipt for each turn. They work in the terminal and in the Claude desktop app's Code tab.
| Mod | Where it shows | What it shows |
|---|---|---|
context-window | A pane beside the conversation | A colour-coded bar of what fills the context window, with a legend of every category (tokens and share), the autocompact buffer, and a warning at 50% |
plan-usage | A band above the prompt | 5-hour and weekly limit meters with a pace marker, time until reset, an ahead/under-pace label, and the session's cost |
turn-receipt | A card under each turn's answer | What the turn cost, its tokens by category, duration, model, the tools that ran, and how much tool output went back to the model |
context-window and plan-usage also add a short entry to the status line.
In a Claude Code terminal session:
/plugin install context-window --marketplace oakker-myo/claude-code-mods
/plugin install plan-usage --marketplace oakker-myo/claude-code-mods
/plugin install turn-receipt --marketplace oakker-myo/claude-code-mods
Answer y to add the marketplace, then pick a scope (the user scope loads them in every session). Installed at the user scope from a terminal, they also load in the desktop app's Code tab.
Or from a shell:
claude plugin marketplace add oakker-myo/claude-code-mods
claude plugin install context-window@claude-code-mods --scope user
claude plugin install plan-usage@claude-code-mods --scope user
claude plugin install turn-receipt@claude-code-mods --scope user
| Command | Effect |
|---|---|
/context-window | Open or close the context-window pane |
/plan-usage | Show or hide the plan-usage band |
/turn-receipt | Show or hide the receipts under each turn |
/compact.Context window · 11%.│: how much of the window's time has passed. Usage left of it is under pace.Plan meters appear on Pro and Max plans after the first reply of a session.
A small card under the answer that closes each turn:
Tools calls output fed back
Read 2 ██████████ ≈261
Bash 3 ███████ ≈182
+ 2 more (ExitPlanMode, ToolSearch) · 2 calls ≈457
─────────────────────────────────────────────
7 calls · ≈900 tokens fed back
The four tools with the most output get a row; the rest, and Claude's own bookkeeping tools (ToolSearch, plan mode, todo lists), fold into one "+ more" line. A turn with fewer than three calls and under 1k tokens of output shows one line instead, e.g. Tools Bash ×2 · ≈180 fed back.
A turn that ends on a tool call with no closing text has no answer to hang a receipt under, so it shows none.
Claude Code with plugin hooks modules (2.1.293 or later).
Each mod is a plugin folder: .claude-plugin/plugin.json, a hooks module in hooks/, and its state contract in types/. The repository root holds the marketplace file, .claude-plugin/marketplace.json.
claude plugin validate .
claude plugin validate context-window
claude plugin test context-window
claude plugin test plan-usage
claude plugin test turn-receipt
To try a change without installing, start a session with claude --plugin-dir ./context-window --plugin-dir ./plan-usage --plugin-dir ./turn-receipt. If you installed from a clone of this folder (claude plugin marketplace add <folder>), Claude Code installs from the clone's last commit, so a change reaches your sessions once it is committed:
git commit -am "…"
claude plugin update <mod>@claude-code-mods
then run /reload-plugins in a running session.
See CHANGELOG.md for release notes.
hooks/register.tsx 172 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Category, Snapshot } from '../types'
5import { GLYPH, compact, crossed, pct, rows, split, tone } from './format'
6
7const PANE = 'context-window'
8const snapshot = atom({ plugin: 'context-window', key: 'snapshot' } as const, null)
9const paneOpen = atom({ plugin: 'context-window', key: 'paneOpen' } as const, false)
10
11const MARKS = [50]
12const ORDER: Record<Category['kind'], number> = { used: 0, buffer: 1, free: 2, deferred: 3 }
13
14function title(snap: Snapshot | null): string {
15 return snap?.percent === undefined ? 'Context window' : `Context window · ${snap.percent}%`
16}
17
18// The engine's pane list can miss a pane a remote surface (the desktop app)
19// is showing, so the mod keeps its own record: set on open, cleared by ui.close.
20async function isOpen($: EngineInterface): Promise<boolean> {
21 return (await read($, paneOpen)) || (await $.ui.panes()).some(p => p.id === PANE)
22}
23
24async function openPane($: EngineInterface, snap: Snapshot | null): Promise<void> {
25 await update($, paneOpen, () => true)
26 await $.ui.open({ id: PANE, title: title(snap) })
27}
28
29async function measure($: EngineInterface): Promise<Snapshot> {
30 const usage = await $.session.usage({ breakdown: 'summary' })
31 const categories = (usage.context.breakdown?.categories ?? [])
32 .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
33 .sort((a, b) => ORDER[a.kind] - ORDER[b.kind])
34 const fresh: Snapshot = {
35 tokens: usage.context.tokens,
36 window: usage.context.window,
37 percent: usage.context.percent,
38 categories,
39 }
40 await update($, snapshot, () => fresh)
41 $.ui.status(
42 fresh.percent === undefined
43 ? undefined
44 : `ctx ${fresh.percent}% ${compact(fresh.tokens ?? 0)}/${compact(fresh.window)}`,
45 )
46 // Opening an open pane retitles it; a closed one stays closed.
47 if (await read($, paneOpen)) await $.ui.open({ id: PANE, title: title(fresh) })
48
49 return fresh
50}
51
52export const register: Register = on => {
53 on('session.start', async ($, e, next) => {
54 const ran = await next(e)
55 await $.command.register({
56 name: 'context-window',
57 description: 'Open or close the context-window pane',
58 })
59 const fresh = await measure($)
60 await openPane($, fresh)
61
62 return ran
63 })
64
65 on('session.measure', async ($, e, next) => {
66 if (e.changed.includes('context')) {
67 const before = await read($, snapshot)
68 const fresh = await measure($)
69 if (crossed(before?.percent, fresh.percent, MARKS) !== undefined) {
70 $.ui.toast(`Context window ${fresh.percent}% full; /compact frees room`)
71 }
72 }
73
74 return next(e)
75 })
76
77 on('command.run', { command: 'context-window' }, async $ => {
78 if (await isOpen($)) {
79 await update($, paneOpen, () => false)
80 await $.ui.close({ id: PANE })
81 return { text: 'Context-window pane closed.' }
82 }
83 await openPane($, await read($, snapshot))
84
85 return { text: 'Context-window pane opened.' }
86 })
87
88 on('ui.close', { id: PANE }, async ($, e, next) => {
89 await update($, paneOpen, () => false)
90
91 return next(e)
92 })
93
94 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
95 const { Box, Text } = $.ui.resolve(e)
96 const snap = await read($, snapshot)
97 if (snap === null) {
98 return <Text dimColor>Measuring…</Text>
99 }
100
101 const percent = snap.percent
102 const list = rows(snap.categories)
103 const deferred = snap.categories.filter(c => c.kind === 'deferred').reduce((s, c) => s + c.tokens, 0)
104 const cols = e.props.bodyColumns
105 // Block glyphs can draw wider than a cell on some fonts: leave slack, and
106 // draw the bar as one truncating line so it can never wrap onto a second.
107 const width = Math.max(10, Math.floor((cols - 2) * 0.9))
108 const rowWidth = Math.min(cols, 44)
109
110 const bar = (
111 <Box flexDirection="row" overflow="hidden">
112 <Text wrap="truncate-end">
113 {split(
114 list.map(c => c.tokens),
115 width,
116 list.map(c => c.kind === 'used'),
117 ).map((cells, i) => {
118 const c = list[i]!
119 return cells > 0 ? (
120 <Text color={c.hex} dimColor={c.kind === 'free'}>
121 {GLYPH[c.kind].repeat(cells)}
122 </Text>
123 ) : null
124 })}
125 </Text>
126 </Box>
127 )
128
129 const legend = list.map(c => (
130 <Box key={c.name} width={rowWidth} flexDirection="row">
131 <Text color={c.hex} dimColor={c.kind === 'free'}>
132 {GLYPH[c.kind].repeat(2)}{' '}
133 </Text>
134 <Box flexGrow={1}>
135 <Text dimColor={c.kind !== 'used'} wrap="truncate">
136 {c.name}
137 </Text>
138 </Box>
139 <Text bold={c.kind === 'used'} dimColor={c.kind !== 'used'}>
140 {compact(c.tokens).padStart(5)}
141 </Text>
142 <Text dimColor>{pct(c.share).padStart(7)}</Text>
143 </Box>
144 ))
145
146 return (
147 <Box flexDirection="column" paddingX={1}>
148 <Text>
149 <Text bold color={percent === undefined ? 'inactive' : tone(percent)}>
150 {percent === undefined ? 'Waiting for first reply' : `${percent}% used`}
151 </Text>
152 <Text dimColor>
153 {' '}
154 {snap.tokens === undefined ? '—' : compact(snap.tokens)} / {compact(snap.window)} tokens
155 </Text>
156 </Text>
157 <Box marginY={1}>{bar}</Box>
158 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
159 {legend}
160 </Box>
161 {deferred > 0 ? (
162 <Box marginTop={1}>
163 <Text dimColor italic>
164 + {compact(deferred)} of tool schemas load on demand and sit outside the window
165 </Text>
166 </Box>
167 ) : null}
168 </Box>
169 )
170 })
171}
172hooks/format.ts 74 lines1import type { Category } from '../types'
2
3/** Mid-tone categorical colours that read on light and dark backgrounds alike. */
4export const PALETTE = ['#d97757', '#6c8cf5', '#3fb3a8', '#b77ee0', '#9b7653', '#5fae5f', '#e06c8a', '#4aa3df']
5export const TRACK = '#8b949e'
6export const BUFFER = '#e0a43a'
7
8export const GLYPH: Record<Category['kind'], string> = {
9 used: '█',
10 buffer: '▒',
11 free: '░',
12 deferred: '·',
13}
14
15export type Row = Category & { hex: string; share: number }
16
17/** The in-window rows in drawing order, each with its colour and share of the window. */
18export function rows(categories: Category[]): Row[] {
19 const inWindow = categories.filter(c => c.kind !== 'deferred' && c.tokens > 0)
20 const total = inWindow.reduce((s, c) => s + c.tokens, 0) || 1
21 let used = 0
22 return inWindow.map(c => ({
23 ...c,
24 hex: c.kind === 'used' ? PALETTE[used++ % PALETTE.length]! : c.kind === 'buffer' ? BUFFER : TRACK,
25 share: (c.tokens / total) * 100,
26 }))
27}
28
29export function compact(n: number): string {
30 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
31 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
32 return String(n)
33}
34
35export function pct(share: number): string {
36 return share >= 10 ? `${Math.round(share)}%` : share >= 0.1 ? `${share.toFixed(1)}%` : '<0.1%'
37}
38
39export function tone(percent: number): 'success' | 'warning' | 'error' {
40 return percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
41}
42
43/**
44 * Splits `width` units across `sizes` in proportion (largest remainder),
45 * giving each index in `atLeast` a floor of `min` so small rows stay visible.
46 */
47export function split(sizes: number[], width: number, atLeast: boolean[], min = 1): number[] {
48 const total = sizes.reduce((s, n) => s + n, 0)
49 if (total === 0 || width <= 0) return sizes.map(() => 0)
50
51 const exact = sizes.map(n => (n / total) * width)
52 const out = exact.map((x, i) => Math.max(atLeast[i] ? min : 0, Math.floor(x)))
53 let left = width - out.reduce((s, n) => s + n, 0)
54
55 const order = exact.map((x, i) => ({ i, rem: x - Math.floor(x) })).sort((a, b) => b.rem - a.rem)
56 for (let k = 0; left > 0 && k < order.length; k++, left--) out[order[k]!.i]! += 1
57
58 while (left < 0) {
59 const big = out.indexOf(Math.max(...out))
60 out[big]! -= 1
61 left += 1
62 }
63 return out
64}
65
66export function crossed(
67 before: number | undefined,
68 after: number | undefined,
69 marks: number[],
70): number | undefined {
71 if (after === undefined) return undefined
72 return [...marks].sort((a, b) => b - a).find(m => after >= m && (before ?? 0) < m)
73}
74types/index.d.ts 20 lines1export type Category = {
2 name: string
3 tokens: number
4 color: string
5 kind: 'used' | 'free' | 'buffer' | 'deferred'
6}
7
8export type Snapshot = {
9 tokens?: number
10 window: number
11 percent?: number
12 categories: Category[]
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'context-window': { snapshot: Snapshot | null; paneOpen: boolean }
18 }
19}
20