/** * ProposalController — the editor-facing layer for F4 (spec * coauthoring-propose-accept §6.2). Owns the proposal lifecycle: the propose * ingress (INV-10: NEVER mutates the document), persistence at propose time, * resolve-or-flag on load/external change (INV-11 — a proposal's anchor is * immutable for its life: no save-time re-fingerprint, unlike threads), * rendering (second Comments controller + amber pending-range decoration), * and the human-only accept/reject gestures (INV-12). Accept drives the seam * (AttributionController.applyAgentEdit, INV-9) so accepted text lands * Claude-attributed with zero new attribution code. */ import * as vscode from "vscode"; import { CoauthorStore } from "./store"; import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; import { resolve, shift, type OffsetRange } from "./anchorer"; import { addProposal, proposalBody, removeProposal } from "./proposalModel"; import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; import { isUnderRoot } from "./workspacePath"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { id: string; /** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */ pending: boolean; /** always false — proposals are decide-only, no reply input (INV-12). */ canReply: boolean; turnId?: string; range: { start: number; end: number }; } interface DocState { docPath: string; uri: vscode.Uri; artifact: Artifact; vsThreads: Map; /** proposal id -> live offset range (within-session optimization, INV-3). */ live: Map; /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */ unresolved: Set; } const PENDING_DECO: vscode.DecorationRenderOptions = { backgroundColor: "rgba(245, 158, 11, 0.18)", overviewRulerColor: "rgba(245, 158, 11, 0.8)", overviewRulerLane: vscode.OverviewRulerLane.Right, }; export class ProposalController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath private readonly pendingType = vscode.window.createTextEditorDecorationType(PENDING_DECO); private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89); constructor( private readonly store: CoauthorStore, private readonly attribution: AttributionController, private readonly rootDir: string, private readonly guard: VersionGuard, ) { // No commentingRangeProvider: humans never open proposal threads by hand — // proposals are born of machine turns only (INV-12 keeps decisions human). this.controller = vscode.comments.createCommentController("cowriting.proposals", "Claude Proposals"); this.disposables.push(this.controller, this.pendingType, this.statusItem); this.disposables.push( vscode.commands.registerCommand("cowriting.acceptProposal", (t: vscode.CommentThread) => this.acceptThread(t)), vscode.commands.registerCommand("cowriting.rejectProposal", (t: vscode.CommentThread) => this.rejectThread(t)), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), ); } private isTracked(document: vscode.TextDocument): boolean { return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir); } private docPathOf(uri: vscode.Uri): string { return vscode.workspace.asRelativePath(uri, false); } private ensureState(document: vscode.TextDocument): DocState { const docPath = this.docPathOf(document.uri); let state = this.docs.get(docPath); if (!state) { state = { docPath, uri: document.uri, artifact: this.store.load(docPath) ?? emptyArtifact(docPath), vsThreads: new Map(), live: new Map(), unresolved: new Set(), }; this.docs.set(docPath, state); } return state; } // ---- PUC-1: the propose ingress (INV-10) ----------------------------------------- /** * Record + render a pending proposal. The fingerprint is built by the CALLER * at gesture time (editSelection captures it BEFORE the turn, so mid-turn * edits can't skew the anchor). Never touches the document. */ async propose( document: vscode.TextDocument, fp: Fingerprint, replacement: string, author: Provenance, opts?: { turnId?: string; instruction?: string }, ): Promise { if (!this.isTracked(document)) return undefined; if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined; const docPath = this.docPathOf(document.uri); let proposalId: string | undefined; this.store.update(docPath, (a) => { proposalId = addProposal(a, fp, replacement, author, opts).proposalId; }); this.renderAll(document); return proposalId; } // ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ---------------------------------- /** Accept by proposal id (test-facing twin of the thread-menu gesture). */ async acceptById(docPath: string, proposalId: string): Promise { const hit = this.byId(docPath, proposalId); return hit ? this.accept(hit.state, hit.proposal) : false; } /** Reject by proposal id (test-facing twin of the thread-menu gesture). */ rejectById(docPath: string, proposalId: string): boolean { const hit = this.byId(docPath, proposalId); if (!hit) return false; this.reject(hit.state, hit.proposal); return true; } private async acceptThread(vsThread: vscode.CommentThread): Promise { const hit = this.byThread(vsThread); if (hit) await this.accept(hit.state, hit.proposal); } private rejectThread(vsThread: vscode.CommentThread): void { const hit = this.byThread(vsThread); if (hit) this.reject(hit.state, hit.proposal); } private async accept(state: DocState, proposal: Proposal): Promise { if (this.guard.isReadOnly(state.docPath)) return false; const document = this.openDoc(state); if (!document) return false; // INV-11: the fingerprint-guard — exact re-resolve at decision time. const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; const resolved = fp ? resolve(document.getText(), fp) : "orphaned"; if (resolved === "orphaned") { void vscode.window.showWarningMessage( "Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).", ); this.renderAll(document); return false; } const range = new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)); // No awaits between resolve and the seam call: document.version is current. const ok = await this.attribution.applyAgentEdit(document, range, proposal.replacement, proposal.author, { expectedVersion: document.version, turnId: proposal.turnId, }); if (!ok) { void vscode.window.showWarningMessage( "Cowriting: the editor rejected the accept — the proposal is still pending.", ); return false; } this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); this.renderAll(document); return true; } private reject(state: DocState, proposal: Proposal): void { if (this.guard.isReadOnly(state.docPath)) return; this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); const document = this.openDoc(state); if (document) this.renderAll(document); } // ---- PUC-4: load / external change / resolve-or-flag -------------------------------- /** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ renderAll(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; const docPath = this.docPathOf(document.uri); const state = this.ensureState(document); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); for (const vsThread of state.vsThreads.values()) vsThread.dispose(); state.vsThreads.clear(); state.live.clear(); state.unresolved.clear(); const text = document.getText(); for (const proposal of state.artifact.proposals) { const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; const resolved = fp ? resolve(text, fp) : "orphaned"; if (resolved === "orphaned") { const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; const off = document.offsetAt(new vscode.Position(line, 0)); this.renderProposal(document, state, proposal, { start: off, end: off }, false); } else { this.renderProposal(document, state, proposal, resolved, true); } } this.renderDecorations(document, state); this.renderStatus(state); } /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ handleExternalSidecarChange(uri: vscode.Uri): void { for (const state of this.docs.values()) { if (this.store.sidecarPath(state.docPath) === uri.fsPath) { const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); if (doc) this.renderAll(doc); } } } private onDidChange(e: vscode.TextDocumentChangeEvent): void { const state = this.docs.get(this.docPathOf(e.document.uri)); if (!state || state.live.size === 0) return; for (const change of e.contentChanges) { const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; for (const [id, range] of state.live) { const next = shift(range, edit); state.live.set(id, next); const vsThread = state.vsThreads.get(id); if (vsThread && !state.unresolved.has(id)) { vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end)); } } } // Live shift keeps the UI following; staleness is judged at decision time // (accept re-resolves, INV-11) and at the next renderAll. this.renderDecorations(e.document, state); } // ---- rendering ----------------------------------------------------------------------- private renderProposal( document: vscode.TextDocument, state: DocState, proposal: Proposal, offsets: OffsetRange, pending: boolean, ): void { const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); const vsThread = this.controller.createCommentThread(document.uri, range, [ { body: new vscode.MarkdownString(proposalBody(fp?.text ?? "", proposal)), mode: vscode.CommentMode.Preview, author: { name: proposal.author.id }, }, ]); // Proposals are decide-only (INV-12): ✓ accept / ✗ reject in the title // bar. Without this, VS Code renders its default "Reply…" input with no // submit command wired — a dead end. Discussion belongs in a regular // coauthoring thread; proposal discussion trails are deferred (spec §1.7). vsThread.canReply = false; vsThread.label = pending ? "Pending proposal" : "⚠ Stale proposal (target text changed or missing) — accept disabled"; vsThread.contextValue = pending ? "pending" : "unresolved"; vsThread.collapsibleState = pending ? vscode.CommentThreadCollapsibleState.Expanded : vscode.CommentThreadCollapsibleState.Collapsed; state.vsThreads.set(proposal.id, vsThread); state.live.set(proposal.id, offsets); if (!pending) state.unresolved.add(proposal.id); } private renderDecorations(document: vscode.TextDocument, state: DocState): void { const ranges: vscode.Range[] = []; for (const [id, off] of state.live) { if (state.unresolved.has(id)) continue; ranges.push(new vscode.Range(document.positionAt(off.start), document.positionAt(off.end))); } for (const editor of vscode.window.visibleTextEditors) { if (editor.document === document) editor.setDecorations(this.pendingType, ranges); } } private renderStatus(state: DocState): void { const n = state.unresolved.size; if (n === 0) { this.statusItem.hide(); return; } this.statusItem.text = `$(warning) ${n} stale proposal${n === 1 ? "" : "s"}`; this.statusItem.tooltip = "Cowriting: proposals whose target text changed or is missing — undo to restore, or reject"; this.statusItem.show(); } // ---- lookups ---------------------------------------------------------------------------- private openDoc(state: DocState): vscode.TextDocument | undefined { return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); } private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined { for (const state of this.docs.values()) { for (const [id, t] of state.vsThreads) { if (t === vsThread) { const proposal = state.artifact.proposals.find((p) => p.id === id); if (proposal) return { state, proposal }; } } } return undefined; } private byId(docPath: string, proposalId: string): { state: DocState; proposal: Proposal } | undefined { const state = this.docs.get(docPath); const proposal = state?.artifact.proposals.find((p) => p.id === proposalId); return state && proposal ? { state, proposal } : undefined; } // ---- test-facing surface ------------------------------------------------------------------ getRendered(docPath: string): RenderedProposal[] { const state = this.docs.get(docPath); if (!state) return []; const out: RenderedProposal[] = []; for (const [id, vsThread] of state.vsThreads) { const p = state.artifact.proposals.find((x) => x.id === id)!; const off = state.live.get(id)!; out.push({ id, pending: !state.unresolved.has(id), canReply: vsThread.canReply !== false, turnId: p.turnId, range: { start: off.start, end: off.end }, }); } return out; } getStaleCount(docPath: string): number { return this.docs.get(docPath)?.unresolved.size ?? 0; } dispose(): void { for (const d of this.disposables) d.dispose(); } }