SLOPSHOPPER

file-preview

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

newpanerowscommandtoastprocess
v0.2.0MITupdated 2026-10-05abonckus/claude-code-file-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-preview
│ ┃ file-preview ✕ › fix the failing auth test and add an audit log call │ ┃ / │ ┃ 1 lines · 0 words · ~1 min read ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Nothing to preview. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ ↑ Top ] [ ⌕ Search ] [ ↻ Refresh ]u top │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /preview │ ┃ ⎿ file-preview: Usage: /preview <file.md | .json | .yaml> │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · file-preview
/ 1 lines · 0 words · ~1 min read ──────────────────────────────────────────────────── Nothing to preview. ──────────────────────────────────────────────────── [ ↑ Top ] [ ⌕ Search ] [ ↻ Refresh ]u top · s search · r r
README

file-preview

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.

Features

  • Clickable file links. File references in Claude's replies (markdown links, ` 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.
  • Docs-page layout. A breadcrumb and stats header, headings with rules under them, a capped reading width and spacing between blocks.
  • Tables. Columns are fitted to the pane width, long cells wrap inside their column, and a dotted rule separates rows. A table too wide for the pane is shown as one card per row.
  • GitHub callouts. > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] and [!CAUTION] are drawn as coloured boxes.
  • Mermaid diagrams. Drawn as box-drawing text by mermaid-ascii. Diagram types it does not support show their source, with the reason.
  • JSON and YAML files. Shown as numbered, highlighted source. The header gives the file's shape (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.
  • Syntax highlighting. Code blocks and data files use Claude Code's own highlighter, so any language that highlighter knows is coloured to match your theme, with nothing to install. A language it does not know is drawn plain.
  • Live reload. The file is checked every 1.5 s and reloaded when it changes, with a toast to say so. There is also a ↻ Refresh button.
  • Search. ⌕ Search opens a fuzzy search over the file: type a few letters in order (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.
  • ↑ Top scrolls back to the start. A search jump highlights the block it lands on until you do anything else.
  • Sticky footer. The buttons (and the search field while searching) stay on the pane's bottom rows as you scroll.
  • /preview <path> opens any file by hand.

Requirements

  • Claude Code with mod (function hook) support
  • Optional: mermaid-ascii on your PATH, for diagrams ``sh go install github.com/AlexanderGrooff/mermaid-ascii@latest ``

Install

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).

Keys

While the pane has the keyboard (click it, or ctrl+x tab from the prompt):

KeyDoes
uScroll to the top
sOpen search (the cursor starts in the field)
EscClose search (it hands the keyboard back to the prompt)
rRefresh
up / down, pageup / pagedown, home / endScroll (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" } }

External highlighters

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.

Limitations

  • Clicks need the fullscreen layout. A plain click only reaches the mod in Claude Code's fullscreen terminal layout. Elsewhere, or with ctrl- or alt-click, links open the usual way, so use /preview there.
  • The reply bullet is lost. Replies that mention an existing file are redrawn by the mod and lose their leading bullet.
  • Size caps. Replies over 10,000 characters are left alone. Each block of plain text in the pane is capped at 10,000 characters and a markdown file at 60,000, which keeps the drawing inside Claude Code's 100,000-character limit for a pane. JSON and YAML files are cut by line when they would pass that limit.
  • Plain table cells. Bold, code and links inside a table cell are drawn as plain text.
  • Boxed callouts. Callouts get a full border, because a terminal box cannot have a border on one side only.

Development

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.

License

MIT

Source 6 files
hooks/register.tsx 506 lines
1import { 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}
506
hooks/linkify.ts 54 lines
1// 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}
54
hooks/blocks.ts 137 lines
1// 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}
137
hooks/highlighters.ts 75 lines
1// 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}
75
hooks/search.ts 39 lines
1// 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)
39
types/index.d.ts 11 lines
1export 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