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

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.
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.
Click any of these in a reply from Claude:
plan 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.
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.
Plain clicks reach the mod in the fullscreen terminal layout and in the desktop app. In the standard terminal layout, use /md <path>.
.md file inside the browser preview opens the raw file.claude --plugin-dir .
claude plugin validate .
claude plugin test .
claude plugin test . runs two kinds of test:
hooks/linkify.test.ts, hooks/preview.test.ts) check the link and page-building functions on their own.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.assets/ holds copies of these files:
github and github-dark stylesscripts/update-assets.sh holds the pinned versions and downloads each file from jsDelivr. To update a library:
scripts/update-assets.sh.scripts/update-assets.sh.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.
MIT. See LICENSE.
hooks/register.tsx 264 lines1import { 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, '<')}</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}
264types/index.d.ts 8 lines1export type RecentPaths = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'md-preview': { path: string; rev: number; recent: RecentPaths }
6 }
7}
8