/** * EditorProposalController — F12/#64 editor surface (spec coauthoring-inline-editor-diff * §3.5). Reverses INV-32 for pending proposals: on `onDidChangeProposals` it * (1) optimistically applies any not-yet-applied proposal into the active editor's * buffer (ProposalController.optimisticApply, INV-48), (2) decorates each applied * proposal — insertion tint over the proposed text + a non-editable struck-red hint * 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). 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"; import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; import type { CoeditingRegistry } from "./coeditingRegistry"; export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { private readonly disposables: vscode.Disposable[] = []; 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" | "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; /** Pending debounce timers — one per URI — coalesce rapid-fire propose events * (e.g. runEditAndPropose's N sequential propose() calls) into a single * optimistic-apply pass that runs after ALL proposals are created. Without this, * each propose() fires onDidChangeProposals synchronously and the controller's * 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, private readonly diffView: DiffViewController, private readonly attribution: AttributionController, private readonly registry: CoeditingRegistry, ) { this.disposables.push( 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)), // Refresh committed decorations when the baseline changes (establish / pin / head-refresh). 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); }), // INV-10: re-render (and refresh CodeLens) on every gate change — exit clears // decorations/CodeLens via renderEditor's own gate check, re-entry redraws. this.registry.onDidChange(({ uri }) => { const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); if (ed) this.renderEditor(ed); this.lensEmitter.fire(); }), // 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)), ); } /** Debounce-schedule an optimistic-apply pass for the given URI. Multiple rapid * onDidChangeProposals events (from a single runEditAndPropose batch) collapse * into one pass that runs after the batch completes. */ private scheduleApply(uri: string): void { const prev = this.pendingApply.get(uri); if (prev !== undefined) clearTimeout(prev); this.pendingApply.set( uri, setTimeout(() => { this.pendingApply.delete(uri); void this.onProposalsChanged(uri); }, 0), ); } /** 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); if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return; // INV-10: a doc that exited coediting between the schedule and this tick gets // no optimistic-apply/decorations/CodeLens. if (!this.registry.isCoediting(doc.uri)) return; const key = this.proposals.keyFor(doc); for (const v of this.proposals.listProposals(doc)) { if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) { await this.proposals.optimisticApply(doc, v.id); // fires onDidChangeProposals again; guarded by isApplied } } const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); if (ed) this.renderEditor(ed); this.lensEmitter.fire(); } /** Decorate the editor for every applied proposal on its document (INV-52). */ 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], []); for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []); }; if (doc.languageId !== "markdown") return clearAll(); // INV-10: a non-entered doc gets no decorations at all. if (!this.registry.isCoediting(doc.uri)) return clearAll(); const key = this.proposals.keyFor(doc); const ins: Record<"human" | "claude", vscode.Range[]> = { 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; const plan = decorationPlan(v.anchorStart, v.original, v.replacement); 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" } }, }); } } // 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]); } /** * 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". * 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" | "none", 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. // 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. 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))); } // 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[delAuthor].push({ range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)), renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } }, }); } } } /** * CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above * each applied block, plus — when ≥2 proposals are pending on the doc — a * top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the * batch commands (no QuickPick detour for the common "review is done" case). */ provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { if (document.languageId !== "markdown") return []; if (!this.registry.isCoediting(document.uri)) return []; const key = this.proposals.keyFor(document); const applied = this.proposals .listProposals(document) .filter((v) => v.anchorStart !== null && this.proposals.isApplied(key, v.id)); const lenses: vscode.CodeLens[] = []; if (applied.length >= 2) { const top = new vscode.Range(0, 0, 0, 0); lenses.push( new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }), new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }), ); } for (const v of applied) { const pos = document.positionAt(v.anchorStart!); const line = new vscode.Range(pos.line, 0, pos.line, 0); lenses.push( new vscode.CodeLens(line, { title: "✓ Keep", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), new vscode.CodeLens(line, { title: "✗ Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), ); } return lenses; } /** The dropdown: this-proposal vs all-proposals, then dispatch. */ private async menu(kind: "accept" | "reject", id?: string): Promise { const doc = vscode.window.activeTextEditor?.document; if (!doc || !id) return; const key = this.proposals.keyFor(doc); const verb = kind === "accept" ? "Accept" : "Reject"; const pick = await vscode.window.showQuickPick( [`${verb} this proposal`, `${verb} ALL proposals`], { placeHolder: `${verb} Claude's proposal` }, ); if (!pick) return; const all = pick.includes("ALL"); if (kind === "accept") { if (all) await this.proposals.acceptAllProposals(doc); else await this.proposals.finalizeInPlace(key, id); } else { if (all) await this.proposals.rejectAll(doc); else await this.proposals.revertInPlace(key, id); } } 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(); } }