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
+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.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 { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
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
// (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