/** * TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one * sealed webview panel per markdown document, beside the source editor. On open / * debounced edit / F6 baseline-epoch change it reads the baseline (from the * reused DiffViewController) + the live buffer, runs the pure render engine, and * posts the HTML. Pure read-only: never mutates the document, sidecar, or * baseline (INV-20). The webview is sealed: local assets only, strict CSP, * per-load nonce, no network (INV-21). */ import * as path from "node:path"; import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; import type { ProposalController } from "./proposalController"; import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel"; import { buildFingerprint } from "./anchorer"; import { isAuthorable } from "./workspacePath"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; import type { LiveProgressUi } from "./liveProgressUi"; /** * F11: a host edit turn (selection/document text + instruction → rewrite). * Injectable for tests. #60: accepts optional turn options (onProgress/signal); * the arg is optional so existing test stubs that ignore it stay valid. */ type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; /** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */ type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" }; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; /** * Inbound webview→host messages (intent only — the sealed webview never mutates, * INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11 * adds the toolbar intents (pin baseline / ask Claude). */ type ToolbarMsg = | { type: "setMode"; mode: "on" | "off" } | { type: "accept"; proposalId: string } | { type: "reject"; proposalId: string } | { type: "pinBaseline" } | { type: "askClaude"; scope: "document" } | { type: "askClaude"; scope: "selection"; start: number; end: number } | { type: "acceptAll" }; export class TrackChangesPreviewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly panels = new Map(); private readonly lastModel = new Map(); private readonly debounces = new Map(); /** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */ private readonly mode = new Map(); /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */ private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); /** * F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and * never bundled). Injectable so host E2E can stub it (no LLM in CI). */ private editTurn: EditTurn = async (instruction, text, opts) => { const { runEditTurn } = await import("./liveTurn"); return runEditTurn(instruction, text, opts); }; /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; private nextTurnSeq(): number { return ++this.turnSeq; } constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, private readonly proposals: ProposalController, private readonly liveProgressUi: LiveProgressUi, ) { this.disposables.push( // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; // the palette / keybinding pass nothing → fall back to the active editor. // #41: the explorer/tab right-click also passes the clicked Uri, which may // not be an open document yet — open it so we preview the clicked file, not // whatever happens to be the active editor. vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => { if (uri) { const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()); this.show(open ?? (await vscode.workspace.openTextDocument(uri))); return; } this.show(vscode.window.activeTextEditor?.document); }), // F11: document-scoped Ask-Claude (also reused by #42's reach gateways). // Edits a markdown doc; the rewrite is diffed into F4 proposals. // #42 (INV-38): the editor/title/context (tab) entry passes the clicked // tab's resource Uri — target THAT document, opening it if it isn't already // an open buffer (mirrors showTrackChangesPreview's #41 resolution); the // palette / keybinding / editor/context pass nothing → the active editor. vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { const doc = uri ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? (await vscode.workspace.openTextDocument(uri)) : vscode.window.activeTextEditor?.document; if (!doc || !this.isMarkdown(doc)) { void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); return; } void this.askClaude(doc, { kind: "document" }); }), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), this.proposals.onDidChangeProposals(({ uri }) => { this.refreshByUri(uri); this.updateStatus(uri); }), this.statusItem, ); this.statusItem.command = "cowriting.showTrackChangesPreview"; } private isMarkdown(document: vscode.TextDocument): boolean { return document.languageId === "markdown"; } /** Open or reveal the preview for a markdown document (PUC-1). */ show(document: vscode.TextDocument | undefined): void { if (!document || !this.isMarkdown(document)) { void vscode.window.showWarningMessage( "Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).", ); return; } const key = document.uri.toString(); const existing = this.panels.get(key); if (existing) { existing.reveal(vscode.ViewColumn.Beside); this.refresh(document); return; } const name = path.basename(document.uri.path) || "untitled"; const panel = vscode.window.createWebviewPanel( VIEW_TYPE, `Review: ${name}`, { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, retainContextWhenHidden: false, localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")], }, ); panel.webview.html = this.shellHtml(panel.webview); panel.onDidDispose( () => { this.panels.delete(key); this.lastModel.delete(key); this.mode.delete(key); // A panel is gone: re-show the off-panel indicator if proposals remain. this.updateStatus(key); }, null, this.disposables, ); // F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions, // and (F11) the toolbar intents (pin baseline / ask Claude) back to the host. panel.webview.onDidReceiveMessage( (m: ToolbarMsg) => this.handleWebviewMessage(document, m), null, this.disposables, ); this.panels.set(key, panel); // A panel is now open for this doc — the off-panel indicator is redundant. this.hideStatus(); this.refresh(document); } /** * Route an inbound webview intent through the existing seams (INV-35): the * annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures * (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the * active editor — the preview knows its bound doc (§6.7). */ private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void { const key = document.uri.toString(); if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) { this.mode.set(key, m.mode); this.refresh(document); } else if (m?.type === "accept" && m.proposalId) { void this.proposals .acceptById(this.proposals.keyFor(document), m.proposalId) .then(() => this.refresh(document)); } else if (m?.type === "reject" && m.proposalId) { this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId); this.refresh(document); } else if (m?.type === "pinBaseline") { // F6 baseline store re-render arrives via the onDidChangeBaseline subscription. this.diffView.pin(document); } else if (m?.type === "askClaude") { const target: EditTarget = m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" }; void this.askClaude(document, target); } else if (m?.type === "acceptAll") { // #46 (INV-42): batch-accept every pending proposal on this doc, then report. void this.acceptAll(document); } } /** * #46 (INV-42): apply every pending proposal on the document through the F4 * accept seam (orphan-skip) and report the applied-vs-skipped tally. No * confirmation dialog — VS Code undo restores (parity with single accept). * Public so the `cowriting.acceptAllProposals` command can reach it for the * active doc (not only the webview button). */ async acceptAll(document: vscode.TextDocument): Promise { const { applied, skipped } = await this.proposals.acceptAllProposals(document); this.refresh(document); if (applied === 0 && skipped === 0) return; const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; void vscode.window.showInformationMessage( `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, ); } /** * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret * surface out of the sealed webview, INV-8/35), run the edit turn, and surface * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. */ private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { const instruction = await vscode.window.showInputBox({ prompt: target.kind === "document" ? "What should Claude do with the document?" : "What should Claude do with the selection?", placeHolder: "e.g. tighten the prose", }); if (!instruction) return; try { const ids = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true, }, async (progress, token) => { const ui = this.liveProgressUi.begin(instruction, progress, token); try { return await this.runEditAndPropose(document, target, instruction, { onProgress: ui.onProgress, signal: ui.signal, }); } catch (err) { // #60 (INV-47): a user cancel proposes nothing (the benign empty path). if (token.isCancellationRequested) return [] as string[]; throw err; } }, ); if (ids.length === 0) { void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes."); } else { void vscode.window.showInformationMessage( `Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`, ); } } catch (err) { const message = err instanceof Error ? err.message : String(err); void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`); } } /** * F11/F12 (INV-35/39): run one host edit turn and record the result as F4 * proposal(s) — a SELECTION yields one single-range proposal over the resolved * block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per * changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged * `granularity:"block"` so accept reconciles attribution per word (INV-40). * Never mutates the document (INV-10). Returns the created proposal ids. */ async runEditAndPropose( document: vscode.TextDocument, target: EditTarget, instruction: string, opts?: RunEditTurnOptions, ): Promise { const full = document.getText(); // One turnId per gesture — the document case's N hunk-proposals all share it, // so a single rewrite groups as one agent turn (parity with editSelection). const turnId = `turn-${this.nextTurnSeq()}`; const provenance = (turn: EditTurnResult) => ({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } }); if (target.kind === "range") { const selected = full.slice(target.start, target.end); const turn = await this.editTurn(instruction, selected, opts); if (turn.replacement === "" || turn.replacement === selected) return []; const fp = buildFingerprint(full, { start: target.start, end: target.end }); const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction }); return id ? [id] : []; } const turn = await this.editTurn(instruction, full, opts); const ids: string[] = []; // #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK // granularity — one proposal per changed block (the unit a human reviews) — // not per word. Each is tagged `granularity:"block"` so accept reconciles // attribution per word inside the block (INV-40). for (const h of diffToBlockHunks(full, turn.replacement)) { const fp = buildFingerprint(full, { start: h.start, end: h.end }); const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { turnId, instruction, granularity: "block", }); if (id) ids.push(id); } return ids; } private onEdit(document: vscode.TextDocument): void { const key = document.uri.toString(); if (!this.panels.has(key)) return; const pending = this.debounces.get(key); if (pending) clearTimeout(pending); this.debounces.set( key, setTimeout(() => { this.debounces.delete(key); this.refresh(document); }, DEBOUNCE_MS), ); } private refreshByUri(uri: string): void { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (doc) this.refresh(doc); } /** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */ refresh(document: vscode.TextDocument): void { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; const mode = this.mode.get(key) ?? "on"; const current = document.getText(); const baseline = this.diffView.getBaseline(key); const baselineText = baseline?.text ?? current; // no baseline → no change-marks const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); // F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed). const authorable = isAuthorable(document.uri.scheme); if (mode === "off") { void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable }); return; } const spans = this.attribution.spansFor(document); const proposals = this.proposals.listProposals(document); const summary = { added: ops.filter((o) => o.kind === "added").length, removed: ops.filter((o) => o.kind === "removed").length, proposals: proposals.length, }; void panel.webview.postMessage({ type: "render", mode, html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }), epoch: this.epochLabel(baseline), summary, authorable, }); } /** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */ private updateStatus(uri: string): void { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (!doc) { this.hideStatus(); return; } const n = this.proposals.listProposals(doc).length; if (n === 0 || this.panels.has(uri)) { this.hideStatus(); return; } this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`; this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals"; this.statusItem.show(); } /** * Hide the off-panel indicator AND clear its text, so the `statusText()` seam * is honest: a hidden indicator reports `undefined` (not its stale last value). */ private hideStatus(): void { this.statusItem.text = ""; this.statusItem.hide(); } private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { if (!baseline) return "opened (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); switch (baseline.reason) { case "machine-landing": return `Claude landed ${time}`; case "pinned": return `pinned ${time}`; default: return `opened ${time}`; } } private shellHtml(webview: vscode.Webview): string { const nonce = randomBytes(16).toString("base64"); const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"), ); const styleUri = webview.asWebviewUri( vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"), ); // Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for // mermaid's dynamically injected