644885c6ec
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
123 lines
4.7 KiB
TypeScript
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);
|
|
}
|