SLOPSHOPPER

readout

Shows which files Read opened, which lines, and what each search looked for, on the folded tool line

newrowsguard
v0.2.0MITupdated 2026-10-04lucenity0/claude-readout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · readout
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⟨Claude Code's own drawing⟩ ⏺ 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
⟨Claude Code's own drawing⟩
README

claude-readout

Shows which files Claude Code read. The folded Read 3 files line names them instead, along with the lines it read and what it searched for.

<img src="assets/demo.svg" alt="The same turn twice. Claude Code folds six calls into Read 5 files, ran 1 shell command. readout names each file as it is read: src/{date.ts ×2, date.test.ts:1-60}, src/tz/{offsets.ts, rules.ts}, then the grep command on its own row" width="760">

It answers #21151. Verbose mode shows the names too, but it turns on more than names, and it doesn't reach VS Code. readout changes only the folded line, on every surface, at no token cost.

&nbsp;

install

readout runs on function hooks, an early-access part of Claude Code. Turn them on in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Then install it and start a new session:

claude plugin marketplace add lucenity0/claude-readout
claude plugin install readout@claude-readout

To hack on it instead, clone it and load the folder with claude --plugin-dir ~/claude-readout.

Function hooks may change between releases. readout was tested on Claude Code 2.1.289.

&nbsp;

what it shows

Read                 the path, relative to the project or under ~, and the lines: date.test.ts:1-60
Grep, Glob           the pattern, and where it looked
Bash                 the command, one row each, wrapping when long
WebFetch, WebSearch  the url or the query

Calls in a row to the same tool share a line. Files in one folder gather as src/{a.ts, b.ts}, a file read twice shows ×2, and the call still running is dim until it lands. A line that runs past the window ends in +N more.

In terminals that draw links (iTerm2, Warp, Ghostty, WezTerm, kitty, VS Code), each path is a link: cmd-click it to open the file.

In the terminal, tool rows sit on a quiet band in softer text, so they stand apart from Claude's replies.

A call that failed is red, along with its reason. A call a plugin blocked says which one (blocked by <plugin>).

● Read  src/old.ts  File does not exist

ctrl+o and --verbose work as before.

&nbsp;

settings

{ "pluginConfigs": { "readout@claude-readout": { "options": { "mode": "inline", "links": "auto", "tint": "auto" } } } }

mode is inline (the default: one line per tool, as above) or expand, which unfolds every group into Claude Code's own row per call.

links is auto, on or off. A terminal that can't draw links prints each URL beside its name, so auto only turns them on in the terminals above.

tint is auto (a band to suit your light or dark theme), off, or a band color of your own such as #1d2128.

&nbsp;

contributing

claude plugin validate .
claude plugin test .

The labels and layout live in format.ts and the drawing in register.tsx. The demo is drawn with format.ts too: node --experimental-strip-types assets/demo.mts.

&nbsp;


<sub>MIT · built with Claude Code</sub>

Source 3 files
hooks/register.tsx 137 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { groupLines, shortReason, tintFor, wantsLinks } from './format'
5import type { Item } from './format'
6
7const reasons = atom({ plugin: 'readout', key: 'reasons' } as const, {})
8
9const DONE = '#4eba65'
10const FAILED = '#ff6b80'
11const KEPT_REASONS = 200
12
13export const register: Register = (on, options) => {
14  const isExpandMode = options.mode === 'expand'
15  let theme = 'dark'
16
17  on('session.start', async ($, e, next) => {
18    const row = (await $.config.list()).find(each => each.key === 'theme')
19    if (typeof row?.value === 'string') theme = row.value
20    return next(e)
21  })
22
23  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
24    const drawn = await next(e)
25    const tint = e.surface === 'terminal' ? tintFor(options.tint, theme) : undefined
26    if (tint === undefined) return drawn
27    const { Box } = $.ui.resolve(e)
28    return <Box flexDirection="column" backgroundColor={tint.band}>{drawn}</Box>
29  })
30
31  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
32    const drawn = await next(e)
33    const tint = e.surface === 'terminal' ? tintFor(options.tint, theme) : undefined
34    if (tint === undefined) return drawn
35    const { Box } = $.ui.resolve(e)
36    return <Box flexDirection="column" backgroundColor={tint.band}>{drawn}</Box>
37  })
38
39  on('tool.call', async ($, e, next) => {
40    const ran = await next(e)
41    const isDenied = ran.deny !== undefined
42    const message = ran.deny ?? (ran.isError === true ? ran.text : undefined)
43    if (message === undefined) return ran
44
45    const reason = shortReason(message, isDenied)
46    await update($, reasons, current => {
47      const kept = Object.entries(current).slice(-(KEPT_REASONS - 1))
48      return { ...Object.fromEntries(kept), [e.tool_use_id]: reason }
49    })
50
51    return ran
52  })
53
54  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
55    if (e.props.isExpanded) return next(e)
56    if (isExpandMode) return next({ ...e, props: { ...e.props, isExpanded: true } })
57
58    const why = await read($, reasons)
59    const cwd = await $.session.cwd()
60    const home = await $.env.get('HOME')
61    const calls = e.props.calls.map(call => {
62      const reason = call.tool_use_id === undefined ? undefined : why[call.tool_use_id]
63      return {
64        tool: call.tool,
65        input: call.input,
66        isErrored: call.isErrored || call.isInterrupted,
67        isRunning: call.isRunning,
68        ...(reason === undefined ? {} : { reason }),
69      }
70    })
71
72    const nameWidth = Math.max(...calls.map(call => call.tool.length))
73    const gutter = nameWidth + 4
74    const room = Math.max(12, (e.viewport?.columns ?? 80) - gutter - 2)
75    const lines = groupLines(calls, room, cwd, home)
76    if (lines === undefined) return next(e)
77
78    const { Box, Text, Link } = $.ui.resolve(e)
79    const hasLinks =
80      e.surface === 'terminal' &&
81      wantsLinks(options.links, await $.env.get('TERM_PROGRAM'), await $.env.get('TERM'))
82    const isRunning = calls.some(call => call.isRunning)
83    const isFailed = calls.length > 0 && calls.every(call => call.isErrored)
84    const bullet = isRunning ? { dimColor: true } : { color: isFailed ? FAILED : DONE }
85    const tint = e.surface === 'terminal' ? tintFor(options.tint, theme) : undefined
86    const band = tint === undefined ? {} : { backgroundColor: tint.band }
87    const ink = tint === undefined ? {} : { color: tint.ink }
88
89    const drawItem = (item: Item) => {
90      const name =
91        hasLinks && item.path !== undefined ? <Link href={`file://${encodeURI(item.path)}`}>{item.name}</Link> : item.name
92      const style = item.isErrored ? { color: FAILED } : item.isRunning ? { dimColor: true } : ink
93      return (
94        <Text {...style}>
95          {name}
96          {item.count > 1 ? <Text dimColor>{` ×${item.count}`}</Text> : ''}
97          {item.reason === undefined ? '' : `  ${item.reason}`}
98        </Text>
99      )
100    }
101
102    return (
103      <Box flexDirection="column" marginTop={1} {...band}>
104        {lines.map((line, index) => (
105          <Box flexDirection="row">
106            <Box width={gutter} flexShrink={0}>
107              <Text>
108                {index === 0 ? <Text {...bullet}>{'● '}</Text> : '  '}
109                <Text bold {...ink}>{(line.isFirstOfTool ? line.tool : '').padEnd(nameWidth)}</Text>
110                {'  '}
111              </Text>
112            </Box>
113            <Box flexGrow={1} flexShrink={1}>
114              <Text wrap={line.wraps ? 'wrap' : 'truncate-end'}>
115                {line.entries.map((entry, at) => (
116                  <Text>
117                    {at === 0 ? '' : ', '}
118                    {entry.folder === '' ? '' : `${entry.folder}{`}
119                    {entry.items.map((item, i) => (
120                      <Text>
121                        {i === 0 ? '' : ', '}
122                        {drawItem(item)}
123                      </Text>
124                    ))}
125                    {entry.folder === '' ? '' : '}'}
126                  </Text>
127                ))}
128                {line.more > 0 ? <Text dimColor>{` +${line.more} more`}</Text> : ''}
129              </Text>
130            </Box>
131          </Box>
132        ))}
133      </Box>
134    )
135  })
136}
137
hooks/format.ts 266 lines
1/** One call of a folded group, as much of it as a line needs. */
2export type Call = {
3  tool: string
4  input: unknown
5  isErrored: boolean
6  isRunning: boolean
7  reason?: string
8}
9
10/** One drawn name: a file, pattern or command, how often it came up, and its state. */
11export type Item = {
12  name: string
13  path?: string
14  count: number
15  isErrored: boolean
16  isRunning: boolean
17  reason?: string
18}
19
20/** Names that share a folder, drawn `folder/{a, b}`; `folder` is empty for one drawn whole. */
21export type Entry = { folder: string; items: Item[] }
22
23/** One row of the readout: a tool and what it touched, cut to fit or left to wrap. */
24export type Line = { tool: string; isFirstOfTool: boolean; entries: Entry[]; more: number; wraps: boolean }
25
26/** What a call touched: the words to show, and the file behind them when there is one. */
27export type Target = { label: string; path?: string; range?: string }
28
29const PATH_TOOLS: Record<string, string> = {
30  Read: 'file_path',
31  Edit: 'file_path',
32  Write: 'file_path',
33  NotebookEdit: 'notebook_path',
34  LS: 'path',
35}
36
37const QUERY_TOOLS: Record<string, string> = {
38  WebFetch: 'url',
39  WebSearch: 'query',
40  ToolSearch: 'query',
41}
42
43/** Terminals that draw OSC 8 hyperlinks; elsewhere a link prints its URL beside the name. */
44const LINKING_TERMINALS = ['iTerm.app', 'WezTerm', 'ghostty', 'vscode', 'WarpTerminal', 'Hyper', 'Tabby', 'rio']
45
46function field(input: unknown, key: string): unknown {
47  if (typeof input !== 'object' || input === null || !(key in input)) return undefined
48  return (input as Record<string, unknown>)[key]
49}
50
51function text(input: unknown, key: string): string | undefined {
52  const value = field(input, key)
53  return typeof value === 'string' && value.length > 0 ? value : undefined
54}
55
56function count(input: unknown, key: string): number | undefined {
57  const value = field(input, key)
58  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined
59}
60
61export function clip(line: string, length: number): string {
62  const flat = line.replace(/\s+/g, ' ').trim()
63  return flat.length > length ? `${flat.slice(0, Math.max(1, length - 1))}…` : flat
64}
65
66/** A path relative to the working directory, or under `~`, whichever reads shorter. */
67export function shortPath(path: string, cwd: string, home: string | undefined): string {
68  const base = cwd.endsWith('/') ? cwd : `${cwd}/`
69  if (path === cwd) return '.'
70  if (path.startsWith(base)) return path.slice(base.length)
71  if (home !== undefined && home !== '' && (path === home || path.startsWith(`${home}/`))) {
72    return `~${path.slice(home.length)}`
73  }
74  return path
75}
76
77/** The part of a file a Read took: `:40-79`, `:120-` or ` p.3-5`; empty for the whole file. */
78export function rangeOf(input: unknown): string {
79  const pages = text(input, 'pages')
80  if (pages !== undefined) return ` p.${pages}`
81
82  const offset = count(input, 'offset')
83  const limit = count(input, 'limit')
84  if (offset === undefined && limit === undefined) return ''
85  const start = offset ?? 1
86  return limit === undefined ? `:${start}-` : `:${start}-${start + limit - 1}`
87}
88
89/** What a call touched, or undefined for a tool readout does not know. */
90export function targetOf(tool: string, input: unknown, cwd: string, home: string | undefined): Target | undefined {
91  const pathKey = PATH_TOOLS[tool]
92  if (pathKey !== undefined) {
93    const path = text(input, pathKey)
94    if (path === undefined) return undefined
95    const absolute = path.startsWith('/') ? path : `${cwd.replace(/\/$/, '')}/${path}`
96    const range = tool === 'Read' ? rangeOf(input) : ''
97    return { label: shortPath(absolute, cwd, home), path: absolute, ...(range === '' ? {} : { range }) }
98  }
99
100  if (tool === 'Grep' || tool === 'Glob') {
101    const pattern = text(input, 'pattern')
102    if (pattern === undefined) return undefined
103    const where = text(input, 'path')
104    const quoted = tool === 'Grep' ? `"${clip(pattern, 40)}"` : clip(pattern, 40)
105    return { label: where === undefined ? quoted : `${quoted} in ${shortPath(where, cwd, home)}` }
106  }
107
108  if (tool === 'Bash') {
109    const command = text(input, 'command')
110    return command === undefined ? undefined : { label: command.replace(/\s+/g, ' ').trim() }
111  }
112
113  const queryKey = QUERY_TOOLS[tool]
114  if (queryKey !== undefined) {
115    const query = text(input, queryKey)
116    return query === undefined ? undefined : { label: clip(query.replace(/^https?:\/\//, ''), 120) }
117  }
118
119  return undefined
120}
121
122/** The short form of a failure: who blocked it, or the error's first clause. */
123export function shortReason(message: string, isDenied: boolean): string {
124  const owner = /^([\w@.-]+):\s/.exec(message)?.[1]
125  if (isDenied) return owner === undefined ? 'blocked' : `blocked by ${owner}`
126
127  const first = message.split('\n').find(line => line.trim() !== '') ?? ''
128  return clip(first.replace(/<\/?[\w_-]+>/g, '').split(/[.;]\s/)[0] ?? '', 40)
129}
130
131/** Whether to draw paths as links: `on`, `off`, or `auto` for terminals known to draw them. */
132export function wantsLinks(setting: unknown, termProgram: string | undefined, term: string | undefined): boolean {
133  if (setting === 'on') return true
134  if (setting === 'off') return false
135  if (termProgram !== undefined && LINKING_TERMINALS.includes(termProgram)) return true
136  return term !== undefined && /kitty|ghostty|wezterm/.test(term)
137}
138
139/** The look of tool rows: a quiet band behind them and softer text, or nothing when off. */
140export type Tint = { band: string; ink: string }
141
142/** `off`, a band color of the person's own, or a quiet band and soft ink for the theme. */
143export function tintFor(setting: unknown, theme: string): Tint | undefined {
144  if (setting === 'off') return undefined
145  const isLight = theme.includes('light')
146  const ink = isLight ? '#5b6170' : '#a9aeb7'
147  if (typeof setting === 'string' && /^#[0-9a-f]{6}$/i.test(setting)) return { band: setting, ink }
148  return { band: isLight ? '#eef0f4' : '#16191f', ink }
149}
150
151export function itemWidth(item: Item): number {
152  return (
153    item.name.length +
154    (item.count > 1 ? ` ×${item.count}`.length : 0) +
155    (item.reason === undefined ? 0 : item.reason.length + 2)
156  )
157}
158
159/** Same name, same outcome: one item with a count. */
160function merge(items: Item[]): Item[] {
161  const merged: Item[] = []
162  for (const item of items) {
163    const same = merged.find(seen => seen.name === item.name && seen.isErrored === item.isErrored)
164    if (same === undefined) merged.push({ ...item })
165    else {
166      same.count += item.count
167      same.isRunning ||= item.isRunning
168    }
169  }
170  return merged
171}
172
173/** Names under one folder gather into `folder/{a, b}`, in the order each folder first came up. */
174export function foldFolders(items: Item[]): Entry[] {
175  const byFolder = new Map<string, Item[]>()
176  for (const item of items) {
177    const cut = item.name.lastIndexOf('/')
178    const folder = cut < 0 ? '' : item.name.slice(0, cut + 1)
179    byFolder.set(folder, [...(byFolder.get(folder) ?? []), item])
180  }
181
182  const entries: Entry[] = []
183  for (const [folder, members] of byFolder) {
184    if (folder === '' || members.length === 1) {
185      for (const member of members) entries.push({ folder: '', items: [member] })
186    } else {
187      entries.push({ folder, items: members.map(member => ({ ...member, name: member.name.slice(folder.length) })) })
188    }
189  }
190  return entries
191}
192
193/** Keeps what fits in `room` columns, by item, leaving space for `+N more`. */
194function fit(entries: Entry[], room: number): { entries: Entry[]; more: number } {
195  const total = entries.reduce((sum, entry) => sum + entry.items.length, 0)
196  const kept: Entry[] = []
197  let used = 0
198  let placed = 0
199
200  for (const entry of entries) {
201    const isFolded = entry.folder !== ''
202    const keptItems: Item[] = []
203    for (const item of entry.items) {
204      const opening = keptItems.length === 0 ? (kept.length === 0 ? 0 : 2) + entry.folder.length + (isFolded ? 2 : 0) : 2
205      const cost = opening + itemWidth(item)
206      const remaining = total - placed - 1
207      const tail = remaining === 0 ? 0 : ` +${remaining} more`.length
208      if (placed > 0 && used + cost + tail > room) {
209        if (keptItems.length > 0) kept.push({ ...entry, items: keptItems })
210        return { entries: kept, more: total - placed }
211      }
212      keptItems.push(item)
213      used += cost
214      placed += 1
215    }
216    kept.push({ ...entry, items: keptItems })
217  }
218
219  return { entries: kept, more: 0 }
220}
221
222/**
223 * Lays a run of calls out as rows: one per run of the same tool, its names
224 * merged, folded by folder and cut to `room` with `+N more`; one per Bash
225 * command, left to wrap. Undefined when no call has a label, so the engine's
226 * own count line stays.
227 */
228export function groupLines(calls: readonly Call[], room: number, cwd: string, home: string | undefined): Line[] | undefined {
229  const runs: Array<{ tool: string; items: Item[] }> = []
230  let isAnyLabelled = false
231
232  for (const call of calls) {
233    const target = targetOf(call.tool, call.input, cwd, home)
234    if (target !== undefined) isAnyLabelled = true
235    const item: Item = {
236      name: target === undefined ? '…' : `${target.label}${target.range ?? ''}`,
237      ...(target?.path === undefined ? {} : { path: target.path }),
238      count: 1,
239      isErrored: call.isErrored,
240      isRunning: call.isRunning,
241      ...(call.isErrored && call.reason !== undefined ? { reason: call.reason } : {}),
242    }
243    const last = runs[runs.length - 1]
244    if (last !== undefined && last.tool === call.tool) last.items.push(item)
245    else runs.push({ tool: call.tool, items: [item] })
246  }
247
248  if (!isAnyLabelled) return undefined
249
250  return runs.flatMap(({ tool, items }): Line[] => {
251    if (tool === 'Bash') {
252      return merge(items).map((item, index) => ({
253        tool,
254        isFirstOfTool: index === 0,
255        entries: [{ folder: '', items: [{ ...item, name: clip(item.name, room * 3) }] }],
256        more: 0,
257        wraps: true,
258      }))
259    }
260
261    const merged = merge(items)
262    const entries = PATH_TOOLS[tool] === undefined ? merged.map(item => ({ folder: '', items: [item] })) : foldFolders(merged)
263    return [{ tool, isFirstOfTool: true, ...fit(entries, room), wraps: false }]
264  })
265}
266
types/index.d.ts 9 lines
1/** Why a call failed, by its tool_use_id: `blocked by seatbelt`, `File does not exist`. */
2export type Reasons = Record<string, string>
3
4declare module 'claude-code' {
5  interface PluginState {
6    readout: { reasons: Reasons }
7  }
8}
9