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",
|
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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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",
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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