import type { EventFrame } from "./protocol"; // ---------- event list merge ---------- export function mergeEvents( existing: EventFrame[], incoming: EventFrame[], ): EventFrame[] { const bySeq = new Map(); for (const e of existing) bySeq.set(e.seq, e); for (const e of incoming) bySeq.set(e.seq, e); return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq); } /** Highest persisted seq — deltas (`message_update`) are not persisted, so they * must not advance the refetch cursor. */ export function lastPersistedSeq(events: EventFrame[]): number { let max = 0; for (const e of events) if (e.type !== "message_update" && e.seq > max) max = e.seq; return max; } // ---------- chat view derivation ---------- export interface ToolState { id: string; name: string; args: string; running: boolean; isError: boolean; preview: string; } export interface ChatMessage { key: string; role: "user" | "assistant" | "system" | "toolResult"; text: string; thinking: string | null; toolCalls: { id: string; name: string; argsJson: string }[]; toolCallId: string | null; streaming: boolean; /** envelope ts of the persisted message_end (0 while streaming) */ ts: number; /** true for daemon/plugin error notices: rendered as a notice line, not a chat bubble */ notice?: boolean; } export interface ChatDerivation { messages: ChatMessage[]; tools: Map; busy: boolean; usage: { inputTokens: number; outputTokens: number; totalCost: number }; } // The plugin mirrors pi extension events verbatim: tool args arrive as a raw // JSON object (or string in older frames). Normalizing at this boundary keeps // the render layer string-only; regression: object args crashed React (#31). export function argsText(args: unknown): string { if (typeof args === "string") return args; if (args === null || args === undefined) return ""; try { return JSON.stringify(args, null, 2) ?? ""; } catch { return String(args); } } export function deriveChat(events: EventFrame[]): ChatDerivation { const messages: ChatMessage[] = []; const tools = new Map(); let stream: { id: string; text: string } | null = null; let busy = false; const usage = { inputTokens: 0, outputTokens: 0, totalCost: 0 }; for (const e of events) { switch (e.type) { case "tool_execution_start": { if (e.toolCallId !== undefined) { tools.set(e.toolCallId, { id: e.toolCallId, name: e.toolName ?? "tool", args: argsText(e.args), running: true, isError: false, preview: "", }); } break; } case "tool_execution_end": { const t = e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined; if (t !== undefined) { t.running = false; t.isError = e.isError ?? false; t.preview = e.resultPreview ?? ""; } break; } case "agent_start": busy = true; break; case "agent_end": { const u = e.usage; if (u !== undefined) { usage.inputTokens += u.inputTokens ?? 0; usage.outputTokens += u.outputTokens ?? 0; usage.totalCost += u.totalCost ?? 0; } break; } case "agent_settled": busy = false; break; case "message_start": if (e.message?.role === "assistant") stream = { id: e.message.id, text: "" }; break; case "message_update": if (stream !== null) stream.text += e.delta ?? ""; break; case "message_end": { const m = e.message; if (m !== undefined) { if (stream !== null && stream.id === m.id) stream = null; messages.push({ key: `msg-${e.seq}`, role: m.role, text: m.text, thinking: m.thinking, toolCalls: m.toolCalls ?? [], toolCallId: m.toolCallId, streaming: false, ts: e.ts ?? 0, }); } break; } case "error_notice": messages.push({ key: `notice-${e.seq}`, role: "system", text: e.reason ?? "unknown error", thinking: null, toolCalls: [], toolCallId: null, streaming: false, ts: e.ts ?? 0, notice: true, }); break; default: break; } } if (stream !== null) { messages.push({ key: `stream-${stream.id}`, role: "assistant", text: stream.text, thinking: null, toolCalls: [], toolCallId: null, streaming: true, ts: 0, }); } return { messages, tools, busy: busy || stream !== null, usage }; } // ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ---------- export type TodoStatus = "pending" | "in-progress" | "completed"; export interface TodoItem { content: string; status: TodoStatus; deleted: boolean; } export interface SubagentRun { key: string; name: string; running: boolean; isError: boolean; } export interface TaskDerivation { todos: TodoItem[]; subagents: SubagentRun[]; workingTools: ToolState[]; } const TODO_TOOL = "todo"; const SUBAGENT_TOOL = "subagent"; function parseJson(raw: unknown): unknown { if (typeof raw === "object" && raw !== null) return raw; // already-decoded args if (typeof raw !== "string" || raw.length === 0) return undefined; try { return JSON.parse(raw) as unknown; } catch { return undefined; } } function normalizeStatus(raw: unknown): TodoStatus { if (typeof raw !== "string") return "pending"; switch (raw.toLowerCase()) { case "in_progress": case "in-progress": case "inprogress": case "in progress": case "doing": case "started": return "in-progress"; case "completed": case "complete": case "done": return "completed"; default: return "pending"; } } function extractSnapshot( raw: unknown, ): { content: string; status: TodoStatus }[] | null { let arr: unknown = raw; if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") { const o = raw as Record; const nested = o.todos ?? o.items ?? o.tasks ?? o.list; if (Array.isArray(nested)) arr = nested; } if (Array.isArray(arr) === false) return null; const items: { content: string; status: TodoStatus }[] = []; for (const entry of arr) { if (typeof entry === "string") { items.push({ content: entry, status: "pending" }); continue; } if (entry !== null && typeof entry === "object") { const o = entry as Record; const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary; if (typeof content === "string" && content.length > 0) { items.push({ content, status: normalizeStatus(o.status) }); } } } return items.length > 0 ? items : null; } export function deriveTasks(events: EventFrame[]): TaskDerivation { const toolNames = new Map(); // toolCallId -> toolName for (const e of events) { if (e.type === "tool_execution_start" && e.toolCallId !== undefined) { toolNames.set(e.toolCallId, e.toolName ?? ""); } } // todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message const snapshots: { seq: number; items: { content: string; status: TodoStatus }[]; }[] = []; const subagents: SubagentRun[] = []; const runningTools: ToolState[] = []; for (const e of events) { if (e.type === "tool_execution_start" && e.toolCallId !== undefined) { const name = e.toolName ?? ""; if (name === TODO_TOOL) { const snap = extractSnapshot(parseJson(e.args)); if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); } else if (name === SUBAGENT_TOOL) { const parsed = parseJson(e.args); const o = parsed !== null && typeof parsed === "object" ? (parsed as Record) : {}; const nameField = o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role; subagents.push({ key: e.toolCallId, name: typeof nameField === "string" && nameField.length > 0 ? nameField : "subagent", running: true, isError: false, }); } else { runningTools.push({ id: e.toolCallId, name, args: argsText(e.args), running: true, isError: false, preview: "", }); } } else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) { const name = toolNames.get(e.toolCallId) ?? ""; if (name === SUBAGENT_TOOL) { const run = subagents.find((s) => s.key === e.toolCallId); if (run !== undefined) { run.running = false; run.isError = e.isError ?? false; } } else if (name !== TODO_TOOL) { const t = runningTools.find((w) => w.id === e.toolCallId); if (t !== undefined) { t.running = false; t.isError = e.isError ?? false; t.preview = e.resultPreview ?? ""; } } if (name === TODO_TOOL) { const snap = extractSnapshot(parseJson(e.resultPreview)); if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); } } else if ( e.type === "message_end" && e.message?.role === "toolResult" && e.message.toolCallId !== null ) { const name = toolNames.get(e.message.toolCallId) ?? ""; if (name === TODO_TOOL) { const snap = extractSnapshot(parseJson(e.message.text)); if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); } } } // todos: latest snapshot wins; earlier items missing from it are deleted const todos: TodoItem[] = []; if (snapshots.length > 0) { snapshots.sort((a, b) => a.seq - b.seq); const latest = snapshots[snapshots.length - 1]?.items ?? []; const seen = new Map(); for (const snap of snapshots) { for (const item of snap.items) if (!seen.has(item.content)) seen.set(item.content, item.status); } for (const item of latest) { todos.push({ content: item.content, status: item.status, deleted: false, }); seen.delete(item.content); } for (const [content] of seen) todos.push({ content, status: "pending", deleted: true }); } return { todos, subagents, workingTools: runningTools.filter((t) => t.running), }; }