Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) #72
+21
-2
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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 {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n+$/, "");
|
||||
}
|
||||
+49
-27
@@ -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<SentinelTag, { open: string; close: string }> = {
|
||||
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<string, { author: AuthorKind; open: boolean } | undefined> = {
|
||||
[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<string, { tag: SentinelTag; open: boolean } | undefined> = {};
|
||||
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 — `<span><strong>bo</span>ld</strong>` — 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 `<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
|
||||
* (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[] = [];
|
||||
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`
|
||||
const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
|
||||
const openSpan = () => {
|
||||
if (current && !spanOpen) {
|
||||
out.push(`<span class="cw-${kind}-${current}">`);
|
||||
out.push(`<span class="${classFor(current)}">`);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user