Opens Markdown files rendered in a side pane: from a button on the row that wrote them, when asked in plain words, or by itself for a new report

A Claude Code mod that opens Markdown files, rendered, in a side pane. There is no command to remember.
.md file, a framed button appears under that row: ▶ Open report.md in the side pane..md file opens by itself when the terminal is wide enough for a sidebar.Files under a .claude/ folder (memory, settings, skills) never open by themselves. Files longer than 90,000 characters are cut, with a note.
At the Claude Code prompt in a terminal:
/plugin install markdown-pane --marketplace JorgeRomero123/claude-markdown-pane
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
claude plugin validate .
claude plugin test .
claude --plugin-dir .
The mod API is early access and may change between Claude Code releases.
MIT
hooks/register.tsx 182 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { MarkdownPaneFile } from '../types'
5import { narrow } from './tables'
6
7const TOOL = 'mcp__markdown-pane__show'
8const PANE = 'markdown'
9/** One drawing draws 100,000 characters in all; a longer file is cut here and says so. */
10const ROOM = 90_000
11/** The button's frame, and how it lights up under the pointer. */
12const ACCENT = '#4e9af1'
13const LIT = '#9cc8ff'
14const WASH = '#1f3a5f'
15const shown = atom({ plugin: 'markdown-pane', key: 'shown' } as const, null)
16
17const DESCRIPTION = [
18 'Shows a Markdown file rendered in the side pane, for the person to read.',
19 'Use it when they ask to see, show, open or preview a .md file,',
20 'and after writing a report, plan or document they asked for, so they can read it without leaving the conversation.',
21 'The pane follows later edits to the file by itself.',
22].join(' ')
23
24const isMarkdown = (path: unknown): path is string => typeof path === 'string' && /\.(md|markdown|mdx)$/i.test(path)
25
26/** Files Claude keeps for itself (memory, settings, skills) are not reports: they never open by themselves. */
27const isPrivate = (path: string) => /(^|\/)\.claude\//.test(path)
28
29const nameOf = (path: string) => path.split('/').pop() || path
30
31/** Reads the file into the pane's state; a file that cannot be read is shown as that, not thrown. */
32const load = async ($: EngineInterface, asked: string) => {
33 // The resolved path, so a file opened as `docs/report.md` is known again when a later write names it in full.
34 let path = asked
35 try {
36 path = (await $.fs.stat(asked, { resolve: true })).realPath ?? asked
37 } catch {}
38 let file: MarkdownPaneFile
39 try {
40 file = { path, text: await $.fs.read(path) }
41 } catch (error) {
42 file = { path, text: '', error: error instanceof Error ? error.message : String(error) }
43 }
44 await update($, shown, () => file)
45
46 return file
47}
48
49const open = async ($: EngineInterface, path: string) => {
50 const file = await load($, path)
51 const opened = await $.ui.open({ id: PANE, title: nameOf(path) })
52
53 return { file, isPlaced: opened.isPlaced }
54}
55
56/** The button that opens a file, drawn to be seen: a framed, accented label that lights up under the pointer. */
57const openButton = (els: Pick<Elements['terminal'], 'Box' | 'Button'>, id: string, path: string, onPress: () => void) => {
58 const { Box, Button } = els
59
60 return (
61 <Box
62 key={`frame-${id}`}
63 alignSelf="flex-start"
64 marginLeft={2}
65 paddingX={1}
66 borderStyle="round"
67 borderColor={ACCENT}
68 hover={{ borderColor: LIT, backgroundColor: WASH }}
69 >
70 <Button
71 key={`open-${id}`}
72 plain
73 variant="primary"
74 label={`\u25b6 Open ${nameOf(path)} in the side pane`}
75 hover={{ bold: true, underline: true }}
76 onPress={onPress}
77 />
78 </Box>
79 )
80}
81
82export const register: Register = on => {
83 on('session.start', async ($, e, next) => {
84 await $.tool.register({
85 name: 'show',
86 description: DESCRIPTION,
87 inputSchema: {
88 type: 'object',
89 properties: { path: { type: 'string', description: 'The Markdown file, absolute or relative to the working directory.' } },
90 required: ['path'],
91 },
92 })
93
94 return next(e)
95 })
96
97 on('tool.call', { tool: TOOL }, async ($, e) => {
98 const { path } = e as unknown as { path?: unknown }
99 if (typeof path !== 'string' || path === '') return { deny: 'path must name a file.' }
100 const { file, isPlaced } = await open($, path)
101 if (file.error !== undefined) return { deny: `${path} could not be read: ${file.error}` }
102
103 return {
104 result: isPlaced
105 ? `${nameOf(file.path)} is open in the side pane. (${file.path})`
106 : `${nameOf(file.path)} is ready, but the terminal is too narrow for the pane to open by itself; the person can press the Open button under this row. (${file.path})`,
107 }
108 })
109
110 // A written Markdown file: the pane follows it when it is the one shown, and a new one opens by itself.
111 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
112 const ran = await next(e)
113 if (ran.deny !== undefined || ran.isError || !isMarkdown(e.file_path)) return ran
114 // The file is written: nothing the pane does may fail the call.
115 try {
116 const now = await read($, shown)
117 if (now?.path === e.file_path) await load($, e.file_path)
118 else if (!isPrivate(e.file_path)) await open($, e.file_path)
119 } catch {}
120
121 return ran
122 })
123
124 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
125 const ran = await next(e)
126 if (ran.deny !== undefined || ran.isError) return ran
127 try {
128 if ((await read($, shown))?.path === e.file_path) await load($, e.file_path)
129 } catch {}
130
131 return ran
132 })
133
134 // The show tool's own row says where the file is, and keeps a button that opens it again.
135 on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) => {
136 const path = typeof e.props.output === 'string' ? /\(([^()]+)\)$/.exec(e.props.output)?.[1] : undefined
137 if (path === undefined || e.props.isErrored || e.surface !== 'terminal') return next(e)
138 const { Box, Button, Text } = $.ui.resolve(e)
139
140 return (
141 <Box flexDirection="column">
142 <Text dimColor>{` ${nameOf(path)} is in the side pane.`}</Text>
143 {openButton({ Box, Button }, e.requestId, path, () => void open($, path))}
144 </Box>
145 )
146 })
147
148 // Under the result of a call that wrote or edited a Markdown file: a button that opens it, drawn to be seen.
149 for (const tool of ['Write', 'Edit'] as const) {
150 on('ui.render', { component: 'ToolResult', props: { tool } }, async ($, e, next) => {
151 const result = await next(e)
152 const path = (e.props.output as { filePath?: unknown } | null | undefined)?.filePath
153 if (!isMarkdown(path) || e.props.isErrored) return result
154 const { Box, Button } = $.ui.resolve(e)
155
156 return (
157 <Box flexDirection="column">
158 {result}
159 {openButton({ Box, Button }, e.requestId, path, () => void open($, path))}
160 </Box>
161 )
162 })
163 }
164
165 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
166 const { Box, Text, Markdown, Button } = $.ui.resolve(e)
167 const file = await read($, shown)
168 if (file === null) return <Text dimColor>No file open. Ask to see a Markdown file.</Text>
169 const isCut = file.text.length > ROOM
170
171 return (
172 <Box flexDirection="column">
173 <Text dimColor>{file.path}</Text>
174 {file.error !== undefined && <Text>{`This file could not be read: ${file.error}`}</Text>}
175 {file.error === undefined && <Markdown text={narrow(isCut ? file.text.slice(0, ROOM) : file.text, e.props.bodyColumns - 1)} />}
176 {isCut && <Text dimColor>{`The pane shows the first ${ROOM.toLocaleString('en-US')} characters of this file.`}</Text>}
177 <Button key="reload" label="Reload" hotkey="r" onPress={() => void load($, file.path)} />
178 </Box>
179 )
180 })
181}
182hooks/tables.ts 57 lines1const SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
2const FENCE = /^\s*(```|~~~)/
3
4const cellsOf = (line: string) =>
5 line
6 .trim()
7 .replace(/^\|/, '')
8 .replace(/\|$/, '')
9 .split(/(?<!\\)\|/)
10 .map(cell => cell.trim())
11
12/** A cell's width as drawn, near enough: its text without the marks of emphasis, code and links. */
13const widthOf = (cell: string) => cell.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').length
14
15/**
16 * A table is drawn at its natural width, and one wider than the pane wraps into
17 * noise. This rewrites each table that would not fit `columns` as a list: the
18 * row's first cell in bold, then one `header: cell` line for each other cell.
19 * Tables that fit, and everything inside code fences, are left as written.
20 */
21export const narrow = (text: string, columns: number): string => {
22 const lines = text.split('\n')
23 const out: string[] = []
24 let isFenced = false
25 for (let i = 0; i < lines.length; i++) {
26 const line = lines[i] ?? ''
27 if (FENCE.test(line)) isFenced = !isFenced
28 const isTable = !isFenced && line.includes('|') && SEPARATOR.test(lines[i + 1] ?? '') && (lines[i + 1] ?? '').includes('-')
29 if (!isTable) {
30 out.push(line)
31 continue
32 }
33 const header = cellsOf(line)
34 const rows: string[][] = []
35 let end = i + 2
36 while (end < lines.length && (lines[end] ?? '').includes('|') && (lines[end] ?? '').trim() !== '') {
37 rows.push(cellsOf(lines[end] ?? ''))
38 end++
39 }
40 const widths = header.map((h, c) => Math.max(widthOf(h), ...rows.map(row => widthOf(row[c] ?? ''))))
41 const natural = widths.reduce((sum, w) => sum + w + 3, 1)
42 if (natural <= columns) {
43 out.push(...lines.slice(i, end))
44 } else {
45 for (const row of rows) {
46 out.push(`**${(row[0] ?? '').replace(/^\*\*|\*\*$/g, '')}**`)
47 for (let c = 1; c < header.length; c++) if ((row[c] ?? '') !== '') out.push(`- ${header[c]}: ${row[c]}`)
48 out.push('')
49 }
50 if (out[out.length - 1] === '') out.pop()
51 }
52 i = end - 1
53 }
54
55 return out.join('\n')
56}
57types/index.d.ts 18 lines1export type MarkdownPaneFile = {
2 /** The file's path as it was asked for. */
3 path: string
4 /** Its text as last read; empty when it could not be read. */
5 text: string
6 /** Why it could not be read, when it could not. */
7 error?: string
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'markdown-pane': {
13 /** The file the pane shows; null before any was opened. */
14 shown: MarkdownPaneFile | null
15 }
16 }
17}
18