Preview a markdown file in a pane: /md-preview lists the markdown files edited in this session, and Claude can show one itself.

A Claude Code mod that previews markdown files in a pane, rendered as they stand on disk now.
/md-preview lists the last five markdown files edited in the session, newest first. Pick one with its number or Enter to preview it. With a single file in the list, the preview opens at once./md-preview <path> previews any markdown file.mcp__md-preview__show tool.In the preview, b goes back to the list, r reads the file again, and q or Esc closes it. An open preview follows the file when Claude edits it again.
/md-preview <path> still previews it./md-preview <path> command that opens it.From the repository root:
claude --plugin-dir plugins/md-preview
claude plugin validate plugins/md-preview
claude plugin test plugins/md-preview
| Path | Holds |
|---|---|
.claude-plugin/plugin.json | The manifest |
hooks/register.tsx | The hooks: the command, the tool, edit tracking and the pane |
hooks/paths.ts | Path and text helpers |
types/index.d.ts | The state the mod keeps for the session |
tests/md-preview.test.ts | Tests, run by claude plugin test |
hooks/register.tsx 279 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileView, View } from '../types'
5import {
6 absolute,
7 argOf,
8 clean,
9 isMarkdown,
10 isSameFile,
11 labelOf,
12 nameOf,
13 remember,
14} from './paths'
15
16const PANE = 'md-preview'
17const COMMAND = 'md-preview'
18const TOOL = 'mcp__md-preview__show'
19const MAX_ROWS = 30
20
21const recent = atom({ plugin: 'md-preview', key: 'recent' } as const, [])
22const view = atom({ plugin: 'md-preview', key: 'view' } as const, null)
23
24const load = async ($: EngineInterface, path: string): Promise<FileView> => {
25 try {
26 return { kind: 'file', path, text: clean(await $.fs.read(path)), error: null }
27 } catch (error) {
28 const message = error instanceof Error ? error.message : String(error)
29 const reason = message.includes('ENOENT') ? 'no such file' : message
30
31 return { kind: 'file', path, text: '', error: reason }
32 }
33}
34
35// Opens the preview on the file as it stands on disk now. `isAsked` says the
36// person is behind the open, which is when the pane may take the keyboard.
37const show = async ($: EngineInterface, given: string, isAsked: boolean) => {
38 const path = absolute(given, await $.session.cwd())
39 const file = await load($, path)
40
41 if (file.error !== null) {
42 return { failed: `Could not read ${given}: ${file.error}` }
43 }
44
45 await update($, view, (): View => file)
46
47 return $.ui.open({
48 id: PANE,
49 title: `Preview: ${nameOf(path)}`,
50 closeOnEscape: true,
51 rows: Math.min(file.text.split('\n').length + 2, MAX_ROWS),
52 ...(isAsked ? { focus: true as const } : {}),
53 })
54}
55
56const pick = async ($: EngineInterface, count: number) => {
57 await update($, view, (): View => ({ kind: 'list' }))
58
59 return $.ui.open({
60 id: PANE,
61 title: 'Markdown preview',
62 focus: true,
63 closeOnEscape: true,
64 holdToasts: true,
65 rows: count + 2,
66 })
67}
68
69const press = async ($: EngineInterface, path: string) => {
70 const opened = await show($, path, true)
71
72 if ('failed' in opened) {
73 $.ui.toast(opened.failed)
74 }
75}
76
77const reload = async ($: EngineInterface) => {
78 const current = await read($, view)
79
80 if (current?.kind === 'file') {
81 const file = await load($, current.path)
82 await update($, view, (): View => file)
83 }
84}
85
86const noteEdit = async ($: EngineInterface, given: string) => {
87 const path = absolute(given, await $.session.cwd())
88 await update($, recent, list => remember(list, path))
89 const current = await read($, view)
90
91 if (current?.kind === 'file' && isSameFile(current.path, path)) {
92 await reload($)
93 }
94}
95
96// A resumed session, or a mod loaded mid-session, starts from the edits the
97// transcript already holds.
98const seed = async ($: EngineInterface) => {
99 if ((await read($, recent)).length > 0) {
100 return
101 }
102
103 const cwd = await $.session.cwd()
104 let found: string[] = []
105
106 for (const message of await $.session.messages()) {
107 for (const use of message.toolUses) {
108 const path = use.input.file_path
109 const isEdit = use.tool === 'Edit' || use.tool === 'Write'
110 const hasRun = use.text !== undefined && use.isError !== true
111
112 if (isEdit && hasRun && typeof path === 'string' && isMarkdown(path)) {
113 found = remember(found, absolute(path, cwd))
114 }
115 }
116 }
117
118 if (found.length > 0) {
119 await update($, recent, () => found)
120 }
121}
122
123export const register: Register = on => {
124 on('session.start', async ($, e, next) => {
125 await $.command.register({
126 name: 'md-preview',
127 description: 'Preview a markdown file edited in this session',
128 argumentHint: '[path]',
129 })
130 await $.tool.register({
131 name: 'show',
132 description:
133 'Shows the user a rendered preview of a markdown file in a pane beside the conversation, as the file stands on disk now. Use it when the user should read a markdown file you wrote or changed. The result says whether the preview is on screen; it does not return the file.',
134 inputSchema: {
135 type: 'object',
136 properties: {
137 file_path: {
138 type: 'string',
139 description: 'The markdown file to show (.md), absolute or relative to the working directory',
140 },
141 },
142 required: ['file_path'],
143 },
144 isDeferred: false,
145 })
146 await seed($)
147
148 return next(e)
149 })
150
151 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
152 const ran = await next(e)
153 const hasRun = ran.deny === undefined && ran.isError !== true
154
155 if (hasRun && isMarkdown(e.file_path)) {
156 await noteEdit($, e.file_path)
157 }
158
159 return ran
160 }).catch(($, e, next) => next(e))
161
162 on('tool.call', { tool: TOOL }, async ($, e) => {
163 const path = typeof e.file_path === 'string' ? e.file_path.trim() : ''
164
165 if (!isMarkdown(path)) {
166 return { deny: `${$.plugin.name}: file_path must name a markdown file (.md).` }
167 }
168
169 const opened = await show($, path, false)
170
171 if ('failed' in opened) {
172 return { deny: `${$.plugin.name}: ${opened.failed}` }
173 }
174
175 if (!opened.isPlaced) {
176 $.ui.toast(`A preview of ${nameOf(path)} is ready: /${COMMAND} ${path}`)
177
178 return {
179 result: `The preview of ${path} is ready but not on screen (${opened.reason}). The user can open it with /${COMMAND} ${path}.`,
180 }
181 }
182
183 return { result: `The user now sees a preview of ${path}.` }
184 }).catch($ => ({ deny: `${$.plugin.name}: the preview failed.` }))
185
186 on('command.run', { command: 'md-preview' }, async ($, e) => {
187 const asked = argOf(e.args)
188 const list = await read($, recent)
189 const path = asked !== '' ? asked : list.length === 1 ? list[0] : undefined
190
191 if (path === undefined && list.length === 0) {
192 return {
193 text: `No markdown file has been edited in this session yet. Name one to preview it: /${COMMAND} <path>`,
194 }
195 }
196
197 if (path === undefined) {
198 await pick($, list.length)
199
200 return { text: `Choose one of the ${list.length} markdown files edited in this session.` }
201 }
202
203 const opened = await show($, path, true)
204
205 if ('failed' in opened) {
206 return { text: opened.failed }
207 }
208
209 return {
210 text: opened.isPlaced
211 ? `Previewing ${path}.`
212 : `The preview of ${path} is not on screen: ${opened.reason}`,
213 }
214 })
215
216 on('ui.close', { id: PANE }, async ($, e, next) => {
217 const closed = await next(e)
218 await update($, view, () => null)
219
220 return closed
221 }).catch(($, e, next) => next(e))
222
223 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
224 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
225 const current = await read($, view)
226 const list = await read($, recent)
227 const cwd = await $.session.cwd()
228
229 if (current?.kind !== 'file') {
230 return (
231 <Box flexDirection="column">
232 <Text bold>Markdown files edited in this session</Text>
233 {list.length === 0 && <Text dimColor>None yet.</Text>}
234 {list.map((path, index) => (
235 <Button
236 key={`pick-${index}`}
237 plain
238 hotkey={String(index + 1)}
239 label={labelOf(path, cwd)}
240 autoFocus={index === 0 ? true : undefined}
241 onPress={() => press($, path)}
242 />
243 ))}
244 <Text dimColor>A number or Enter previews, Esc closes.</Text>
245 </Box>
246 )
247 }
248
249 return (
250 <Box flexDirection="column">
251 <Box columnGap={2} flexWrap="wrap">
252 <Text bold>{labelOf(current.path, cwd)}</Text>
253 {list.length > 1 && (
254 <Button key="back" plain hotkey="b" label="Back" onPress={() => pick($, list.length)} />
255 )}
256 <Button key="reload" plain hotkey="r" label="Reload" onPress={() => reload($)} />
257 <Button
258 key="close"
259 plain
260 hotkey="q"
261 label="Close"
262 role="dismiss"
263 onPress={() => $.ui.close({ id: PANE })}
264 />
265 </Box>
266 {current.error !== null && (
267 <Text color="error">Could not read the file: {current.error}</Text>
268 )}
269 {current.error === null && current.text.trim() === '' && (
270 <Text dimColor>The file is empty.</Text>
271 )}
272 {current.error === null && current.text.trim() !== '' && (
273 <Markdown text={current.text} />
274 )}
275 </Box>
276 )
277 })
278}
279hooks/paths.ts 70 lines1export const MAX_RECENT = 5
2
3const BACKSLASH = String.fromCharCode(92)
4const BYTE_ORDER_MARK = String.fromCharCode(0xfeff)
5const MARKDOWN = /[.](md|markdown)$/i
6const DRIVE = /^[a-z]:[/]/i
7const TRAILING_SLASHES = /[/]+$/
8const FRONT_MATTER = /^---\n([^]*?)\n---[ \t]*(\n|$)/
9
10const slashed = (path: string) => path.replaceAll(BACKSLASH, '/')
11
12// Windows paths differ in case and separators without naming another file.
13const keyOf = (path: string) => {
14 const flat = slashed(path)
15
16 return DRIVE.test(flat) ? flat.toLowerCase() : flat
17}
18
19export const isMarkdown = (path: string) => MARKDOWN.test(path)
20
21export const isSameFile = (a: string, b: string) => keyOf(a) === keyOf(b)
22
23export const absolute = (path: string, cwd: string) => {
24 const flat = slashed(path)
25 const isAbsolute = flat.startsWith('/') || DRIVE.test(flat)
26
27 return isAbsolute ? path : `${slashed(cwd).replace(TRAILING_SLASHES, '')}/${flat}`
28}
29
30export const remember = (list: readonly string[], path: string) =>
31 [path, ...list.filter(one => !isSameFile(one, path))].slice(0, MAX_RECENT)
32
33export const nameOf = (path: string) => slashed(path).split('/').pop() || path
34
35export const labelOf = (path: string, cwd: string) => {
36 const root = `${keyOf(cwd).replace(TRAILING_SLASHES, '')}/`
37
38 return keyOf(path).startsWith(root) ? slashed(path).slice(root.length) : path
39}
40
41// What a person types after the command: a path, maybe quoted or @-mentioned.
42export const argOf = (args: string) => {
43 const path = args.trim().replace(/^@/, '')
44 const quote = path[0]
45 const isQuoted =
46 path.length > 1 && (quote === '"' || quote === "'") && path.endsWith(quote)
47
48 return isQuoted ? path.slice(1, -1) : path
49}
50
51// Tab (9) and newline (10) are the only control characters Markdown takes.
52const isDrawable = (character: string) => {
53 const code = character.charCodeAt(0)
54
55 return code === 9 || code === 10 || (code > 31 && code !== 127)
56}
57
58// Front matter reads as a heading unless it is fenced.
59export const clean = (raw: string) => {
60 const unmarked = raw.startsWith(BYTE_ORDER_MARK) ? raw.slice(1) : raw
61 const text = Array.from(unmarked.replace(/\r\n?/g, '\n'))
62 .filter(isDrawable)
63 .join('')
64 const front = FRONT_MATTER.exec(text)
65
66 return front === null
67 ? text
68 : `\`\`\`yaml\n${front[1]}\n\`\`\`\n${text.slice(front[0].length)}`
69}
70types/index.d.ts 15 lines1export type FileView = {
2 kind: 'file'
3 path: string
4 text: string
5 error: string | null
6}
7
8export type View = { kind: 'list' } | FileView
9
10declare module 'claude-code' {
11 interface PluginState {
12 'md-preview': { recent: string[]; view: View | null }
13 }
14}
15