/**
* ToolCallFeed — interleaves text chunks with tool call activity steps.
*
* This is the core Conductor pattern: a unified thread showing
* [text -> tool_call -> tool_result -> text -> tool_call -> ...].
*
* Renders assistant response as a sequence of segments, where each
* segment is either text (markdown) or a tool call group.
*/
import { type ReactNode } from "react";
import { Markdown } from "../markdown/markdown";
import { ToolCallStep, ToolCallGroup, type ToolCallType, type ToolCallStatus } from "./tool-call-step";
import { cn } from "../lib/utils";
export interface ToolCallData {
id: string;
type: ToolCallType;
label: string;
status: ToolCallStatus;
detail?: string;
output?: string;
duration?: number;
}
export type FeedSegment =
| { kind: "text"; content: string }
| { kind: "tool_call"; call: ToolCallData }
| { kind: "tool_group"; title?: string; calls: ToolCallData[] };
export interface ToolCallFeedProps {
segments: FeedSegment[];
className?: string;
}
/**
* Renders a feed of interleaved text and tool calls.
*/
export function ToolCallFeed({ segments, className }: ToolCallFeedProps) {
if (segments.length === 0) return null;
return (
{segments.map((segment, i) => {
if (segment.kind === "text") {
return segment.content.trim() ? (
{segment.content}
) : null;
}
if (segment.kind === "tool_call") {
return (
);
}
if (segment.kind === "tool_group") {
return (
{segment.calls.map((call) => (
))}
);
}
return null;
})}
);
}
/**
* Parse raw SSE tool events into FeedSegments.
*
* This is the bridge between the orchestrator's SSE stream format
* and the ToolCallFeed component. Consumers call this in their
* useToolCallStream hook.
*/
export function parseToolEvent(event: {
type: string;
data: Record;
}): ToolCallData | null {
const { type, data } = event;
// Map common event types to ToolCallData
if (type === "tool.invocation" || type === "tool_use") {
const toolName = (data.name || data.tool || "unknown") as string;
const input = data.input as Record | undefined;
return {
id: (data.id || data.toolUseId || createId()) as string,
type: mapToolName(toolName),
label: formatToolLabel(toolName, input),
status: "running",
detail: input ? formatToolInput(toolName, input) : undefined,
};
}
if (type === "tool.result" || type === "tool_result") {
return {
id: (data.id || data.toolUseId || "") as string,
type: mapToolName((data.name || data.tool || "unknown") as string),
label: formatToolLabel((data.name || data.tool || "unknown") as string),
status: data.error ? "error" : "success",
output: truncate((data.output || data.result || "") as string, 500),
duration: data.duration as number | undefined,
};
}
return null;
}
// --- Helpers ---
function createId(): string {
if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
try {
return globalThis.crypto.randomUUID();
} catch {
// Fall through to the local fallback below.
}
}
return `tool_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
function mapToolName(name: string): ToolCallType {
const lower = name.toLowerCase();
if (lower.includes("bash") || lower.includes("terminal") || lower.includes("exec")) return "bash";
if (lower.includes("read") || lower.includes("cat")) return "read";
if (lower.includes("write") || lower.includes("create")) return "write";
if (lower.includes("edit") || lower.includes("replace")) return "edit";
if (lower.includes("glob") || lower.includes("find")) return "glob";
if (lower.includes("grep") || lower.includes("search")) return "grep";
if (lower.includes("list") || lower.includes("ls")) return "list";
if (lower.includes("inspect")) return "inspect";
if (lower.includes("audit")) return "audit";
return "unknown";
}
function formatToolLabel(toolName: string, input?: Record): string {
const lower = toolName.toLowerCase();
if (lower.includes("bash") && input?.command) {
const cmd = String(input.command);
return cmd.length > 80 ? `${cmd.slice(0, 77)}...` : cmd;
}
if ((lower.includes("read") || lower.includes("cat")) && input?.path) {
return `Read ${input.path}`;
}
if (lower.includes("write") && input?.path) {
return `Write ${input.path}`;
}
if (lower.includes("edit") && input?.path) {
return `Edit ${input.path}`;
}
if (lower.includes("glob") && input?.pattern) {
return `Find ${input.pattern}`;
}
if (lower.includes("grep") && input?.pattern) {
return `Search for ${input.pattern}`;
}
return toolName;
}
function formatToolInput(toolName: string, input: Record): string {
if (input.command) return String(input.command);
if (input.path) return String(input.path);
return JSON.stringify(input, null, 2).slice(0, 300);
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 3) + "..." : text;
}