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…

<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 │
╰────────────────────────────────────────────────────────────────────╯
/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.
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.
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.
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.
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.
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.
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.
| Mark | Meaning |
|---|---|
● | You commented on this line |
◆ | Only Claude commented on this line |
✎ | Claude suggested a change here |
+ | Changed since you opened the file |
▸ | Your caret |
In the text, type to edit. Click to place the caret and drag to select.
| Key | Does |
|---|---|
| ctrl+j | Ask Claude about the selection |
| ctrl+k | Comment on the selection |
| ctrl+u | Undo (ctrl+z suspends Claude Code) |
| cmd+v | Paste text. A dragged-in image file becomes an image link. |
| ctrl+v | Paste the image on the clipboard: saved to assets/ and linked (macOS) |
| Tab | Move to the pane's buttons |
With the pane's buttons focused:
| Key | Does |
|---|---|
| j / k | Next / previous line |
| f / b | Page down / up |
| v | Start or clear a selection |
| o | Copy the selection, or the whole document, to the clipboard |
| i | Paste the image on the clipboard |
| z | Focus mode: fade all but this paragraph, keep your line centered |
| n | Next comment |
| c / a | Comment / ask |
| x | Send all open comments to Claude |
| s | Resolve the comment on this line |
| y / d | Accept / reject the suggestion on this line |
| u | Undo |
| r | Switch to the reader view (and back) |
| q | Close the pane |
| Command | Does |
|---|---|
/md | Pick 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 works the pane through the same moves you have.
| Tool | Does |
|---|---|
draft | Write or extend the draft, streamed into the pane |
suggest | Propose a change for you to accept or reject |
edit | Change exact text, marked in the gutter and undoable |
comment | Leave a note on quoted text |
reply | Answer a note, and resolve it when done |
open | Show a file, a line range, or select exact text |
view | See your caret, selection, comments, suggestions and changes |
save | Write the draft to a file |
close | Close the pane |
MIT
hooks/register.tsx 1623 lines1import { 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 ` ? `<${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 lines1import 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}
394hooks/editor.tsx 121 lines1import 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
121hooks/blocks.tsx 68 lines1import 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}
68hooks/preview.ts 95 lines1// 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: , 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}
95types/index.d.ts 69 lines1export 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