399 lines
20 KiB
Markdown
399 lines
20 KiB
Markdown
---
|
|
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:
|
|
|
|
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.
|
|
|
|
```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<EditTurnResult>;
|
|
```
|
|
|
|
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(`<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:
|
|
|
|
```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<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-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.
|