A receipt under each turn's answer: cost, tokens by category, duration, model and the tools that ran

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 269 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Receipt, Tokens } from '../types'
5import {
6 cells,
7 compact,
8 duration,
9 emptyTokens,
10 estimate,
11 find,
12 isCompact,
13 miniBar,
14 pct,
15 shortModel,
16 tail,
17 toolList,
18 toolTable,
19 total,
20 usd,
21} from './format'
22
23const receipts = atom({ plugin: 'turn-receipt', key: 'receipts' } as const, [])
24const isHidden = atom({ plugin: 'turn-receipt', key: 'isHidden' } as const, false)
25
26const KEEP = 100
27
28export const register: Register = on => {
29 // The main turn in progress. Module state: a reload mid-turn loses that one receipt.
30 let current: {
31 turnId: string
32 usd?: number
33 context?: number
34 tools: Record<string, number>
35 /** tool_use_id to tool name, to file each result under its tool. */
36 names: Record<string, string>
37 toolOutput: Record<string, number>
38 subagents: number
39 uuid?: string
40 } | null = null
41
42 on('session.start', async ($, e, next) => {
43 const ran = await next(e)
44 await $.command.register({
45 name: 'turn-receipt',
46 description: 'Show or hide the receipt under each turn',
47 })
48
49 return ran
50 })
51
52 on('turn.start', async ($, e, next) => {
53 const usage = await $.session.usage()
54 current = {
55 turnId: e.turnId,
56 usd: usage.cost?.usd,
57 context: usage.context.tokens,
58 tools: {},
59 names: {},
60 toolOutput: {},
61 subagents: 0,
62 }
63
64 return next(e)
65 })
66
67 on('tool.call', ($, e, next) => {
68 if (current) {
69 current.tools[e.tool] = (current.tools[e.tool] ?? 0) + 1
70 current.names[e.tool_use_id] = e.tool
71 }
72
73 return next(e)
74 })
75
76 on('session.append', async ($, e, next) => {
77 const stored = await next(e)
78 const hasText = e.message.content.some(b => (b as { type?: string }).type === 'text')
79 if (current && e.door === 'response' && e.agentId === undefined && hasText) {
80 current.uuid = stored.uuid
81 }
82 // Tool results the main loop feeds back become input of its next request.
83 if (current && e.door === 'tool-result' && e.agentId === undefined) {
84 for (const b of e.message.content) {
85 const block = b as { type?: string; tool_use_id?: string; content?: unknown }
86 if (block.type !== 'tool_result') continue
87 const name = current.names[block.tool_use_id ?? ''] ?? 'Other'
88 current.toolOutput[name] = (current.toolOutput[name] ?? 0) + estimate(block.content)
89 }
90 }
91
92 return stored
93 })
94
95 on('turn.complete', async ($, e, next) => {
96 const ran = await next(e)
97 const u = e.usage
98
99 if (e.agentId !== undefined) {
100 if (current && u) {
101 current.subagents +=
102 u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
103 }
104 return ran
105 }
106 if (!current || current.turnId !== e.turnId) return ran
107
108 const after = await $.session.usage()
109 const tokens: Tokens = {
110 ...emptyTokens(),
111 input: u?.input_tokens ?? 0,
112 cacheRead: u?.cache_read_input_tokens ?? 0,
113 cacheWrite: u?.cache_creation_input_tokens ?? 0,
114 output: u?.output_tokens ?? 0,
115 subagents: current.subagents,
116 }
117 const receipt: Receipt = {
118 turnId: e.turnId,
119 uuid: current.uuid,
120 answerTail: tail(e.answer),
121 usd:
122 after.cost?.usd !== undefined && current.usd !== undefined
123 ? Math.max(0, after.cost.usd - current.usd)
124 : undefined,
125 durationMs: e.durationMs,
126 model: u?.model,
127 tokens,
128 contextDelta:
129 after.context.tokens !== undefined && current.context !== undefined
130 ? after.context.tokens - current.context
131 : undefined,
132 tools: current.tools,
133 toolOutput: current.toolOutput,
134 isAborted: e.isAborted,
135 }
136 current = null
137 await update($, receipts, list => [...list, receipt].slice(-KEEP))
138
139 return ran
140 })
141
142 on('command.run', { command: 'turn-receipt' }, async $ => {
143 const hidden = await update($, isHidden, h => !h)
144
145 return { text: hidden ? 'Turn receipts hidden.' : 'Turn receipts shown.' }
146 })
147
148 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
149 const list = await read($, receipts)
150 const r = find(list, e.requestId, e.props.text)
151 if (!r || (await read($, isHidden))) return next(e)
152
153 const message = await next(e)
154 const { Box, Text } = $.ui.resolve(e)
155 const all = total(r.tokens)
156 const width = Math.max(16, Math.min(48, (e.viewport?.columns ?? 80) - 12))
157 const table = toolTable(r.tools, r.toolOutput ?? {})
158 const maxTokens = Math.max(0, ...table.rows.map(t => t.tokens))
159 const model = shortModel(r.model)
160
161 const receipt = (
162 <Box
163 key={`receipt-${r.turnId}`}
164 flexDirection="column"
165 borderStyle="round"
166 borderColor="subtle"
167 paddingX={2}
168 paddingY={1}
169 rowGap={1}
170 marginTop={1}
171 >
172 <Text>
173 <Text bold>{usd(r.usd)}</Text>
174 <Text dimColor>
175 {' · '}
176 {compact(all)} tokens{' · '}
177 {duration(r.durationMs)}
178 {model ? ` · ${model}` : ''}
179 {r.contextDelta !== undefined && r.contextDelta !== 0
180 ? ` · context ${r.contextDelta > 0 ? '+' : '−'}${compact(Math.abs(r.contextDelta))}`
181 : ''}
182 {r.isAborted ? ' · interrupted' : ''}
183 </Text>
184 </Text>
185 {all > 0 ? (
186 <Box overflow="hidden">
187 <Text wrap="truncate-end">
188 {cells(r.tokens, width).map(s => (
189 <Text color={s.category.hex}>{'█'.repeat(s.cells)}</Text>
190 ))}
191 </Text>
192 </Box>
193 ) : null}
194 {all > 0 ? (
195 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
196 {cells(r.tokens, width).map(s => (
197 <Text key={s.category.key}>
198 <Text color={s.category.hex}>██</Text>
199 <Text> {s.category.name} </Text>
200 <Text bold>{compact(r.tokens[s.category.key])}</Text>
201 <Text dimColor> {pct(r.tokens[s.category.key], all)}</Text>
202 </Text>
203 ))}
204 </Box>
205 ) : null}
206 {table.calls === 0 ? (
207 <Text dimColor>No tools ran</Text>
208 ) : isCompact(table) ? (
209 <Text dimColor wrap="wrap">
210 Tools {toolList(r.tools)}
211 {table.tokens > 0 ? ` · ≈${compact(table.tokens)} fed back` : ''}
212 </Text>
213 ) : (
214 <Box flexDirection="column">
215 <Box flexDirection="row">
216 <Box width={18}>
217 <Text dimColor>Tools</Text>
218 </Box>
219 <Box width={6} justifyContent="flex-end">
220 <Text dimColor>calls</Text>
221 </Box>
222 <Box width={3} />
223 <Text dimColor>output fed back</Text>
224 </Box>
225 {table.rows.map(t => (
226 <Box key={`tool-${t.name}`} flexDirection="row">
227 <Box width={18}>
228 <Text wrap="truncate-end">{t.name}</Text>
229 </Box>
230 <Box width={6} justifyContent="flex-end">
231 <Text>{t.calls}</Text>
232 </Box>
233 <Box width={3} />
234 <Box width={11}>
235 <Text color="suggestion">{miniBar(t.tokens, maxTokens)}</Text>
236 </Box>
237 <Box width={7} justifyContent="flex-end">
238 <Text dimColor={t.tokens === 0}>{t.tokens > 0 ? `≈${compact(t.tokens)}` : '—'}</Text>
239 </Box>
240 </Box>
241 ))}
242 {table.more ? (
243 <Text dimColor wrap="truncate-end">
244 + {table.more.names.length} more ({table.more.names.join(', ')}) · {table.more.calls}{' '}
245 {table.more.calls === 1 ? 'call' : 'calls'}
246 {table.more.tokens > 0 ? ` ≈${compact(table.more.tokens)}` : ''}
247 </Text>
248 ) : null}
249 <Text dimColor>{'─'.repeat(45)}</Text>
250 <Text>
251 <Text bold>{table.calls}</Text>
252 <Text dimColor> calls · </Text>
253 <Text bold>≈{compact(table.tokens)}</Text>
254 <Text dimColor> tokens fed back</Text>
255 </Text>
256 </Box>
257 )}
258 </Box>
259 )
260
261 return (
262 <Box flexDirection="column">
263 {message}
264 {receipt}
265 </Box>
266 )
267 })
268}
269hooks/format.ts 155 lines1import type { Receipt, Tokens } from '../types'
2
3export type Category = { key: keyof Tokens; name: string; hex: string }
4
5/** Token categories in drawing order, in colours that read on light and dark. */
6export const CATEGORIES: Category[] = [
7 { key: 'cacheRead', name: 'Cache read', hex: '#3fb3a8' },
8 { key: 'cacheWrite', name: 'Cache write', hex: '#b77ee0' },
9 { key: 'input', name: 'Input', hex: '#6c8cf5' },
10 { key: 'output', name: 'Output', hex: '#d97757' },
11 { key: 'subagents', name: 'Subagents', hex: '#e06c8a' },
12]
13
14export const emptyTokens = (): Tokens => ({ input: 0, cacheRead: 0, cacheWrite: 0, output: 0, subagents: 0 })
15
16export function total(t: Tokens): number {
17 return t.input + t.cacheRead + t.cacheWrite + t.output + t.subagents
18}
19
20export function compact(n: number): string {
21 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
22 if (n >= 10_000) return `${Math.round(n / 1_000)}k`
23 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
24 return String(n)
25}
26
27export function usd(n: number | undefined): string {
28 if (n === undefined) return '—'
29 if (n === 0) return '$0.00'
30 if (n < 0.01) return `$${n.toFixed(4)}`
31 return `$${n.toFixed(n < 1 ? 3 : 2)}`
32}
33
34export function duration(ms: number): string {
35 const s = Math.round(ms / 1000)
36 if (s < 60) return `${s}s`
37 const m = Math.floor(s / 60)
38 return `${m}m ${String(s % 60).padStart(2, '0')}s`
39}
40
41export function pct(part: number, whole: number): string {
42 if (whole === 0) return '0%'
43 const p = (part / whole) * 100
44 return p >= 10 ? `${Math.round(p)}%` : p >= 0.1 ? `${p.toFixed(1)}%` : '<0.1%'
45}
46
47/** Cells per non-empty category for a `width`-cell bar; every non-empty one gets at least one. */
48export function cells(t: Tokens, width: number): { category: Category; cells: number }[] {
49 const rows = CATEGORIES.filter(c => t[c.key] > 0)
50 const sum = rows.reduce((s, c) => s + t[c.key], 0)
51 if (sum === 0 || width <= 0) return []
52
53 const exact = rows.map(c => (t[c.key] / sum) * width)
54 const out = exact.map(x => Math.max(1, Math.floor(x)))
55 let left = width - out.reduce((s, n) => s + n, 0)
56 const order = exact.map((x, i) => ({ i, rem: x - Math.floor(x) })).sort((a, b) => b.rem - a.rem)
57 for (let k = 0; left > 0 && k < order.length; k++, left--) out[order[k]!.i]! += 1
58 while (left < 0) {
59 const big = out.indexOf(Math.max(...out))
60 out[big]! -= 1
61 left += 1
62 }
63 return rows.map((category, i) => ({ category, cells: out[i]! }))
64}
65
66/** `Bash ×3 · Read ×2 · Edit`, busiest first. */
67export function toolList(tools: Record<string, number>): string {
68 return Object.entries(tools)
69 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
70 .map(([name, n]) => (n > 1 ? `${name} ×${n}` : name))
71 .join(' · ')
72}
73
74/** Rough tokens per image block; the API sizes images by pixels, which a row does not carry. */
75const IMAGE_TOKENS = 1_500
76
77/**
78 * Estimated tokens of one tool_result's content (a string or content blocks),
79 * at about four characters a token: the API does not count rows separately.
80 */
81export function estimate(content: unknown): number {
82 if (typeof content === 'string') return Math.ceil(content.length / 4)
83 if (!Array.isArray(content)) return 0
84 return content.reduce((sum: number, b: unknown) => {
85 const block = b as { type?: string; text?: unknown }
86 if (block.type === 'text' && typeof block.text === 'string') return sum + Math.ceil(block.text.length / 4)
87 if (block.type === 'image') return sum + IMAGE_TOKENS
88 return sum + Math.ceil(JSON.stringify(b).length / 4)
89 }, 0)
90}
91
92/** Claude's own bookkeeping: always folded into the "+ more" line, never a row of its own. */
93export const BOOKKEEPING = new Set(['ToolSearch', 'ExitPlanMode', 'EnterPlanMode', 'TodoWrite', 'TaskStop'])
94
95export type ToolRow = { name: string; calls: number; tokens: number }
96
97export type ToolTable = {
98 rows: ToolRow[]
99 /** Everything past the top rows, and all bookkeeping tools, folded into one line. */
100 more: { names: string[]; calls: number; tokens: number } | null
101 calls: number
102 tokens: number
103}
104
105/** The tools as table rows: the `top` busiest by output (then calls) and one folded "more" line. */
106export function toolTable(tools: Record<string, number>, output: Record<string, number>, top = 4): ToolTable {
107 const names = new Set([...Object.keys(tools), ...Object.keys(output)])
108 const all: ToolRow[] = [...names].map(name => ({ name, calls: tools[name] ?? 0, tokens: output[name] ?? 0 }))
109 all.sort((a, b) => b.tokens - a.tokens || b.calls - a.calls || a.name.localeCompare(b.name))
110
111 const rows = all.filter(r => !BOOKKEEPING.has(r.name)).slice(0, top)
112 const rest = all.filter(r => !rows.includes(r))
113 const sum = (list: ToolRow[], k: 'calls' | 'tokens') => list.reduce((s, r) => s + r[k], 0)
114
115 return {
116 rows,
117 more: rest.length ? { names: rest.map(r => r.name), calls: sum(rest, 'calls'), tokens: sum(rest, 'tokens') } : null,
118 calls: sum(all, 'calls'),
119 tokens: sum(all, 'tokens'),
120 }
121}
122
123/** Small turns draw the tools as one line instead of a table. */
124export function isCompact(t: ToolTable): boolean {
125 return t.calls < 3 && t.tokens < 1_000
126}
127
128/**
129 * A bar of up to `width` whole cells for `value` against `max`, at least one.
130 * Whole blocks only: fonts draw the partial blocks (▏…▉) at uneven heights.
131 */
132export function miniBar(value: number, max: number, width = 10): string {
133 if (max <= 0 || value <= 0) return ''
134 return '█'.repeat(Math.max(1, Math.round((value / max) * width)))
135}
136
137const TAIL = 120
138
139export function tail(text: string): string {
140 return text.trim().slice(-TAIL)
141}
142
143/** The receipt belonging to an assistant row: by its id, else by its text's ending. */
144export function find(receipts: Receipt[], requestId: string | undefined, text: string): Receipt | undefined {
145 const byId = requestId === undefined ? undefined : receipts.find(r => r.uuid === requestId)
146 if (byId) return byId
147 const end = tail(text)
148 if (end.length < 8) return undefined
149 return receipts.find(r => r.answerTail.length >= 8 && (end.endsWith(r.answerTail) || r.answerTail.endsWith(end)))
150}
151
152export function shortModel(model: string | undefined): string | undefined {
153 return model?.replace(/^claude-/, '').replace(/-\d{8}$/, '')
154}
155types/index.d.ts 32 lines1export type Tokens = {
2 input: number
3 cacheRead: number
4 cacheWrite: number
5 output: number
6 subagents: number
7}
8
9export type Receipt = {
10 turnId: string
11 /** Transcript id of the turn's last reply row that held text. */
12 uuid?: string
13 /** The end of the turn's answer, to find its row when the id does not match. */
14 answerTail: string
15 usd?: number
16 durationMs: number
17 model?: string
18 tokens: Tokens
19 /** Context-window tokens added by the turn, when both readings exist. */
20 contextDelta?: number
21 tools: Record<string, number>
22 /** Estimated tokens of tool output fed back to the model, by tool. */
23 toolOutput?: Record<string, number>
24 isAborted: boolean
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'turn-receipt': { receipts: Receipt[]; isHidden: boolean }
30 }
31}
32