diff --git a/esbuild.mjs b/esbuild.mjs index 916f33a..762ec83 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -45,15 +45,34 @@ const previewOptions = { logLevel: "info", }; +/** @type {import('esbuild').BuildOptions} */ +const previewMermaidOptions = { + entryPoints: ["media/preview-mermaid.ts"], + outfile: "out/media/preview-mermaid.js", + bundle: true, + // Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN + // preview's webview — same browser/IIFE shape as previewOptions above, and + // the same reason mermaid is bundled here (never in the host bundle). + platform: "browser", + format: "iife", + target: "es2020", + sourcemap: true, + logLevel: "info", +}; + 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()]); + const ctxPreviewMermaid = await context(previewMermaidOptions); + await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch(), ctxPreviewMermaid.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"); + await build(previewMermaidOptions); + console.log( + "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js + out/media/preview-mermaid.js", + ); } diff --git a/media/preview-annotations.css b/media/preview-annotations.css new file mode 100644 index 0000000..9123dc5 --- /dev/null +++ b/media/preview-annotations.css @@ -0,0 +1,43 @@ +/* + * Task 7 (D3/D21, PUC-3) — authorship + change annotations for the BUILT-IN VS + * Code Markdown preview (`markdown.previewStyles`). Same F10 vocabulary as the + * (sunsetting) custom webview's `media/preview.css`: style = operation + * (underline = inserted, strikethrough = removed), color = author (human + * green, Claude blue) — `cw-del` has no author variant (a single fixed + * struck-red mark; see `previewAnnotations.ts`/`trackChangesModel.ts`'s "del" + * sentinel tag). Light-theme defaults below; `body.vscode-dark` (also applied + * on high-contrast dark) overrides with the same palette the webview preview + * already ships, for continuity across both review surfaces. + */ +.cw-ins-claude { + background: rgba(9, 105, 218, 0.12); + border-bottom: 2px solid #0969da; + text-decoration: none; +} +.cw-ins-human { + background: rgba(26, 127, 55, 0.12); + border-bottom: 2px solid #1a7f37; + text-decoration: none; +} +.cw-del { + background: rgba(207, 34, 46, 0.1); + text-decoration: line-through; + text-decoration-color: #cf222e; + opacity: 0.75; +} + +body.vscode-dark .cw-ins-claude, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-claude { + background: rgba(88, 166, 255, 0.15); + border-bottom-color: #58a6ff; +} +body.vscode-dark .cw-ins-human, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-human { + background: rgba(63, 185, 80, 0.14); + border-bottom-color: #3fb950; +} +body.vscode-dark .cw-del, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del { + background: rgba(248, 81, 73, 0.11); + text-decoration-color: #f85149; +} diff --git a/media/preview-mermaid.ts b/media/preview-mermaid.ts new file mode 100644 index 0000000..221b176 --- /dev/null +++ b/media/preview-mermaid.ts @@ -0,0 +1,53 @@ +/** + * Task 7 (Q4 mermaid check, D3/D21): the built-in VS Code Markdown preview does + * not render mermaid fences on its own — `previewAnnotations.ts`'s + * `cowritingMarkdownItPlugin` teaches ```mermaid fences to render as + * `
SRC` (via `options.highlight`, the same + * extension point the built-in preview's own fence rule already calls); this + * script (contributed via `markdown.previewScripts`) is what actually turns + * those into diagrams — mermaid needs a DOM, so it runs here, in the preview's + * webview, never in the extension host. Bundled by esbuild as a standalone + * IIFE → out/media/preview-mermaid.js, so mermaid never enters the host + * bundle (matching the sealed webview's `media/preview.ts` precedent). + * + * Contributed preview scripts are reloaded on every content change (VS Code + * docs), so running at module top-level is sufficient; the + * `vscode.markdown.updateContent` listener is added defensively to also cover + * in-place content updates that don't reload the script (mirrors the proven + * `bierner.markdown-mermaid` extension's own approach). + * + * SCOPE NOTE (Q4 finding, recorded per the migration plan's Task 7 Step 2.6): + * this renders the CURRENT diagram only — it does NOT re-emit a changed + * diagram through `mermaidDiff.ts`'s intra-diagram diff/legend augmentation + * (the F7.1 feature the sealed webview preview has). That augmentation needs a + * block-level (not word-hunk-level) diff pass wired into `annotateSource`, + * which was judged out of scope for this increment — it cannot be verified by + * the automated E2E harness (the built-in preview's DOM/CSP sandbox isn't + * queryable from a host test) and shipping it unverified risked a silent + * regression. Basic mermaid rendering (this file) IS wired and is exercised by + * the same proven pattern as the real `bierner.markdown-mermaid` extension. + */ +import mermaid from "mermaid"; + +function theme(): "dark" | "default" { + return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast") + ? "dark" + : "default"; +} + +async function run(): Promise
`
+ * (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the
+ * built-in preview's own fence rule already calls for syntax highlighting — so
+ * the bundled `media/preview-mermaid.js` previewScript has something to run
+ * mermaid over (the built-in preview does not render mermaid on its own).
+ */
+export function cowritingMarkdownItPlugin(
+ md: MarkdownIt,
+ host: { inputsFor(env: unknown): AnnotationInputs | undefined },
+): MarkdownIt {
+ md.core.ruler.before("normalize", "cowriting-annotate", (state) => {
+ const inputs = host.inputsFor(state.env);
+ if (!inputs) return;
+ state.src = annotateSource(state.src, inputs);
+ });
+
+ const baseRender = md.renderer.render.bind(md.renderer);
+ md.renderer.render = (tokens, options, env) => sentinelsToSpans(baseRender(tokens, options, env), "ins");
+
+ const baseHighlight = md.options.highlight;
+ md.options.highlight = (code, lang, attrs) => {
+ if (lang?.trim().toLowerCase() === "mermaid") {
+ return `${escapeMermaidSource(code)}`;
+ }
+ return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
+ };
+
+ return md;
+}
+
+/** Minimal HTML-escape for a mermaid fence body (no markdown parsing — verbatim diagram source). */
+function escapeMermaidSource(source: string): string {
+ return source
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/\n+$/, "");
+}
diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts
index 7c4c3a6..7a6316a 100644
--- a/src/trackChangesModel.ts
+++ b/src/trackChangesModel.ts
@@ -658,15 +658,33 @@ export interface AuthorSpan {
author: AuthorKind;
}
+/**
+ * Task 7 (preview annotations, native-surfaces migration): the sentinel
+ * vocabulary generalizes beyond per-author spans to a plain "del" tag — a
+ * re-surfaced baseline deletion has no author variant (CSS carries one fixed
+ * `.cw-del`, the F10 vocabulary). `injectSentinels`/`sentinelsToSpans` are
+ * exported so `previewAnnotations.ts` can drive the SAME token-aware
+ * discipline (#33/#47) for its 3-way ins-claude/ins-human/del marks;
+ * `colorByAuthor` (F9/F10) keeps its narrower per-author call, unaffected.
+ */
+export type SentinelTag = AuthorKind | "del";
+export interface TaggedSpan {
+ start: number;
+ end: number;
+ tag: SentinelTag;
+}
+
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
-// them through as plain text). Paired open/close per author.
-const SENT = {
+// them through as plain text). Paired open/close per tag.
+const SENT: Record = {
claude: { open: "", close: "" },
human: { open: "", close: "" },
-} as const;
+ del: { open: "", close: "" },
+};
+const SENT_TAGS = Object.keys(SENT) as SentinelTag[];
function isCloseSentinel(m: string): boolean {
- return m === SENT.claude.close || m === SENT.human.close;
+ return SENT_TAGS.some((t) => SENT[t].close === m);
}
// Markdown emphasis / code delimiters whose RUNS must never be split by an
@@ -712,8 +730,8 @@ function blockContentStart(raw: string): number {
return 0;
}
-/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
-function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
+/** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */
+export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string {
const inserts: { at: number; marker: string }[] = [];
// Open sentinels must not precede block-level markdown markers (heading/list/
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
@@ -723,8 +741,8 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
- inserts.push({ at: lo, marker: SENT[s.author].open });
- inserts.push({ at: hi, marker: SENT[s.author].close });
+ inserts.push({ at: lo, marker: SENT[s.tag].open });
+ inserts.push({ at: hi, marker: SENT[s.tag].close });
}
// Apply high offset → low so earlier offsets stay valid. At an equal offset
// (one span's close == the next's open), apply opens BEFORE closes so the
@@ -735,34 +753,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
return out;
}
-const SENTINEL_OF: Record = {
- [SENT.claude.open]: { author: "claude", open: true },
- [SENT.claude.close]: { author: "claude", open: false },
- [SENT.human.open]: { author: "human", open: true },
- [SENT.human.close]: { author: "human", open: false },
-};
-const ALL_SENTINELS = new RegExp(
- `[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
- "g",
-);
+const SENTINEL_OF: Record = {};
+for (const tag of SENT_TAGS) {
+ SENTINEL_OF[SENT[tag].open] = { tag, open: true };
+ SENTINEL_OF[SENT[tag].close] = { tag, open: false };
+}
+const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g");
/**
- * #33: token-aware replacement of the rendered author sentinels with `cw-ins-*`
- * spans. A naive global string-replace (the old approach) could emit a span that
+ * #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A
+ * naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `bold` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
- * emits the author span only around TEXT runs, CLOSING it before any `` and
+ * emits the span only around TEXT runs, CLOSING it before any `` and
* REOPENING it after, so a span is always well-nested within the inline elements
* (one `` segment per text run). Tags are copied verbatim (with any stray
- * sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
+ * sentinel stripped, so no Private-Use-Area char ever leaks). `kind` picks the
+ * author-class prefix (`cw-by-*`/`cw-ins-*`); the tag-less "del" mark (Task 7,
+ * no author variant) always renders the fixed `cw-del` regardless of `kind`.
+ * Pure, deterministic.
*/
-function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
+export function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const out: string[] = [];
- let current: AuthorKind | null = null; // which author region we're inside
+ let current: SentinelTag | null = null; // which region we're inside
let spanOpen = false; // whether a is currently open in `out`
+ const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
const openSpan = () => {
if (current && !spanOpen) {
- out.push(``);
+ out.push(``);
spanOpen = true;
}
};
@@ -776,7 +794,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const ch = html[i];
const sentinel = SENTINEL_OF[ch];
if (sentinel) {
- if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
+ if (sentinel.open) current = sentinel.tag; // span opens lazily before the next text char
else {
closeSpan();
current = null;
@@ -813,7 +831,11 @@ export function colorByAuthor(
kind: "by" | "ins" = "by",
): string {
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
- const injected = injectSentinels(raw, blockStart, overlapping);
+ const injected = injectSentinels(
+ raw,
+ blockStart,
+ overlapping.map((s) => ({ start: s.start, end: s.end, tag: s.author })),
+ );
return sentinelsToSpans(render(injected), kind);
}
diff --git a/test/e2e/suite/previewAnnotations.test.ts b/test/e2e/suite/previewAnnotations.test.ts
new file mode 100644
index 0000000..dbdf403
--- /dev/null
+++ b/test/e2e/suite/previewAnnotations.test.ts
@@ -0,0 +1,79 @@
+import * as assert from "node:assert";
+import MarkdownIt from "markdown-it";
+import * as vscode from "vscode";
+import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
+import { activateApi, settle } from "./helpers";
+
+// Task 7 (D3/D21, PUC-3): the built-in preview's DOM isn't queryable from a
+// host E2E test (§6.8), so this asserts the pure transform's INPUTS/OUTPUTS
+// through the real `CowritingApi` seams instead — the production
+// `previewAnnotationHost.inputsFor` (not a hand-rolled reimplementation) fed
+// through the real `cowritingMarkdownItPlugin` + a fresh markdown-it instance
+// (mirroring the unit suite's own `render()` helper), so this exercises the
+// ACTUAL registry/diffView/attribution/proposals/config wiring end to end.
+suite("PUC-3 annotate-toggle (built-in Markdown preview annotations)", () => {
+ test("a machine-attributed change renders cw-ins-claude; disabling the setting passes the source through unchanged", async () => {
+ const api = await activateApi();
+ const doc = await vscode.workspace.openTextDocument({
+ language: "markdown",
+ content: "hello world\n",
+ });
+ await vscode.window.showTextDocument(doc);
+ await vscode.commands.executeCommand("cowriting.coeditDocument");
+ await settle();
+
+ const start = doc.getText().indexOf("world");
+ const ok = await vscode.commands.executeCommand("cowriting.applyAgentEdit", {
+ uri: doc.uri.toString(),
+ start,
+ end: start + "world".length,
+ newText: "brave new world",
+ model: "sonnet",
+ sessionId: "e2e-preview-annotations",
+ turnId: "turn-e2e-preview-annotations",
+ });
+ assert.strictEqual(ok, true, "seam edit applies");
+ await settle();
+
+ // Annotations ON (default): render the CURRENT buffer through the real
+ // markdown-it plugin + the production host — proves the full pipeline
+ // (core-rule swap → full-render sentinel walk) reaches a live doc.
+ const md = new MarkdownIt();
+ cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
+ const html = md.render(doc.getText());
+ assert.ok(html.includes("cw-ins-claude"), `expected cw-ins-claude in:\n${html}`);
+
+ // Toggle OFF: the config-gated `enabled` flag reaches `annotateSource` and
+ // the transform becomes the identity (no core-rule swap, no marks).
+ const config = vscode.workspace.getConfiguration("cowriting");
+ await config.update("annotations", false, vscode.ConfigurationTarget.Global);
+ try {
+ const inputs = api.previewAnnotationHost.inputsFor(undefined);
+ assert.ok(inputs, "inputsFor still resolves a coedited doc while disabled");
+ assert.strictEqual(inputs!.enabled, false);
+ const md2 = new MarkdownIt();
+ cowritingMarkdownItPlugin(md2, api.previewAnnotationHost);
+ const plainMd = new MarkdownIt();
+ assert.strictEqual(md2.render(doc.getText()), plainMd.render(doc.getText()));
+ } finally {
+ await config.update("annotations", true, vscode.ConfigurationTarget.Global);
+ }
+ });
+
+ test("cowriting.toggleAnnotations flips the setting", async () => {
+ const api = await activateApi();
+ const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "toggle me\n" });
+ await vscode.window.showTextDocument(doc);
+ await vscode.commands.executeCommand("cowriting.coeditDocument");
+ await settle();
+
+ const config = () => vscode.workspace.getConfiguration("cowriting");
+ const before = config().get("annotations", true);
+ await vscode.commands.executeCommand("cowriting.toggleAnnotations");
+ assert.strictEqual(config().get("annotations", true), !before);
+ // restore
+ await vscode.commands.executeCommand("cowriting.toggleAnnotations");
+ assert.strictEqual(config().get("annotations", true), before);
+ void api; // keep the activated extension alive for the duration of the test
+ });
+});
diff --git a/test/previewAnnotations.test.ts b/test/previewAnnotations.test.ts
new file mode 100644
index 0000000..0279fdd
--- /dev/null
+++ b/test/previewAnnotations.test.ts
@@ -0,0 +1,46 @@
+import MarkdownIt from "markdown-it";
+import { describe, expect, it } from "vitest";
+import { cowritingMarkdownItPlugin } from "../src/previewAnnotations";
+
+function render(src: string, inputs: any): string {
+ const md = new MarkdownIt();
+ cowritingMarkdownItPlugin(md, { inputsFor: () => inputs });
+ return md.render(src);
+}
+
+const base = { baselineReason: "entered", proposals: [], enabled: true };
+
+describe("preview annotations (PUC-3/D21)", () => {
+ it("renders clean when disabled", () => {
+ const html = render("hello brave world\n", { ...base, enabled: false, baselineText: "hello world\n", spans: [] });
+ expect(html).not.toContain("cw-");
+ });
+ it("colors machine spans blue and human insertions green vs baseline", () => {
+ const html = render("hello brave new world\n", {
+ ...base,
+ baselineText: "hello world\n",
+ spans: [{ start: 6, end: 12, author: "claude" }, { start: 12, end: 16, author: "human" }],
+ });
+ expect(html).toContain('class="cw-ins-claude"');
+ expect(html).toContain('class="cw-ins-human"');
+ });
+ it("strikes deletions vs baseline", () => {
+ const html = render("hello world\n", { ...base, baselineText: "hello cruel world\n", spans: [] });
+ expect(html).toContain("cw-del");
+ expect(html).toContain("cruel");
+ });
+ it("pinned baseline renders clean even with spans (pin→clean, INV-33/#48)", () => {
+ const html = render("hello brave world\n", {
+ ...base, baselineReason: "pinned", baselineText: "hello brave world\n",
+ spans: [{ start: 6, end: 12, author: "claude" }],
+ });
+ expect(html).not.toContain("cw-ins");
+ });
+ it("survives intra-emphasis boundaries (#33 discipline)", () => {
+ const html = render("a *bold claim* here\n", {
+ ...base, baselineText: "a here\n", spans: [{ start: 2, end: 14, author: "claude" }],
+ });
+ expect(html).toContain("cw-ins-claude");
+ expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks
+ });
+});