diff --git a/src/editFlow.ts b/src/editFlow.ts new file mode 100644 index 0000000..a174fdd --- /dev/null +++ b/src/editFlow.ts @@ -0,0 +1,195 @@ +/** + * EditFlow — the host edit-turn flow (F11/F12), extracted from the review + * webview controller ahead of its sunset (native-surfaces migration, spec + * §6.10). Owns the `cowriting.editDocument` command, the instruction prompt + + * progress-wrapped "ask Claude" gesture (INV-10 gate, INV-8 host-only LLM + * surface), and the low-level turn→proposal(s) cut (`runEditAndPropose`, + * INV-39/40). Never mutates the document directly — every turn lands as one or + * more F4 proposals via `ProposalController.propose` (INV-10). Reachable from + * both the review webview (delegates here) and, from Task 6 on, the thread + * controller — vscode-API-only, no webview state. + */ +import * as vscode from "vscode"; +import type { ProposalController } from "./proposalController"; +import type { AttributionController } from "./attributionController"; +import type { LiveProgressUi } from "./liveProgressUi"; +import type { CoeditingRegistry } from "./coeditingRegistry"; +import { diffToBlockHunks } from "./trackChangesModel"; +import { buildFingerprint } from "./anchorer"; +import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; +import { promptEditInstruction } from "./editInstructionInput"; + +/** The exact warning copy for every gated edit gesture (INV-10). */ +const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; + +/** + * F11: a host edit turn (selection/document text + instruction → rewrite). + * Injectable for tests. #60: accepts optional turn options (onProgress/signal); + * the arg is optional so existing test stubs that ignore it stay valid. + */ +type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; +/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */ +export type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" }; + +export class EditFlow implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + /** + * 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, opts) => { + const { runEditTurn } = await import("./liveTurn"); + return runEditTurn(instruction, text, opts); + }; + /** + * The instruction prompt (the multi-line split-below webview box) for BOTH the + * selection and document cases. A field so host E2E can stub it — the webview + * DOM can't run in CI (mirrors `editTurn`). Also used by the editor's + * `cowriting.editSelection` command (extension.ts). + */ + askEditInstruction: (header: string) => Promise = promptEditInstruction; + /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ + private turnSeq = 0; + private nextTurnSeq(): number { + return ++this.turnSeq; + } + + constructor( + private readonly proposals: ProposalController, + private readonly attribution: AttributionController, + private readonly liveProgressUi: LiveProgressUi, + private readonly registry: CoeditingRegistry, + ) { + this.disposables.push( + // F11: document-scoped Ask-Claude (also reused by #42's reach gateways). + // Edits a markdown doc; the rewrite is diffed into F4 proposals. + // #42 (INV-38): the editor/title/context (tab) entry passes the clicked + // tab's resource Uri — target THAT document, opening it if it isn't already + // an open buffer (mirrors showTrackChangesPreview's #41 resolution); the + // palette / keybinding / editor/context pass nothing → the active editor. + vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { + const doc = uri + ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? + (await vscode.workspace.openTextDocument(uri)) + : vscode.window.activeTextEditor?.document; + if (!doc || doc.languageId !== "markdown") { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); + return; + } + void this.askClaude(doc, { kind: "document" }); + }), + ); + } + + /** + * 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`. Called + * both by the `cowriting.editDocument` command above and (via delegation) by + * the review webview's `askClaude` toolbar intent (either scope). + */ + async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { + // INV-10: the choke point for BOTH the editDocument command and the webview's + // askClaude toolbar intent (either scope) — a non-entered doc gets the warning, + // not a turn. + if (!this.registry.isCoediting(document.uri)) { + void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); + return; + } + // Both scopes use the same multi-line split-below webview box; only the header + // (and the downstream proposal logic) differs. For a selection the document + // above keeps the selection highlighted while the box is open. + const header = + target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; + const instruction = await this.askEditInstruction(header); + if (!instruction) return; + try { + const ids = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Cowriting: asking Claude…", + cancellable: true, + }, + async (progress, token) => { + const ui = this.liveProgressUi.begin(instruction, progress, token); + try { + return await this.runEditAndPropose(document, target, instruction, { + onProgress: ui.onProgress, + signal: ui.signal, + }); + } catch (err) { + // #60 (INV-47): a user cancel proposes nothing (the benign empty path). + if (token.isCancellationRequested) return [] as string[]; + throw err; + } + }, + ); + 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/F12 (INV-35/39): 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 `diffToBlockHunks`'d into one proposal per + * changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged + * `granularity:"block"` so accept reconciles attribution per word (INV-40). + * Never mutates the document (INV-10). Returns the created proposal ids. + */ + async runEditAndPropose( + document: vscode.TextDocument, + target: EditTarget, + instruction: string, + opts?: RunEditTurnOptions, + ): Promise { + const full = document.getText(); + // One turnId per gesture — the document case's N hunk-proposals all share it, + // so a single rewrite groups as one agent turn (parity with editSelection). + const turnId = `turn-${this.nextTurnSeq()}`; + 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, opts); + 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), { turnId, instruction }); + return id ? [id] : []; + } + const turn = await this.editTurn(instruction, full, opts); + const ids: string[] = []; + // #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK + // granularity — one proposal per changed block (the unit a human reviews) — + // not per word. Each is tagged `granularity:"block"` so accept reconciles + // attribution per word inside the block (INV-40). + for (const h of diffToBlockHunks(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), { + turnId, + instruction, + granularity: "block", + }); + if (id) ids.push(id); + } + return ids; + } + + // ---- test seam (§6.4) ---- + /** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */ + setEditTurnForTest(fn: EditTurn): void { + this.editTurn = fn; + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} diff --git a/src/extension.ts b/src/extension.ts index cf978a6..4ecba83 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,6 +12,7 @@ import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { GitBaselineAdapter } from "./gitBaseline"; import { TrackChangesPreviewController } from "./trackChangesPreview"; +import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; @@ -29,6 +30,7 @@ export interface CowritingApi { versionGuard: VersionGuard; diffViewController: DiffViewController; trackChangesPreviewController: TrackChangesPreviewController; + editFlow: EditFlow; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; editorProposalController: EditorProposalController; @@ -164,17 +166,25 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); context.subscriptions.push(proposalController); + // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the + // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped + // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut + // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which + // delegates to it) and reachable from the thread controller too (Task 6). --- + const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry); + context.subscriptions.push(editFlow); + // --- F7/F10: the review preview is the single interactive review surface --- // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, - // INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes - // F4 accept/reject from the webview ✓/✗). + // INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes + // F4 accept/reject from the webview ✓/✗), and editFlow (delegates the webview's + // askClaude toolbar intent to it). const trackChangesPreviewController = new TrackChangesPreviewController( diffViewController, context.extensionUri, attributionController, proposalController, - liveProgressUi, - coeditingRegistry, + editFlow, ); context.subscriptions.push(trackChangesPreviewController); @@ -189,8 +199,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.subscriptions.push(editorProposalController); // #46 (INV-42): accept every pending proposal on the active doc in one gesture - // (also reachable from the preview toolbar's "Accept all" button). Reuses the - // batched F4 seam + reports applied-vs-skipped. + // (also reachable from the preview toolbar's "Accept all" button, which routes + // through its own webview-facing acceptAll). Reuses the batched F4 seam + + // reports applied-vs-skipped (native-surfaces migration: routes directly to + // ProposalController, no preview-controller indirection). context.subscriptions.push( vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; @@ -198,11 +210,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals."); return; } - await trackChangesPreviewController.acceptAll(doc); + const { applied, skipped } = await proposalController.acceptAllProposals(doc); + if (applied === 0 && skipped === 0) return; + const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; + void vscode.window.showInformationMessage( + `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, + ); }), ); - // #64 (INV-53): reject every pending proposal on the active doc in one gesture. + // #64 (INV-53): reject every pending proposal on the active doc in one gesture + // (native-surfaces migration: routes directly to ProposalController). context.subscriptions.push( vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; @@ -210,7 +228,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals."); return; } - await trackChangesPreviewController.rejectAll(doc); + const { reverted } = await proposalController.rejectAll(doc); + if (reverted > 0) { + void vscode.window.showInformationMessage( + `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, + ); + } }), ); @@ -319,12 +342,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef end: document.offsetAt(selection.end), }); // The instruction prompt is the multi-line split-below webview box (shared - // with the document case, via the preview controller); the document above - // keeps the selection highlighted while it's open. selectedText/fp were - // captured above, so moving focus to the box doesn't affect what we edit. - const instruction = await trackChangesPreviewController.askEditInstruction( - "Ask Claude to Edit This Selection", - ); + // with the document case, via EditFlow); the document above keeps the + // selection highlighted while it's open. selectedText/fp were captured + // above, so moving focus to the box doesn't affect what we edit. + const instruction = await editFlow.askEditInstruction("Ask Claude to Edit This Selection"); if (!instruction) return; const turnId = `turn-${Date.now().toString(36)}`; try { @@ -433,6 +454,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef versionGuard, diffViewController, trackChangesPreviewController, + editFlow, sidecarRouter, liveProgressUi, editorProposalController, diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 8c2b951..9c1b1e5 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -13,25 +13,9 @@ import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; import type { ProposalController } from "./proposalController"; -import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel"; -import { buildFingerprint } from "./anchorer"; +import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; -import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; -import type { LiveProgressUi } from "./liveProgressUi"; -import { promptEditInstruction } from "./editInstructionInput"; -import type { CoeditingRegistry } from "./coeditingRegistry"; - -/** The exact warning copy for every gated edit gesture (INV-10). */ -const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; - -/** - * F11: a host edit turn (selection/document text + instruction → rewrite). - * Injectable for tests. #60: accepts optional turn options (onProgress/signal); - * the arg is optional so existing test stubs that ignore it stay valid. - */ -type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => 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" }; +import type { EditFlow, EditTarget } from "./editFlow"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; @@ -60,34 +44,13 @@ 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, opts) => { - const { runEditTurn } = await import("./liveTurn"); - return runEditTurn(instruction, text, opts); - }; - /** - * The instruction prompt (the multi-line split-below webview box) for BOTH the - * selection and document cases. A field so host E2E can stub it — the webview - * DOM can't run in CI (mirrors `editTurn`). Also used by the editor's - * `cowriting.editSelection` command (via this controller). - */ - askEditInstruction: (header: string) => Promise = promptEditInstruction; - /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ - private turnSeq = 0; - private nextTurnSeq(): number { - return ++this.turnSeq; - } constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, private readonly proposals: ProposalController, - private readonly liveProgressUi: LiveProgressUi, - private readonly registry: CoeditingRegistry, + private readonly editFlow: EditFlow, ) { this.disposables.push( // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; @@ -103,23 +66,6 @@ export class TrackChangesPreviewController implements vscode.Disposable { } this.show(vscode.window.activeTextEditor?.document); }), - // F11: document-scoped Ask-Claude (also reused by #42's reach gateways). - // Edits a markdown doc; the rewrite is diffed into F4 proposals. - // #42 (INV-38): the editor/title/context (tab) entry passes the clicked - // tab's resource Uri — target THAT document, opening it if it isn't already - // an open buffer (mirrors showTrackChangesPreview's #41 resolution); the - // palette / keybinding / editor/context pass nothing → the active editor. - vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { - const doc = uri - ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? - (await vscode.workspace.openTextDocument(uri)) - : 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 }) => { @@ -209,7 +155,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { } 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); + void this.editFlow.askClaude(document, target); } else if (m?.type === "acceptAll") { // #46 (INV-42): batch-accept every pending proposal on this doc, then report. void this.acceptAll(document); @@ -246,106 +192,6 @@ export class TrackChangesPreviewController implements vscode.Disposable { } } - /** - * 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 { - // INV-10: the choke point for BOTH the editDocument command and the webview's - // askClaude toolbar intent (either scope) — a non-entered doc gets the warning, - // not a turn. - if (!this.registry.isCoediting(document.uri)) { - void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); - return; - } - // Both scopes use the same multi-line split-below webview box; only the header - // (and the downstream proposal logic) differs. For a selection the document - // above keeps the selection highlighted while the box is open. - const header = - target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; - const instruction = await this.askEditInstruction(header); - if (!instruction) return; - try { - const ids = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Cowriting: asking Claude…", - cancellable: true, - }, - async (progress, token) => { - const ui = this.liveProgressUi.begin(instruction, progress, token); - try { - return await this.runEditAndPropose(document, target, instruction, { - onProgress: ui.onProgress, - signal: ui.signal, - }); - } catch (err) { - // #60 (INV-47): a user cancel proposes nothing (the benign empty path). - if (token.isCancellationRequested) return [] as string[]; - throw err; - } - }, - ); - 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/F12 (INV-35/39): 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 `diffToBlockHunks`'d into one proposal per - * changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged - * `granularity:"block"` so accept reconciles attribution per word (INV-40). - * Never mutates the document (INV-10). Returns the created proposal ids. - */ - async runEditAndPropose( - document: vscode.TextDocument, - target: EditTarget, - instruction: string, - opts?: RunEditTurnOptions, - ): Promise { - const full = document.getText(); - // One turnId per gesture — the document case's N hunk-proposals all share it, - // so a single rewrite groups as one agent turn (parity with editSelection). - const turnId = `turn-${this.nextTurnSeq()}`; - 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, opts); - 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), { turnId, instruction }); - return id ? [id] : []; - } - const turn = await this.editTurn(instruction, full, opts); - const ids: string[] = []; - // #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK - // granularity — one proposal per changed block (the unit a human reviews) — - // not per word. Each is tagged `granularity:"block"` so accept reconciles - // attribution per word inside the block (INV-40). - for (const h of diffToBlockHunks(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), { - turnId, - instruction, - granularity: "block", - }); - if (id) ids.push(id); - } - return ids; - } - private onEdit(document: vscode.TextDocument): void { const key = document.uri.toString(); if (!this.panels.has(key)) return; @@ -496,10 +342,6 @@ 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; - } /** * F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude) * are enabled — true only for an authorable doc. The annotations toggle is diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index ed5f01b..44ddfb0 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -80,18 +80,18 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = "# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n", ); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); // Stub the host edit turn (no LLM in CI): rewrite two distinct words. - ctl.setEditTurnForTest(async () => ({ + editFlow.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"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT"); await settle(); assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)"); @@ -114,19 +114,19 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n"; const { doc, key } = await freshDoc("docs/f11sel.md", body); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const target = "The target paragraph Claude will rewrite."; const start = doc.getText().indexOf(target); const end = start + target.length; - ctl.setEditTurnForTest(async (_instruction, text) => { + editFlow.setEditTurnForTest(async (_instruction, text) => { assert.strictEqual(text, target, "the turn receives exactly the selected source range"); return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" }; }); - const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph"); await settle(); assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal"); const views = api.proposalController.listProposals(doc); @@ -144,13 +144,13 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => { const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const target = "Leave me exactly as I am."; const start = doc.getText().indexOf(target); - ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change"); + editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change"); assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing"); }); @@ -162,12 +162,12 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n"; const { doc } = await freshDoc("docs/f11accept.md", original); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); await settle(); assert.ok(ids.length >= 1, "the rewrite produced at least one proposal"); diff --git a/test/e2e/suite/f12Accept.test.ts b/test/e2e/suite/f12Accept.test.ts index ae7d769..8cca13e 100644 --- a/test/e2e/suite/f12Accept.test.ts +++ b/test/e2e/suite/f12Accept.test.ts @@ -39,11 +39,12 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => { const { doc, key } = await freshDoc("docs/f12-all.md", original); const api = await getApi(); const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); await settle(); assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals"); @@ -63,12 +64,12 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => { const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n"; const { doc } = await freshDoc("docs/f12-orphan.md", original); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase"); await settle(); assert.strictEqual(ids.length, 2, "two pending proposals"); @@ -100,15 +101,15 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => { test("acceptAllProposals with one pending proposal applies it", async () => { const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ + editFlow.setEditTurnForTest(async () => ({ replacement: "# One\n\nThe ONLY paragraph here.\n", model: "sonnet", sessionId: "e2e-f12-one", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase only"); await settle(); assert.strictEqual(ids.length, 1, "one pending proposal"); const { applied, skipped } = await api.proposalController.acceptAllProposals(doc); diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 07ae483..5c42855 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -106,10 +106,10 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => { test("rejectAll reverts every pending proposal", async () => { const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const p = api.proposalController; - ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); + editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); for (const id of ids) await p.optimisticApply(doc, id); await settle(); @@ -126,9 +126,9 @@ suite("F12 inline diff — INV-50 listProposals.replaced", () => { test("listProposals reports the original as `replaced` after optimistic apply", async () => { const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); - await ctl.runEditAndPropose(doc, { kind: "document" }, "x"); + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); + await editFlow.runEditAndPropose(doc, { kind: "document" }, "x"); await settle(); await settle(); assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here."); }); @@ -138,9 +138,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => { test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => { const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); - await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); + await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); await settle(); await settle(); assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer"); const v = api.proposalController.listProposals(doc)[0]; @@ -150,9 +150,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => { test("editing the inserted text then finalizing keeps the human edit", async () => { const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); // human tweaks the inserted text const at = doc.getText().indexOf("ALPHA"); @@ -175,10 +175,11 @@ suite("F12 inline diff — control parity (#64, INV-53)", () => { const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); + editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB")); // reject ONE via the webview intent → that block reverts, the other stays applied diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts index 6aaf940..4aa4468 100644 --- a/test/e2e/suite/f12Reach.test.ts +++ b/test/e2e/suite/f12Reach.test.ts @@ -74,7 +74,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { // not whatever editor happens to be active (mirrors #41's clicked-doc resolution). test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => { const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; // Doc A is the active editor; Doc B is the "clicked tab" we pass by URI. const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n"); @@ -83,9 +83,9 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await settle(); // Stub the document instruction prompt (the webview can't run in CI) + the LLM turn. - const origPrompt = ctl.askEditInstruction; - ctl.askEditInstruction = async () => "rewrite it"; - ctl.setEditTurnForTest(async () => ({ + const origPrompt = editFlow.askEditInstruction; + editFlow.askEditInstruction = async () => "rewrite it"; + editFlow.setEditTurnForTest(async () => ({ replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", model: "sonnet", sessionId: "e2e-f12-tab", @@ -94,7 +94,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri); await settle(); } finally { - ctl.askEditInstruction = origPrompt; + editFlow.askEditInstruction = origPrompt; } // The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none. @@ -113,14 +113,14 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { // No URI arg (palette / keybinding) → fall back to the active editor. test("editDocument() with no arg targets the active editor", async () => { const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n"); await vscode.window.showTextDocument(a.doc); await settle(); - const origPrompt = ctl.askEditInstruction; - ctl.askEditInstruction = async () => "rewrite it"; - ctl.setEditTurnForTest(async () => ({ + const origPrompt = editFlow.askEditInstruction; + editFlow.askEditInstruction = async () => "rewrite it"; + editFlow.setEditTurnForTest(async () => ({ replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", model: "sonnet", sessionId: "e2e-f12-noarg", @@ -129,7 +129,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.commands.executeCommand("cowriting.editDocument"); await settle(); } finally { - ctl.askEditInstruction = origPrompt; + editFlow.askEditInstruction = origPrompt; } assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg"); }); diff --git a/test/e2e/suite/f12Review.test.ts b/test/e2e/suite/f12Review.test.ts index 7a7be35..bad0887 100644 --- a/test/e2e/suite/f12Review.test.ts +++ b/test/e2e/suite/f12Review.test.ts @@ -39,13 +39,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" "# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n", ); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n", model: "sonnet", sessionId: "e2e-f12-multi", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns"); await settle(); assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none"); @@ -62,13 +62,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n", ); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n", model: "sonnet", sessionId: "e2e-f12-fence", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "bump a to 10"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10"); await settle(); assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal"); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!; @@ -80,15 +80,15 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => { const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const key = api.proposalController.keyFor(doc); - ctl.setEditTurnForTest(async () => ({ + editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n", model: "sonnet", sessionId: "e2e-f12-attr", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "change two words"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words"); await settle(); assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal"); @@ -118,11 +118,11 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n"; const doc = await freshDoc("docs/f12-insert.md", original); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const key = api.proposalController.keyFor(doc); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph"); await settle(); assert.ok(ids.length >= 1, "the insertion produced at least one proposal"); diff --git a/test/e2e/suite/liveProgress.test.ts b/test/e2e/suite/liveProgress.test.ts index 39dabaf..6e06b28 100644 --- a/test/e2e/suite/liveProgress.test.ts +++ b/test/e2e/suite/liveProgress.test.ts @@ -36,19 +36,19 @@ suite("#60 live turn progress (additive + cancel)", () => { test("a stub that emits progress still produces the same proposals (INV-44)", async () => { const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); // The stub honors opts.onProgress (emitting synthetic snapshots) but returns // the same rewrite — proposals must be unaffected by progress events. - ctl.setEditTurnForTest(async (_i, _text, opts) => { + editFlow.setEditTurnForTest(async (_i, _text, opts) => { opts?.onProgress?.({ phase: "writing", chars: 5 }); opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." }); return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" }; }); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph"); await settle(); assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events"); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]); @@ -62,14 +62,14 @@ suite("#60 live turn progress (additive + cancel)", () => { test("an aborted turn proposes nothing (INV-47)", async () => { const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); // The stub throws ONLY when the aborted signal reached it — so if opts.signal // failed to thread through runEditAndPropose, the stub would instead return a // rewrite and create a proposal, failing this test. That proves propagation. - ctl.setEditTurnForTest(async (_i, _text, opts) => { + editFlow.setEditTurnForTest(async (_i, _text, opts) => { if (opts?.signal?.aborted) throw new Error("claude-code turn aborted"); return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" }; }); @@ -78,7 +78,7 @@ suite("#60 live turn progress (additive + cancel)", () => { ac.abort(); let ids: string[] = []; try { - ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); + ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); } catch { ids = []; }