SLOPSHOPPER

md-preview

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

paneguardcommandtoasttool
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 │ ┃ Markdown files edited in this session │ ┃ None yet. ⏺ Read(src/auth.ts) │ ┃ A number or Enter previews, Esc closes. ⎿ 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-preview │ ⎿ md-preview: No markdown file has been edited in this session yet │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · md-preview
Markdown files edited in this session None yet. A number or Enter previews, Esc closes.
README

md-preview

A Claude Code mod that previews markdown files in a pane, rendered as they stand on disk now.

Use

  • /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.
  • Claude can show a file itself through the 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.

Limits

  • Only edits made with the Edit and Write tools are tracked. A file changed by a shell command is not in the list, but /md-preview <path> still previews it.
  • A preview Claude opens through the tool is placed only on a terminal at least 144 columns wide, or 110 once you have opened the pane yourself. Below that a toast names the /md-preview <path> command that opens it.
  • Files Claude shows through the tool do not join the list.

Run

From the repository root:

claude --plugin-dir plugins/md-preview

Check

claude plugin validate plugins/md-preview
claude plugin test plugins/md-preview

Layout

PathHolds
.claude-plugin/plugin.jsonThe manifest
hooks/register.tsxThe hooks: the command, the tool, edit tracking and the pane
hooks/paths.tsPath and text helpers
types/index.d.tsThe state the mod keeps for the session
tests/md-preview.test.tsTests, run by claude plugin test
Source 3 files
hooks/register.tsx 279 lines
1import { 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}
279
hooks/paths.ts 70 lines
1export 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}
70
types/index.d.ts 15 lines
1export 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