Click a Markdown file in Claude's reply to read it rendered in a side pane, or list the session's Markdown files with /md-view.

Read Markdown files without leaving Claude Code.
.md file in a reply, the path becomes a link. A single click opens the file rendered in a pane docked on the right, the way /diff docks. Ctrl- or alt-click keeps your terminal's own behaviour./md-view: opens the pane with every Markdown file that has come up in the session, newest first. Arrows and Enter pick one; the last file you viewed is marked and holds the focus./md-view <path>: previews that file directly.The pane shows the file the way a reply is drawn: headings, lists, tables, code fences. Fenced code blocks are indented under their language so they stand apart from prose. Scroll with the wheel, press Esc to close. If Claude edits the file you are looking at, the preview updates.
/plugin marketplace add scoobynko/claude-code-mods
/plugin install md-view@scoobynko-mods
A path is listed, and linked in replies, once Claude has brought it up and the file exists:
.md path in a shell command Claude ran, including a file the command created,.md path in Claude's own text.Paths you type, tool output and URLs are left out. Relative paths resolve against the session's working directory. /md-view <path> previews any text file, but only Markdown files join the list.
"tui": "fullscreen"). Elsewhere the paths are ordinary file:// links and /md-view still works.Header: value rows; a very long table is drawn in parts, each with its header.claude plugin test plugins/md-viewhooks/register.tsx 282 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsStat, Register } from 'claude-code'
3
4import type { MdFile, MdView } from '../types'
5import { CONTROL, MARKDOWN_CHARS, layout, load } from './document'
6import { displayPath, fitStart, hasMention, isMarkdown, linkify, mentionsIn, pathOfHref, resolvePath, splitPath } from './paths'
7import type { Place } from './paths'
8
9type ToolUse = readonly [tool: string, input: unknown]
10
11const PANE = 'md-view'
12const TITLE = 'Markdown'
13const NO_FILES = 'No Markdown files in this session yet.'
14const ICON = 'M↓'
15const ICON_COLOR = 'claude'
16const LAST_VIEWED = 'last viewed'
17const GAP = 2
18const MIN_FOLDER = 4
19const CODE_INDENT = 2
20const MAX_FILES = 200
21const MAX_CANDIDATES = 400
22const MIN_COLUMNS = 20
23const MAX_BYTES = 4 * 1024 * 1024
24const MAX_LINKS = 256
25const MAC_HOME = '/Users/'
26const MAC_BULLET = '⏺'
27const BULLET = '●'
28const FILE_TOOLS = new Set(['Read', 'Write', 'Edit'])
29const EMPTY_VIEW: MdView = { path: '', file: null }
30
31const files = atom({ plugin: 'md-view', key: 'files' } as const, [])
32const view = atom({ plugin: 'md-view', key: 'view' } as const, EMPTY_VIEW)
33
34const fieldOf = (input: unknown, name: string): string => {
35 if (typeof input !== 'object' || input === null) return ''
36 const value = (input as Record<string, unknown>)[name]
37 return typeof value === 'string' ? value : ''
38}
39
40const mentionsOfTool = ([tool, input]: ToolUse): string[] => {
41 if (tool === 'Bash') return mentionsIn(fieldOf(input, 'command'))
42 const path = FILE_TOOLS.has(tool) ? fieldOf(input, 'file_path') : ''
43 return isMarkdown(path) ? [path] : []
44}
45
46const mentionsOf = (text: string, uses: readonly ToolUse[]): string[] => [...mentionsIn(text), ...uses.flatMap(mentionsOfTool)]
47
48const plural = (count: number): string => (count === 1 ? 'file' : 'files')
49
50const failed = (error: string, stamp = ''): MdFile => ({ text: '', totalChars: 0, error, stamp })
51
52const stampOf = (stat: FsStat | null): string => (stat ? `${stat.kind}:${stat.size}:${stat.mtimeMs}` : '')
53
54const unquoted = (text: string): string => text.trim().replace(/^(["'])(.*)\1$/, '$2').replace(/^@/, '')
55
56const quietly = (work: Promise<unknown>): Promise<void> =>
57 work.then(
58 () => undefined,
59 () => undefined,
60 )
61
62async function placeOf($: EngineInterface): Promise<Place> {
63 const [cwd, home] = await Promise.all([$.session.cwd(), $.env.get('HOME')])
64 return { cwd, home: home ?? '' }
65}
66
67async function statOf($: EngineInterface, path: string) {
68 try {
69 return await $.fs.stat(path)
70 } catch {
71 return null
72 }
73}
74
75async function remember($: EngineInterface, raws: string[], place: Place) {
76 const known = await read($, files)
77 const paths = [...new Set(raws.map(raw => resolvePath(raw, place)).reverse())].slice(0, MAX_CANDIDATES)
78 const stats = await Promise.all(paths.map(path => (known.includes(path) ? null : statOf($, path))))
79 const head = paths.filter((path, index) => known.includes(path) || stats[index]?.kind === 'file').slice(0, MAX_FILES)
80 if (head.length === 0 || head.every((path, index) => known[index] === path)) return
81 await update($, files, list => [...head, ...list.filter(path => !head.includes(path))].slice(0, MAX_FILES))
82}
83
84async function note($: EngineInterface, raws: string[]) {
85 if (raws.length > 0) await remember($, raws, await placeOf($))
86}
87
88async function readFile($: EngineInterface, path: string): Promise<MdFile> {
89 const stat = await statOf($, path)
90 if (!stat) return failed('File not found.')
91 const stamp = stampOf(stat)
92 if (stat.kind !== 'file') return failed('Not a file.', stamp)
93 if (stat.size > MAX_BYTES) return failed('Too large to preview.', stamp)
94 try {
95 return { ...load(await $.fs.read(path)), error: '', stamp }
96 } catch {
97 return failed('File not found.')
98 }
99}
100
101async function showFile($: EngineInterface, path: string) {
102 const file = await readFile($, path)
103 await update($, view, (): MdView => ({ path, file }))
104 return file
105}
106
107async function listFiles($: EngineInterface) {
108 await update($, view, (shown): MdView => ({ path: shown.path, file: null }))
109}
110
111async function toStart($: EngineInterface) {
112 await $.ui.scroll({ in: PANE, to: 'start' })
113}
114
115async function present($: EngineInterface, isFocused: boolean) {
116 await $.ui.open({ id: PANE, title: TITLE, closeOnEscape: true, ...(isFocused ? { focus: true as const } : {}) })
117 await quietly(toStart($))
118}
119
120async function pick($: EngineInterface, path: string) {
121 await showFile($, path)
122 await quietly(toStart($))
123}
124
125async function openHref($: EngineInterface, href: string) {
126 const path = pathOfHref(href, await placeOf($))
127 if (!path) return
128 await showFile($, path)
129 await present($, false)
130}
131
132async function refresh($: EngineInterface, path?: string) {
133 const shown = await read($, view)
134 if (!shown.file || (path !== undefined && path !== shown.path)) return
135 if (stampOf(await statOf($, shown.path)) === shown.file.stamp) return
136 const file = await readFile($, shown.path)
137 await update($, view, (now): MdView => (now.file && now.path === shown.path ? { path: now.path, file } : now))
138}
139
140async function afterRun($: EngineInterface, raws: string[]) {
141 if (raws.length === 0) return
142 const place = await placeOf($)
143 await remember($, raws, place)
144 const shown = await read($, view)
145 if (raws.some(raw => resolvePath(raw, place) === shown.path)) await refresh($)
146}
147
148export const register: Register = on => {
149 on('session.start', async ($, e, next) => {
150 await $.command.register({ name: 'md-view', description: 'Preview the Markdown files of this session in a pane' })
151 const said = (await $.session.messages()).filter(message => message.role === 'assistant')
152 await note(
153 $,
154 said.flatMap(message => mentionsOf(message.text, message.toolUses.map(use => [use.tool, use.input] as const))),
155 )
156
157 return next(e)
158 })
159
160 on('turn.step', async function* ($, e, next) {
161 const result = yield* next(e)
162 await quietly(note($, mentionsOf(result.answer, result.toolUses.map(use => [use.name, use.input] as const))))
163
164 return result
165 })
166
167 on('tool.call', { tool: ['Write', 'Edit', 'Bash'] }, async ($, e, next) => {
168 const ran = await next(e)
169 await quietly(afterRun($, e.tool === 'Bash' ? mentionsIn(e.command) : [e.file_path].filter(isMarkdown)))
170
171 return ran
172 })
173
174 on('turn.complete', async ($, e, next) => {
175 await quietly(refresh($))
176
177 return next(e)
178 })
179
180 on('command.run', { command: 'md-view' }, async ($, e) => {
181 const asked = unquoted(e.args)
182 if (!asked) {
183 await listFiles($)
184 await present($, true)
185 const count = (await read($, files)).length
186
187 return { text: count === 0 ? NO_FILES : `Markdown pane: ${count} ${plural(count)}.` }
188 }
189 const place = await placeOf($)
190 const path = resolvePath(asked, place)
191 const file = await showFile($, path)
192 if (isMarkdown(path)) await remember($, [path], place)
193 await present($, true)
194 const shown = displayPath(path, place)
195
196 return { text: file.error ? `Cannot preview ${shown}: ${file.error}` : `Previewing ${shown}.` }
197 })
198
199 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
200 const reply = e.props.text
201 if (!hasMention(reply) || reply.length > MARKDOWN_CHARS || CONTROL.test(reply)) return next(e)
202 if (e.surface === 'terminal' && !e.props.isFirstOfReply) return next(e)
203 const known = await read($, files)
204 if (known.length === 0) return next(e)
205 const place = await placeOf($)
206 const linked = linkify(reply, new Set(known), place)
207 if (linked.hrefs.length === 0 || linked.hrefs.length > MAX_LINKS || linked.text.length > MARKDOWN_CHARS) return next(e)
208 const { Box, Markdown, Text } = $.ui.resolve(e)
209 const body = <Markdown key="reply" text={linked.text} pressableLinks={linked.hrefs} onLinkPress={link => openHref($, link.href)} />
210 if (e.surface !== 'terminal') return body
211
212 return (
213 <Box flexDirection="row" marginTop={1}>
214 <Box minWidth={2}>
215 <Text color="text">{place.home.startsWith(MAC_HOME) ? MAC_BULLET : BULLET}</Text>
216 </Box>
217 <Box flexDirection="column">{body}</Box>
218 </Box>
219 )
220 })
221
222 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
223 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
224 const shown = await read($, view)
225 const place = await placeOf($)
226 const columns = Math.max(MIN_COLUMNS, e.props.bodyColumns)
227
228 if (!shown.file) {
229 const known = await read($, files)
230 const focused = Math.max(0, known.indexOf(shown.path))
231
232 return (
233 <Box flexDirection="column" width={columns} gap={1}>
234 <Text dimColor>{known.length === 0 ? NO_FILES : `${known.length} Markdown ${plural(known.length)}, newest first`}</Text>
235 {known.map((path, index) => {
236 const { folder, name } = splitPath(displayPath(path, place))
237 const isLast = path === shown.path
238 const room = columns - ICON.length - name.length - 2 * GAP - (isLast ? LAST_VIEWED.length + GAP : 0)
239
240 return (
241 <Box flexDirection="row" gap={GAP}>
242 <Text color={ICON_COLOR}>{ICON}</Text>
243 <Button key={`file-${index}`} plain autoFocus={index === focused ? true : undefined} label={name} onPress={() => pick($, path)} />
244 {folder !== '' && room >= MIN_FOLDER && <Text dimColor>{fitStart(folder, room)}</Text>}
245 {isLast && <Text dimColor>{LAST_VIEWED}</Text>}
246 </Box>
247 )
248 })}
249 </Box>
250 )
251 }
252
253 const { text, totalChars, error } = shown.file
254 const parts = error ? [] : layout(text, columns)
255 const cut = `Showing the first ${text.length.toLocaleString('en-US')} of ${totalChars.toLocaleString('en-US')} characters.`
256
257 return (
258 <Box flexDirection="column" width={columns}>
259 <Box paddingRight={2}>
260 <Text dimColor wrap="truncate-start">
261 {displayPath(shown.path, place)}
262 </Text>
263 </Box>
264 <Button key="files" plain dimColor label="← Files" onPress={() => listFiles($)} />
265 {error !== '' && <Text dimColor>{error}</Text>}
266 {error === '' && parts.length === 0 && <Text dimColor>Empty file.</Text>}
267 {parts.map((part, index) => (
268 <Box marginTop={1} flexDirection="column" paddingLeft={part.language === null ? 0 : CODE_INDENT}>
269 {part.language && <Text dimColor>{part.language}</Text>}
270 <Markdown key={`part-${index}`} text={part.text} />
271 </Box>
272 ))}
273 {totalChars > text.length && (
274 <Box marginTop={1}>
275 <Text dimColor>{cut}</Text>
276 </Box>
277 )}
278 </Box>
279 )
280 })
281}
282hooks/document.ts 202 lines1import { fenceAfter, marked } from './fences'
2
3export const MAX_CHARS = 60_000
4export const MARKDOWN_CHARS = 10_000
5export const CHUNK_CHARS = 9_000
6export const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/
7
8const CONTROLS = new RegExp(CONTROL.source, 'g')
9const PIECE_CHARS = CHUNK_CHARS - 300
10const TABLE_CHARS = 4_000
11const TAIL_CHARS = 2_000
12const OPENER_CHARS = 200
13const FRONT_MATTER_CHARS = 4_000
14const FRONT_MATTER = /^---\n(?=[\w-]+:)([\s\S]*?)\n---(\n|$)/
15const RULE_CELL = /^:?-+:?$/
16const TOP_FENCE = /^(?:`{3,}|~{3,})\s*([^\s`]*)/
17const MARKS = /\p{M}/gu
18const WIDE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/gu
19
20export type Loaded = { text: string; totalChars: number }
21export type Part = { text: string; language: string | null }
22
23type Segment = { lines: string[]; language: string | null }
24type Layout = { text: string; columns: number; parts: Part[] }
25
26const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
27
28const endBefore = (text: string, end: number): number =>
29 end < text.length && isHighSurrogate(text.charCodeAt(end - 1)) ? end - 1 : end
30
31const sizeOf = (lines: string[]): number => lines.reduce((sum, line) => sum + line.length + 1, 0)
32
33export const load = (raw: string): Loaded => {
34 const clean = raw.replace(/\r\n?/g, '\n').replace(CONTROLS, '')
35 if (clean.length <= MAX_CHARS) return { text: clean, totalChars: clean.length }
36 const cut = clean.lastIndexOf('\n', MAX_CHARS)
37 const end = cut > MAX_CHARS - TAIL_CHARS ? cut : endBefore(clean, MAX_CHARS)
38 return { text: clean.slice(0, end), totalChars: clean.length }
39}
40
41const fenceFrontMatter = (text: string): string => {
42 const match = FRONT_MATTER.exec(text)
43 if (!match || match[0].length > FRONT_MATTER_CHARS) return text
44 const body = match[1] ?? ''
45 const longest = Math.max(0, ...Array.from(body.matchAll(/`+/g), run => run[0].length))
46 const fence = '`'.repeat(Math.max(3, longest + 1))
47 return `${fence}yaml\n${body}\n${fence}\n${text.slice(match[0].length)}`
48}
49
50const cellsOf = (row: string): string[] =>
51 row
52 .trim()
53 .replace(/^\||\|$/g, '')
54 .split(/(?<!\\)\|/)
55 .map(cell => cell.trim())
56
57const shownLength = (cell: string): number => {
58 const shown = cell.replace(/\[([^\][]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]/g, '').replace(MARKS, '')
59 return [...shown].length + (shown.match(WIDE)?.length ?? 0)
60}
61
62const stack = (header: string[], rows: string[][]): string[] =>
63 rows.flatMap((row, index) => [
64 ...(index > 0 ? [''] : []),
65 ...row.flatMap((cell, column) => (cell ? [`- **${header[column] ?? ''}:** ${cell}`] : [])),
66 ])
67
68const repeatHead = (head: string[], body: string[]): string[] => {
69 const out = [...head]
70 let size = sizeOf(head)
71 for (const row of body) {
72 if (size + row.length + 1 > TABLE_CHARS && size > sizeOf(head)) {
73 out.push('', ...head)
74 size = sizeOf(head)
75 }
76 out.push(row)
77 size += row.length + 1
78 }
79 return out
80}
81
82const narrowTables = (lines: string[], columns: number): string[] => {
83 const rows = marked(lines)
84 const isRow = (index: number) => {
85 const row = rows[index]
86 return row !== undefined && !row.isCode && row.line.includes('|') && row.line.trim() !== ''
87 }
88 const out: string[] = []
89 for (let i = 0; i < lines.length; i++) {
90 const line = lines[i] ?? ''
91 const header = cellsOf(line)
92 const rule = cellsOf(lines[i + 1] ?? '')
93 const isTable = isRow(i) && !rows[i + 1]?.isCode && rule.length === header.length && rule.every(cell => RULE_CELL.test(cell))
94 let end = i + 2
95 while (isTable && isRow(end)) end++
96 if (!isTable || end === i + 2) {
97 out.push(line)
98 continue
99 }
100 const body = lines.slice(i + 2, end).map(cellsOf)
101 const width = header.reduce(
102 (sum, cell, column) => sum + 3 + Math.max(shownLength(cell), ...body.map(row => shownLength(row[column] ?? ''))),
103 1,
104 )
105 out.push(...(width > columns ? stack(header, body) : repeatHead(lines.slice(i, i + 2), lines.slice(i + 2, end))))
106 i = end - 1
107 }
108 return out
109}
110
111const blocksOf = (lines: string[]): string[][] => {
112 const blocks: string[][] = [[]]
113 let fence = ''
114 for (const line of lines) {
115 const next = fenceAfter(fence, line)
116 if (!fence && !next && line.trim() === '') blocks.push([])
117 else blocks.at(-1)?.push(line)
118 fence = next
119 }
120 if (fence) blocks.at(-1)?.push(fence)
121 return blocks.filter(block => block.length > 0)
122}
123
124const split = (block: string[]): string[][] => {
125 const pieces: string[][] = []
126 let current: string[] = []
127 let size = 0
128 let fence = ''
129 let opener = ''
130 const flush = () => {
131 pieces.push(fence ? [...current, fence] : current)
132 current = fence ? [opener] : []
133 size = sizeOf(current)
134 }
135 for (const line of block) {
136 const next = fenceAfter(fence, line)
137 const closing = fence ? fence.length + 1 : 0
138 const isClosing = fence !== '' && next === ''
139 let at = 0
140 do {
141 const end = endBefore(line, Math.min(line.length, at + PIECE_CHARS))
142 if (!isClosing && current.length > 0 && size + end - at + 1 + closing > CHUNK_CHARS) flush()
143 current.push(line.slice(at, end))
144 size += end - at + 1
145 at = end
146 } while (at < line.length)
147 if (!fence && next) opener = line.length <= OPENER_CHARS ? line : next
148 fence = next
149 }
150 if (current.length > 0) pieces.push(current)
151 return pieces
152}
153
154const chunk = (lines: string[]): string[] => {
155 const chunks: string[][] = []
156 let size = 0
157 for (const block of blocksOf(lines).flatMap(block => (sizeOf(block) > CHUNK_CHARS ? split(block) : [block]))) {
158 const blockSize = sizeOf(block)
159 const open = chunks.at(-1)
160 if (open && size + 1 + blockSize <= CHUNK_CHARS) {
161 open.push('', ...block)
162 size += 1 + blockSize
163 } else {
164 chunks.push([...block])
165 size = blockSize
166 }
167 }
168 return chunks.map(block => block.join('\n'))
169}
170
171const segmentsOf = (lines: string[]): Segment[] => {
172 const segments: Segment[] = [{ lines: [], language: null }]
173 let fence = ''
174 for (const line of lines) {
175 const next = fenceAfter(fence, line)
176 const language = !fence && next ? (TOP_FENCE.exec(line)?.[1] ?? null) : null
177 if (language !== null) segments.push({ lines: [], language })
178 const open = segments.at(-1)
179 open?.lines.push(line)
180 if (fence && !next && open?.language !== null) segments.push({ lines: [], language: null })
181 fence = next
182 }
183 const open = segments.at(-1)
184 if (fence && open?.language !== null) open?.lines.push(fence)
185 return segments.filter(segment => segment.lines.length > 0)
186}
187
188const partsOf = ({ lines, language }: Segment): Part[] => {
189 if (language === null) return chunk(lines).map(text => ({ text, language }))
190 const pieces = sizeOf(lines) > CHUNK_CHARS ? split(lines) : [lines]
191 return pieces.map(piece => ({ text: piece.join('\n'), language }))
192}
193
194let last: Layout | null = null
195
196export const layout = (text: string, columns: number): Part[] => {
197 if (last?.text === text && last.columns === columns) return last.parts
198 const parts = segmentsOf(narrowTables(fenceFrontMatter(text).split('\n'), columns)).flatMap(partsOf)
199 last = { text, columns, parts }
200 return parts
201}
202hooks/paths.ts 94 lines1import { marked } from './fences'
2
3export type Place = { cwd: string; home: string }
4export type Linkified = { text: string; hrefs: string[] }
5
6type Found = { link?: string; label?: string; target?: string; ticks?: string; code?: string; url?: string; at?: string; token?: string }
7
8const NAME = String.raw`\p{L}\p{M}\p{N}_`
9const TOKEN = String.raw`[${NAME}.+~\/-][${NAME}@.+~\/-]*\.md`
10const BEFORE = String.raw`(?<![${NAME}@.+~\/:-])`
11const AFTER = String.raw`(?![${NAME}@+~\/-]|\.[${NAME}])`
12const LINK = String.raw`(?<link>\[(?<label>(?:[^\]\n\\]|\\.)*)\]\((?<target>[^)\n]*)\))`
13const CODE = '(?<!`)(?<ticks>`+)(?!`)(?<code>.+?)(?<!`)\\k<ticks>(?!`)'
14const URL_TEXT = String.raw`[a-z][a-z0-9+.-]{0,31}:\/\/[^\s<>]+`
15const URLS = new RegExp(URL_TEXT, 'gi')
16const MENTION = new RegExp(`${BEFORE}@?(${TOKEN})${AFTER}`, 'giu')
17const INLINE = new RegExp(`${LINK}|${CODE}|(?<url>${URL_TEXT})|${BEFORE}(?<at>@?)(?<token>${TOKEN})${AFTER}`, 'giu')
18const WHOLE = new RegExp(`^${TOKEN}$`, 'iu')
19const FILE_URL = /^file:\/\//i
20const SCHEME = /^[a-z][a-z0-9+.-]*:/i
21const MARKDOWN_FILE = /\.md$/i
22
23export const isMarkdown = (path: string): boolean => MARKDOWN_FILE.test(path)
24
25export const hasMention = (text: string): boolean => /\.md(?![\w-])/i.test(text)
26
27export const mentionsIn = (text: string): string[] =>
28 Array.from(text.replace(URLS, ' ').matchAll(MENTION), match => match[1] ?? '')
29
30export const resolvePath = (raw: string, { cwd, home }: Place): string => {
31 const joined = raw.startsWith('~/') ? `${home}/${raw.slice(2)}` : raw.startsWith('/') ? raw : `${cwd}/${raw}`
32 const parts: string[] = []
33 for (const part of joined.split('/')) {
34 if (part === '' || part === '.') continue
35 if (part === '..') parts.pop()
36 else parts.push(part)
37 }
38 return `/${parts.join('/')}`
39}
40
41export const hrefOf = (path: string): string =>
42 `file://${encodeURI(path).replace(/[()#?]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}`
43
44export const pathOfHref = (href: string, place: Place): string | null => {
45 const target = href.trim().replace(/\s+["'].*$/, '').replace(/^<|>$/g, '').replace(/#.*$/, '')
46 const isFileUrl = FILE_URL.test(target)
47 if (!isFileUrl && (SCHEME.test(target) || !isMarkdown(target))) return null
48 try {
49 return resolvePath(decodeURIComponent(isFileUrl ? target.slice(7) : target), place)
50 } catch {
51 return null
52 }
53}
54
55export const displayPath = (path: string, { cwd, home }: Place): string => {
56 if (path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1)
57 if (home && path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`
58 return path
59}
60
61export const splitPath = (path: string): { folder: string; name: string } => {
62 const at = path.lastIndexOf('/') + 1
63 return { folder: path.slice(0, at), name: path.slice(at) }
64}
65
66export const fitStart = (text: string, columns: number): string =>
67 text.length > columns ? `…${text.slice(text.length - columns + 1)}` : text
68
69export const linkify = (text: string, known: ReadonlySet<string>, place: Place): Linkified => {
70 const hrefs = new Set<string>()
71 const hrefFor = (raw: string) => {
72 const path = resolvePath(raw, place)
73 if (!known.has(path)) return null
74 const href = hrefOf(path)
75 hrefs.add(href)
76 return href
77 }
78 const inline = (line: string) =>
79 line.replace(INLINE, (whole: string, ...rest: unknown[]) => {
80 const { link, label = '', target = '', ticks, code = '', url, at = '', token = '' } = rest.at(-1) as Found
81 if (url) return whole
82 if (link) {
83 const path = pathOfHref(target, place)
84 const href = path ? hrefFor(path) : null
85 return href ? `[${label}](${href})` : whole
86 }
87 const href = ticks ? (WHOLE.test(code.trim()) ? hrefFor(code.trim()) : null) : hrefFor(token)
88 if (!href) return whole
89 return ticks ? `[${whole}](${href})` : `${at}[${token}](${href})`
90 })
91 const lines = marked(text.split('\n')).map(({ line, isCode }) => (isCode ? line : inline(line)))
92 return { text: lines.join('\n'), hrefs: [...hrefs] }
93}
94hooks/fences.ts 20 lines1const FENCE = /^(?:[\s>]|[-*+]\s|\d+[.)]\s)*(`{3,64}|~{3,64})(.*)$/
2
3export type Marked = { line: string; isCode: boolean }
4
5export const fenceAfter = (open: string, line: string): string => {
6 const [, mark, rest = ''] = FENCE.exec(line) ?? []
7 if (!mark) return open
8 if (!open) return mark.startsWith('`') && rest.includes('`') ? '' : mark
9 return mark[0] === open[0] && mark.length >= open.length && rest.trim() === '' ? '' : open
10}
11
12export const marked = (lines: string[]): Marked[] => {
13 let fence = ''
14 return lines.map(line => {
15 const wasOpen = fence !== ''
16 fence = fenceAfter(fence, line)
17 return { line, isCode: wasOpen || fence !== '' }
18 })
19}
20types/index.d.ts 18 lines1export type MdFile = {
2 text: string
3 totalChars: number
4 error: string
5 stamp: string
6}
7
8export type MdView = {
9 path: string
10 file: MdFile | null
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'md-view': { files: string[]; view: MdView }
16 }
17}
18