SLOPSHOPPER

md-view

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.

newpanerowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · md-view
│ ┃ Markdown ✕ › fix the failing auth test and add an audit log call │ ┃ No Markdown files in this session yet. │ ⏺ Read(src/auth.ts) │ ⎿ 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-view │ ⎿ md-view: No Markdown files in this session yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Markdown
No Markdown files in this session yet.
README

md-view

Read Markdown files without leaving Claude Code.

  • Click: when Claude mentions a .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.

Install

/plugin marketplace add scoobynko/claude-code-mods
/plugin install md-view@scoobynko-mods

What counts as a file of the session

A path is listed, and linked in replies, once Claude has brought it up and the file exists:

  • a file Claude read, wrote or edited,
  • a .md path in a shell command Claude ran, including a file the command created,
  • a .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.

Limits

  • Clicks reach the mod in the fullscreen terminal ("tui": "fullscreen"). Elsewhere the paths are ordinary file:// links and /md-view still works.
  • Links appear when a reply block finishes, not while it streams, and only in the block that opens a reply (the one with the bullet).
  • Paths are read the macOS and Linux way; Windows paths are not recognised.
  • A preview shows the first 60,000 characters of a file and says so when it cuts.
  • A table wider than the pane is drawn as a list of Header: value rows; a very long table is drawn in parts, each with its header.
  • A reply over 10,000 characters keeps its normal drawing, without links.

Test

claude plugin test plugins/md-view
Source 5 files
hooks/register.tsx 282 lines
1import { 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}
282
hooks/document.ts 202 lines
1import { 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}
202
hooks/paths.ts 94 lines
1import { 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}
94
hooks/fences.ts 20 lines
1const 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}
20
types/index.d.ts 18 lines
1export 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