SLOPSHOPPER

md-preview

Click a markdown file link in Claude Code to preview it rendered

newpanebandrowsguardcommand
v0.1.0MITupdated 2026-10-09wicksipedia/md-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · md-preview
│ ┃ md-preview ✕ › fix the failing auth test and add an audit log call │ ┃ No file. Click a .md link or run /md path. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /md │ ⎿ md-preview: Usage: /md <path> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · md-preview
No file. Click a .md link or run /md path.
README

md-preview

A Claude Code mod that opens markdown files as rendered pages. Click a .md link in Claude's reply and you get a GitHub-styled preview with syntax highlighting, images and Mermaid diagrams.

Install

At the Claude Code prompt in a terminal session, run:

/plugin install md-preview --marketplace wicksipedia/md-preview

Answer y to add the marketplace, then press Enter to pick the user scope. The mod starts working in that session.

Use

Click any of these in a reply from Claude:

  • markdown links such as plan
  • paths in backticks such as ` docs/plan.md `
  • bare paths such as docs/plan.md

The mod links bare paths and backtick paths only when the file exists. A plain click opens the preview. Ctrl-click and Cmd-click keep your terminal's own behaviour.

To open a file by name, run /md <path>.

The mod also lists the last five markdown files that Claude read or edited in a row above the prompt. Click one to preview it.

While the preview is open, the mod checks the file once a second. When the file changes, the page reloads and keeps your scroll position, so you can watch Claude edit a doc.

Where the preview shows

In bigtty, the preview opens in a browser pane beside the terminal. The page works offline: its styles and scripts ship in assets/.

Without bigtty, the preview opens in a Claude Code side pane. That pane draws markdown with the terminal's own renderer: headings, lists, tables and code, but no images or diagrams.

Requirements

Plain clicks reach the mod in the fullscreen terminal layout and in the desktop app. In the standard terminal layout, use /md <path>.

Limits

  • A link to another .md file inside the browser preview opens the raw file.
  • If you change directory during a session, bare paths still resolve against the directory the session started in.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .

claude plugin test . runs two kinds of test:

  • Unit tests (hooks/linkify.test.ts, hooks/preview.test.ts) check the link and page-building functions on their own.
  • Integration tests (hooks/integration.test.ts) load the whole mod into the Claude Code test engine. Fake hooks stand in for the disk, btty and the side pane. The tests run /md, mount the reply, pane and prompt-row components, press links, and move a fake clock to check live reload.

Bundled assets

assets/ holds copies of these files:

  • github-markdown-css 5.5.1
  • highlight.js 11.9.0, with the github and github-dark styles
  • marked 12.0.2
  • mermaid 10.9.1

scripts/update-assets.sh holds the pinned versions and downloads each file from jsDelivr. To update a library:

  1. Change its version at the top of scripts/update-assets.sh.
  2. Run scripts/update-assets.sh.
  3. Change the version in the list above.
  4. Run claude plugin test ., then open a preview with code, a table and a Mermaid diagram to check it still renders.

To check that the files in assets/ match the pinned versions, run scripts/update-assets.sh --check.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 264 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RecentPaths } from '../types'
5
6const PANE = 'md-preview'
7const current = atom({ plugin: 'md-preview', key: 'path' } as const, '')
8const rev = atom({ plugin: 'md-preview', key: 'rev' } as const, 0)
9const recent = atom({ plugin: 'md-preview', key: 'recent' } as const, [] as RecentPaths)
10
11const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit'])
12const MD_PATH = /^(?:\.{1,2}\/|\/)?(?:[\w.@-]+\/)*[\w.@-]+\.md$/
13// Fenced block | [text](href) | `code` | bare path. Order matters: earlier
14// alternatives swallow paths that must stay untouched.
15const TOKENS =
16  /(```[\s\S]*?(?:```|$))|\[([^\]\n]*)\]\(([^)\s]+)\)|`([^`\n]+)`|(?<![\w/.@:-])((?:\.{1,2}\/|\/)?(?:[\w.@-]+\/)*[\w.@-]+\.md)(?![\w/])/g
17
18export function resolvePath(p: string, base: string): string {
19  const parts = (p.startsWith('/') ? p : `${base}/${p}`).split('/')
20  const out: string[] = []
21  for (const part of parts) {
22    if (part === '..') out.pop()
23    else if (part && part !== '.') out.push(part)
24  }
25  return `/${out.join('/')}`
26}
27
28export const toHref = (abs: string) => `file://${encodeURI(abs)}`
29export const fromHref = (href: string) => decodeURI(href.replace(/^file:\/\//, ''))
30
31/**
32 * Turn every markdown file reference in `text` into a `file://` link.
33 * Bare paths and inline-code paths link only when `keep` accepts them.
34 */
35export function linkify(text: string, base: string, keep: (abs: string) => boolean) {
36  const hrefs = new Set<string>()
37  const link = (label: string, abs: string) => {
38    const href = toHref(abs)
39    hrefs.add(href)
40    return `[${label}](${href})`
41  }
42  const out = text.replace(TOKENS, (m, fence, label, href, code, bare) => {
43    if (fence) return m
44    if (href !== undefined) {
45      const target = href.replace(/#.*$/, '')
46      if (/^https?:/.test(target) || !target.endsWith('.md')) return m
47      const abs = target.startsWith('file://') ? fromHref(target) : resolvePath(target, base)
48      return link(label, abs)
49    }
50    const path = code ?? bare
51    if (!MD_PATH.test(path)) return m
52    const abs = resolvePath(path, base)
53    return keep(abs) ? link(code ? `\`${code}\`` : bare, abs) : m
54  })
55  return { text: out, hrefs: [...hrefs] }
56}
57
58type Linked = ReturnType<typeof linkify>
59// Redraws (scrolls included) re-run the hook, so memoize per text and base.
60const linked = new Map<string, Promise<Linked>>()
61
62function linkifyExisting($: EngineInterface, text: string, base: string) {
63  const id = `${base}\0${text}`
64  let hit = linked.get(id)
65  if (!hit) {
66    if (linked.size > 500) linked.clear()
67    hit = linkifyUncached($, text, base)
68    linked.set(id, hit)
69  }
70  return hit
71}
72
73async function linkifyUncached($: EngineInterface, text: string, base: string) {
74  const seen = new Set<string>()
75  linkify(text, base, abs => (seen.add(abs), false))
76  const found = new Set<string>()
77  await Promise.all([...seen].map(async abs => (await $.fs.exists(abs)) && found.add(abs)))
78  return linkify(text, base, abs => found.has(abs))
79}
80
81const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1)
82
83const HTML_DIR = '/tmp/md-preview'
84const htmlPath = (path: string) => `${HTML_DIR}/${path.replace(/[^\w.-]+/g, '_')}.html`
85let lastMtime = 0
86let inBrowser = false
87
88/** A self-contained page that renders `md` with GitHub styles, highlighting and mermaid. */
89export function previewHtml(path: string, md: string, assets: string) {
90  const dir = path.slice(0, path.lastIndexOf('/') + 1)
91  const hasMermaid = /^```mermaid/m.test(md)
92  const data = JSON.stringify(md).replace(/</g, '\\u003c')
93
94  return `<!doctype html><html><head><meta charset="utf-8">
95<title>${basename(path).replace(/</g, '&lt;')}</title>
96<base href="${toHref(dir)}">
97<link rel="stylesheet" href="${assets}/github-markdown.min.css">
98<link rel="stylesheet" media="(prefers-color-scheme: light)" href="${assets}/github.min.css">
99<link rel="stylesheet" media="(prefers-color-scheme: dark)" href="${assets}/github-dark.min.css">
100<style>
101:root { --bg: #fff } @media (prefers-color-scheme: dark) { :root { --bg: #0d1117 } }
102body { background: var(--bg); margin: 0 }
103.markdown-body { box-sizing: border-box; max-width: 900px; margin: 0 auto; padding: 32px 24px }
104</style></head><body><article class="markdown-body" id="doc"></article>
105<script src="${assets}/marked.min.js"></script>
106<script src="${assets}/highlight.min.js"></script>
107${hasMermaid ? `<script src="${assets}/mermaid.min.js"></script>` : ''}
108<script>
109const key = 'scroll:' + location.pathname
110const doc = document.getElementById('doc')
111doc.innerHTML = marked.parse(${data}, { gfm: true })
112doc.querySelectorAll('pre code.language-mermaid').forEach(c => {
113  const d = document.createElement('div'); d.className = 'mermaid'; d.textContent = c.textContent
114  c.parentElement.replaceWith(d)
115})
116hljs.registerAliases(['zsh', 'sh', 'shell'], { languageName: 'bash' })
117doc.querySelectorAll('pre code').forEach(c => hljs.highlightElement(c))
118const restore = () => scrollTo(0, +sessionStorage.getItem(key) || 0)
119if (window.mermaid) {
120  const dark = matchMedia('(prefers-color-scheme: dark)').matches
121  mermaid.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'default' })
122  mermaid.run().finally(restore)
123} else restore()
124addEventListener('scroll', () => sessionStorage.setItem(key, scrollY))
125</script></body></html>`
126}
127
128async function writeHtml($: EngineInterface, path: string) {
129  const md = await $.fs.read(path).then(t => t as string, () => `_Cannot read ${path}_`)
130  await $.fs.write(htmlPath(path), previewHtml(path, md, toHref(`${$.plugin.root}/assets`)))
131}
132
133async function btty($: EngineInterface, ...args: string[]) {
134  return $.process.run(['btty', 'browser', ...args]).then(r => r.exitCode === 0, () => false)
135}
136
137async function preview($: EngineInterface, path: string) {
138  $.ui.status(`Opening ${basename(path)}…`)
139  const [mtime] = await Promise.all([
140    $.fs.stat(path).then(s => s.mtimeMs, () => -1),
141    update($, current, () => path),
142    writeHtml($, path),
143  ])
144  lastMtime = mtime
145  inBrowser = await btty($, 'open', toHref(htmlPath(path)))
146  $.ui.status(undefined)
147  if (inBrowser) return
148  await update($, rev, n => n + 1)
149  await $.ui.open({ id: PANE, title: basename(path) })
150}
151
152let cwd: Promise<string> | undefined
153
154export const register: Register = on => {
155
156  on('session.start', async ($, e, next) => {
157    $.ui.status(undefined)
158    await $.command.register({
159      name: 'md',
160      description: 'Preview a markdown file rendered',
161      argumentHint: '<path>',
162    })
163    // ponytail: 1s mtime poll — swap for a file watch if the API grows one
164    $.clock.every(1000, async () => {
165      const { value: path } = await $.state.get({ plugin: 'md-preview', key: 'path' })
166      if (!path) return
167      const mtime = await $.fs.stat(path).then(s => s.mtimeMs, () => -1)
168      if (mtime === lastMtime) return
169      lastMtime = mtime
170      if (!inBrowser) return void (await update($, rev, n => n + 1))
171      await writeHtml($, path)
172      inBrowser = await btty($, 'reload')
173    })
174
175    return next(e)
176  })
177
178  on('command.run', { command: 'md' }, async ($, e) => {
179    const path = e.args.trim()
180    if (!path) return { text: 'Usage: /md <path>' }
181    await preview($, resolvePath(path, await $.session.cwd()))
182
183    return { text: `Previewing ${path}` }
184  })
185
186  on('tool.call', async ($, e, next) => {
187    const ran = await next(e)
188    const path = 'file_path' in e ? e.file_path : undefined
189    if (FILE_TOOLS.has(e.tool) && typeof path === 'string' && path.endsWith('.md')) {
190      await update($, recent, list => [path, ...list.filter(p => p !== path)].slice(0, 5)).catch(
191        () => {},
192      )
193    }
194
195    return ran
196  })
197
198  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
199    if (e.props.isSummary) return next(e)
200    const { text, hrefs } = await linkifyExisting($, e.props.text, await (cwd ??= $.session.cwd()))
201    if (hrefs.length === 0) return next(e)
202    const { Box, Text, Markdown } = $.ui.resolve(e)
203    const body = (
204      <Markdown
205        key={`md-preview-${e.requestId}`}
206        text={text}
207        pressableLinks={hrefs}
208        onLinkPress={link => void preview($, fromHref(link.href))}
209      />
210    )
211    if (e.surface !== 'terminal') return body
212
213    return (
214      <Box flexDirection="row">
215        <Box width={2} flexShrink={0}>
216          <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
217        </Box>
218        <Box flexGrow={1}>{body}</Box>
219      </Box>
220    )
221  })
222
223  // ponytail: tool-row paths go in a band, not a redrawn tool row — the
224  // engine's own row stays intact and folded groups still work.
225  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
226    const list = await read($, recent)
227    if (list.length === 0) return next(e)
228    const { Markdown } = $.ui.resolve(e)
229    const hrefs = list.map(toHref)
230    const text = `md: ${list.map((p, i) => `[${basename(p)}](${hrefs[i]})`).join(' · ')}`
231
232    return (
233      <Markdown
234        key="md-preview-recent"
235        text={text}
236        dimColor
237        pressableLinks={hrefs}
238        onLinkPress={link => void preview($, fromHref(link.href))}
239      />
240    )
241  })
242
243  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
244    const { Text, Markdown } = $.ui.resolve(e)
245    const path = await read($, current)
246    await read($, rev)
247    if (!path) return <Text dimColor>No file. Click a .md link or run /md path.</Text>
248    const raw = await $.fs.read(path).then(
249      t => t as string,
250      () => `_Cannot read ${path}_`,
251    )
252    const { text, hrefs } = await linkifyExisting($, raw, path.slice(0, path.lastIndexOf('/')))
253
254    return (
255      <Markdown
256        key="md-preview-pane"
257        text={text}
258        pressableLinks={hrefs}
259        onLinkPress={link => void preview($, fromHref(link.href))}
260      />
261    )
262  })
263}
264
types/index.d.ts 8 lines
1export type RecentPaths = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'md-preview': { path: string; rev: number; recent: RecentPaths }
6  }
7}
8