diff --git a/replicas-matrix-bridge/src/poller.ts b/replicas-matrix-bridge/src/poller.ts index 6b31c59e..301681a4 100644 --- a/replicas-matrix-bridge/src/poller.ts +++ b/replicas-matrix-bridge/src/poller.ts @@ -61,6 +61,12 @@ interface ContentBlock { thinking?: string; name?: string; input?: Record; + // tool_use blocks: Anthropic-assigned id ("toolu_…"). Used to pair the + // later tool_result back to its tool_use so we can swap the running 🔄 + // status icon on the rendered line in place to ✅ / ❌. + id?: string; + // tool_result blocks: points at the originating tool_use.id. + tool_use_id?: string; // tool_result blocks only — the raw output (string) or list of inner blocks. content?: string | Array<{ type?: string; text?: string }>; is_error?: boolean; @@ -201,6 +207,9 @@ export class ReplicaPoller { "lastTypingAt", "lastEditAt", "plan", + // Per-turn tool lifecycle tracker — wipe so the next turn doesn't + // inherit stale tool_use_id → line mappings. + "toolLineIndex", ]); const baseline = await baselineP; const seed: Record = { @@ -311,6 +320,12 @@ export class ReplicaPoller { let stepCount = (snap.get("stepCount") as number | undefined) ?? 0; let currentAction = (snap.get("currentAction") as string | undefined) ?? ""; let plan = (snap.get("plan") as PlanState | undefined) ?? null; + // Per-tool lifecycle tracker: tool_use.id → index in `lines`. When the + // matching tool_result lands later, we use this to find and mutate the + // `🔄 …` status prefix in place (→ `✅ …` or `❌ …`). Matches the + // OpenACP per-line lifecycle icon UX. + const toolLineIndex = + (await this.state.storage.get>("toolLineIndex")) ?? {}; let systemInfo = (await this.state.storage.get("systemInfo")) ?? null; let contextUsage = (await this.state.storage.get("contextUsage")) ?? null; let resultMeta = (await this.state.storage.get("resultMeta")) ?? null; @@ -344,7 +359,15 @@ export class ReplicaPoller { (block.input ?? {}) as Record, ); currentAction = line; - lines.push(line); + // Emit with a 🔄 lifecycle prefix to show "running". + // The matching tool_result will swap this in place + // to ✅ on success or ❌ on error. Record the row + // index by the Anthropic tool_use.id so the pairing + // survives across alarm ticks. + const renderedLine = `🔄 ${line}`; + const newIdx = lines.length; + lines.push(renderedLine); + if (block.id) toolLineIndex[block.id] = newIdx; stepCount += 1; appended = true; } else if (block.type === "text" && block.text) { @@ -422,6 +445,28 @@ export class ReplicaPoller { // so the reader sees both the call and its outcome. for (const block of content) { if (block.type === "tool_result") { + const isErr = block.is_error === true; + + // Mutate the originating tool_use line's status prefix + // in place: 🔄 → ✅ on success, 🔄 → ❌ on error. The + // OpenACP "each tool ticks live" UX. We find the row by + // tool_use_id via the toolLineIndex we built when the + // tool_use was first projected. + if (block.tool_use_id && toolLineIndex[block.tool_use_id] !== undefined) { + const idx = toolLineIndex[block.tool_use_id]!; + if (idx >= 0 && idx < lines.length) { + const newPrefix = isErr ? "❌ " : "✅ "; + const before = lines[idx]!; + if (before.startsWith("🔄 ")) { + lines[idx] = newPrefix + before.slice("🔄 ".length); + appended = true; + } + } + // One-shot: clear so a malformed second result for + // the same tool_use_id can't keep flipping the row. + delete toolLineIndex[block.tool_use_id]; + } + const raw = block.content; let preview = ""; if (typeof raw === "string") preview = raw; @@ -433,7 +478,6 @@ export class ReplicaPoller { preview = preview.replace(/\s+/g, " ").trim(); if (preview.length > 0) { const trimmed = preview.length > 100 ? preview.slice(0, 99) + "…" : preview; - const isErr = block.is_error === true; const icon = isErr ? "✗" : "↳"; lines.push(`${icon} ${escapeHtml(trimmed)}`); appended = true; @@ -466,6 +510,7 @@ export class ReplicaPoller { stepCount, currentAction, lastSeenCount: events.length, + toolLineIndex, }; if (plan) writes.plan = plan; if (systemInfo) writes.systemInfo = systemInfo; diff --git a/replicas-matrix-bridge/src/render.test.ts b/replicas-matrix-bridge/src/render.test.ts index 35246441..f9f60f76 100644 --- a/replicas-matrix-bridge/src/render.test.ts +++ b/replicas-matrix-bridge/src/render.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { formatCost, + renderToolsHeader, formatDuration, formatToolUseLine, parsePlan, @@ -441,3 +442,46 @@ describe("formatCost", () => { expect(formatCost(1.42)).toBe("$1.42"); }); }); + +describe("renderToolsHeader", () => { + const run = (s: string) => `🔄 ${s}`; + const ok = (s: string) => `✅ ${s}`; + const err = (s: string) => `❌ ${s}`; + const tool = (s: string) => `🔧 ${s}`; + + it("returns empty when there are no tool lines", () => { + expect(renderToolsHeader([])).toBe(""); + expect(renderToolsHeader(["💬 some narration", "↳ standalone output"])).toBe(""); + }); + + it("counts running tools toward the total but not the done count", () => { + const lines = [run(tool("ls")), run(tool("pwd"))]; + expect(renderToolsHeader(lines)).toBe("📋 Tools (0/2)"); + }); + + it("counts both ✅ and ❌ as done", () => { + const lines = [ + ok(tool("ls")), + err(tool("rm -rf /")), + run(tool("git status")), + ]; + expect(renderToolsHeader(lines)).toBe("📋 Tools (2/3)"); + }); + + it("appends ✅ to the header when all tools are done", () => { + const lines = [ok(tool("ls")), ok(tool("pwd")), err(tool("nope"))]; + // 3 done, 3 total → all complete + expect(renderToolsHeader(lines)).toBe("📋 Tools (3/3) ✅"); + }); + + it("ignores non-tool lines mixed in (narration, outputs, user steers)", () => { + const lines = [ + "💬 Reading the codebase", + ok(tool("ls")), + "↳ index.ts main.ts", + "💬 You: now what", + run(tool("grep foo")), + ]; + expect(renderToolsHeader(lines)).toBe("📋 Tools (1/2)"); + }); +}); diff --git a/replicas-matrix-bridge/src/render.ts b/replicas-matrix-bridge/src/render.ts index 58be4807..0663df63 100644 --- a/replicas-matrix-bridge/src/render.ts +++ b/replicas-matrix-bridge/src/render.ts @@ -244,6 +244,29 @@ export function formatCost(usd: number): string { return `$${Math.round(usd)}`; } +// Scan `lines` for the 🔄 / ✅ / ❌ lifecycle prefixes and tally a +// "📋 Tools (M/N)" header line. Stateless — relies on the prefixes the +// poller writes when it pairs each tool_use to its later tool_result. +// +// Returns the rendered header HTML, or "" when there are no tool lines. +// Adds a trailing " ✅" when M === N AND N > 0 — matches the OpenACP +// allComplete header treatment. +export function renderToolsHeader(lines: string[]): string { + let total = 0; + let done = 0; + for (const line of lines) { + if (line.startsWith("🔄 ")) { + total += 1; + } else if (line.startsWith("✅ ") || line.startsWith("❌ ")) { + total += 1; + done += 1; + } + } + if (total === 0) return ""; + const allComplete = done === total && total > 0 ? " ✅" : ""; + return `📋 Tools (${done}/${total})${allComplete}`; +} + function renderActive(state: StatusState): string { const elapsedSec = Math.max(0, Math.round((Date.now() - state.startedAt) / 1000)); const headerParts: string[] = [ @@ -268,6 +291,13 @@ function renderActive(state: StatusState): string { blocks.push(renderPlan(state.plan)); } + // "📋 Tools (M/N) ✅" header — live completion count derived from the + // 🔄 / ✅ / ❌ lifecycle prefixes the poller writes on each tool line. + // Sits above the rolling log so the user sees "how many tools done" at + // a glance before reading the individual lines. + const toolsHeader = renderToolsHeader(state.lines); + if (toolsHeader) blocks.push(toolsHeader); + // Stream every tool call through unmodified. Tool calls are the point — // the user is watching the agent work, line by line in real time. The // older overflow goes into
("Show more") so the @@ -344,6 +374,11 @@ function renderTerminal(state: StatusState): string { blocks.push(renderPlan(state.plan)); } + // "📋 Tools (M/N) ✅" header — same as renderActive. On the Done frame + // this is a permanent at-a-glance recap of how many tools ran. + const toolsHeader = renderToolsHeader(state.lines); + if (toolsHeader) blocks.push(toolsHeader); + // Keep the rolling log (tool-calls, narration) visible after terminal so // the reader can see what actually happened during the turn — same // window as the in-progress render. Tool calls are the showpiece; the