/** * markdown-review webview app. * * Renders a markdown file and lets the user comment on selected spans. Three lanes: * - Answer Now → app.sendMessage(one comment) immediately (ephemeral, Lane B) * - Add to batch → held locally (Lane A) * - Submit All → callServerTool('submit_batch') to persist, then sendMessage the bundle * * SPIKE anchoring: offsets are derived by locating the selected text in the raw * markdown (indexOf). Robust re-anchoring across edits is Phase 5 (DESIGN.md §2). */ import { App } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import MarkdownIt from "markdown-it"; interface Anchor { line_start: number; line_end: number; char_start: number; char_end: number; quote: string; } interface Comment { id: string; anchor: Anchor; body: string; store: "memory" | "sidecar" | "inline"; created: string; } const md = new MarkdownIt({ html: false, linkify: true, breaks: false }); const state = { path: "" as string, markdown: "" as string, mode: "edit" as "edit" | "review", batch: [] as Comment[], pending: null as Anchor | null, }; const $ = (id: string) => document.getElementById(id) as T; const docEl = $("doc"); const railEl = $("rail"); const pathEl = $("path"); const popEl = $("pop"); const popQuote = $("popQuote"); const popText = $("popText"); const submitAllBtn = $("submitAll"); const app = new App({ name: "Markdown Review", version: "0.0.1" }); // ── render ──────────────────────────────────────────────────────────────────── /** Hide YAML frontmatter from the rendered view (it's metadata, not body). */ function stripFrontmatter(src: string): string { return src.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n?/, ""); } function renderDoc(): void { // Render without frontmatter; anchoring still uses the full state.markdown, // so comment offsets stay correct against the real file. docEl.innerHTML = md.render(stripFrontmatter(state.markdown)); pathEl.textContent = state.path || "no file"; } function renderRail(): void { if (state.batch.length === 0) { railEl.innerHTML = `
No comments yet.
`; } else { railEl.innerHTML = state.batch .map( (c) => `
${escapeHtml( truncate(c.anchor.quote, 90), )}
${escapeHtml(c.body)}
`, ) .join(""); } submitAllBtn.textContent = `Submit All (${state.batch.length})`; submitAllBtn.disabled = state.batch.length === 0; } // ── selection → anchor ────────────────────────────────────────────────────────── function anchorFromSelection(text: string): Anchor { const idx = state.markdown.indexOf(text); const char_start = idx >= 0 ? idx : 0; const char_end = idx >= 0 ? idx + text.length : text.length; const before = state.markdown.slice(0, char_start); const line_start = before.split("\n").length; const line_end = line_start + text.split("\n").length - 1; return { line_start, line_end, char_start, char_end, quote: text }; } document.addEventListener("mouseup", () => { const sel = window.getSelection(); const text = sel?.toString().trim() ?? ""; if (!text || !sel || sel.rangeCount === 0 || !docEl.contains(sel.anchorNode)) { return; } state.pending = anchorFromSelection(text); const rect = sel.getRangeAt(0).getBoundingClientRect(); popQuote.textContent = truncate(text, 160); popText.value = ""; popEl.style.display = "block"; const top = Math.min(rect.bottom + window.scrollY + 6, window.innerHeight - 180); popEl.style.top = `${top}px`; popEl.style.left = `${Math.min(rect.left + window.scrollX, window.innerWidth - 320)}px`; popText.focus(); }); document.addEventListener("mousedown", (e) => { if (!popEl.contains(e.target as Node)) popEl.style.display = "none"; }); // ── lane B: Answer Now ────────────────────────────────────────────────────────── $("answerNow").addEventListener("click", async () => { const body = popText.value.trim(); if (!state.pending || !body) return; popEl.style.display = "none"; await sendToAgent([{ ...mkComment(state.pending, body, "memory") }], /*single*/ true); state.pending = null; }); // ── lane A: add to batch ──────────────────────────────────────────────────────── $("addBatch").addEventListener("click", () => { const body = popText.value.trim(); if (!state.pending || !body) return; state.batch.push(mkComment(state.pending, body, "sidecar")); state.pending = null; popEl.style.display = "none"; renderRail(); }); // ── lane A flush: Submit All ──────────────────────────────────────────────────── submitAllBtn.addEventListener("click", async () => { if (state.batch.length === 0) return; try { await app.callServerTool({ name: "submit_batch", arguments: { file: state.path, comments: state.batch, mode: state.mode }, }); } catch (e) { console.error("submit_batch persist failed:", e); } await sendToAgent(state.batch, /*single*/ false); state.batch = []; renderRail(); }); // ── mode toggle ───────────────────────────────────────────────────────────────── $("mode").addEventListener("click", (e) => { const t = e.target as HTMLElement; const m = t.dataset.mode as "edit" | "review" | undefined; if (!m) return; state.mode = m; $("mode") .querySelectorAll("button") .forEach((b) => b.classList.toggle("on", (b as HTMLElement).dataset.mode === m)); }); // ── send to the conversation ──────────────────────────────────────────────────── async function sendToAgent(comments: Comment[], single: boolean): Promise { const verb = state.mode === "edit" ? "Apply these review comments by editing the file" : "Add these review comments to a punch-list (do not edit the file yet)"; const header = single ? `Quick question on \`${state.path}\`:` : `${verb} \`${state.path}\` (${comments.length} comment(s)):`; const blocks = comments .map( (c) => `--- L${c.anchor.line_start}-${c.anchor.line_end}\n> ${c.anchor.quote.replace( /\n/g, "\n> ", )}\nComment: ${c.body}`, ) .join("\n\n"); const text = `${header}\n\n${blocks}`; try { const { isError } = await app.sendMessage({ role: "user", content: [{ type: "text", text }], }); if (isError) console.warn("Host rejected sendMessage"); } catch (e) { console.error("sendMessage failed:", e); } } // ── host wiring ───────────────────────────────────────────────────────────────── type IngestShape = { path?: string; mode?: string; markdown?: string; existingComments?: Comment[] }; /** Pull the {path,mode,markdown,…} payload out of whatever shape the host delivers. */ function coerceData(raw: unknown): IngestShape | undefined { if (!raw || typeof raw !== "object") return undefined; const r = raw as Record; // 1) standard MCP: result.structuredContent const sc = r.structuredContent as IngestShape | undefined; if (sc && typeof sc.markdown === "string") return sc; // 2) some hosts hand the structured object straight through if (typeof r.markdown === "string") return r as IngestShape; // 3) last resort: a JSON payload embedded in a text content block const content = r.content as Array<{ type?: string; text?: string }> | undefined; if (Array.isArray(content)) { for (const blk of content) { if (blk?.type === "text" && typeof blk.text === "string") { try { const parsed = JSON.parse(blk.text) as IngestShape; if (typeof parsed.markdown === "string") return parsed; } catch { /* not JSON — ignore */ } } } } return undefined; } // Visible lifecycle log — failures are never a silent black box again. const diagLines: string[] = []; function diag(msg: string): void { console.info("[md-review]", msg); diagLines.push(msg); if (!state.markdown) { docEl.innerHTML = `
Markdown Review — loading…` + `
` + diagLines.map((l) => "• " + escapeHtml(l)).join("\n") + `
`; } } function ingestResult(result: unknown): boolean { const sc = coerceData(result); if (!sc?.markdown) return false; state.path = sc.path ?? state.path ?? ""; state.markdown = sc.markdown; state.mode = (sc.mode as "edit" | "review") ?? state.mode ?? "edit"; state.batch = Array.isArray(sc.existingComments) ? (sc.existingComments as Comment[]) : state.batch; renderDoc(); renderRail(); return true; } let loaded = false; let connected = false; async function fetchContent(): Promise { if (loaded || !state.path) return; diag("fetching content for " + state.path); try { const r = await app.callServerTool({ name: "read_markdown", arguments: { path: state.path, mode: state.mode }, }); if (ingestResult(r)) { loaded = true; diag("loaded ✓"); } else { diag("read_markdown returned no markdown: " + JSON.stringify(r).slice(0, 200)); } } catch (e) { diag("read_markdown call failed: " + String(e)); } } // The path arrives via the tool INPUT; we then fetch the file ourselves. app.ontoolinput = (params: unknown) => { const p = params as { arguments?: Record } | undefined; const args = (p?.arguments ?? (params as Record) ?? {}) as Record; diag("ontoolinput: " + JSON.stringify(args).slice(0, 160)); if (typeof args.path === "string") { state.path = args.path; pathEl.textContent = args.path; } if (args.mode === "edit" || args.mode === "review") state.mode = args.mode; if (connected) void fetchContent(); }; // If the host DOES deliver the result, use it directly (no fetch needed). app.ontoolresult = (result: CallToolResult) => { diag("ontoolresult received"); if (ingestResult(result)) loaded = true; }; app.onerror = (e: unknown) => diag("app error: " + String(e)); diag("widget loaded; connecting…"); app .connect() .then(() => { connected = true; diag("connected"); if (state.path) void fetchContent(); setTimeout(() => { if (!loaded) { diag( state.path ? "no document yet (path known — fetch pending/failed above)" : "no path delivered by host (neither ontoolinput nor ontoolresult fired with one)", ); } }, 2500); }) .catch((e) => diag("connect FAILED: " + String(e))); // ── utils ─────────────────────────────────────────────────────────────────────── let seq = 0; function mkComment(anchor: Anchor, body: string, store: Comment["store"]): Comment { return { id: `c${Date.now().toString(36)}-${seq++}`, anchor, body, store, created: new Date().toISOString() }; } function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n - 1) + "…" : s; } function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] as string), ); }