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");