Files
vscode-cowriting-plugin/src/turnProgress.ts
T
benstull 644885c6ec #60: live turn progress (activity line + token count + OutputChannel stream + cancel) (#61)
Surface Claude live output/progress during the asking-Claude status: notification activity line + token count, a Cowriting: Claude OutputChannel streaming assistant text, and a cancellable turn. Pure turnProgress reducer + runEditTurn onProgress/AbortSignal (vscode-free) + both call sites. INV-43..47.

Fixes #60
2026-06-26 11:53:10 +00:00

123 lines
4.7 KiB
TypeScript

/**
* 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/shared";
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);
}