SLOPSHOPPER

readable

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…

newrowsguardprocess
v0.6.1MITupdated 2026-10-23bencsmartucph/ben-mods/readable
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · readable
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ● Edit /work/app/src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ── claude ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ──────────────────────────── Done. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
── claude ──────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────── Done. I made refresh reject expired claims and added an audit call.
Tool row
● Edit /work/app/src/auth.ts
README

readable

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.

A readable turn: the rule with time, length and tool count, the 'you' bar, two one-line Read calls, the 'claude' label, a code block with a language header and copy button

  • Your prompts get a ▌ you bar and a rule above them, labelled with the turn's start time, its length and how many tools and agents it used.
  • Tool calls are one dim line each, with a status dot. In Claude Code's fullscreen layout (/tui fullscreen) the whole line is clickable and shows the output; Read, Grep and Glob output shows inline.
  • Claude's replies get a claude label and a bold first sentence.
  • File paths in replies become links when the file exists: code opens in VS Code at the line, documents in their default app, programs are only shown in Explorer, never run.
  • Code blocks get a language header and a copy button.

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.

Install

claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install readable@ben-mods
Source 3 files
hooks/register.tsx 322 lines
1import { 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}
322
hooks/paths.ts 106 lines
1// 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}
106
types/index.d.ts 4 lines
1export 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