Click a .md path in the Claude Code conversation to read it rendered as markdown beside the session, pictures included, and point at any block to have Claude…

Markdown, rendered right where you are already looking.<br> Click a .md path Claude mentions and read the file beside the conversation, with headings, tables, code and pictures. Then point at any block and tell Claude what to change.
English · 中文
<img src="assets/pane.svg" alt="A markdown file rendered in a pane beside the Claude Code conversation" width="820">
Claude writes its plans, reports and notes as markdown files and gives you the path. Reading one used to mean leaving the session for an editor or a browser, and editing it meant describing a spot in a file you could not see.
mdview is a Claude Code mod that closes that loop. Paths become links. A click opens the file in a pane beside the conversation, drawn by Claude Code's own markdown renderer, so a plan reads like the reply it came from. When something needs to change, you point at the block and say so. Claude makes the edit, and the pane redraws as it lands.
| One click to read | A .md path that names a real file gets a ↗, whether it is in backticks, bare, a markdown link or run into Chinese text. Links appear in replies, under Read / Write / Edit rows and under your own prompts. path:42 and path#heading land on the spot |
| A reader, not a dump | Sections with a Contents list (t), Find (f), long files in parts (p n), links between files followed in place, and Back (b) |
| Pictures in the terminal | The terminal's own sharp picture in Ghostty, kitty and iTerm2 3.7+, and colored half blocks anywhere else. PNG, JPEG, GIF and HEIC all work |
| Point, then edit | Hover a block and it lights up with a ✎. Say what to change and press Enter; Claude gets the file, the lines and how they begin, and the block shows how the edit is going |
| Live | A file Claude writes redraws at once; one changed elsewhere within 1.5 s |
| Your viewer, per terminal | A click opens mdview's pane, Warp's own Markdown viewer split beside the session, or the app macOS opens .md with. /md mode switches |
| More than markdown | Any text file opens as highlighted code with line numbers. Common HTML and too-wide tables are turned into something a terminal can show |
| Claude can show you | Ask to see a file and Claude opens it with mcp__mdview__show: in the pane, or in Warp's viewer in Warp |
<table> <tr> <td width="62%" valign="top">
Point at a block, say the change
<img src="assets/edit.svg" alt="A hovered block with its edit bar, and the prompt it sent to Claude" width="100%">
</td> <td width="38%" valign="top">
Hover a paragraph, list, table, code block or picture. It lights up, and a ✎ shows at its right end.
Click ✎ and type the change, such as make it a checklist. Enter sends it as your prompt, naming the file, the lines and how they begin.
Claude edits. A line under the block follows the edit, as Claude Code hangs a result under its row: ✶ Waiting for Claude… while another turn runs, ✶ Claude is editing… once its own starts, then Updated when Claude wrote the file, or No changes made. The pane redraws in place, and the line goes a few seconds later. Send several and each keeps its own line; a prompt that cannot be sent says why.
If the file shifts under an open bar, the bar follows its block; if the block is gone, the bar closes.
</td> </tr> </table>
Needs Claude Code 2.1.287 or later (the release that brought mods; tested on 2.1.288), in the fullscreen layout (/tui fullscreen), the only layout where a click reaches a mod. Mods are early access, and their API may change between releases.
/plugin marketplace add xuanji86/claude-mdview
/plugin install mdview@claude-mdview
git clone https://github.com/xuanji86/claude-mdview ~/Desktop/claude-mdview
Then add it to ~/.claude/settings.json (several folders separate with :):
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/Desktop/claude-mdview" } }
Or load it for one session only: claude --plugin-dir ~/Desktop/claude-mdview.
Click a ↗ path | Opens it, where your setting says (see Configure) | |
| `/md <path>[#heading\ | :line]` | Opens any file in the pane; /md alone reopens the last one |
/md mode [choice] | Switches what a click opens in this terminal; alone, moves to the next choice | |
Esc · q | Closes the pane | |
b | Back to the file you came from | |
t · f | Contents · Find (Enter again for the next match) | |
p · n | Previous · next part of a long file, also at the foot of each part | |
w | In Warp: this file in Warp's own viewer | |
Hover → ✎ | Edit that block with Claude |
The pane lists its keys in one dim row under the file's name, each a click away too. They work while the pane holds the keyboard, which it takes when it opens over an empty prompt; a click on the pane gives the keyboard back to it.
In /config, the rows Click opens and Click opens (Warp). /md mode sets the same values.
| Setting | Choices | Default |
|---|---|---|
clickOpens | pane: mdview's pane · app: the app macOS opens .md files with | pane |
clickOpensInWarp | warp: Warp's Markdown viewer, split to the right · pane · app | warp |
If the app or Warp cannot open a file, the click falls back to the pane. The app always opens at the top of the file; :line works in Warp's viewer and in the pane. To change the app, use Finder: Get Info › Open with › Change All.
| Terminal | Pictures | A click opens, by default |
|---|---|---|
| Ghostty, kitty | Sharp: the terminal's own picture | mdview's pane |
| iTerm2 3.7+ | Sharp, with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 set for iTerm2 only (see below). Without it, half blocks | mdview's pane |
| Warp | Half blocks in the pane; sharp in Warp's own viewer | Warp's viewer, split to the right |
| Others | Colored half blocks (▀, two pixels a cell) | mdview's pane |
Claude Code draws pictures only in terminals it trusts to place them: kitty and Ghostty. iTerm2 3.7 supports the same kitty graphics protocol, Unicode placeholders included, so it can be switched on there. Put it in ~/.zshrc for iTerm2 alone:
[[ $TERM_PROGRAM == iTerm.app ]] && export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
Do not set it for Warp: Warp has no Unicode placeholders yet (warpdotdev/Warp#6210), and forcing pictures garbles the screen. Formats other than PNG, and every half-block picture, go through macOS's sips; without it you see their alt text.
mdview reads, draws, and acts only when you ask it to. claude plugin validate . prints every hook and call it makes.
| It | When |
|---|---|
| reads a file | its path is in the conversation (to see that it exists), or you open it |
runs sips | it draws a picture other than a PNG, or any picture as half blocks: into a file under $TMPDIR (a half-block one is removed once read) |
runs open | you click with app or warp chosen, for markdown files only. The model's show tool never uses it, so it cannot launch a script or an app |
| sends a prompt | you press Enter in a ✎ bar, and only with the text you typed |
| changes a setting | you run /md mode |
It makes no network requests, stores nothing across sessions, and calls no model of its own.
/md.⏺ along.claude plugin validate .
claude plugin test . # 38 tests
python3 assets/make_previews.py # the README pictures
A saved file reloads the mod in every session that loads it from this folder.
MIT © Anji Xu
hooks/register.tsx 851 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { MdviewDoc, MdviewEdit, MdviewFind, MdviewPending } from '../types'
5import { blocksOf, bmpPixels, clean, fitCells, halfBlocks, hashOf, html, paginate, pngSize, sectionAt, sections, split, tables } from './doc'
6import { basename, dirname, FENCE, hrefOf, linkify, MD, refOf, resolveRef } from './links'
7import type { Ref } from './links'
8
9const PANE = 'mdview'
10const doc = atom({ plugin: 'mdview', key: 'doc' } as const, null as MdviewDoc | null)
11const rev = atom({ plugin: 'mdview', key: 'rev' } as const, 0)
12const toc = atom({ plugin: 'mdview', key: 'toc' } as const, false)
13const find = atom({ plugin: 'mdview', key: 'find' } as const, null as MdviewFind | null)
14const edit = atom({ plugin: 'mdview', key: 'edit' } as const, null as MdviewEdit | null, { shape: 'line+hash' })
15const pending = atom({ plugin: 'mdview', key: 'pending' } as const, [] as MdviewPending[], { shape: 'id+text+turn+changed+done' })
16const cwdHistory = atom({ plugin: 'mdview', key: 'cwds' } as const, [] as string[])
17
18const TEXT_MAX = 9_000 // a Markdown or Code element takes at most 10,000 characters
19const PAGE_MAX = 60_000 // a page of a long file: a whole tree tops out near 100k characters
20const MAYBE_MD = /\.(?:md|markdown|mdx)\b/i
21const MARK = '↗' // after each path a click opens here, so it reads as clickable
22const PNG = /\.png$/i
23const IMAGE = /\.(?:png|jpe?g|gif|webp|svg|bmp|tiff?|heic)$/i
24const LONE_IMAGE = /^\s*!\[([^\]\n]*)\]\(<?([^)\s>]+)>?(?:\s+"[^"]*")?\)\s*$/
25const ANY_IMAGE = /!\[([^\]\n]*)\]\(/g
26
27type Part =
28 | { kind: 'md'; text: string; hrefs: string[] }
29 | { kind: 'img'; file: string; alt: string; columns: number; rows: number }
30 | { kind: 'raster'; file: string; cells: string; alt: string; columns: number; rows: number }
31 | { kind: 'code'; source: string; path: string; startLine: number }
32// A stretch the pane jumps to: a section of a markdown file, or a run of lines of any other.
33// A blank-line separated block of the file (a paragraph, list, table, fence, heading): the lines it spans, how it
34// begins, and what draws it.
35type Block = { line: number; end: number; first: string; hash: string; parts: Part[] }
36type Unit = { level: number; title: string; slug: string; line: number; end: number; plain: string; blocks: Block[] }
37type View = { units: Unit[]; pages: number[]; lines: number } | { note: string }
38
39// Module state: a reload starts these over, which costs only a few stats.
40let home = ''
41let columns = 0 // the widest the terminal has been seen
42let paneColumns = 80
43let isOpen = false
44const known = new Set<string>() // files seen to exist
45// A transcript row's working directory when first drawn: its relative paths are resolved there first, so a cd does not
46// move them (lost on a reload, when the session's directory history stands in).
47const cwdOf = new Map<string, string>()
48const views = new Map<string, View>() // lazy: the last 8 files drawn, by path, mtime and width
49const sizes = new Map<string, { width: number; height: number } | null>() // lazy: a picture's size, never re-read
50let pictures = false // the terminal draws an Image itself (kitty's graphics protocol); elsewhere pictures are Rasters
51let isWarp = false // Warp has a Markdown viewer of its own, a click away from the pane
52// What a click on a path in the conversation opens (userConfig): outside Warp `clickOpens`, in Warp `clickOpensInWarp`.
53type Mode = 'pane' | 'warp' | 'app'
54const SETTING = {
55 warp: { key: 'clickOpensInWarp', choices: ['warp', 'pane', 'app'] as Mode[] },
56 other: { key: 'clickOpens', choices: ['pane', 'app'] as Mode[] },
57} as const
58const modes: Record<keyof typeof SETTING, Mode> = { warp: 'warp', other: 'pane' }
59const here = (): keyof typeof SETTING => (isWarp ? 'warp' : 'other')
60const modeHere = (): Mode => modes[here()]
61const asMode = (value: unknown, choices: readonly Mode[], fallback: Mode): Mode => choices.find(m => m === value) ?? fallback
62const MODE_TEXT: Record<Mode, string> = {
63 pane: "mdview's pane",
64 warp: "Warp's Markdown viewer",
65 app: 'the app macOS opens .md files with',
66}
67let tmp = '/tmp'
68let made = 0
69
70const tilde = (p: string): string => (home && (p === home || p.startsWith(home + '/')) ? '~' + p.slice(home.length) : p)
71// lines as an editor counts them: a final newline ends the last line rather than starting one
72const lineCount = (s: string): number => (s ? s.replace(/\n$/, '').split('\n').length : 0)
73const plainTitle = (t: string): string => t.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[`*~]/g, '')
74const note = (text: string): View => ({ note: text })
75const keyOf = (unit: number, part: number) => (part ? `u${unit}-${part}` : `u${unit}`)
76
77// A working directory the session moved to, kept in the session's state so a reload of the mod keeps it.
78async function remember($: EngineInterface, cwd: string) {
79 if ((await read($, cwdHistory))[0] === cwd) return // unchanged: no write, so no transcript row redraws
80 await update($, cwdHistory, list => [cwd, ...(list ?? []).filter(c => c !== cwd)].slice(0, 10))
81}
82
83async function homeDir($: EngineInterface) {
84 return (home ||= (await $.env.get('HOME')) ?? '')
85}
86
87// `text` with every reference to an existing file made a file:// link (markdown files, and with `anyLink` any
88// file a markdown link names), tried against each of `bases` in turn; null when there is none.
89async function linkFiles($: EngineInterface, text: string, bases: readonly string[], opts: { self?: string; anyLink?: boolean } = {}) {
90 await homeDir($)
91 const found = new Map<string, Ref[]>()
92 linkify(text, (raw, kind) => {
93 const refs: Ref[] = []
94 for (const base of bases) {
95 const ref = resolveRef(raw, base, home, { anyExt: opts.anyLink && kind === 'link', self: opts.self })
96 if (ref && !refs.some(r => r.abs === ref.abs)) refs.push(ref)
97 }
98 if (refs.length) found.set(`${kind}|${raw}`, refs)
99 return null
100 })
101 if (found.size === 0) return null
102 await Promise.all(
103 [...new Set([...found.values()].flat().map(r => r.abs))]
104 .filter(p => !known.has(p))
105 .map(async p => {
106 const st = await $.fs.stat(p).catch(() => null)
107 if (st?.kind === 'file') known.add(p)
108 }),
109 )
110 const hrefs = new Set<string>()
111 const out = linkify(
112 text,
113 (raw, kind) => {
114 const ref = found.get(`${kind}|${raw}`)?.find(r => known.has(r.abs))
115 if (!ref) return null
116 try {
117 const h = hrefOf(ref)
118 hrefs.add(h)
119 return h
120 } catch {
121 return null // a lone surrogate encodeURI refuses
122 }
123 },
124 MARK,
125 )
126 return hrefs.size ? { text: out, hrefs: [...hrefs] } : null
127}
128
129const mdParts = (text: string, hrefs: readonly string[]): Part[] =>
130 split(text, TEXT_MAX).map(t => ({ kind: 'md', text: t, hrefs: hrefs.filter(h => t.includes(`](${h})`)).slice(0, 256) }))
131
132// macOS's own image tool: what converts and scales any picture format here. Null where it is missing or fails.
133async function sips($: EngineInterface, args: string[]): Promise<string | null> {
134 const run = await $.process.run(['sips', ...args], { timeoutMs: 15_000 }).catch(() => null)
135 return run?.exitCode === 0 ? run.stdout : null
136}
137
138async function imageSize($: EngineInterface, file: string) {
139 if (!sizes.has(file)) {
140 let size: { width: number; height: number } | null = null
141 if (PNG.test(file)) {
142 const bytes = await $.fs.read(file, { as: 'bytes' }).catch(() => null)
143 size = bytes ? pngSize(bytes.base64) : null
144 }
145 if (!size) {
146 const out = (await sips($, ['-g', 'pixelWidth', '-g', 'pixelHeight', file])) ?? ''
147 const width = Number(/pixelWidth: (\d+)/.exec(out)?.[1])
148 const height = Number(/pixelHeight: (\d+)/.exec(out)?.[1])
149 size = width > 0 && height > 0 ? { width, height } : null
150 }
151 sizes.set(file, size)
152 }
153 return sizes.get(file) ?? null
154}
155
156// A local picture drawn in place, sized to the pane and kept to its proportions: the terminal's own picture where it
157// draws one (another format turned into a PNG first), elsewhere colored half-block cells any truecolor terminal shows.
158async function imagePart($: EngineInterface, src: string, alt: string, base: string): Promise<Part | null> {
159 const ref = resolveRef(src, base, home, { anyExt: true })
160 if (!ref || !IMAGE.test(ref.abs) || (await $.fs.stat(ref.abs).catch(() => null))?.kind !== 'file') return null
161 const size = await imageSize($, ref.abs)
162 if (!size) return null
163 alt ||= basename(ref.abs)
164 if (pictures) {
165 let file = ref.abs
166 if (!PNG.test(file)) {
167 file = `${tmp}/mdview-${[...ref.abs].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7)}.png` // left in place: the terminal reads it
168 if ((await sips($, ['-s', 'format', 'png', ref.abs, '--out', file])) === null) return null
169 }
170 return { kind: 'img', file, alt, ...fitCells(size.width, size.height, Math.min(paneColumns - 2, 80), 40) }
171 }
172 const box = fitCells(size.width, size.height, Math.min(paneColumns - 2, 80), 24) // lazy: 24 rows keeps a page's cells small; tall pictures shrink
173 const bmp = `${tmp}/mdview-${made++}.bmp`
174 if ((await sips($, ['-s', 'format', 'bmp', '-z', String(box.rows * 2), String(box.columns), ref.abs, '--out', bmp])) === null) return null
175 const bytes = await $.fs.read(bmp, { as: 'bytes' }).catch(() => null)
176 void $.process.run(['rm', '-f', bmp]).catch(() => null)
177 const px = bytes ? bmpPixels((Uint8Array as unknown as { fromBase64: (s: string) => Uint8Array }).fromBase64(bytes.base64)) : null
178 if (!px) return null
179 const cells = new Uint8Array(halfBlocks(px, box.columns, box.rows).buffer) as Uint8Array & { toBase64: () => string }
180 return { kind: 'raster', file: ref.abs, cells: cells.toBase64(), alt, ...box }
181}
182
183// One section of a markdown file as the pane draws it: HTML and too-wide tables turned to markdown, local PNGs on
184// a line of their own drawn as pictures, other pictures a link, file references links.
185async function sectionParts($: EngineInterface, text: string, path: string): Promise<Part[]> {
186 const base = dirname(path)
187 const parts: Part[] = []
188 let buf: string[] = []
189 const flush = async () => {
190 const md = buf.join('\n').replace(/^\n+|\n+$/g, '')
191 buf = []
192 if (!md) return
193 const linked = await linkFiles($, md, [base], { self: path, anyLink: true })
194 parts.push(...mdParts(linked?.text ?? md, linked?.hrefs ?? []))
195 }
196 let fence: string | null = null
197 for (const line of tables(html(text), paneColumns - 2).split('\n')) {
198 const f = FENCE.exec(line)?.[1]
199 if (fence || f) {
200 if (fence && f && f[0] === fence[0] && f.length >= fence.length) fence = null
201 else if (!fence && f) fence = f
202 buf.push(line)
203 continue
204 }
205 const lone = LONE_IMAGE.exec(line)
206 const img = lone ? await imagePart($, lone[2]!, lone[1]!, base) : null
207 if (img) {
208 await flush()
209 parts.push(img)
210 } else buf.push(line.replace(ANY_IMAGE, (_, alt: string) => `[🖼 ${alt || 'image'}](`))
211 }
212 await flush()
213 return parts
214}
215
216async function build($: EngineInterface, path: string): Promise<View> {
217 const link = `[${basename(path)}](${hrefOf({ abs: path, frag: '' })})`
218 if (IMAGE.test(path)) {
219 const img = await imagePart($, path, basename(path), '/')
220 return img
221 ? { units: [{ level: 0, title: '', slug: '', line: 1, end: 1, plain: '', blocks: [{ line: 1, end: 1, first: '', hash: '', parts: [img] }] }], pages: [0], lines: 0 }
222 : note(`This picture can't be drawn here. Open ${link} instead.`)
223 }
224 let raw: string
225 try {
226 raw = await $.fs.read(path)
227 } catch (err) {
228 return note(`Can't read ${link}: ${err instanceof Error ? err.message : String(err)}`.slice(0, 500))
229 }
230 if (raw.slice(0, 8000).includes(String.fromCharCode(0))) return note(`${link} is a binary file.`)
231 const src = clean(raw)
232
233 if (!MD.test(path)) {
234 const units: Unit[] = []
235 let line = 1
236 for (const source of split(src, TEXT_MAX)) {
237 const end = line + source.split('\n').length - 1
238 const parts: Part[] = [{ kind: 'code', source, path, startLine: line }]
239 units.push({ level: 0, title: '', slug: '', line, end, plain: source.toLowerCase(), blocks: [{ line, end, first: '', hash: hashOf(source), parts }] })
240 line = end + 1
241 }
242 return { units, pages: paginate(units.map(u => u.plain.length), PAGE_MAX), lines: lineCount(src) }
243 }
244
245 const units: Unit[] = []
246 for (const s of sections(src)) {
247 const blocks: Block[] = []
248 for (const b of blocksOf(s.text, s.line)) {
249 const parts = await sectionParts($, b.text, path)
250 const first = [...(b.text.split('\n').find(l => l.trim())?.trim().replace(/\s+/g, ' ') ?? '')]
251 const begins = first.length > 40 ? `${first.slice(0, 40).join('')}…` : first.join('')
252 if (parts.length) blocks.push({ line: b.line, end: b.end, first: begins, hash: hashOf(b.text), parts })
253 }
254 units.push({ level: s.level, title: plainTitle(s.title), slug: s.slug, line: s.line, end: 0, plain: s.text.toLowerCase(), blocks })
255 }
256 const lines = lineCount(src)
257 units.forEach((u, i) => (u.end = (units[i + 1]?.line ?? lines + 1) - 1))
258 if (!units.length) units.push({ level: 0, title: '', slug: '', line: 1, end: 1, plain: '', blocks: [{ line: 1, end: 1, first: '', hash: '', parts: [{ kind: 'md', text: '*(empty file)*', hrefs: [] }] }] })
259 const size = (u: Unit) =>
260 u.blocks.flatMap(b => b.parts).reduce((n, p) => n + (p.kind === 'md' ? p.text.length : p.kind === 'code' ? p.source.length : p.kind === 'raster' ? p.cells.length : 100), 0)
261 return { units, pages: paginate(units.map(size), PAGE_MAX), lines }
262}
263
264// The file as the pane draws it, parsed once per change on disk and width.
265async function load($: EngineInterface, path: string): Promise<View> {
266 await homeDir($)
267 const st = await $.fs.stat(path).catch(() => null)
268 if (st?.kind !== 'file') return note(`Can't find ${tilde(path)}.`)
269 const key = `${path}|${st.mtimeMs}|${paneColumns}`
270 const hit = views.get(key)
271 if (hit) return hit
272 const view = await build($, path)
273 views.set(key, view)
274 if (views.size > 8) views.delete(views.keys().next().value!)
275 return view
276}
277
278async function openPane($: EngineInterface, path: string): Promise<string> {
279 // No placement takes the whole screen: ask the dock for all of it, and the engine leaves the transcript its narrowest strip.
280 const pane = { id: PANE, title: basename(path), focus: true as const, closeOnEscape: true as const }
281 const opened = await $.ui.open(columns ? { ...pane, columns } : pane).catch(() => $.ui.open(pane))
282 isOpen = true
283 return opened.isPlaced ? 'shown' : `waiting: ${opened.reason}`
284}
285
286// Brings an element of the pane into view (null: its top), retrying while a pane just opened is not drawn yet.
287async function scrollTo($: EngineInterface, key: string | null) {
288 try {
289 for (let n = 0; n < 8; n++) {
290 const r = await $.ui.scroll({ in: PANE, to: key ? { key } : 'start', block: 'start' }).catch((err: unknown) => ({ deny: String(err) }))
291 if (!r.deny) return
292 await $.clock.sleep(80)
293 }
294 } catch {
295 // the module reloaded or the dispatch ended: the pane stays where it is
296 }
297}
298
299// Shows `page` of the file with `target` brought into view; the scroll runs on without holding up the caller.
300async function moveTo($: EngineInterface, page: number, target: string | null) {
301 await update($, doc, x => (x ? { ...x, page, target } : x))
302 void scrollTo($, target)
303}
304
305async function goUnit($: EngineInterface, i: number) {
306 const d = await read($, doc)
307 if (!d) return
308 const v = await load($, d.path)
309 if ('note' in v) return
310 await update($, toc, () => false)
311 await moveTo($, v.pages[i] ?? 0, keyOf(i, 0))
312}
313
314async function show($: EngineInterface, ref: Ref | null): Promise<string> {
315 if (!ref) return 'no file'
316 // Open first, before any await: only an open made while the person's click or command is being answered is
317 // placed at any width; one made later counts as the plugin's own and waits below 144 columns.
318 const opening = openPane($, ref.abs)
319 const d = await read($, doc)
320 const isNew = d?.path !== ref.abs
321 if (isNew) {
322 await update($, doc, x => ({ path: ref.abs, page: 0, target: null, back: x ? [...x.back, x.path].slice(-30) : [] }))
323 await update($, find, () => null)
324 await update($, edit, () => null)
325 await update($, toc, () => false)
326 }
327 const placed = await opening
328 const v = ref.frag ? await load($, ref.abs) : null
329 const i = v && !('note' in v) ? sectionAt(v.units, ref.frag) : null
330 if (i !== null) await goUnit($, i)
331 else if (isNew) void scrollTo($, null)
332 return placed
333}
334
335
336// Warp's own Markdown viewer, split to the right as its "open file layout" setting says (a file:// URL would open a tab).
337const warpUrl = (ref: Ref): string => {
338 const line = /^L(\d+)$/i.exec(ref.frag)?.[1]
339 return `warp://action/open_file_editor?path=${encodeURIComponent(ref.abs)}${line ? `&line=${line}` : ''}`
340}
341
342// Opens a file where `mode` says: the pane, Warp's own viewer beside the session, or the app macOS opens it with; when
343// either of the last two cannot, the pane. Says where it went. For the pane, the open starts before any await (see show).
344async function openRef($: EngineInterface, ref: Ref, mode: Mode): Promise<string> {
345 // `open` launches whatever it is handed (a .command runs, a .app starts): the app takes markdown files only
346 if (mode === 'app' && !MD.test(ref.abs)) mode = 'pane'
347 // lazy: the fallback opens the pane after `open` has answered, which on a narrow terminal counts as the mod's own
348 // open and waits for room; checking for an app first (LaunchServices) would keep it the click's
349 if (mode !== 'pane' && (await openWith($, [mode === 'warp' ? warpUrl(ref) : ref.abs]))) return MODE_TEXT[mode]
350 return `${MODE_TEXT.pane}, ${await show($, ref)}`
351}
352
353// A click on a path in the conversation, opened where this terminal's setting says.
354function clickFrom($: EngineInterface) {
355 return (link: { href: string }) => press($, refOf(link.href), modeHere())
356}
357
358// A click on a link: opened where `mode` says, and a toast when the pane has to wait or it fails. The promise goes
359// back to the press, so the open is part of answering it.
360function press($: EngineInterface, ref: Ref | null, mode: Mode = 'pane'): Promise<unknown> {
361 if (!ref) return Promise.resolve()
362 return openRef($, ref, mode)
363 .then(where => where.includes('waiting') && $.ui.toast(`mdview: ${where}`))
364 .catch((err: unknown) => $.ui.toast(`mdview: ${err instanceof Error ? err.message : String(err)}`.slice(0, 200)))
365}
366
367async function back($: EngineInterface) {
368 const d = await read($, doc)
369 const prev = d?.back.at(-1)
370 if (!d || !prev) return
371 await update($, doc, () => ({ path: prev, page: 0, target: null, back: d.back.slice(0, -1) }))
372 await update($, find, () => null)
373 await update($, edit, () => null)
374 await update($, toc, () => false)
375 await openPane($, prev)
376 void scrollTo($, null)
377}
378
379// Hands a file or URL to macOS (`open`): the app it opens that kind with; false, after a toast, when that fails.
380async function openWith($: EngineInterface, args: string[]): Promise<boolean> {
381 const run = await $.process.run(['open', ...args], { timeoutMs: 10_000 }).catch((err: unknown) => ({ exitCode: -1, stderr: String(err) }))
382 if (run.exitCode === 0) return true
383 $.ui.toast(`mdview: could not open it (${run.stderr.trim().slice(0, 120) || `exit ${run.exitCode}`})`)
384 return false
385}
386
387// ✎ then Enter: the instruction goes to Claude as your own prompt, naming the file, the block's lines and how it begins;
388// its progress shows under the block (see turn.start). A prompt that does not enter says why, and leaves no progress.
389async function sendEdit($: EngineInterface, path: string, u: Unit, b: Block, instruction: string) {
390 await update($, edit, () => null)
391 const what = instruction.trim()
392 if (!what) return
393 const where = u.title ? ` in the section "${u.title}"` : ''
394 const begins = b.first ? `, which begin "${b.first}"` : ''
395 const text = `Edit lines ${b.line}-${b.end} of \`${tilde(path)}\`${where}${begins}: ${what}`
396 const id = `${await $.clock.now()}-${b.hash}`
397 // a block sent again replaces its earlier line; lazy: the five latest edits are shown, an older one drops
398 const mine: MdviewPending = { id, path, line: b.line, hash: b.hash, text, turn: null, changed: false, done: null }
399 await update($, pending, list => [...(list ?? []).filter(p => p.path !== path || p.hash !== b.hash), mine].slice(-5))
400 const sent = await $.prompt.submit({ text, asUser: true }).catch((err: unknown) => ({ drop: err instanceof Error ? err.message : String(err) }))
401 if (sent.drop === undefined) return
402 await update($, pending, list => (list ?? []).filter(p => p.id !== id))
403 $.ui.toast(`mdview: not sent (${sent.drop})`.slice(0, 200))
404}
405
406// Enter in the find bar: the next section holding the text, wrapping around; a new text starts from the first.
407async function findNext($: EngineInterface, query: string) {
408 const d = await read($, doc)
409 const q = query.trim()
410 if (!d) return
411 const v = await load($, d.path)
412 if (!q || 'note' in v) return void (await update($, find, () => ({ query: q, at: 0, hits: 0 })))
413 const hits = v.units.flatMap((u, i) => (u.plain.includes(q.toLowerCase()) ? [i] : []))
414 const prev = await read($, find)
415 const at = prev && prev.query === q && hits.length ? (prev.at + 1) % hits.length : 0
416 await update($, find, () => ({ query: q, at, hits: hits.length }))
417 const hit = hits[at]
418 if (hit !== undefined) await goUnit($, hit)
419}
420
421export const register: Register = (on, options) => {
422 for (const where of ['warp', 'other'] as const) {
423 const { key, choices } = SETTING[where]
424 modes[where] = asMode((options as Record<string, unknown> | undefined)?.[key], choices, choices[0]!)
425 }
426 on('session.start', async ($, e, next) => {
427 await homeDir($)
428 await remember($, e.cwd)
429 // As Claude Code decides it: kitty or Ghostty, or forced on (iTerm2 3.7 draws kitty pictures; Warp can't place them)
430 const forced = (await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) ?? ''
431 pictures =
432 (forced !== '' && !/^(0|false|no|off)$/i.test(forced)) ||
433 /kitty/i.test((await $.env.get('TERM')) ?? '') ||
434 /ghostty/i.test((await $.env.get('TERM_PROGRAM')) ?? '') ||
435 !!(await $.env.get('KITTY_WINDOW_ID'))
436 isWarp = (await $.env.get('TERM_PROGRAM')) === 'WarpTerminal'
437 tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '') || '/tmp'
438 await $.command.register({ name: 'md', description: 'Show a file rendered in a side pane; `mode` switches what a click opens', argumentHint: '<path>[#heading|:line] | mode [warp|pane|app]' })
439 await $.tool.register({
440 name: 'show',
441 description:
442 'Open a local file in the mdview side pane for the person to read: markdown rendered, any other text file as highlighted code. Use when the person asks to see, open or preview a file.',
443 inputSchema: {
444 type: 'object',
445 properties: { path: { type: 'string', description: 'Absolute, ~ or working-directory-relative path; may end in #heading or :line' } },
446 required: ['path'],
447 },
448 })
449 // Redraw the pane when its file changes on disk, and let an edit's line go a few seconds after its turn ended
450 // (here rather than in a timer of its own, which a reload would cancel: this one starts again with the mod).
451 let seen = -1
452 $.clock.every(1_500, async () => {
453 const sent = await read($, pending)
454 if (sent.some(p => p.done !== null)) {
455 const now = await $.clock.now()
456 const isOver = (p: MdviewPending) => p.done !== null && now - p.done >= 5_000
457 if (sent.some(isOver)) await update($, pending, list => (list ?? []).filter(p => !isOver(p)))
458 }
459 const d = isOpen ? await read($, doc) : null
460 if (!d) return
461 const m = (await $.fs.stat(d.path).catch(() => null))?.mtimeMs ?? -1
462 if (m !== seen) {
463 seen = m
464 await update($, rev, () => m)
465 }
466 })
467 return next(e)
468 })
469
470 on('classic.CwdChanged', async ($, e, next) => {
471 await remember($, e.new_cwd)
472 return next(e)
473 })
474
475 // A markdown file Claude just wrote: links to it appear, and the pane showing it redraws, at once. Written while an
476 // edit from ✎ on that file runs, it is that edit's change.
477 on('tool.call', async ($, e, next) => {
478 const result = await next(e)
479 const file = (e as { file_path?: unknown }).file_path
480 if ((e.tool === 'Write' || e.tool === 'Edit') && typeof file === 'string') {
481 if (MD.test(file) || (isOpen && (await read($, doc))?.path === file)) $.ui.invalidate('ui.render')
482 const isRunning = (p: MdviewPending) => p.path === file && p.turn !== null && p.done === null && !p.changed
483 if (!result.deny && !result.isError && (await read($, pending)).some(isRunning))
484 await update($, pending, list => (list ?? []).map(p => (isRunning(p) ? { ...p, changed: true } : p)))
485 }
486 return result
487 })
488
489 on('tool.call', { tool: 'mcp__mdview__show' }, async ($, e) => {
490 const arg = String((e as { path?: unknown }).path ?? '').trim()
491 const ref = resolveRef(arg, await $.session.cwd(), await homeDir($), { anyExt: true })
492 const st = ref ? await $.fs.stat(ref.abs).catch(() => null) : null
493 if (!ref || st?.kind !== 'file') return { result: `No such file: ${arg}` }
494 try {
495 // the model's tool views only: Warp's viewer or the pane, never the app (see openRef)
496 return { result: `${tilde(ref.abs)}: opened in ${await openRef($, ref, isWarp ? 'warp' : 'pane')}` }
497 } catch (err) {
498 return { result: `Could not open it: ${err instanceof Error ? err.message : String(err)}` }
499 }
500 })
501
502 on('command.run', { command: 'md' }, async ($, e) => {
503 const arg = e.args.trim().replace(/^(['"`])(.*)\1$/, '$2')
504 // `/md mode [choice]`: the click setting of this terminal, as /config sets it (stored; the mod reloads with it).
505 // A file of that name wins.
506 const mode = /^mode(?:\s+(\S+))?$/.exec(arg)
507 const named = mode ? resolveRef(arg, await $.session.cwd(), await homeDir($), { anyExt: true }) : null
508 if (mode && (!named || (await $.fs.stat(named.abs).catch(() => null))?.kind !== 'file')) {
509 const { key, choices } = SETTING[here()]
510 const now = modeHere()
511 const want = mode[1] ?? choices[(choices.indexOf(now) + 1) % choices.length]!
512 if (!(choices as string[]).includes(want)) return { text: `Usage: /md mode [${choices.join('|')}]` }
513 if (want === now) return { text: `A click on a path already opens ${MODE_TEXT[now]} (${key}: ${now})` }
514 const set = await $.config.set({ key: `mdview.${key}`, value: want }).catch((err: unknown) => ({ deny: err instanceof Error ? err.message : String(err) }))
515 if (set.deny !== undefined) return { text: `Could not switch: ${set.deny}` }
516 const got = asMode(set.value, choices, now)
517 modes[here()] = got
518 return { text: `A click on a path now opens ${MODE_TEXT[got]} (${key}: ${got})` }
519 }
520 if (!arg) {
521 const d = await read($, doc)
522 if (!d) return { text: 'Usage: /md <path>[#heading|:line]' }
523 await openPane($, d.path)
524 return { text: `Showing ${tilde(d.path)}` }
525 }
526 const ref = resolveRef(arg, await $.session.cwd(), await homeDir($), { anyExt: true })
527 const st = ref ? await $.fs.stat(ref.abs).catch(() => null) : null
528 if (!ref || st?.kind !== 'file') return { text: `No such file: ${arg}` }
529 await show($, ref)
530 return { text: `Showing ${tilde(ref.abs)}` }
531 })
532
533 on('ui.close', async ($, e, next) => {
534 const result = await next(e)
535 if (e.id === PANE && !(result && 'deny' in result && result.deny)) {
536 isOpen = false
537 await update($, toc, () => false)
538 await update($, find, () => null)
539 await update($, edit, () => null)
540 }
541 return result
542 })
543
544 // A reply that names .md files: drawn as the engine draws it, with those paths a click away.
545 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
546 columns = Math.max(columns, e.viewport?.columns ?? 0)
547 // Clicks reach a plugin only in the fullscreen terminal; elsewhere the engine's own drawing stays.
548 if (e.surface !== 'terminal' || !e.viewport?.isFullscreen || !MAYBE_MD.test(e.props.text)) return next(e)
549 const now = await $.session.cwd()
550 if (!cwdOf.has(e.requestId)) cwdOf.set(e.requestId, now)
551 const linked = await linkFiles($, e.props.text, [...new Set([cwdOf.get(e.requestId)!, now, ...(await read($, cwdHistory))])])
552 if (!linked) return next(e)
553 const { Box, Markdown, Text } = $.ui.resolve(e)
554 const follow = clickFrom($)
555 return (
556 <Box flexDirection="row" marginTop={1}>
557 <Box minWidth={2}>
558 <Text>{e.props.isFirstOfReply ? '⏺' : ' '}</Text>
559 </Box>
560 <Box flexDirection="column" flexGrow={1}>
561 {split(linked.text, TEXT_MAX).map((text, i) => {
562 const hrefs = linked.hrefs.filter(h => text.includes(`](${h})`)).slice(0, 256)
563 return hrefs.length ? (
564 <Markdown key={`r${i}`} text={text} pressableLinks={hrefs} onLinkPress={follow} />
565 ) : (
566 <Markdown key={`r${i}`} text={text} />
567 )
568 })}
569 </Box>
570 </Box>
571 )
572 })
573
574 // A tool row that read or wrote a markdown file: the engine's own row, with a line under it that opens the file here.
575 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
576 const file = (e.props.input as { file_path?: unknown } | null)?.file_path
577 const isMdTool = ['Read', 'Write', 'Edit'].includes(e.props.tool) && typeof file === 'string' && MD.test(file)
578 if (e.surface !== 'terminal' || !e.viewport?.isFullscreen || !isMdTool || e.props.isRunning || e.props.isErrored) return next(e)
579 const st = await $.fs.stat(file).catch(() => null)
580 if (st?.kind !== 'file') return next(e)
581 const href = hrefOf({ abs: file, frag: '' })
582 const { Box, Markdown } = $.ui.resolve(e)
583 return (
584 <Box flexDirection="column">
585 {await next(e)}
586 <Box paddingLeft={2}>
587 <Markdown key="open" dimColor text={`⎿ [${tilde(file)}${MARK}](${href})`} pressableLinks={[href]} onLinkPress={clickFrom($)} />
588 </Box>
589 </Box>
590 )
591 })
592
593 // A prompt of yours naming markdown files: your own row, with a line under it that opens them here.
594 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
595 if (e.surface !== 'terminal' || !e.viewport?.isFullscreen || e.props.origin.kind !== 'composer' || !MAYBE_MD.test(e.props.text)) return next(e)
596 const now = await $.session.cwd()
597 if (!cwdOf.has(e.requestId)) cwdOf.set(e.requestId, now)
598 const linked = await linkFiles($, e.props.text, [...new Set([cwdOf.get(e.requestId)!, now, ...(await read($, cwdHistory))])])
599 if (!linked) return next(e)
600 const hrefs = linked.hrefs.slice(0, 20)
601 const { Box, Markdown } = $.ui.resolve(e)
602 return (
603 <Box flexDirection="column">
604 {await next(e)}
605 <Box paddingLeft={2}>
606 <Markdown
607 key="open"
608 dimColor
609 text={`⎿ ${hrefs.map(h => `[${tilde(refOf(h)?.abs ?? h)}${MARK}](${h})`).join(' · ')}`}
610 pressableLinks={hrefs}
611 onLinkPress={clickFrom($)}
612 />
613 </Box>
614 </Box>
615 )
616 })
617
618 // The turn that runs an edit sent from ✎: the one that starts with its prompt, waiting until then behind any other.
619 // lazy: a prompt another plugin rewrites before it enters is never matched and reads "waiting" until five newer
620 // edits push it out; matching the text that entered (a prompt.submit hook beneath the others) would find it
621 on('turn.start', async ($, e, next) => {
622 const p = (await read($, pending)).find(x => x.turn === null && e.text.includes(x.text))
623 if (p) await update($, pending, list => (list ?? []).map(x => (x.id === p.id ? { ...x, turn: e.turnId } : x)))
624 return next(e)
625 })
626
627 // That turn ended: its edit reads as Updated or No changes made, and goes a few seconds later (see the poll).
628 on('turn.complete', async ($, e, next) => {
629 const result = await next(e)
630 if ((await read($, pending)).some(p => p.turn === e.turnId)) {
631 const now = await $.clock.now()
632 await update($, pending, list => (list ?? []).map(p => (p.turn === e.turnId ? { ...p, done: now } : p)))
633 }
634 return result
635 })
636
637 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
638 isOpen = true
639 paneColumns = e.props.bodyColumns || paneColumns
640 const { Box, Button, Code, Markdown, Text } = $.ui.resolve(e)
641 const Input = e.surface === 'mobile' ? null : $.ui.resolve(e).Input
642 const Image = e.surface === 'terminal' ? $.ui.resolve(e).Image : null
643 const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : null
644 const d = await read($, doc)
645 await read($, rev) // subscribes: a change on disk redraws
646 const isTocOpen = await read($, toc)
647 const f = await read($, find)
648 const ed = await read($, edit)
649 const sent = await read($, pending)
650 // a ⎿ line, as Claude Code hangs a result under its row
651 const hang = (body: RenderNode, key: string) => (
652 <Box key={key} flexDirection="row">
653 <Text dimColor>{'⎿ '}</Text>
654 {body}
655 </Box>
656 )
657 if (!d) return hang(<Text dimColor>{'Click a .md path in the conversation, or run /md <path>'}</Text>, 'empty')
658
659 const v = await load($, d.path)
660 const units = 'note' in v ? [] : v.units
661 const pages = 'note' in v ? [0] : v.pages
662 const pageCount = (pages.at(-1) ?? 0) + 1
663 const page = Math.min(d.page, pageCount - 1)
664 const heads = units.flatMap((u, i) => (u.level > 0 ? [{ u, i }] : []))
665 const follow = (link: { href: string }) => press($, refOf(link.href))
666 // the section in view: the one last jumped to, else the first on this page
667 const inView = Number(/^u(\d+)/.exec(d.target ?? '')?.[1] ?? pages.indexOf(page))
668 // The block the edit bar is on, and the one an edit was sent from: the block with the same text, nearest where it
669 // was, since the file may have changed above it; a block whose text is gone has no bar (it closes rather than move).
670 // lazy: two blocks of identical text are told apart by the nearer line only; harmless, as either holds that text
671 const locate = (where: { path: string; line: number; hash: string } | null, nearest = false) => {
672 let at: { i: number; k: number } | null = null
673 let gap = Infinity
674 if (where?.path !== d.path) return at
675 for (const [i, u] of units.entries())
676 for (const [k, b] of u.blocks.entries()) {
677 if ((!nearest && b.hash !== where.hash) || Math.abs(b.line - where.line) >= gap) continue
678 gap = Math.abs(b.line - where.line)
679 at = { i, k }
680 }
681 return at
682 }
683 const editAt = locate(ed)
684 // Each edit sent from ✎, as a line under its block: found by its text until Claude wrote the file, then the block
685 // now at its lines.
686 const progress = sent.flatMap(p => {
687 const at = p.changed ? locate(p, true) : (locate(p) ?? locate(p, true))
688 if (!at) return []
689 const body =
690 p.done === null ? (
691 <Text color="claude">{p.turn ? '✶ Claude is editing…' : '✶ Waiting for Claude…'}</Text>
692 ) : p.changed ? (
693 <Text color="success">Updated</Text>
694 ) : (
695 <Text dimColor>No changes made</Text>
696 )
697 return [{ ...at, line: hang(body, `progress-${p.id}`) }]
698 })
699
700 // The keys, as Claude Code's own panels list theirs: one dim row, each a click away.
701 const keys = [
702 d.back.length > 0 && <Button key="back" plain dimColor hotkey="b" label="back" onPress={() => back($)} />,
703 heads.length > 1 && <Button key="toc" plain dimColor hotkey="t" label="contents" onPress={() => update($, toc, x => !x)} />,
704 Input && units.length > 0 && <Button key="find-toggle" plain dimColor hotkey="f" label="find" onPress={() => update($, find, x => (x ? null : { query: '', at: 0, hits: 0 }))} />,
705 page > 0 && <Button key="prev" plain dimColor hotkey="p" label="previous part" onPress={() => moveTo($, page - 1, null)} />,
706 page < pageCount - 1 && <Button key="next" plain dimColor hotkey="n" label="next part" onPress={() => moveTo($, page + 1, null)} />,
707 isWarp && <Button key="warp" plain dimColor hotkey="w" label="Warp" onPress={() => openWith($, [warpUrl({ abs: d.path, frag: '' })])} />,
708 <Button key="close" plain dimColor role="dismiss" hotkey="q" label="close" onPress={() => $.ui.close({ id: PANE })} />,
709 ].filter(Boolean)
710 // items in a dim row, a dot between each
711 const dotted = <T,>(items: readonly T[], key: string) => items.flatMap((k, i) => (i ? [<Text key={`${key}${i}`} dimColor>·</Text>, k] : [k]))
712 const dir = dirname(d.path)
713 const meta =
714 'note' in v
715 ? ''
716 : `${v.lines ? ` · ${v.lines} line${v.lines === 1 ? '' : 's'}` : ''}${heads.length ? ` · ${heads.length} section${heads.length === 1 ? '' : 's'}` : ''}`
717
718 const draw = (p: Part, key: string) => {
719 if (p.kind === 'code') return <Code key={key} source={p.source} path={p.path} startLine={p.startLine} wrap="wrap" />
720 if (p.kind === 'raster')
721 return Raster ? (
722 <Box key={`${key}-box`} flexDirection="column" alignItems="flex-start">
723 <Raster key={key} cells={p.cells} columns={p.columns} rows={p.rows} />
724 <Button key={`${key}-original`} plain dimColor label="⎿ open the original" onPress={() => openWith($, [p.file])} />
725 </Box>
726 ) : (
727 <Text key={key} dimColor>{`[picture: ${p.alt}]`}</Text>
728 )
729 if (p.kind === 'img')
730 return Image ? (
731 <Image key={key} source={{ file: p.file, format: 'png' }} columns={p.columns} rows={p.rows} alt={p.alt} />
732 ) : (
733 <Text key={key} dimColor>{`[picture: ${p.alt}]`}</Text>
734 )
735 return p.hrefs.length ? (
736 <Markdown key={key} text={p.text} pressableLinks={p.hrefs} onLinkPress={follow} />
737 ) : (
738 <Markdown key={key} text={p.text} />
739 )
740 }
741
742 // a part boundary, as a dim rule
743 const rule = (text: string, key: string) => (
744 <Text key={key} dimColor wrap="truncate-end">
745 {`── ${text} ${'─'.repeat(Math.max(0, paneColumns - text.length - 4))}`}
746 </Text>
747 )
748
749 return (
750 <Box flexDirection="column">
751 {/* the file, as Claude Code names one in its rows: ⏺ name, then where and how big, dim */}
752 <Box flexDirection="row">
753 <Text>{'⏺ '}</Text>
754 <Text bold>{basename(d.path)}</Text>
755 <Text dimColor wrap="truncate-start">{` ${tilde(dir)}${meta}`}</Text>
756 </Box>
757 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginBottom={1}>
758 {dotted(keys, 'dot')}
759 </Box>
760 {f && Input && (
761 // the find row, as Claude Code's history search reads: search: <text> 2/5
762 <Box flexDirection="row" gap={1} marginBottom={1}>
763 <Box flexGrow={1}>
764 <Input key="find" label="search:" placeholder="text in this file" value={f.query} submitLabel="next" autoFocus onSubmit={q => findNext($, q)} />
765 </Box>
766 {f.query !== '' && <Text dimColor>{f.hits ? `${f.at + 1}/${f.hits}` : 'no match'}</Text>}
767 </Box>
768 )}
769 {isTocOpen && (
770 // the contents, as Claude Code's own menus list: the section in view marked, a row lights while hovered
771 <Box flexDirection="column" marginBottom={1}>
772 {heads.slice(0, 200).map(({ u, i }) => (
773 <Box key={`tocrow-${i}`} flexDirection="row">
774 <Text color="suggestion">{i === inView ? '› ' : ' '}</Text>
775 <Button
776 key={`toc-${i}`}
777 plain
778 hover={{ color: 'suggestion' }}
779 label={`${' '.repeat(u.level - 1)}${u.title}`.slice(0, Math.max(10, paneColumns - 4))}
780 onPress={() => goUnit($, i)}
781 />
782 </Box>
783 ))}
784 </Box>
785 )}
786 {pageCount > 1 && page > 0 && rule(`part ${page + 1} of ${pageCount}`, 'rule-top')}
787 {'note' in v && hang(<Markdown key="note" text={v.note} />, 'note')}
788 {units.flatMap((u, i) => {
789 if (pages[i] !== page) return []
790 const firstPart = u.blocks.map((_, k) => u.blocks.slice(0, k).reduce((n, b) => n + b.parts.length, 0))
791 return [
792 // a blank line between sections and between blocks, as the file itself reads
793 <Box key={`sec${i}`} flexDirection="column" marginTop={pages.indexOf(page) === i ? 0 : 1}>
794 {u.blocks.map((b, k) => (
795 // each block a hover scope: while the pointer is over it, it takes the background Claude Code gives
796 // your own prompts, and shows its ✎
797 <Box key={`blk${i}-${k}`} flexDirection="column" marginTop={k ? 1 : 0} hover={{ backgroundColor: 'userMessageBackground' }}>
798 {editAt?.i === i && editAt.k === k && Input && (
799 // a small prompt to Claude: its border, its ">", and the keys under it
800 <Box flexDirection="column" marginBottom={1}>
801 <Box borderStyle="round" borderColor="promptBorder" paddingX={1}>
802 <Input
803 key="edit-input"
804 label=">"
805 placeholder={`what to change in lines ${b.line}-${b.end}`}
806 submitLabel="send"
807 autoFocus
808 onSubmit={text => sendEdit($, d.path, u, b, text)}
809 />
810 </Box>
811 <Box flexDirection="row" columnGap={1} paddingLeft={2}>
812 <Text dimColor>enter to send to Claude ·</Text>
813 <Button key="edit-cancel" plain dimColor label="cancel" onPress={() => update($, edit, () => null)} />
814 </Box>
815 </Box>
816 )}
817 {b.parts.map((p, m) => draw(p, keyOf(i, firstPart[k]! + m)))}
818 {progress.filter(x => x.i === i && x.k === k).map(x => x.line)}
819 {Input && (
820 <Box position="absolute" top={0} right={0} display="none" hover={{ display: 'flex' }}>
821 <Button key={`edit-${i}-${k}`} plain dimColor label="✎" onPress={() => update($, edit, () => ({ path: d.path, line: b.line, hash: b.hash }))} />
822 </Box>
823 )}
824 </Box>
825 ))}
826 </Box>,
827 ]
828 })}
829 {pageCount > 1 && (
830 // the end of a part: where it stands, and the way to either neighbor
831 <Box flexDirection="row" columnGap={1} marginTop={1}>
832 {dotted(
833 [
834 <Text key="end-part" dimColor>{`── part ${page + 1} of ${pageCount}`}</Text>,
835 page > 0 && <Button key="prev-end" plain dimColor label="↑ previous part" onPress={() => moveTo($, page - 1, null)} />,
836 page < pageCount - 1 ? (
837 <Button key="next-end" plain dimColor label="next part ↓" onPress={() => moveTo($, page + 1, null)} />
838 ) : (
839 <Text key="end" dimColor>end</Text>
840 ),
841 ].filter(Boolean),
842 'enddot',
843 )}
844 <Text dimColor>──</Text>
845 </Box>
846 )}
847 </Box>
848 )
849 })
850}
851hooks/doc.ts 425 lines1// Pure helpers: shape a markdown file for the pane, which draws it with the engine's own markdown renderer.
2
3import { FENCE } from './links'
4
5// Text a Markdown or Code element takes: no control characters but tab and newline.
6export const clean = (s: string): string => s.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
7
8// Terminal cells a string takes: East Asian wide and fullwidth characters and most emoji take two.
9export const cells = (s: string): number => {
10 let n = 0
11 for (const ch of s) {
12 const c = ch.codePointAt(0)!
13 n +=
14 (c >= 0x1100 && c <= 0x115f) ||
15 (c >= 0x2e80 && c <= 0xa4cf) ||
16 (c >= 0xac00 && c <= 0xd7a3) ||
17 (c >= 0xf900 && c <= 0xfaff) ||
18 (c >= 0xfe30 && c <= 0xfe4f) ||
19 (c >= 0xff00 && c <= 0xff60) ||
20 (c >= 0xffe0 && c <= 0xffe6) ||
21 (c >= 0x1f300 && c <= 0x1faff) ||
22 (c >= 0x20000 && c <= 0x3fffd)
23 ? 2
24 : 1
25 }
26 return n
27}
28
29const closes = (line: string, fence: string): boolean => {
30 const f = FENCE.exec(line)?.[1]
31 return !!f && f[0] === fence[0] && f.length >= fence.length && line.trim() === f
32}
33
34// Runs `fn` over the text outside code fences and inline code spans.
35const outsideCode = (text: string, fn: (s: string) => string): string => {
36 const out: string[] = []
37 let buf: string[] = []
38 let fence: string | null = null
39 const flush = () => {
40 if (!buf.length) return
41 out.push(buf.join('\n').split(/(`[^`\n]*`)/).map((p, i) => (i % 2 ? p : fn(p))).join(''))
42 buf = []
43 }
44 for (const line of text.split('\n')) {
45 if (fence) {
46 out.push(line)
47 if (closes(line, fence)) fence = null
48 continue
49 }
50 const f = FENCE.exec(line)?.[1]
51 if (f) {
52 flush()
53 fence = f
54 out.push(line)
55 continue
56 }
57 buf.push(line)
58 }
59 flush()
60 return out.join('\n')
61}
62
63const attr = (attrs: string, name: string): string | undefined => {
64 const m = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(attrs)
65 return m ? (m[1] ?? m[2] ?? m[3]) : undefined
66}
67
68// The HTML a markdown file commonly holds, as markdown the terminal renderer draws; other tags stay as written.
69export const html = (text: string): string =>
70 outsideCode(text, s =>
71 s
72 .replace(/<!--[\s\S]*?-->/g, '')
73 .replace(/<summary[^>]*>([\s\S]*?)<\/summary>/gi, (_, t: string) => `**▸ ${t.trim()}**\n`)
74 .replace(/<\/?details\b[^>]*>/gi, '')
75 .replace(/<img\b([^>]*?)\/?>/gi, (_, a: string) => {
76 const src = attr(a, 'src')
77 return src ? `})` : ''
78 })
79 .replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_, a: string, t: string) => {
80 const href = attr(a, 'href')
81 return href ? `[${t}](${href.replace(/ /g, '%20')})` : t
82 })
83 .replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, '**$2**')
84 .replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, '*$2*')
85 .replace(/<(s|del|strike)>([\s\S]*?)<\/\1>/gi, '~~$2~~')
86 .replace(/<(code|kbd|samp|tt)>([\s\S]*?)<\/\1>/gi, '`$2`')
87 .replace(/<hr\s*\/?>/gi, '\n---\n')
88 .replace(
89 /<\/?(?:p|div|span|center|picture|source|sup|sub|u|small|big|font|section|article|header|footer|nav|main|figure|figcaption|ins|mark|abbr)\b[^>]*>/gi,
90 '',
91 )
92 .replace(/^[ \t]*\|.*$/gm, row => row.replace(/<br\s*\/?>/gi, ' · '))
93 .replace(/<br\s*\/?>/gi, ' \n')
94 .replace(/ /g, ' '),
95 )
96
97const isSep = (line: string): boolean => /^[\s|:-]+$/.test(line) && line.includes('-') && line.includes('|')
98const cellsOf = (row: string): string[] =>
99 row
100 .trim()
101 .replace(/^\|/, '')
102 .replace(/(?<!\\)\|$/, '')
103 .split(/(?<!\\)\|/)
104 .map(c => c.trim())
105
106// A table that cannot fit `columns` even with every cell wrapped to its longest word, drawn as one record per row.
107export const tables = (text: string, columns: number): string => {
108 const lines = text.split('\n')
109 const out: string[] = []
110 let fence: string | null = null
111 for (let i = 0; i < lines.length; i++) {
112 const line = lines[i]!
113 if (fence) {
114 if (closes(line, fence)) fence = null
115 out.push(line)
116 continue
117 }
118 const f = FENCE.exec(line)?.[1]
119 if (f) fence = f
120 if (f || !line.includes('|') || !isSep(lines[i + 1] ?? '')) {
121 out.push(line)
122 continue
123 }
124 const head = cellsOf(line)
125 let j = i + 2
126 const rows: string[][] = []
127 while (j < lines.length && lines[j]!.includes('|') && lines[j]!.trim()) rows.push(cellsOf(lines[j++]!))
128 const n = Math.max(head.length, ...rows.map(r => r.length))
129 let need = 3 * n + 1
130 for (let k = 0; k < n; k++) need += Math.max(1, ...[head, ...rows].flatMap(r => (r[k] ?? '').split(/\s+/).map(cells)))
131 if (need <= columns) {
132 out.push(...lines.slice(i, j))
133 } else {
134 for (const r of rows) {
135 out.push(`**${r[0] || '—'}**`)
136 for (let k = 1; k < n; k++) out.push(`- ${head[k] ? `**${head[k]}**: ` : ''}${r[k] ?? ''}`)
137 out.push('')
138 }
139 }
140 i = j - 1
141 }
142 return out.join('\n')
143}
144
145export type Section = { level: number; title: string; slug: string; line: number; text: string }
146
147// A heading's anchor as GitHub writes it.
148export const slugOf = (title: string): string =>
149 title
150 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
151 .replace(/<[^>]+>/g, '')
152 .replace(/[`*~]/g, '')
153 .toLowerCase()
154 .trim()
155 .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '')
156 .replace(/\s/g, '-')
157
158const HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/
159
160// The file cut at its ATX headings outside code fences, each with the line it starts on; YAML front matter
161// first, as a fenced yaml block, and the text before the first heading as a section of level 0.
162export const sections = (src: string): Section[] => {
163 const lines = src.split('\n')
164 const out: Section[] = []
165 let start = 0
166 if (lines[0] === '---') {
167 const end = lines.indexOf('---', 1)
168 if (end > 0) {
169 out.push({ level: 0, title: '', slug: '', line: 1, text: ['```yaml', ...lines.slice(1, end), '```'].join('\n') })
170 start = end + 1
171 }
172 }
173 const seen = new Map<string, number>()
174 let cur: Omit<Section, 'text'> & { lines: string[] } = { level: 0, title: '', slug: '', line: start + 1, lines: [] }
175 const push = () => {
176 const text = cur.lines.join('\n').replace(/\s+$/, '')
177 if (cur.level > 0 || text.trim()) out.push({ level: cur.level, title: cur.title, slug: cur.slug, line: cur.line, text })
178 }
179 let fence: string | null = null
180 for (let i = start; i < lines.length; i++) {
181 const line = lines[i]!
182 if (fence) {
183 if (closes(line, fence)) fence = null
184 } else {
185 const f = FENCE.exec(line)?.[1]
186 const h = f ? null : HEADING.exec(line)
187 if (f) fence = f
188 else if (h) {
189 push()
190 const base = slugOf(h[2]!)
191 const n = seen.get(base) ?? 0
192 seen.set(base, n + 1)
193 cur = { level: h[1]!.length, title: h[2]!, slug: n ? `${base}-${n}` : base, line: i + 1, lines: [] }
194 }
195 }
196 cur.lines.push(line)
197 }
198 push()
199 return out
200}
201
202// The section a fragment names: a heading's slug, else `L<line>` (the section holding that line).
203export const sectionAt = (secs: readonly Pick<Section, 'slug' | 'line'>[], frag: string): number | null => {
204 if (!frag) return null
205 const want = frag.toLowerCase()
206 const bySlug = secs.findIndex(s => s.slug && (s.slug === want || s.slug === slugOf(frag)))
207 if (bySlug >= 0) return bySlug
208 const line = /^l(\d+)/.exec(want)
209 if (!line) return null
210 let at = 0
211 secs.forEach((s, i) => {
212 if (s.line <= Number(line[1])) at = i
213 })
214 return at
215}
216
217// `lines` packed into pieces of at most `max` characters, each wrapped in `head` and `tail`; a longer line is cut.
218const pack = (lines: string[], max: number, head: string[] = [], tail: string[] = []): string[] => {
219 const base = [...head, ...tail].reduce((n, l) => n + l.length + 1, 0)
220 const room = Math.max(1, max - base - 1)
221 const out: string[] = []
222 let cur: string[] = []
223 let size = base
224 const push = () => {
225 if (cur.length) out.push([...head, ...cur, ...tail].join('\n'))
226 cur = []
227 size = base
228 }
229 for (const line of lines) {
230 for (let i = 0; i < Math.max(1, line.length); i += room) {
231 const piece = line.slice(i, i + room)
232 if (cur.length && size + piece.length + 1 > max) push()
233 cur.push(piece)
234 size += piece.length + 1
235 }
236 }
237 push()
238 return out
239}
240
241// Pieces of at most `max` characters, cut at blank lines and around code fences; a longer block is cut by lines,
242// a fence closed and opened again, a table's header repeated.
243export const split = (text: string, max: number): string[] => {
244 if (text.length <= max) return [text]
245 const blocks: string[][] = []
246 let cur: string[] = []
247 let fence: string | null = null
248 const push = () => {
249 if (cur.length) blocks.push(cur)
250 cur = []
251 }
252 for (const line of text.split('\n')) {
253 if (fence) {
254 cur.push(line)
255 if (closes(line, fence)) {
256 fence = null
257 push()
258 }
259 continue
260 }
261 const f = FENCE.exec(line)?.[1]
262 if (f) {
263 push()
264 fence = f
265 cur.push(line)
266 } else if (line.trim() === '') push()
267 else cur.push(line)
268 }
269 push()
270
271 const out: string[] = []
272 let acc = ''
273 for (const lines of blocks) {
274 const b = lines.join('\n')
275 if (acc && acc.length + 2 + b.length > max) {
276 out.push(acc)
277 acc = ''
278 }
279 if (b.length <= max) {
280 acc = acc ? `${acc}\n\n${b}` : b
281 continue
282 }
283 if (acc) out.push(acc)
284 acc = ''
285 const last = lines.at(-1)!
286 const open = FENCE.exec(lines[0]!)?.[1]
287 if (open && lines.length > 1 && closes(last, open)) out.push(...pack(lines.slice(1, -1), max, [lines[0]!], [last]))
288 else if (lines.length > 2 && isSep(lines[1]!)) out.push(...pack(lines.slice(2), max, lines.slice(0, 2)))
289 else out.push(...pack(lines, max))
290 }
291 if (acc) out.push(acc)
292 return out
293}
294
295// The page each section lands on when a page holds at most `max` characters.
296export const paginate = (sizes: readonly number[], max: number): number[] => {
297 let page = 0
298 let acc = 0
299 return sizes.map(n => {
300 if (acc && acc + n > max) {
301 page++
302 acc = 0
303 }
304 acc += n
305 return page
306 })
307}
308
309const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
310
311// A PNG's size from the head of its base64, or null when it is no PNG.
312export const pngSize = (base64: string): { width: number; height: number } | null => {
313 const bytes: number[] = []
314 for (let i = 0; i + 4 <= Math.min(32, base64.length); i += 4) {
315 let n = 0
316 for (let k = 0; k < 4; k++) n = n * 64 + Math.max(0, B64.indexOf(base64[i + k]!))
317 bytes.push((n >> 16) & 255, (n >> 8) & 255, n & 255)
318 }
319 if (bytes.length < 24 || bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return null
320 const u32 = (o: number) => bytes[o]! * 2 ** 24 + (bytes[o + 1]! << 16) + (bytes[o + 2]! << 8) + bytes[o + 3]!
321 return { width: u32(16), height: u32(20) }
322}
323
324export type Pixels = { width: number; height: number; at: (x: number, y: number) => number }
325
326// The pixels of an uncompressed 24- or 32-bit BMP (what `sips -s format bmp` writes), each 0xRRGGBB, or -1 where
327// an alpha channel makes it transparent; null when it is no such BMP.
328export const bmpPixels = (b: Uint8Array): Pixels | null => {
329 if (b.length < 54 || b[0] !== 0x42 || b[1] !== 0x4d) return null
330 const v = new DataView(b.buffer, b.byteOffset, b.byteLength)
331 const off = v.getUint32(10, true)
332 const header = v.getUint32(14, true)
333 const width = v.getInt32(18, true)
334 const h = v.getInt32(22, true)
335 const bpp = v.getUint16(28, true)
336 const comp = v.getUint32(30, true)
337 if ((bpp !== 24 && bpp !== 32) || (comp !== 0 && comp !== 3) || width <= 0 || h === 0) return null
338 const height = Math.abs(h)
339 const size = bpp / 8
340 const stride = Math.ceil((width * size) / 4) * 4
341 if (off + stride * height > b.length) return null
342 const hasAlpha = size === 4 && comp === 3 && header >= 56 && v.getUint32(66, true) === 0xff000000
343 return {
344 width,
345 height,
346 at: (x, y) => {
347 const o = off + (h < 0 ? y : height - 1 - y) * stride + x * size
348 if (hasAlpha && b[o + 3]! < 128) return -1
349 return (b[o + 2]! << 16) | (b[o + 1]! << 8) | b[o]!
350 },
351 }
352}
353
354const DEFAULT_COLOR = 0x01000000 // a Raster cell's "the terminal's own color"
355
356// A picture as Raster cells, two pixels to a cell: the upper half block in the top pixel's color over the bottom
357// one's (the lower half block, or a blank, where a pixel is transparent). Sampled to `columns` × `rows * 2`.
358export const halfBlocks = (px: Pixels, columns: number, rows: number): Uint32Array => {
359 const out = new Uint32Array(columns * rows * 3)
360 const sample = (x: number, y: number) =>
361 px.at(Math.min(px.width - 1, Math.floor(((x + 0.5) * px.width) / columns)), Math.min(px.height - 1, Math.floor(((y + 0.5) * px.height) / (rows * 2))))
362 for (let r = 0; r < rows; r++) {
363 for (let c = 0; c < columns; c++) {
364 const top = sample(c, 2 * r)
365 const bottom = sample(c, 2 * r + 1)
366 const cell =
367 top < 0 && bottom < 0
368 ? [0x20, DEFAULT_COLOR, DEFAULT_COLOR]
369 : top < 0
370 ? [0x2584, bottom, DEFAULT_COLOR]
371 : [0x2580, top, bottom < 0 ? DEFAULT_COLOR : bottom]
372 out.set(cell, (r * columns + c) * 3)
373 }
374 }
375 return out
376}
377
378// Box in terminal cells for a picture: as wide as it is at about 8 pixels a cell, within `maxColumns` and
379// `maxRows`, its proportions kept (a cell is about twice as tall as wide).
380export const fitCells = (width: number, height: number, maxColumns: number, maxRows: number): { columns: number; rows: number } => {
381 let columns = Math.max(4, Math.min(maxColumns, Math.round(width / 8)))
382 let rows = Math.max(1, Math.round((columns * height) / width / 2))
383 if (rows > maxRows) {
384 rows = maxRows
385 columns = Math.max(4, Math.min(maxColumns, Math.round((rows * 2 * width) / height)))
386 }
387 return { columns, rows }
388}
389
390export type SourceBlock = { text: string; line: number; end: number }
391
392// The blank-line separated blocks of a section's text, a fenced code block whole with its blank lines, each with
393// the file lines it spans (`firstLine` is the section's first).
394export const blocksOf = (text: string, firstLine: number): SourceBlock[] => {
395 const lines = text.split('\n')
396 const out: SourceBlock[] = []
397 let cur: string[] = []
398 let start = 0
399 let fence: string | null = null
400 const push = (last: number) => {
401 if (cur.length) out.push({ text: cur.join('\n'), line: firstLine + start, end: firstLine + last })
402 cur = []
403 }
404 lines.forEach((line, idx) => {
405 if (fence) {
406 cur.push(line)
407 if (closes(line, fence)) fence = null
408 return
409 }
410 if (line.trim() === '') return push(idx - 1)
411 if (!cur.length) start = idx
412 fence = FENCE.exec(line)?.[1] ?? null
413 cur.push(line)
414 })
415 push(lines.length - 1)
416 return out
417}
418
419// A short fingerprint of a block's text (FNV-1a, 32 bits): how an open edit bar knows its block again.
420export const hashOf = (s: string): string => {
421 let h = 0x811c9dc5
422 for (const ch of s) h = Math.imul(h ^ ch.codePointAt(0)!, 0x01000193) >>> 0
423 return h.toString(36)
424}
425hooks/links.ts 121 lines1// Pure helpers: find file references in markdown text and turn them into file:// links.
2
3export const MD = /\.(?:md|markdown|mdx)$/i
4
5// Inline code, a markdown link (or image), a URL, or a bare path ending .md (spaces need backticks).
6const TOKEN =
7 /(`+)([^`\n]+?)\1|(!?)\[([^\]\n]*)\]\(<?([^)\s>]+)>?\)|\b(?:https?|file):\/\/[^\s<>)\]]+|(?<![\w/.~@+-])((?:~|\.{1,2})?\/?(?:[\p{L}\p{N}_.@+-]+\/)*[\p{L}\p{N}_.@+-]+\.(?:md|markdown|mdx)(?::\d+(?::\d+)?)?)(?![\w/])/giu
8
9export const FENCE = /^\s{0,3}(`{3,}|~{3,})/
10
11export const dirname = (p: string): string => p.replace(/\/[^/]*$/, '') || '/'
12export const basename = (p: string): string => p.slice(p.lastIndexOf('/') + 1)
13
14// Collapse `.` and `..` segments of an absolute path.
15const normalize = (p: string): string => {
16 const out: string[] = []
17 for (const s of p.split('/')) {
18 if (s === '' || s === '.') continue
19 if (s === '..') out.pop()
20 else out.push(s)
21 }
22 return '/' + out.join('/')
23}
24
25// Where a reference leads: the file, and inside it a heading's slug or `L<line>` ('' for the top).
26export type Ref = { abs: string; frag: string }
27
28// A reference as written (`~/x.md`, `docs/a.md:12`, `a.md#setup`, `#setup` with `self`, `file:///…`) → Ref;
29// null when it names no local markdown file (with `anyExt`, no local file).
30export const resolveRef = (raw: string, base: string, home: string, opts: { anyExt?: boolean; self?: string } = {}): Ref | null => {
31 let p = raw.trim()
32 if (/^file:\/\//i.test(p)) {
33 try {
34 p = decodeURIComponent(p.slice(7))
35 } catch {
36 return null
37 }
38 }
39 let frag = ''
40 const hash = p.indexOf('#')
41 if (hash >= 0) {
42 frag = p.slice(hash + 1)
43 p = p.slice(0, hash)
44 }
45 const line = /:(\d+)(?:[:-]\d+)?$/.exec(p)
46 if (line) {
47 p = p.slice(0, line.index)
48 frag ||= `L${line[1]}`
49 }
50 if (!p) return opts.self && frag ? { abs: opts.self, frag } : null
51 if ((!opts.anyExt && !MD.test(p)) || /^[a-z][\w+.-]*:/i.test(p) || p.includes('\n')) return null
52 if (p === '~' || p.startsWith('~/')) p = home + p.slice(1)
53 else if (!p.startsWith('/')) p = `${base}/${p}`
54 return { abs: normalize(p), frag }
55}
56
57export const hrefOf = (ref: Ref): string =>
58 'file://' +
59 encodeURI(ref.abs).replace(/[#?()]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) +
60 (ref.frag ? '#' + encodeURIComponent(ref.frag) : '')
61
62export const refOf = (href: string): Ref | null => {
63 if (!/^file:\/\//i.test(href)) return null
64 const hash = href.indexOf('#')
65 try {
66 return {
67 abs: decodeURIComponent(href.slice(7, hash < 0 ? undefined : hash)),
68 frag: hash < 0 ? '' : decodeURIComponent(href.slice(hash + 1)),
69 }
70 } catch {
71 return null
72 }
73}
74
75// The ways a bare path may begin when other text runs into it ("见README.md"): the whole run first,
76// then from each point where non-ASCII text gives way to ASCII.
77const starts = (bare: string): number[] => {
78 const out = [0]
79 for (let i = 1; i < bare.length; i++) if (bare.charCodeAt(i - 1) > 0x7f && bare.charCodeAt(i) <= 0x7f) out.push(i)
80 return out
81}
82
83export type LinkKind = 'code' | 'link' | 'bare'
84
85// Rewrites every reference `link` answers with an href into a markdown link to it, `mark` after its label;
86// code fences are left alone.
87export const linkify = (text: string, link: (raw: string, kind: LinkKind) => string | null, mark = ''): string => {
88 let fence: string | null = null
89 return text
90 .split('\n')
91 .map(line => {
92 const f = FENCE.exec(line)?.[1]
93 if (fence) {
94 if (f && f[0] === fence[0] && f.length >= fence.length) fence = null
95 return line
96 }
97 if (f) {
98 fence = f
99 return line
100 }
101 return line.replace(TOKEN, (m, _ticks, code, bang, label, target, bare) => {
102 if (code !== undefined) {
103 const h = link(code, 'code')
104 return h ? `[${m}${mark}](${h})` : m
105 }
106 if (target !== undefined && !bang) {
107 const h = link(target, 'link')
108 return h ? `[${label}${mark}](${h})` : m
109 }
110 if (bare !== undefined) {
111 for (const i of starts(bare)) {
112 const h = link(bare.slice(i), 'bare')
113 if (h) return `${bare.slice(0, i)}[${bare.slice(i)}${mark}](${h})`
114 }
115 }
116 return m
117 })
118 })
119 .join('\n')
120}
121types/index.d.ts 25 lines1// The file the pane shows, the page of it on screen, the element last brought into view (null: the top),
2// and the files Back returns to (latest last).
3export type MdviewDoc = { path: string; page: number; target: string | null; back: string[] }
4
5// The find bar: open or not, what was last looked for, and which hit is shown.
6export type MdviewFind = { query: string; at: number; hits: number }
7
8// The block whose ✎ was pressed, by the line it began on and a fingerprint of its text (found again if the file
9// changes above it): its edit bar is open.
10export type MdviewEdit = { path: string; line: number; hash: string }
11
12// An edit sent to Claude from a block's ✎: the block, the prompt as sent, the turn that runs it (null until it starts),
13// whether that turn wrote the file, and when the turn ended (null while it runs). The pane shows it under its block
14// until a few seconds after.
15export type MdviewPending = { id: string; path: string; line: number; hash: string; text: string; turn: string | null; changed: boolean; done: number | null }
16
17declare module 'claude-code' {
18 // `edit` and `pending` are Shaped: each atom names a shape, bumped whenever its type changes, so a reload declines
19 // an old value
20 interface PluginState {
21 // `cwds`: the working directories the session has had, latest first, kept across reloads of the mod
22 mdview: { doc: MdviewDoc | null; rev: number; toc: boolean; find: MdviewFind | null; edit: Shaped<MdviewEdit | null>; cwds: string[]; pending: Shaped<MdviewPending[]> }
23 }
24}
25