Terminal transcript in three levels: your prompt in a tinted blue bar, tool calls as one dim line each (press to see output), Claude's reply at full contrast…

A calmer Claude Code terminal transcript, in three levels: your prompt, Claude's reply, and the tool calls in between, each easy to tell apart at a glance.

▌ you bar and a rule above them, labelled with the turn's start time, its length and how many tools and agents it used./tui fullscreen) the whole line is clickable and shows the output; Read, Grep and Glob output shows inline.claude label and a bold first sentence.ctrl+o still shows Claude Code's stock view. Colours are ANSI names, so they follow your terminal's colour scheme.
Windows only: path handling and links assume Windows paths and Explorer. Draws in the terminal, not in the desktop app.
Status: in daily use. File links and code headers are the newest parts and haven't been tried live yet.
claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install readable@ben-modshooks/register.tsx 322 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import { linkPaths, openCommand } from './paths'
4
5// Whether a path exists, cached for the session: replies redraw often.
6const seen = new Map<string, boolean>()
7async function existsCached($: { fs: { exists(p: string): Promise<boolean> } }, p: string): Promise<boolean> {
8 const hit = seen.get(p)
9 if (hit !== undefined) return hit
10 const ok = await $.fs.exists(p).catch(() => false)
11 seen.set(p, ok)
12 return ok
13}
14
15// The user's home folder, for ~/ paths; asked of Windows once.
16let home: string | undefined
17async function homeDir($: { process: { run(cmd: string[]): Promise<{ stdout: string }> } }): Promise<string> {
18 if (home === undefined) {
19 const r = await $.process.run(['cmd.exe', '/d', '/c', 'echo %USERPROFILE%']).catch(() => ({ stdout: '' }))
20 home = r.stdout.trim() || 'C:' + String.fromCharCode(92) + 'Users'
21 }
22 return home
23}
24
25// Colours are ANSI names, so they come from the Windows Terminal scheme (GitHub Light by day,
26// GitHub Dark after f.lux flips) rather than from a palette of our own. No background tint on
27// the prompt: Claude's theme can be dark under a light terminal, which drew a black bar.
28const YOU = 'blue'
29const CLAUDE = 'magenta'
30const FAILED = 'red'
31const DONE = 'green'
32const RUNNING = 'yellow'
33
34// Tool calls whose output you opened, by tool_use_id.
35const open = atom({ plugin: 'readable', key: 'open' } as const, [] as string[])
36
37// One entry per main-loop turn, keyed by the prompt that started it, for the turn divider.
38type Turn = { text: string; at: number; ms?: number; tools: number; agents: number }
39const turns = atom({ plugin: 'readable', key: 'turns' } as const, [] as Turn[])
40const MAX_TURNS = 200
41const AGENT_TOOLS = new Set(['Agent', 'Task'])
42
43/** "14:32", local time. */
44export function clock(at: number): string {
45 const d = new Date(at)
46 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
47}
48
49/** "48s", "3m 05s", "1h 02m". */
50export function duration(ms: number): string {
51 const s = Math.round(ms / 1000)
52 if (s < 60) return `${s}s`
53 const m = Math.floor(s / 60)
54 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
55 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
56}
57
58/** The label on the rule above a prompt: when the turn began, how long it ran, what it used. */
59export function dividerLabel(turn: Turn | undefined): string {
60 if (!turn) return ''
61 const parts = [clock(turn.at), turn.ms === undefined ? 'running' : duration(turn.ms)]
62 if (turn.tools) parts.push(`${turn.tools} tool${turn.tools === 1 ? '' : 's'}`)
63 if (turn.agents) parts.push(`${turn.agents} agent${turn.agents === 1 ? '' : 's'}`)
64 return parts.join(' · ')
65}
66
67const same = (a: string, b: string) => a.trim() === b.trim()
68const isSubagent = (e: unknown) => Boolean((e as { agentId?: string }).agentId)
69
70// Input fields that name what a call acts on, most telling first.
71const SUBJECT_KEYS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'skill', 'description', 'path', 'prompt']
72const MAX_SUBJECT = 60
73const FALLBACK_COLUMNS = 120
74
75/** The one argument worth showing for a tool call, on one line and clipped. */
76export function subjectOf(input: unknown, max = MAX_SUBJECT): string {
77 if (typeof input !== 'object' || input === null) return ''
78 const fields = input as Record<string, unknown>
79 const key = SUBJECT_KEYS.find(k => typeof fields[k] === 'string' && fields[k] !== '')
80 const raw = key !== undefined ? String(fields[key]) : ''
81 const one = raw.replace(/\s+/g, ' ').trim()
82 return one.length > max ? one.slice(0, max - 1) + '…' : one
83}
84
85/** Word-wraps text to a width, so every row can carry the bar. */
86export function wrapLines(text: string, width: number): string[] {
87 const out: string[] = []
88 for (const para of text.split('\n')) {
89 let row = ''
90 for (const word of para.split(/\s+/).filter(w => w !== '')) {
91 if (row === '') row = word
92 else if (row.length + 1 + word.length <= width) row += ' ' + word
93 else { out.push(row); row = word }
94 while (row.length > width) { out.push(row.slice(0, width)); row = row.slice(width) }
95 }
96 out.push(row)
97 }
98 return out
99}
100
101export type Segment = { kind: 'prose' | 'code'; text: string; lang: string }
102
103/** Splits a reply into prose and closed fenced code blocks; an unclosed fence stays prose. */
104export function segments(text: string): Segment[] {
105 const out: Segment[] = []
106 let at = 0
107 for (const m of text.matchAll(/^```([\w+#.-]*)[^\n]*\n([\s\S]*?)^```[ \t]*$/gm)) {
108 if (m.index! > at) out.push({ kind: 'prose', text: text.slice(at, m.index), lang: '' })
109 out.push({ kind: 'code', text: m[2]!.replace(/\n$/, ''), lang: m[1]!.toLowerCase() })
110 at = m.index! + m[0].length
111 }
112 if (at < text.length) out.push({ kind: 'prose', text: text.slice(at), lang: '' })
113 return out.filter(s => s.kind === 'code' || s.text.trim() !== '')
114}
115
116/** The first line number a Read call asked for, so its lines are numbered right. */
117export function readOffset(input: unknown): number {
118 const o = (input ?? {}) as { offset?: unknown }
119 return typeof o.offset === 'number' && o.offset > 0 ? o.offset : 1
120}
121
122/** Bolds a reply's opening sentence, when the reply opens with plain prose. */
123export function boldLead(text: string): string {
124 const m = /^([A-Z][^\n*`#>|[\]]{0,240}?[.!?])(\s|$)/.exec(text)
125 const lead = m?.[1]
126 return lead ? `**${lead}**${text.slice(lead.length)}` : text
127}
128
129/**
130 * Turns markdown links to local paths into file: links, which the terminal can press;
131 * a link to a bare relative path is otherwise drawn as plain text. A :line suffix is dropped.
132 */
133export function linkify(text: string, cwd: string): string {
134 const base = cwd.split(String.fromCharCode(92)).join('/').replace(/\/$/, '')
135 return text.replace(/\]\(([^)\s]+)\)/g, (whole, href: string) => {
136 if (/^(https?:|file:|mailto:|#)/i.test(href)) return whole
137 const path = href.replace(/:\d+(-\d+)?$/, '').split(String.fromCharCode(92)).join('/')
138 const abs = /^[A-Za-z]:\//.test(path) ? path : `${base}/${path.replace(/^\.\//, '')}`
139 return `](file:///${encodeURI(abs)})`
140 })
141}
142
143/** The Windows path a file: link points at, for explorer.exe. */
144export function pathOfFileHref(href: string): string {
145 return decodeURI(href.replace(/^file:\/\/\/?/i, '')).split('/').join(String.fromCharCode(92))
146}
147
148// Reads and searches are drawn as a group, which has no result row of its own, so their
149// output is drawn under the tool line when opened. Other tools open the engine's result row.
150const INLINE_OUTPUT = new Set(['Read', 'Grep', 'Glob'])
151const INLINE_LINES = 12
152
153/** The text a Read, Grep or Glob call returned, as far as its stored output says. */
154export function inlineOutput(tool: string, output: unknown): string[] {
155 if (typeof output === 'string') return output.split('\n')
156 if (typeof output !== 'object' || output === null) return []
157 const o = output as Record<string, unknown>
158 const file = o.file as Record<string, unknown> | undefined
159 if (tool === 'Read' && typeof file?.content === 'string') return file.content.split('\n')
160 if (typeof o.content === 'string' && o.content !== '') return o.content.split('\n')
161 if (Array.isArray(o.filenames)) return o.filenames.map(String)
162 return []
163}
164
165// The full ctrl+o view is left to the engine. Only prompt rows say whether it is open,
166// so their flag is mirrored here for the tool rows.
167let expanded = false
168
169export const register: Register = on => {
170 // Turn stats for the divider, main loop only: a subagent's events carry agentId.
171 on('turn.start', async ($, e, next) => {
172 if (!isSubagent(e)) {
173 await update($, turns, list => [...(list ?? []), { text: e.text, at: Date.now(), tools: 0, agents: 0 }].slice(-MAX_TURNS))
174 }
175 return next(e)
176 })
177
178 on('tool.call', async ($, e, next) => {
179 if (!isSubagent(e)) {
180 const tool = String((e as { tool?: unknown }).tool ?? '')
181 await update($, turns, list => {
182 const all = list ?? []
183 const last = all[all.length - 1]
184 if (!last || last.ms !== undefined) return all
185 return [...all.slice(0, -1), { ...last, tools: last.tools + 1, agents: last.agents + (AGENT_TOOLS.has(tool) ? 1 : 0) }]
186 })
187 }
188 return next(e)
189 // A tool call must never fail because the count did.
190 }).catch(($, e, next) => next(e))
191
192 on('turn.complete', async ($, e, next) => {
193 if (!isSubagent(e)) {
194 await update($, turns, list => {
195 const all = list ?? []
196 const last = all[all.length - 1]
197 return last && last.ms === undefined ? [...all.slice(0, -1), { ...last, ms: e.durationMs }] : all
198 })
199 $.ui.invalidate('ui.render')
200 }
201 return next(e)
202 })
203
204 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => {
205 if (e.surface !== 'terminal') return next(e)
206 if (e.props.isExpanded !== expanded) {
207 expanded = e.props.isExpanded
208 $.ui.invalidate('ui.render')
209 }
210 if (expanded) return next(e)
211 const { Box, Text } = $.ui.resolve(e)
212 const width = Math.max(20, (e.viewport?.columns ?? FALLBACK_COLUMNS) - 4)
213 // 7 columns go to the bar and the "you" label.
214 const lines = wrapLines(e.props.text.replace(/^\s*\n/, '').trimEnd(), width - 7)
215 const all = await read($, turns)
216 const label = dividerLabel([...all].reverse().find(t => same(t.text, e.props.text)))
217 const rule = label ? `── ${label} ${'─'.repeat(Math.max(3, width - label.length - 4))}` : '─'.repeat(width)
218 return (
219 <Box flexDirection="column" marginTop={1} marginBottom={1}>
220 <Text dimColor>{rule}</Text>
221 {lines.map((line, i) => (
222 <Box key={`l${i}`} flexDirection="row">
223 <Text color={YOU}>▌ </Text>
224 <Text color={YOU} bold>{i === 0 ? 'you ' : ' '}</Text>
225 <Text>{(line === '' ? ' ' : line).padEnd(width - 7)}</Text>
226 </Box>
227 ))}
228 </Box>
229 )
230 })
231
232 // Each block of Claude's text gets a "claude" label line above it, so it reads apart from the
233 // ∴ thinking summary (which no hook draws). The text itself starts at the left edge.
234 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
235 if (e.surface !== 'terminal' || expanded) return next(e)
236 const { Box, Text, Markdown } = $.ui.resolve(e)
237 const cwd = await $.session.cwd()
238 // A click on a link: code and text open in VS Code (at the line, when given), documents in
239 // their app, folders in Explorer, programs are only revealed, web links go to the browser.
240 const { Code, Button } = $.ui.resolve(e)
241 const userHome = await homeDir($)
242 const onLinkPress = (link: { href: string }) => {
243 const cmd = /^file:/i.test(link.href) ? openCommand(link.href) : ['explorer.exe', link.href]
244 void $.process.run(cmd).catch(() => undefined)
245 }
246 // Prose goes to Markdown with its paths linked; each fenced block gets a header with its
247 // language and a copy button over highlighted code (prismantis' code idea, MIT).
248 const parts = await Promise.all(segments(boldLead(e.props.text)).map(async (seg, i) => seg.kind === 'code' ? (
249 <Box key={`c${i}`} flexDirection="column" marginTop={1}>
250 <Box flexDirection="row">
251 <Text dimColor>{`${seg.lang || 'code'} `}</Text>
252 <Button key={`copy-${i}`} plain dimColor label="⧉ copy" onPress={press => void $.ui.copy({ text: seg.text, surface: press.surface })} />
253 </Box>
254 <Code source={seg.text} language={seg.lang || undefined} />
255 </Box>
256 ) : (
257 <Markdown key={`m${i}`} text={await linkPaths(linkify(seg.text, cwd), cwd, userHome, p => existsCached($, p))} onLinkPress={onLinkPress} />
258 )))
259 return (
260 <Box flexDirection="column" marginTop={1}>
261 {/* A coloured full-width rule opens each reply, so it reads apart from the dim ∴ thinking
262 line above it, which no mod can restyle (probed 2026-10-19). */}
263 <Text color={CLAUDE} bold>{`── claude ${'─'.repeat(Math.max(3, Math.max(20, (e.viewport?.columns ?? FALLBACK_COLUMNS) - 4) - 10))}`}</Text>
264 {parts}
265 </Box>
266 )
267 })
268
269 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
270 if (e.surface !== 'terminal' || expanded) return next(e)
271 const { Box, Text, Button, Code } = $.ui.resolve(e)
272 const p = e.props
273 const failed = p.isErrored || p.isInterrupted
274 const dot = failed ? FAILED : p.isRunning || p.output === undefined ? RUNNING : DONE
275 const note = p.isInterrupted ? 'interrupted' : p.isErrored ? 'error' : ''
276 const id = p.tool_use_id
277 const isOpen = (await read($, open)).includes(id)
278 const hasOutput = p.output !== undefined && !failed
279 const inline = isOpen && INLINE_OUTPUT.has(p.tool) ? inlineOutput(p.tool, p.output) : []
280 const shown = inline.slice(0, INLINE_LINES)
281 return (
282 <Box flexDirection="column">
283 <Box flexDirection="row" marginLeft={2}>
284 <Text color={dot}>● </Text>
285 {hasOutput ? (
286 // The whole line is the toggle: click it to show or hide the output.
287 <Button key={`out-${id}`} plain dimColor label={`${p.tool} ${subjectOf(p.input)} ${isOpen ? '▾' : '▸'}`}
288 onPress={() => update($, open, ids => (ids ?? []).includes(id) ? (ids ?? []).filter(x => x !== id) : [...(ids ?? []), id])} />
289 ) : (
290 <Text wrap="wrap">
291 <Text bold dimColor>{p.tool}</Text>
292 <Text dimColor>{` ${subjectOf(p.input)}`}</Text>
293 {note ? <Text color={FAILED}>{` ${note}`}</Text> : null}
294 </Text>
295 )}
296 </Box>
297 {shown.length > 0 && p.tool === 'Read' ? (
298 // A file read is drawn highlighted by its type, numbered from where the read began.
299 <Box marginLeft={6}><Code source={shown.join('\n')} path={subjectOf(p.input, 400)} startLine={readOffset(p.input)} /></Box>
300 ) : shown.map((line, i) => (
301 <Text key={`o${i}`} dimColor>{` ${line}`}</Text>
302 ))}
303 {inline.length > INLINE_LINES ? <Text dimColor>{` … +${inline.length - INLINE_LINES} lines (ctrl+o)`}</Text> : null}
304 </Box>
305 )
306 })
307
308 // Results stay hidden unless the call failed or you opened it.
309 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
310 if (e.surface !== 'terminal' || expanded || e.props.isErrored) return next(e)
311 if ((await read($, open)).includes(e.props.tool_use_id)) return next(e)
312 const { Box } = $.ui.resolve(e)
313 return <Box />
314 })
315
316 // A grouped run of reads and searches unfolds, so each call gets its own one-line row.
317 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
318 if (e.surface !== 'terminal' || expanded) return next(e)
319 return next({ ...e, props: { ...e.props, isExpanded: true } })
320 })
321}
322hooks/paths.ts 106 lines1// File paths in Claude's replies become links, the open-paths idea (kiankhooban/open-paths, MIT),
2// rebuilt for readable: only paths that exist are linked, code and text open in VS Code at the
3// line, documents in their default app, and nothing is ever run.
4
5const BS = String.fromCharCode(92)
6
7/** Types that open in the editor (at the line when one is given), never in their default app. */
8const EDITOR = new Set([
9 'py', 'r', 'rmd', 'qmd', 'ipynb', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'json', 'jsonc', 'md', 'txt', 'csv', 'tsv',
10 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'xml', 'html', 'css', 'scss', 'sql', 'tex', 'bib', 'log', 'ahk', 'lua',
11 'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'cs', 'rb', 'sh', 'ps1', 'psm1', 'bat', 'cmd', 'do', 'env', 'gitignore',
12])
13/** Types that are only shown in Explorer, never launched. */
14const REVEAL = new Set(['exe', 'msi', 'lnk', 'dll', 'com', 'scr', 'vbs', 'reg'])
15
16export type Target = { path: string; line?: number; col?: number }
17
18const extOf = (p: string) => (/\.([A-Za-z0-9]+)$/.exec(p)?.[1] ?? '').toLowerCase()
19
20/** Splits `path:12:3` into the path and its position. */
21export function splitPosition(raw: string): Target {
22 const m = /^(.*?):(\d+)(?::(\d+))?$/.exec(raw)
23 if (!m || /^[A-Za-z]$/.test(m[1]!)) return { path: raw }
24 return { path: m[1]!, line: Number(m[2]), col: m[3] ? Number(m[3]) : undefined }
25}
26
27/** True when a string reads as a file path rather than prose or code. */
28export function looksLikePath(s: string): boolean {
29 if (s.length < 3 || s.length > 260 || /\s{2}|[<>|*?"]/.test(s) || /^[a-z]+:\/\//i.test(s)) return false
30 const { path } = splitPosition(s)
31 if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('~/') || path.startsWith('~' + BS)) return true
32 // Relative: needs a separator, or a file name with an extension.
33 return /[\\/]/.test(path) ? !/\s/.test(path) : /^[\w.-]+\.[A-Za-z0-9]{1,6}$/.test(path) && !/^\d/.test(path)
34}
35
36/** The absolute Windows path a written path points at. */
37export function absolute(path: string, cwd: string, home: string): string {
38 let p = path.split('/').join(BS)
39 if (p.startsWith('~' + BS)) p = home.replace(/[\\/]$/, '') + p.slice(1)
40 else if (!/^[A-Za-z]:\\/.test(p)) p = cwd.replace(/[\\/]$/, '') + BS + p.replace(/^\.\\/, '')
41 return p
42}
43
44export function hrefOf(abs: string, t: Target): string {
45 const pos = t.line ? `#L${t.line}${t.col ? 'C' + t.col : ''}` : ''
46 return `file:///${encodeURI(abs.split(BS).join('/'))}${pos}`
47}
48
49/** What a press on a file link runs: VS Code at the line, the default app, or an Explorer reveal. */
50export function openCommand(href: string): string[] {
51 const m = /^file:\/\/\/?([^#]*)(?:#L(\d+)(?:C(\d+))?)?$/i.exec(href)
52 if (!m) return ['explorer.exe', href]
53 const fwd = decodeURI(m[1]!)
54 const win = fwd.split('/').join(BS)
55 const ext = extOf(fwd)
56 if (EDITOR.has(ext) || (m[2] && !REVEAL.has(ext))) {
57 const pos = m[2] ? `:${m[2]}${m[3] ? ':' + m[3] : ''}` : ''
58 return ['explorer.exe', `vscode://file/${fwd}${pos}`]
59 }
60 if (REVEAL.has(ext)) return ['explorer.exe', `/select,${win}`]
61 return ['explorer.exe', win]
62}
63
64// Bare paths in prose: absolute (C:\ or C:/), home (~/) or relative with a separator, then an
65// optional :line:col. Trailing punctuation is trimmed afterwards.
66const BARE = /(?<![\w`/\\[(])((?:[A-Za-z]:[\\/]|~[\\/])[^\s`'"<>|*?()[\]]+|(?:\.{1,2}[\\/])?[\w.-]+(?:[\\/][\w.-]+)+(?::\d+(?::\d+)?)?)/g
67
68type Found = { start: number; end: number; raw: string; code: boolean }
69
70/** Path candidates in a reply, outside fenced blocks and existing links. */
71export function candidates(text: string): Found[] {
72 const out: Found[] = []
73 // Mask what must not be touched: fenced blocks, markdown links, autolinks.
74 const masked = text.replace(/```[\s\S]*?```|\[[^\]]*\]\([^)]*\)|<https?:[^>]*>|https?:\/\/\S+/g, m => ' '.repeat(m.length))
75 for (const m of masked.matchAll(/`([^`\n]+)`/g)) {
76 if (looksLikePath(m[1]!.trim())) out.push({ start: m.index!, end: m.index! + m[0].length, raw: m[1]!.trim(), code: true })
77 }
78 const noCode = masked.replace(/`[^`\n]+`/g, m => ' '.repeat(m.length))
79 for (const m of noCode.matchAll(BARE)) {
80 const raw = m[1]!.replace(/[.,;:!?)\]]+$/, '')
81 if (looksLikePath(raw) && /[\\/]/.test(raw)) out.push({ start: m.index!, end: m.index! + raw.length, raw, code: false })
82 }
83 return out.sort((a, b) => a.start - b.start)
84}
85
86/** Rewrites the reply so each path that exists is a link; `exists` gets absolute paths. */
87export async function linkPaths(text: string, cwd: string, home: string, exists: (abs: string) => Promise<boolean>): Promise<string> {
88 const found = candidates(text)
89 if (!found.length) return text
90 const checks = await Promise.all(found.map(async f => {
91 const t = splitPosition(f.raw)
92 const abs = absolute(t.path, cwd, home)
93 return (await exists(abs).catch(() => false)) ? hrefOf(abs, t) : null
94 }))
95 let out = ''
96 let at = 0
97 found.forEach((f, i) => {
98 const href = checks[i]
99 if (!href || f.start < at) return
100 const shown = f.code ? '`' + f.raw + '`' : f.raw
101 out += text.slice(at, f.start) + `[${shown}](${href})`
102 at = f.end
103 })
104 return out + text.slice(at)
105}
106types/index.d.ts 4 lines1export type OpenIds = string[]
2export type Turns = { text: string; at: number; ms?: number; tools: number; agents: number }[]
3declare module 'claude-code' { interface PluginState { readable: { open: OpenIds; turns: Turns } } }
4