Click a markdown, JSON or YAML file link in a reply to preview it in a side pane, drawn like a docs page

A Claude Code mod that previews markdown, JSON and YAML files in a side pane. Markdown is drawn like a docs page.
Mention a .md, .json or .yaml file in a conversation and Claude's reply turns it into a link. Click it and the file opens beside the transcript.
code spans and bare paths, relative or absolute) become links when the file exists. A plain click on a .md, .markdown, .json, .jsonc, .yaml or .yml` file opens the preview; any other file opens the way Claude Code opens a link. Files without an extension are not linked.> [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] and [!CAUTION] are drawn as coloured boxes.object, 12 keys, array of 340, or ✖ invalid JSON: and the parser's message; documents and top-level keys for YAML). A file too long to draw is cut cleanly, with a note.crlim finds Credit Limit), the best matches list under the field, Enter jumps to the first, and each match is a button that scrolls to it./preview <path> opens any file by hand.mermaid-ascii on your PATH, for diagrams ``sh go install github.com/AlexanderGrooff/mermaid-ascii@latest ``The repository is its own marketplace. Add it, then install the plugin:
claude plugin marketplace add abonckus/claude-code-file-preview
claude plugin install file-preview@file-preview
claude plugin update file-preview@file-preview picks up new versions.
To work on the mod instead, clone the repository and start Claude Code with it as a plugin folder (do not also install it, or it loads twice):
git clone https://github.com/abonckus/claude-code-file-preview
claude --plugin-dir ./claude-code-file-preview
To load the folder in every session, add it to CLAUDE_CODE_PLUGIN_DIRS.
Then ask Claude about a markdown file, or run /preview examples/sample.md (also examples/sample.json and examples/sample.yaml).
While the pane has the keyboard (click it, or ctrl+x tab from the prompt):
| Key | Does |
|---|---|
u | Scroll to the top |
s | Open search (the cursor starts in the field) |
Esc | Close search (it hands the keyboard back to the prompt) |
r | Refresh |
up / down, pageup / pagedown, home / end | Scroll (Claude Code's own pane keys) |
A pane button's hotkey must be one letter or digit, so search cannot be /.
To move between search matches without leaving the keyboard, bind Claude Code's focus actions (Tab / Shift+Tab by default) in ~/.claude/keybindings.json; Enter then opens the focused match:
{ "context": "PaneField", "bindings": { "ctrl+j": "abovePrompt:next", "ctrl+k": "abovePrompt:previous" } },
{ "context": "Pane", "bindings": { "ctrl+j": "abovePrompt:next", "ctrl+k": "abovePrompt:previous" } }
Claude Code's highlighter does not know every language. For one it does not, point file-preview at any command that can highlight it, with the highlighters option in ~/.claude/settings.json, under pluginConfigs and the plugin's id (file-preview@file-preview when installed from the marketplace, file-preview or file-preview@inline for a plugin loaded from a folder):
"pluginConfigs": {
"file-preview": {
"options": {
"highlighters": ["al: node \"/path/to/highlighter.mjs\""]
}
}
}
Each entry is <language>[, <language>…]: <command> [args…], the language being the code fence's name; double quotes group a path with spaces. For each code block in that language, the command is run with the code on stdin and must write a JSON array of [text, capture] spans to stdout, capture being a tree-sitter highlight name such as keyword.control or comment.line, or null. The spans must join back into the code. Anything else (a non-zero exit, other output) and the block is drawn by Claude Code's highlighter instead.
/preview there.claude plugin validate .
claude plugin test .
The pure logic lives in hooks/blocks.ts (markdown splitting, table fitting, code chunking), hooks/linkify.ts (file links), hooks/highlighters.ts (the highlighter setting and capture colours) and hooks/search.ts (fuzzy search), each with its own *.test.ts. hooks/pane.test.ts mounts the pane and checks the drawn tree for markdown, JSON and YAML files, the file watcher and the refresh button.
hooks/register.tsx 506 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Doc, Find, Mark, View } from '../types'
5import { linkify, PREVIEWABLE, toPath } from './linkify'
6import { blocks, chunk, clean, fit, GAP, plain } from './blocks'
7import type { Align, Block, Callout, Table } from './blocks'
8import { lines, parse, spansOf } from './highlighters'
9import type { Span } from './highlighters'
10import { search } from './search'
11
12const PANE = 'file-preview'
13const MAX = 10000 // Markdown element cap, per prose block
14const FILE_MAX = 1_000_000 // read cap; what is drawn is cut further below
15const MD_MAX = 60_000 // markdown drawn, kept well inside the engine's 100,000-character tree bound
16const DATA_BUDGET = 70_000 // JSON/YAML source characters drawn
17const PAD = 2 // side gutter, like a docs page
18const READ_MAX = 110 // reading width cap, columns
19const JUSTIFY = { left: 'flex-start', center: 'center', right: 'flex-end' } as const satisfies Record<Align, string>
20const CALLOUTS = {
21 note: { color: 'blue', icon: 'ℹ', label: 'Note' },
22 tip: { color: 'green', icon: '✓', label: 'Tip' },
23 important: { color: 'magenta', icon: '★', label: 'Important' },
24 warning: { color: 'yellow', icon: '▲', label: 'Warning' },
25 caution: { color: 'red', icon: '✖', label: 'Caution' },
26} as const satisfies Record<Callout, { color: string; icon: string; label: string }>
27const isAbs =(p: string) => /^([A-Za-z]:)?[\\/]/.test(p)
28const doc = atom({ plugin: 'file-preview', key: 'doc' } as const, { path: '', text: '', mtime: 0 } as Doc)
29const find = atom({ plugin: 'file-preview', key: 'find' } as const, { open: false, query: '' } as Find)
30// Bumped after every scroll so the pane redraws, its footer placed from the engine's
31// own `scroll` prop (a scroll this mod starts may not reach its own ui.scroll hook).
32const view = atom({ plugin: 'file-preview', key: 'view' } as const, { tick: 0 } as View)
33// The block a search jumped to, highlighted until the person does anything else.
34const mark = atom({ plugin: 'file-preview', key: 'mark' } as const, { block: null } as Mark)
35const redraw = ($: EngineInterface) => update($, view, v => ({ tick: v.tick + 1 }))
36const unmark = async ($: EngineInterface) => {
37 if ((await read($, mark)).block !== null) await update($, mark, () => ({ block: null }))
38}
39const WATCH_MS = 1500
40const MARK_BG = '#1f3a5f' // the jumped-to block's background
41
42// A block's searchable lines, as plain text.
43const blockLines = (b: Block): string[] =>
44 (b.kind === 'table'
45 ? [b.table.head, ...b.table.rows].map(r => r.join(' | '))
46 : (b.kind === 'code' || b.kind === 'mermaid' ? b.code : b.text).split('\n').map(plain)
47 )
48 .map(l => l.trim())
49 .filter(Boolean)
50
51// ponytail: module cache, lost on reload; fine, a redraw re-renders
52// Successes only: a failure is retried on the next draw and says why.
53const diagrams = new Map<string, string>()
54const renderMermaid = async ($: EngineInterface, code: string, width: number): Promise<{ art: string } | { error: string }> => {
55 const id = `${width}\n${code}`
56 const hit = diagrams.get(id)
57 if (hit) return { art: hit }
58 try {
59 const run = await $.process.run(['mermaid-ascii', '-f', '-', '--max-width', String(width)], {
60 stdin: code,
61 timeoutMs: 10_000,
62 })
63 const art = run.stdout.replace(/\s+$/, '')
64 if (run.exitCode !== 0 || !art) {
65 return { error: `exit ${run.exitCode}: ${run.stderr.trim().split('\n')[0] || 'no output'}` }
66 }
67 diagrams.set(id, art)
68 return { art }
69 } catch (err) {
70 return { error: String(err) }
71 }
72}
73
74// Header facts for a data file: its shape, or why it does not parse.
75const describeJson = (text: string, lang: string): string[] => {
76 if (lang === 'jsonc') return ['JSON with comments']
77 try {
78 const value: unknown = JSON.parse(text)
79 if (Array.isArray(value)) return [`array of ${value.length}`]
80 if (value && typeof value === 'object') return [`object, ${Object.keys(value).length} keys`]
81 return [typeof value]
82 } catch (err) {
83 return [`✖ invalid JSON: ${err instanceof Error ? err.message : String(err)}`]
84 }
85}
86const describeYaml = (text: string): string[] => {
87 const docs = text.split(/^---\s*$/m).filter(d => d.trim()).length
88 const keys = text.match(/^[^\s#\-][^:#]*:(\s|$)/gm)?.length ?? 0
89 return [docs > 1 ? `${docs} documents` : '', `${keys} top-level keys`]
90}
91
92// The `highlighters` setting, read at load (a change reloads the module).
93let highlighters = new Map<string, string[]>()
94const CODE_BUDGET = 40_000 // serialized characters of one coloured block; past it, Code draws it
95
96// Successes only, by command and source: a failing highlighter is tried again on the next draw.
97const highlighted = new Map<string, Span[]>()
98const external = async ($: EngineInterface, cmd: string[], source: string): Promise<Span[] | null> => {
99 const id = `${cmd.join('\0')}\n${source}`
100 const hit = highlighted.get(id)
101 if (hit) return hit
102 const run = await $.process.run(cmd, { stdin: source, timeoutMs: 15_000 }).catch(() => null)
103 const spans = run?.exitCode === 0 ? spansOf(run.stdout, source) : null
104 if (spans) highlighted.set(id, spans)
105 return spans
106}
107
108const mtime = ($: EngineInterface, path: string) => $.fs.stat(path).then(s => s.mtimeMs, () => 0)
109
110const load = async ($: EngineInterface, path: string) => {
111 let text: string
112 try {
113 text = await $.fs.read(path)
114 } catch (err) {
115 text = `_Could not read file:_ ${String(err)}`
116 }
117 if (text.length > FILE_MAX) text = `${text.slice(0, FILE_MAX)}\n\n_…truncated_`
118 await update($, doc, () => ({ path, text, mtime: 0 }))
119 const at = await mtime($, path)
120 await update($, doc, d => ({ ...d, mtime: at }))
121}
122
123// ponytail: polls mtime, no fs watch in the API; one watcher, for the file on show
124let watcher: { cancel: () => void } | undefined
125const watch = ($: EngineInterface) => {
126 watcher?.cancel()
127 watcher = $.clock.every(WATCH_MS, () => {
128 void (async () => {
129 const d = await read($, doc)
130 if (!d.path || !d.mtime) return
131 const at = await mtime($, d.path)
132 if (at && at !== d.mtime) {
133 await load($, d.path)
134 $.ui.toast(`${d.path.split(/[\\/]/).pop()} changed on disk; preview reloaded`)
135 }
136 })()
137 })
138}
139
140const show = async ($: EngineInterface, path: string) => {
141 await load($, path)
142 await $.ui.open({ id: PANE, title: path.split(/[\\/]/).pop() })
143 watch($)
144}
145
146export const register: Register = (on, options) => {
147 highlighters = parse(Array.isArray(options.highlighters) ? options.highlighters : [])
148
149 on('session.start', async ($, e, next) => {
150 await $.command.register({ name: 'preview', description: 'Preview a markdown, JSON or YAML file in a side pane: /preview <path>' })
151 return next(e)
152 })
153
154 on('command.run', { command: 'preview' }, async ($, e) => {
155 const arg = e.args.trim()
156 if (!arg) return { text: 'Usage: /preview <file.md | .json | .yaml>' }
157 const path = isAbs(arg) ? arg : `${await $.session.cwd()}/${arg}`
158 await show($, path)
159 return { text: `Previewing ${arg}` }
160 })
161
162 // Lets the engine scroll as usual, then records where to, so the pane redraws its
163 // footer on the window's new last rows.
164 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
165 const moved = await next(e)
166 if (e.origin.kind === 'person') await unmark($)
167 await redraw($)
168 return moved
169 })
170
171 // Anything the person does in the pane ends a jump's highlight; a jump that the
172 // press or Enter itself makes sets it again beneath this.
173 on('ui.press', { requestId: PANE }, async ($, e, next) => {
174 await unmark($)
175 return next(e)
176 })
177 on('ui.input', { requestId: PANE }, async ($, e, next) => {
178 await unmark($)
179 return next(e)
180 })
181
182 // Moving the focus ring (Tab, a click) is doing something else too.
183 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
184 if (e.origin.kind === 'person') await unmark($)
185 return next(e)
186 })
187
188 on('ui.close', async ($, e, next) => {
189 if (e.id === PANE) {
190 watcher?.cancel()
191 watcher = undefined
192 }
193 return next(e)
194 })
195
196 // Rewrites replies that mention existing files as file: links. A click on a previewable
197 // one opens the pane; the others are left to the surface, which opens them as usual.
198 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
199 const cwd = await $.session.cwd()
200 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
201 const found = linkify(e.props.text, cwd, undefined, home).links
202 if (found.length === 0) return next(e)
203 const exists = await Promise.all(
204 found.map(url => $.fs.stat(toPath(url)).then(s => s.kind === 'file', () => false)),
205 )
206 const { text, links } = linkify(e.props.text, cwd, new Set(found.filter((_, i) => exists[i])), home)
207 if (links.length === 0 || text.length > MAX) return next(e)
208 const { Markdown } = $.ui.resolve(e)
209 return (
210 <Markdown
211 key="md"
212 text={text}
213 pressableLinks={links.filter(url => PREVIEWABLE.test(url))}
214 onLinkPress={link => void show($, toPath(link.href))}
215 />
216 )
217 })
218
219 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220 const els = $.ui.resolve(e)
221 const { Box, Text, Markdown, Code, Button } = els
222 const Input = 'Input' in els ? els.Input : null // mobile draws no fields: no search there
223 const { path, text } = await read($, doc)
224 const { block: marked } = await read($, mark)
225 await read($, view) // subscribes: a scroll's tick redraws the footer
226 const cols = e.props.bodyColumns
227 const width = Math.min(Math.max(20, cols - 2 * PAD), READ_MAX)
228 const frame = { borderStyle: 'round', borderDimColor: true, paddingX: 1, flexDirection: 'column' } as const
229
230 const table = (t: Table) => {
231 const widths = fit(t, width)
232 if (!widths) {
233 // Too many columns for the pane: one card per row, `header: value` lines.
234 return t.rows.map(row => (
235 <Box {...frame}>
236 {t.head.map((name, j) => (
237 <Box flexDirection="row">
238 <Box flexShrink={0}>
239 <Text bold color="cyan">{`${name}: `}</Text>
240 </Box>
241 <Text wrap="wrap">{row[j] ?? ''}</Text>
242 </Box>
243 ))}
244 </Box>
245 ))
246 }
247 const line = (cells: string[], isHead: boolean) => (
248 <Box flexDirection="row" columnGap={GAP}>
249 {cells.map((c, j) => (
250 <Box width={widths[j]} flexShrink={0} justifyContent={JUSTIFY[t.align[j] ?? 'left']}>
251 <Text wrap="wrap" bold={isHead} color={isHead ? 'cyan' : undefined}>{c}</Text>
252 </Box>
253 ))}
254 </Box>
255 )
256 const rule = '─'.repeat(widths.reduce((a, b) => a + b, 0) + GAP * (widths.length - 1))
257 return (
258 <Box {...frame}>
259 {line(t.head, true)}
260 <Text dimColor>{rule}</Text>
261 {t.rows.flatMap((r, i) => (i === 0 ? [line(r, false)] : [<Text dimColor>{rule.replaceAll('─', '┈')}</Text>, line(r, false)]))}
262 </Box>
263 )
264 }
265
266 const mermaid = async (code: string) => {
267 const drawn = await renderMermaid($, code, width - 4)
268 if ('error' in drawn) {
269 return (
270 <Box flexDirection="column">
271 <Markdown text={`\`\`\`mermaid\n${code}\n\`\`\``} />
272 <Text dimColor wrap="wrap">{`mermaid-ascii could not draw this (${drawn.error}); showing the source.`}</Text>
273 </Box>
274 )
275 }
276 const art = drawn.art
277 const type = code.trim().split(/\s/)[0] ?? 'diagram'
278 return (
279 <Box {...frame} borderColor="magenta">
280 <Text color="magenta" bold>{`◆ ${type}`}</Text>
281 {art.split('\n').map(l => (
282 <Text wrap="truncate-end">{l || ' '}</Text>
283 ))}
284 </Box>
285 )
286 }
287
288 // Claude Code's own highlighter: every language it knows, nothing to install.
289 // A highlighter from the `highlighters` setting for this language, else Claude
290 // Code's own: every language it knows, nothing to install.
291 const code = async (lang: string, source: string) => {
292 const src = clean(source)
293 const cmd = highlighters.get(lang.toLowerCase())
294 const spans = cmd && src.length <= MAX ? await external($, cmd, src) : null
295 const rows = spans
296 ? lines(spans).map(segs => (
297 <Text wrap="wrap">
298 {segs.length ? segs.map(s => (s.color || s.italic ? <Text color={s.color} italic={s.italic}>{s.text}</Text> : s.text)) : ' '}
299 </Text>
300 ))
301 : null
302 return (
303 <Box {...frame}>
304 {lang ? <Text dimColor>{lang}</Text> : null}
305 {rows && JSON.stringify(rows).length <= CODE_BUDGET
306 ? rows
307 : chunk(src, MAX, MAX * 3).map(p => <Code source={p.source || ' '} language={lang || undefined} />)}
308 </Box>
309 )
310 }
311
312 // GitHub style: h1/h2 bold with a quiet rule beneath, h3 bold alone.
313 const heading = (level: 1 | 2 | 3, title: string) =>
314 level === 3 ? (
315 <Text bold>{title}</Text>
316 ) : (
317 <Box flexDirection="column" marginTop={level === 2 ? 1 : 0}>
318 <Text bold color={level === 1 ? 'whiteBright' : undefined}>{title}</Text>
319 <Text dimColor>{'─'.repeat(Math.max(1, width))}</Text>
320 </Box>
321 )
322
323 const callout = (type: Callout, body: string) => {
324 const { color, icon, label } = CALLOUTS[type]
325 return (
326 <Box borderStyle="round" borderColor={color} paddingX={1} flexDirection="column">
327 <Text bold color={color}>{`${icon} ${label}`}</Text>
328 <Markdown text={body.slice(0, MAX) || ' '} />
329 </Box>
330 )
331 }
332
333 // A markdown file: the docs page.
334 const page = async () => {
335 const md = text.length > MD_MAX ? `${text.slice(0, MD_MAX)}\n\n_…truncated_` : text
336 const all = blocks(md || '_Nothing to preview._')
337 const parts = await Promise.all(
338 all.map(b =>
339 b.kind === 'md'
340 ? <Markdown text={b.text.slice(0, MAX)} />
341 : b.kind === 'heading'
342 ? heading(b.level, b.text)
343 : b.kind === 'callout'
344 ? callout(b.type, b.text)
345 : b.kind === 'table'
346 ? table(b.table)
347 : b.kind === 'code'
348 ? code(b.lang, b.code)
349 : mermaid(b.code),
350 ),
351 )
352 const words = text.split(/\s+/).filter(Boolean).length
353 const count = (kind: Block['kind']) => all.filter(b => b.kind === kind).length
354 const stats = [
355 `${text.split('\n').length} lines`,
356 `${words} words`,
357 `~${Math.max(1, Math.round(words / 220))} min read`,
358 count('table') && `${count('table')} tables`,
359 count('mermaid') && `${count('mermaid')} diagrams`,
360 ]
361 const entries = all.flatMap((b, i) => blockLines(b).map(line => ({ block: i, text: line })))
362 const body = parts.map((p, i) => (
363 <Box key={`b:${i}`} flexDirection="column" backgroundColor={marked === i ? MARK_BG : undefined}>
364 {p}
365 </Box>
366 ))
367 return { stats, body, entries }
368 }
369
370 // A JSON or YAML file: the whole source, highlighted and numbered, cut where the
371 // drawing would pass the engine's tree bounds.
372 const dataFile = async (kind: 'json' | 'yaml', lang: string) => {
373 const src = clean(text).replace(/\n$/, '')
374 const total = src.split('\n').length
375 const parts = chunk(src, MAX, DATA_BUDGET)
376 const last = parts.at(-1)
377 const shown = last ? last.startLine - 1 + last.source.split('\n').length : 0
378 const body = [
379 <Box {...frame}>
380 <Text dimColor>{lang}</Text>
381 {parts.map((p, i) => (
382 <Box key={`b:${i}`} flexDirection="column">
383 <Code source={p.source || ' '} language={lang === 'jsonc' ? 'json' : lang} startLine={p.startLine} />
384 </Box>
385 ))}
386 </Box>,
387 shown < total ? (
388 <Text dimColor>{`Showing the first ${shown} of ${total} lines; open the file in an editor for the rest.`}</Text>
389 ) : null,
390 ]
391 const entries = parts.flatMap((p, i) =>
392 p.source.split('\n').map((line, j) => ({ block: i, text: `${p.startLine + j}: ${line.trim()}` })),
393 )
394 return { stats: [`${total} lines`, `${(text.length / 1024).toFixed(1)} KB`, ...(kind === 'json' ? describeJson(text, lang) : describeYaml(text))], body, entries }
395 }
396
397 const kind = /\.jsonc?$/i.test(path) ? 'json' : /\.ya?ml$/i.test(path) ? 'yaml' : 'md'
398 const { stats: parts, body, entries } =
399 kind === 'md' ? await page() : await dataFile(kind, /\.jsonc$/i.test(path) ? 'jsonc' : kind)
400 const stats = parts.filter(Boolean).join(' · ')
401 const crumbs = path.split(/[\\/]/).filter(Boolean)
402 const name = crumbs.pop() ?? path
403
404 // Search: the query lives in state, so typing redraws the hits.
405 const { open: isSearching, query } = await read($, find)
406 const hits = isSearching ? search(query, entries) : []
407 // A refused scroll or focus (the pane not holding the keys) is not an error worth a throw.
408 const jump = async (block: number) => {
409 await update($, mark, () => ({ block }))
410 await $.ui.scroll({ to: { key: `b:${block}` }, in: PANE, block: 'start' }).catch(() => {})
411 await redraw($)
412 }
413 // The sticky footer: absolutely placed on the window's last rows, following the
414 // scroll (the ui.scroll hook records the offset). Every row is one terminal row,
415 // so its height is known: a rule, the search rows while searching, the buttons.
416 // Esc hands the keyboard back to the prompt, which no event reports, but the pane
417 // redraws with isFocused false: search shows only while the pane holds the keys,
418 // and losing them ends it (a render may not write state, so the clock does).
419 const searching = isSearching && Input !== null && e.props.isFocused
420 if (isSearching && !e.props.isFocused) $.clock.after(0, () => void update($, find, () => ({ open: false, query: '' })))
421 const footerRows = 2 + (searching ? 2 + hits.length : 0)
422 const offset = e.props.scroll.offset
423 const bodyRows = Math.max(footerRows + 1, e.props.scroll.bodyRows)
424 const blank = ' '.repeat(Math.max(1, width))
425 const clip = (t: string) => (t.length > width ? `${t.slice(0, width - 1)}…` : t)
426 const footer = (
427 <Box key="footer" position="absolute" top={offset + bodyRows - footerRows} left={PAD} width={width} flexDirection="column">
428 {/* paints over the page beneath before the footer's own rows */}
429 <Box position="absolute" top={0} left={0} flexDirection="column">
430 {Array.from({ length: footerRows }, () => <Text>{blank}</Text>)}
431 </Box>
432 <Text dimColor>{'─'.repeat(Math.max(1, width))}</Text>
433 {searching && Input ? (
434 <Box flexDirection="column">
435 <Box flexDirection="row">
436 <Box flexGrow={1}>
437 <Input
438 key="search"
439 autoFocus
440 placeholder="Fuzzy search…"
441 value={query}
442 onInput={(value: string) => void update($, find, f => ({ ...f, query: value }))}
443 onSubmit={(value: string) => {
444 const [first] = search(value, entries, 1)
445 if (first) void jump(first.block)
446 }}
447 />
448 </Box>
449 </Box>
450 <Text dimColor wrap="truncate-end">{query ? `${hits.length === 8 ? '8+' : hits.length} matches; Enter jumps to the first` : 'Type to search; Enter jumps to the best match'}</Text>
451 {hits.map((hit, k) => (
452 <Button key={`hit:${k}`} plain label={clip(hit.text)} onPress={() => jump(hit.block)} />
453 ))}
454 </Box>
455 ) : null}
456 <Box flexDirection="row" justifyContent="space-between">
457 <Box flexDirection="row" columnGap={1}>
458 <Button key="top" label="↑ Top" hotkey="u" onPress={async () => {
459 await $.ui.scroll({ to: 'start', in: PANE }).catch(() => {})
460 await redraw($)
461 }} />
462 {Input && !searching ? (
463 <Button
464 key="find"
465 label="⌕ Search"
466 hotkey="s"
467 onPress={async () => {
468 await update($, find, f => ({ ...f, open: true }))
469 await $.ui.focus({ requestId: PANE, key: 'search' }).catch(() => {})
470 }}
471 />
472 ) : null}
473 {searching ? <Button key="close-search" label="✕ Close" onPress={() => update($, find, () => ({ open: false, query: '' }))} /> : null}
474 <Button
475 key="refresh"
476 label="↻ Refresh"
477 hotkey="r"
478 onPress={async () => {
479 await load($, path)
480 $.ui.toast('Preview refreshed')
481 }}
482 />
483 </Box>
484 <Text dimColor wrap="truncate-start">{searching ? 'Esc closes search' : 'u top · s search · r refresh'}</Text>
485 </Box>
486 </Box>
487 )
488
489 return (
490 // minHeight keeps the tree at least a window tall, so the footer is never below its end
491 <Box flexDirection="column" paddingX={PAD} paddingBottom={footerRows} minHeight={bodyRows}>
492 <Box flexDirection="row" flexWrap="wrap">
493 <Text dimColor wrap="truncate-start">{`${crumbs.slice(-3).join(' / ')} / `}</Text>
494 <Text bold color="cyan">{name}</Text>
495 </Box>
496 <Text dimColor wrap="truncate-end">{stats}</Text>
497 <Text dimColor>{'─'.repeat(Math.max(1, width))}</Text>
498 <Box flexDirection="column" rowGap={1} marginTop={1} width={width}>
499 {body}
500 </Box>
501 {footer}
502 </Box>
503 )
504 })
505}
506hooks/linkify.ts 54 lines1// Turns file references in reply text into file: links, leaving fenced code alone.
2// Any `name.ext` matches; the caller keeps only files that exist. Previewable ones
3// (markdown, JSON, YAML) open the pane, the rest open the way the surface opens links.
4export const PREVIEWABLE = /\.(?:markdown|md|jsonc|json|yaml|yml)$/i
5// ponytail: extensionless files (Makefile, LICENSE) are not matched, or every bare word would be a candidate
6const FILE = String.raw`(?:[A-Za-z]:[\\/]|[.~]?[\\/])?[\w.\-\\/ ]*[\w\-]\.[A-Za-z][A-Za-z0-9]*`
7const TOKEN = new RegExp(
8 String.raw`\[([^\]]*)\]\((${FILE})(?:#[^)]*)?\)` + // [label](path.md)
9 String.raw`|\x60(${FILE})(:\d+)?\x60` + // `path.md:12`
10 String.raw`|(?<![\w\\/.\[(\x60:])(${FILE.replace(' ', '')})(:\d+)?(?![\w])`, // bare path.md
11 'gi',
12)
13
14const BS = String.fromCharCode(92) // backslash
15
16// `~/` resolves against `home`; without one it stays relative and will not exist.
17export const toUrl =(path: string, cwd: string, home?: string): string => {
18 const slashed = path.split(BS).join('/')
19 const p = home && slashed.startsWith('~/') ? `${home.split(BS).join('/').replace(/\/$/, '')}${slashed.slice(1)}` : slashed
20 const abs = /^([A-Za-z]:)?\//.test(p) ? p : `${cwd.split(BS).join('/').replace(/\/$/, '')}/${p.replace(/^\.\//, '')}`
21 return encodeURI(`file://${abs.startsWith('/') ? '' : '/'}${abs}`)
22}
23
24export const toPath = (href: string): string => {
25 const p = decodeURIComponent(href.replace(/^file:\/\//, ''))
26 return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p
27}
28
29// `keep` limits the links to those urls (files known to exist); absent, every match links.
30export const linkify = (text: string, cwd: string, keep?: Set<string>, home?: string): { text: string; links: string[] } => {
31 const links: string[] = []
32 const link = (match: string, label: string, path: string) => {
33 const url = toUrl(path, cwd, home)
34 if (keep && !keep.has(url)) return match
35 links.push(url)
36 return `[${label}](${url})`
37 }
38 const out = text
39 .split(/(^```[\s\S]*?^```)/m)
40 .map((part, i) =>
41 i % 2
42 ? part
43 : part.replace(TOKEN, (m, label, linkPath, codePath, codeLine, barePath, bareLine) =>
44 linkPath
45 ? link(m, label, linkPath)
46 : codePath
47 ? link(m, `\`${codePath}${codeLine ?? ''}\``, codePath)
48 : link(m, `${barePath}${bareLine ?? ''}`, barePath),
49 ),
50 )
51 .join('')
52 return { text: out, links }
53}
54hooks/blocks.ts 137 lines1// Splits markdown into the blocks the pane draws itself (headings, callouts, tables,
2// mermaid) and prose left to Markdown, and fits a table's columns to a width.
3export type Align = 'left' | 'center' | 'right'
4export type Table = { head: string[]; align: Align[]; rows: string[][] }
5export type Callout = 'note' | 'tip' | 'important' | 'warning' | 'caution'
6export type Block =
7 | { kind: 'md'; text: string }
8 | { kind: 'heading'; level: 1 | 2 | 3; text: string }
9 | { kind: 'callout'; type: Callout; text: string }
10 | { kind: 'table'; table: Table }
11 | { kind: 'mermaid'; code: string }
12 | { kind: 'code'; lang: string; code: string }
13
14const HEADING = /^(#{1,3})\s+(.+?)\s*#*\s*$/
15const CALLOUT = /^>\s*\[!(note|tip|important|warning|caution)\]\s*$/i
16
17const SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
18const FENCE = /^\s*(```|~~~)/
19
20const cells = (line: string): string[] =>
21 line
22 .trim()
23 .replace(/^\|/, '')
24 .replace(/(?<!\\)\|$/, '')
25 .split(/(?<!\\)\|/)
26 .map(c => plain(c.trim().replaceAll('\\|', '|')))
27
28// Inline markdown to plain text: a grid cell is drawn as one Text.
29export const plain = (s: string): string =>
30 s
31 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
32 .replace(/(\*\*|__)(.+?)\1/g, '$2')
33 .replace(/(?<![\w*])[*_](.+?)[*_](?![\w*])/g, '$1')
34 .replace(/`([^`]*)`/g, '$1')
35 .replace(/<br\s*\/?>/gi, ' ')
36
37export const blocks = (text: string): Block[] => {
38 const lines = text.replace(/\r\n?/g, '\n').split('\n')
39 const out: Block[] = []
40 let prose: string[] = []
41 const flush = () => {
42 if (prose.join('').trim()) out.push({ kind: 'md', text: prose.join('\n') })
43 prose = []
44 }
45 for (let i = 0; i < lines.length; i++) {
46 const line = lines[i] ?? ''
47 const fence = /^\s*(```|~~~)\s*([\w+#.-]*)/.exec(line)
48 if (fence) {
49 const code: string[] = []
50 for (i++; i < lines.length && !FENCE.test(lines[i] ?? ''); i++) code.push(lines[i] ?? '')
51 flush()
52 const lang = (fence[2] ?? '').toLowerCase()
53 out.push(lang === 'mermaid' ? { kind: 'mermaid', code: code.join('\n') } : { kind: 'code', lang, code: code.join('\n') })
54 continue
55 }
56 const heading = HEADING.exec(line)
57 if (heading) {
58 flush()
59 out.push({ kind: 'heading', level: (heading[1] ?? '#').length as 1 | 2 | 3, text: plain(heading[2] ?? '') })
60 continue
61 }
62 const callout = CALLOUT.exec(line)
63 if (callout) {
64 const body: string[] = []
65 for (i++; i < lines.length && (lines[i] ?? '').startsWith('>'); i++) body.push((lines[i] ?? '').replace(/^>\s?/, ''))
66 i--
67 flush()
68 out.push({ kind: 'callout', type: (callout[1] ?? 'note').toLowerCase() as Callout, text: body.join('\n') })
69 continue
70 }
71 const next = lines[i + 1] ?? ''
72 if (!line.includes('|') || !SEP.test(next)) {
73 prose.push(line)
74 continue
75 }
76 const head = cells(line)
77 const align = cells(next).map((c, j): Align => {
78 const raw = next.trim().replace(/^\|/, '').split('|')[j]?.trim() ?? c
79 return raw.startsWith(':') && raw.endsWith(':') ? 'center' : raw.endsWith(':') ? 'right' : 'left'
80 })
81 const rows: string[][] = []
82 for (i += 2; i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim(); i++) {
83 const row = cells(lines[i] ?? '')
84 rows.push(head.map((_, j) => row[j] ?? ''))
85 }
86 i--
87 flush()
88 out.push({ kind: 'table', table: { head, align: head.map((_, j) => align[j] ?? 'left'), rows } })
89 }
90 flush()
91 return out
92}
93
94export const GAP = 2
95const FRAME = 4 // border + paddingX on each side
96const MIN = 4
97
98// Column widths that fit `columns`, or null when even MIN per column does not fit (draw cards).
99export const fit = (t: Table, columns: number): number[] | null => {
100 const natural = t.head.map((h, j) => Math.max(h.length, ...t.rows.map(r => (r[j] ?? '').length), 1))
101 let room = columns - FRAME - GAP * (natural.length - 1)
102 if (natural.reduce((a, b) => a + b, 0) <= room) return natural
103 if (room < MIN * natural.length) return null
104 // ponytail: water-fill, narrow columns keep their width and the rest share what is left
105 const widths = [...natural]
106 const order = natural.map((w, j) => [w, j] as const).sort((a, b) => a[0] - b[0])
107 order.forEach(([w, j], k) => {
108 const share = Math.floor(room / (order.length - k))
109 widths[j] = Math.max(MIN, Math.min(w, share))
110 room -= widths[j] ?? 0
111 })
112 return widths
113}
114
115// A Code element's source: no control characters but tab and newline.
116export const clean = (s: string) => s.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
117
118// Splits source into runs of whole lines, each at most `max` characters (a longer
119// line is cut), numbered from where it starts, until `budget` characters are used.
120export const chunk = (src: string, max: number, budget: number): { source: string; startLine: number }[] => {
121 const rows = src.split('\n')
122 const out: { source: string; startLine: number }[] = []
123 let used = 0
124 for (let start = 0; start < rows.length && used < budget; ) {
125 let end = start
126 let len = 0
127 while (end < rows.length && len + Math.min((rows[end] ?? '').length, max - 1) + 1 <= max) {
128 len += Math.min((rows[end] ?? '').length, max - 1) + 1
129 end++
130 }
131 out.push({ source: rows.slice(start, end).map(r => r.slice(0, max - 1)).join('\n'), startLine: start + 1 })
132 used += len
133 start = end
134 }
135 return out
136}
137hooks/highlighters.ts 75 lines1// External highlighters from the `highlighters` setting: one entry per command,
2// `<lang>[, <lang>…]: <command> [args…]`. A command reads source on stdin and
3// writes a JSON array of [text, capture | null] spans (tree-sitter capture names).
4export type Span = [text: string, capture: string | null]
5export type Styled = { text: string; color?: string; italic?: boolean }
6
7// Splits a command line into argv: whitespace separates, double quotes group.
8export const argv = (line: string): string[] => [...line.matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2] ?? '')
9
10// Language (lower case) to argv; an entry without a colon or a command is skipped.
11export const parse = (entries: readonly string[]): Map<string, string[]> => {
12 const out = new Map<string, string[]>()
13 for (const entry of entries) {
14 const at = entry.indexOf(':')
15 const cmd = at > 0 ? argv(entry.slice(at + 1)) : []
16 if (!cmd.length) continue
17 for (const lang of entry.slice(0, at).split(',')) if (lang.trim()) out.set(lang.trim().toLowerCase(), cmd)
18 }
19 return out
20}
21
22// Capture names to colours (GitHub dark).
23const THEME: Record<string, Omit<Styled, 'text'>> = {
24 keyword: { color: '#ff7b72' },
25 operator: { color: '#ff7b72' },
26 function: { color: '#d2a8ff' },
27 method: { color: '#d2a8ff' },
28 type: { color: '#ffa657' },
29 module: { color: '#ffa657' },
30 string: { color: '#a5d6ff' },
31 number: { color: '#79c0ff' },
32 boolean: { color: '#79c0ff' },
33 constant: { color: '#79c0ff' },
34 property: { color: '#79c0ff' },
35 attribute: { color: '#7ee787' },
36 tag: { color: '#7ee787' },
37 label: { color: '#7ee787' },
38 'variable.builtin': { color: '#ffa657' },
39 'variable.parameter': { color: '#ffa657' },
40 comment: { color: '#8b949e', italic: true },
41}
42
43// `keyword.control.conditional` tries itself, then `keyword.control`, then `keyword`.
44export const style = (capture: string | null): Omit<Styled, 'text'> => {
45 for (let name = capture ?? ''; name; name = name.slice(0, Math.max(0, name.lastIndexOf('.')))) {
46 const hit = THEME[name]
47 if (hit) return hit
48 }
49 return {}
50}
51
52export const lines = (spans: readonly Span[]): Styled[][] => {
53 const out: Styled[][] = [[]]
54 for (const [text, capture] of spans) {
55 text.split('\n').forEach((piece, i) => {
56 if (i > 0) out.push([])
57 if (piece) out[out.length - 1]?.push({ text: piece, ...style(capture) })
58 })
59 }
60 if (out.length > 1 && out[out.length - 1]?.length === 0) out.pop()
61 return out
62}
63
64// What a highlighter wrote, if it is spans that join back into the source.
65export const spansOf = (stdout: string, source: string): Span[] | null => {
66 try {
67 const spans: unknown = JSON.parse(stdout)
68 if (!Array.isArray(spans)) return null
69 const ok = spans.every(s => Array.isArray(s) && typeof s[0] === 'string' && (s[1] === null || typeof s[1] === 'string'))
70 return ok && (spans as Span[]).map(s => s[0]).join('') === source ? (spans as Span[]) : null
71 } catch {
72 return null
73 }
74}
75hooks/search.ts 39 lines1// Fuzzy search over the pane's lines: the query's characters must appear in order
2// (case-insensitive); consecutive runs and word starts score higher, gaps cost.
3export type Entry = { block: number; text: string }
4export type Hit = Entry & { score: number }
5
6const isWordStart = (s: string, i: number) => i === 0 || /[^A-Za-z0-9]/.test(s[i - 1] ?? '') || (/[a-z]/.test(s[i - 1] ?? '') && /[A-Z]/.test(s[i] ?? ''))
7
8// The best score of `query` in `text`, or null when it does not match.
9export const score = (query: string, text: string): number | null => {
10 const q = query.toLowerCase().replace(/\s+/g, '')
11 if (!q) return null
12 const t = text.toLowerCase()
13 // ponytail: greedy left-to-right match, then a contiguous-substring bonus; an
14 // optimal (DP) alignment if rankings ever look wrong
15 let pos = -1
16 let total = 0
17 let run = 0
18 for (const ch of q) {
19 const at = t.indexOf(ch, pos + 1)
20 if (at < 0) return null
21 run = at === pos + 1 ? run + 1 : 0
22 total += 1 + run * 2 + (isWordStart(text, at) ? 3 : 0) - Math.min(at - pos - 1, 5) * 0.2
23 pos = at
24 }
25 if (t.includes(q)) total += q.length * 2
26 return total
27}
28
29// The best `limit` hits, one per line, best first; ties keep document order.
30export const search = (query: string, entries: readonly Entry[], limit = 8): Hit[] =>
31 entries
32 .flatMap((e, i) => {
33 const s = score(query, e.text)
34 return s === null ? [] : [{ ...e, score: s, i }]
35 })
36 .sort((a, b) => b.score - a.score || a.i - b.i)
37 .slice(0, limit)
38 .map(({ i: _, ...hit }) => hit)
39types/index.d.ts 11 lines1export type Doc = { path: string; text: string; mtime: number }
2export type Find = { open: boolean; query: string }
3export type View = { tick: number }
4export type Mark = { block: number | null }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'file-preview': { doc: Doc; find: Find; view: View; mark: Mark }
9 }
10}
11