--- status: graduated --- # Solution Design: Live Turn Progress (#60) | | | | --- | --- | | **Author(s)** | Ben Stull (with Claude) | | **Reviewers / approvers** | Ben Stull | | **Status** | `graduated` | | **Version** | v0.1.0 | | **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#60` (Show Claude's live output/progress during the "asking Claude…" status, `type/feature`, `priority/P1`) · Capture session `vscode-cowriting-plugin-0054` (2026-06-15) · Brainstorming session `vscode-cowriting-plugin-0055` · Builds on (all shipped): the LiveTurn machine-edit ingress (`liveTurn.ts`, INV-8/9), F4 `#12` (propose/accept seam), F10 `#29` (interactive review preview), F11 `#43` (preview toolbar + `editDocument`/`runEditAndPropose`) · Parent specs (graduated): `coauthoring-inner-loop.md`, `coauthoring-propose-accept.md`, `coauthoring-rendered-preview.md`, `coauthoring-document-edit-flow.md` · Lineage: `ben.stull/rfc-app#48` | **Change log** | Date | Version | Change | By | | --- | --- | --- | --- | | 2026-06-22 | v0.1.0 | Initial draft — brainstorming session 0055. Three forks locked with the operator: **(1) surface** = the existing "asking Claude…" notification carries a live **activity line** *plus* a host **OutputChannel** streaming the full assistant text; **(2) content** = activity (`thinking…`/`writing… (N chars)`/`running …`) **+ running token count**; **(3) cancellation** = reflect aborted/failed states **and** add a cancel button wired to `agent.abort()`. Two sub-decisions confirmed: OutputChannel auto-reveals (preserveFocus) on first text delta, gated by a `cowriting.liveProgress.revealOutput` setting (default on); the channel **appends** per-turn (with a header), doubling as a debug log. | Ben Stull + Claude | --- ## 1. Business Context ### 1.1 Executive Summary When the writer asks Claude to edit, the plugin shows one static notification — *"Cowriting: asking Claude…"* — and then nothing until the whole turn resolves. Both edit entry points (`editSelection` in `extension.ts:232` and the preview's `askClaude` in `trackChangesPreview.ts:232`) `await` the turn as **one opaque promise** (`liveTurn.ts` `runEditTurn` → `agent.run()`). For anything past a trivial edit this is a black box: the writer can't tell whether Claude is thinking, reading, partway through writing, or stuck — and a long edit feels like the tool hung. The fix is small and almost entirely *additive observability*: `@cline/sdk` **already streams** everything we need (`assistant-text-delta`, `tool-started|finished`, `usage-updated`, lifecycle events) via `agent.subscribe(listener)`, and exposes `agent.abort()` for cancellation — the extension just never subscribes (`liveTurn.ts` constructs the Agent with no hook). This design wires that stream to the existing notification (a live **activity line** + running **token count**), mirrors the full assistant text into a host **OutputChannel** so the writer can *read it forming*, and makes the notification **cancellable**. The final result path — `runEditTurn` → `runEditAndPropose` → F4 proposals — is **untouched**. ### 1.2 Background The plugin's machine-edit ingress is `LiveTurn` (`liveTurn.ts`, spec `coauthoring-inner-loop.md` §6.2): one `@cline/sdk` Agent turn on the built-in `claude-code` provider (INV-8 — the extension holds no credentials; it rides the operator's local Claude Code login). The turn's result feeds the F4 propose/accept seam (`coauthoring-propose-accept.md`) and is surfaced in the F10/F11 interactive review preview. `liveTurn.ts` is **deliberately `vscode`-free** (its module header says so) so the SDK ingress stays independent of the editor host. Two gestures invoke a turn, both wrapping it in `vscode.window.withProgress(… "asking Claude…")`: - **`editSelection`** (`extension.ts`) — selection → one single-range proposal. - **preview `askClaude`** (`trackChangesPreview.ts`) — selection or whole document → one proposal (range) or one-per-changed-block (document, INV-39). The preview path runs the turn through an **injectable** seam — `editTurn` (`trackChangesPreview.ts:56`, type `EditTurn`) — so host E2E can stub the LLM (no Claude in CI). This design widens that seam without breaking the stub. ### 1.3 Who feels it & why it's P1 The **human coauthor** — anyone who asks Claude to edit and waits for the turn, most acutely on longer/slower edits. It is **P1** because the opaque wait hurts *every* Claude turn (the core gesture of the tool), the enabling stream already exists (low cost), and visible progress materially raises trust ("the tool is alive and on task"). WSJF (issue #60): Value 6 · TC 3 · OE 4 ÷ Size 4 ≈ 3.25. --- ## 2. Product Design ### 2.1 The experience During a turn, the writer sees **two coordinated surfaces**, both tied to the existing "asking Claude…" status — no new window, no webview change: 1. **The notification line (legible motion + scale).** Below the "Cowriting: asking Claude…" title, a live activity line that updates as events arrive: | State | Line | | --- | --- | | before first output | `thinking…` | | streaming text | `writing… (412 chars)` | | streaming text + usage seen | `writing… · 1.2k tokens` | | a tool is running | `running read_file…` | The line shows the **current phase** and, once a `usage-updated` event has arrived, the **running token total** (input + output). `chars` and `tokens` render together when both are known (`writing… (412 chars) · 1.2k tokens`). 2. **The OutputChannel (read it forming).** A single shared `"Cowriting"` output channel receives the **full assistant text** as it streams, under a per-turn header `── asking: ──`. On the **first** text delta the channel reveals itself **without stealing focus** (`show(true)`), so the writer can start reading the output before the turn finishes. Auto-reveal is gated by a setting `cowriting.liveProgress.revealOutput` (default `true`) for writers who prefer to open it manually. The notification is **cancellable** (`✕`): clicking it aborts the turn (`agent.abort()`); the status resolves to **"cancelled"** (not a frozen spinner, not a "failed" error), and **no proposal is created**. If the turn *fails*, the existing error path shows the error (no longer a frozen "asking Claude…"). ### 2.2 What this is *not* (non-goals — from #60) - **Not a chat/transcript panel.** This is *in-flight progress*, not a persistent conversation history surface. (The OutputChannel's append-log is a debug convenience, not a product history feature.) - **Not streaming into the document.** The result still lands as F4 proposals exactly as today; only *observation* is added. - **Not a cancellation feature in its own right.** The `✕` button + abort wiring are the cheap, in-scope *reflection* of cancel; rich cancel controls (partial apply, resume, queueing) are out of scope. - **Not surfacing reasoning text.** `assistant-reasoning-delta` is collapsed to a generic `thinking…`; the chain-of-thought text is **not** shown (kept the notification/OutputChannel uncluttered; operator fork). ### 2.3 Surfaces considered & rejected | Surface | Why not (as the *primary*) | | --- | --- | | **Webview relay** (into the preview) | The `editSelection` path has **no** webview; can't cover both entry points uniformly (acceptance requires both). | | **Status-bar item only** | One terse line; no room for streamed text; weaker "what is it doing" signal than the in-status notification line. | | **OutputChannel only** (notification stays a plain spinner) | The "in/alongside the asking-Claude status" signal weakens — the writer must open the Output panel to learn anything. | Chosen: **notification activity-line (primary, in-status) + OutputChannel (secondary, full text)** — covers both call sites, no new network/webview surface (INV-21/INV-45), and delivers both "see motion" and "read output as it forms." --- ## 3. Engineering Design ### 3.1 Architecture — three units ``` @cline/sdk Agent events (raw AgentRuntimeEvent stream) │ agent.subscribe(listener) ▼ ┌─────────────────────────┐ pure, vscode-free, SDK-free │ turnProgress.ts (NEW) │ reduce(state, event) → TurnProgressSnapshot │ the reducer (INV-46) │ └─────────────────────────┘ │ TurnProgressSnapshot ▼ ┌─────────────────────────┐ vscode-free (INV-43) │ liveTurn.ts runEditTurn │ subscribe → reduce → onProgress(snapshot); │ (EXTENDED) │ signal?.abort → agent.abort(); result UNCHANGED └─────────────────────────┘ │ onProgress(snapshot) + AbortSignal in ▼ ┌─────────────────────────┐ the two UI call sites (relay only) │ extension.ts:editSelection │ format notification line · append OutputChannel · │ trackChangesPreview:askClaude │ withProgress({cancellable}) · token→AbortController └─────────────────────────┘ ``` The decisive layering rule: **progress flows *out* of `liveTurn` as a domain `TurnProgressSnapshot`; cancel flows *in* as a web-standard `AbortSignal`.** No `vscode` import is added to `liveTurn.ts` or `turnProgress.ts` (INV-43). ### 3.2 `src/turnProgress.ts` (new, pure) — INV-46 The reducer owns *all* event→state logic so the call sites stay dumb (format + relay only) and the logic is unit-tested in isolation. ```ts export interface TurnProgressSnapshot { phase: "thinking" | "writing" | "tool"; tool?: string; // present iff phase === "tool" chars: number; // accumulated assistant-text length so far tokens?: number; // running total (input+output); undefined until first usage event textDelta?: string; // the new assistant-text chunk since the last snapshot } // Opaque carried state; start from createTurnProgressState(). export interface TurnProgressState { /* phase, chars, tokens, toolDepth/name */ } export function createTurnProgressState(): TurnProgressState; // Pure: fold one SDK event into the state, returning the next state and the // snapshot to emit (or undefined for events that don't change the surface). export function reduceTurnProgress( state: TurnProgressState, event: AgentRuntimeEvent, // imported as a TYPE only from @cline/shared ): { state: TurnProgressState; snapshot?: TurnProgressSnapshot }; ``` Event mapping (the relevant `AgentRuntimeEvent` members): | SDK event | Effect on snapshot | | --- | --- | | `run-started` / `turn-started` | `phase: "thinking"` | | `assistant-text-delta` | `phase: "writing"`, `chars = accumulatedText.length`, `textDelta = text` | | `assistant-reasoning-delta` | `phase: "thinking"` (text **not** surfaced) | | `tool-started` / `tool-updated` | `phase: "tool"`, `tool = toolCall.name` | | `tool-finished` | revert `phase` to `writing` if any text seen, else `thinking` | | `usage-updated` | `tokens = usage` total (input+output) | | `turn-finished` / `run-finished` / `run-failed` | no snapshot (resolution handled by the turn promise) | `AgentRuntimeEvent` is imported **type-only** (`import type`) — `turnProgress.ts` has **no runtime dependency** on the SDK and no `vscode` import, so it is a pure, fast unit (INV-43/46). Tool concurrency is tracked by name/depth so overlapping `tool-started`/`tool-finished` pairs resolve the phase correctly. ### 3.3 `liveTurn.ts` `runEditTurn` (extended) `opts` gains two optional fields; the signature and result type are otherwise unchanged (INV-44): ```ts export interface RunEditTurnOptions { modelId?: string; onProgress?: (s: TurnProgressSnapshot) => void; // out: live progress signal?: AbortSignal; // in: cancellation } export async function runEditTurn( instruction: string, selectedText: string, opts?: RunEditTurnOptions, ): Promise; ``` Inside, around the existing `agent.run(...)`: ```ts const agent = new sdk.Agent({ providerId: "claude-code", modelId, systemPrompt }); let state = createTurnProgressState(); const unsubscribe = opts?.onProgress ? agent.subscribe((ev) => { const next = reduceTurnProgress(state, ev); 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(``); // existing non-"completed" guard throws (covers "aborted" and "failed") … } finally { unsubscribe?.(); opts?.signal?.removeEventListener("abort", onAbort); } ``` `AbortSignal`/`addEventListener` are web standards (no `vscode`), preserving INV-43. An aborted turn yields `status: "aborted"` → the existing guard throws → the call site reflects "cancelled" and proposes nothing (INV-47). **`extractReplacement` and the return shape are untouched** (INV-44). ### 3.4 The two call sites (UI relay) A small shared host module owns the relay so both sites are identical: ```ts // src/liveProgressUi.ts (host; vscode-only) export function createLiveProgressUi(): { channel: vscode.OutputChannel; // Begin a turn: clears nothing, writes the header, returns the wiring for // one withProgress run. begin(instruction: string, progress: vscode.Progress<{message: string}>, token: vscode.CancellationToken): { signal: AbortSignal; onProgress: (s: TurnProgressSnapshot) => void }; }; ``` - **`onProgress(s)`** → `progress.report({ message: formatLine(s) })` where `formatLine` renders `phase`/`chars`/`tokens`; and appends `s.textDelta` (if any) to the shared `"Cowriting"` channel. On the **first** non-empty `textDelta`, if `cowriting.liveProgress.revealOutput` is `true`, call `channel.show(true)` once. - **`signal`** comes from an `AbortController` whose `abort()` is fired by `token.onCancellationRequested`. - The `withProgress` options gain **`cancellable: true`**. - On `catch`, the call site checks `token.isCancellationRequested` → show **"cancelled"** (information) instead of the "failed" error. `extension.ts:editSelection` passes `{ onProgress, signal }` straight into `runEditTurn`. The preview path threads the same through its seam: ```ts // trackChangesPreview.ts — widen the injectable seam (back-compat: opts optional) type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; ``` `runEditAndPropose` forwards `opts` to its single `editTurn(...)` call (both the `range` and `document` branches invoke the turn exactly once per gesture, so there is one progress stream per gesture). The **host-E2E stub** (the default-overriding injection) keeps working — `opts` is optional and ignored by the stub; a focused test may simulate progress by calling `opts?.onProgress` (§4). ### 3.5 OutputChannel semantics - **One shared channel** (`createOutputChannel("Cowriting")`), created once at activation and disposed with the extension. - **Append, don't clear** — each turn writes a `── asking: ──` header then streams text, so the channel doubles as a debug log of recent turns (per #60 solution-notes). (Trimming an unbounded log is a possible later increment, not v1.) - **Reveal** on first text delta, `preserveFocus: true`, gated by `cowriting.liveProgress.revealOutput` (boolean, default `true`). ### 3.6 New configuration | Setting | Type | Default | Effect | | --- | --- | --- | --- | | `cowriting.liveProgress.revealOutput` | boolean | `true` | Auto-reveal the "Cowriting" OutputChannel (without focus) on the first streamed text of a turn. | Declared in `package.json` `contributes.configuration`. ### 3.7 Invariants - **INV-43** — `liveTurn.ts` and `turnProgress.ts` stay **`vscode`-free**: progress leaves as a domain `TurnProgressSnapshot`, cancel enters as a web-standard `AbortSignal`; `AgentRuntimeEvent` is imported **type-only**. No `vscode` import is added to either module. - **INV-44** — Live progress is **purely additive**: it never changes the result path (`runEditTurn` return shape, `extractReplacement`, `runEditAndPropose`, the F4 proposals). A turn that emits **no** events (the E2E stub) produces the identical proposals it does today. - **INV-45** — **No new network/webview surface.** Progress rides the existing `withProgress` notification + a host `OutputChannel` only; the sealed webview is untouched (preserves INV-21). - **INV-46** — The event→progress **reduction is a pure function** (`reduceTurnProgress`), unit-tested in isolation; the UI call sites only format and relay its snapshots. - **INV-47** — **Cancellation reflects, doesn't mutate.** Abort surfaces "cancelled" (not a frozen spinner, not a silent partial apply); an aborted turn proposes **nothing** (parity with the empty / no-change outcomes). ### 3.8 Error & edge handling - **Failure** — non-"completed" status throws (existing guard); call site shows the error message, the notification resolves (no frozen spinner). - **Cancel** — `token` → `agent.abort()` → `status: "aborted"` → guarded throw → call site detects `token.isCancellationRequested` → "cancelled", no proposal. - **No events** — if the provider emits nothing before completing, the line stays `thinking…` and resolves normally; result identical to today (INV-44). - **Overlapping tools** — phase tracked by tool name/depth so interleaved `tool-started`/`tool-finished` resolve back to `writing`/`thinking` correctly. - **Empty / no-change replacement** — unchanged from today (warning / info), independent of progress. --- ## 4. Testing & E2E Per the §9 pipeline and `coauthoring-*` precedent (unit + host E2E; this is a VS Code extension — **no flotilla/PPE**, the pipeline's deploy tiers are N/A; the gate is unit + host-E2E green). - **Unit (the core):** `turnProgress.test.ts` exercises `reduceTurnProgress` across the full event table — phase transitions (thinking→writing→tool→writing), `chars` accumulation, `tokens` appearing only after `usage-updated`, `textDelta` carrying each chunk, reasoning-delta staying `thinking`, overlapping tools. Pure, no vscode/SDK. (`formatLine` gets its own small unit test.) - **Unit (relay):** `liveProgressUi` line formatting and first-delta reveal gating (mock `OutputChannel`/`Progress`). - **Host E2E:** the existing preview `editTurn` stub is extended in one test to **emit synthetic progress** (call `opts.onProgress` with a couple of snapshots) and assert the turn still yields the same proposals (INV-44) and that cancel (firing the token) yields **zero** proposals (INV-47). Streamed UI itself (the live notification text) isn't asserted — E2E can't read notification subtitles reliably; that's covered by the unit tests on the mapping + a **manual smoke** on a longer real edit (per #60 acceptance). - **Manual smoke:** a longer real edit shows the activity line advancing, token count rising, the OutputChannel streaming, and `✕` cancelling cleanly. --- ## 5. Delivery Plan (rollout — not a task list) One increment (the feature is a single design-then-build, #60 decomposition). Ships through the standard branch → PR → `main` flow; no migration, no persisted state, no config beyond the one additive setting (default preserves prior behavior except that progress is now visible). Reversible by reverting the PR. The implementation plan (downstream `wgl-planning-and-executing` session) owns the Task breakdown; a natural cut is: **(1)** pure `turnProgress.ts` + tests → **(2)** `runEditTurn` subscribe/abort wiring → **(3)** `liveProgressUi` + both call sites + setting → **(4)** host-E2E stub extension + manual smoke. --- ## 6. Open Questions - **OQ-1** — Unbounded OutputChannel log: append-forever is fine for v1 (it's a manual debug surface); if it becomes noisy, add per-turn trimming or a "clear on new turn" mode later. *(Deferred — not blocking.)* - **OQ-2 (inherited)** — The F11 design (`#43`) is still un-graduated (lives in code + an issue draft). Unrelated to #60 but noted in the shared lineage; track separately.