diff --git a/src/proposalController.ts b/src/proposalController.ts new file mode 100644 index 0000000..9295080 --- /dev/null +++ b/src/proposalController.ts @@ -0,0 +1,332 @@ +/** + * 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"; + +/** 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; + 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, + ) { + // 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" && document.uri.fsPath.startsWith(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; + 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 { + 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 { + 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 }, + }, + ]); + 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] 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), + 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(); + } +}