Files

20 KiB

status
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 <tool>…) + 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 runEditTurnagent.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 — runEditTurnrunEditAndPropose → 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: <instruction> ──. 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.

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):

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<EditTurnResult>;

Inside, around the existing agent.run(...):

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(`<instruction>…</instruction><text>…</text>`);
  // 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:

// 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:

// trackChangesPreview.ts — widen the injectable seam (back-compat: opts optional)
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;

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: <instruction> ── 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-43liveTurn.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-45No 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-47Cancellation 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).
  • Canceltokenagent.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.