SLOPSHOPPER

md

Read, write and review markdown with Claude in a pane inside Claude Code: drafts stream in, comments stay on their words, and Claude suggests edits you accept…

newpaneguardcommandtoasttool
v0.2.0MITupdated 2026-10-05davekiss/md
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · md
│ ┃ Open markdown ✕ › fix the failing auth test and add an audit log call │ ┃ Open: type to filter, or a path ⏎ open │ ┃ + New file ⏺ Read(src/auth.ts) │ ┃ README.md ⎿ Read 6 lines │ ┃ cancel ⏺ 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 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Open markdown
Open: type to filter, or a path ⏎ open + New file README.md cancel
README

<h1 align="center">md</h1>

A Claude Code mod for markdown files.<br> Claude drafts into a pane beside the chat. You read, select, comment and edit there, and so does Claude.

<img src="https://img.shields.io/badge/version-0.2.0-000" alt="Version 0.2.0"> <img src="https://img.shields.io/badge/Claude%20Code-2.1.288%2B-000" alt="Claude Code 2.1.288+"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-000" alt="MIT"></a>


Chat is a poor place to edit prose. Each revision is a fresh wall of text, and the sentence you reacted to scrolls away.

md gives the text a place to stay. Claude writes the draft into a pane, and you see it stream in. You select a phrase and ask about it. Claude answers on that phrase, or proposes a rewrite you accept or reject. Every comment stays on its words, even after the paragraph above it changes.

╭─ essay.md ─────────────────────────── 22:1 · 2 open · 1 suggested ─╮
│ 20 │ For most of history the margin was a dead end.                │
│ 21 │                                                               │
│✎22▸│ That is no longer quite true. A bracketed paragraph can come  │
│    │ back rewritten.                                               │
│ 23 │                                                               │
│◆24 │ The page stops being a finished object.                       │
├────────────────────────────────────────────────────────────────────┤
│ ✎ Claude suggests (line 22): flagged                               │
│   fits the conversation idea   [ accept ]  d: reject               │
╰────────────────────────────────────────────────────────────────────╯

Get started

/plugin marketplace add davekiss/cc-plugins
/plugin install md@davekiss

Then ask Claude to draft something in the pane, or type /md to pick a file.

What it does

Drafts stream into the pane

Ask for a first draft and Claude writes it into the pane instead of the chat. The text appears as Claude writes it, so you can start on the first section while the last is still coming. The draft isn't a file yet. It keeps its comments between sessions, and /md save drafts/post.md writes it out when it's worth keeping.

Point at exact words

Drag across a phrase, then press ctrl+j to ask Claude about it or ctrl+k to leave a note. Claude gets the exact text you selected, not just a line number. Press x to send every open note at once.

Claude suggests, you decide

For your prose, Claude proposes changes instead of making them. The old text is struck through in place and the new text waits underneath. Accept or reject it with a click, or press Tab to land on accept, then y or d. When you ask for a change outright, Claude makes it directly. Either way it's marked in the gutter and u undoes it.

Made for reading what you wrote

The text sits in a centered column about 72 characters wide, the line length prose reads best at, however wide the pane is. Markdown stays real and editable, but it steps back. and # are faint, so bold** reads bold and italic reads italic. Code and links get their own color. The header keeps a live word count, of the whole document or of what you've selected.

Press z for focus mode. Every paragraph but the one you're in fades, and your line stays near the middle of the pane as you write.

Images in the text

An image on its own line shows as a picture in the reader view, and under your line when the caret is on it. Ghostty, kitty and WezTerm draw real pixels. Other terminals with full color, iTerm2 included, get a half-block thumbnail. Previews need macOS, which reads the image with sips.

Comments stay on their words

Notes are anchored to the text they quote. Edit above them and they move with it. Delete their text and they're marked stale instead of pointing at the wrong line.

The gutter tells you who said what

MarkMeaning
●You commented on this line
◆Only Claude commented on this line
✎Claude suggested a change here
+Changed since you opened the file
▸Your caret

Keys

In the text, type to edit. Click to place the caret and drag to select.

KeyDoes
ctrl+jAsk Claude about the selection
ctrl+kComment on the selection
ctrl+uUndo (ctrl+z suspends Claude Code)
cmd+vPaste text. A dragged-in image file becomes an image link.
ctrl+vPaste the image on the clipboard: saved to assets/ and linked (macOS)
TabMove to the pane's buttons

With the pane's buttons focused:

KeyDoes
j / kNext / previous line
f / bPage down / up
vStart or clear a selection
oCopy the selection, or the whole document, to the clipboard
iPaste the image on the clipboard
zFocus mode: fade all but this paragraph, keep your line centered
nNext comment
c / aComment / ask
xSend all open comments to Claude
sResolve the comment on this line
y / dAccept / reject the suggestion on this line
uUndo
rSwitch to the reader view (and back)
qClose the pane

Commands

CommandDoes
/mdPick a file: recent ones first, then markdown under the current directory. Type to filter, or choose + New file.
/md <path>Open a file
/md new [name]Create an empty markdown file and open it. Without a name, untitled.md. Never overwrites.
/md save <path>Save the draft, or the open file under a new name. Never overwrites another file.

Claude's tools

Claude works the pane through the same moves you have.

ToolDoes
draftWrite or extend the draft, streamed into the pane
suggestPropose a change for you to accept or reject
editChange exact text, marked in the gutter and undoable
commentLeave a note on quoted text
replyAnswer a note, and resolve it when done
openShow a file, a line range, or select exact text
viewSee your caret, selection, comments, suggestions and changes
saveWrite the draft to a file
closeClose the pane

License

MIT

Source 6 files
hooks/register.tsx 1623 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Comment, Doc, Mode, Pos, StoredDraft, Suggestion } from '../types'
5import {
6  askPrompt,
7  blockAt,
8  blocksOf,
9  changedLines,
10  clampPos,
11  ordered,
12  quoteOf,
13  rangeLabel,
14  rangesOfQuote,
15  fuzzy,
16  inlineSpans,
17  countWords,
18  draftLines,
19  partialString,
20  readMinutes,
21  spansOf,
22  reanchor,
23  replaceRange,
24  reviewPrompt,
25  rowsOfBlock,
26  splitLines,
27  stepCol,
28  textBetween,
29  wrapSegments,
30} from './doc'
31import type { EditorRow } from './editor'
32import { blockBody } from './blocks'
33import { decodeBmp, fitRows, halfBlocks, hash, imageOfLine } from './preview'
34import type { Preview } from './preview'
35
36const PANE = 'md'
37
38const docAtom = atom({ plugin: 'md', key: 'doc' } as const, null)
39const commentsAtom = atom({ plugin: 'md', key: 'comments' } as const, [])
40const cursorAtom = atom({ plugin: 'md', key: 'cursor' } as const, 1)
41const colAtom = atom({ plugin: 'md', key: 'col' } as const, 0)
42const markAtom = atom({ plugin: 'md', key: 'mark' } as const, null)
43const viewTopAtom = atom({ plugin: 'md', key: 'viewTop' } as const, 1)
44const modeAtom = atom({ plugin: 'md', key: 'mode' } as const, 'review')
45const draftAtom = atom({ plugin: 'md', key: 'draft' } as const, null)
46const writingAtom = atom({ plugin: 'md', key: 'writing' } as const, false)
47const pickerAtom = atom({ plugin: 'md', key: 'picker' } as const, null)
48const suggestionsAtom = atom({ plugin: 'md', key: 'suggestions' } as const, [])
49const focusAtom = atom({ plugin: 'md', key: 'focus' } as const, false)
50const changedAtom = atom({ plugin: 'md', key: 'changed' } as const, [])
51const readerAtom = atom({ plugin: 'md', key: 'reader' } as const, false)
52const tocAtom = atom({ plugin: 'md', key: 'toc' } as const, false)
53const readTopAtom = atom({ plugin: 'md', key: 'readTop' } as const, 1)
54
55const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
56
57// Wheel rows per paragraph step in the reader; ticks below it add up
58const ROWS_PER_BLOCK = 5
59let wheelRows = 0
60
61// The review view's text area as last drawn, so moving the caret can keep
62// it on screen
63let layout = { textWidth: 60, room: 20 }
64
65// Where a drag started, so the selection runs from there
66let dragFrom: Pos | null = null
67
68// The editor's rows as last drawn, to map a click's cell to a line and column
69let drawn: { rows: EditorRow[]; gutterWidth: number } = { rows: [], gutterWidth: 0 }
70
71function cellPos(x: number, y: number): Pos | null {
72  const { rows, gutterWidth } = drawn
73  const row = rows[Math.max(0, Math.min(rows.length - 1, y))]
74  if (!row) return null
75  // Above or below the text clamps to its first or last row
76  if (y < 0) return { line: row.line, col: row.start }
77  const end = row.isLast ? row.text.length : Math.max(0, row.text.length - 1)
78  return { line: row.line, col: row.start + Math.min(Math.max(0, x - gutterWidth), end) }
79}
80
81// Earlier states of the file, newest last, for undo
82const undoStack: { lines: string[]; caret: Pos }[] = []
83
84// Editor messages run one at a time, so fast typing applies in order
85let queue: Promise<unknown> = Promise.resolve()
86
87function newId(): string {
88  return 'c' + crypto.randomUUID().slice(0, 6)
89}
90
91async function absolute($: EngineInterface, path: string): Promise<string> {
92  const home = (await $.env.get('HOME')) ?? ''
93  if (path.startsWith('~/')) return home + path.slice(1)
94  if (path.startsWith('/')) return path
95  const cwd = await $.session.cwd()
96  return cwd.replace(/\/$/, '') + '/' + path.replace(/^\.\//, '')
97}
98
99// Keeps the open document's comments and suggestions in the store: under its
100// path, or with the unsaved draft, which survives the session that way
101async function persist($: EngineInterface) {
102  const doc = await read($, docAtom)
103  if (!doc) return
104  const comments = await read($, commentsAtom)
105  const suggestions = await read($, suggestionsAtom)
106  if (!doc.path) return $.store.set('draft', { lines: doc.lines, comments, suggestions } satisfies StoredDraft)
107  await $.store.set('comments:' + doc.path, comments)
108  await $.store.set('suggestions:' + doc.path, suggestions)
109}
110
111async function saveComments($: EngineInterface, comments: Comment[]) {
112  await update($, commentsAtom, () => comments)
113  await persist($)
114}
115
116async function saveSuggestions($: EngineInterface, suggestions: Suggestion[]) {
117  await update($, suggestionsAtom, () => suggestions)
118  await persist($)
119}
120
121async function storedDraft($: EngineInterface): Promise<StoredDraft | null> {
122  return ((await $.store.get('draft')) ?? null) as StoredDraft | null
123}
124
125// The draft's lines, open or kept in the store
126async function draftSoFar($: EngineInterface): Promise<string[] | null> {
127  const doc = await read($, docAtom)
128  return doc && doc.path === null ? doc.lines : ((await storedDraft($))?.lines ?? null)
129}
130
131// Files opened or saved here, newest first
132async function remember($: EngineInterface, path: string) {
133  const recent = ((await $.store.get('recent')) ?? []) as string[]
134  await $.store.set('recent', [path, ...recent.filter(p => p !== path)].slice(0, 10))
135}
136
137const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'vendor', 'coverage'])
138
139// Markdown files under `dir`, a few levels down, newest first
140async function markdownUnder($: EngineInterface, dir: string): Promise<string[]> {
141  const found: { path: string; mtimeMs: number }[] = []
142  const walk = async (at: string, depth: number) => {
143    const entries = await $.fs.list(at).catch(() => [])
144    for (const e of entries) {
145      if (found.length >= 200) return
146      const path = at.replace(/\/$/, '') + '/' + e.name
147      if (e.kind === 'file' && /\.(md|markdown|mdx)$/i.test(e.name)) found.push({ path, mtimeMs: e.mtimeMs })
148      else if (e.kind === 'dir' && depth < 3 && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) await walk(path, depth + 1)
149    }
150  }
151  await walk(dir, 0)
152  return found.sort((a, b) => b.mtimeMs - a.mtimeMs).map(f => f.path)
153}
154
155async function showPicker($: EngineInterface) {
156  const recent: string[] = []
157  // Before the recent list there was only the last file opened
158  const stored = ((await $.store.get('recent')) ?? [await $.store.get('last')].filter(Boolean)) as string[]
159  for (const p of stored) if (await $.fs.exists(p)) recent.push(p)
160  const here = await markdownUnder($, await $.session.cwd())
161  const files = [...recent, ...here.filter(p => !recent.includes(p))]
162  const doc = await read($, docAtom)
163  const hasDraft = (doc !== null && doc.path === null) || (await storedDraft($)) !== null
164  await update($, pickerAtom, () => ({ files, recent: recent.length, filter: '', hasDraft }))
165  await $.ui.open({ id: PANE, title: 'Open markdown', focus: true })
166}
167
168// Opens a file from the picker, or tells the person why not; null goes back
169// to the unsaved draft
170async function openPicked($: EngineInterface, path: string | null) {
171  if (path === null) {
172    const doc = await read($, docAtom)
173    const stored = await storedDraft($)
174    if (doc?.path !== null && stored) await showDoc($, { path: null, lines: stored.lines, mtimeMs: 0 }, stored.comments, stored.suggestions)
175    await update($, pickerAtom, () => null)
176    return $.ui.open({ id: PANE, title: docName(null), focus: true })
177  }
178  try {
179    await openDoc($, path)
180    await update($, pickerAtom, () => null)
181    await $.ui.open({ id: PANE, title: docName(path), focus: true })
182  } catch (err) {
183    $.ui.toast(`Could not open ${docName(path)}: ${String(err)}`)
184  }
185}
186
187// The markdown file a typed name means: `.md` added when it has no extension
188function markdownName(given: string): string {
189  const name = given.trim() || 'untitled.md'
190  return /\.(md|markdown|mdx)$/i.test(name) ? name : name + '.md'
191}
192
193// Creates an empty markdown file and opens it; a name that exists just opens.
194// With no name, the first free untitled.md, untitled-2.md, ...
195async function newFile($: EngineInterface, given: string) {
196  let path = await absolute($, markdownName(given))
197  if (!given.trim()) for (let i = 2; await $.fs.exists(path); i++) path = await absolute($, `untitled-${i}.md`)
198  if (!(await $.fs.exists(path))) await $.fs.write(path, '')
199  await openPicked($, path)
200}
201
202function docName(path: string | null): string {
203  return path?.split('/').pop() ?? 'Untitled draft'
204}
205
206async function openDoc($: EngineInterface, given: string): Promise<string> {
207  const path = await absolute($, given)
208  const text = await $.fs.read(path)
209  const { mtimeMs } = await $.fs.stat(path)
210  const lines = splitLines(text)
211  const comments = ((await $.store.get('comments:' + path)) ?? []) as Comment[]
212  const suggestions = ((await $.store.get('suggestions:' + path)) ?? []) as Suggestion[]
213  await showDoc($, { path, lines, mtimeMs }, comments, suggestions)
214  await remember($, path)
215  return path
216}
217
218// Puts a document in the pane from the top, with nothing selected or pending
219async function showDoc($: EngineInterface, doc: Doc, comments: Comment[], suggestions: Suggestion[]) {
220  await update($, docAtom, () => doc)
221  await update($, cursorAtom, () => 1)
222  await update($, colAtom, () => 0)
223  await update($, markAtom, () => null)
224  await update($, viewTopAtom, () => 1)
225  await update($, readTopAtom, () => 1)
226  undoStack.length = 0
227  await update($, changedAtom, () => [])
228  await update($, modeAtom, () => 'review')
229  await update($, draftAtom, () => null)
230  await update($, suggestionsAtom, () => suggestions)
231  await saveComments($, reanchor(comments, doc.lines))
232}
233
234// The draft as Claude's text sets it: the text alone, or added after the
235// draft so far with a blank line between
236async function setDraft($: EngineInterface, lines: string[]) {
237  const doc = await read($, docAtom)
238  if (doc && doc.path === null) {
239    await update($, docAtom, () => ({ ...doc, lines }))
240    await saveComments($, reanchor(await read($, commentsAtom), lines))
241  } else {
242    const stored = await storedDraft($)
243    await showDoc($, { path: null, lines, mtimeMs: 0 }, stored?.comments ?? [], stored?.suggestions ?? [])
244  }
245}
246
247// Writes the open document, draft or file, to a new path and keeps it open
248// from there; refuses to overwrite another file
249async function saveAs($: EngineInterface, given: string): Promise<string> {
250  const doc = await read($, docAtom)
251  if (!doc) throw new Error('nothing is open in the pane')
252  const path = await absolute($, given)
253  if (path !== doc.path && (await $.fs.exists(path))) throw new Error(`${path} already exists`)
254  await $.fs.write(path, doc.lines.join('\n') + '\n')
255  const { mtimeMs } = await $.fs.stat(path)
256  await update($, docAtom, () => ({ path, lines: doc.lines, mtimeMs }))
257  await persist($)
258  if (doc.path === null) await $.store.delete('draft')
259  await remember($, path)
260  return path
261}
262
263const DRAFT_TOOL = 'mcp__md__draft'
264
265// The draft's lines before the draft call now streaming, so the call's end
266// can add to them and u can bring them back
267let draftBefore: string[] | null = null
268
269// Shows a draft call's text as far as it has streamed, following its end
270async function previewDraft($: EngineInterface, json: string) {
271  const text = partialString(json, 'text')
272  if (text === null) return
273  const append = /"append"\s*:\s*true/.test(json)
274  const lines = draftLines(append ? draftBefore : null, text)
275  await setDraft($, lines)
276  await update($, cursorAtom, () => lines.length)
277  await update($, colAtom, () => 0)
278  await keepVisible($)
279}
280
281// Re-reads the open file when it changed on disk, marking the new lines and
282// moving comments along with the text they were made on.
283async function refresh($: EngineInterface) {
284  const doc = await read($, docAtom)
285  if (!doc?.path) return
286  const stat = await $.fs.stat(doc.path).catch(() => undefined)
287  if (!stat || stat.mtimeMs === doc.mtimeMs) return
288  const lines = splitLines(await $.fs.read(doc.path))
289  await update($, docAtom, () => ({ ...doc, lines, mtimeMs: stat.mtimeMs }))
290  await update($, changedAtom, () => changedLines(doc.lines, lines))
291  await update($, cursorAtom, n => Math.min(n, Math.max(1, lines.length)))
292  const cursor = await read($, cursorAtom)
293  await update($, colAtom, c => Math.min(c, (lines[cursor - 1] ?? '').length))
294  await saveComments($, reanchor(await read($, commentsAtom), lines))
295}
296
297async function caretOf($: EngineInterface): Promise<Pos> {
298  return { line: await read($, cursorAtom), col: await read($, colAtom) }
299}
300
301// Moves the view so the caret's line is on screen
302async function keepVisible($: EngineInterface) {
303  const doc = await read($, docAtom)
304  if (!doc) return
305  const cursor = await read($, cursorAtom)
306  const rowsOf = (n: number) => wrapSegments(doc.lines[n - 1] ?? '', layout.textWidth).length
307  let top = await read($, viewTopAtom)
308  // Focus mode keeps the caret's line near the middle, as a typewriter does
309  if (await read($, focusAtom)) {
310    top = cursor
311    let above = 0
312    while (top > 1 && above + rowsOf(top - 1) <= Math.floor(layout.room / 2)) above += rowsOf(--top)
313    return update($, viewTopAtom, () => top)
314  }
315  if (cursor < top) top = cursor
316  let used = 0
317  for (let n = top; n <= cursor; n++) used += rowsOf(n)
318  while (used > layout.room && top < cursor) {
319    used -= rowsOf(top)
320    top++
321  }
322  await update($, viewTopAtom, () => top)
323}
324
325// Puts the caret at a position; `extend` keeps (or starts) the selection
326async function placeCaret($: EngineInterface, to: Pos, extend: boolean) {
327  const doc = await read($, docAtom)
328  if (!doc) return
329  const caret = await caretOf($)
330  const p = clampPos(doc.lines, to)
331  if (extend) await update($, markAtom, m => m ?? caret)
332  else await update($, markAtom, () => null)
333  await update($, cursorAtom, () => p.line)
334  await update($, colAtom, () => p.col)
335  await keepVisible($)
336}
337
338// Replaces the selection (or the range given) with text, saving the file
339async function edit($: EngineInterface, from: Pos, to: Pos, text: string) {
340  const doc = await read($, docAtom)
341  if (!doc) return
342  undoStack.push({ lines: doc.lines, caret: await caretOf($) })
343  if (undoStack.length > 200) undoStack.shift()
344  const next = replaceRange(doc.lines, from, to, text)
345  await writeLines($, next.lines)
346  await update($, markAtom, () => null)
347  await update($, cursorAtom, () => next.caret.line)
348  await update($, colAtom, () => next.caret.col)
349  await keepVisible($)
350}
351
352async function undo($: EngineInterface) {
353  const last = undoStack.pop()
354  if (!last) return
355  await writeLines($, last.lines)
356  await update($, markAtom, () => null)
357  await update($, cursorAtom, () => last.caret.line)
358  await update($, colAtom, () => last.caret.col)
359  await keepVisible($)
360}
361
362// Where a suggestion's old text is now, nearest the line it was made on
363function locate(lines: string[], s: Suggestion): [Pos, Pos] | null {
364  return spansOf(lines, s.old, s.line)[0] ?? null
365}
366
367async function suggestionAtCaret($: EngineInterface): Promise<Suggestion | null> {
368  const doc = await read($, docAtom)
369  if (!doc) return null
370  const cursor = await read($, cursorAtom)
371  for (const s of await read($, suggestionsAtom)) {
372    const span = locate(doc.lines, s)
373    if (span && span[0].line <= cursor && cursor <= span[1].line) return s
374  }
375  return null
376}
377
378// Puts the selection, or the whole document, on the clipboard: copying from
379// the terminal itself would take the gutter and the wrapping along
380async function copyOut($: EngineInterface) {
381  const doc = await read($, docAtom)
382  if (!doc) return
383  const sel = ordered(await read($, markAtom), await caretOf($))
384  const text = sel ? textBetween(doc.lines, sel[0], sel[1]) : doc.lines.join('\n') + '\n'
385  const copied = await $.ui.copy({ text })
386  const what = sel ? 'the selection' : `${docName(doc.path)} (${doc.lines.length} lines)`
387  $.ui.toast(copied.isCopied ? `Copied ${what}` : `Could not copy: ${copied.reason}`)
388}
389
390// Accepts the suggestion on the caret's line, as an edit u undoes, or drops it
391async function settle($: EngineInterface, accept: boolean) {
392  const s = await suggestionAtCaret($)
393  const doc = await read($, docAtom)
394  if (!s || !doc) return $.ui.toast('No suggested change on this line.')
395  await saveSuggestions($, (await read($, suggestionsAtom)).filter(x => x.id !== s.id))
396  const span = locate(doc.lines, s)
397  if (!accept || !span) return
398  await edit($, span[0], span[1], s.text)
399  const after = (await read($, docAtom))?.lines ?? []
400  await update($, changedAtom, () => changedLines(doc.lines, after))
401}
402
403// The names a key event uses for keys; any other text longer than one
404// character is a paste
405const KEY_NAMES = new Set(['up', 'down', 'left', 'right', 'return', 'tab', 'backspace', 'delete', 'pageup', 'pagedown', 'home', 'end', 'escape', 'space'])
406
407const IMAGE_FILE = /\.(png|jpe?g|gif|webp|svg)$/i
408
409// Where the open document's images go and how its links name them: next to
410// the file, or under the working directory for a draft
411async function assetsDir($: EngineInterface, doc: Doc): Promise<string> {
412  return doc.path ? doc.path.replace(/\/[^/]*$/, '') : await $.session.cwd()
413}
414
415// A markdown image link for `path`, relative to `dir` when it lies inside it
416function imageLink(dir: string, path: string): string {
417  const target = path.startsWith(dir + '/') ? path.slice(dir.length + 1) : path
418  const alt = (path.split('/').pop() ?? '').replace(IMAGE_FILE, '')
419  return `![${alt}](${/\s/.test(target) ? `<${target}>` : target})`
420}
421
422// Inserts pasted text at the caret over any selection; a dropped image file's
423// path goes in as an image link
424async function paste($: EngineInterface, raw: string) {
425  const doc = await read($, docAtom)
426  if (!doc) return
427  let text = raw.replace(/\r\n?/g, '\n')
428  const dropped = text.trim().replace(/^(['"])(.*)\1$/, '$2').replace(/\\ /g, ' ')
429  if (!dropped.includes('\n') && dropped.startsWith('/') && IMAGE_FILE.test(dropped) && (await $.fs.exists(dropped))) {
430    text = imageLink(await assetsDir($, doc), dropped)
431  }
432  const caret = await caretOf($)
433  const sel = ordered(await read($, markAtom), caret)
434  await edit($, sel?.[0] ?? caret, sel?.[1] ?? caret, text)
435}
436
437// Saves the image on the clipboard as a PNG under assets/ and links it at the
438// caret. A terminal can't paste image data, so this reads the clipboard itself
439async function pasteImage($: EngineInterface) {
440  const doc = await read($, docAtom)
441  if (!doc) return
442  const info = await $.process.run(['osascript', '-e', 'clipboard info']).catch(() => null)
443  if (!info) return $.ui.toast('Pasting images needs macOS (osascript).')
444  if (!info.stdout.includes('PNGf')) return $.ui.toast('No image on the clipboard.')
445  const dir = await assetsDir($, doc)
446  const stem = docName(doc.path).replace(/\.[^.]*$/, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'draft'
447  const file = `${dir}/assets/${stem}-${Date.now()}.png`
448  await $.process.run(['mkdir', '-p', `${dir}/assets`])
449  const wrote = await $.process.run([
450    'osascript',
451    '-e', 'set png to (the clipboard as «class PNGf»)',
452    '-e', `set f to open for access POSIX file ${JSON.stringify(file)} with write permission`,
453    '-e', 'write png to f',
454    '-e', 'close access f',
455  ])
456  if (wrote.exitCode !== 0) return $.ui.toast(`Could not save the image: ${wrote.stderr.trim()}`)
457  const caret = await caretOf($)
458  const sel = ordered(await read($, markAtom), caret)
459  await edit($, sel?.[0] ?? caret, sel?.[1] ?? caret, imageLink(dir, file))
460  $.ui.toast(`Saved ${file.slice(dir.length + 1)}`)
461}
462
463// One key typed into the editor: caret moves, selection with shift, edits
464async function editorKey($: EngineInterface, k: { key: string; shift: boolean; ctrl: boolean; meta: boolean }) {
465  const doc = await read($, docAtom)
466  if (!doc || (await read($, writingAtom))) return
467  // Letters type into the file, so comment and ask need a chord. The pane's
468  // Input can't take the keys from the editor, so the text is typed here, into
469  // a draft, until Enter
470  const chord = k.ctrl ? ({ k: 'comment', j: 'ask' } as const)[k.key as 'k' | 'j'] : undefined
471  const draft = await read($, draftAtom)
472  if (chord) {
473    const same = draft !== null && (await read($, modeAtom)) === chord
474    await update($, modeAtom, () => (same ? 'review' : chord))
475    await update($, draftAtom, () => (same ? null : draft ?? ''))
476    return
477  }
478  if (draft !== null) {
479    if (k.key === 'return') {
480      const mode = await read($, modeAtom)
481      await update($, draftAtom, () => null)
482      await update($, modeAtom, () => 'review')
483      if (mode !== 'review') await submitPrompt($, mode, draft)
484    } else if (k.key === 'backspace') await update($, draftAtom, () => [...draft].slice(0, -1).join(''))
485    else if (k.key === 'space') await update($, draftAtom, () => draft + ' ')
486    else if (!k.ctrl && !k.meta && !KEY_NAMES.has(k.key)) await update($, draftAtom, () => draft + k.key.replace(/\s*\n\s*/g, ' '))
487    return
488  }
489  const lines = doc.lines
490  const caret = await caretOf($)
491  const sel = ordered(await read($, markAtom), caret)
492  const go = (p: Pos) => placeCaret($, p, k.shift)
493  switch (k.key) {
494    case 'left':
495      return sel && !k.shift ? placeCaret($, sel[0], false) : go(stepCol(lines, caret, -1))
496    case 'right':
497      return sel && !k.shift ? placeCaret($, sel[1], false) : go(stepCol(lines, caret, 1))
498    case 'up':
499      return go({ line: caret.line - 1, col: caret.col })
500    case 'down':
501      return go({ line: caret.line + 1, col: caret.col })
502    case 'home':
503      return go({ line: caret.line, col: 0 })
504    case 'end':
505      return go({ line: caret.line, col: Number.MAX_SAFE_INTEGER })
506    case 'pageup':
507      return go({ line: caret.line - layout.room, col: caret.col })
508    case 'pagedown':
509      return go({ line: caret.line + layout.room, col: caret.col })
510    case 'backspace':
511      return sel ? edit($, sel[0], sel[1], '') : edit($, stepCol(lines, caret, -1), caret, '')
512    case 'delete':
513      return sel ? edit($, sel[0], sel[1], '') : edit($, caret, stepCol(lines, caret, 1), '')
514    case 'return':
515      return edit($, sel?.[0] ?? caret, sel?.[1] ?? caret, '\n')
516    case 'tab':
517      // To the pane's buttons: accept on a suggested change, else the toolbar
518      return $.ui
519        .focus({ requestId: PANE, key: (await suggestionAtCaret($)) ? 'k-y' : 'k-c' })
520        .then(r => r.deny)
521        .catch(String)
522        .then(denied => void (denied && $.ui.toast('Press Esc, then Tab, for the pane’s buttons.')))
523    case 'space':
524      return edit($, sel?.[0] ?? caret, sel?.[1] ?? caret, ' ')
525  }
526  // ctrl+z never arrives: Claude Code takes it to suspend itself
527  if ((k.ctrl && ['u', '_', '/'].includes(k.key)) || (k.meta && k.key === 'z')) return undo($)
528  if (k.ctrl && k.key === 'v') return pasteImage($)
529  if (k.ctrl || k.meta) return
530  // A printable character
531  if ([...k.key].length === 1) return edit($, sel?.[0] ?? caret, sel?.[1] ?? caret, k.key)
532  if (!KEY_NAMES.has(k.key)) return paste($, k.key)
533}
534
535async function writeLines($: EngineInterface, lines: string[]) {
536  const doc = await read($, docAtom)
537  if (!doc) return
538  let mtimeMs = doc.mtimeMs
539  if (doc.path) {
540    await $.fs.write(doc.path, lines.join('\n') + '\n')
541    mtimeMs = (await $.fs.stat(doc.path)).mtimeMs
542  }
543  await update($, docAtom, () => ({ ...doc, lines, mtimeMs }))
544  await saveComments($, reanchor(await read($, commentsAtom), lines))
545}
546
547async function currentRange($: EngineInterface): Promise<[number, number]> {
548  return (await currentPick($)).range
549}
550
551// What the person means: the selected text (exact, with the lines it spans),
552// else the caret's line
553async function currentPick($: EngineInterface): Promise<{ range: [number, number]; excerpt?: string }> {
554  const doc = await read($, docAtom)
555  const caret = await caretOf($)
556  const sel = ordered(await read($, markAtom), caret)
557  if (doc && sel) {
558    // A selection ending at column 0 doesn't reach into that line
559    const endLine = sel[1].col === 0 && sel[1].line > sel[0].line ? sel[1].line - 1 : sel[1].line
560    return { range: [sel[0].line, endLine], excerpt: textBetween(doc.lines, sel[0], sel[1]) }
561  }
562  return { range: [caret.line, caret.line] }
563}
564
565async function addComment(
566  $: EngineInterface,
567  text: string,
568  kind: Comment['kind'],
569  author: 'you' | 'claude' = 'you',
570  at?: [number, number],
571): Promise<Comment | null> {
572  const doc = await read($, docAtom)
573  if (!doc || !text.trim()) return null
574  const pick = at ? { range: at, excerpt: undefined } : await currentPick($)
575  const [start, end] = pick.range
576  const comment: Comment = {
577    id: newId(),
578    start,
579    end,
580    quote: quoteOf(doc.lines, start, end),
581    text: text.trim(),
582    kind,
583    author,
584    status: 'open',
585    ...(pick.excerpt ? { excerpt: pick.excerpt } : {}),
586  }
587  await saveComments($, [...(await read($, commentsAtom)), comment])
588  await update($, markAtom, () => null)
589  return comment
590}
591
592// Saves a comment, or saves a question and sends it to Claude
593async function submitPrompt($: EngineInterface, mode: 'comment' | 'ask', text: string) {
594  if (mode === 'comment') return void (await addComment($, text, 'note'))
595  const c = await addComment($, text, 'ask')
596  const doc = await read($, docAtom)
597  if (c && doc) void $.prompt.submit({ text: askPrompt(doc.path, doc.lines, c), asUser: true })
598}
599
600function relative(path: string, cwd: string): string {
601  return path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
602}
603
604// The paragraph a line belongs to: its run of non-blank lines
605function paragraphAround(lines: string[], n: number): [number, number] {
606  if ((lines[n - 1] ?? '').trim() === '') return [n, n]
607  let start = n
608  let end = n
609  while (start > 1 && (lines[start - 2] ?? '').trim() !== '') start--
610  while (end < lines.length && (lines[end] ?? '').trim() !== '') end++
611  return [start, end]
612}
613
614// Where an image link points on disk, read from the document's folder (the
615// working directory for a draft); web images have no preview
616async function imagePath($: EngineInterface, doc: Doc, src: string): Promise<string | null> {
617  if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return null
618  if (src.startsWith('/') || src.startsWith('~/')) return absolute($, src)
619  return (await assetsDir($, doc)) + '/' + decodeURI(src).replace(/^\.\//, '')
620}
621
622async function pixelsAvailable($: EngineInterface): Promise<boolean> {
623  const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
624  return ['ghostty', 'wezterm'].includes(program) || ((await $.env.get('TERM')) ?? '').includes('kitty')
625}
626
627// Previews by file, size and mode; an edited image has a new mtime, so a new key
628const previewCache = new Map<string, Promise<Preview | null>>()
629
630// The preview of the image at `path`, or null when it can't be read. macOS
631// sips measures and decodes it, so any format sips reads works
632async function previewOf($: EngineInterface, path: string, columns: number, maxRows: number, pixels: boolean) {
633  const stat = await $.fs.stat(path).catch(() => null)
634  if (!stat || stat.kind !== 'file') return null
635  const key = [path, stat.mtimeMs, columns, maxRows, pixels].join('|')
636  let made = previewCache.get(key)
637  if (!made) {
638    // A redraw that starts while sips runs aborts it; forget the failure so
639    // the next redraw tries again
640    made = makePreview($, path, stat.mtimeMs, columns, maxRows, pixels).catch(err => {
641      previewCache.delete(key)
642      if (!String(err).includes('aborted')) $.ui.log(`md: no preview for ${path}: ${String(err)}`)
643      return null
644    })
645    previewCache.set(key, made)
646  }
647  return made
648}
649
650async function makePreview($: EngineInterface, path: string, mtimeMs: number, columns: number, maxRows: number, pixels: boolean): Promise<Preview | null> {
651  const info = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path])
652  const width = Number(/pixelWidth:\s*(\d+)/.exec(info.stdout)?.[1])
653  const height = Number(/pixelHeight:\s*(\d+)/.exec(info.stdout)?.[1])
654  if (!width || !height) return null
655  const fit = fitRows(width, height, columns, maxRows)
656  const tmp = `/tmp/md-preview-${hash(`${path}|${mtimeMs}|${fit.columns}|${fit.rows}`)}`
657  if (pixels) {
658    let file = path
659    if (!/\.png$/i.test(path)) {
660      file = tmp + '.png'
661      if ((await $.process.run(['sips', '-s', 'format', 'png', path, '--out', file])).exitCode !== 0) return null
662    }
663    return { kind: 'pixels', file, generation: Math.round(mtimeMs), ...fit }
664  }
665  const out = tmp + '.bmp'
666  const size = ['--resampleHeightWidth', String(fit.rows * 2), String(fit.columns)]
667  if ((await $.process.run(['sips', '-s', 'format', 'bmp', ...size, path, '--out', out])).exitCode !== 0) return null
668  const { base64 } = await $.fs.read(out, { as: 'bytes' })
669  const bmp = decodeBmp(Uint8Array.fromBase64(base64))
670  return { kind: 'cells', cells: halfBlocks(bmp.rgba, bmp.width, bmp.height), ...fit }
671}
672
673type PaneElements = ReturnType<EngineInterface['ui']['resolve']>
674
675function drawPreview(E: PaneElements, key: string, p: Preview, alt: string) {
676  const { Image, Raster } = E as unknown as {
677    Image: (props: Record<string, unknown>) => ReturnType<PaneElements['Text']>
678    Raster: (props: Record<string, unknown>) => ReturnType<PaneElements['Text']>
679  }
680  return p.kind === 'pixels'
681    ? <Image key={key} source={{ file: p.file, format: 'png', generation: p.generation }} columns={p.columns} rows={p.rows} alt={alt || 'image'} />
682    : <Raster key={key} columns={p.columns} rows={p.rows} cells={p.cells} />
683}
684
685function lineStyle(line: string, inFence: boolean): { color?: string; bold?: boolean; dimColor?: boolean; italic?: boolean } {
686  if (line.trimStart().startsWith('```')) return { dimColor: true }
687  if (inFence) return { color: 'cyan' }
688  if (/^#{1,6}\s/.test(line)) return { bold: true, color: 'magenta' }
689  if (/^\s*>/.test(line)) return { italic: true }
690  return {}
691}
692
693export const register: Register = on => {
694  on('session.start', async ($, e, next) => {
695    $.clock.every(1500, () => void refresh($))
696    await $.tool.register({
697      name: 'view',
698      description:
699        'What the person has open in the md markdown pane: the file, the caret (line and column), the exact text they selected, and every open review comment with its line range and quoted text.',
700      inputSchema: { type: 'object', properties: {} },
701    })
702    await $.tool.register({
703      name: 'reply',
704      description:
705        'Reply to a review comment in the md pane after working on it. Use resolve: true when the comment is fully addressed.',
706      inputSchema: {
707        type: 'object',
708        properties: { id: { type: 'string' }, reply: { type: 'string' }, resolve: { type: 'boolean' } },
709        required: ['id', 'reply'],
710      },
711    })
712    await $.tool.register({
713      name: 'open',
714      description:
715        "Open a markdown file in the person's md pane at a line or range (selected for them), to show them something or to start reviewing together. Give quote to select exact text instead, as the person would by dragging; line then picks among repeats. Leave out path to move within the file already open.",
716      inputSchema: {
717        type: 'object',
718        properties: {
719          path: { type: 'string' },
720          quote: { type: 'string', description: 'Exact text in the file to select' },
721          line: { type: 'number' },
722          endLine: { type: 'number' },
723          view: { type: 'string', enum: ['read', 'review'], description: 'read: the formatted reader; review: numbered lines' },
724        },
725      },
726    })
727    await $.tool.register({
728      name: 'comment',
729      description:
730        "Leave a review comment on the file open in the md pane, as the person can. Use it to flag something for them, propose a change before making it, or ask them a question about specific lines. Prefer quote (a few words copied from the file) over line numbers: it lands on the right lines even after edits. With both, line picks among repeated quotes. The pane moves to the range.",
731      inputSchema: {
732        type: 'object',
733        properties: {
734          quote: { type: 'string', description: 'Text from the file to comment on; whitespace and line breaks need not match' },
735          line: { type: 'number' },
736          endLine: { type: 'number' },
737          text: { type: 'string' },
738        },
739        required: ['text'],
740      },
741    })
742    await $.tool.register({
743      name: 'edit',
744      description:
745        "Edit the file open in the md pane the way the person types in it: replaces the exact text find with replace, saves the file, moves the caret there and marks the lines changed. The person can undo it with u. find must appear once, or give line to pick the nearest. Prefer this over Edit for the open file.",
746      inputSchema: {
747        type: 'object',
748        properties: {
749          find: { type: 'string', description: 'Exact text in the file, line breaks included' },
750          replace: { type: 'string' },
751          line: { type: 'number', description: 'Picks the match nearest this line when find repeats' },
752        },
753        required: ['find', 'replace'],
754      },
755    })
756    await $.tool.register({
757      name: 'suggest',
758      description:
759        "Propose a change to the text open in the md pane without making it: find is struck through in place and replace shows beneath with accept and reject buttons. Prefer this over edit for the person's own prose; use edit when they asked for the change. find must appear once, or give line to pick the nearest. mcp__md__view lists what is still pending.",
760      inputSchema: {
761        type: 'object',
762        properties: {
763          find: { type: 'string', description: 'Exact text in the file, line breaks included' },
764          replace: { type: 'string' },
765          note: { type: 'string', description: 'Why, in a few words' },
766          line: { type: 'number', description: 'Picks the match nearest this line when find repeats' },
767        },
768        required: ['find', 'replace'],
769      },
770    })
771    await $.tool.register({
772      name: 'draft',
773      description:
774        "Write markdown into the md pane as an unsaved draft, streamed onto the person's screen as you write it. Use it for first drafts instead of answering in chat or writing a file: the person reads, selects and comments on it in the pane. Replaces the draft, or with append: true adds after it (put append before text). Revise parts with mcp__md__edit; mcp__md__save writes it to a file.",
775      inputSchema: {
776        type: 'object',
777        properties: {
778          append: { type: 'boolean', description: 'Add after the draft so far instead of replacing it' },
779          text: { type: 'string', description: 'The markdown' },
780        },
781        required: ['text'],
782      },
783    })
784    await $.tool.register({
785      name: 'save',
786      description: 'Save what the md pane shows (a draft, or the open file under a new name) to path. Refuses to overwrite another file.',
787      inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
788    })
789    await $.tool.register({
790      name: 'close',
791      description: "Close the md pane on the person's screen.",
792      inputSchema: { type: 'object', properties: {} },
793    })
794    await $.command.register({
795      name: 'md',
796      description: 'Open a markdown file in the review pane, or save the draft there with /md save <path>',
797      argumentHint: '[path | new [name] | save <path>]',
798      immediate: true,
799    })
800    return next(e)
801  })
802
803  on('command.run', { command: 'md' }, async ($, e) => {
804    const args = e.args.trim()
805    const saving = /^save\s+(.+)$/.exec(args)
806    if (saving?.[1]) {
807      try {
808        const path = await saveAs($, saving[1])
809        await $.ui.open({ id: PANE, title: docName(path) })
810        return { text: `Saved to ${path}` }
811      } catch (err) {
812        return { text: `Could not save: ${String(err)}` }
813      }
814    }
815    if (!args) {
816      await showPicker($)
817      return {}
818    }
819    const fresh = /^new(?:\s+(.+))?$/.exec(args)
820    if (fresh) {
821      await newFile($, fresh[1] ?? '')
822      return {}
823    }
824    try {
825      const path = await openDoc($, args)
826      await update($, pickerAtom, () => null)
827      await $.ui.open({ id: PANE, title: docName(path), focus: true })
828      return {}
829    } catch (err) {
830      return { text: `Could not open ${args}: ${String(err)}` }
831    }
832  })
833
834  on('tool.call', { tool: 'mcp__md__view' }, async $ => {
835    const doc = await read($, docAtom)
836    if (!doc) return { result: 'No file is open in the md pane.' }
837    await refresh($)
838    const lines = (await read($, docAtom))?.lines ?? doc.lines
839    const suggestions = await read($, suggestionsAtom)
840    const pick = await currentPick($)
841    const [start, end] = pick.range
842    const open = (await read($, commentsAtom)).filter(c => c.status !== 'resolved')
843    return {
844      result: JSON.stringify(
845        {
846          path: doc.path,
847          lineCount: lines.length,
848          showing: (await read($, readerAtom)) ? 'read' : 'review',
849          changedLines: await read($, changedAtom),
850          suggestions: suggestions.map(s => {
851            const span = locate(lines, s)
852            return { ...s, ...(span ? { line: span[0].line } : { stale: true }) }
853          }),
854          caret: await caretOf($),
855          selection: pick.excerpt !== undefined ? { start, end, text: pick.excerpt } : null,
856          caretLine: quoteOf(doc.lines, start, end),
857          comments: open.map(({ id, start, end, text, kind, author, status, reply, quote, excerpt }) => ({
858            id, start, end, text, kind, author: author ?? 'you', status, reply, quote, excerpt,
859          })),
860        },
861        null,
862        2,
863      ),
864    }
865  })
866
867  on('tool.call', { tool: 'mcp__md__reply' }, async ($, e) => {
868    const args = e as unknown as { id: string; reply: string; resolve?: boolean }
869    const doc = await read($, docAtom)
870    const comments = await read($, commentsAtom)
871    if (!doc || !comments.some(c => c.id === args.id)) return { result: `No comment ${args.id} in the open file.` }
872    await refresh($)
873    const latest = await read($, commentsAtom)
874    await saveComments(
875      $,
876      latest.map(c =>
877        c.id === args.id ? { ...c, reply: args.reply, status: args.resolve ? 'resolved' : c.status } : c,
878      ),
879    )
880    return { result: 'Reply shown in the pane.' }
881  })
882
883  on('tool.call', { tool: 'mcp__md__open' }, async ($, e) => {
884    const args = e as unknown as { path?: string; quote?: string; line?: number; endLine?: number; view?: 'read' | 'review' }
885    try {
886      const current = await read($, docAtom)
887      const path = args.path || !current ? await openDoc($, args.path ?? '') : current.path
888      const total = (await read($, docAtom))?.lines.length ?? 1
889      let where = args.line ? ` at ${rangeLabel(args.line, args.endLine ?? args.line)}` : ''
890      if (args.quote) {
891        const span = spansOf((await read($, docAtom))?.lines ?? [], args.quote, args.line)[0]
892        if (!span) return { result: `"${args.quote}" is not in ${path}; nothing selected.` }
893        await placeCaret($, span[0], false)
894        await placeCaret($, span[1], true)
895        await update($, readTopAtom, () => span[0].line)
896        where = ` with "${args.quote}" selected (${rangeLabel(span[0].line, span[1].line)})`
897      } else if (args.line) {
898        const line = Math.max(1, Math.min(total, args.line))
899        const end = args.endLine ? Math.max(line, Math.min(total, args.endLine)) : line
900        const lines = (await read($, docAtom))?.lines ?? []
901        await update($, markAtom, () => (end > line ? { line, col: 0 } : null))
902        await update($, cursorAtom, () => end)
903        await update($, colAtom, () => (end > line ? (lines[end - 1] ?? '').length : 0))
904        await update($, viewTopAtom, () => line)
905        await update($, readTopAtom, () => line)
906      }
907      if (args.view) await update($, readerAtom, () => args.view === 'read')
908      const placed = await $.ui.open({ id: PANE, title: docName(path) })
909      if (placed.isPlaced) return { result: `Showing ${path ?? 'the draft'}${where} in the pane.` }
910      $.ui.toast(`Claude opened ${docName(path)}${where} — run /md to see it`)
911      return { result: `The pane is ready but not on screen (${placed.reason}); the person was told to run /md.` }
912    } catch (err) {
913      return { result: `Could not open ${args.path}: ${String(err)}` }
914    }
915  })
916
917  on('tool.call', { tool: 'mcp__md__close' }, async $ => {
918    await $.ui.close({ id: PANE })
919    return { result: 'Closed the pane.' }
920  })
921
922  on('tool.call', { tool: 'mcp__md__comment' }, async ($, e) => {
923    const args = e as unknown as { quote?: string; line?: number; endLine?: number; text: string }
924    const doc = await read($, docAtom)
925    if (!doc) return { result: 'No file is open in the md pane; call mcp__md__open first.' }
926    await refresh($)
927    const total = (await read($, docAtom))?.lines.length ?? 1
928    const lines = (await read($, docAtom))?.lines ?? []
929    let start: number
930    let end: number
931    if (args.quote?.trim()) {
932      const found = rangesOfQuote(lines, args.quote, args.line)[0]
933      if (!found) return { result: `Could not find "${args.quote}" in ${doc.path}; no comment added. Call mcp__md__view or re-read the file.` }
934      ;[start, end] = found
935    } else if (args.line) {
936      start = Math.max(1, Math.min(total, args.line))
937      while (start > 1 && (lines[start - 1] ?? '').trim() === '') start--
938      end = Math.max(start, Math.min(total, args.endLine ?? start))
939    } else {
940      return { result: 'Give quote (preferred) or line; no comment added.' }
941    }
942    const c = await addComment($, args.text, 'note', 'claude', [start, end])
943    if (!c) return { result: 'Comment text was empty.' }
944    await update($, cursorAtom, () => start)
945    await update($, colAtom, () => 0)
946    $.ui.toast(`Claude commented on ${rangeLabel(start, end)}`)
947    return { result: `Comment ${c.id} added on ${rangeLabel(start, end)}.` }
948  })
949
950  on('tool.call', { tool: 'mcp__md__edit' }, async ($, e) => {
951    const args = e as unknown as { find: string; replace: string; line?: number }
952    const doc = await read($, docAtom)
953    if (!doc) return { result: 'No file is open in the md pane; call mcp__md__open first.' }
954    await refresh($)
955    const before = (await read($, docAtom))?.lines ?? []
956    const spans = spansOf(before, args.find, args.line)
957    const span = spans[0]
958    if (!span) return { result: `The text to find is not in ${doc.path}; nothing changed. Call mcp__md__view or re-read the file.` }
959    if (spans.length > 1 && !args.line) {
960      const at = spans.map(s => s[0].line).sort((a, b) => a - b).join(', ')
961      return { result: `That text appears ${spans.length} times (lines ${at}); give line to pick one. Nothing changed.` }
962    }
963    await edit($, span[0], span[1], args.replace)
964    const after = (await read($, docAtom))?.lines ?? []
965    await update($, changedAtom, () => changedLines(before, after))
966    const end = span[0].line + splitLines(args.replace).length - 1
967    $.ui.toast(`Claude edited ${rangeLabel(span[0].line, end)} — u to undo`)
968    return { result: `Edited ${rangeLabel(span[0].line, end)}; the person can undo it with u.` }
969  })
970
971  on('tool.call', { tool: 'mcp__md__suggest' }, async ($, e) => {
972    const args = e as unknown as { find: string; replace: string; note?: string; line?: number }
973    const doc = await read($, docAtom)
974    if (!doc) return { result: 'Nothing is open in the md pane; call mcp__md__open first.' }
975    await refresh($)
976    const lines = (await read($, docAtom))?.lines ?? []
977    const spans = spansOf(lines, args.find, args.line)
978    const span = spans[0]
979    if (!span) return { result: 'The text to find is not in the open document; nothing suggested. Call mcp__md__view or re-read it.' }
980    if (spans.length > 1 && !args.line) {
981      const at = spans.map(s => s[0].line).sort((a, b) => a - b).join(', ')
982      return { result: `That text appears ${spans.length} times (lines ${at}); give line to pick one. Nothing suggested.` }
983    }
984    const s: Suggestion = { id: 's' + newId().slice(1), line: span[0].line, old: args.find, text: args.replace, ...(args.note ? { note: args.note } : {}) }
985    await saveSuggestions($, [...(await read($, suggestionsAtom)), s])
986    await placeCaret($, span[0], false)
987    $.ui.toast(`Claude suggested a change on ${rangeLabel(span[0].line, span[1].line)} — accept or reject it under the text`)
988    return { result: `Suggestion ${s.id} shown on ${rangeLabel(span[0].line, span[1].line)}; the person will accept or reject it.` }
989  })
990
991  // A draft call's text shows in the pane while the model is still writing it
992  on('turn.step', async function* ($, e, next) {
993    const calls = new Map<number, string>()
994    let shownAt = 0
995    for await (const chunk of next(e)) {
996      yield chunk
997      if (chunk.kind === 'tool' && chunk.name === DRAFT_TOOL) {
998        calls.set(chunk.index, '')
999        draftBefore = await draftSoFar($)
1000        await update($, writingAtom, () => true)
1001        await $.ui.open({ id: PANE, title: docName(null) })
1002      } else if (chunk.kind === 'input' && calls.has(chunk.index)) {
1003        const json = (calls.get(chunk.index) ?? '') + chunk.json
1004        calls.set(chunk.index, json)
1005        if (Date.now() - shownAt < 80) continue
1006        shownAt = Date.now()
1007        await previewDraft($, json)
1008      }
1009    }
1010    if (calls.size === 0) return
1011    for (const json of calls.values()) await previewDraft($, json)
1012    await update($, writingAtom, () => false)
1013  })
1014
1015  on('tool.call', { tool: DRAFT_TOOL }, async ($, e) => {
1016    const args = e as unknown as { text: string; append?: boolean }
1017    const before = draftBefore ?? (await draftSoFar($))
1018    draftBefore = null
1019    await update($, writingAtom, () => false)
1020    const lines = draftLines(args.append ? before : null, args.text)
1021    await setDraft($, lines)
1022    if (before) undoStack.push({ lines: before, caret: await caretOf($) })
1023    const first = args.append && before ? Math.min(lines.length, before.length + 2) : 1
1024    await placeCaret($, { line: first, col: 0 }, false)
1025    await update($, viewTopAtom, () => first)
1026    const placed = await $.ui.open({ id: PANE, title: docName(null) })
1027    const where = placed.isPlaced ? 'in the pane' : 'ready, but the pane is not on screen; the person was told to run /md'
1028    if (!placed.isPlaced) $.ui.toast('Claude wrote a draft — run /md to see it')
1029    return { result: `Draft ${args.append ? 'extended' : 'written'} (${lines.length} lines) ${where}. Not saved to a file yet.` }
1030  })
1031
1032  on('tool.call', { tool: 'mcp__md__save' }, async ($, e) => {
1033    const args = e as unknown as { path: string }
1034    try {
1035      const path = await saveAs($, args.path)
1036      await $.ui.open({ id: PANE, title: docName(path) })
1037      return { result: `Saved to ${path}; the pane now edits that file.` }
1038    } catch (err) {
1039      return { result: `Not saved: ${String(err)}` }
1040    }
1041  })
1042
1043  // Claude's edits to the open file show up right away, marked as changed,
1044  // and u undoes them like the person's own
1045  on('tool.call', async ($, e, next) => {
1046    if (!EDIT_TOOLS.includes(String(e.tool))) return next(e)
1047    const before = await read($, docAtom)
1048    const caret = await caretOf($)
1049    const ran = await next(e)
1050    await refresh($)
1051    const after = await read($, docAtom)
1052    if (before && after && before.path === after.path && after.mtimeMs !== before.mtimeMs) {
1053      undoStack.push({ lines: before.lines, caret })
1054    }
1055    return ran
1056  })
1057
1058  // The pane draws its own window over the file, so the wheel moves the
1059  // position the views draw from: a line in review, a paragraph in the reader
1060  on('ui.scroll', { requestId: PANE }, async ($, e) => {
1061    const doc = await read($, docAtom)
1062    if (!doc) return {}
1063    const total = doc.lines.length
1064    if (!(await read($, readerAtom))) {
1065      await update($, viewTopAtom, n => Math.max(1, Math.min(total, n + e.by)))
1066      return {}
1067    }
1068    wheelRows += e.by
1069    const steps = Math.trunc(wheelRows / ROWS_PER_BLOCK)
1070    if (steps === 0) return {}
1071    wheelRows -= steps * ROWS_PER_BLOCK
1072    const blocks = blocksOf(doc.lines)
1073    const at = blockAt(blocks, await read($, readTopAtom))
1074    const target = blocks[Math.max(0, Math.min(blocks.length - 1, at + steps))]
1075    if (target) {
1076      await update($, readTopAtom, () => target.start)
1077      await update($, cursorAtom, () => target.start)
1078    }
1079    return {}
1080  })
1081
1082  // Clicks, drags and keys from the editor, applied one at a time
1083  on('ui.message', { requestId: PANE }, async ($, e) => {
1084    const m = e.data as
1085      | { type: 'down'; x: number; y: number; shift: boolean }
1086      | { type: 'drag'; x: number; y: number }
1087      | { type: 'key'; key: string; shift: boolean; ctrl: boolean; meta: boolean }
1088    const run = async () => {
1089      if (m.type === 'down') {
1090        const p = cellPos(m.x, m.y)
1091        if (!p) return
1092        dragFrom = m.shift ? null : p
1093        await placeCaret($, p, m.shift)
1094      } else if (m.type === 'drag') {
1095        const p = cellPos(m.x, m.y)
1096        if (!p) return
1097        if (dragFrom && (await read($, markAtom)) === null) await update($, markAtom, () => dragFrom)
1098        await update($, cursorAtom, () => p.line)
1099        await update($, colAtom, () => p.col)
1100      } else if (m.type === 'key') {
1101        await editorKey($, m)
1102      }
1103    }
1104    queue = queue.then(run, run)
1105    await queue
1106    return {}
1107  })
1108
1109  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1110    if (e.surface === 'mobile') return next(e)
1111    if (e.surface === 'vscode') return next(e)
1112    const E = $.ui.resolve(e)
1113    const { Box, Text, Button, Input, Client } = E
1114    const doc = await read($, docAtom)
1115    const picker = await read($, pickerAtom)
1116    if (picker) {
1117      const cwd = await $.session.cwd()
1118      const label = (f: string | null) =>
1119        f === null ? 'Untitled draft  · unsaved' : relative(f, cwd) + (picker.files.indexOf(f) < picker.recent ? '  · recent' : '')
1120      const entries = [...(picker.hasDraft ? [null] : []), ...picker.files]
1121      const matches = entries.filter(f => fuzzy(picker.filter, label(f))).slice(0, 15)
1122      return (
1123        <Box flexDirection="column">
1124          <Input
1125            key="pick-filter"
1126            label="Open"
1127            value={picker.filter}
1128            placeholder="type to filter, or a path"
1129            submitLabel="open"
1130            autoFocus
1131            onInput={v => void update($, pickerAtom, p => p && { ...p, filter: v })}
1132            onSubmit={v => void (matches.length ? openPicked($, matches[0] ?? null) : newFile($, v))}
1133          />
1134          <Button
1135            key="pick-new"
1136            label={picker.filter.trim() ? `+ New file ${markdownName(picker.filter)}` : '+ New file'}
1137            plain
1138            onPress={() => void newFile($, picker.filter)}
1139          />
1140          {matches.map((f, i) => (
1141            <Button
1142              key={'pick-' + i}
1143              label={label(f)}
1144              plain
1145              onPress={() => void openPicked($, f)}
1146            />
1147          ))}
1148          {matches.length === 0 && <Text dimColor>No markdown files match. Enter creates {markdownName(picker.filter)}.</Text>}
1149          <Button
1150            key="pick-cancel"
1151            label="cancel"
1152            plain
1153            dimColor
1154            onPress={() => void (async () => {
1155              await update($, pickerAtom, () => null)
1156              if (!(await read($, docAtom))) await $.ui.close({ id: PANE })
1157            })()}
1158          />
1159        </Box>
1160      )
1161    }
1162    if (!doc) return <Text dimColor>No file open. Run /md to pick one.</Text>
1163
1164    const comments = await read($, commentsAtom)
1165    const cursor = await read($, cursorAtom)
1166    const col = await read($, colAtom)
1167    const mark = await read($, markAtom)
1168    const mode = await read($, modeAtom)
1169    const draft = await read($, draftAtom)
1170    const writing = await read($, writingAtom)
1171    const reader = await read($, readerAtom)
1172    const changed = new Set(await read($, changedAtom))
1173    const cwd = await $.session.cwd()
1174    const total = doc.lines.length
1175    const sel = ordered(mark, { line: cursor, col })
1176    const excerpt = sel ? textBetween(doc.lines, sel[0], sel[1]) : null
1177    const short = (t: string, n: number) => (t.length > n ? t.slice(0, n - 1).replace(/\s+/g, ' ') + '…' : t.replace(/\s+/g, ' '))
1178    const open = comments.filter(c => c.status !== 'resolved')
1179    const here = comments.filter(c => c.status !== 'resolved' && c.start <= cursor && cursor <= c.end)
1180    const suggested = (await read($, suggestionsAtom)).flatMap(s => {
1181      const span = locate(doc.lines, s)
1182      return span ? [{ s, span }] : []
1183    })
1184    const suggestedHere = suggested.filter(({ span }) => span[0].line <= cursor && cursor <= span[1].line)
1185    const focus = await read($, focusAtom)
1186    const pixels = await pixelsAvailable($)
1187    const words = countWords(doc.lines.join('\n'))
1188    const bodyRows = e.props.scroll.bodyRows || 20
1189    const page = Math.max(5, bodyRows - 8)
1190
1191    const move = (to: number) => placeCaret($, { line: Math.max(1, Math.min(total, to)), col: 0 }, false)
1192    // The toolbar's prompts type into the pane's Input, never a draft
1193    const setMode = async (m: Mode) => {
1194      await update($, draftAtom, () => null)
1195      await update($, modeAtom, () => m)
1196    }
1197    const nextComment = () => {
1198      const after = open.map(c => c.start).filter(s => s > cursor).sort((a, b) => a - b)
1199      const first = open.map(c => c.start).sort((a, b) => a - b)
1200      return move(after[0] ?? first[0] ?? cursor)
hooks/doc.ts 394 lines
1import type { Comment, Pos } from '../types'
2
3export type { Pos }
4
5export function splitLines(text: string): string[] {
6  const lines = text.split('\n')
7  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
8  return lines
9}
10
11export function quoteOf(lines: string[], start: number, end: number): string {
12  return lines.slice(start - 1, end).join('\n')
13}
14
15// Moves each comment to where its quoted lines went after an edit, nearest the
16// old spot first; a comment whose lines are gone is marked stale.
17export function reanchor(comments: Comment[], lines: string[]): Comment[] {
18  return comments.map(c => {
19    if (c.status === 'resolved') return c
20    if (quoteOf(lines, c.start, c.end) === c.quote) return { ...c, status: c.status === 'stale' ? 'open' : c.status }
21    const span = c.end - c.start
22    let best = -1
23    for (let i = 1; i + span <= lines.length; i++) {
24      if (quoteOf(lines, i, i + span) !== c.quote) continue
25      if (best === -1 || Math.abs(i - c.start) < Math.abs(best - c.start)) best = i
26    }
27    if (best === -1) return { ...c, status: 'stale' }
28    return { ...c, start: best, end: best + span, status: 'open' }
29  })
30}
31
32// Lines of `next` (1-based) that the old text did not have.
33export function changedLines(prev: string[], next: string[]): number[] {
34  const pool = new Map<string, number>()
35  for (const line of prev) pool.set(line, (pool.get(line) ?? 0) + 1)
36  const changed: number[] = []
37  next.forEach((line, i) => {
38    const left = pool.get(line) ?? 0
39    if (left > 0) pool.set(line, left - 1)
40    else if (line.trim() !== '') changed.push(i + 1)
41  })
42  return changed
43}
44
45// Finds the line range a mouse selection covers, by its first and last lines.
46export function rangeOfText(lines: string[], text: string): [number, number] | null {
47  const picked = splitLines(text).map(l => l.trim()).filter(Boolean)
48  const firstText = picked[0]
49  const lastText = picked[picked.length - 1]
50  if (firstText === undefined || lastText === undefined) return null
51  const first = lines.findIndex(l => l.includes(firstText))
52  if (first === -1) return null
53  let last = first
54  for (let i = first; i < lines.length && i < first + picked.length * 3; i++) {
55    if (lines[i]?.includes(lastText)) last = i
56  }
57  return [first + 1, last + 1]
58}
59
60// Every line range where `quote` appears, ignoring differences in whitespace and
61// line breaks, nearest `near` first.
62export function rangesOfQuote(lines: string[], quote: string, near = 1): [number, number][] {
63  const words = quote.trim().split(/\s+/).filter(Boolean)
64  if (words.length === 0) return []
65  const pattern = new RegExp(words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+'), 'g')
66  const text = lines.join('\n')
67  const lineAt = (offset: number) => text.slice(0, offset).split('\n').length
68  const found: [number, number][] = []
69  for (const m of text.matchAll(pattern)) {
70    const at = m.index ?? 0
71    found.push([lineAt(at), lineAt(at + m[0].length)])
72  }
73  return found.sort((a, b) => Math.abs(a[0] - near) - Math.abs(b[0] - near))
74}
75
76// Every place `text` appears exactly, as the caret positions around it,
77// nearest `near` first.
78export function spansOf(lines: string[], text: string, near = 1): [Pos, Pos][] {
79  if (!text) return []
80  const all = lines.join('\n')
81  const posAt = (offset: number): Pos => {
82    const before = all.slice(0, offset).split('\n')
83    return { line: before.length, col: (before[before.length - 1] ?? '').length }
84  }
85  const found: [Pos, Pos][] = []
86  for (let i = all.indexOf(text); i !== -1; i = all.indexOf(text, i + 1)) found.push([posAt(i), posAt(i + text.length)])
87  return found.sort((a, b) => Math.abs(a[0].line - near) - Math.abs(b[0].line - near))
88}
89
90export function fence(lines: string[], start: number, end: number): string {
91  return '```markdown\n' + quoteOf(lines, start, end) + '\n```'
92}
93
94export function rangeLabel(start: number, end: number): string {
95  return start === end ? `line ${start}` : `lines ${start}-${end}`
96}
97
98// How a prompt names the open document: its path, or the unsaved draft
99export function docLabel(path: string | null): string {
100  return path ?? 'the unsaved draft in the md pane (edit it with mcp__md__edit)'
101}
102
103export type SpanKind = 'mark' | 'bold' | 'italic' | 'code' | 'link' | 'bullet'
104
105// How a line of markdown reads styled: [from, to) columns and what they are.
106// Syntax characters are `mark`, drawn faint, so the words come forward while
107// the markdown stays visible and editable
108export function inlineSpans(line: string): [number, number, SpanKind][] {
109  const spans: [number, number, SpanKind][] = []
110  // Columns already claimed, so code isn't read as emphasis and so on
111  const taken = new Array<boolean>(line.length).fill(false)
112  const free = (from: number, to: number) => taken.slice(from, to).every(t => !t)
113  const claim = (from: number, to: number, kind: SpanKind) => {
114    spans.push([from, to, kind])
115    taken.fill(true, from, to)
116  }
117  const lead = /^(#{1,6}\s+|\s*>+\s?|\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?)/.exec(line)?.[0] ?? ''
118  if (lead) claim(0, lead.length, /^\s*(?:[-*+]|\d+[.)])\s/.test(lead) ? 'bullet' : 'mark')
119  const each = (re: RegExp, on: (m: RegExpExecArray) => void) => {
120    for (const m of line.matchAll(re)) if (free(m.index, m.index + m[0].length)) on(m as RegExpExecArray)
121  }
122  each(/(`+)(.+?)\1/g, m => {
123    const tick = m[1]?.length ?? 1
124    claim(m.index, m.index + tick, 'mark')
125    claim(m.index + tick, m.index + m[0].length - tick, 'code')
126    claim(m.index + m[0].length - tick, m.index + m[0].length, 'mark')
127  })
128  each(/!?\[([^\]]+)\]\(([^)]*)\)/g, m => {
129    const open = m[0].startsWith('!') ? 2 : 1
130    const textEnd = m.index + open + (m[1]?.length ?? 0)
131    claim(m.index, m.index + open, 'mark')
132    claim(m.index + open, textEnd, 'link')
133    claim(textEnd, m.index + m[0].length, 'mark')
134  })
135  const emphasis = (re: RegExp, kind: SpanKind) =>
136    each(re, m => {
137      const d = m[1]?.length ?? 1
138      claim(m.index, m.index + d, 'mark')
139      claim(m.index + d, m.index + m[0].length - d, kind)
140      claim(m.index + m[0].length - d, m.index + m[0].length, 'mark')
141    })
142  emphasis(/(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, 'bold')
143  emphasis(/(?<![*_\w])([*_])(?=\S)(.+?)(?<=\S)\1(?![*_\w])/g, 'italic')
144  return spans.sort((a, b) => a[0] - b[0])
145}
146
147// Words in a stretch of prose: letters and digits, with apostrophes and
148// hyphens inside a word
149export function countWords(text: string): number {
150  return text.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu)?.length ?? 0
151}
152
153// Whether every character of `query` appears in `text` in order, ignoring case
154export function fuzzy(query: string, text: string): boolean {
155  const t = text.toLowerCase()
156  let at = 0
157  for (const ch of query.toLowerCase().replace(/\s+/g, '')) {
158    at = t.indexOf(ch, at)
159    if (at === -1) return false
160    at++
161  }
162  return true
163}
164
165// A draft's lines: Claude's text alone, or after `base` with a blank line between
166export function draftLines(base: string[] | null, text: string): string[] {
167  const added = splitLines(text.replace(/\n+$/, ''))
168  return base && base.some(l => l.trim()) ? [...base, '', ...added] : added
169}
170
171// The value of string `field` in JSON that is still arriving, as far as it
172// has come; null until the field starts
173export function partialString(json: string, field: string): string | null {
174  const start = new RegExp(`"${field}"\\s*:\\s*"`).exec(json)
175  if (!start) return null
176  let raw = ''
177  for (let i = start.index + start[0].length; i < json.length; i++) {
178    const ch = json[i]
179    if (ch === '"') break
180    if (ch === '\\') {
181      const len = json[i + 1] === 'u' ? 6 : 2
182      if (i + len > json.length) break
183      raw += json.slice(i, i + len)
184      i += len - 1
185      continue
186    }
187    raw += ch
188  }
189  try {
190    return JSON.parse('"' + raw + '"') as string
191  } catch {
192    return raw
193  }
194}
195
196export function askPrompt(path: string | null, lines: string[], c: Comment): string {
197  return [
198    `In ${docLabel(path)}, ${rangeLabel(c.start, c.end)}:`,
199    '',
200    fence(lines, c.start, c.end),
201    '',
202    ...(c.excerpt ? [`Specifically this text: "${c.excerpt}"`, ''] : []),
203    c.text,
204    '',
205    `(From the md pane, comment ${c.id}. Edit the file as needed, then call mcp__md__reply with id "${c.id}" and a one-line summary.)`,
206  ].join('\n')
207}
208
209export function reviewPrompt(path: string | null, lines: string[], open: Comment[]): string {
210  const parts = open.map(c =>
211    [`### ${c.id}: ${rangeLabel(c.start, c.end)}`, fence(lines, c.start, c.end), c.text].join('\n'),
212  )
213  return [
214    `Address my review comments on ${docLabel(path)}. Edit the file for each one, then call mcp__md__reply for each id with a one-line summary (resolve: true when done).`,
215    '',
216    ...parts,
217  ].join('\n\n')
218}
219
220export type Block = {
221  /** 1-based, inclusive */
222  start: number
223  end: number
224  kind: 'heading' | 'code' | 'text'
225  /** Heading level, 1-6 */
226  level?: number
227  /** The heading's words, or a code fence's language */
228  label?: string
229  text: string
230}
231
232// Splits markdown into the units a reader steps through: headings, fenced
233// code, and runs of text separated by blank lines.
234export function blocksOf(lines: string[]): Block[] {
235  const blocks: Block[] = []
236  let i = 0
237  while (i < lines.length) {
238    const line = lines[i] ?? ''
239    if (line.trim() === '') {
240      i++
241      continue
242    }
243    const heading = /^(#{1,6})\s+(.*)$/.exec(line)
244    if (heading) {
245      blocks.push({ start: i + 1, end: i + 1, kind: 'heading', level: heading[1]?.length, label: heading[2]?.trim(), text: line })
246      i++
247      continue
248    }
249    const fence = /^\s*(```|~~~)\s*(\S*)/.exec(line)
250    if (fence) {
251      let j = i + 1
252      while (j < lines.length && !(lines[j] ?? '').trimStart().startsWith(fence[1] ?? '```')) j++
253      const end = Math.min(j, lines.length - 1)
254      blocks.push({ start: i + 1, end: end + 1, kind: 'code', label: fence[2] || undefined, text: lines.slice(i + 1, j).join('\n') })
255      i = end + 1
256      continue
257    }
258    let j = i
259    while (j + 1 < lines.length && (lines[j + 1] ?? '').trim() !== '' && !/^(#{1,6}\s|\s*(```|~~~))/.test(lines[j + 1] ?? '')) j++
260    blocks.push({ start: i + 1, end: j + 1, kind: 'text', text: lines.slice(i, j + 1).join('\n') })
261    i = j + 1
262  }
263  return blocks
264}
265
266// The block a line falls in, or the last one starting before it.
267export function blockAt(blocks: Block[], line: number): number {
268  let at = 0
269  blocks.forEach((b, i) => {
270    if (b.start <= line) at = i
271  })
272  return at
273}
274
275// Rows a block takes at a width, roughly: wrapped lines plus its gap.
276export function rowsOfBlock(b: Block, width: number): number {
277  const lines = b.kind === 'heading' && b.level === 1 ? 1 : b.kind === 'code' ? 3 : 0
278  if (isTable(b.text)) return tableRows(tableOf(b.text, width)) + 1
279  return lines + b.text.split('\n').reduce((n, l) => n + Math.max(1, Math.ceil(l.length / Math.max(10, width))), 0) + 1
280}
281
282export function readMinutes(lines: string[]): number {
283  const words = lines.join(' ').split(/\s+/).filter(Boolean).length
284  return Math.max(1, Math.round(words / 230))
285}
286
287export type TableCell = { text: string; isCode: boolean; isBold: boolean }
288export type Table = { header: TableCell[]; rows: TableCell[][]; widths: number[] }
289
290export function isTable(text: string): boolean {
291  const rows = text.split('\n')
292  return rows.length > 1 && rows.every(r => r.trimStart().startsWith('|')) && /^\s*\|?\s*:?-{2,}/.test(rows[1] ?? '')
293}
294
295function cellOf(raw: string): TableCell {
296  const t = raw.trim()
297  const isCode = /^`[^`]+`$/.test(t)
298  const isBold = /^\*\*[^*]+\*\*$/.test(t)
299  return { text: t.replace(/`/g, '').replace(/\*\*/g, ''), isCode, isBold }
300}
301
302function cellsOf(row: string): TableCell[] {
303  return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cellOf)
304}
305
306// Column widths that fit `width`: natural widths when they fit, otherwise the
307// widest columns give up room first, none narrower than 6.
308export function tableOf(text: string, width: number): Table {
309  const [head = '', , ...body] = text.split('\n')
310  const header = cellsOf(head)
311  const rows = body.map(cellsOf)
312  const cols = header.length
313  const natural = header.map((h, i) => Math.max(h.text.length, ...rows.map(r => r[i]?.text.length ?? 0)))
314  const room = Math.max(cols * 6, width - (cols - 1) * 3)
315  const widths = [...natural]
316  while (widths.reduce((a, b) => a + b, 0) > room) {
317    const widest = widths.indexOf(Math.max(...widths))
318    if ((widths[widest] ?? 0) <= 6) break
319    widths[widest] = (widths[widest] ?? 0) - 1
320  }
321  return { header, rows, widths }
322}
323
324export function tableRows(t: Table): number {
325  const tall = (cells: TableCell[]) => Math.max(1, ...cells.map((c, i) => Math.ceil(c.text.length / Math.max(1, t.widths[i] ?? 1))))
326  const wraps = t.rows.some(r => r.some((c, i) => c.text.length > (t.widths[i] ?? 0)))
327  return tall(t.header) + 1 + t.rows.reduce((n, r) => n + tall(r), 0) + (wraps ? t.rows.length - 1 : 0)
328}
329
330/** One wrapped row of a line: columns [start, end) of the line's text */
331export type Piece = { start: number; end: number }
332
333// Splits a line into rows of at most `width` columns, breaking after a space
334// where one falls in the row, else mid-word. The pieces cover the whole line
335// in order, so a column on screen maps back to one column of the line.
336export function wrapSegments(text: string, width: number): Piece[] {
337  const w = Math.max(1, width)
338  const pieces: Piece[] = []
339  let start = 0
340  while (text.length - start > w) {
341    const space = text.lastIndexOf(' ', start + w - 1)
342    const end = space >= start ? space + 1 : start + w
343    pieces.push({ start, end })
344    start = end
345  }
346  pieces.push({ start, end: text.length })
347  return pieces
348}
349
350
351export function before(a: Pos, b: Pos): boolean {
352  return a.line < b.line || (a.line === b.line && a.col < b.col)
353}
354
355// The selection between the mark and the caret, start first; null when empty
356export function ordered(mark: Pos | null, caret: Pos): [Pos, Pos] | null {
357  if (!mark || (mark.line === caret.line && mark.col === caret.col)) return null
358  return before(mark, caret) ? [mark, caret] : [caret, mark]
359}
360
361export function textBetween(lines: string[], from: Pos, to: Pos): string {
362  if (from.line === to.line) return (lines[from.line - 1] ?? '').slice(from.col, to.col)
363  const first = (lines[from.line - 1] ?? '').slice(from.col)
364  const middle = lines.slice(from.line, to.line - 1)
365  const last = (lines[to.line - 1] ?? '').slice(0, to.col)
366  return [first, ...middle, last].join('\n')
367}
368
369export function clampPos(lines: string[], p: Pos): Pos {
370  const line = Math.max(1, Math.min(Math.max(1, lines.length), p.line))
371  return { line, col: Math.max(0, Math.min((lines[line - 1] ?? '').length, p.col)) }
372}
373
374// Replaces the text between two positions with `text` (which may hold line
375// breaks) and returns the new lines and the caret after the inserted text
376export function replaceRange(lines: string[], from: Pos, to: Pos, text: string): { lines: string[]; caret: Pos } {
377  const head = (lines[from.line - 1] ?? '').slice(0, from.col)
378  const tail = (lines[to.line - 1] ?? '').slice(to.col)
379  const parts = text.split('\n')
380  const middle = parts.map((p, i) => (i === 0 ? head + p : p))
381  const lastIndex = middle.length - 1
382  const caret = { line: from.line + lastIndex, col: (middle[lastIndex] ?? '').length }
383  middle[lastIndex] = (middle[lastIndex] ?? '') + tail
384  const next = [...lines.slice(0, from.line - 1), ...middle, ...lines.slice(to.line)]
385  return { lines: next.length ? next : [''], caret }
386}
387
388// The caret one step left or right, across line ends
389export function stepCol(lines: string[], p: Pos, dir: 1 | -1): Pos {
390  const len = (lines[p.line - 1] ?? '').length
391  if (dir === -1) return p.col > 0 ? { line: p.line, col: p.col - 1 } : p.line > 1 ? { line: p.line - 1, col: (lines[p.line - 2] ?? '').length } : p
392  return p.col < len ? { line: p.line, col: p.col + 1 } : p.line < lines.length ? { line: p.line + 1, col: 0 } : p
393}
394
hooks/editor.tsx 121 lines
1import type { ClientModule } from 'claude-code'
2
3type Style = { color?: string; bold?: boolean; dimColor?: boolean; italic?: boolean; underline?: boolean }
4
5type SpanKind = 'mark' | 'bold' | 'italic' | 'code' | 'link' | 'bullet'
6
7// Markdown syntax recedes; the words it marks take their meaning's style
8const SPAN_STYLE: Record<SpanKind, Style> = {
9  mark: { dimColor: true },
10  bold: { bold: true },
11  italic: { italic: true },
12  code: { color: '#d7a86e' },
13  link: { color: '#82aaff', underline: true },
14  bullet: { color: 'magenta' },
15}
16
17// One screen row: a wrapped piece of a line, with what to highlight in it
18export type EditorRow = {
19  line: number
20  /** Column of the line where this piece starts */
21  start: number
22  text: string
23  /** The last piece of its line: a click past its end lands at the line's end */
24  isLast: boolean
25  gutter: string
26  gutterColor: string | null
27  style: Style
28  isCaretLine: boolean
29  /** Piece columns [from, to) inside the selection */
30  sel: [number, number] | null
31  /** Piece columns [from, to) a suggested change would replace */
32  struck: [number, number][]
33  /** Piece columns [from, to) of styled markdown: emphasis, code, links, syntax */
34  spans: [number, number, SpanKind][]
35  /** Outside the paragraph in focus */
36  dim: boolean
37  /** Piece column the caret sits on */
38  caret: number | null
39}
40
41export type EditorProps = { rows: EditorRow[]; gutterWidth: number }
42
43// The review view's text. It draws the caret and the selection the hooks module
44// holds, and reports the cell each click and drag lands on and each key.
45const Editor: ClientModule<EditorProps> = (props, surface) => {
46  const { Box, Text } = surface.elements
47  const { rows } = props
48
49  // Sends the cell, not a line and column: the hooks map it with the rows they
50  // drew last, which a listener set on an earlier draw may not have seen
51  surface.onPointer(ev => {
52    if (ev.type === 'down' && ev.button === 'left') surface.post({ type: 'down', x: ev.x, y: ev.y, shift: ev.shift === true })
53    else if (ev.type === 'move' && ev.button === 'left') surface.post({ type: 'drag', x: ev.x, y: ev.y })
54  })
55  surface.onKey(ev => surface.post({ type: 'key', key: ev.key, shift: ev.shift === true, ctrl: ev.ctrl === true, meta: ev.meta === true }))
56
57  return (
58    <Box flexDirection="column">
59      {rows.map((r, i) => {
60        const parts: { text: string; selected: boolean; caret: boolean; struck: boolean; style: Style }[] = []
61        const text = r.text
62        // Split the piece where the selection and the caret begin and end
63        const cuts = new Set([0, text.length])
64        if (r.sel) {
65          cuts.add(r.sel[0])
66          cuts.add(r.sel[1])
67        }
68        // Absent while a reload has the hooks one version behind this module
69        const struck = r.struck ?? []
70        const spans = r.spans ?? []
71        for (const [a, b] of [...struck, ...spans]) {
72          cuts.add(a)
73          cuts.add(b)
74        }
75        if (r.caret !== null) {
76          cuts.add(r.caret)
77          cuts.add(r.caret + 1)
78        }
79        const points = [...cuts].filter(c => c >= 0 && c <= text.length).sort((a, b) => a - b)
80        for (let k = 0; k < points.length - 1; k++) {
81          const from = points[k] ?? 0
82          const to = points[k + 1] ?? 0
83          parts.push({
84            text: text.slice(from, to),
85            selected: r.sel !== null && from >= r.sel[0] && to <= r.sel[1],
86            caret: r.caret === from,
87            struck: struck.some(([a, b]) => from >= a && to <= b),
88            style: Object.assign({}, ...spans.filter(([a, b]) => from >= a && to <= b).map(([, , kind]) => SPAN_STYLE[kind])),
89          })
90        }
91        // A caret at the end of the line sits on a space after it
92        const caretAtEnd = r.caret !== null && r.caret >= text.length
93        return (
94          <Box key={'r' + i} flexDirection="row" backgroundColor={r.isCaretLine ? '#2a2a40' : undefined}>
95            <Text color={r.gutterColor ?? undefined} dimColor={!r.gutterColor && !r.isCaretLine}>
96              {r.gutter}
97            </Text>
98            <Text {...r.style} {...(r.dim ? { dimColor: true } : {})} wrap="truncate-end">
99              {parts.map((p, k) =>
100                p.caret ? (
101                  <Text key={'p' + k} inverse>{p.text}</Text>
102                ) : p.selected ? (
103                  <Text key={'p' + k} backgroundColor="#6d4fd8" color="white">{p.text}</Text>
104                ) : p.struck ? (
105                  <Text key={'p' + k} strikethrough color="magenta">{p.text}</Text>
106                ) : (
107                  <Text key={'p' + k} {...p.style}>{p.text}</Text>
108                ),
109              )}
110              {caretAtEnd ? <Text inverse> </Text> : ''}
111              {text === '' && !caretAtEnd ? ' ' : ''}
112            </Text>
113          </Box>
114        )
115      })}
116    </Box>
117  )
118}
119
120export default Editor
121
hooks/blocks.tsx 68 lines
1import type { ClientElements, Elements } from 'claude-code'
2
3import type { Block } from './doc'
4import { isTable, tableOf } from './doc'
5
6type El = Elements['terminal'] | Elements['desktop'] | ClientElements
7
8// The body of one reader block, drawn with the surface's elements
9export function blockBody(E: El, b: Block, width: number) {
10  const { Box, Text, Markdown, Code } = E
11  if (b.kind === 'heading') {
12    const label = b.label ?? ''
13    if (b.level === 1) {
14      return (
15        <Box flexDirection="column">
16          <Text bold color="magenta" wrap="wrap">{label}</Text>
17          <Text color="magenta" dimColor>{'━'.repeat(Math.min(width, label.length + 2))}</Text>
18        </Box>
19      )
20    }
21    return b.level === 2 ? <Text bold color="magenta" wrap="wrap">{label}</Text> : <Text bold wrap="wrap">{label}</Text>
22  }
23  if (b.kind === 'code') {
24    const label = b.label && !['text', 'plain', 'txt'].includes(b.label) ? b.label : null
25    return (
26      <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
27        {label && <Text dimColor>{label}</Text>}
28        <Code source={b.text || ' '} language={b.label} />
29      </Box>
30    )
31  }
32  if (isTable(b.text)) {
33    const t = tableOf(b.text, width)
34    const full = t.widths.reduce((n, w) => n + w, 0) + (t.widths.length - 1) * 3
35    const wraps = t.rows.some(r => r.some((c, i) => c.text.length > (t.widths[i] ?? 0)))
36    const line = (cells: typeof t.header, isHead: boolean, k: string) => (
37      <Box key={k} flexDirection="row">
38        {t.widths.map((w, i) => {
39          const c = cells[i] ?? { text: '', isCode: false, isBold: false }
40          return (
41            <Box key={k + '-' + i} flexDirection="row" flexShrink={0}>
42              {i > 0 && <Text>{'   '}</Text>}
43              <Box width={w} flexShrink={0}>
44                <Text bold={isHead || c.isBold} color={c.isCode ? 'blue' : undefined} wrap="wrap">
45                  {c.text || ' '}
46                </Text>
47              </Box>
48            </Box>
49          )
50        })}
51      </Box>
52    )
53    return (
54      <Box flexDirection="column">
55        {line(t.header, true, 'h')}
56        <Text dimColor>{'─'.repeat(full)}</Text>
57        {t.rows.map((r, i) => (
58          <Box key={'row' + i} flexDirection="column">
59            {i > 0 && wraps && <Text dimColor>{'┄'.repeat(full)}</Text>}
60            {line(r, false, 'r' + i)}
61          </Box>
62        ))}
63      </Box>
64    )
65  }
66  return <Markdown text={b.text.slice(0, 9900) || ' '} />
67}
68
hooks/preview.ts 95 lines
1// The runtime has these (ES2025); the TypeScript lib in use doesn't declare them yet
2declare global {
3  interface Uint8Array {
4    toBase64(): string
5  }
6  interface Uint8ArrayConstructor {
7    fromBase64(base64: string): Uint8Array
8  }
9}
10
11// How an image is drawn: real pixels where the terminal speaks a graphics
12// protocol, else half-block cells (two pixels a cell) in any truecolor terminal
13export type Preview =
14  | { kind: 'pixels'; file: string; generation: number; columns: number; rows: number }
15  | { kind: 'cells'; cells: string; columns: number; rows: number }
16
17// A line that is only an image: ![alt](src), src optionally in <angle brackets>
18export function imageOfLine(line: string): { alt: string; src: string } | null {
19  const m = /^\s*!\[([^\]]*)\]\(\s*<?([^)>]+?)>?(?:\s+"[^"]*")?\s*\)\s*$/.exec(line)
20  return m ? { alt: m[1] ?? '', src: m[2] ?? '' } : null
21}
22
23
24// Cells for an image at most `columns` wide and `maxRows` tall, keeping its
25// shape: a cell is about twice as tall as it is wide
26export function fitRows(width: number, height: number, columns: number, maxRows: number): { columns: number; rows: number } {
27  const rows = Math.max(1, Math.round((columns * height) / width / 2))
28  if (rows <= maxRows) return { columns, rows }
29  return { columns: Math.max(1, Math.round((maxRows * 2 * width) / height)), rows: maxRows }
30}
31
32// RGBA pixels of a BMP as sips writes it (32-bit with masks), or plain 24/32-bit
33export function decodeBmp(bytes: Uint8Array): { width: number; height: number; rgba: Uint8Array } {
34  const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
35  const offset = v.getUint32(10, true)
36  const width = v.getInt32(18, true)
37  const rawHeight = v.getInt32(22, true)
38  const height = Math.abs(rawHeight)
39  const bpp = v.getUint16(28, true)
40  const bitfields = v.getUint32(30, true) === 3
41  const mask = (at: number, fallback: number) => (bitfields ? v.getUint32(at, true) : fallback)
42  const masks = [mask(54, 0xff0000), mask(58, 0xff00), mask(62, 0xff), bpp === 32 ? mask(66, 0xff000000) : 0]
43  const channel = (px: number, m: number) => {
44    if (!m) return 255
45    let shift = 0
46    while (((m >>> shift) & 1) === 0) shift++
47    return Math.round((((px & m) >>> shift) * 255) / (m >>> shift))
48  }
49  const stride = Math.floor((bpp * width + 31) / 32) * 4
50  const rgba = new Uint8Array(width * height * 4)
51  for (let y = 0; y < height; y++) {
52    const row = offset + (rawHeight < 0 ? y : height - 1 - y) * stride
53    for (let x = 0; x < width; x++) {
54      const at = row + x * (bpp / 8)
55      const px = bpp === 32 ? v.getUint32(at, true) : v.getUint8(at) | (v.getUint8(at + 1) << 8) | (v.getUint8(at + 2) << 16)
56      masks.forEach((m, c) => (rgba[(y * width + x) * 4 + c] = channel(px, m)))
57    }
58  }
59  return { width, height, rgba }
60}
61
62// Half-block cells: each cell's upper pixel as ▀ in the foreground, the lower
63// as the background; mostly transparent pixels show the terminal's own color
64export function halfBlocks(rgba: Uint8Array, width: number, height: number): string {
65  const rows = Math.ceil(height / 2)
66  const words = new Uint32Array(width * rows * 3)
67  const clear = 0x01000000
68  const color = (x: number, y: number) => {
69    if (y >= height) return clear
70    const at = (y * width + x) * 4
71    if ((rgba[at + 3] ?? 255) < 128) return clear
72    return ((rgba[at] ?? 0) << 16) | ((rgba[at + 1] ?? 0) << 8) | (rgba[at + 2] ?? 0)
73  }
74  for (let r = 0; r < rows; r++) {
75    for (let x = 0; x < width; x++) {
76      const i = (r * width + x) * 3
77      const top = color(x, r * 2)
78      const bottom = color(x, r * 2 + 1)
79      // A half block's glyph half takes the text color, so an empty half is
80      // drawn as the cell's background instead: ▀ or ▄ by which half has color
81      const [glyph, fg, bg] = top !== clear ? [0x2580, top, bottom] : bottom !== clear ? [0x2584, bottom, clear] : [0x20, clear, clear]
82      words[i] = glyph
83      words[i + 1] = fg
84      words[i + 2] = bg
85    }
86  }
87  return new Uint8Array(words.buffer).toBase64()
88}
89
90export function hash(text: string): string {
91  let h = 5381
92  for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) >>> 0
93  return h.toString(36)
94}
95
types/index.d.ts 69 lines
1export type Comment = {
2  id: string
3  /** 1-based, inclusive */
4  start: number
5  end: number
6  /** The lines the comment was made on, to re-anchor after edits */
7  quote: string
8  /** The exact text the person selected, when they selected some */
9  excerpt?: string
10  text: string
11  kind: 'note' | 'ask'
12  /** Who wrote it; absent on comments saved before this field existed */
13  author?: 'you' | 'claude'
14  status: 'open' | 'resolved' | 'stale'
15  reply?: string
16}
17
18/** A change Claude proposes; the person accepts or rejects it. Found by its exact old text, nearest `line` */
19export type Suggestion = {
20  id: string
21  line: number
22  old: string
23  text: string
24  note?: string
25}
26
27/** An unsaved draft as the store keeps it between sessions */
28export type StoredDraft = { lines: string[]; comments: Comment[]; suggestions: Suggestion[] }
29
30/** A caret position: 1-based line, 0-based column */
31export type Pos = { line: number; col: number }
32
33/** `path` is null for a draft Claude wrote that no file holds yet; the store keeps it until it is saved */
34export type Doc = { path: string | null; lines: string[]; mtimeMs: number }
35
36export type Mode = 'review' | 'comment' | 'ask'
37
38declare module 'claude-code' {
39  interface PluginState {
40    'md': {
41      doc: Doc | null
42      comments: Comment[]
43      suggestions: Suggestion[]
44      /** The caret's line */
45      cursor: number
46      /** The caret's column in its line */
47      col: number
48      /** The other end of the selection; the caret is one end */
49      mark: Pos | null
50      /** The first line the review view shows */
51      viewTop: number
52      mode: Mode
53      /** The comment or question typed in the editor after ctrl+k / ctrl+j; null when the Input takes it */
54      draft: string | null
55      /** Claude's draft tool call is streaming into the pane */
56      writing: boolean
57      /** The file picker /md shows without a path: absolute paths, recent first, and the filter typed */
58      picker: { files: string[]; recent: number; filter: string; hasDraft: boolean } | null
59      changed: number[]
60      reader: boolean
61      /** The line the reader's window starts at; the cursor marks the selected block */
62      readTop: number
63      toc: boolean
64      /** Focus mode: only the caret's paragraph at full strength, its line kept mid-pane */
65      focus: boolean
66    }
67  }
68}
69