From 11a0b41f3da941cb9eacb0f2a937faf9a746302d Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 17:34:03 +0000 Subject: [PATCH] feat(matrix-bridge): per-tool lifecycle status icons + Tools (M/N) header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the OpenACP UX Jaden shared screenshots of (see docs/acp-telegram-status-research.md Detailed Deep-Dive ยง 2 + ยง 5). Each tool line now ticks live: ๐Ÿ”„ while running, โœ… on success, โŒ on error. A "๐Ÿ“‹ Tools (M/N) โœ…" header sits above the rolling log so the user can see how many tools have completed at a glance. How it works: - ContentBlock gains `id` (Anthropic-assigned `toolu_โ€ฆ`) and `tool_use_id` (the back-pointer carried by tool_result blocks). - The poller maintains a `toolLineIndex: Record` in DO storage that maps tool_use.id โ†’ the row index in `lines` where its rendered call lives. Persisted in the writes batch; wiped on /watch fresh-spawn alongside `lines`/`plan` so a new turn starts clean. - On tool_use projection: line is pushed as `๐Ÿ”„ ${formatToolUseLine}` and the row index is recorded under the block's id. - On tool_result projection: the original row's leading `๐Ÿ”„ ` is mutated in place to `โœ… ` (success) or `โŒ ` (error). The toolLineIndex entry is then deleted โ€” one-shot, so a malformed duplicate result can't keep flipping the row. - render.ts gains `renderToolsHeader(lines)`: scans for ๐Ÿ”„/โœ…/โŒ prefixes, returns "๐Ÿ“‹ Tools (M/N)" โ€” with a trailing โœ… when M === N. Wired into both renderActive and renderTerminal above the rolling log. Tests: 5 new for renderToolsHeader covering empty input, mixed done/running states, all-complete suffix, and ignoring non-tool lines (narration, outputs, user steers). 48/48 pass. Telegram bridge gets the same treatment in a follow-up โ€” needs the focus-window revert first (still on commit 8728c39 per the handoff) to slot in cleanly. Matrix-only for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/src/poller.ts | 49 ++++++++++++++++++++++- replicas-matrix-bridge/src/render.test.ts | 44 ++++++++++++++++++++ replicas-matrix-bridge/src/render.ts | 35 ++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) 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