SLOPSHOPPER

mdview

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…

newpanerowsguardcommandtoast
★ 3v1.0.0MITupdated 2026-10-03xuanji86/claude-mdview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mdview
│ ┃ mdview ✕ › fix the failing auth test and add an audit log call │ ┃ ⎿ Click a .md path in the conversation, or │ ┃ /md <path> ⏺ 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 │ ⎿ mdview: Usage: /md <path>[#heading|:line] │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mdview
⎿ Click a .md path in the conversation, or run /md <path>
README

mdview

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.

Version Claude Code mod Terminals License: MIT

English · 中文

<img src="assets/pane.svg" alt="A markdown file rendered in a pane beside the Claude Code conversation" width="820">

Why

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.

Highlights

One click to readA .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 dumpSections 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 terminalThe 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 editHover 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
LiveA file Claude writes redraws at once; one changed elsewhere within 1.5 s
Your viewer, per terminalA 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 markdownAny 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 youAsk to see a file and Claude opens it with mcp__mdview__show: in the pane, or in Warp's viewer in Warp

See it work

<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>

Install

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.

Use

Click a ↗ pathOpens 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 · qCloses the pane
bBack to the file you came from
t · fContents · Find (Enter again for the next match)
p · nPrevious · next part of a long file, also at the foot of each part
wIn 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.

Configure

In /config, the rows Click opens and Click opens (Warp). /md mode sets the same values.

SettingChoicesDefault
clickOpenspane: mdview's pane · app: the app macOS opens .md files withpane
clickOpensInWarpwarp: Warp's Markdown viewer, split to the right · pane · appwarp

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.

Terminals

TerminalPicturesA click opens, by default
Ghostty, kittySharp: the terminal's own picturemdview's pane
iTerm2 3.7+Sharp, with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 set for iTerm2 only (see below). Without it, half blocksmdview's pane
WarpHalf blocks in the pane; sharp in Warp's own viewerWarp's viewer, split to the right
OthersColored 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.

What it can reach

mdview reads, draws, and acts only when you ask it to. claude plugin validate . prints every hook and call it makes.

ItWhen
reads a fileits path is in the conversation (to see that it exists), or you open it
runs sipsit 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 openyou 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 promptyou press Enter in a ✎ bar, and only with the text you typed
changes a settingyou run /md mode

It makes no network requests, stores nothing across sessions, and calls no model of its own.

Limits

  • No true fullscreen. A mod's pane docks beside the transcript, which keeps a minimum width; mdview asks for all the rest.
  • Clicks need the fullscreen layout. On the main screen, use /md.
  • A terminal draws text. Math, mermaid and heading sizes show as written. Warp's own viewer does draw mermaid.
  • Redrawn replies. A reply with a clickable path is drawn by mdview, so copying it brings its ⏺ along.
  • Size. One markdown element holds at most 10,000 characters and a pane about 100,000, so long files come in parts.
  • Early access. The mods API may change between Claude Code releases.

Develop

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.

License

MIT © Anji Xu

Source 4 files
hooks/register.tsx 851 lines
1import { 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}
851
hooks/doc.ts 425 lines
1// 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 ? `![${attr(a, 'alt') ?? ''}](${src.replace(/ /g, '%20')})` : ''
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(/&nbsp;/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}
425
hooks/links.ts 121 lines
1// 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}
121
types/index.d.ts 25 lines
1// 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