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 { + const nodes = Array.from(document.querySelectorAll("pre.mermaid")); + if (nodes.length === 0) return; + mermaid.initialize({ startOnLoad: false, theme: theme(), 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("vscode.markdown.updateContent", () => void run()); +void run(); diff --git a/package.json b/package.json index 1b1bbd2..5f927ce 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,13 @@ "onStartupFinished" ], "contributes": { + "markdown.markdownItPlugins": true, + "markdown.previewStyles": [ + "./media/preview-annotations.css" + ], + "markdown.previewScripts": [ + "./out/media/preview-mermaid.js" + ], "configuration": { "title": "Cowriting", "properties": { @@ -25,6 +32,11 @@ "type": "boolean", "default": true, "description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams." + }, + "cowriting.annotations": { + "type": "boolean", + "default": true, + "description": "Show authorship + change annotations in the Markdown preview for coedited documents." } } }, @@ -147,6 +159,12 @@ "title": "✦ Make this edit", "category": "Cowriting", "icon": "$(sparkle)" + }, + { + "command": "cowriting.toggleAnnotations", + "title": "Toggle Annotations", + "category": "Cowriting", + "icon": "$(eye)" } ], "menus": { @@ -187,6 +205,10 @@ "command": "cowriting.askClaude", "when": "editorLangId == markdown && cowriting.isCoediting" }, + { + "command": "cowriting.toggleAnnotations", + "when": "editorLangId == markdown && cowriting.isCoediting" + }, { "command": "cowriting.makeThreadEdit", "when": "false" @@ -219,6 +241,11 @@ "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "navigation@2" }, + { + "command": "cowriting.toggleAnnotations", + "when": "resourceLangId == markdown && cowriting.isCoediting", + "group": "navigation@3" + }, { "command": "cowriting.markReviewed", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", diff --git a/src/extension.ts b/src/extension.ts index 18883bf..c9877d9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,6 +18,8 @@ import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit } from "./workspacePath"; import { CoeditingRegistry } from "./coeditingRegistry"; import { ScmSurfaceController } from "./scmSurface"; +import type MarkdownIt from "markdown-it"; +import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -34,6 +36,13 @@ export interface CowritingApi { editorProposalController: EditorProposalController; coeditingRegistry: CoeditingRegistry; scmSurfaceController: ScmSurfaceController; + /** Task 7 test seam: the REAL production `inputsFor`, so E2E exercises the + * actual registry/diffView/attribution/proposals/config wiring (not a + * hand-rolled reimplementation of it). */ + previewAnnotationHost: { inputsFor(env: unknown): AnnotationInputs | undefined }; + /** Task 7 (D3/D21): the markdown-it plugin factory VS Code's built-in preview + * loads via `markdown.markdownItPlugins` (package.json). */ + extendMarkdownIt: (md: unknown) => unknown; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -391,6 +400,56 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef vscode.workspace.textDocuments.forEach(renderIfOpen); context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); + // --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview --- + // `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED + // preview-engine behavior, not a documented contract, so `lastActiveCoedited` + // is the fallback that keeps single-doc correctness when it's absent — the + // last editor that WAS coediting when it lost focus (e.g. focus moved to the + // preview pane itself) stays the annotation target until a different + // coedited doc becomes active. + let lastCoeditedUri: string | undefined; + const trackLastCoedited = (ed: vscode.TextEditor | undefined) => { + if (ed && coeditingRegistry.isCoediting(ed.document.uri)) lastCoeditedUri = ed.document.uri.toString(); + }; + trackLastCoedited(vscode.window.activeTextEditor); + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(trackLastCoedited), + // The active editor doesn't itself change when `cowriting.coeditDocument` + // enters IT into coediting — re-check on every gate change too, so the + // very first render after entering already has an annotation target. + coeditingRegistry.onDidChange(() => trackLastCoedited(vscode.window.activeTextEditor)), + ); + + const previewAnnotationHost = { + inputsFor(env: unknown): AnnotationInputs | undefined { + const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString(); + const key = envUri && coeditingRegistry.list().includes(envUri) ? envUri : lastCoeditedUri; + if (!key) return undefined; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (!doc) return undefined; + const baseline = diffViewController.getBaseline(key); + return { + baselineText: baseline?.text, + baselineReason: baseline?.reason, + spans: attributionController.spansFor(doc), + proposals: proposalController.listProposals(doc), + enabled: vscode.workspace.getConfiguration("cowriting").get("annotations", true), + }; + }, + }; + + // Toggle: flips `cowriting.annotations` + refreshes the built-in preview + // (VS Code re-invokes extendMarkdownIt's plugin on the next render — no + // extension-side re-render call exists beyond the standard refresh command). + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.toggleAnnotations", async () => { + const config = vscode.workspace.getConfiguration("cowriting"); + const current = config.get("annotations", true); + await config.update("annotations", !current, vscode.ConfigurationTarget.Global); + await vscode.commands.executeCommand("markdown.preview.refresh"); + }), + ); + return { threadController, attributionController, @@ -404,6 +463,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef editorProposalController, coeditingRegistry, scmSurfaceController, + previewAnnotationHost, + extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost), }; } diff --git a/src/previewAnnotations.ts b/src/previewAnnotations.ts new file mode 100644 index 0000000..c9c6b6b --- /dev/null +++ b/src/previewAnnotations.ts @@ -0,0 +1,197 @@ +/** + * previewAnnotations — Task 7 (D3/D21, PUC-3): a pure, vscode-free markdown-it + * plugin that annotates the BUILT-IN VS Code Markdown preview with the F10 + * authorship/change-mark vocabulary (`.cw-ins-claude` / `.cw-ins-human` / + * `.cw-del`) — the native-surfaces migration's replacement for the sunset + * webview's review body (spec §6.4/§6.10). Reuses the shipped sentinel + * discipline (`injectSentinels`/`sentinelsToSpans`, #33/#47-hardened) instead + * of inventing a new one: `annotateSource` computes PUA-sentinel-wrapped + * markdown SOURCE (never HTML) against the F6/F7 baseline + F3 spans + F4 + * pending proposals; `cowritingMarkdownItPlugin` wires that transform into the + * shared `markdown-it` instance (the SAME instance VS Code's built-in preview + * uses via `markdown.markdownItPlugins`) as (a) a core rule — swap `state.src` + * before `normalize` — and (b) a full-render wrapper that turns surviving + * sentinel pairs into ``. + * + * Implementation note (resolved drift from the migration plan's Task 7 sketch, + * which described (b) as "a text renderer rule"): `sentinelsToSpans` is a + * stateful, single-pass walk over one HTML string — installing it as a + * PER-TOKEN `renderer.rules.text` hook would reset that state at every text + * token boundary and silently break the "survives emphasis/tag boundaries" + * invariant (#33 CASE3) for any span whose sentinel-open and sentinel-close + * land in DIFFERENT text tokens (e.g. either side of a `**bold**` run — the + * exact scenario CASE3 exists to cover). Wrapping `md.renderer.render` instead + * runs the walk ONCE over the fully-assembled HTML, exactly like the F9/F10 + * `colorByAuthor` precedent (`render(injected)` — a whole-block HTML string, + * not a token fragment) — this is what actually delivers the stated invariant, + * robust to callers that invoke `md.render()` directly or `md.parse()` + + * `md.renderer.render()` separately (VS Code's own scroll-sync `data-line` + * injection uses the latter). + * + * `host.inputsFor(env)` is the ONE thin, vscode-touching seam (owned by + * `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine + * behavior, not a documented contract, so the host falls back to the last + * actively-coedited document when it is absent (see extension.ts's + * `lastActiveCoedited`). + */ +import type MarkdownIt from "markdown-it"; +import { + injectSentinels, + landedTextOf, + sentinelsToSpans, + splitBlocksWithRanges, + wordEditHunks, + type AuthorSpan, + type ProposalView, + type TaggedSpan, +} from "./trackChangesModel"; + +export interface AnnotationInputs { + baselineText: string | undefined; + baselineReason: "entered" | "pinned" | "head" | undefined; + spans: AuthorSpan[]; + proposals: ProposalView[]; + enabled: boolean; +} + +/** + * Inject the sentinel vocabulary into `text` block-by-block (INV-23: code/ + * mermaid fences stay atomic — never sentinel-injected, never word-refined) so + * the delimiter-run + block-marker safety `injectSentinels` carries applies + * PER BLOCK, not just at the very top of the whole document (its clamps are + * anchored to a block's own start, matching every other caller — `colorByAuthor` + * is always invoked per block too). + */ +function injectAllSentinels(text: string, marks: TaggedSpan[]): string { + if (marks.length === 0) return text; + const blocks = splitBlocksWithRanges(text); + if (blocks.length === 0) return text; + let out = ""; + let cursor = 0; + for (const b of blocks) { + out += text.slice(cursor, b.start); // preserve inter-block gaps (blank lines) verbatim + if (b.type === "prose") { + const blockMarks = marks.filter((m) => m.end > b.start && m.start < b.end); + out += blockMarks.length ? injectSentinels(b.raw, b.start, blockMarks) : b.raw; + } else { + out += b.raw; // INV-23: fences are never annotated + } + cursor = b.end; + } + out += text.slice(cursor); // trailing content after the last block + return out; +} + +/** + * Pure: markdown source -> sentinel-annotated source (authorship + change + + * proposal marks vs the F6/F7 baseline). Skips everything when annotations are + * disabled, or (the #48 rule) once the baseline was just PINNED with zero + * LANDED diff — pending F4 proposals are unaffected by that gate (matching the + * shipped preview's "pin→clean, proposals still show", #48/INV-33). + */ +export function annotateSource(src: string, inputs: AnnotationInputs): string { + if (!inputs.enabled) return src; + const proposals = inputs.proposals ?? []; + const baselineText = inputs.baselineText ?? src; // no baseline → no change-marks + const landedText = landedTextOf(src, proposals); + const pinnedZeroDiff = inputs.baselineReason === "pinned" && landedText === baselineText; + + const insSpans: TaggedSpan[] = []; + const deletions: { at: number; text: string }[] = []; + const appliedRanges: { start: number; end: number }[] = []; + + // (c) pending F4 proposals already sit applied in `src` (F12 optimistic + // apply, INV-48) — mark the applied range ins-claude (proposals are agent- + // authored, matching editorProposalController's own convention) and + // resurface the pre-apply original as a deletion at the same point. + for (const p of proposals) { + if (p.anchorStart === null || p.anchorEnd === null) continue; + appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd }); + if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: "claude" }); + const original = p.original ?? p.replaced; + if (original) deletions.push({ at: p.anchorStart, text: original }); + } + + // (a)/(b) committed changes-since-baseline. Each hunk (wordEditHunks(src, + // baseline) — start/end in SRC coords, `replacement` = the baseline-side text + // for that span) yields (a) insertion marks from the F3 `spans` CLIPPED to the + // hunk's changed range — a hunk may straddle more than one author (e.g. two + // adjacent words typed by different authors with nothing committed between + // them), so spans are clipped individually, never the whole hunk tagged with + // one author — and (b) the baseline's dropped text reinserted at the hunk + // start. A hunk owned by an applied pending proposal is already marked above. + if (!pinnedZeroDiff && landedText !== baselineText) { + for (const h of wordEditHunks(src, baselineText)) { + if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue; + for (const s of inputs.spans) { + const start = Math.max(s.start, h.start); + const end = Math.min(s.end, h.end); + if (end > start) insSpans.push({ start, end, tag: s.author }); + } + if (h.replacement) deletions.push({ at: h.start, text: h.replacement }); + } + } + + if (insSpans.length === 0 && deletions.length === 0) return src; + + // Splice deletion reinsertions into the source high→low (so earlier offsets + // stay valid), shifting every mark recorded so far and appending a "del" mark + // over the freshly-inserted text. + const marks: TaggedSpan[] = insSpans.map((s) => ({ ...s })); + let text = src; + for (const d of [...deletions].sort((a, b) => b.at - a.at)) { + text = text.slice(0, d.at) + d.text + text.slice(d.at); + for (const m of marks) { + if (m.start >= d.at) m.start += d.text.length; + if (m.end >= d.at) m.end += d.text.length; + } + marks.push({ start: d.at, end: d.at + d.text.length, tag: "del" }); + } + + return injectAllSentinels(text, marks); +} + +/** + * markdown-it plugin factory: the host supplies `AnnotationInputs` per render + * (`host.inputsFor(env)`; `undefined` = no coediting context for this render — + * source passed through untouched). Installs (a) a core rule BEFORE `normalize` + * that swaps `state.src` for `annotateSource(...)`, and (b) a full-render + * wrapper (see module docstring) that turns surviving sentinel pairs into + * `cw-…` spans. Also teaches ```mermaid fences to render as `
`
+ * (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 + }); +});