diff --git a/src/extension.ts b/src/extension.ts index 1db0f5f..12b1006 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { TrackChangesPreviewController } from "./trackChangesPreview"; +import { LiveProgressUi } from "./liveProgressUi"; import { isAuthorable, selectionRejection } from "./workspacePath"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -23,12 +24,18 @@ export interface CowritingApi { diffViewController: DiffViewController; trackChangesPreviewController: TrackChangesPreviewController; sidecarRouter: SidecarRouter; + liveProgressUi: LiveProgressUi; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { // --- POC command (Feature #2), unchanged --- const output = vscode.window.createOutputChannel(CHANNEL_NAME); 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( vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => { try { @@ -104,6 +111,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.extensionUri, attributionController, proposalController, + liveProgressUi, ); context.subscriptions.push(trackChangesPreviewController); @@ -230,10 +238,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const turnId = `turn-${Date.now().toString(36)}`; try { 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 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 === "") { void vscode.window.showWarningMessage( "Cowriting: Claude returned an empty replacement — nothing was proposed.", @@ -292,6 +318,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef diffViewController, trackChangesPreviewController, sidecarRouter, + liveProgressUi, }; } diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index f7c7f38..037cf91 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -16,10 +16,15 @@ import type { ProposalController } from "./proposalController"; import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel"; import { buildFingerprint } from "./anchorer"; 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; +/** + * F11: a host edit turn (selection/document text + instruction → rewrite). + * Injectable for tests. #60: accepts optional turn options (onProgress/signal); + * the arg is optional so existing test stubs that ignore it stay valid. + */ +type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; /** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */ type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" }; @@ -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 * 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"); - return runEditTurn(instruction, text); + return runEditTurn(instruction, text, opts); }; /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; @@ -68,6 +73,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, private readonly proposals: ProposalController, + private readonly liveProgressUi: LiveProgressUi, ) { this.disposables.push( // 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; try { 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) { void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes."); @@ -258,6 +280,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { document: vscode.TextDocument, target: EditTarget, instruction: string, + opts?: RunEditTurnOptions, ): Promise { const full = document.getText(); // One turnId per gesture — the document case's N hunk-proposals all share it, @@ -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 } }); if (target.kind === "range") { 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 []; 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); + 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) —