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

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.
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.
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.
{ "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.
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.
<sub>MIT · built with Claude Code</sub>
hooks/register.tsx 137 lines1import { 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}
137hooks/format.ts 266 lines1/** 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}
266types/index.d.ts 9 lines1/** 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