Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) #72

Merged
benstull merged 18 commits from session-0065 into main 2026-07-02 23:09:38 +00:00
9 changed files with 576 additions and 29 deletions
Showing only changes of commit 17fc01e8d8 - Show all commits
+21 -2
View File
@@ -45,15 +45,34 @@ const previewOptions = {
logLevel: "info", 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) { if (watch) {
const ctx = await context(options); const ctx = await context(options);
const ctxLive = await context(liveTurnOptions); const ctxLive = await context(liveTurnOptions);
const ctxPreview = await context(previewOptions); 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…"); console.log("esbuild: watching…");
} else { } else {
await build(options); await build(options);
await build(liveTurnOptions); await build(liveTurnOptions);
await build(previewOptions); 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",
);
} }
+43
View File
@@ -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;
}
+53
View File
@@ -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
* `<pre class="mermaid">SRC</pre>` (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<void> {
const nodes = Array.from(document.querySelectorAll<HTMLElement>("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();
+27
View File
@@ -18,6 +18,13 @@
"onStartupFinished" "onStartupFinished"
], ],
"contributes": { "contributes": {
"markdown.markdownItPlugins": true,
"markdown.previewStyles": [
"./media/preview-annotations.css"
],
"markdown.previewScripts": [
"./out/media/preview-mermaid.js"
],
"configuration": { "configuration": {
"title": "Cowriting", "title": "Cowriting",
"properties": { "properties": {
@@ -25,6 +32,11 @@
"type": "boolean", "type": "boolean",
"default": true, "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." "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", "title": "✦ Make this edit",
"category": "Cowriting", "category": "Cowriting",
"icon": "$(sparkle)" "icon": "$(sparkle)"
},
{
"command": "cowriting.toggleAnnotations",
"title": "Toggle Annotations",
"category": "Cowriting",
"icon": "$(eye)"
} }
], ],
"menus": { "menus": {
@@ -187,6 +205,10 @@
"command": "cowriting.askClaude", "command": "cowriting.askClaude",
"when": "editorLangId == markdown && cowriting.isCoediting" "when": "editorLangId == markdown && cowriting.isCoediting"
}, },
{
"command": "cowriting.toggleAnnotations",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{ {
"command": "cowriting.makeThreadEdit", "command": "cowriting.makeThreadEdit",
"when": "false" "when": "false"
@@ -219,6 +241,11 @@
"when": "resourceLangId == markdown && cowriting.isCoediting", "when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@2" "group": "navigation@2"
}, },
{
"command": "cowriting.toggleAnnotations",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@3"
},
{ {
"command": "cowriting.markReviewed", "command": "cowriting.markReviewed",
"when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot",
+61
View File
@@ -18,6 +18,8 @@ import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit } from "./workspacePath"; import { isAuthorable, routeEdit } from "./workspacePath";
import { CoeditingRegistry } from "./coeditingRegistry"; import { CoeditingRegistry } from "./coeditingRegistry";
import { ScmSurfaceController } from "./scmSurface"; import { ScmSurfaceController } from "./scmSurface";
import type MarkdownIt from "markdown-it";
import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -34,6 +36,13 @@ export interface CowritingApi {
editorProposalController: EditorProposalController; editorProposalController: EditorProposalController;
coeditingRegistry: CoeditingRegistry; coeditingRegistry: CoeditingRegistry;
scmSurfaceController: ScmSurfaceController; 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 { 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); vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(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<boolean>("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<boolean>("annotations", true);
await config.update("annotations", !current, vscode.ConfigurationTarget.Global);
await vscode.commands.executeCommand("markdown.preview.refresh");
}),
);
return { return {
threadController, threadController,
attributionController, attributionController,
@@ -404,6 +463,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
editorProposalController, editorProposalController,
coeditingRegistry, coeditingRegistry,
scmSurfaceController, scmSurfaceController,
previewAnnotationHost,
extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost),
}; };
} }
+197
View File
@@ -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 `<span class="cw-…">`.
*
* 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 `<pre class="mermaid">`
* (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 `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(code)}</pre>`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n+$/, "");
}
+49 -27
View File
@@ -658,15 +658,33 @@ export interface AuthorSpan {
author: AuthorKind; 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 // Private-Use-Area sentinels (never appear in real content; markdown-it passes
// them through as plain text). Paired open/close per author. // them through as plain text). Paired open/close per tag.
const SENT = { const SENT: Record<SentinelTag, { open: string; close: string }> = {
claude: { open: "", close: "" }, claude: { open: "", close: "" },
human: { open: "", close: "" }, human: { open: "", close: "" },
} as const; del: { open: "", close: "" },
};
const SENT_TAGS = Object.keys(SENT) as SentinelTag[];
function isCloseSentinel(m: string): boolean { 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 // Markdown emphasis / code delimiters whose RUNS must never be split by an
@@ -712,8 +730,8 @@ function blockContentStart(raw: string): number {
return 0; return 0;
} }
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */ /** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string { export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string {
const inserts: { at: number; marker: string }[] = []; const inserts: { at: number; marker: string }[] = [];
// Open sentinels must not precede block-level markdown markers (heading/list/ // Open sentinels must not precede block-level markdown markers (heading/list/
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it // 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 lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart)); const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
inserts.push({ at: lo, marker: SENT[s.author].open }); inserts.push({ at: lo, marker: SENT[s.tag].open });
inserts.push({ at: hi, marker: SENT[s.author].close }); inserts.push({ at: hi, marker: SENT[s.tag].close });
} }
// Apply high offset → low so earlier offsets stay valid. At an equal offset // 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 // (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; return out;
} }
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = { const SENTINEL_OF: Record<string, { tag: SentinelTag; open: boolean } | undefined> = {};
[SENT.claude.open]: { author: "claude", open: true }, for (const tag of SENT_TAGS) {
[SENT.claude.close]: { author: "claude", open: false }, SENTINEL_OF[SENT[tag].open] = { tag, open: true };
[SENT.human.open]: { author: "human", open: true }, SENTINEL_OF[SENT[tag].close] = { tag, open: false };
[SENT.human.close]: { author: "human", open: false }, }
}; const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g");
const ALL_SENTINELS = new RegExp(
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
"g",
);
/** /**
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*` * #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A
* spans. A naive global string-replace (the old approach) could emit a span that * naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a * CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and * 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 `<tag>` and * emits the span only around TEXT runs, CLOSING it before any `<tag>` and
* REOPENING it after, so a span is always well-nested within the inline elements * REOPENING it after, so a span is always well-nested within the inline elements
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray * (one `<span>` 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[] = []; 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 <span> is currently open in `out` let spanOpen = false; // whether a <span> is currently open in `out`
const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
const openSpan = () => { const openSpan = () => {
if (current && !spanOpen) { if (current && !spanOpen) {
out.push(`<span class="cw-${kind}-${current}">`); out.push(`<span class="${classFor(current)}">`);
spanOpen = true; spanOpen = true;
} }
}; };
@@ -776,7 +794,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const ch = html[i]; const ch = html[i];
const sentinel = SENTINEL_OF[ch]; const sentinel = SENTINEL_OF[ch];
if (sentinel) { 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 { else {
closeSpan(); closeSpan();
current = null; current = null;
@@ -813,7 +831,11 @@ export function colorByAuthor(
kind: "by" | "ins" = "by", kind: "by" | "ins" = "by",
): string { ): string {
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length); 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); return sentinelsToSpans(render(injected), kind);
} }
+79
View File
@@ -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<boolean>("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<boolean>("annotations", true);
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
assert.strictEqual(config().get<boolean>("annotations", true), !before);
// restore
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
assert.strictEqual(config().get<boolean>("annotations", true), before);
void api; // keep the activated extension alive for the duration of the test
});
});
+46
View File
@@ -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
});
});