From dcdd2ffc1cfada7e2dfebb9bd8acc4680494e94e Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 17:31:13 -0700 Subject: [PATCH 1/5] feat: per-author editor decoration types; proposals decorate in Claude blue/purple --- src/editorProposalController.ts | 68 +++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index 0e2441d..6b44e9b 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -17,14 +17,20 @@ import { isAuthorable } from "./workspacePath"; export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { private readonly disposables: vscode.Disposable[] = []; - private readonly insertionDeco = vscode.window.createTextEditorDecorationType({ - backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"), - }); - private readonly deletionDeco = vscode.window.createTextEditorDecorationType({ - // a non-editable struck-red hint injected AFTER the insertion (INV-52) - after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") }, - textDecoration: "none", - }); + private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = { + human: vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950", + border: "0 0 2px 0", borderStyle: "solid", + }), + claude: vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff", + border: "0 0 2px 0", borderStyle: "solid", + }), + }; + private readonly delDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = { + human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }), + claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }), + }; private readonly lensEmitter = new vscode.EventEmitter(); readonly onDidChangeCodeLenses = this.lensEmitter.event; /** Pending debounce timers — one per URI — coalesce rapid-fire propose events @@ -37,7 +43,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL constructor(private readonly proposals: ProposalController) { this.disposables.push( - this.insertionDeco, this.deletionDeco, this.lensEmitter, + this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter, vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)), @@ -80,31 +86,43 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL /** Decorate the editor for every applied proposal on its document (INV-52). */ private renderEditor(editor: vscode.TextEditor): void { const doc = editor.document; - if (doc.languageId !== "markdown") { - editor.setDecorations(this.insertionDeco, []); - editor.setDecorations(this.deletionDeco, []); - return; - } + const clearAll = () => { + for (const a of ["human", "claude"] as const) { + editor.setDecorations(this.insDeco[a], []); + editor.setDecorations(this.delDeco[a], []); + } + }; + if (doc.languageId !== "markdown") return clearAll(); const key = this.proposals.keyFor(doc); - const insertions: vscode.Range[] = []; - const deletions: vscode.DecorationOptions[] = []; + const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] }; + const del: Record<"human" | "claude", vscode.DecorationOptions[]> = { human: [], claude: [] }; + // Pending proposals — always Claude (INV: proposals are agent-authored). for (const v of this.proposals.listProposals(doc)) { if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue; const plan = decorationPlan(v.anchorStart, v.original, v.replacement); - for (const ins of plan.insertions) { - insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end))); - } - for (const del of plan.deletions) { - deletions.push({ - range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)), - renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } }, + for (const i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end))); + for (const d of plan.deletions) { + del.claude.push({ + range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)), + renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } }, }); } } - editor.setDecorations(this.insertionDeco, insertions); - editor.setDecorations(this.deletionDeco, deletions); + // Committed changes-since-baseline are added in Task 7 (pushes into ins/del[author]). + this.decorateCommitted(editor, ins, del); // no-op stub until Task 7 + for (const a of ["human", "claude"] as const) { + editor.setDecorations(this.insDeco[a], ins[a]); + editor.setDecorations(this.delDeco[a], del[a]); + } } + /** Overridden in Task 7 to add committed (non-proposal) author-colored changes. */ + private decorateCommitted( + _editor: vscode.TextEditor, + _ins: Record<"human" | "claude", vscode.Range[]>, + _del: Record<"human" | "claude", vscode.DecorationOptions[]>, + ): void {} + /** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { if (document.languageId !== "markdown") return []; From 7b59c324d5a7a7430dd30cc1b278b40fc13e75ee Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 17:40:15 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Task=207=20=E2=80=94=20decorateComm?= =?UTF-8?q?itted=20fills=20committed=20author-colored=20changes=20in=20the?= =?UTF-8?q?=20editor=20(reverses=20INV-32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/editorProposalController.ts | 89 ++++++++++++++++++++++++++++++--- src/extension.ts | 2 +- test/trackChangesModel.test.ts | 23 +++++++++ 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index 6b44e9b..06d09f2 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -12,7 +12,9 @@ */ import * as vscode from "vscode"; import type { ProposalController } from "./proposalController"; -import { decorationPlan } from "./trackChangesModel"; +import type { DiffViewController } from "./diffViewController"; +import type { AttributionController } from "./attributionController"; +import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { @@ -40,13 +42,29 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL * optimisticApply runs concurrently with the still-in-progress propose loop, * causing "file changed in the meantime" workspace-edit conflicts. */ private readonly pendingApply = new Map>(); + /** Debounce timers for committed-change re-render (one per URI, 50 ms). */ + private readonly pendingRender = new Map>(); - constructor(private readonly proposals: ProposalController) { + constructor( + private readonly proposals: ProposalController, + private readonly diffView: DiffViewController, + private readonly attribution: AttributionController, + ) { this.disposables.push( this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter, vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)), + // Refresh committed decorations when the baseline advances (pin / machine-landing / open). + this.diffView.onDidChangeBaseline(({ uri }) => { + const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); + if (ed) this.renderEditor(ed); + }), + // Refresh committed decorations (debounced) when the active doc changes — attribution spans shift. + vscode.workspace.onDidChangeTextDocument((e) => { + const ed = vscode.window.activeTextEditor; + if (ed && e.document === ed.document) this.scheduleRender(ed); + }), // the four QuickPick-backed menu commands vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)), vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)), @@ -68,6 +86,20 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL ); } + /** Debounce a committed-change re-render for `editor` (50 ms — lets rapid typing settle). */ + private scheduleRender(editor: vscode.TextEditor): void { + const uri = editor.document.uri.toString(); + const prev = this.pendingRender.get(uri); + if (prev !== undefined) clearTimeout(prev); + this.pendingRender.set( + uri, + setTimeout(() => { + this.pendingRender.delete(uri); + this.renderEditor(editor); + }, 50), + ); + } + /** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */ private async onProposalsChanged(uri: string): Promise { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); @@ -116,12 +148,53 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL } } - /** Overridden in Task 7 to add committed (non-proposal) author-colored changes. */ + /** + * Decorates committed (non-proposal) changes-since-baseline by author (Task 7 / reverse INV-32). + * Diffs current buffer against the F6 baseline; attributes each changed run to its author; + * skips any hunk overlapping an applied pending proposal (the proposal loop owns those). + * pin→clean: returns without marking when baseline.reason === "pinned". + */ private decorateCommitted( - _editor: vscode.TextEditor, - _ins: Record<"human" | "claude", vscode.Range[]>, - _del: Record<"human" | "claude", vscode.DecorationOptions[]>, - ): void {} + editor: vscode.TextEditor, + ins: Record<"human" | "claude", vscode.Range[]>, + del: Record<"human" | "claude", vscode.DecorationOptions[]>, + ): void { + const doc = editor.document; + const baseline = this.diffView.getBaseline(doc.uri.toString()); + // No baseline yet, or baseline was pinned (pin→clean: no committed marks, INV-48). + if (!baseline || baseline.reason === "pinned") return; + const current = doc.getText(); + // Nothing changed — short-circuit before the diff. + if (current === baseline.text) return; + const spans = this.attribution.spansFor(doc); + const key = this.proposals.keyFor(doc); + // Build the set of current-buffer ranges owned by applied pending proposals so we + // can skip hunks that fall inside them — the proposal loop already decorates those. + const appliedRanges: { start: number; end: number }[] = []; + for (const v of this.proposals.listProposals(doc)) { + if (v.anchorStart !== null && v.original !== undefined && this.proposals.isApplied(key, v.id)) { + appliedRanges.push({ start: v.anchorStart, end: v.anchorStart + v.replacement.length }); + } + } + // wordEditHunks(current, baseline.text): start/end are in current coords; replacement is baseline text. + const hunks = wordEditHunks(current, baseline.text); + for (const h of hunks) { + // Skip hunks whose current-buffer range overlaps an applied pending proposal. + if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue; + const author = authorAt(h.start, spans) ?? "human"; + // h.replacement = baseline text for this span; current.slice(h.start, h.end) = applied text. + const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end)); + for (const i of plan.insertions) { + ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end))); + } + for (const d of plan.deletions) { + del[author].push({ + range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)), + renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } }, + }); + } + } + } /** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { @@ -164,6 +237,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL dispose(): void { for (const t of this.pendingApply.values()) clearTimeout(t); this.pendingApply.clear(); + for (const t of this.pendingRender.values()) clearTimeout(t); + this.pendingRender.clear(); for (const d of this.disposables) d.dispose(); } } diff --git a/src/extension.ts b/src/extension.ts index 4cc73cd..3a81496 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -119,7 +119,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer, // decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). --- - const editorProposalController = new EditorProposalController(proposalController); + const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController); context.subscriptions.push(editorProposalController); // #46 (INV-42): accept every pending proposal on the active doc in one gesture diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 2907fb1..9e350a0 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -250,6 +250,29 @@ describe("wordDiffByAuthor", () => { import { renderPlain } from "../src/trackChangesModel"; +import { wordEditHunks, decorationPlan } from "../src/trackChangesModel"; + +test("committed change: wordEditHunks(current, baseline) → start/end index current; replacement is baseline text", () => { + const baseline = "the light mode"; + const current = "the dark mode"; + const [h] = wordEditHunks(current, baseline); + expect(current.slice(h.start, h.end)).toContain("dark"); // applied (current) side + expect(h.replacement).toContain("light"); // baseline (deleted) side + const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end)); + expect(plan.insertions.length).toBeGreaterThan(0); + expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true); +}); + +test("committed change with NO shared prefix: current offsets + deleted text both correct", () => { + const baseline = "alpha tail"; + const current = "beta tail"; + const [h] = wordEditHunks(current, baseline); + expect(h.start).toBe(0); + expect(current.slice(h.start, h.end)).toContain("beta"); // applied + const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end)); + expect(plan.deletions.some((d) => d.text.includes("alpha"))).toBe(true); // deletion not lost +}); + describe("renderPlain", () => { test("renderPlain renders current buffer as plain markdown (no marks)", () => { const html = renderPlain("# Title\n\nhello"); From ae19df78fbda925979b1dd0e45106a53e51010c3 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 17:50:39 -0700 Subject: [PATCH 3/5] feat: overlap renders stacked author marks (insert + delete) in both panes Co-Authored-By: Claude Sonnet 4.6 --- media/preview.css | 8 ++++++++ test/trackChangesModel.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/media/preview.css b/media/preview.css index fa944b0..f706ff1 100644 --- a/media/preview.css +++ b/media/preview.css @@ -35,6 +35,14 @@ del { text-decoration: line-through; opacity: 0.7; } .cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; } .cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; } +/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B}, + both marks must show. cw-ins-* sets text-decoration:none which would otherwise + suppress the parent del's strikethrough. `inherit` restores the parent's + line-through while the child's border-bottom underline is unaffected. + The engine currently emits SIBLING ins/del (never nested) so this rule is a + forward defensive guarantee only. See task-8-report.md for details. */ +.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; } + /* whole-block ops: an added block is an insertion (its author runs are emitted as cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral struck (adjacency heuristic fallback) */ diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 9e350a0..101ea91 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -246,6 +246,21 @@ describe("wordDiffByAuthor", () => { expect(html).not.toContain("cw-del-human"); expect(html).not.toContain("cw-del-claude"); }); + test("emits SIBLING ins/del elements, never nested — CSS overlap rule in preview.css is a defensive forward guarantee", () => { + // The engine assigns the cluster's insertion author to all deletions in the same + // cluster (adjacency heuristic), so and always carry the SAME author + // as siblings. Cross-author nesting () + // is never produced today. The CSS rule `.cw-del-claude .cw-ins-human { text-decoration: inherit }` + // guards against a future regression where such nesting appears. + const spans: AuthorSpan[] = [{ start: 0, end: 100, author: "human" }]; + const html = wordDiffByAuthor("old text", "new text", 0, spans); + expect(html).toContain(''); + expect(html).toContain(''); + // Structural check: no immediately wraps an (sibling, not nested). + // /]*>[^<]* tag. + expect(html).not.toMatch(/]*>[^<]* Date: Fri, 26 Jun 2026 17:57:16 -0700 Subject: [PATCH 4/5] chore: sweep retired semantic-diff markup; full suite green - Delete dead `authorBadge` function (no callers; grep-verified) - Merge adjacent duplicate imports from trackChangesModel in test file Co-Authored-By: Claude Sonnet 4.6 --- src/trackChangesModel.ts | 9 --------- test/trackChangesModel.test.ts | 4 +--- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 9351551..b892dc2 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -642,15 +642,6 @@ 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). diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 101ea91..bf8173f 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -263,9 +263,7 @@ describe("wordDiffByAuthor", () => { }); }); -import { renderPlain } from "../src/trackChangesModel"; - -import { wordEditHunks, decorationPlan } from "../src/trackChangesModel"; +import { renderPlain, wordEditHunks, decorationPlan } from "../src/trackChangesModel"; test("committed change: wordEditHunks(current, baseline) → start/end index current; replacement is baseline text", () => { const baseline = "the light mode"; From c3cecdfaa2a06947cd49a7a43eae18c8cda7ea38 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 18:10:25 -0700 Subject: [PATCH 5/5] fix: editor renders standalone committed deletions neutral; doc/comment cleanups (final review) Co-Authored-By: Claude Sonnet 4.6 --- src/editorProposalController.ts | 40 +++++++++++++++++++-------------- src/trackChangesModel.ts | 2 +- test/trackChangesModel.test.ts | 15 +++++++++++++ 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index 06d09f2..691af60 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -7,8 +7,9 @@ * for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens * `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all). * Owns no proposal STATE — it is a view over ProposalController (which stays the - * pure F4 owner). A document with no pending proposals shows nothing (INV-32's - * spirit when nothing is pending). + * pure F4 owner). Phase 2 decorates ALL committed changes-since-baseline by author + * (human green / Claude blue + struck hints for deletions), plus pending proposals; + * a pinned baseline suppresses committed marks entirely (pin→clean, INV-48). */ import * as vscode from "vscode"; import type { ProposalController } from "./proposalController"; @@ -29,9 +30,10 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL border: "0 0 2px 0", borderStyle: "solid", }), }; - private readonly delDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = { + private readonly delDeco: Record<"human" | "claude" | "none", vscode.TextEditorDecorationType> = { human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }), claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }), + none: vscode.window.createTextEditorDecorationType({ after: { color: new vscode.ThemeColor("descriptionForeground") } }), }; private readonly lensEmitter = new vscode.EventEmitter(); readonly onDidChangeCodeLenses = this.lensEmitter.event; @@ -51,7 +53,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL private readonly attribution: AttributionController, ) { this.disposables.push( - this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter, + this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter, vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)), @@ -119,15 +121,13 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL private renderEditor(editor: vscode.TextEditor): void { const doc = editor.document; const clearAll = () => { - for (const a of ["human", "claude"] as const) { - editor.setDecorations(this.insDeco[a], []); - editor.setDecorations(this.delDeco[a], []); - } + for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], []); + for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []); }; if (doc.languageId !== "markdown") return clearAll(); const key = this.proposals.keyFor(doc); const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] }; - const del: Record<"human" | "claude", vscode.DecorationOptions[]> = { human: [], claude: [] }; + const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] }; // Pending proposals — always Claude (INV: proposals are agent-authored). for (const v of this.proposals.listProposals(doc)) { if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue; @@ -140,12 +140,10 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL }); } } - // Committed changes-since-baseline are added in Task 7 (pushes into ins/del[author]). - this.decorateCommitted(editor, ins, del); // no-op stub until Task 7 - for (const a of ["human", "claude"] as const) { - editor.setDecorations(this.insDeco[a], ins[a]); - editor.setDecorations(this.delDeco[a], del[a]); - } + // Committed changes-since-baseline are added by decorateCommitted (pushes into ins/del[author]). + this.decorateCommitted(editor, ins, del); + for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], ins[a]); + for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], del[a]); } /** @@ -153,11 +151,14 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL * Diffs current buffer against the F6 baseline; attributes each changed run to its author; * skips any hunk overlapping an applied pending proposal (the proposal loop owns those). * pin→clean: returns without marking when baseline.reason === "pinned". + * Adjacency heuristic: a deletion in a hunk that has insertions inherits the hunk author; + * a standalone deletion (hunk with no insertions) renders neutral ("none") — matching the + * preview's cw-del-none treatment. */ private decorateCommitted( editor: vscode.TextEditor, ins: Record<"human" | "claude", vscode.Range[]>, - del: Record<"human" | "claude", vscode.DecorationOptions[]>, + del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]>, ): void { const doc = editor.document; const baseline = this.diffView.getBaseline(doc.uri.toString()); @@ -180,6 +181,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL const hunks = wordEditHunks(current, baseline.text); for (const h of hunks) { // Skip hunks whose current-buffer range overlaps an applied pending proposal. + // The skip is whole-hunk and therefore conservative: a committed edit word-merged + // into a proposal's hunk is skipped entirely — rare at word granularity. if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue; const author = authorAt(h.start, spans) ?? "human"; // h.replacement = baseline text for this span; current.slice(h.start, h.end) = applied text. @@ -187,8 +190,11 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL for (const i of plan.insertions) { ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end))); } + // Adjacency heuristic: a deletion paired with an insertion in THIS hunk inherits the + // insertion author; a standalone deletion (no insertion in the hunk) is neutral. + const delAuthor = plan.insertions.length > 0 ? author : "none"; for (const d of plan.deletions) { - del[author].push({ + del[delAuthor].push({ range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)), renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } }, }); diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index b892dc2..c349298 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -720,7 +720,7 @@ const ALL_SENTINELS = new RegExp( ); /** - * #33: token-aware replacement of the rendered author sentinels with `cw-by-*` + * #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 * CROSSES an element boundary — `bold` — when a * boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index bf8173f..8ae493f 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -286,6 +286,21 @@ test("committed change with NO shared prefix: current offsets + deleted text bot expect(plan.deletions.some((d) => d.text.includes("alpha"))).toBe(true); // deletion not lost }); +test("standalone committed deletion → plan has no insertions (controller routes del to neutral)", () => { + // current = "keep word" (baseline had "this " between "keep " and "word" — standalone deletion) + const [h] = wordEditHunks("keep word", "keep this word"); + const plan = decorationPlan(h.start, h.replacement, "keep word".slice(h.start, h.end)); + expect(plan.insertions.length).toBe(0); + expect(plan.deletions.some((d) => d.text.includes("this"))).toBe(true); +}); + +test("paired committed replace → plan has insertions (deletion inherits author, not neutral)", () => { + // current = "the dark mode", baseline = "the light mode" — replacement, so paired + const [h] = wordEditHunks("the dark mode", "the light mode"); + const plan = decorationPlan(h.start, h.replacement, "the dark mode".slice(h.start, h.end)); + expect(plan.insertions.length).toBeGreaterThan(0); +}); + describe("renderPlain", () => { test("renderPlain renders current buffer as plain markdown (no marks)", () => { const html = renderPlain("# Title\n\nhello");