/** * trackChangesModel — F7 pure, vscode-free render engine (spec §6.2, INV-22/23). * * `renderTrackChanges(baselineText, currentText)` returns the annotated HTML body * for the preview webview: a block-level diff (LCS over normalized blocks) with * word-level ``/`` refinement inside changed PROSE blocks, and CODE / * MERMAID fences kept ATOMIC (INV-23 — never word-refined, never partially * rendered). Deterministic: same inputs → identical HTML (INV-22). No vscode, no * DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview. */ import MarkdownIt from "markdown-it"; import { diffArrays, diffWords, diffWordsWithSpace } from "diff"; import { diffMermaid } from "./mermaidDiff"; export type BlockType = "prose" | "code" | "mermaid"; export interface Block { /** Verbatim source of the block (no surrounding blank lines). */ raw: string; /** Normalized match key: lowercased, whitespace-collapsed. */ key: string; type: BlockType; } function normalize(raw: string): string { return raw.trim().replace(/\s+/g, " ").toLowerCase(); } function makeBlock(raw: string, type: BlockType): Block { return { raw, key: normalize(raw), type }; } /** Split markdown into top-level blocks; fenced code/mermaid stay whole. */ export function splitBlocks(text: string): Block[] { const lines = text.split(/\r?\n/); const blocks: Block[] = []; let buf: string[] = []; const flushProse = () => { const raw = buf.join("\n"); if (raw.trim()) blocks.push(makeBlock(raw, "prose")); buf = []; }; let i = 0; while (i < lines.length) { const line = lines[i]; const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (fence) { flushProse(); const marker = fence[2][0]; // ` or ~ const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); const fenceLines = [line]; i++; while (i < lines.length) { fenceLines.push(lines[i]); const closed = lines[i].trim().startsWith(marker.repeat(3)); i++; if (closed) break; } blocks.push(makeBlock(fenceLines.join("\n"), info === "mermaid" ? "mermaid" : "code")); continue; } if (line.trim() === "") { flushProse(); i++; continue; } buf.push(line); i++; } flushProse(); return blocks; } export interface BlockWithRange extends Block { /** char offset of the block's first char in the source string. */ start: number; /** char offset one past the block's last char (source.slice(start,end) === raw). */ end: number; } /** * Like splitBlocks, but each block carries its [start,end) char range in the * SOURCE string (offsets align with document.getText(), so F3 attribution * offsets map directly). Lines are tracked with their true source offsets — a * trailing CR stays in the line, so a CRLF source keeps correct offsets. */ export function splitBlocksWithRanges(text: string): BlockWithRange[] { const lines: { raw: string; start: number }[] = []; let from = 0; for (let i = 0; i <= text.length; i++) { if (i === text.length || text[i] === "\n") { lines.push({ raw: text.slice(from, i), start: from }); from = i + 1; if (i === text.length) break; } } const rangeOf = (lo: number, hi: number): { start: number; end: number } => ({ start: lines[lo].start, end: lines[hi].start + lines[hi].raw.length, }); const out: BlockWithRange[] = []; let buf: number[] = []; const flushProse = () => { if (buf.length) { const { start, end } = rangeOf(buf[0], buf[buf.length - 1]); const raw = text.slice(start, end); if (raw.trim()) out.push({ ...makeBlock(raw, "prose"), start, end }); } buf = []; }; let i = 0; while (i < lines.length) { const line = lines[i].raw; const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (fence) { flushProse(); const marker = fence[2][0]; const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); const open = i; i++; while (i < lines.length) { const closed = lines[i].raw.trim().startsWith(marker.repeat(3)); i++; if (closed) break; } const close = i - 1; const { start, end } = rangeOf(open, close); const raw = text.slice(start, end); out.push({ ...makeBlock(raw, info === "mermaid" ? "mermaid" : "code"), start, end }); continue; } if (line.trim() === "") { flushProse(); i++; continue; } buf.push(i); i++; } flushProse(); return out; } export type BlockOp = | { kind: "unchanged"; block: Block } | { kind: "added"; block: Block } | { kind: "removed"; block: Block } | { kind: "changed"; block: Block; before: Block; atomic: boolean }; /** * Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`); * adjacent removed-then-added runs are paired element-wise into `changed` ops, the * surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when * either side is a code/mermaid fence — rendered whole, never word-refined. */ export function diffBlocks(baselineText: string, currentText: string): BlockOp[] { const before = splitBlocks(baselineText); const after = splitBlocks(currentText); const changes = diffArrays( before.map((b) => b.key), after.map((b) => b.key), ); const ops: BlockOp[] = []; let bi = 0; // index into `before` let ci = 0; // index into `after` for (let n = 0; n < changes.length; n++) { const ch = changes[n]; const count = ch.count ?? ch.value.length; if (!ch.added && !ch.removed) { for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] }); bi += count; continue; } if (ch.removed) { const next = changes[n + 1]; const addCount = next?.added ? (next.count ?? next.value.length) : 0; const paired = Math.min(count, addCount); for (let k = 0; k < paired; k++) { const beforeBlk = before[bi++]; const afterBlk = after[ci++]; const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose"; ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic }); } for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] }); for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] }); if (next?.added) n++; // consumed the paired added run continue; } // a lone added run (no preceding removed) for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] }); } return ops; } /** A contiguous changed region of `currentText` and its replacement (F11). */ export interface EditHunk { /** char offset of the hunk's first changed char in currentText. */ start: number; /** char offset one past the hunk's last changed char (start==end → a pure insertion). */ end: number; /** the text that replaces currentText.slice(start, end). */ replacement: string; } /** * F11 (INV-37, §6.4): diff a whole-document rewrite into per-hunk replacement * ranges — each becomes one independent F4 single-range proposal (so a document * edit surfaces as N independently ✓/✗-able blue blocks, no new model). Pure, * vscode-free, deterministic. Offsets index into `currentText`: removed + * unchanged parts reconstruct it exactly, so advancing on those yields true * source offsets. Adjacent added/removed runs coalesce into one hunk; an * unchanged part flushes the open hunk. * * A PURE insertion (added text between two unchanged regions) would otherwise be * a zero-width hunk (start==end) — and F4's fingerprint of empty text can never * resolve (anchorer.resolve orphans an empty needle), so the proposal could * never be accepted. Every zero-width hunk is therefore anchored to an adjacent * source token (`anchorInsertion`): its range absorbs that token and its * replacement keeps it, so the net text is identical but `fp.text` is non-empty * and resolvable. A zero-width hunk is always bordered by unchanged text (or a * doc edge), so the absorbed token is genuinely present in `currentText` and is * never part of another hunk. */ export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] { return wordEditHunks(currentText, rewrittenText).map((h) => h.start === h.end ? anchorInsertion(h, currentText) : h, ); } /** * The raw word-level diff underlying `diffToHunks`: disjoint, ordered EditHunks * that exactly partition the change (a pure insertion stays ZERO-WIDTH at its * offset — not anchored). `diffToHunks` adds anchoring on top so each hunk's F4 * fingerprint resolves; callers that apply hunks directly through the seam * (INV-40's intra-block accept) want THESE raw, non-overlapping hunks instead — * anchoring can grow an insertion to absorb a token an adjacent hunk also edits, * which corrupts a batch apply. Pure, vscode-free, deterministic. Applying these * right→left reconstructs `rewrittenText` exactly. */ export function wordEditHunks(currentText: string, rewrittenText: string): EditHunk[] { const hunks: EditHunk[] = []; let offset = 0; let open: EditHunk | null = null; const flush = () => { if (open) hunks.push(open); open = null; }; for (const part of diffWordsWithSpace(currentText, rewrittenText)) { if (part.added) { open ??= { start: offset, end: offset, replacement: "" }; open.replacement += part.value; } else if (part.removed) { open ??= { start: offset, end: offset, replacement: "" }; offset += part.value.length; open.end = offset; } else { flush(); offset += part.value.length; } } flush(); return hunks; } /** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */ export interface DecorationPlan { /** buffer ranges of inserted (proposed) text → tinted (INV-52). */ insertions: { start: number; end: number }[]; /** struck original text shown as a non-editable hint at a buffer offset (INV-52). */ deletions: { at: number; text: string }[]; } /** * F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied * text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The * SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show * the identical diff. A changed run maps to (a) an insertion range over the run's * applied text and (b) a deletion hint carrying the run's removed original text at * the run start; a pure insertion has no deletion hint; a pure deletion has only a * hint. Pure, vscode-free, deterministic. */ export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan { const insertions: { start: number; end: number }[] = []; const deletions: { at: number; text: string }[] = []; // Walk the word diff once, tracking the applied-side offset as we consume parts. let appliedOffset = anchorStart; for (const part of diffWordsWithSpace(original, replacement)) { if (part.added) { insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length }); appliedOffset += part.value.length; } else if (part.removed) { deletions.push({ at: appliedOffset, text: part.value }); // removed text is NOT in the applied buffer → appliedOffset does not advance } else { appliedOffset += part.value.length; } } return { insertions, deletions }; } const isWs = (c: string): boolean => /\s/.test(c); /** * Grow a zero-width insertion hunk to absorb an adjacent source token so its F4 * fingerprint has real text to resolve. Prefer the FOLLOWING token (optional * leading whitespace + a run of non-whitespace); at end-of-document, fall back to * the PRECEDING token. The absorbed text is kept in the replacement, so the * applied result is unchanged. */ function anchorInsertion(hunk: EditHunk, text: string): EditHunk { const p = hunk.start; if (p < text.length) { let e = p; while (e < text.length && isWs(text[e])) e++; while (e < text.length && !isWs(text[e])) e++; if (e === p) e = Math.min(text.length, p + 1); return { start: p, end: e, replacement: hunk.replacement + text.slice(p, e) }; } let s = p; while (s > 0 && isWs(text[s - 1])) s--; while (s > 0 && !isWs(text[s - 1])) s--; if (s === p) s = Math.max(0, p - 1); return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement }; } type BlockAlignKind = "unchanged" | "changed" | "removed" | "added"; interface BlockAlignOp { kind: BlockAlignKind; ci?: number; // index into the current blocks ni?: number; // index into the rewritten blocks } /** * Align two block sequences by normalized key (jsdiff `diffArrays`), pairing an * adjacent removed-then-added run element-wise into `changed` ops (the surplus * staying `removed` / `added`) — the same pairing `diffBlocks` uses, but yielding * index ops so the caller can read each side's source range. Pure. */ function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignOp[] { const changes = diffArrays( cur.map((b) => b.key), next.map((b) => b.key), ); const ops: BlockAlignOp[] = []; let bi = 0; // current index let ci = 0; // rewritten index for (let n = 0; n < changes.length; n++) { const ch = changes[n]; const count = ch.count ?? ch.value.length; if (!ch.added && !ch.removed) { for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", ci: bi++, ni: ci++ }); continue; } if (ch.removed) { const nx = changes[n + 1]; const addCount = nx?.added ? (nx.count ?? nx.value.length) : 0; const paired = Math.min(count, addCount); for (let k = 0; k < paired; k++) ops.push({ kind: "changed", ci: bi++, ni: ci++ }); for (let k = paired; k < count; k++) ops.push({ kind: "removed", ci: bi++ }); for (let k = paired; k < addCount; k++) ops.push({ kind: "added", ni: ci++ }); if (nx?.added) n++; continue; } for (let k = 0; k < count; k++) ops.push({ kind: "added", ni: ci++ }); } return ops; } /** * #47 (INV-39, §6.4): diff a whole-document rewrite into ONE EditHunk per CHANGED * BLOCK — the unit a human reviews — rather than per word (which INV-37/`diffToHunks` * did and this supersedes for document edits). Built by aligning both sides into * the existing block units (`splitBlocksWithRanges`) and keying with the same diff * as `diffBlocks`: * - a CHANGED block (1:1 aligned, even when adjacent to other changed blocks) → a * block-aligned hunk `[block.start, block.end)` → the rewritten block's raw * text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). So * a copy-edit pass touching N consecutive paragraphs yields N proposals. * - a run of block INSERTIONS / DELETIONS → one gap-span hunk covering the source * between the bounding aligned blocks (unchanged OR changed — both are 1:1 * anchors) → the matching rewritten span (separators included), so * reconstruction stays exact. A zero-width gap-span (insert at a seamless * boundary) is anchored to an adjacent token (INV-41) so its F4 fingerprint * resolves and it accepts. * - unchanged blocks (same key AND same raw) → no hunk. * Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which * is RETAINED as the intra-block sub-diff engine for word-precise accept * attribution — INV-40). Applying all hunks right→left reconstructs `rewrittenText` * exactly. */ export function diffToBlockHunks(currentText: string, rewrittenText: string): EditHunk[] { const cur = splitBlocksWithRanges(currentText); const next = splitBlocksWithRanges(rewrittenText); const ops = alignBlocks(cur, next); // Same key but different raw (whitespace/case) is still a real content change. for (const op of ops) { if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed"; } // `changed` and `unchanged` are 1:1 anchors (both sides' offsets are known); // `added`/`removed` have no counterpart and must be spanned together. const isAnchor = (op: BlockAlignOp) => op.kind === "unchanged" || op.kind === "changed"; const hunks: EditHunk[] = []; let i = 0; while (i < ops.length) { const op = ops[i]; if (op.kind === "unchanged") { i++; continue; } if (op.kind === "changed") { const c = cur[op.ci!]; hunks.push({ start: c.start, end: c.end, replacement: next[op.ni!].raw }); i++; continue; } // A maximal run of added/removed blocks → one gap-span hunk over the source // between the bounding anchors (or the document edges), replaced with the // matching rewritten span, so the separators reconstruct exactly. let j = i; while (j < ops.length && !isAnchor(ops[j])) j++; const prev = i > 0 ? ops[i - 1] : null; // an anchor by construction const after = j < ops.length ? ops[j] : null; // an anchor (or null at EOF) const curStart = prev ? cur[prev.ci!].end : 0; const curEnd = after ? cur[after.ci!].start : currentText.length; const newStart = prev ? next[prev.ni!].end : 0; const newEnd = after ? next[after.ni!].start : rewrittenText.length; const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) }; hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk); i = j; } hunks.sort((a, b) => a.start - b.start); return hunks; } const md = new MarkdownIt({ html: true, linkify: false, breaks: false }); // mermaid fences →
SRC
for client-side rendering; all // other fences fall through to markdown-it's default (escaped
).
const defaultFence = md.renderer.rules.fence!.bind(md.renderer.rules);
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
  const info = tokens[idx].info.trim().split(/\s+/)[0].toLowerCase();
  if (info === "mermaid") {
    return `
${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}
\n`; } return defaultFence(tokens, idx, options, env, self); }; /** Inner source of a ```mermaid fence (drops the fence lines), or null. */ function mermaidFenceBody(raw: string): string | null { const lines = raw.split(/\r?\n/); const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); if (!open || open[3]?.toLowerCase() !== "mermaid") return null; const body = lines.slice(1); if (body.length && /^(\s*)(`{3,}|~{3,})/.test(body[body.length - 1])) body.pop(); return body.join("\n"); } /** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */ const MERMAID_LEGEND = '
' + 'added' + 'changed' + 'removed' + "
"; /** Build a markdown string with inline / from a word-level prose diff. */ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string { return diffWords(beforeRaw, afterRaw) .map((part) => { if (part.added) return `${part.value}`; if (part.removed) return `${part.value}`; return part.value; }) .join(""); } /** The author whose span covers `offset` (half-open [start,end)), or null. */ export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null { for (const s of spans) if (offset >= s.start && offset < s.end) return s.author; return null; } const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`; const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`; /** * Author-colored word diff for a changed PROSE block in the review. * `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's * offset maps into `spans`. Insertions are colored by the author covering their * offset; deletions inherit the author of the insertion they are paired with in * the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a * cluster has no insertion. Pure, deterministic. */ export function wordDiffByAuthor( beforeRaw: string, afterRaw: string, afterStart: number, spans: AuthorSpan[], ): string { const parts = diffWordsWithSpace(beforeRaw, afterRaw); // First pass: find each change cluster's insertion author (first added run). // A cluster is a maximal run of added/removed parts bounded by unchanged parts. let afterOff = afterStart; const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author { let off = afterStart; let i = 0; while (i < parts.length) { const p = parts[i]; if (!p.added && !p.removed) { clusterAuthor[i] = null; off += p.value.length; i++; continue; } // a cluster: scan to its end, capturing the first added run's author let j = i; let author: AuthorKind | null = null; let scanOff = off; while (j < parts.length && (parts[j].added || parts[j].removed)) { if (parts[j].added && author === null) author = authorAt(scanOff, spans); if (parts[j].added) scanOff += parts[j].value.length; j++; } for (let k = i; k < j; k++) clusterAuthor[k] = author; off = scanOff; i = j; } } // Second pass: emit. const out: string[] = []; parts.forEach((p, i) => { if (p.added) { const a = authorAt(afterOff, spans); out.push(`${p.value}`); afterOff += p.value.length; } else if (p.removed) { out.push(`${p.value}`); } else { out.push(p.value); afterOff += p.value.length; } }); return out.join(""); } export interface RenderOptions { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ render?: (src: string) => string; /** * #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes * since that pin, the on-render is fully clean — no authorship coloring — so a * pin reads as "this is my clean starting point". Distinct from a baseline * advanced by a machine-landing (accept), which keeps its authorship coloring * (F10 INV-33). Only consulted by `renderReview`. */ pinned?: boolean; } function defaultRender(src: string): string { return md.render(src); } function chip(message: string): string { return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; } /** * F11 (INV-36): the source-range attributes a live block's wrapping element * carries, so a preview selection can map back to a source markdown range. `""` * when the block has no live source (a baseline-only deletion / a proposal * block) — those are skipped by the selection mapper. Pure + deterministic. */ function srcAttr(blk: { start: number; end: number } | undefined): string { return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : ""; } function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string { const safe = (src: string): string => { try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } }; let cls: string; let inner: string; let badge = ""; switch (op.kind) { case "unchanged": cls = "cw-unchanged"; inner = safe(op.block.raw); break; case "added": cls = "cw-added"; inner = safe(op.block.raw); if (op.block.type !== "prose") badge = 'added'; break; case "removed": cls = "cw-removed"; inner = safe(op.block.raw); break; case "changed": cls = "cw-changed"; if (op.atomic) { const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null; const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null; const md = curBody !== null && beforeBody !== null ? diffMermaid(beforeBody, curBody) : { kind: "fallback" as const }; if (md.kind === "augmented") { // Re-render the augmented diagram as a mermaid fence; add a legend, drop the badge (INV-29). inner = safe("```mermaid\n" + md.source + "\n```") + MERMAID_LEGEND; } else { inner = safe(op.block.raw); // the NEW block, whole (INV-23 / INV-30 fallback) badge = 'changed'; } } else { inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw)); } break; } return `
${badge}${inner}
`; } export type AuthorKind = "claude" | "human"; export interface AuthorSpan { start: number; end: number; author: AuthorKind; } // Private-Use-Area sentinels (never appear in real content; markdown-it passes // them through as plain text). Paired open/close per author. const SENT = { claude: { open: "", close: "" }, human: { open: "", close: "" }, } as const; function isCloseSentinel(m: string): boolean { return m === SENT.claude.close || m === SENT.human.close; } function authorBadge(authors: Set): { cls: string; label: string } | null { if (authors.size === 0) return null; if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" }; const only = [...authors][0]; return only === "claude" ? { cls: "cw-by-claude", label: "Claude" } : { cls: "cw-by-human", label: "You" }; } // Markdown emphasis / code delimiters whose RUNS must never be split by an // injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks // markdown-it's delimiter pairing (#33 CASE1). const isDelimChar = (c: string): boolean => c === "*" || c === "_" || c === "~" || c === "`"; /** * #33 (CASE1): a sentinel must not land STRICTLY INSIDE a delimiter run. If `at` * sits between two identical delimiter chars, snap it to the run's start (a * position outside the run) so the run stays intact. Delimiters are invisible once * rendered, so snapping only shifts the colored boundary across markup, never over * visible text. */ function clampOffDelimiterRun(raw: string, at: number): number { if (at <= 0 || at >= raw.length) return at; if (!(isDelimChar(raw[at]) && raw[at - 1] === raw[at])) return at; let p = at; while (p > 0 && raw[p - 1] === raw[at]) p--; return p; } /** 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 { const inserts: { at: number; marker: string }[] = []; for (const s of spans) { const lo = 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 }); } // 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 // close ends up left of the open: "……" for adjacent spans. inserts.sort((a, b) => b.at - a.at || (isCloseSentinel(a.marker) ? 1 : -1)); let out = raw; for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at); return out; } const SENTINEL_OF: Record = { [SENT.claude.open]: { author: "claude", open: true }, [SENT.claude.close]: { author: "claude", open: false }, [SENT.human.open]: { author: "human", open: true }, [SENT.human.close]: { author: "human", open: false }, }; const ALL_SENTINELS = new RegExp( `[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`, "g", ); /** * #33: token-aware replacement of the rendered author sentinels with `cw-by-*` * spans. A naive global string-replace (the old approach) could emit a span that * CROSSES an element boundary — `bold` — when a * boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and * emits the author span only around TEXT runs, CLOSING it before any `` and * REOPENING it after, so a span is always well-nested within the inline elements * (one `` segment per text run). Tags are copied verbatim (with any stray * sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic. */ function sentinelsToSpans(html: string): string { const out: string[] = []; let current: AuthorKind | null = null; // which author region we're inside let spanOpen = false; // whether a is currently open in `out` const openSpan = () => { if (current && !spanOpen) { out.push(``); spanOpen = true; } }; const closeSpan = () => { if (spanOpen) { out.push(""); spanOpen = false; } }; for (let i = 0; i < html.length; i++) { 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 else { closeSpan(); current = null; } continue; } if (ch === "<") { // An HTML tag: never let an author span straddle it (text `<` is escaped to // <, so a raw `<` is always a real tag). Copy the tag verbatim, sentinel-free. closeSpan(); const gt = html.indexOf(">", i); const end = gt === -1 ? html.length - 1 : gt; out.push(html.slice(i, end + 1).replace(ALL_SENTINELS, "")); i = end; continue; } openSpan(); out.push(ch); } closeSpan(); return out.join(""); } /** * Color one prose block's HTML by F3 author spans (PUA-sentinel technique, * salvaged from F9 renderAuthorship). `blockStart` is the block's char offset in * the source so spans map correctly. Pure; deterministic. */ export function colorByAuthor( raw: string, blockStart: number, spans: AuthorSpan[], render: (src: string) => string, ): string { const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length); const injected = injectSentinels(raw, blockStart, overlapping); return sentinelsToSpans(render(injected)); } /** * Off-state body: the current buffer as plain markdown, no annotations (INV-33). * Each block is wrapped in a bare `
` so the off-mode * preview is still a selection→source mapping surface (INV-36) while staying * visually clean — no `cw-` annotation classes. A doc with no blocks (empty / * whitespace-only) renders whole. Pure + deterministic. * * Tradeoff of the locked block-level mapping (§6.7): rendering PER BLOCK (so each * carries its offsets) means a markdown construct split across blank-line- * separated blocks — a reference-link use and its definition — doesn't resolve * across blocks. `renderReview` already rendered per-block; this keeps the two * modes consistent rather than faithful-but-different. Characterized in tests. */ export function renderPlain(currentText: string, opts: RenderOptions = {}): string { const render = opts.render ?? defaultRender; const safe = (src: string): string => { try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } }; const blocks = splitBlocksWithRanges(currentText); if (blocks.length === 0) return safe(currentText); return blocks.map((b) => `${safe(b.raw)}
`).join("\n"); } export interface ProposalView { id: string; /** resolved offsets in currentText; null when the anchor did not resolve. */ anchorStart: number | null; anchorEnd: number | null; /** the text the proposal would replace (fp.text), for the struck "before". */ replaced: string; /** the proposed replacement text. */ replacement: string; /** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */ original?: string; } function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string { const safe = (src: string): string => { try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } }; const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : ""; const before = p.replaced ? `${safe(p.replaced)}` : ""; const after = `${safe(p.replacement)}`; const actions = `` + `` + `` + `` + `` + `` + `` + `` + `` + ``; return `
${actions}${before}${after}
`; } function renderReviewOp( op: BlockOp, render: (src: string) => string, colored: (raw: string) => string, src: string, changedHtml?: string, ): string { // A non-atomic changed prose block is pre-rendered with author-colored ins/del // (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp // (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a // removed block (no live source — INV-36). if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) { return `
${changedHtml}
`; } if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src); // "added": use changedHtml (author-colored word-diff from empty before, all insertions). // "unchanged": colored() returns plain render(raw) — color means "changed since baseline". return `
${changedHtml ?? colored(op.block.raw)}
`; } /** * On-state body: the F7 baseline diff — added/changed PROSE author-colored via * wordDiffByAuthor (cw-ins-{author}/cw-del-{author} per token), unchanged blocks render plain, * deletions struck neutral — overlaid with F4 pending proposals as blue cw-proposal * blocks (✓/✗). One pass, pure, vscode-free. * * A resolved proposal renders INLINE, right after the current-side block its anchor * falls in (#31) — so "what is Claude proposing, and where?" is answered by * position. A proposal whose anchor does not resolve (or falls before the first * block) renders as a trailing cw-proposal-unanchored block (never dropped — * INV-34). Deterministic: proposals in the same block are ordered by anchorStart * then id; trailing proposals keep input order. */ /** * F12/#64 (INV-50): `currentText` with every resolved pending proposal's applied * span reverted to its original (`replaced`) — the "landed" text the baseline diff * should run against, so a pending proposal renders ONCE (as a proposal), never also * as a landed change. Reverts high→low so earlier offsets stay valid. Pure. The * preview's summary tally diffs against this too, so the toolbar count matches the * body (a pending change is not double-counted as both a landed add/remove and a * proposal). */ export function landedTextOf(currentText: string, proposals: ProposalView[]): string { const pendingApplied = proposals .filter((p) => p.anchorStart !== null) .sort((a, b) => b.anchorStart! - a.anchorStart!); let landedText = currentText; for (const p of pendingApplied) { landedText = landedText.slice(0, p.anchorStart!) + p.replaced + landedText.slice(p.anchorEnd!); } return landedText; } export function renderReview( baselineText: string, currentText: string, authorSpans: AuthorSpan[], proposals: ProposalView[], opts: RenderOptions = {}, ): string { const render = opts.render ?? defaultRender; // F12/#64 (INV-50): with optimistic apply the proposed text is already in // `currentText`, so a naive baseline→current diff would render each proposed // change BOTH as a landed diff and as its proposal block. Diff against the // "landed" text (current minus pending proposals) so they render once. const pendingApplied = proposals .filter((p) => p.anchorStart !== null) .sort((a, b) => b.anchorStart! - a.anchorStart!); const landedText = landedTextOf(currentText, proposals); const ranges = splitBlocksWithRanges(landedText); const ops = diffBlocks(baselineText, landedText); // #48: right after a PIN (baseline reason "pinned") with no changes since, the // panel is fully clean: no change marks (already absent) AND no authorship // coloring, so the pin reads as "this is my clean starting point". Suppress // wordDiffByAuthor for every block in this case; data-src mapping and any pending // proposals (review actions, not annotations) are kept below. const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged"); // Map a currentText offset to its landedText offset (account for reverted spans // that precede it; reverts were applied high→low so the cumulative delta is stable). const toLanded = (curOff: number): number => { let delta = 0; for (const p of [...pendingApplied].sort((a, b) => a.anchorStart! - b.anchorStart!)) { if (p.anchorStart! < curOff) delta += p.replaced.length - (p.anchorEnd! - p.anchorStart!); } return curOff + delta; }; // F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a // landedText offset — but `authorSpans` arrive in `currentText` coordinates. A // colored block sitting AFTER a length-changing pending proposal would otherwise // be mis-colored (the offsets diverge by the revert delta). Map the spans into // landedText coordinates once so authorship coloring stays aligned. const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({ ...s, start: toLanded(s.start), end: toLanded(s.end), })); // Associate each resolved proposal with the landedText block index whose range // it anchors into: the largest block with start <= anchorStart (the containing // block, or the nearest preceding block when the anchor sits in a gap). A // resolved anchor before all blocks, and every unresolved proposal, trails. const blockOf = (a: number): number => { let j = -1; for (let k = 0; k < ranges.length && ranges[k].start <= a; k++) j = k; return j; }; const byBlock = new Map(); const trailing: ProposalView[] = []; for (const p of proposals) { const j = p.anchorStart === null ? -1 : blockOf(toLanded(p.anchorStart)); if (j < 0) { trailing.push(p); continue; } (byBlock.get(j) ?? byBlock.set(j, []).get(j)!).push(p); } for (const arr of byBlock.values()) { arr.sort((a, b) => a.anchorStart! - b.anchorStart! || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); } let ci = 0; // pointer into ranges; advances for every op with a current-side block const bodyParts: string[] = []; for (const op of ops) { const blockIndex = op.kind === "removed" ? -1 : ci; const blk = op.kind === "removed" ? undefined : ranges[ci++]; // Only ADDED and CHANGED prose blocks are author-colored (they differ since baseline); // UNCHANGED blocks render plain (color means "changed since baseline"). const colored = (raw: string): string => render(raw); // A non-atomic changed prose block: author-colored word diff (ins by author, // del by adjacency). An added block: word diff from empty before — all insertions. // `blk.start` is the landed offset of the after-side text. const changedHtml = blk && !clean ? op.kind === "changed" && !op.atomic ? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans)) : op.kind === "added" ? render(wordDiffByAuthor("", op.block.raw, blk.start, landedSpans)) : undefined : undefined; bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk), changedHtml)); const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined; if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render)); } for (const p of trailing) bodyParts.push(proposalBlockHtml(p, render)); return bodyParts.join("\n"); } /** Pure entry point: annotated HTML body for the preview (INV-22). */ export function renderTrackChanges( baselineText: string, currentText: string, opts: RenderOptions = {}, ): string { const render = opts.render ?? defaultRender; return diffBlocks(baselineText, currentText) .map((op) => renderOp(op, render)) .join("\n"); }