SLOPSHOPPER

turn-receipt

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

newspinnerguardtimer
v0.1.0MITupdated 2026-10-02roaringsoul404/cc-mods/turn-receipt
A shopper browsing a rack in a slop shop

Draws

Turn line
╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ ╲╱
README

turn-receipt

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.

turn-receipt in the terminal

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 - - - - -
  • Replaces the line that closes a turn in the terminal (Baked for 1m 4s); the time row keeps Claude's word (BAKED, COOKED…).
  • Prints row by row, one every 35 ms, the first time it is drawn. Scrolling back redraws it whole.
  • A turn with no tools gets the one-line strip at the bottom instead of the full receipt.
  • Files show by name only, never by path. +/− 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.
  • An interrupted turn is stamped VOID.

Install

/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.

Check that it loaded

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.

Files

| 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 |

Source 2 files
hooks/register.js 143 lines
1import { 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}
143
hooks/receipt.js 163 lines
1// 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