diff --git a/media/preview.ts b/media/preview.ts index 4a3ad35..ee636b3 100644 --- a/media/preview.ts +++ b/media/preview.ts @@ -27,6 +27,7 @@ const summary = document.getElementById("cw-summary")!; const legend = document.getElementById("cw-legend")!; const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | 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. annotationsEl?.addEventListener("change", () => { @@ -38,6 +39,13 @@ pinEl?.addEventListener("click", () => { 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). body.addEventListener("click", (e) => { const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); diff --git a/package.json b/package.json index 716d3fa..2a4f50f 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,11 @@ "command": "cowriting.showTrackChangesPreview", "title": "Cowriting: Open Review Preview", "category": "Cowriting" + }, + { + "command": "cowriting.editDocument", + "title": "Ask Claude to Edit Document", + "category": "Cowriting" } ], "menus": { @@ -101,6 +106,10 @@ { "command": "cowriting.pinDiffBaseline", "when": "editorLangId == markdown" + }, + { + "command": "cowriting.editDocument", + "when": "editorLangId == markdown" } ], "editor/context": [ diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index bb44737..3a5a6ce 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -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 →
SRC
for client-side rendering; all // other fences fall through to markdown-it's default (escaped
).
diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts
index 43f7be1..20112c8 100644
--- a/src/trackChangesPreview.ts
+++ b/src/trackChangesPreview.ts
@@ -13,7 +13,14 @@ import * as vscode from "vscode";
 import type { DiffViewController } from "./diffViewController";
 import type { AttributionController } from "./attributionController";
 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;
+/** 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;
@@ -27,7 +34,9 @@ type ToolbarMsg =
   | { type: "setMode"; mode: "on" | "off" }
   | { type: "accept"; 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 {
   private readonly disposables: vscode.Disposable[] = [];
@@ -38,6 +47,14 @@ export class TrackChangesPreviewController implements vscode.Disposable {
   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) => {
+    const { runEditTurn } = await import("./liveTurn");
+    return runEditTurn(instruction, text);
+  };
 
   constructor(
     private readonly diffView: DiffViewController,
@@ -49,6 +66,16 @@ export class TrackChangesPreviewController implements vscode.Disposable {
       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 }) => {
@@ -136,9 +163,78 @@ export class TrackChangesPreviewController implements vscode.Disposable {
     } 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 {
+    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 {
+    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;
@@ -257,6 +353,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
   
+ Review @@ -280,6 +377,10 @@ export class TrackChangesPreviewController implements vscode.Disposable { 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); } diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index cc07ea2..573bc15 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -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"); + }); }); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index e995f56..7389fa6 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -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