At the end of every turn Claude prints a receipt: files touched, commands run, tokens and what the turn cost

A Claude Code mod. At the end of every turn Claude prints a receipt: the files it touched, the commands it ran, the tokens it used and what the turn cost.

As plain text:
╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
CLAUDE CODE
· turn receipt ·
#0007 02 OCT 2026 14:32
- - - - - - - - - - - - - - - - - - - -
register.js +42 −7
turn-receipt.test.ts +88
receipt.js +12 −3
+ 2 more files
- - - - - - - - - - - - - - - - - - - -
BASH 4 runs · 1 failed
TOOLS 11
BAKED 1m 04s
- - - - - - - - - - - - - - - - - - - -
opus 5.5
TOKENS IN 1.2k
TOKENS OUT 3.5k
CACHE READ 48.1k
CACHE WRITE 12.4k
════════════════════════════════════════
TOTAL $0.184
════════════════════════════════════════
thank you · come again
╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
- - - - - 4.2s · 1.2k in · 380 out · $0.004 - - - - -
Baked for 1m 4s); the time row keeps Claude's word (BAKED, COOKED…).+/− are an estimate: the lines an Edit changed, past what old and new share at both ends, and every line of a Write. Files written by a Bash command are not counted.TOTAL is what the session's cost grew by during the turn, so it includes anything else billed meanwhile.VOID./plugin marketplace add roaringsoul404/cc-mods
/plugin install turn-receipt@roaringsoul
To receive new versions on their own: /plugin → Marketplaces → roaringsoul → Enable auto-update. To turn it off, disable it in /plugin.
To try it without installing:
git clone https://github.com/roaringsoul404/cc-mods
claude --plugin-dir ./cc-mods/turn-receipt
Ask for anything that edits a file or runs a command, then look under the answer.
Desktop app: there is no closing line there, so the receipt is added under the answer as monospaced text (no colors, no printing animation). It is covered by the tests but hasn't been watched in the app yet.
Start with --debug-file /tmp/receipt.log and run:
grep "turn-receipt@inline" /tmp/receipt.log
loaded means it's on. not loaded: ... the rollout switch served off means Claude Code switched mods off remotely for that launch; restart and check again.
| File | What it does | | :- | :- | | hooks/register.js | Hooks: tallies the turn (turn.start, tool.call), builds the receipt (turn.complete), draws it in place of the closing line (ui.render) | | hooks/receipt.js | Pure layout: tallies, number formats and the rows, shared by terminal, desktop and the preview | | scripts/preview.mjs | Prints a sample receipt in an 80-column frame: node scripts/preview.mjs | | demo.gif | A real session, recorded by media/capture.py turn-receipt and rendered by media/shoot.mjs | | tests/turn-receipt.test.ts | claude plugin test: the numbers of a busy turn, the compact strip, the printing, stale lines and narrow terminals |
hooks/register.js 143 lines1import { WIDTH, STRIP_WIDTH, LINE_MS, newTally, lineCount, editDelta, addFile, layout, compactLine, receiptText } from './receipt.js'
2
3const INDENT = 2 // lines up with the answer's text, past the ⏺
4const KEEP = 50 // receipts kept so older turns redraw theirs on scroll
5
6// What the running turn has done so far, reset on every turn.start
7let tally = newTally()
8let turns = 0
9let costAtStart = null
10// Finished receipts, newest last, and which TurnDuration row drew each one
11const receipts = []
12const drawnBy = new Map()
13// The turn count when each closing line was first drawn: a line already on
14// screen before a turn started (a resumed session's) never takes its receipt
15const firstSeen = new Map()
16// Whether a redraw is already queued for the printing animation
17let printing = false
18
19// One receipt row as Box/Text: items push their right side to the edge
20function drawRow(Box, Text, row, width) {
21 const style = s => ({ ...(s.dim ? { dimColor: true } : {}), ...(s.bold ? { bold: true } : {}), ...(s.color ? { color: s.color } : {}) })
22 switch (row.kind) {
23 case 'tear':
24 case 'rule':
25 return Text({ dimColor: true, children: [row.pattern.repeat(width).slice(0, width)] })
26 case 'center':
27 return Box({ width, justifyContent: 'center', children: [Text({ ...style(row), children: [row.text] })] })
28 case 'item':
29 return Box({
30 width,
31 justifyContent: 'space-between',
32 children: [
33 Text({ ...style(row), children: [row.left] }),
34 Text({ ...style(row), ...(row.rightColor ? { color: row.rightColor } : {}), children: [row.right] }),
35 ],
36 })
37 default:
38 return Text({ ...style(row), children: [row.text] })
39 }
40}
41
42export function register(on) {
43 on('turn.start', async ($, e, next) => {
44 tally = newTally()
45 turns += 1
46 const usage = await $.session.usage()
47 costAtStart = usage.cost ? usage.cost.usd : null
48 return next(e)
49 })
50
51 on('tool.call', async ($, e, next) => {
52 const r = await next(e)
53 tally.tools += 1
54 if (e.tool === 'Bash') {
55 tally.bash += 1
56 if (r && r.isError === true) tally.bashFailed += 1
57 } else if ((e.tool === 'Edit' || e.tool === 'Write') && typeof e.file_path === 'string' && !(r && r.isError === true)) {
58 addFile(tally, e.file_path, e.tool === 'Edit' ? editDelta(e.old_string, e.new_string) : { add: lineCount(e.content), del: 0 })
59 }
60 return r
61 })
62
63 on('turn.complete', async ($, e, next) => {
64 const r = await next(e)
65 // A subagent's turn is part of the main one: its tools already counted
66 if (e.agentId) return r
67 const usage = await $.session.usage()
68 const receipt = {
69 turn: turns,
70 at: await $.clock.now(),
71 model: e.usage ? e.usage.model : '',
72 durationMs: e.durationMs,
73 usage: e.usage
74 ? { input: e.usage.input_tokens, output: e.usage.output_tokens, cacheRead: e.usage.cache_read_input_tokens, cacheWrite: e.usage.cache_creation_input_tokens }
75 : null,
76 usd: usage.cost && costAtStart !== null ? usage.cost.usd - costAtStart : null,
77 files: [...tally.files.values()],
78 bash: tally.bash,
79 bashFailed: tally.bashFailed,
80 tools: tally.tools,
81 aborted: e.isAborted,
82 }
83 receipts.push(receipt)
84 if (receipts.length > KEEP) receipts.shift()
85 // The terminal may have drawn the closing line already: redraw it as the receipt
86 $.ui.invalidate('ui.render')
87
88 // Desktop has no TurnDuration: the receipt goes under the answer, monospaced
89 const surfaces = await $.session.surfaces()
90 if (!surfaces.includes('desktop') || surfaces.includes('terminal')) return r
91 const text = '```\n' + receiptText(receipt) + '\n```'
92 return { ...r, text: r.text && r.text !== e.answer ? `${r.text}\n\n${text}` : text }
93 })
94
95 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
96 if (e.surface !== 'terminal') return next(e)
97 const columns = (e.viewport ? e.viewport.columns : 80) - INDENT - 2
98 // Too narrow for paper: the engine's own line
99 if (columns < 20) return next(e)
100 if (!firstSeen.has(e.requestId)) {
101 firstSeen.set(e.requestId, turns)
102 if (firstSeen.size > KEEP * 4) firstSeen.delete(firstSeen.keys().next().value)
103 }
104 let receipt = drawnBy.get(e.requestId)
105 if (!receipt) {
106 // The newest receipt not drawn yet, of a turn this line appeared in, that
107 // lasted as long as the line says
108 const seenAt = firstSeen.get(e.requestId)
109 receipt = receipts.findLast(x => !x.drawn && x.turn === seenAt && Math.abs(x.durationMs - e.props.durationMs) < 1000)
110 if (!receipt) return next(e)
111 receipt.drawn = true
112 drawnBy.set(e.requestId, receipt)
113 if (drawnBy.size > KEEP) drawnBy.delete(drawnBy.keys().next().value)
114 }
115
116 const { Box, Text } = $.ui.resolve(e)
117 const width = Math.min(WIDTH, columns)
118 if (receipt.tools === 0) {
119 return Box({ marginLeft: INDENT, children: [Text({ dimColor: true, children: [compactLine(receipt, Math.min(STRIP_WIDTH, columns))] })] })
120 }
121
122 // Print: one more row every LINE_MS from the first time it is drawn
123 const rows = layout(receipt, width, e.props.word || 'time')
124 const now = await $.clock.now()
125 if (receipt.printedAt === undefined) receipt.printedAt = now
126 const shown = Math.min(rows.length, Math.floor((now - receipt.printedAt) / LINE_MS) + 1)
127 if (shown < rows.length && !printing) {
128 printing = true
129 $.clock.after(LINE_MS, () => {
130 printing = false
131 $.ui.invalidate('ui.render')
132 })
133 }
134 return Box({
135 flexDirection: 'column',
136 marginTop: 1,
137 marginLeft: INDENT,
138 width,
139 children: rows.slice(0, shown).map(row => drawRow(Box, Text, row, width)),
140 })
141 })
142}
143hooks/receipt.js 163 lines1// The receipt itself: tallies, number formats and the row layout. Pure, no
2// hooks and no $, so the terminal tree, the desktop text and
3// scripts/preview.mjs all print the same paper.
4
5export const WIDTH = 40 // paper width, in columns
6export const LINE_MS = 35 // printer speed: one row every 35 ms
7export const STRIP_WIDTH = 56 // the one-line strip of a turn without tools
8const BRAND = '#D77757' // Claude orange
9const MAX_FILES = 4 // compact receipt: the rest fold into "+ N more files"
10
11const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
12
13export function newTally() {
14 return { files: new Map(), bash: 0, bashFailed: 0, tools: 0 }
15}
16
17// Lines in a text, 0 when empty
18export function lineCount(s) {
19 if (!s) return 0
20 return s.replace(/\n$/, '').split('\n').length
21}
22
23// Rough +/− of an Edit: drop the lines old and new share at both ends, count the rest
24export function editDelta(oldStr = '', newStr = '') {
25 const a = oldStr.split('\n')
26 const b = newStr.split('\n')
27 let head = 0
28 while (head < a.length && head < b.length && a[head] === b[head]) head++
29 let tail = 0
30 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
31 return { add: b.length - head - tail, del: a.length - head - tail }
32}
33
34// Files are keyed by full path but only the name is ever kept for printing
35export function addFile(tally, path, delta) {
36 const f = tally.files.get(path) ?? { name: path.split(/[\\/]/).pop(), add: 0, del: 0 }
37 f.add += delta.add
38 f.del += delta.del
39 tally.files.set(path, f)
40}
41
42export function fmtTokens(n) {
43 if (n < 1000) return String(n)
44 // Cut where rounding would print 1000.0k
45 if (n < 999950) return `${(n / 1000).toFixed(1)}k`
46 return `${(n / 1e6).toFixed(2)}M`
47}
48
49export function fmtUsd(usd) {
50 if (usd === null || !Number.isFinite(usd)) return '—'
51 return `$${Math.max(0, usd).toFixed(usd < 10 ? 3 : 2)}`
52}
53
54export function fmtTime(ms) {
55 // Under a minute as it will print: 59.97 s rounds up to 1m 00s, not 60.0s
56 if (ms < 59950) return `${(ms / 1000).toFixed(1)}s`
57 const s = Math.round(ms / 1000)
58 return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
59}
60
61// claude-opus-5-5 → opus 5.5, claude-haiku-4-5-20251001 → haiku 4.5
62export function fmtModel(id) {
63 if (!id) return ''
64 const m = id.replace(/^claude-/, '').match(/^([a-z]+)-(\d+)-(\d+)/)
65 return m ? `${m[1]} ${m[2]}.${m[3]}` : id
66}
67
68const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
69
70function stamp(at) {
71 const d = new Date(at)
72 const p = n => String(n).padStart(2, '0')
73 return { date: `${p(d.getDate())} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`, time: `${p(d.getHours())}:${p(d.getMinutes())}` }
74}
75
76// An item row whose left side is cut to leave room for the right one
77function item(left, right, width, style = {}) {
78 const room = width - right.length - 1
79 const cut = left.length > room ? `${left.slice(0, Math.max(0, room - 1))}…` : left
80 return { kind: 'item', left: cut, right, ...style }
81}
82
83/**
84 * The full receipt as rows. `r` is what the turn left behind:
85 * { turn, at, model, durationMs, usage: { input, output, cacheRead, cacheWrite }, usd, files, bash, bashFailed, tools, aborted }
86 * `timeLabel` is the terminal's past-tense word (BAKED), TIME elsewhere.
87 */
88export function layout(r, width = WIDTH, timeLabel = 'time') {
89 const { date, time } = stamp(r.at)
90 const rows = [
91 { kind: 'tear', pattern: '╲╱' },
92 { kind: 'center', text: 'CLAUDE CODE', bold: true, color: BRAND },
93 { kind: 'center', text: '· turn receipt ·', dim: true },
94 item(`#${String(r.turn).padStart(4, '0')} ${date}`, time, width, { dim: true }),
95 { kind: 'rule', pattern: '- ' },
96 ]
97
98 if (r.files.length > 0) {
99 const shown = r.files.length > MAX_FILES ? MAX_FILES - 1 : r.files.length
100 for (const f of r.files.slice(0, shown)) {
101 // Two fixed columns, so the + and − line up down the list
102 const right = `+${f.add}`.padStart(5) + (f.del > 0 ? `−${f.del}`.padStart(5) : ' '.repeat(5))
103 rows.push(item(f.name, right, width))
104 }
105 if (shown < r.files.length) rows.push({ kind: 'note', text: ` + ${r.files.length - shown} more files`, dim: true })
106 rows.push({ kind: 'rule', pattern: '- ' })
107 }
108
109 if (r.bash > 0) {
110 rows.push(item('BASH', `${plural(r.bash, 'run')} · ${r.bashFailed} failed`, width, r.bashFailed > 0 ? { rightColor: 'error' } : {}))
111 }
112 rows.push(item('TOOLS', String(r.tools), width))
113 rows.push(item(timeLabel.toUpperCase(), fmtTime(r.durationMs), width))
114
115 if (r.usage) {
116 rows.push({ kind: 'rule', pattern: '- ' })
117 if (r.model) rows.push({ kind: 'note', text: fmtModel(r.model), dim: true })
118 rows.push(item('TOKENS IN', fmtTokens(r.usage.input), width))
119 rows.push(item('TOKENS OUT', fmtTokens(r.usage.output), width))
120 rows.push(item('CACHE READ', fmtTokens(r.usage.cacheRead), width))
121 rows.push(item('CACHE WRITE', fmtTokens(r.usage.cacheWrite), width))
122 }
123
124 rows.push({ kind: 'rule', pattern: '═' })
125 rows.push(item('TOTAL', fmtUsd(r.usd), width, { bold: true }))
126 rows.push({ kind: 'rule', pattern: '═' })
127 rows.push({ kind: 'center', text: r.aborted ? '** VOID · interrupted **' : 'thank you · come again', dim: true })
128 rows.push({ kind: 'tear', pattern: '╱╲' })
129 return rows
130}
131
132// The one-liner for a turn that touched no tools
133export function compactLine(r, width = STRIP_WIDTH) {
134 const parts = [fmtTime(r.durationMs)]
135 if (r.usage) parts.push(`${fmtTokens(r.usage.input)} in`, `${fmtTokens(r.usage.output)} out`)
136 parts.push(fmtUsd(r.usd))
137 const core = ` ${parts.join(' · ')} `
138 const dashes = '- '.repeat(Math.max(1, Math.floor((width - core.length + 2) / 4))).trimEnd()
139 return `${dashes}${core}${dashes}`
140}
141
142// One row as plain text, exactly `width` wide (trailing blanks trimmed)
143export function rowText(row, width = WIDTH) {
144 switch (row.kind) {
145 case 'tear':
146 case 'rule':
147 return row.pattern.repeat(width).slice(0, width).trimEnd()
148 case 'center': {
149 const pad = Math.max(0, Math.floor((width - row.text.length) / 2))
150 return ' '.repeat(pad) + row.text
151 }
152 case 'item':
153 return (row.left + ' '.repeat(Math.max(1, width - row.left.length - row.right.length)) + row.right).trimEnd()
154 default:
155 return row.text
156 }
157}
158
159export function receiptText(r, width = WIDTH) {
160 if (r.tools === 0) return compactLine(r)
161 return layout(r, width).map(row => rowText(row, width)).join('\n')
162}
163