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 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:
-
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 tokensa tool is running running read_file…The line shows the current phase and, once a
usage-updatedevent has arrived, the running token total (input + output).charsandtokensrender together when both are known (writing… (412 chars) · 1.2k tokens). -
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 settingcowriting.liveProgress.revealOutput(defaulttrue) 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-deltais collapsed to a genericthinking…; 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) })whereformatLinerendersphase/chars/tokens; and appendss.textDelta(if any) to the shared"Cowriting"channel. On the first non-emptytextDelta, ifcowriting.liveProgress.revealOutputistrue, callchannel.show(true)once.signalcomes from anAbortControllerwhoseabort()is fired bytoken.onCancellationRequested.- The
withProgressoptions gaincancellable: true. - On
catch, the call site checkstoken.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 bycowriting.liveProgress.revealOutput(boolean, defaulttrue).
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.tsandturnProgress.tsstayvscode-free: progress leaves as a domainTurnProgressSnapshot, cancel enters as a web-standardAbortSignal;AgentRuntimeEventis imported type-only. Novscodeimport is added to either module. - INV-44 — Live progress is purely additive: it never changes the result
path (
runEditTurnreturn 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
withProgressnotification + a hostOutputChannelonly; 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 detectstoken.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-finishedresolve back towriting/thinkingcorrectly. - 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.tsexercisesreduceTurnProgressacross the full event table — phase transitions (thinking→writing→tool→writing),charsaccumulation,tokensappearing only afterusage-updated,textDeltacarrying each chunk, reasoning-delta stayingthinking, overlapping tools. Pure, no vscode/SDK. (formatLinegets its own small unit test.) - Unit (relay):
liveProgressUiline formatting and first-delta reveal gating (mockOutputChannel/Progress). - Host E2E: the existing preview
editTurnstub is extended in one test to emit synthetic progress (callopts.onProgresswith 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.