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>
This commit is contained in:
Ben Stull
2026-06-12 13:51:07 -07:00
parent 1ef9451e89
commit 0d1a5635cb
6 changed files with 254 additions and 4 deletions
+8
View File
@@ -27,6 +27,7 @@ const summary = document.getElementById("cw-summary")!;
const legend = document.getElementById("cw-legend")!; const legend = document.getElementById("cw-legend")!;
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null; const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null;
// F10: the annotations on/off toggle. // F10: the annotations on/off toggle.
annotationsEl?.addEventListener("change", () => { annotationsEl?.addEventListener("change", () => {
@@ -38,6 +39,13 @@ pinEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "pinBaseline" }); vscodeApi.postMessage({ type: "pinBaseline" });
}); });
// F11 (SLICE-3): Ask Claude to Edit Document — post the document-scope intent; the
// host prompts for the instruction + runs the turn (INV-8). SLICE-4 makes this
// button adaptive (Edit Selection when text is selected in the preview).
askEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
});
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam). // F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
body.addEventListener("click", (e) => { body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button"); const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
+9
View File
@@ -78,6 +78,11 @@
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.showTrackChangesPreview",
"title": "Cowriting: Open Review Preview", "title": "Cowriting: Open Review Preview",
"category": "Cowriting" "category": "Cowriting"
},
{
"command": "cowriting.editDocument",
"title": "Ask Claude to Edit Document",
"category": "Cowriting"
} }
], ],
"menus": { "menus": {
@@ -101,6 +106,10 @@
{ {
"command": "cowriting.pinDiffBaseline", "command": "cowriting.pinDiffBaseline",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
},
{
"command": "cowriting.editDocument",
"when": "editorLangId == markdown"
} }
], ],
"editor/context": [ "editor/context": [
+45 -1
View File
@@ -9,7 +9,7 @@
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview. * DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
*/ */
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { diffArrays, diffWords } from "diff"; import { diffArrays, diffWords, diffWordsWithSpace } from "diff";
import { diffMermaid } from "./mermaidDiff"; import { diffMermaid } from "./mermaidDiff";
export type BlockType = "prose" | "code" | "mermaid"; export type BlockType = "prose" | "code" | "mermaid";
@@ -193,6 +193,50 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
return ops; return ops;
} }
/** A contiguous changed region of `currentText` and its replacement (F11). */
export interface EditHunk {
/** char offset of the hunk's first changed char in currentText. */
start: number;
/** char offset one past the hunk's last changed char (start==end → a pure insertion). */
end: number;
/** the text that replaces currentText.slice(start, end). */
replacement: string;
}
/**
* F11 (INV-37, §6.4): diff a whole-document rewrite into per-hunk replacement
* ranges — each becomes one independent F4 single-range proposal (so a document
* edit surfaces as N independently ✓/✗-able blue blocks, no new model). Pure,
* vscode-free, deterministic. Offsets index into `currentText`: removed +
* unchanged parts reconstruct it exactly, so advancing on those yields true
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
* unchanged part flushes the open hunk.
*/
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
const hunks: EditHunk[] = [];
let offset = 0;
let open: EditHunk | null = null;
const flush = () => {
if (open) hunks.push(open);
open = null;
};
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
if (part.added) {
open ??= { start: offset, end: offset, replacement: "" };
open.replacement += part.value;
} else if (part.removed) {
open ??= { start: offset, end: offset, replacement: "" };
offset += part.value.length;
open.end = offset;
} else {
flush();
offset += part.value.length;
}
}
flush();
return hunks;
}
const md = new MarkdownIt({ html: true, linkify: false, breaks: false }); const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all // mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>). // other fences fall through to markdown-it's default (escaped <pre><code>).
+103 -2
View File
@@ -13,7 +13,14 @@ import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController"; import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController"; import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController"; import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel"; 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 VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150; const DEBOUNCE_MS = 150;
@@ -27,7 +34,9 @@ type ToolbarMsg =
| { type: "setMode"; mode: "on" | "off" } | { type: "setMode"; mode: "on" | "off" }
| { type: "accept"; proposalId: string } | { type: "accept"; proposalId: string }
| { type: "reject"; proposalId: string } | { type: "reject"; proposalId: string }
| { type: "pinBaseline" }; | { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number };
export class TrackChangesPreviewController implements vscode.Disposable { export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
@@ -38,6 +47,14 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly mode = new Map<string, "on" | "off">(); private readonly mode = new Map<string, "on" | "off">();
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */ /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); 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( constructor(
private readonly diffView: DiffViewController, private readonly diffView: DiffViewController,
@@ -49,6 +66,16 @@ export class TrackChangesPreviewController implements vscode.Disposable {
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
this.show(vscode.window.activeTextEditor?.document), 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)), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
this.proposals.onDidChangeProposals(({ uri }) => { this.proposals.onDidChangeProposals(({ uri }) => {
@@ -136,9 +163,78 @@ export class TrackChangesPreviewController implements vscode.Disposable {
} else if (m?.type === "pinBaseline") { } else if (m?.type === "pinBaseline") {
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription. // F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
this.diffView.pin(document); 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 { private onEdit(document: vscode.TextDocument): void {
const key = document.uri.toString(); const key = document.uri.toString();
if (!this.panels.has(key)) return; if (!this.panels.has(key)) return;
@@ -257,6 +353,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
<div id="cw-header"> <div id="cw-header">
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label> <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-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-epoch">Review</span>
<span id="cw-summary"></span> <span id="cw-summary"></span>
<span id="cw-legend"></span> <span id="cw-legend"></span>
@@ -280,6 +377,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m); 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 { getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString); return this.lastModel.get(uriString);
} }
+46
View File
@@ -66,4 +66,50 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
}); });
// SLICE-3: Edit Document → a whole-document rewrite diffed into N F4 proposals.
test("runEditAndPropose(document) with a stubbed multi-hunk rewrite → N proposals matching the hunks (PUC-4, INV-37)", async () => {
const { doc, key } = await freshDoc(
"docs/f11doc.md",
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
ctl.setEditTurnForTest(async () => ({
replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
model: "sonnet",
sessionId: "e2e-f11-doc",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
await settle();
assert.strictEqual(ids.length, 2, "two changed words → two independent proposals");
const views = api.proposalController.listProposals(doc);
assert.ok(
ids.every((id) => views.some((v) => v.id === id)),
"every returned proposal id is a live pending proposal",
);
const replacements = views.map((v) => v.replacement);
assert.ok(replacements.includes("RED") && replacements.includes("CAT"), "proposals carry the per-hunk replacements");
// INV-10: proposing never mutates the document.
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
void key;
});
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.editDocument",
);
assert.ok(entry, "editDocument has a commandPalette entry");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
}); });
+43 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, test, expect } from "vitest"; import { describe, it, test, expect } from "vitest";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel"; import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => { describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => { it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -320,6 +320,48 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
}); });
}); });
// F11 SLICE-3 (INV-37, §6.4): a whole-document rewrite is diffed into per-hunk
// proposal ranges — each an independent F4 single-range proposal. Pure,
// vscode-free, deterministic; offsets index into currentText.
describe("F11 diffToHunks (INV-37)", () => {
test("an identical rewrite → zero hunks", () => {
expect(diffToHunks("The same text.\n", "The same text.\n")).toEqual([]);
});
test("a single changed word → one hunk over exactly that word", () => {
const current = "The quick brown fox.";
const hunks = diffToHunks(current, "The quick red fox.");
expect(hunks).toHaveLength(1);
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("brown");
expect(hunks[0].replacement).toBe("red");
});
test("two disjoint changes → two hunks with correct ranges + replacements", () => {
const current = "one two three four";
const hunks = diffToHunks(current, "one TWO three FOUR");
expect(hunks).toHaveLength(2);
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("two");
expect(hunks[0].replacement).toBe("TWO");
expect(current.slice(hunks[1].start, hunks[1].end)).toBe("four");
expect(hunks[1].replacement).toBe("FOUR");
// disjoint + ordered
expect(hunks[0].end).toBeLessThanOrEqual(hunks[1].start);
});
test("a wholesale replacement (nothing in common) → one full-range hunk", () => {
const current = "alpha";
const hunks = diffToHunks(current, "omega");
expect(hunks).toHaveLength(1);
expect(hunks[0]).toEqual({ start: 0, end: current.length, replacement: "omega" });
});
test("is deterministic — same inputs → identical hunks", () => {
const a = diffToHunks("a b c d", "a B c D");
const b = diffToHunks("a b c d", "a B c D");
expect(a).toEqual(b);
});
});
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end // F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
// (source char offsets from BlockWithRange) on every LIVE-source rendered block, // (source char offsets from BlockWithRange) on every LIVE-source rendered block,
// in BOTH modes. The webview's selection→source mapping walks the DOM to the // in BOTH modes. The webview's selection→source mapping walks the DOM to the