SLOPSHOPPER

ledger

Every tool call is one line: status, verb, target, result; ctrl+o still shows the full rows

newrows
v0.1.0MITupdated 2026-10-22rezaulhreza/claude-code-mods/ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ledger
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ✓ Edit src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
✓ Edit src/auth.ts
README

ledger

Every tool call is one line in three columns: status and verb, target, result (✓ Edit app/Price.php +14 −3). Errors are red and keep the first useful error line. Edit and Write keep their diff unless you turn keepDiffs off. ctrl+o still shows the full engine rows.

Source 3 files
hooks/register.tsx 122 lines
1import type { Register } from 'claude-code'
2
3import { hex, hueOf } from './avatar'
4import { clip, noteOf, target } from './describe'
5
6const RED = '#F0776B'
7
8// Tools whose row ledger draws; every other tool keeps the engine's own row.
9const LEDGER = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash', 'WebFetch', 'WebSearch', 'Agent', 'Skill', 'Grep', 'Glob', 'LSP'])
10
11// The engine's result block stays for these: the diff is the review.
12const KEEP_RESULT = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
13
14const isLedger = (tool: string) => LEDGER.has(tool) || tool.startsWith('mcp__')
15
16type Input = Parameters<typeof target>[0]
17
18const asInput = (tool: string, input: unknown): Input => ({ ...(input as object), tool }) as Input
19
20const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
21
22// The folded group's line: "Read 3 files · searched 2 patterns · listed 1 folder".
23export function groupLine(calls: readonly { tool: string; input: unknown }[]): string {
24  let files = 0
25  let patterns = 0
26  let folders = 0
27  let commands = 0
28
29  for (const call of calls) {
30    const command = String((call.input as { command?: unknown } | null)?.command ?? '')
31
32    if (call.tool === 'Read') files += 1
33    else if (call.tool === 'Grep' || call.tool === 'Glob' || /^\s*(rg|grep|find|fd|ag)\b/.test(command)) patterns += 1
34    else if (/^\s*(ls|tree)\b/.test(command)) folders += 1
35    else commands += 1
36  }
37
38  const parts = [
39    files ? `read ${plural(files, 'file')}` : '',
40    patterns ? `searched ${plural(patterns, 'pattern')}` : '',
41    folders ? `listed ${plural(folders, 'folder')}` : '',
42    commands ? `ran ${plural(commands, 'command')}` : '',
43  ].filter(Boolean)
44  const line = parts.join(' · ')
45
46  return line.charAt(0).toUpperCase() + line.slice(1)
47}
48
49export const register: Register = (on, options) => {
50  const ACCENT = String(options.accent || '#D97757')
51  let cwd = ''
52
53  on('session.start', async ($, e, next) => {
54    const started = await next(e)
55    cwd = e.cwd
56
57    return started
58  })
59
60  on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
61    if (e.surface !== 'terminal' || e.props.isExpanded || !isLedger(e.props.tool)) {
62      return next(e)
63    }
64
65    const { Box, Text } = $.ui.resolve(e)
66    const { tool, isRunning, isErrored, isInterrupted, output } = e.props
67    const input = asInput(tool, e.props.input)
68    const { verb, what } = target(input, cwd)
69    const note = isRunning
70      ? 'running'
71      : isInterrupted
72        ? 'interrupted'
73        : noteOf(tool, { result: output, isError: isErrored, text: isErrored ? String(output ?? '') : undefined })
74    const glyph = isRunning ? '●' : isInterrupted ? '–' : isErrored ? '✗' : '✓'
75    const colour = isErrored ? RED : undefined
76    const agentType = tool === 'Agent' ? String((e.props.input as { subagent_type?: unknown }).subagent_type ?? 'agent') : null
77
78    return (
79      <Box flexDirection="row">
80        <Text color={isRunning ? ACCENT : colour} dimColor={isInterrupted}>
81          {glyph}
82        </Text>
83        <Text color={colour}>{` ${verb.padEnd(7)} `}</Text>
84        {agentType !== null && <Text backgroundColor={hex(hueOf(agentType))}>{'  '}</Text>}
85        {agentType !== null && <Text> </Text>}
86        <Box flexGrow={1} flexShrink={1}>
87          <Text color={colour} wrap="truncate-end">
88            {what}
89          </Text>
90        </Box>
91        {note ? <Text color={colour} dimColor={!isErrored}>{`  ${clip(note, 60)}`}</Text> : null}
92      </Box>
93    )
94  })
95
96  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
97    if (e.surface !== 'terminal' || e.props.isExpanded || !isLedger(e.props.tool) || (options.keepDiffs !== false && KEEP_RESULT.has(e.props.tool))) {
98      return next(e)
99    }
100
101    const { Box } = $.ui.resolve(e)
102
103    return <Box />
104  })
105
106  on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
107    if (e.surface !== 'terminal' || e.props.isExpanded || e.props.isCallsOnly !== true || e.props.calls.length === 0) {
108      return next(e)
109    }
110
111    const { Text } = $.ui.resolve(e)
112    const isRunning = e.props.calls.some(call => call.isRunning)
113
114    return (
115      <Text wrap="truncate-end">
116        <Text color={isRunning ? ACCENT : undefined}>{isRunning ? '●' : '✓'}</Text>
117        {` ${groupLine(e.props.calls)}`}
118      </Text>
119    )
120  })
121}
122
hooks/avatar.ts 95 lines
1// A 6×6 mirrored identicon per agent: colour from its type, pattern from its id.
2// Drawn as a Raster of 6 columns × 3 rows, each cell two pixels high.
3
4const HUES: Record<string, number> = {
5  Explore: 0x4fa3a5,
6  Plan: 0x6a8fd8,
7  'general-purpose': 0xc9a66b,
8  fork: 0x7faf6a,
9}
10
11const SPARE = [0x9b84d6, 0xcf7f9a, 0x7faf6a, 0x4fa3a5, 0x6a8fd8, 0xc9a66b]
12
13const DEFAULT = 0x01000000
14const UPPER = 0x2580
15const LOWER = 0x2584
16const FULL = 0x2588
17const SPACE = 0x20
18
19export const COLUMNS = 6
20export const ROWS = 3
21
22export function hash(text: string): number {
23  let h = 2166136261
24
25  for (let i = 0; i < text.length; i++) {
26    h ^= text.charCodeAt(i)
27    h = Math.imul(h, 16777619)
28  }
29
30  h ^= h >>> 15
31  h = Math.imul(h, 0x2c1b3c6d)
32  h ^= h >>> 12
33
34  return h >>> 0
35}
36
37export function hueOf(type: string): number {
38  return HUES[type] ?? SPARE[hash(type) % SPARE.length]!
39}
40
41export const hex = (color: number) => '#' + color.toString(16).padStart(6, '0')
42
43function dim(color: number): number {
44  const scale = (shift: number) => Math.round(((color >> shift) & 0xff) * 0.45) << shift
45
46  return scale(16) | scale(8) | scale(0)
47}
48
49export function pixels(id: string, type: string): boolean[][] {
50  const h = hash(id + ':' + type)
51
52  return Array.from({ length: 6 }, (_, y) =>
53    Array.from({ length: 6 }, (_, x) => ((h >>> (y * 3 + (x < 3 ? x : 5 - x))) & 1) === 1),
54  )
55}
56
57const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
58
59function base64(bytes: Uint8Array): string {
60  let out = ''
61
62  for (let i = 0; i < bytes.length; i += 3) {
63    const a = bytes[i]!
64    const b = bytes[i + 1]
65    const c = bytes[i + 2]
66    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
67    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
68    out += b === undefined ? '=' : B64[(n >> 6) & 63]!
69    out += c === undefined ? '=' : B64[n & 63]!
70  }
71
72  return out
73}
74
75// The Raster's `cells`: one [codePoint, foreground, background] triplet per cell.
76export function avatarCells(id: string, type: string, isDim: boolean): string {
77  const grid = pixels(id, type)
78  const color = isDim ? dim(hueOf(type)) : hueOf(type)
79  const words = new Uint32Array(COLUMNS * ROWS * 3)
80
81  for (let row = 0; row < ROWS; row++) {
82    for (let x = 0; x < COLUMNS; x++) {
83      const top = grid[row * 2]![x]!
84      const bottom = grid[row * 2 + 1]![x]!
85      const glyph = top && bottom ? FULL : top ? UPPER : bottom ? LOWER : SPACE
86      const at = (row * COLUMNS + x) * 3
87      words[at] = glyph
88      words[at + 1] = glyph === SPACE ? DEFAULT : color
89      words[at + 2] = DEFAULT
90    }
91  }
92
93  return base64(new Uint8Array(words.buffer))
94}
95
hooks/describe.ts 269 lines
1type Input = { tool: string } & Record<string, unknown>
2
3export type FileDiff = { file: string; added: number; removed: number }
4
5export type TurnStat = { durationMs: number; tools: number; files: number; added: number; removed: number }
6
7const HOME = /^\/(Users|home)\/[^/]+/
8
9export function relative(path: string, cwd: string): string {
10  if (cwd && path.startsWith(cwd + '/')) {
11    return path.slice(cwd.length + 1)
12  }
13
14  return path.replace(HOME, '~')
15}
16
17export function clip(text: string, max: number): string {
18  const line = text.split('\n')[0]!.replace(/\s+/g, ' ').trim()
19
20  return line.length > max ? line.slice(0, max - 1) + '…' : line
21}
22
23function str(input: Input, key: string): string {
24  const value = input[key]
25
26  return typeof value === 'string' ? value : ''
27}
28
29// What the main loop is doing, as one short present-tense phrase.
30export function describe(input: Input, cwd: string): string {
31  const file = relative(str(input, 'file_path') || str(input, 'notebook_path'), cwd)
32
33  switch (input.tool) {
34    case 'Read':
35      return `Reading ${file}`
36    case 'Edit':
37    case 'MultiEdit':
38    case 'NotebookEdit':
39      return `Editing ${file}`
40    case 'Write':
41      return `Writing ${file}`
42    case 'Bash':
43      return `Running ${clip(str(input, 'command'), 60)}`
44    case 'WebFetch':
45      return `Fetching ${clip(str(input, 'url').replace(/^https?:\/\//, ''), 50)}`
46    case 'WebSearch':
47      return `Searching the web for "${clip(str(input, 'query'), 40)}"`
48    case 'Agent': {
49      const type = str(input, 'subagent_type') || 'agent'
50
51      return `Waiting on ${type}: ${clip(str(input, 'description'), 40)}`
52    }
53    case 'Skill':
54      return `Using skill ${str(input, 'skill')}`
55    case 'TodoWrite':
56    case 'TaskCreate':
57    case 'TaskUpdate':
58      return 'Updating tasks'
59  }
60
61  const mcp = /^mcp__(.+?)__(.+)$/.exec(input.tool)
62
63  if (mcp) {
64    return `Calling ${mcp[1]} ${mcp[2]}`
65  }
66
67  return `Using ${input.tool}`
68}
69
70// The tool's short verb and what it acts on, for a ledger-style row.
71export function target(input: Input, cwd: string): { verb: string; what: string } {
72  const file = relative(str(input, 'file_path') || str(input, 'notebook_path'), cwd)
73
74  switch (input.tool) {
75    case 'Read':
76    case 'Write':
77      return { verb: input.tool, what: file }
78    case 'Edit':
79    case 'MultiEdit':
80    case 'NotebookEdit':
81      return { verb: 'Edit', what: file }
82    case 'Bash':
83      return { verb: 'Bash', what: clip(str(input, 'command'), 200) }
84    case 'WebFetch':
85      return { verb: 'Fetch', what: clip(str(input, 'url').replace(/^https?:\/\//, ''), 200) }
86    case 'WebSearch':
87      return { verb: 'Search', what: `"${clip(str(input, 'query'), 200)}" on the web` }
88    case 'Agent':
89      return { verb: 'Agent', what: `${str(input, 'subagent_type') || 'agent'} · ${clip(str(input, 'description'), 200)}` }
90    case 'Skill':
91      return { verb: 'Skill', what: str(input, 'skill') }
92  }
93
94  const mcp = /^mcp__(.+?)__(.+)$/.exec(input.tool)
95
96  if (mcp) {
97    return { verb: mcp[1]!, what: mcp[2]! }
98  }
99
100  const first = Object.entries(input).find(([key, value]) => key !== 'tool' && typeof value === 'string')
101
102  return { verb: input.tool, what: first ? clip(String(first[1]), 200) : '' }
103}
104
105type Ran = { result?: unknown; text?: string; isError?: boolean; deny?: string }
106
107// One plain summary of a finished call: lines read, diff size, the first error line.
108export function noteOf(tool: string, ran: Ran): string {
109  if (ran.deny !== undefined) {
110    return clip(`refused: ${ran.deny}`, 80)
111  }
112
113  if (ran.isError) {
114    const lines = (ran.text ?? '').split('\n').map(one => one.trim())
115    const code = lines.map(one => /^(?:error: )?exit code (\d+)/i.exec(one)).find(Boolean)
116    const first = lines.find(one => one && !/^(?:error: )?exit code \d+/i.test(one))
117    const parts = [code ? `exit ${code[1]}` : '', first ?? (code ? '' : 'failed')].filter(Boolean)
118
119    return clip(parts.join(' · '), 80)
120  }
121
122  const r = (ran.result ?? {}) as Record<string, unknown>
123
124  if (tool === 'Read') {
125    const file = r.file as { numLines?: number } | undefined
126
127    return typeof file?.numLines === 'number' ? `${file.numLines} lines` : ''
128  }
129
130  const diffs = diffsOf(tool, ran.result)
131
132  if (tool === 'Write' && r.type === 'create' && diffs[0]) {
133    return `new · ${diffs[0].added} lines`
134  }
135
136  if (diffs.length) {
137    const added = diffs.reduce((sum, d) => sum + d.added, 0)
138    const removed = diffs.reduce((sum, d) => sum + d.removed, 0)
139
140    return removed ? `+${added} −${removed}` : `+${added}`
141  }
142
143  return ''
144}
145
146type Hunk = { lines: string[] }
147
148function count(file: string, hunks: readonly Hunk[]): FileDiff {
149  let added = 0
150  let removed = 0
151
152  for (const hunk of hunks) {
153    for (const line of hunk.lines) {
154      if (line.startsWith('+')) added += 1
155      else if (line.startsWith('-')) removed += 1
156    }
157  }
158
159  return { file, added, removed }
160}
161
162// The files a finished call changed, with line counts, from the tool's own result.
163export function diffsOf(tool: string, result: unknown): FileDiff[] {
164  if (!result || typeof result !== 'object') {
165    return []
166  }
167
168  const r = result as Record<string, unknown>
169
170  if ((tool === 'Edit' || tool === 'Write') && typeof r.filePath === 'string') {
171    if (tool === 'Write' && r.type === 'create' && typeof r.content === 'string') {
172      return [{ file: r.filePath, added: r.content.replace(/\n$/, '').split('\n').length, removed: 0 }]
173    }
174
175    return [count(r.filePath, (r.structuredPatch as Hunk[] | undefined) ?? [])]
176  }
177
178  if (tool === 'Bash' && r.bashEditDiff && typeof r.bashEditDiff === 'object') {
179    const files = (r.bashEditDiff as { files?: { filePath: string; hunks: Hunk[] }[] }).files ?? []
180
181    return files.map(one => count(one.filePath, one.hunks))
182  }
183
184  return []
185}
186
187export function duration(ms: number): string {
188  const s = Math.round(ms / 1000)
189
190  if (s < 60) return `${s}s`
191  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
192
193  return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
194}
195
196const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
197
198export function turnLine(durationMs: number, stat: TurnStat | undefined): string {
199  const parts = [`✓ Done in ${duration(durationMs)}`]
200
201  if (stat) {
202    parts.push(stat.tools ? plural(stat.tools, 'tool') : 'no tools')
203    if (stat.files) parts.push(`${plural(stat.files, 'file')} changed`)
204    if (stat.added || stat.removed) parts.push(`+${stat.added} −${stat.removed}`)
205  }
206
207  return parts.join(' · ')
208}
209
210// The turn whose recorded length is nearest to the line's, within 1.5 s.
211export function closest(list: readonly TurnStat[], durationMs: number): TurnStat | undefined {
212  let best: TurnStat | undefined
213  let gap = 1500
214
215  for (const stat of list) {
216    const d = Math.abs(stat.durationMs - durationMs)
217
218    if (d <= gap) {
219      best = stat
220      gap = d
221    }
222  }
223
224  return best
225}
226
227export function modelLabel(model: string): string {
228  const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(model)
229
230  return m ? `${m[1]}-${m[2]}.${m[3]}` : model.replace(/^claude-/, '')
231}
232
233type Usage = {
234  context?: { percent?: number; tokens?: number; window?: number }
235  rateLimits?: readonly { kind: string; percentUsed: number }[]
236  cost?: { usd: number }
237}
238
239// 950, 76k, 1M, 1.2M: token counts the way the status line reads them.
240export function tokenCount(n: number): string {
241  if (n < 1000) return String(n)
242  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
243
244  return `${Number((n / 1_000_000).toFixed(1))}M`
245}
246
247export function statusLine(model: string, usage: Usage, effort?: string | number, warnAt = 75): string {
248  const parts = [effort === undefined ? modelLabel(model) : `${modelLabel(model)} ${effort}`]
249  const pct = usage.context?.percent
250  const tokens = usage.context?.tokens
251  const window = usage.context?.window
252
253  if (pct !== undefined) {
254    const fill = tokens !== undefined && window ? `context ${tokenCount(tokens)}/${tokenCount(window)} (${pct}%), ${tokenCount(Math.max(0, window - tokens))} left` : `context ${pct}%`
255    parts.push(pct >= warnAt ? `${fill}, compact soon` : fill)
256  }
257
258  for (const limit of usage.rateLimits ?? []) {
259    if (limit.kind === 'five_hour') parts.push(`5h limit ${Math.round(limit.percentUsed)}%`)
260    if (limit.kind === 'seven_day') parts.push(`week limit ${Math.round(limit.percentUsed)}%`)
261  }
262
263  if (usage.cost) {
264    parts.push(`$${usage.cost.usd.toFixed(2)}`)
265  }
266
267  return parts.join(' · ')
268}
269