Files
vscode-cowriting-plugin-con…/plans/2026-06-26-live-turn-progress.md
T
2026-06-26 04:53:43 -07:00

36 KiB

Live Turn Progress (#60) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Surface Claude's live output/progress (activity + token count + streamed text + cancel) during the otherwise-opaque "asking Claude…" status, for both edit entry points.

Architecture: A pure reducer (turnProgress.ts) folds @cline/sdk Agent runtime events into a small TurnProgressSnapshot; runEditTurn subscribes to the agent and emits snapshots via an onProgress callback while accepting an AbortSignal for cancellation — staying vscode-free. The two call sites relay snapshots through a shared host liveProgressUi module: the existing withProgress notification shows a live activity line, a dedicated OutputChannel streams the full assistant text, and the notification becomes cancellable. The final result path (proposals) is untouched.

Tech Stack: TypeScript (strict, moduleResolution: Bundler), VS Code extension API, @cline/sdk (ESM, dynamic-import-only, never bundled), Vitest (unit), @vscode/test-electron (host E2E), esbuild.

Spec: coauthoring-live-progress.md (graduated, content repo). Invariants INV-43..47.


File Structure

  • Create src/turnProgress.ts — pure reducer + line formatter. No vscode, no SDK runtime import (AgentRuntimeEvent type-only). Owns ALL event→state logic (INV-46).
  • Create test/turnProgress.test.ts — unit tests for the reducer + formatProgressLine.
  • Modify src/liveTurn.tsrunEditTurn gains RunEditTurnOptions { modelId?, onProgress?, signal? }; subscribes to the agent, folds events through the reducer, wires signalagent.abort(). Result shape unchanged (INV-44). Stays vscode-free (INV-43).
  • Create src/liveProgressUi.ts — host module (vscode-only): owns the shared "Cowriting: Claude" OutputChannel and a begin(instruction, progress, token) helper returning { signal, onProgress } for one withProgress run. Reads cowriting.liveProgress.revealOutput.
  • Modify src/extension.ts — construct liveProgressUi once at activate; the editSelection command uses it (cancellable notification + onProgress + signal); pass it into the preview controller; expose it on CowritingApi for E2E.
  • Modify src/trackChangesPreview.ts — widen the EditTurn seam to accept opts?; thread opts through askClauderunEditAndProposeeditTurn; use the injected liveProgressUi in askClaude.
  • Modify package.json — add contributes.configuration with cowriting.liveProgress.revealOutput (boolean, default true).
  • Modify test/e2e/suite/f11Toolbar.test.ts (or a new test/e2e/suite/liveProgress.test.ts) — extend the preview editTurn stub to emit synthetic progress and assert (a) identical proposals (INV-44) and (b) cancel→zero proposals (INV-47).

Build note: the type-only import type { AgentRuntimeEvent } from "@cline/sdk" is erased at compile and @cline/* stays external in esbuild — no bundling of the ESM SDK (preserves the POC constraint).


Task 1: Pure progress reducer (turnProgress.ts)

Files:

  • Create: src/turnProgress.ts

  • Test: test/turnProgress.test.ts

  • Step 1: Write the failing test

Create test/turnProgress.test.ts:

import { describe, it, expect } from "vitest";
import {
  createTurnProgressState,
  reduceTurnProgress,
  formatProgressLine,
  formatTokens,
  type TurnProgressSnapshot,
} from "../src/turnProgress";

// Minimal event factories — structurally match the @cline/sdk AgentRuntimeEvent
// members the reducer reads. `as any` because we only supply the fields used.
const ev = (e: any) => e as any;
const textDelta = (text: string, accumulatedText: string) =>
  ev({ type: "assistant-text-delta", text, accumulatedText });
const toolStarted = (toolName: string) => ev({ type: "tool-started", toolCall: { toolName } });
const toolFinished = (toolName: string) => ev({ type: "tool-finished", toolCall: { toolName } });
const usage = (inputTokens: number, outputTokens: number) =>
  ev({ type: "usage-updated", usage: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 } });

// Drive a sequence of events, returning every emitted snapshot.
function run(events: any[]): TurnProgressSnapshot[] {
  let state = createTurnProgressState();
  const out: TurnProgressSnapshot[] = [];
  for (const e of events) {
    const r = reduceTurnProgress(state, e);
    state = r.state;
    if (r.snapshot) out.push(r.snapshot);
  }
  return out;
}

describe("reduceTurnProgress", () => {
  it("starts in thinking", () => {
    const s = run([ev({ type: "run-started" })]);
    expect(s.at(-1)!.phase).toBe("thinking");
    expect(s.at(-1)!.chars).toBe(0);
    expect(s.at(-1)!.tokens).toBeUndefined();
  });

  it("text deltas move to writing and accumulate chars + carry the delta", () => {
    const s = run([textDelta("Hel", "Hel"), textDelta("lo", "Hello")]);
    expect(s.map((x) => x.phase)).toEqual(["writing", "writing"]);
    expect(s.at(-1)!.chars).toBe(5);
    expect(s.map((x) => x.textDelta)).toEqual(["Hel", "lo"]);
  });

  it("usage sets a running token total (input+output)", () => {
    const s = run([textDelta("Hi", "Hi"), usage(1000, 234)]);
    expect(s.at(-1)!.tokens).toBe(1234);
  });

  it("tool start shows the tool name; tool finish reverts to writing once text was seen", () => {
    const s = run([textDelta("x", "x"), toolStarted("read_file"), toolFinished("read_file")]);
    expect(s[1].phase).toBe("tool");
    expect(s[1].tool).toBe("read_file");
    expect(s.at(-1)!.phase).toBe("writing");
  });

  it("tool finish reverts to thinking when no text was seen", () => {
    const s = run([toolStarted("grep"), toolFinished("grep")]);
    expect(s.at(-1)!.phase).toBe("thinking");
  });

  it("overlapping tools resolve in order", () => {
    const s = run([toolStarted("a"), toolStarted("b"), toolFinished("b"), toolFinished("a")]);
    expect(s.map((x) => x.phase)).toEqual(["tool", "tool", "tool", "thinking"]);
    expect(s[1].tool).toBe("b");
    expect(s[2].tool).toBe("a");
  });

  it("reasoning deltas stay thinking and surface no text", () => {
    const s = run([ev({ type: "assistant-reasoning-delta", text: "secret", accumulatedText: "secret" })]);
    expect(s.at(-1)!.phase).toBe("thinking");
    expect(s.at(-1)!.textDelta).toBeUndefined();
  });

  it("ignores lifecycle/finish events (no snapshot)", () => {
    const s = run([ev({ type: "turn-finished" }), ev({ type: "run-finished" })]);
    expect(s).toEqual([]);
  });
});

describe("formatProgressLine / formatTokens", () => {
  it("thinking", () => {
    expect(formatProgressLine({ phase: "thinking", chars: 0 })).toBe("thinking…");
  });
  it("writing with chars", () => {
    expect(formatProgressLine({ phase: "writing", chars: 412 })).toBe("writing… (412 chars)");
  });
  it("writing with chars + tokens", () => {
    expect(formatProgressLine({ phase: "writing", chars: 412, tokens: 1234 })).toBe(
      "writing… (412 chars) · 1.2k tokens",
    );
  });
  it("tool with name", () => {
    expect(formatProgressLine({ phase: "tool", tool: "read_file", chars: 0 })).toBe("running read_file…");
  });
  it("formats token magnitudes", () => {
    expect(formatTokens(950)).toBe("950");
    expect(formatTokens(1234)).toBe("1.2k");
  });
});
  • Step 2: Run the test to verify it fails

Run: npx vitest run test/turnProgress.test.ts Expected: FAIL — Cannot find module "../src/turnProgress".

  • Step 3: Write the reducer

Create src/turnProgress.ts:

/**
 * turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a
 * small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2).
 *
 * INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime
 * dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at
 * compile and never pulls the ESM SDK into the bundle). All event→state logic
 * lives here so it is unit-tested in isolation; the UI call sites only format and
 * relay snapshots.
 */
import type { AgentRuntimeEvent } from "@cline/sdk";

export type TurnPhase = "thinking" | "writing" | "tool";

export interface TurnProgressSnapshot {
  phase: TurnPhase;
  /** present iff phase === "tool" — the running tool's name. */
  tool?: string;
  /** accumulated assistant-text length so far. */
  chars: number;
  /** running total tokens (input+output); undefined until the first usage event. */
  tokens?: number;
  /** the new assistant-text chunk since the last snapshot (for the OutputChannel). */
  textDelta?: string;
}

export interface TurnProgressState {
  phase: TurnPhase;
  chars: number;
  tokens?: number;
  /** stack of tool names currently running (depth-tracked for overlap). */
  activeTools: string[];
  /** true once any assistant text has streamed (tool-finish then reverts to writing). */
  sawText: boolean;
}

export function createTurnProgressState(): TurnProgressState {
  return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false };
}

function restingPhase(state: TurnProgressState): TurnPhase {
  if (state.activeTools.length) return "tool";
  return state.sawText ? "writing" : "thinking";
}

function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot {
  return {
    phase: state.phase,
    tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined,
    chars: state.chars,
    tokens: state.tokens,
    textDelta,
  };
}

/**
 * Fold one SDK event into the state, returning the next state and the snapshot to
 * emit (snapshot undefined for events that don't change the surface).
 */
export function reduceTurnProgress(
  state: TurnProgressState,
  event: AgentRuntimeEvent,
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } {
  switch (event.type) {
    case "run-started":
    case "turn-started": {
      const next: TurnProgressState = { ...state, phase: restingPhase(state) };
      return { state: next, snapshot: toSnapshot(next) };
    }
    case "assistant-text-delta": {
      const next: TurnProgressState = {
        ...state,
        phase: state.activeTools.length ? "tool" : "writing",
        chars: event.accumulatedText.length,
        sawText: true,
      };
      return { state: next, snapshot: toSnapshot(next, event.text) };
    }
    case "assistant-reasoning-delta": {
      // Reasoning TEXT is not surfaced (operator fork); collapse to motion only.
      const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" };
      return { state: next, snapshot: toSnapshot(next) };
    }
    case "tool-started": {
      const activeTools = [...state.activeTools, event.toolCall.toolName];
      const next: TurnProgressState = { ...state, phase: "tool", activeTools };
      return { state: next, snapshot: toSnapshot(next) };
    }
    case "tool-updated": {
      const next: TurnProgressState = { ...state, phase: "tool" };
      return { state: next, snapshot: toSnapshot(next) };
    }
    case "tool-finished": {
      const name = event.toolCall.toolName;
      const idx = state.activeTools.lastIndexOf(name);
      const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1);
      const next: TurnProgressState = { ...state, activeTools, phase: "thinking" };
      next.phase = restingPhase(next);
      return { state: next, snapshot: toSnapshot(next) };
    }
    case "usage-updated": {
      const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined;
      const next: TurnProgressState = { ...state, tokens };
      return { state: next, snapshot: toSnapshot(next) };
    }
    default:
      return { state };
  }
}

/** Render the notification activity line from a snapshot (pure; spec §2.1). */
export function formatProgressLine(s: TurnProgressSnapshot): string {
  let head: string;
  if (s.phase === "tool") head = `running ${s.tool ?? "tool"}…`;
  else if (s.phase === "writing") head = `writing… (${s.chars} chars)`;
  else head = "thinking…";
  return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head;
}

export function formatTokens(n: number): string {
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
  • Step 4: Run the test to verify it passes

Run: npx vitest run test/turnProgress.test.ts Expected: PASS (all cases).

  • Step 5: Typecheck

Run: npm run typecheck Expected: no errors (confirms the type-only AgentRuntimeEvent import resolves under moduleResolution: Bundler and the discriminated-union narrowing is sound).

  • Step 6: Commit
git add src/turnProgress.ts test/turnProgress.test.ts
git commit -m "feat(#60): pure turn-progress reducer (INV-46)"

Task 2: Extend runEditTurn with progress + cancellation

Files:

  • Modify: src/liveTurn.ts

  • Test: test/liveTurn.test.ts (add a mocked-agent case)

  • Step 1: Write the failing test

Append to test/liveTurn.test.ts (keep the existing extractReplacement block). This mocks @cline/sdk via Vitest so runEditTurn runs without a real LLM, exercising subscribe + abort wiring:

import { vi } from "vitest";
import { runEditTurn } from "../src/liveTurn";

// A fake Agent that replays a scripted event list to subscribers, supports abort,
// and resolves agent.run() with a completed result.
function fakeSdk(events: any[], opts?: { abortAware?: boolean }) {
  return {
    Agent: class {
      private listeners: ((e: any) => void)[] = [];
      private aborted = false;
      constructor(_cfg: unknown) {}
      subscribe(fn: (e: any) => void) {
        this.listeners.push(fn);
        return () => {
          this.listeners = this.listeners.filter((l) => l !== fn);
        };
      }
      abort() {
        this.aborted = true;
      }
      async run(_input: string) {
        for (const e of events) for (const l of this.listeners) l(e);
        if (opts?.abortAware && this.aborted) {
          return { status: "aborted", outputText: "", runId: "r1", error: { message: "aborted" } };
        }
        return { status: "completed", outputText: "EDITED", runId: "r1" };
      }
    },
  };
}

describe("runEditTurn progress + cancel", () => {
  it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
    vi.doMock("@cline/sdk", () => fakeSdk([
      { type: "assistant-text-delta", text: "ED", accumulatedText: "ED" },
      { type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } },
    ]));
    const seen: string[] = [];
    const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) });
    expect(turn.replacement).toBe("EDITED");
    expect(seen).toContain("writing");
    vi.doUnmock("@cline/sdk");
  });

  it("a fired AbortSignal aborts the agent and the turn throws (INV-47)", async () => {
    vi.doMock("@cline/sdk", () => fakeSdk([], { abortAware: true }));
    const ac = new AbortController();
    ac.abort();
    await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/);
    vi.doUnmock("@cline/sdk");
  });
});

Note: dynamic import("@cline/sdk") inside runEditTurn picks up vi.doMock. If the existing test file lacks vi import, the added import { vi } covers it.

  • Step 2: Run to verify it fails

Run: npx vitest run test/liveTurn.test.ts Expected: FAIL — runEditTurn doesn't accept onProgress/signal yet (type error) or doesn't subscribe.

  • Step 3: Implement the extension

In src/liveTurn.ts, add the import and options type, and rewrite the body of runEditTurn (keep SYSTEM_PROMPT, extractReplacement, EditTurnResult as-is):

import type { TurnProgressSnapshot } from "./turnProgress";
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";

export interface RunEditTurnOptions {
  modelId?: string;
  /** out: called with a reduced progress snapshot as the SDK streams (INV-44 additive). */
  onProgress?: (snapshot: TurnProgressSnapshot) => void;
  /** in: aborting this signal cancels the turn via agent.abort() (INV-47). */
  signal?: AbortSignal;
}

export async function runEditTurn(
  instruction: string,
  selectedText: string,
  opts?: RunEditTurnOptions,
): Promise<EditTurnResult> {
  const sdk = await import("@cline/sdk");
  const modelId = opts?.modelId ?? "sonnet";
  const agent = new sdk.Agent({ providerId: "claude-code", modelId, systemPrompt: SYSTEM_PROMPT });

  let state = createTurnProgressState();
  const unsubscribe = opts?.onProgress
    ? agent.subscribe((event) => {
        const next = reduceTurnProgress(state, event);
        state = next.state;
        if (next.snapshot) opts.onProgress!(next.snapshot);
      })
    : undefined;
  const onAbort = () => agent.abort();
  opts?.signal?.addEventListener("abort", onAbort);

  try {
    const result = await agent.run(
      `<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
    );
    if (result.status !== "completed") {
      throw new Error(
        `claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
          "(is Claude Code installed and signed in?)",
      );
    }
    return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
  } finally {
    unsubscribe?.();
    opts?.signal?.removeEventListener("abort", onAbort);
  }
}

(agent.subscribe returns its unsubscribe fn per @cline/agents AgentRuntime.subscribe(listener): () => void. agent.abort() exists on the same class. AbortSignal/addEventListener are web standards — no vscode import is added, preserving INV-43.)

  • Step 4: Run the test to verify it passes

Run: npx vitest run test/liveTurn.test.ts Expected: PASS (both new cases + the existing extractReplacement cases).

  • Step 5: Typecheck + full unit run

Run: npm run typecheck && npx vitest run Expected: typecheck clean; all unit tests pass (was 222 + Task 1 + these).

  • Step 6: Commit
git add src/liveTurn.ts test/liveTurn.test.ts
git commit -m "feat(#60): runEditTurn streams progress + accepts AbortSignal (INV-43/44/47)"

Task 3: Host liveProgressUi module + setting

Files:

  • Create: src/liveProgressUi.ts

  • Modify: package.json (add contributes.configuration)

  • Step 1: Add the configuration to package.json

In package.json, under the top-level "contributes" object, add a configuration block (there is no existing one). Insert it as a sibling of the existing commands/menus keys:

"configuration": {
  "title": "Cowriting",
  "properties": {
    "cowriting.liveProgress.revealOutput": {
      "type": "boolean",
      "default": true,
      "description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
    }
  }
}
  • Step 2: Write the module

Create src/liveProgressUi.ts:

/**
 * liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI
 * (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the
 * existing withProgress notification (an activity line) and a dedicated
 * OutputChannel streaming the full assistant text — no new network/webview
 * surface (INV-45). vscode-only; the pure logic lives in turnProgress.ts.
 */
import * as vscode from "vscode";
import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress";

const CHANNEL_NAME = "Cowriting: Claude";

export interface TurnUi {
  /** Pass to runEditTurn's opts.onProgress. */
  onProgress: (snapshot: TurnProgressSnapshot) => void;
  /** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */
  signal: AbortSignal;
}

export class LiveProgressUi {
  readonly channel: vscode.OutputChannel;
  constructor() {
    this.channel = vscode.window.createOutputChannel(CHANNEL_NAME);
  }

  /**
   * Begin one turn's UI. Writes the per-turn header to the channel, returns the
   * onProgress relay + an AbortSignal linked to the notification's cancel token.
   */
  begin(
    instruction: string,
    progress: vscode.Progress<{ message?: string }>,
    token: vscode.CancellationToken,
  ): TurnUi {
    const controller = new AbortController();
    token.onCancellationRequested(() => controller.abort());
    this.channel.appendLine(`── asking: ${instruction} ──`);
    let revealed = false;
    const reveal = () => {
      if (revealed) return;
      revealed = true;
      const cfg = vscode.workspace.getConfiguration("cowriting");
      if (cfg.get<boolean>("liveProgress.revealOutput", true)) this.channel.show(true);
    };
    const onProgress = (s: TurnProgressSnapshot) => {
      progress.report({ message: formatProgressLine(s) });
      if (s.textDelta) {
        this.channel.append(s.textDelta);
        reveal();
      }
    };
    return { onProgress, signal: controller.signal };
  }

  dispose(): void {
    this.channel.dispose();
  }
}
  • Step 3: Typecheck

Run: npm run typecheck Expected: no errors.

  • Step 4: Validate package.json

Run: node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('ok')" Expected: ok (the configuration block is valid JSON).

  • Step 5: Commit
git add src/liveProgressUi.ts package.json
git commit -m "feat(#60): liveProgressUi host relay + revealOutput setting (INV-45)"

Task 4: Wire the editSelection call site

Files:

  • Modify: src/extension.ts (construct LiveProgressUi at activate; use it in the editSelection command; add to CowritingApi)

  • Step 1: Construct LiveProgressUi once and pass it into the preview controller

Near the top of activate(...), after the existing POC output channel is created, add:

const liveProgressUi = new LiveProgressUi();
context.subscriptions.push(liveProgressUi);

Add the import at the top of src/extension.ts:

import { LiveProgressUi } from "./liveProgressUi";

Then thread it into the preview controller construction (Task 5 widens that constructor):

const trackChangesPreviewController = new TrackChangesPreviewController(
  diffViewController,
  context.extensionUri,
  attributionController,
  proposalController,
  liveProgressUi,
);
  • Step 2: Rewrite the editSelection withProgress block

Replace the editSelection command's withProgress(...) call (currently src/extension.ts:232-268) so it is cancellable and relays progress. The turn body is otherwise unchanged:

try {
  await vscode.window.withProgress(
    {
      location: vscode.ProgressLocation.Notification,
      title: "Cowriting: asking Claude…",
      cancellable: true,
    },
    async (progress, token) => {
      const { runEditTurn } = await import("./liveTurn");
      const ui = liveProgressUi.begin(instruction, progress, token);
      const turn = await runEditTurn(instruction, selectedText, {
        onProgress: ui.onProgress,
        signal: ui.signal,
      });
      if (turn.replacement === "") {
        void vscode.window.showWarningMessage(
          "Cowriting: Claude returned an empty replacement — nothing was proposed.",
        );
        return;
      }
      if (turn.replacement === selectedText) {
        void vscode.window.showInformationMessage("Cowriting: Claude proposed no change to the selection.");
        return;
      }
      const id = await proposalController.propose(
        document,
        fp,
        turn.replacement,
        { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } },
        { turnId, instruction },
      );
      if (id) {
        void vscode.window.showInformationMessage(
          "Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
        );
      }
    },
  );
} catch (err) {
  if (token_was_cancelled(err)) return; // replaced below
  const message = err instanceof Error ? err.message : String(err);
  void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}

Because token is scoped inside the withProgress callback, surface cancellation by catching it there instead of the outer catch. Final form of the catch and cancel handling: wrap the runEditTurn call in a try/catch inside the callback that reflects cancel, and let other errors propagate to the outer catch:

// inside the withProgress async (progress, token) callback, around runEditTurn:
let turn;
try {
  turn = await runEditTurn(instruction, selectedText, { onProgress: ui.onProgress, signal: ui.signal });
} catch (err) {
  if (token.isCancellationRequested) {
    void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
    return;
  }
  throw err;
}

(Keep the single outer try/catch for the non-cancel error path exactly as it is today.)

  • Step 3: Add liveProgressUi to CowritingApi and the return

In the CowritingApi interface (src/extension.ts:18) add:

liveProgressUi: LiveProgressUi;

And in the return { ... } block (src/extension.ts:287) add liveProgressUi,.

  • Step 4: Typecheck

Run: npm run typecheck Expected: no errors (Task 5 must land for the widened constructor to typecheck — if doing tasks strictly in order, expect one error here about the 5th constructor arg; that resolves in Task 5. To keep this task self-contained, do Task 5 Step 1 (constructor signature) before re-running. Otherwise proceed to Task 5 and typecheck there.)

  • Step 5: Build

Run: npm run build Expected: esbuild succeeds; @cline/* stays external.

  • Step 6: Commit
git add src/extension.ts
git commit -m "feat(#60): editSelection shows live progress + cancel"

Task 5: Thread progress through the preview path

Files:

  • Modify: src/trackChangesPreview.ts (widen EditTurn; accept LiveProgressUi; thread opts through askClauderunEditAndPropose)

  • Step 1: Widen the EditTurn seam type and the constructor

In src/trackChangesPreview.ts, change the EditTurn type (line 22) to accept optional turn options (back-compat: existing E2E stubs ignore the new arg):

import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";

type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;

Update the default editTurn (line 56) to forward opts:

private editTurn: EditTurn = async (instruction, text, opts) => {
  const { runEditTurn } = await import("./liveTurn");
  return runEditTurn(instruction, text, opts);
};

Add liveProgressUi as a constructor parameter (after proposals):

constructor(
  private readonly diffView: DiffViewController,
  private readonly extensionUri: vscode.Uri,
  private readonly attribution: AttributionController,
  private readonly proposals: ProposalController,
  private readonly liveProgressUi: LiveProgressUi,
) {
  • Step 2: Make askClaude cancellable + progress-relaying

Rewrite the withProgress block in askClaude (src/trackChangesPreview.ts:232-235) to mirror Task 4:

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) {
        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}`);
}

(On cancel the inner catch returns [], which shows the benign "proposed no changes" — no proposal is created, satisfying INV-47. A distinct "cancelled" toast is optional; the editSelection path shows one. Keep parity simple: returning [] is the no-proposal outcome.)

  • Step 3: Thread opts through runEditAndPropose

Change the signature (src/trackChangesPreview.ts:257) and both editTurn(...) calls (lines 270, 276) to pass opts:

async runEditAndPropose(
  document: vscode.TextDocument,
  target: EditTarget,
  instruction: string,
  opts?: RunEditTurnOptions,
): Promise<string[]> {
  // ...
  // range branch:
  const turn = await this.editTurn(instruction, selected, opts);
  // ...
  // document branch:
  const turn = await this.editTurn(instruction, full, opts);
  • Step 4: Typecheck + full unit run

Run: npm run typecheck && npx vitest run Expected: typecheck clean (the widened constructor now matches Task 4's call site); all unit tests pass.

  • Step 5: Build

Run: npm run build Expected: success.

  • Step 6: Commit
git add src/trackChangesPreview.ts
git commit -m "feat(#60): preview Ask-Claude streams live progress + cancel"

Task 6: Host E2E — additive proof + cancel

Files:

  • Create: test/e2e/suite/liveProgress.test.ts

The E2E harness can't read notification subtitles, so it asserts the contract: progress is additive (proposals unchanged with progress events) and cancel yields zero proposals. The streamed-UI itself is covered by Task 1 units + the manual smoke.

  • Step 1: Write the test

Create test/e2e/suite/liveProgress.test.ts, modeled on f11Toolbar.test.ts (same getExtensionactivate()api.trackChangesPreviewController pattern, same setEditTurnForTest):

import * as assert from "assert";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";

suite("#60 live progress (additive + cancel)", () => {
  async function setup(): Promise<{ api: CowritingApi; doc: vscode.TextDocument }> {
    const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
    const api = (await ext.activate()) as CowritingApi;
    const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# Title\n\nOld paragraph.\n" });
    await vscode.window.showTextDocument(doc);
    await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
    return { api, doc };
  }

  test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
    const { api, doc } = await setup();
    const ctl = api.trackChangesPreviewController;
    // The stub honors opts.onProgress (emitting synthetic snapshots) but returns
    // the same rewrite — proposals must be unaffected by progress.
    ctl.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-live" };
    });
    const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite");
    assert.ok(ids.length >= 1, "expected at least one proposal regardless of progress events");
  });

  test("an aborted turn proposes nothing (INV-47)", async () => {
    const { api, doc } = await setup();
    const ctl = api.trackChangesPreviewController;
    // Simulate the SDK honoring the abort signal: throw as runEditTurn would on
    // a non-"completed" (aborted) status.
    ctl.setEditTurnForTest(async (_i, _text, opts) => {
      if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
      throw new Error("claude-code turn aborted"); // pre-aborted in this test
    });
    const ac = new AbortController();
    ac.abort();
    let ids: string[] = [];
    try {
      ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
    } catch {
      ids = [];
    }
    assert.strictEqual(ids.length, 0, "aborted turn must create no proposals");
  });
});
  • Step 2: Compile + run the E2E suite

Run: npm run pretest:e2e && npm run test:e2e Expected: the new suite passes alongside the existing E2E suites. (If the sandboxed .vscode-test has the known executeCommand("undo") breakage, the #38 undo suite may skip/fail independently — that is pre-existing per session 0048/0049 and unrelated to #60; confirm only the live-progress suite is green and the rest are unchanged from baseline.)

  • Step 3: Commit
git add test/e2e/suite/liveProgress.test.ts
git commit -m "test(#60): host E2E — progress is additive; abort proposes nothing (INV-44/47)"

Task 7: Manual smoke + final verification

Files: none (verification only)

  • Step 1: Full unit + typecheck + build

Run: npm run typecheck && npx vitest run && npm run build Expected: typecheck clean; all unit tests pass; build succeeds.

  • Step 2: Manual smoke (real LLM)

Per #60 acceptance (E2E can't assert streamed UI). With Claude Code signed in, run the extension (F5 / Extension Development Host), open a markdown doc, and Ask-Claude a non-trivial edit. Confirm:

  • the notification line advances (thinking…writing… (N chars) → token count rising; running <tool>… if a tool fires);
  • the "Cowriting: Claude" OutputChannel reveals (without stealing focus) and streams the text;
  • the cancel aborts cleanly (status resolves, no proposal);
  • toggling cowriting.liveProgress.revealOutput off suppresses the auto-reveal;
  • the final proposals are exactly as before (INV-44).

scripts/smoke-live-turn.mjs (npm run smoke:live) exercises the turn headlessly if a UI smoke isn't convenient; note it does not render the notification.

  • Step 3: Record the smoke result in the session transcript (## Deferred decisions or a smoke note), since E2E doesn't cover the live UI.

Self-Review (completed by plan author)

Spec coverage:

  • §2.1 notification activity line + token count → Task 1 (formatProgressLine), Task 4/5 (progress.report). ✓
  • §2.1 OutputChannel full-text stream + auto-reveal gate → Task 3 (LiveProgressUi, setting). ✓
  • §2.1 cancellation → "cancelled", no proposal → Task 4 (editSelection), Task 5 (preview), Task 6 (E2E). ✓
  • §2.2 reasoning not surfaced → Task 1 (reasoning-delta → thinking, no textDelta). ✓
  • §3.2 pure reducer (INV-46) → Task 1. ✓
  • §3.3 runEditTurn onProgress + AbortSignal, vscode-free (INV-43/44) → Task 2. ✓
  • §3.4 both call sites relay; widened EditTurn seam; stub stays valid → Task 4, Task 5, Task 6. ✓
  • §3.5 OutputChannel append + header → Task 3. ✓
  • §3.6 setting → Task 3. ✓
  • §3.7 INV-43..47 → asserted across Tasks 1/2/6. ✓
  • §4 unit (reducer + format) + host E2E (additive + cancel) + manual smoke → Tasks 1, 6, 7. ✓

Placeholder scan: none (every code/command step is concrete). The Task 4 Step 2 token_was_cancelled(err) pseudo-line is immediately replaced by the concrete inner-try/catch in the same step.

Type consistency: TurnProgressSnapshot, RunEditTurnOptions, TurnUi, LiveProgressUi, reduceTurnProgress, formatProgressLine, createTurnProgressState used consistently across tasks; editTurn widened uniformly; toolName/inputTokens+outputTokens match the SDK agent.d.ts.