feat(#60): wire live progress + cancel into both Ask-Claude call sites

editSelection (extension.ts) and preview askClaude (trackChangesPreview.ts)
both run the turn cancellable, relay TurnProgress via the shared LiveProgressUi,
and thread onProgress/signal through runEditAndPropose. The EditTurn seam widens
to accept opts (back-compat: existing stubs ignore it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 04:35:41 -07:00
parent 20e13bba4d
commit 946625e899
2 changed files with 62 additions and 12 deletions
+30 -3
View File
@@ -11,6 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter"; import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController"; import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview"; import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi";
import { isAuthorable, selectionRejection } from "./workspacePath"; import { isAuthorable, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -23,12 +24,18 @@ export interface CowritingApi {
diffViewController: DiffViewController; diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController; trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter; sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi;
} }
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
// --- POC command (Feature #2), unchanged --- // --- POC command (Feature #2), unchanged ---
const output = vscode.window.createOutputChannel(CHANNEL_NAME); const output = vscode.window.createOutputChannel(CHANNEL_NAME);
context.subscriptions.push(output); context.subscriptions.push(output);
// #60: shared live-progress UI (notification activity line + "Cowriting: Claude"
// OutputChannel) for both Ask-Claude entry points.
const liveProgressUi = new LiveProgressUi();
context.subscriptions.push(liveProgressUi);
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => { vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
try { try {
@@ -104,6 +111,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.extensionUri, context.extensionUri,
attributionController, attributionController,
proposalController, proposalController,
liveProgressUi,
); );
context.subscriptions.push(trackChangesPreviewController); context.subscriptions.push(trackChangesPreviewController);
@@ -230,10 +238,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const turnId = `turn-${Date.now().toString(36)}`; const turnId = `turn-${Date.now().toString(36)}`;
try { try {
await vscode.window.withProgress( await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" }, {
async () => { location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const { runEditTurn } = await import("./liveTurn"); const { runEditTurn } = await import("./liveTurn");
const turn = await runEditTurn(instruction, selectedText); const ui = liveProgressUi.begin(instruction, progress, token);
let turn;
try {
turn = await runEditTurn(instruction, selectedText, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
if (token.isCancellationRequested) {
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
return;
}
throw err;
}
if (turn.replacement === "") { if (turn.replacement === "") {
void vscode.window.showWarningMessage( void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.", "Cowriting: Claude returned an empty replacement — nothing was proposed.",
@@ -292,6 +318,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
diffViewController, diffViewController,
trackChangesPreviewController, trackChangesPreviewController,
sidecarRouter, sidecarRouter,
liveProgressUi,
}; };
} }
+32 -9
View File
@@ -16,10 +16,15 @@ import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel"; import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer"; import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { EditTurnResult } from "./liveTurn"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */ /**
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>; * 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. */ /** 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" }; type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
@@ -53,9 +58,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and * 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). * never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/ */
private editTurn: EditTurn = async (instruction, text) => { private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn"); const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text); return runEditTurn(instruction, text, opts);
}; };
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0; private turnSeq = 0;
@@ -68,6 +73,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
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,
) { ) {
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;
@@ -230,8 +236,24 @@ export class TrackChangesPreviewController implements vscode.Disposable {
if (!instruction) return; if (!instruction) return;
try { try {
const ids = await vscode.window.withProgress( const ids = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" }, {
() => this.runEditAndPropose(document, target, instruction), 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) { if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes."); void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
@@ -258,6 +280,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
document: vscode.TextDocument, document: vscode.TextDocument,
target: EditTarget, target: EditTarget,
instruction: string, instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> { ): Promise<string[]> {
const full = document.getText(); const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it, // One turnId per gesture — the document case's N hunk-proposals all share it,
@@ -267,13 +290,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } }); ({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") { if (target.kind === "range") {
const selected = full.slice(target.start, target.end); const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected); const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return []; if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end }); const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction }); const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : []; return id ? [id] : [];
} }
const turn = await this.editTurn(instruction, full); const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = []; const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK // #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) — // granularity — one proposal per changed block (the unit a human reviews) —