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
+45 -1
View File
@@ -9,7 +9,7 @@
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
*/
import MarkdownIt from "markdown-it";
import { diffArrays, diffWords } from "diff";
import { diffArrays, diffWords, diffWordsWithSpace } from "diff";
import { diffMermaid } from "./mermaidDiff";
export type BlockType = "prose" | "code" | "mermaid";
@@ -193,6 +193,50 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
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 });
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>).