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
|
||||
|
||||
Reference in New Issue
Block a user