refactor: extract EditFlow (runEditAndPropose + editDocument) from the review webview ahead of its sunset (spec §6.10)

Moves runEditAndPropose, the askClaude gate/prompt/progress wrapper, the
EditTarget type, the editTurn/askEditInstruction/turnSeq state, and the
cowriting.editDocument command registration out of TrackChangesPreviewController
into a new EditFlow (src/editFlow.ts), reachable from the review webview
(delegates) and, from Task 6 on, the thread controller. extension.ts now
constructs EditFlow before the preview controller and routes
acceptAllProposals/rejectAllProposals directly to ProposalController, dropping
the preview-controller indirection. E2E suites that drove the old
trackChangesPreviewController.{setEditTurnForTest,runEditAndPropose,askEditInstruction}
seams now drive the same seams on editFlow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 08:22:44 -07:00
parent f23fc4afb3
commit ad5d34b85b
9 changed files with 300 additions and 239 deletions
+195
View File
@@ -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<EditTurnResult>;
/** 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<string | undefined> = 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<void> {
// 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<string[]> {
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();
}
}
+37 -15
View File
@@ -12,6 +12,7 @@ import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController"; import { DiffViewController } from "./diffViewController";
import { GitBaselineAdapter } from "./gitBaseline"; import { GitBaselineAdapter } from "./gitBaseline";
import { TrackChangesPreviewController } from "./trackChangesPreview"; import { TrackChangesPreviewController } from "./trackChangesPreview";
import { EditFlow } from "./editFlow";
import { LiveProgressUi } from "./liveProgressUi"; import { LiveProgressUi } from "./liveProgressUi";
import { EditorProposalController } from "./editorProposalController"; import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
@@ -29,6 +30,7 @@ export interface CowritingApi {
versionGuard: VersionGuard; versionGuard: VersionGuard;
diffViewController: DiffViewController; diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController; trackChangesPreviewController: TrackChangesPreviewController;
editFlow: EditFlow;
sidecarRouter: SidecarRouter; sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi; liveProgressUi: LiveProgressUi;
editorProposalController: EditorProposalController; editorProposalController: EditorProposalController;
@@ -164,17 +166,25 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
); );
context.subscriptions.push(proposalController); 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 --- // --- F7/F10: the review preview is the single interactive review surface ---
// Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline,
// INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes // INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes
// F4 accept/reject from the webview ✓/✗). // F4 accept/reject from the webview ✓/✗), and editFlow (delegates the webview's
// askClaude toolbar intent to it).
const trackChangesPreviewController = new TrackChangesPreviewController( const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController, diffViewController,
context.extensionUri, context.extensionUri,
attributionController, attributionController,
proposalController, proposalController,
liveProgressUi, editFlow,
coeditingRegistry,
); );
context.subscriptions.push(trackChangesPreviewController); context.subscriptions.push(trackChangesPreviewController);
@@ -189,8 +199,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.subscriptions.push(editorProposalController); context.subscriptions.push(editorProposalController);
// #46 (INV-42): accept every pending proposal on the active doc in one gesture // #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 // (also reachable from the preview toolbar's "Accept all" button, which routes
// batched F4 seam + reports applied-vs-skipped. // 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( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document; 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."); void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
return; 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( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => { vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document; 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."); void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
return; 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), end: document.offsetAt(selection.end),
}); });
// The instruction prompt is the multi-line split-below webview box (shared // The instruction prompt is the multi-line split-below webview box (shared
// with the document case, via the preview controller); the document above // with the document case, via EditFlow); the document above keeps the
// keeps the selection highlighted while it's open. selectedText/fp were // selection highlighted while it's open. selectedText/fp were captured
// captured above, so moving focus to the box doesn't affect what we edit. // above, so moving focus to the box doesn't affect what we edit.
const instruction = await trackChangesPreviewController.askEditInstruction( const instruction = await editFlow.askEditInstruction("Ask Claude to Edit This Selection");
"Ask Claude to Edit This Selection",
);
if (!instruction) return; if (!instruction) return;
const turnId = `turn-${Date.now().toString(36)}`; const turnId = `turn-${Date.now().toString(36)}`;
try { try {
@@ -433,6 +454,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
versionGuard, versionGuard,
diffViewController, diffViewController,
trackChangesPreviewController, trackChangesPreviewController,
editFlow,
sidecarRouter, sidecarRouter,
liveProgressUi, liveProgressUi,
editorProposalController, editorProposalController,
+4 -162
View File
@@ -13,25 +13,9 @@ 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, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel"; import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; import type { EditFlow, EditTarget } from "./editFlow";
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<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;
@@ -60,34 +44,13 @@ 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, 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<string | undefined> = promptEditInstruction;
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
private nextTurnSeq(): number {
return ++this.turnSeq;
}
constructor( constructor(
private readonly diffView: DiffViewController, private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri, private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController, private readonly attribution: AttributionController,
private readonly proposals: ProposalController, private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi, private readonly editFlow: EditFlow,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push( this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; // 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); 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)), 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 }) => {
@@ -209,7 +155,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
} else if (m?.type === "askClaude") { } else if (m?.type === "askClaude") {
const target: EditTarget = const target: EditTarget =
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" }; 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") { } else if (m?.type === "acceptAll") {
// #46 (INV-42): batch-accept every pending proposal on this doc, then report. // #46 (INV-42): batch-accept every pending proposal on this doc, then report.
void this.acceptAll(document); 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<void> {
// 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<string[]> {
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 { 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;
@@ -496,10 +342,6 @@ 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;
}
/** /**
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude) * 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 * are enabled — true only for an authorable doc. The annotations toggle is
+12 -12
View File
@@ -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", "# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
); );
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
// Stub the host edit turn (no LLM in CI): rewrite two distinct words. // 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", replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f11-doc", 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(); await settle();
assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)"); 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 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 { doc, key } = await freshDoc("docs/f11sel.md", body);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
const target = "The target paragraph Claude will rewrite."; const target = "The target paragraph Claude will rewrite.";
const start = doc.getText().indexOf(target); const start = doc.getText().indexOf(target);
const end = start + target.length; 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"); 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" }; 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(); await settle();
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal"); assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
const views = api.proposalController.listProposals(doc); 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 () => { 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 { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
const target = "Leave me exactly as I am."; const target = "Leave me exactly as I am.";
const start = doc.getText().indexOf(target); const start = doc.getText().indexOf(target);
ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); editFlow.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"); 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"); 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 rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
const { doc } = await freshDoc("docs/f11accept.md", original); const { doc } = await freshDoc("docs/f11accept.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
await settle(); await settle();
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal"); assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
+9 -8
View File
@@ -39,11 +39,12 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
const { doc, key } = await freshDoc("docs/f12-all.md", original); const { doc, key } = await freshDoc("docs/f12-all.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const ctl = api.trackChangesPreviewController;
const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
await settle(); await settle();
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals"); 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 rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n";
const { doc } = await freshDoc("docs/f12-orphan.md", original); const { doc } = await freshDoc("docs/f12-orphan.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
await settle(); await settle();
assert.strictEqual(ids.length, 2, "two pending proposals"); 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 () => { 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 { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# One\n\nThe ONLY paragraph here.\n", replacement: "# One\n\nThe ONLY paragraph here.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-one", 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(); await settle();
assert.strictEqual(ids.length, 1, "one pending proposal"); assert.strictEqual(ids.length, 1, "one pending proposal");
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc); const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
+15 -14
View File
@@ -106,10 +106,10 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
test("rejectAll reverts every pending proposal", async () => { test("rejectAll reverts every pending proposal", async () => {
const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
const p = api.proposalController; const p = api.proposalController;
ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
for (const id of ids) await p.optimisticApply(doc, id); for (const id of ids) await p.optimisticApply(doc, id);
await settle(); 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 () => { 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 { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "x"); await editFlow.runEditAndPropose(doc, { kind: "document" }, "x");
await settle(); await settle(); await settle(); await settle();
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here."); 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 () => { 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 { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
await settle(); await settle(); await settle(); await settle();
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer"); assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
const v = api.proposalController.listProposals(doc)[0]; 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 () => { 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 { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle(); await settle(); await settle();
// human tweaks the inserted text // human tweaks the inserted text
const at = doc.getText().indexOf("ALPHA"); 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 { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const ctl = api.trackChangesPreviewController;
const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle(); await settle(); await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB")); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
// reject ONE via the webview intent → that block reverts, the other stays applied // reject ONE via the webview intent → that block reverts, the other stays applied
+10 -10
View File
@@ -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). // 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 () => { test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
const api = await getApi(); 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. // 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"); 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(); await settle();
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn. // Stub the document instruction prompt (the webview can't run in CI) + the LLM turn.
const origPrompt = ctl.askEditInstruction; const origPrompt = editFlow.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it"; editFlow.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-tab", 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 vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle(); await settle();
} finally { } finally {
ctl.askEditInstruction = origPrompt; editFlow.askEditInstruction = origPrompt;
} }
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none. // 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. // No URI arg (palette / keybinding) → fall back to the active editor.
test("editDocument() with no arg targets the active editor", async () => { test("editDocument() with no arg targets the active editor", async () => {
const api = await getApi(); 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"); const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
await vscode.window.showTextDocument(a.doc); await vscode.window.showTextDocument(a.doc);
await settle(); await settle();
const origPrompt = ctl.askEditInstruction; const origPrompt = editFlow.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it"; editFlow.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-noarg", 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 vscode.commands.executeCommand("cowriting.editDocument");
await settle(); await settle();
} finally { } 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"); assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
}); });
+12 -12
View File
@@ -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", "# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n",
); );
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n", replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-multi", 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(); await settle();
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none"); 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", "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
); );
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n", replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-fence", 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(); await settle();
assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal"); assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!; 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 () => { 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 doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
const key = api.proposalController.keyFor(doc); const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n", replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-attr", 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(); await settle();
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal"); 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 rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n";
const doc = await freshDoc("docs/f12-insert.md", original); const doc = await freshDoc("docs/f12-insert.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
const key = api.proposalController.keyFor(doc); const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
await settle(); await settle();
assert.ok(ids.length >= 1, "the insertion produced at least one proposal"); assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
+6 -6
View File
@@ -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 () => { 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 doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns // The stub honors opts.onProgress (emitting synthetic snapshots) but returns
// the same rewrite — proposals must be unaffected by progress events. // 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: 5 });
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." }); opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" }; 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(); await settle();
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events"); 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]); 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 () => { test("an aborted turn proposes nothing (INV-47)", async () => {
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n"); const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle(); await settle();
// The stub throws ONLY when the aborted signal reached it — so if opts.signal // 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 // failed to thread through runEditAndPropose, the stub would instead return a
// rewrite and create a proposal, failing this test. That proves propagation. // 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"); 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" }; 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(); ac.abort();
let ids: string[] = []; let ids: string[] = [];
try { try {
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
} catch { } catch {
ids = []; ids = [];
} }