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