50 KiB
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 <ins>/<del>, 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). ExportssplitBlocks,diffBlocks,renderTrackChanges, typesBlock/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}, swapsinnerHTML, runsmermaid.run(), per-block error chips. Bundlesmermaid. - 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 byrunTest.ts). - Create
test/e2e/fixtures/workspace/docs/preview.md— markdown fixture (prose + mermaid + code + aTARGETsentence). - 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— additiveonDidChangeBaselineevent fired on everycapture(). - Modify
src/extension.ts— constructTrackChangesPreviewController(workspace-independent, like F6), add toCowritingApi. - Modify
esbuild.mjs— second entry buildingmedia/preview.ts→out/media/preview.js(IIFE, browser, bundles mermaid). - Modify
package.json—markdown-it+diff+mermaiddeps,@types/markdown-it+@types/diffdevDeps, 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:
"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
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:
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:
/**
* 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 `<ins>`/`<del>` 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
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:
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:
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
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:
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 <ins>/<del> for a prose modification", () => {
const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n");
expect(html).toContain("<ins>");
expect(html).toContain("<del>");
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("<ins>");
expect(html).not.toContain("<del>");
expect(html).toContain("cw-badge");
});
it("emits <pre class=\"mermaid\"> 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('<pre class="mermaid">');
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:
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>).
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 `<pre class="mermaid">${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}</pre>\n`;
}
return defaultFence(tokens, idx, options, env, self);
};
/** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */
function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
return diffWords(beforeRaw, afterRaw)
.map((part) => {
if (part.added) return `<ins>${part.value}</ins>`;
if (part.removed) return `<del>${part.value}</del>`;
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 `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
}
}
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 = '<span class="cw-badge">added</span>';
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 = '<span class="cw-badge">changed</span>';
} else {
inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw));
}
break;
}
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
}
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
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:
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 `<p>${src}</p>`;
};
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("<p>Good one.</p>"); // 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:
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 `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
}
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 = '<span class="cw-badge">added</span>';
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 = '<span class="cw-badge">changed</span>';
} else {
inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw));
}
break;
}
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
}
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
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:
/* 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:
/**
* 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<void> {
const nodes = Array.from(body.querySelectorAll<HTMLElement>("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<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
header.textContent = `Track changes since ${msg.epoch}`;
summary.innerHTML =
`<span class="cw-add">+${msg.summary.added + msg.summary.changed}</span> ` +
`<span class="cw-del">−${msg.summary.removed + msg.summary.changed}</span>`;
void renderMermaid();
});
- Step 3: Add the second esbuild entry
In esbuild.mjs, add a third options object after liveTurnOptions, and build it. Insert:
/** @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:
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
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:
/** 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:
this.onDidChangeBaselineEmitter.fire({ uri: key });
- Step 3: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 4: Commit
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:
/**
* 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<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
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 <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<body>
<div id="cw-header">
<span id="cw-epoch">Track changes</span>
<span id="cw-summary"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}
- Step 2: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 3: Commit
git add src/trackChangesPreview.ts
git commit -m "feat(f7): TrackChangesPreviewController (sealed webview, test seam) (#21)"
Task 9: Wire into the extension + package.json contributions
Files:
-
Modify:
src/extension.ts -
Modify:
package.json -
Step 1: Add the command + keybinding to package.json
In package.json → contributes.commands, add (after cowriting.pinDiffBaseline):
{
"command": "cowriting.showTrackChangesPreview",
"title": "Cowriting: Open Track-Changes Preview",
"category": "Cowriting"
}
In contributes.keybindings, add (after the toggleDiffView binding):
{
"command": "cowriting.showTrackChangesPreview",
"key": "ctrl+alt+r",
"when": "editorLangId == markdown"
}
- Step 2: Construct and export the controller
In src/extension.ts:
(a) Add the import after the DiffViewController import:
import { TrackChangesPreviewController } from "./trackChangesPreview";
(b) Extend the CowritingApi interface:
export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
versionGuard: VersionGuard;
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
}
(c) Right after context.subscriptions.push(diffViewController); (the F6 construction, ~line 57), construct F7 (workspace-independent, like F6 — its command must exist with or without a folder):
// --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
// Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it
// is constructed regardless of an open folder and its command is always live.
// It reuses the F6 baseline (INV-20) and adds no persistence.
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
);
context.subscriptions.push(trackChangesPreviewController);
(d) In the with-root return { ... } statement at the end of activate, add the controller:
return {
threadController,
attributionController,
proposalController,
versionGuard,
diffViewController,
trackChangesPreviewController,
};
(The no-root early return undefined; stays as-is — the command is already registered by the controller constructed above, so it exists without a folder.)
- Step 3: Typecheck + build
Run: npm run typecheck && npm run build
Expected: PASS; out/extension.cjs and out/media/preview.js rebuilt.
- Step 4: Commit
git add src/extension.ts package.json
git commit -m "feat(f7): register showTrackChangesPreview command + keybinding, wire controller (#21)"
Task 10: Host E2E fixtures + suite
Files:
-
Create:
test/e2e/fixtures/workspace/docs/preview.md -
Create:
test/e2e/fixtures/workspace/docs/notes.txt -
Create:
test/e2e/suite/trackChangesPreview.test.ts -
Step 1: Create the markdown fixture
Create test/e2e/fixtures/workspace/docs/preview.md:
# Preview fixture
A target sentence Claude will rewrite via the seam.
Some stable prose that does not change during the test run.
```mermaid
flowchart LR
a --> b
const stable = true;
(Note: the fixture file literally contains the nested ``` fences shown above.)
- [ ] **Step 2: Create the non-markdown fixture**
Create `test/e2e/fixtures/workspace/docs/notes.txt`:
Plain text, not markdown — F7 must refuse this (F6 covers it instead).
- [ ] **Step 3: Write the E2E suite**
Create `test/e2e/suite/trackChangesPreview.test.ts`:
```ts
import * as assert from "assert";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/preview.md";
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "extension exports trackChangesPreviewController");
return api;
}
const settle = () => new Promise((r) => setTimeout(r, 400));
const kinds = (api: CowritingApi) =>
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
// Order-dependent (F2–F6 pattern): later tests consume earlier state.
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
const doc = await openDoc();
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
const model = api.trackChangesPreviewController.getLastModel(docUri());
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(
model!.every((o) => o.kind === "unchanged"),
"baseline == buffer at open → every block unchanged",
);
// sanity: the active tab is a webview of our view type
void doc;
});
test("typing produces a changed/added block (PUC-2)", async () => {
const doc = await openDoc();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
// append a brand-new paragraph at the end
const end = doc.positionAt(doc.getText().length);
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.ok(
kinds(api).some((k) => k === "added" || k === "changed"),
"an added/changed block appears after typing",
);
});
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
const doc = await openDoc();
const api = await getApi();
const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target sentence");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + TARGET.length,
newText: REPLACEMENT,
model: "sonnet",
sessionId: "e2e-f7",
turnId: "turn-f7-1",
});
assert.ok(id, "propose returns an id");
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const replacementMarked = model.some(
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
);
assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
});
test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
const doc = await openDoc();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await settle();
assert.ok(
kinds(api).every((k) => k === "unchanged"),
"after pin, every block is unchanged (baseline == buffer)",
);
void doc;
});
test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
const txt = await openDoc("docs/notes.txt");
const api = await getApi();
const key = txt.uri.toString();
assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
});
});
- Step 4: Build E2E + run
Run: npm run pretest:e2e && npm run test:e2e
Expected: the new suite passes alongside the existing F2–F6 suites; both workspace and no-workspace passes complete (the no-workspace pass exercises the F2 stubs only — F7's command exists but isn't invoked there). Exit 0.
If the E2E electron download/run is not available in this environment, build at minimum (npm run pretest:e2e) to confirm the suite compiles, and note the run in the transcript for the manual smoke / CI.
- Step 5: Commit
git add test/e2e/suite/trackChangesPreview.test.ts test/e2e/fixtures/workspace/docs/preview.md test/e2e/fixtures/workspace/docs/notes.txt
git commit -m "test(f7): host E2E for the track-changes preview (#21)"
Task 11: Manual smoke doc + README section
Files:
-
Create:
docs/MANUAL-SMOKE-F7.md -
Modify:
README.md -
Step 1: Write the manual smoke script
Create docs/MANUAL-SMOKE-F7.md following the §6.8 "Live smoke" script. Content:
# Manual smoke — F7 rendered track-changes preview (#21)
The webview's *visual* rendering (mermaid, theming) is verified here, not in the
automated host E2E (the webview is a sealed sandbox — §6.8). Run once per change
that touches F7.
## Setup
1. `npm run build`
2. Launch the Extension Development Host (F5 in VS Code, or the Run panel).
3. Open a folder and a markdown document containing prose, a `mermaid` fenced
diagram, and a `ts` code fence (e.g. copy `test/e2e/fixtures/workspace/docs/preview.md`).
## Steps
1. **Open the preview.** Run **"Cowriting: Open Track-Changes Preview"** (or
`Ctrl+Alt+R`). A preview opens beside the editor, rendering the document.
Header reads `Track changes since opened …`, summary `+0 −0`. The mermaid
diagram renders as a diagram; the code fence renders as highlighted code.
2. **Edit prose.** In the source editor, change a word in a paragraph. The
preview updates (≈150 ms): the old word struck (`<del>`), the new word
highlighted (`<ins>`); summary increments.
3. **Edit the mermaid.** Change `a --> b` to `a --> c`. The preview re-renders the
**new** diagram with a **"changed"** badge at its top-right.
4. **Ask Claude + accept.** Select the target sentence → "Ask Claude to Edit
Selection" → accept the proposal. The accepted text **drops its marks** (the
baseline advanced; PUC-3).
5. **Pin.** Run "Cowriting: Pin Diff Baseline to Now". All marks clear (baseline
== now; PUC-4).
6. **Theme.** Toggle light/dark (`Ctrl+K Ctrl+T`). Marks and the diagram restyle
to the theme.
7. **Cleanliness.** `git status` shows nothing changed by the preview (INV-20).
## Pass criteria
All seven steps behave as described; no console errors in the webview devtools;
nothing written to the document, sidecar, or repo.
- Step 2: Add a README F7 section
In README.md, add a section describing F7 (mirroring how F6 is documented):
## F7 — Rendered track-changes preview (#21)
`Ctrl+Alt+R` (or "Cowriting: Open Track-Changes Preview") opens a read-only
webview beside a **Markdown** editor that renders the document and marks what
changed since the F6 baseline: prose additions highlighted / deletions struck,
and code/mermaid fences shown whole with a "changed" badge (intra-diagram mermaid
diffing is deferred — #22). It updates live as you and Claude edit, and re-bases
when Claude lands an edit or you pin (reusing the F6 baseline — it adds no
persistence). Mermaid renders fully. F7 is **markdown-only**; for any other file,
use F6's diff toggle (`Ctrl+Alt+D`). Manual smoke: `docs/MANUAL-SMOKE-F7.md`.
- Step 3: Commit
git add docs/MANUAL-SMOKE-F7.md README.md
git commit -m "docs(f7): manual smoke script + README section (#21)"
Task 12: Full verification
Files: (none — verification only)
- Step 1: Typecheck
Run: npm run typecheck
Expected: PASS, no errors.
- Step 2: Unit suite
Run: npm test
Expected: all vitest suites pass, including trackChangesModel.test.ts.
- Step 3: Build
Run: npm run build
Expected: out/extension.cjs, out/liveTurn.mjs, out/media/preview.js all produced.
- Step 4: Host E2E
Run: npm run test:e2e
Expected: all suites (F2–F7) green. (If electron is unavailable locally, record that the suite compiles and defer the run to CI / manual smoke — note it in the transcript.)
- Step 5: Confirm the repo is clean of preview side effects
Run: git status
Expected: only intended source/doc changes; nothing written under .threads/ or the workspace by the preview (INV-20).
Self-Review (completed during planning)
Spec coverage:
- Rendered preview beside editable source (PUC-1) → Task 8 (
show,ViewColumn.Beside), Task 9 (command). - Marks since F6 baseline (PUC-2/3), epoch follows machine / pin → Task 7 (
onDidChangeBaseline), Task 8 (refresh), Task 10 (E2E PUC-3/4). - Full mermaid support (PUC-5) → Task 4 (
<pre class="mermaid">), Task 6 (mermaid.run()). - Mermaid change = whole-diagram badge (INV-23) → Task 3 (atomic), Task 4 (badge).
- Prose inline ins/del (PUC-2) → Task 4 (
wordMergedMarkdown). - Live update on edit + epoch (PUC-2/3/4) → Task 8 (debounce + event sub).
- Pure read-only, no sidecar/contract impact (INV-20) → Task 8 (reads only), Task 12 step 5.
- Sealed webview, no network/LLM, local mermaid (INV-21) → Task 6 (browser bundle), Task 8 (CSP/nonce/localResourceRoots).
- Deterministic testable model (INV-22) → Tasks 2–5 (vscode-free, determinism test).
- Atomic non-prose blocks (INV-23) → Task 3 (atomic flag), Task 4 (no word diff for atomic).
- Graceful edges (PUC-6): non-markdown warn → Task 8/10; error chip → Task 5; no baseline → Task 8 (
baselineText ?? getText()). - Unit + host E2E, no LLM in CI (§6.8) → Tasks 2–5 (unit), Task 10 (E2E).
Placeholder scan: none — every code step shows complete code.
Type consistency: Block/BlockOp defined in Task 2/3 and consumed unchanged in Tasks 4/5/8; renderTrackChanges(baselineText, currentText, opts?), diffBlocks, getBaseline(uriString) (existing F6), onDidChangeBaseline({uri}), isOpen/getLastModel consistent across tasks.