Files
vscode-cowriting-plugin/src/trackChangesPreview.ts
T
Ben Stull 0d1a5635cb feat(f11): SLICE-3 — Edit Document button + per-hunk proposal path (#43, INV-37)
A whole-document Ask-Claude rewrite is diffed into hunks and surfaced as N
independent F4 proposals (one per changed hunk) — reusing the F4 single-range
model N times, no new model. Per spec §6.4/§7.2 SLICE-3.

- trackChangesModel: pure `diffToHunks(currentText, rewrittenText)` →
  EditHunk[] (vscode-free, deterministic; diffWordsWithSpace, coalescing
  adjacent add/remove runs; offsets index currentText).
- trackChangesPreview: `runEditAndPropose(document, target, instruction)` — the
  shared host routine (selection → one single-range propose; document → diff →
  one propose per hunk; never mutates the doc, INV-10); `askClaude` UI wrapper
  (host showInputBox keeps LLM/secrets out of the sealed webview, INV-8/35);
  injectable `editTurn` + `setEditTurnForTest` seam (no LLM in CI); the
  `askClaude` inbound message branch; `cowriting.editDocument` command for #42
  reuse.
- package.json: register cowriting.editDocument, palette-guarded on markdown.
- webview: ✦ Ask Claude to Edit Document button → { askClaude, scope:"document" }.
- unit: diffToHunks fixtures (zero/one/multi-hunk, wholesale, determinism).
- host E2E: stubbed multi-hunk rewrite → N matching proposals, doc untouched;
  editDocument command registered + markdown-guarded.

205 unit + 49 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:51:07 -07:00

421 lines
17 KiB
TypeScript

/**
* 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, diffToHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import type { EditTurnResult } from "./liveTurn";
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
/** 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 };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly panels = new Map<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
private readonly mode = new Map<string, "on" | "off">();
/** 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) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text);
};
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
) {
this.disposables.push(
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
this.show(vscode.window.activeTextEditor?.document),
),
// F11: document-scoped Ask-Claude (also reused by #42's gateway). Edits the
// active markdown doc; the rewrite is diffed into per-hunk F4 proposals.
vscode.commands.registerCommand("cowriting.editDocument", () => {
const doc = 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);
}
}
/**
* 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<void> {
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…" },
() => this.runEditAndPropose(document, target, instruction),
);
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 (INV-35/37): 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 `diffToHunks`'d into one single-range
* proposal per changed hunk (reusing the F4 single-range model N times, no new
* model). Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
): Promise<string[]> {
const full = document.getText();
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);
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), { instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full);
const ids: string[] = [];
for (const h of diffToHunks(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), { instruction });
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);
if (mode === "off") {
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) });
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),
epoch: this.epochLabel(baseline),
summary,
});
}
/** 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 <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<body>
<div id="cw-header">
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)">⌖ Pin baseline</button>
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)">✦ Ask Claude to Edit Document</button>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
/**
* F11 test seam: deliver an inbound webview message to the real routing, as if
* the sealed webview had posted it. Exercises message→seam wiring without a
* live webview DOM (which is manual-smoke only). No-op if no doc/panel.
*/
receiveMessage(uriString: string, m: ToolbarMsg): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
}
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn;
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
renderHtmlFor(uriString: string): string {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (!doc) return "";
const current = doc.getText();
const baseline = this.diffView.getBaseline(uriString);
return renderReview(
baseline?.text ?? current,
current,
this.attribution.spansFor(doc),
this.proposals.listProposals(doc),
);
}
/** F10: current annotations mode for a panel (default on). */
getMode(uriString: string): "on" | "off" {
return this.mode.get(uriString) ?? "on";
}
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
setMode(uriString: string, mode: "on" | "off"): void {
this.mode.set(uriString, mode);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc) this.refresh(doc);
}
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
statusText(): string | undefined {
return this.statusItem.text || undefined;
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}