# F7 Rendered Track-Changes Preview Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a read-only webview that renders a markdown document beside its source editor and marks what changed since the F6 baseline (prose ``/``, atomic code/mermaid "changed" badge), updating live as the human and Claude edit. **Architecture:** A pure, vscode-free render engine (`trackChangesModel.ts`) turns `(baselineText, currentText)` into annotated HTML via a block-level diff (LCS over normalized blocks) + word-level prose refinement, with code/mermaid fences kept atomic. A vscode-layer controller (`trackChangesPreview.ts`) owns one sealed webview panel per markdown document, reads the F6 baseline from the reused `DiffViewController`, and posts HTML on open / debounced edit / baseline-epoch change. The webview asset (`media/preview.ts` → `out/media/preview.js`, a separate esbuild bundle) swaps in the HTML and runs `mermaid.run()` over `.mermaid` blocks. The document, sidecar, and baseline are never mutated (INV-20). **Tech Stack:** TypeScript, VS Code extension API (webview), `markdown-it` + `diff` (host bundle), `mermaid` (webview bundle only), esbuild (two entries), vitest (unit), `@vscode/test-electron` + mocha (host E2E). No LLM anywhere; no network/CDN (INV-21). **Spec:** `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md` (F7, #21). Invariants INV-20..23. --- ## File Structure - **Create** `src/trackChangesModel.ts` — pure render engine (INV-22/23). Exports `splitBlocks`, `diffBlocks`, `renderTrackChanges`, types `Block`/`BlockOp`. No vscode, no DOM. - **Create** `test/trackChangesModel.test.ts` — vitest unit suite for the engine. - **Create** `media/preview.ts` — webview client: receives `{type:"render", html, epoch, summary}`, swaps `innerHTML`, runs `mermaid.run()`, per-block error chips. Bundles `mermaid`. - **Create** `media/preview.css` — theme-aware marks (`ins`, `del`, `.cw-added/removed/changed`, `.cw-badge`, `.cw-error`). - **Create** `src/trackChangesPreview.ts` — `TrackChangesPreviewController` (vscode layer): one panel per markdown doc, sealed CSP shell, debounced refresh, test seam. - **Create** `test/e2e/suite/trackChangesPreview.test.ts` — host E2E (auto-globbed by `runTest.ts`). - **Create** `test/e2e/fixtures/workspace/docs/preview.md` — markdown fixture (prose + mermaid + code + a `TARGET` sentence). - **Create** `test/e2e/fixtures/workspace/docs/notes.txt` — non-markdown fixture (markdown-guard test). - **Create** `docs/MANUAL-SMOKE-F7.md` — manual smoke script (§6.8). - **Modify** `src/diffViewController.ts` — additive `onDidChangeBaseline` event fired on every `capture()`. - **Modify** `src/extension.ts` — construct `TrackChangesPreviewController` (workspace-independent, like F6), add to `CowritingApi`. - **Modify** `esbuild.mjs` — second entry building `media/preview.ts` → `out/media/preview.js` (IIFE, browser, bundles mermaid). - **Modify** `package.json` — `markdown-it` + `diff` + `mermaid` deps, `@types/markdown-it` + `@types/diff` devDeps, command + keybinding contributions. - **Modify** `README.md` — F7 section. --- ## Task 1: Add dependencies and types **Files:** - Modify: `package.json` - [ ] **Step 1: Add runtime + dev dependencies** Edit `package.json`. In `"dependencies"` add `markdown-it`, `diff`, `mermaid`; in `"devDependencies"` add `@types/markdown-it`, `@types/diff`. Result: ```json "dependencies": { "@cline/sdk": "0.0.46", "diff": "^7.0.0", "markdown-it": "^14.1.0", "mermaid": "^11.0.0" }, "devDependencies": { "@types/diff": "^7.0.0", "@types/markdown-it": "^14.1.0", "@types/mocha": "^10.0.7", "@types/node": "^22.0.0", "@types/vscode": "^1.90.0", "@vscode/test-electron": "^2.4.0", "ajv": "^8.20.0", "esbuild": "^0.23.0", "glob": "^11.0.0", "mocha": "^10.7.0", "typescript": "^5.5.0", "vitest": "^2.0.0" } ``` - [ ] **Step 2: Install** Run: `npm install` Expected: lockfile updates, `node_modules/markdown-it`, `node_modules/diff`, `node_modules/mermaid` present, exit 0. - [ ] **Step 3: Commit** ```bash git add package.json package-lock.json git commit -m "feat(f7): add markdown-it + diff + mermaid deps (#21)" ``` --- ## Task 2: Block splitting (`splitBlocks`) **Files:** - Create: `src/trackChangesModel.ts` - Test: `test/trackChangesModel.test.ts` - [ ] **Step 1: Write the failing test** Create `test/trackChangesModel.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { splitBlocks } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { const blocks = splitBlocks("# Heading\n\nFirst para.\n\nSecond para.\n"); expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "prose"]); expect(blocks[0].raw).toBe("# Heading"); expect(blocks[2].raw).toBe("Second para."); }); it("keeps a fenced code block whole and tags it `code`", () => { const text = "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro.\n"; const blocks = splitBlocks(text); expect(blocks.map((b) => b.type)).toEqual(["prose", "code", "prose"]); expect(blocks[1].raw).toBe("```ts\nconst a = 1;\n\nconst b = 2;\n```"); }); it("tags a mermaid fence `mermaid`", () => { const blocks = splitBlocks("```mermaid\nflowchart LR\n a-->b\n```\n"); expect(blocks).toHaveLength(1); expect(blocks[0].type).toBe("mermaid"); }); it("normalizes the key (whitespace-collapsed) for matching", () => { const blocks = splitBlocks("Hello world\n"); expect(blocks[0].key).toBe("hello world"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: FAIL — `splitBlocks` not exported / module missing. - [ ] **Step 3: Write minimal implementation** Create `src/trackChangesModel.ts`: ```ts /** * trackChangesModel — F7 pure, vscode-free render engine (spec §6.2, INV-22/23). * * `renderTrackChanges(baselineText, currentText)` returns the annotated HTML body * for the preview webview: a block-level diff (LCS over normalized blocks) with * word-level ``/`` refinement inside changed PROSE blocks, and CODE / * MERMAID fences kept ATOMIC (INV-23 — never word-refined, never partially * rendered). Deterministic: same inputs → identical HTML (INV-22). No vscode, no * DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview. */ import MarkdownIt from "markdown-it"; import { diffArrays, diffWords } from "diff"; export type BlockType = "prose" | "code" | "mermaid"; export interface Block { /** Verbatim source of the block (no surrounding blank lines). */ raw: string; /** Normalized match key: lowercased, whitespace-collapsed. */ key: string; type: BlockType; } function normalize(raw: string): string { return raw.trim().replace(/\s+/g, " ").toLowerCase(); } function makeBlock(raw: string, type: BlockType): Block { return { raw, key: normalize(raw), type }; } /** Split markdown into top-level blocks; fenced code/mermaid stay whole. */ export function splitBlocks(text: string): Block[] { const lines = text.split(/\r?\n/); const blocks: Block[] = []; let buf: string[] = []; const flushProse = () => { const raw = buf.join("\n"); if (raw.trim()) blocks.push(makeBlock(raw, "prose")); buf = []; }; let i = 0; while (i < lines.length) { const line = lines[i]; const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (fence) { flushProse(); const marker = fence[2][0]; // ` or ~ const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); const fenceLines = [line]; i++; while (i < lines.length) { fenceLines.push(lines[i]); const closed = lines[i].trim().startsWith(marker.repeat(3)); i++; if (closed) break; } blocks.push(makeBlock(fenceLines.join("\n"), info === "mermaid" ? "mermaid" : "code")); continue; } if (line.trim() === "") { flushProse(); i++; continue; } buf.push(line); i++; } flushProse(); return blocks; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/trackChangesModel.ts test/trackChangesModel.test.ts git commit -m "feat(f7): block splitter for the render engine (#21)" ``` --- ## Task 3: Block diff (`diffBlocks`) **Files:** - Modify: `src/trackChangesModel.ts` - Test: `test/trackChangesModel.test.ts` - [ ] **Step 1: Write the failing test** Append to `test/trackChangesModel.test.ts`: ```ts import { diffBlocks } from "../src/trackChangesModel"; describe("diffBlocks", () => { const kinds = (base: string, cur: string) => diffBlocks(base, cur).map((o) => o.kind); it("unchanged doc → all unchanged", () => { const doc = "Alpha.\n\nBravo.\n"; expect(kinds(doc, doc)).toEqual(["unchanged", "unchanged"]); }); it("a pure addition → an added op", () => { const ops = diffBlocks("Alpha.\n", "Alpha.\n\nBravo.\n"); expect(ops.map((o) => o.kind)).toEqual(["unchanged", "added"]); }); it("a pure deletion → a removed op", () => { const ops = diffBlocks("Alpha.\n\nBravo.\n", "Alpha.\n"); expect(ops.map((o) => o.kind)).toEqual(["unchanged", "removed"]); }); it("a prose modification → a non-atomic changed op", () => { const ops = diffBlocks("The quick fox.\n", "The slow fox.\n"); expect(ops).toHaveLength(1); expect(ops[0].kind).toBe("changed"); expect(ops[0].kind === "changed" && ops[0].atomic).toBe(false); }); it("a code-fence change → an ATOMIC changed op (INV-23)", () => { const base = "```ts\nconst a = 1;\n```\n"; const cur = "```ts\nconst a = 2;\n```\n"; const ops = diffBlocks(base, cur); expect(ops).toHaveLength(1); expect(ops[0].kind).toBe("changed"); expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); }); it("a mermaid-fence change → an ATOMIC changed op", () => { const base = "```mermaid\nflowchart LR\n a-->b\n```\n"; const cur = "```mermaid\nflowchart LR\n a-->c\n```\n"; const ops = diffBlocks(base, cur); expect(ops[0].kind).toBe("changed"); expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); }); it("a reorder → removed + added (not changed)", () => { const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n"); // Two. matches (unchanged), One. moves: one removed + one added. const k = ops.map((o) => o.kind).sort(); expect(k).toContain("unchanged"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: FAIL — `diffBlocks` not exported. - [ ] **Step 3: Write minimal implementation** Append to `src/trackChangesModel.ts`: ```ts export type BlockOp = | { kind: "unchanged"; block: Block } | { kind: "added"; block: Block } | { kind: "removed"; block: Block } | { kind: "changed"; block: Block; before: Block; atomic: boolean }; /** * Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`); * adjacent removed-then-added runs are paired element-wise into `changed` ops, the * surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when * either side is a code/mermaid fence — rendered whole, never word-refined. */ export function diffBlocks(baselineText: string, currentText: string): BlockOp[] { const before = splitBlocks(baselineText); const after = splitBlocks(currentText); const changes = diffArrays( before.map((b) => b.key), after.map((b) => b.key), ); const ops: BlockOp[] = []; let bi = 0; // index into `before` let ci = 0; // index into `after` for (let n = 0; n < changes.length; n++) { const ch = changes[n]; const count = ch.count ?? ch.value.length; if (!ch.added && !ch.removed) { for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] }); bi += count; continue; } if (ch.removed) { const next = changes[n + 1]; const addCount = next?.added ? (next.count ?? next.value.length) : 0; const paired = Math.min(count, addCount); for (let k = 0; k < paired; k++) { const beforeBlk = before[bi++]; const afterBlk = after[ci++]; const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose"; ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic }); } for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] }); for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] }); if (next?.added) n++; // consumed the paired added run continue; } // a lone added run (no preceding removed) for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] }); } return ops; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: PASS (all diffBlocks tests + Task 2 tests). - [ ] **Step 5: Commit** ```bash git add src/trackChangesModel.ts test/trackChangesModel.test.ts git commit -m "feat(f7): block-level diff with atomic fences (#21)" ``` --- ## Task 4: Render to annotated HTML (`renderTrackChanges`) **Files:** - Modify: `src/trackChangesModel.ts` - Test: `test/trackChangesModel.test.ts` - [ ] **Step 1: Write the failing test** Append to `test/trackChangesModel.test.ts`: ```ts import { renderTrackChanges } from "../src/trackChangesModel"; describe("renderTrackChanges", () => { it("wraps each block in a cw-blk div with its kind class", () => { const html = renderTrackChanges("Alpha.\n", "Alpha.\n\nBravo.\n"); expect(html).toContain('class="cw-blk cw-unchanged"'); expect(html).toContain('class="cw-blk cw-added"'); }); it("emits inline / for a prose modification", () => { const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n"); expect(html).toContain(""); expect(html).toContain(""); expect(html).toContain("cw-changed"); }); it("renders a changed CODE fence atomically: cw-changed, no inline ins/del", () => { const html = renderTrackChanges("```ts\nconst a = 1;\n```\n", "```ts\nconst a = 2;\n```\n"); expect(html).toContain("cw-changed"); expect(html).not.toContain(""); expect(html).not.toContain(""); expect(html).toContain("cw-badge"); }); it("emits
 for a mermaid fence + a changed badge", () => {
    const html = renderTrackChanges(
      "```mermaid\nflowchart LR\n a-->b\n```\n",
      "```mermaid\nflowchart LR\n a-->c\n```\n",
    );
    expect(html).toContain('
');
    expect(html).toContain("a-->c"); // current source, escaped
    expect(html).toContain("cw-badge");
  });

  it("unchanged doc renders with no marks", () => {
    const html = renderTrackChanges("Alpha.\n", "Alpha.\n");
    expect(html).not.toContain("cw-added");
    expect(html).not.toContain("cw-removed");
    expect(html).not.toContain("cw-changed");
  });

  it("is deterministic (same inputs → identical HTML) (INV-22)", () => {
    const a = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
    const b = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
    expect(a).toBe(b);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run test/trackChangesModel.test.ts`
Expected: FAIL — `renderTrackChanges` not exported.

- [ ] **Step 3: Write minimal implementation**

Append to `src/trackChangesModel.ts`:

```ts
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// mermaid fences → 
SRC
for client-side rendering; all // other fences fall through to markdown-it's default (escaped
).
const defaultFence = md.renderer.rules.fence!.bind(md.renderer.rules);
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
  const info = tokens[idx].info.trim().split(/\s+/)[0].toLowerCase();
  if (info === "mermaid") {
    return `
${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}
\n`; } return defaultFence(tokens, idx, options, env, self); }; /** Build a markdown string with inline / from a word-level prose diff. */ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string { return diffWords(beforeRaw, afterRaw) .map((part) => { if (part.added) return `${part.value}`; if (part.removed) return `${part.value}`; return part.value; }) .join(""); } function safeRender(src: string): string { try { return md.render(src); } catch (err) { const message = err instanceof Error ? err.message : String(err); return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; } } function renderOp(op: BlockOp): string { let cls: string; let inner: string; let badge = ""; switch (op.kind) { case "unchanged": cls = "cw-unchanged"; inner = safeRender(op.block.raw); break; case "added": cls = "cw-added"; inner = safeRender(op.block.raw); if (op.block.type !== "prose") badge = 'added'; break; case "removed": cls = "cw-removed"; inner = safeRender(op.block.raw); break; case "changed": cls = "cw-changed"; if (op.atomic) { inner = safeRender(op.block.raw); // the NEW block, whole (INV-23) badge = 'changed'; } else { inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw)); } break; } return `
${badge}${inner}
`; } export interface RenderOptions { /** Counts of changed/added/removed blocks, for the header summary. */ // (reserved; callers derive counts from diffBlocks) } /** Pure entry point: annotated HTML body for the preview (INV-22). */ export function renderTrackChanges(baselineText: string, currentText: string): string { return diffBlocks(baselineText, currentText).map(renderOp).join("\n"); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: PASS (all render tests). - [ ] **Step 5: Commit** ```bash git add src/trackChangesModel.ts test/trackChangesModel.test.ts git commit -m "feat(f7): render annotated track-changes HTML (#21)" ``` --- ## Task 5: Error-chip fallback (PUC-6) made testable **Files:** - Modify: `src/trackChangesModel.ts` - Test: `test/trackChangesModel.test.ts` **Why:** `safeRender` already catches render errors, but `markdown-it` almost never throws on real input, so the catch is untested. Add a tiny, honest seam: an exported `renderBlockHtml(op, render)` that takes the per-block markdown renderer as a parameter (defaulting to the module's `safeRender`). The unit test injects a renderer that throws for one block to prove the chip appears AND the rest still renders. - [ ] **Step 1: Write the failing test** Append to `test/trackChangesModel.test.ts`: ```ts import { renderTrackChanges as renderWith } from "../src/trackChangesModel"; describe("error chip (PUC-6)", () => { it("a block whose render throws becomes an error chip; the rest still renders", () => { const throwing = (src: string) => { if (src.includes("BOOM")) throw new Error("kaboom"); return `

${src}

`; }; const html = renderWith("Good one.\n\nBOOM here.\n", "Good one.\n\nBOOM here.\n", { render: throwing }); expect(html).toContain("cw-error"); expect(html).toContain("kaboom"); expect(html).toContain("

Good one.

"); // the healthy block still rendered }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: FAIL — `renderTrackChanges` takes only 2 args / `opts.render` ignored. - [ ] **Step 3: Write minimal implementation** In `src/trackChangesModel.ts`, replace the `RenderOptions` block and the `safeRender`/`renderOp`/`renderTrackChanges` definitions so the per-block renderer is injectable: ```ts export interface RenderOptions { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ render?: (src: string) => string; } function defaultRender(src: string): string { return md.render(src); } function chip(message: string): string { return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; } function renderOp(op: BlockOp, render: (src: string) => string): string { const safe = (src: string): string => { try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } }; let cls: string; let inner: string; let badge = ""; switch (op.kind) { case "unchanged": cls = "cw-unchanged"; inner = safe(op.block.raw); break; case "added": cls = "cw-added"; inner = safe(op.block.raw); if (op.block.type !== "prose") badge = 'added'; break; case "removed": cls = "cw-removed"; inner = safe(op.block.raw); break; case "changed": cls = "cw-changed"; if (op.atomic) { inner = safe(op.block.raw); badge = 'changed'; } else { inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw)); } break; } return `
${badge}${inner}
`; } export function renderTrackChanges( baselineText: string, currentText: string, opts: RenderOptions = {}, ): string { const render = opts.render ?? defaultRender; return diffBlocks(baselineText, currentText).map((op) => renderOp(op, render)).join("\n"); } ``` Delete the now-unused standalone `safeRender` function. - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: PASS (error-chip test + all prior tests). - [ ] **Step 5: Typecheck + full unit suite** Run: `npm run typecheck && npm test` Expected: PASS, no type errors. - [ ] **Step 6: Commit** ```bash git add src/trackChangesModel.ts test/trackChangesModel.test.ts git commit -m "feat(f7): per-block error-chip fallback (PUC-6, #21)" ``` --- ## Task 6: Webview asset (CSS + client JS) and second esbuild entry **Files:** - Create: `media/preview.css` - Create: `media/preview.ts` - Modify: `esbuild.mjs` - [ ] **Step 1: Create the stylesheet** Create `media/preview.css`: ```css /* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */ body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 0 1.2rem 2rem; line-height: 1.5; } #cw-header { position: sticky; top: 0; background: var(--vscode-editor-background); border-bottom: 1px solid var(--vscode-panel-border); padding: 0.5rem 0; font-size: 0.85em; opacity: 0.85; display: flex; gap: 1rem; } #cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); } #cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); } .cw-blk { position: relative; } ins, .cw-added { background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15)); text-decoration: none; } del, .cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15)); text-decoration: line-through; opacity: 0.7; } .cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; } .cw-badge { position: absolute; top: 0; right: 0; font-size: 0.7em; padding: 0 0.4em; border-radius: 3px; background: var(--vscode-badge-background); color: var(--vscode-badge-foreground); } .cw-error { border: 1px solid var(--vscode-inputValidation-errorBorder); background: var(--vscode-inputValidation-errorBackground); color: var(--vscode-errorForeground); padding: 0.3rem 0.6rem; border-radius: 3px; font-size: 0.85em; } pre.mermaid { text-align: center; background: transparent; } pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); } ``` - [ ] **Step 2: Create the client script** Create `media/preview.ts`: ```ts /** * F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML * from the extension host and swaps it in; runs mermaid over `.mermaid` blocks * (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as * a standalone IIFE → out/media/preview.js, so mermaid never enters the host * bundle. No network, no LLM. */ import mermaid from "mermaid"; interface RenderMessage { type: "render"; html: string; epoch: string; summary: { added: number; removed: number; changed: number }; } const body = document.getElementById("cw-body")!; const header = document.getElementById("cw-epoch")!; const summary = document.getElementById("cw-summary")!; function themeFor(): "dark" | "default" { return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast") ? "dark" : "default"; } async function renderMermaid(): Promise { const nodes = Array.from(body.querySelectorAll("pre.mermaid")); if (nodes.length === 0) return; mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" }); try { await mermaid.run({ nodes }); } catch { // mermaid.run already marks failed nodes; ensure a visible chip per failure. for (const n of nodes) { if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true"); } } } window.addEventListener("message", (event: MessageEvent) => { const msg = event.data; if (msg?.type !== "render") return; body.innerHTML = msg.html; header.textContent = `Track changes since ${msg.epoch}`; summary.innerHTML = `+${msg.summary.added + msg.summary.changed} ` + `−${msg.summary.removed + msg.summary.changed}`; void renderMermaid(); }); ``` - [ ] **Step 3: Add the second esbuild entry** In `esbuild.mjs`, add a third options object after `liveTurnOptions`, and build it. Insert: ```js /** @type {import('esbuild').BuildOptions} */ const previewOptions = { entryPoints: ["media/preview.ts"], outfile: "out/media/preview.js", bundle: true, // The webview is a browser context; mermaid is bundled IN (and ONLY in) this // asset so it never bloats the extension-host bundle (the @cline/sdk size // discipline). No externals — everything is shipped to the sealed webview. platform: "browser", format: "iife", target: "es2020", sourcemap: true, logLevel: "info", }; ``` Then update the watch and build blocks to include it: ```js if (watch) { const ctx = await context(options); const ctxLive = await context(liveTurnOptions); const ctxPreview = await context(previewOptions); await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]); console.log("esbuild: watching…"); } else { await build(options); await build(liveTurnOptions); await build(previewOptions); console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js"); } ``` - [ ] **Step 4: Build to verify the webview bundle compiles** Run: `npm run build` Expected: build complete; `out/media/preview.js` exists (mermaid bundled, a few MB). - [ ] **Step 5: Commit** ```bash git add media/preview.ts media/preview.css esbuild.mjs git commit -m "feat(f7): sealed webview asset + esbuild preview bundle (#21)" ``` --- ## Task 7: Additive `onDidChangeBaseline` event on the F6 controller **Files:** - Modify: `src/diffViewController.ts` - [ ] **Step 1: Add the emitter and public event** In `src/diffViewController.ts`, after the existing `onDidChangeEmitter` field (line ~28), add: ```ts /** F7 (additive): fires on every baseline capture (open / advance / pin) so * the track-changes preview refreshes without polling. Mirrors the internal * content-provider change signal but carries the real document URI. */ private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>(); readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event; ``` - [ ] **Step 2: Fire it on capture, and dispose it** In the constructor's `this.disposables.push(...)` list, add `this.onDidChangeBaselineEmitter,` alongside `this.onDidChangeEmitter,`. In `capture(...)`, after the existing `this.onDidChangeEmitter.fire(this.baselineUri(document));` line, add: ```ts this.onDidChangeBaselineEmitter.fire({ uri: key }); ``` - [ ] **Step 3: Typecheck** Run: `npm run typecheck` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add src/diffViewController.ts git commit -m "feat(f7): additive onDidChangeBaseline event on DiffViewController (#21)" ``` --- ## Task 8: `TrackChangesPreviewController` (vscode layer) **Files:** - Create: `src/trackChangesPreview.ts` - [ ] **Step 1: Write the controller** Create `src/trackChangesPreview.ts`: ```ts /** * TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one * sealed webview panel per markdown document, beside the source editor. On open / * debounced edit / F6 baseline-epoch change it reads the baseline (from the * reused DiffViewController) + the live buffer, runs the pure render engine, and * posts the HTML. Pure read-only: never mutates the document, sidecar, or * baseline (INV-20). The webview is sealed: local assets only, strict CSP, * per-load nonce, no network (INV-21). */ import * as path from "node:path"; import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; import { renderTrackChanges, diffBlocks, type BlockOp } from "./trackChangesModel"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; export class TrackChangesPreviewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly panels = new Map(); private readonly lastModel = new Map(); private readonly debounces = new Map(); constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, ) { this.disposables.push( vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => this.show(vscode.window.activeTextEditor?.document), ), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), ); } private isMarkdown(document: vscode.TextDocument): boolean { return document.languageId === "markdown"; } /** Open or reveal the preview for a markdown document (PUC-1). */ show(document: vscode.TextDocument | undefined): void { if (!document || !this.isMarkdown(document)) { void vscode.window.showWarningMessage( "Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).", ); return; } const key = document.uri.toString(); const existing = this.panels.get(key); if (existing) { existing.reveal(vscode.ViewColumn.Beside); this.refresh(document); return; } const name = path.basename(document.uri.path) || "untitled"; const panel = vscode.window.createWebviewPanel( VIEW_TYPE, `Track changes: ${name}`, { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, retainContextWhenHidden: false, localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")], }, ); panel.webview.html = this.shellHtml(panel.webview); panel.onDidDispose(() => { this.panels.delete(key); this.lastModel.delete(key); }, null, this.disposables); this.panels.set(key, panel); this.refresh(document); } private onEdit(document: vscode.TextDocument): void { const key = document.uri.toString(); if (!this.panels.has(key)) return; const pending = this.debounces.get(key); if (pending) clearTimeout(pending); this.debounces.set( key, setTimeout(() => { this.debounces.delete(key); this.refresh(document); }, DEBOUNCE_MS), ); } private refreshByUri(uri: string): void { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (doc) this.refresh(doc); } /** Recompute the model + post HTML to the panel (no-op if no panel). */ refresh(document: vscode.TextDocument): void { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; const baseline = this.diffView.getBaseline(key); const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks const current = document.getText(); const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); const summary = { added: ops.filter((o) => o.kind === "added").length, removed: ops.filter((o) => o.kind === "removed").length, changed: ops.filter((o) => o.kind === "changed").length, }; const epoch = this.epochLabel(baseline); void panel.webview.postMessage({ type: "render", html: renderTrackChanges(baselineText, current), epoch, summary, }); } private epochLabel( baseline: { reason: string; capturedAt: string } | undefined, ): string { if (!baseline) return "opened (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); switch (baseline.reason) { case "machine-landing": return `Claude landed ${time}`; case "pinned": return `pinned ${time}`; default: return `opened ${time}`; } } private shellHtml(webview: vscode.Webview): string { const nonce = randomBytes(16).toString("base64"); const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"), ); const styleUri = webview.asWebviewUri( vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"), ); // Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for // mermaid's dynamically injected