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:
+195
@@ -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
@@ -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,
|
||||
|
||||
+4
-162
@@ -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<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" };
|
||||
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<string, "on" | "off">();
|
||||
/** 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<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 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<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 {
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user