Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions replicas-matrix-bridge/src/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ interface ContentBlock {
thinking?: string;
name?: string;
input?: Record<string, unknown>;
// 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;
Expand Down Expand Up @@ -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<string, unknown> = {
Expand Down Expand Up @@ -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<Record<string, number>>("toolLineIndex")) ?? {};
let systemInfo = (await this.state.storage.get<import("./render").SystemInfo>("systemInfo")) ?? null;
let contextUsage = (await this.state.storage.get<import("./render").ContextUsage>("contextUsage")) ?? null;
let resultMeta = (await this.state.storage.get<import("./render").ResultMeta>("resultMeta")) ?? null;
Expand Down Expand Up @@ -344,7 +359,15 @@ export class ReplicaPoller {
(block.input ?? {}) as Record<string, unknown>,
);
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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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(`<i>${icon} ${escapeHtml(trimmed)}</i>`);
appended = true;
Expand Down Expand Up @@ -466,6 +510,7 @@ export class ReplicaPoller {
stepCount,
currentAction,
lastSeenCount: events.length,
toolLineIndex,
};
if (plan) writes.plan = plan;
if (systemInfo) writes.systemInfo = systemInfo;
Expand Down
44 changes: 44 additions & 0 deletions replicas-matrix-bridge/src/render.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
formatCost,
renderToolsHeader,
formatDuration,
formatToolUseLine,
parsePlan,
Expand Down Expand Up @@ -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) => `🔧 <code>${s}</code>`;

it("returns empty when there are no tool lines", () => {
expect(renderToolsHeader([])).toBe("");
expect(renderToolsHeader(["💬 some narration", "<i>↳ standalone output</i>"])).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("📋 <b>Tools (0/2)</b>");
});

it("counts both ✅ and ❌ as done", () => {
const lines = [
ok(tool("ls")),
err(tool("rm -rf /")),
run(tool("git status")),
];
expect(renderToolsHeader(lines)).toBe("📋 <b>Tools (2/3)</b>");
});

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("📋 <b>Tools (3/3)</b> ✅");
});

it("ignores non-tool lines mixed in (narration, outputs, user steers)", () => {
const lines = [
"💬 <i>Reading the codebase</i>",
ok(tool("ls")),
"<i>↳ index.ts main.ts</i>",
"💬 <i>You: now what</i>",
run(tool("grep foo")),
];
expect(renderToolsHeader(lines)).toBe("📋 <b>Tools (1/2)</b>");
});
});
35 changes: 35 additions & 0 deletions replicas-matrix-bridge/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `📋 <b>Tools (${done}/${total})</b>${allComplete}`;
}

function renderActive(state: StatusState): string {
const elapsedSec = Math.max(0, Math.round((Date.now() - state.startedAt) / 1000));
const headerParts: string[] = [
Expand All @@ -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 <blockquote expandable> ("Show more") so the
Expand Down Expand Up @@ -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
Expand Down