diff --git a/classes/logger.ts b/classes/logger.ts index 3e726c6..5123310 100644 --- a/classes/logger.ts +++ b/classes/logger.ts @@ -14,6 +14,17 @@ export interface LogEvent { timestamp: string; } +export type ToolCallStatus = "running" | "completed"; + +export interface ToolCallInfo { + callId: string; + name: string; + args: string; + status: ToolCallStatus; + startedAt: number; + duration?: number; +} + export class Logger extends EventEmitter { private level: LogLevel; private color: boolean; @@ -76,8 +87,26 @@ export class Logger extends EventEmitter { this.log(message, LogLevel.DEBUG); } - incrementToolCalls() { - this.emit("toolCall"); + toolCallStarted(callId: string, name: string, args: string) { + const info: ToolCallInfo = { + callId, + name, + args, + status: "running", + startedAt: Date.now(), + }; + this.emit("toolCallUpdate", info); + } + + toolCallCompleted(callId: string, name: string) { + const info: ToolCallInfo = { + callId, + name, + args: "", + status: "completed", + startedAt: 0, + }; + this.emit("toolCallUpdate", info); } onLog(callback: (event: LogEvent) => void) { @@ -85,8 +114,8 @@ export class Logger extends EventEmitter { return () => this.off("log", callback); } - onToolCall(callback: () => void) { - this.on("toolCall", callback); - return () => this.off("toolCall", callback); + onToolCallUpdate(callback: (info: ToolCallInfo) => void) { + this.on("toolCallUpdate", callback); + return () => this.off("toolCallUpdate", callback); } } diff --git a/components/App.tsx b/components/App.tsx index 1e1320e..d930c4e 100644 --- a/components/App.tsx +++ b/components/App.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Box, useApp, useStdout } from "ink"; import type { MCPServerStreamableHttp } from "@openai/agents"; import type { GeneralAgent } from "../agents/general.js"; -import type { Logger, LogEvent } from "../classes/logger.js"; +import type { Logger, LogEvent, ToolCallInfo } from "../classes/logger.js"; import type { Config } from "../classes/config.js"; import chalk from "chalk"; import { renderMarkdown } from "../utils/markdown.js"; @@ -45,7 +45,8 @@ export function App({ ]); const [streamingText, setStreamingText] = useState(""); const [isStreaming, setIsStreaming] = useState(false); - const [toolCallCount, setToolCallCount] = useState(0); + const [toolCalls, setToolCalls] = useState([]); + const toolCallsRef = useRef([]); const [startTime, setStartTime] = useState(null); const processingRef = useRef(false); const queueRef = useRef([]); @@ -127,8 +128,26 @@ export function App({ ]); }); - const unsubTool = logger.onToolCall(() => { - setToolCallCount((prev) => prev + 1); + const unsubTool = logger.onToolCallUpdate((info: ToolCallInfo) => { + setToolCalls((prev) => { + let next: ToolCallInfo[]; + if (info.status === "running") { + next = [...prev, info]; + } else { + // Mark existing call as completed + next = prev.map((tc) => + tc.callId === info.callId + ? { + ...tc, + status: "completed" as const, + duration: Date.now() - tc.startedAt, + } + : tc, + ); + } + toolCallsRef.current = next; + return next; + }); }); return () => { @@ -152,7 +171,8 @@ export function App({ setIsStreaming(true); setStreamingText(""); - setToolCallCount(0); + setToolCalls([]); + toolCallsRef.current = []; setStartTime(Date.now()); const abort = new AbortController(); @@ -160,15 +180,35 @@ export function App({ try { const stream = await agent.chat(input, [mcpServer]); - const textStream = stream.toTextStream({ - compatibleWithNodeStreams: true, - }); let accumulated = ""; - for await (const chunk of textStream) { + for await (const event of stream) { if (abort.signal.aborted) break; - accumulated += chunk; - setStreamingText(accumulated); + if ( + event.type === "raw_model_stream_event" && + event.data.type === "output_text_delta" + ) { + accumulated += (event.data as { delta: string }).delta; + setStreamingText(accumulated); + } else if (event.type === "run_item_stream_event") { + const raw = event.item?.rawItem as { + name?: string; + callId?: string; + arguments?: string; + }; + if (event.name === "tool_called") { + logger.toolCallStarted( + raw?.callId || "", + raw?.name || "unknown", + raw?.arguments || "", + ); + } else if (event.name === "tool_output") { + logger.toolCallCompleted( + raw?.callId || "", + raw?.name || "unknown", + ); + } + } } if (abort.signal.aborted) { @@ -213,6 +253,7 @@ export function App({ content, rendered: renderMarkdown(content), timestamp: logger.getTimestamp(), + toolCalls: toolCallsRef.current, }, ]); } catch (err) { @@ -231,7 +272,8 @@ export function App({ setIsStreaming(false); setStreamingText(""); setStartTime(null); - setToolCallCount(0); + setToolCalls([]); + toolCallsRef.current = []; } }, [agent, mcpServer, logger], @@ -300,7 +342,7 @@ export function App({ messages={messages} streamingText={streamingText} isStreaming={isStreaming} - toolCallCount={toolCallCount} + toolCalls={toolCalls} startTime={startTime} scrollX={scrollX} scrollY={scrollY} diff --git a/components/Message.tsx b/components/Message.tsx index 93e1160..75317a4 100644 --- a/components/Message.tsx +++ b/components/Message.tsx @@ -9,6 +9,7 @@ export interface MessageData { content: string; rendered: string; timestamp: string; + toolCalls?: import("../classes/logger.js").ToolCallInfo[]; } interface MessageProps extends MessageData { diff --git a/components/MessageArea.tsx b/components/MessageArea.tsx index 0d5ac3d..db77f3e 100644 --- a/components/MessageArea.tsx +++ b/components/MessageArea.tsx @@ -2,6 +2,7 @@ import { Box, Static, Text } from "ink"; import type { BoxStyle } from "cli-boxes"; import Spinner from "ink-spinner"; import type { MessageData } from "./Message.js"; +import type { ToolCallInfo } from "../classes/logger.js"; import { renderMarkdown, applyHorizontalScroll } from "../utils/markdown.js"; const dashedBorder: BoxStyle = { @@ -15,11 +16,42 @@ const dashedBorder: BoxStyle = { left: "╎", }; +function ToolCallTree({ toolCalls }: { toolCalls: ToolCallInfo[] }) { + if (toolCalls.length === 0) return null; + return ( + + 🛠️ Tool calls: + + {toolCalls.map((tc, i) => { + const isLast = i === toolCalls.length - 1; + const prefix = isLast ? "└─" : "├─"; + const truncatedArgs = + tc.args.length > 80 ? tc.args.slice(0, 80) + "…" : tc.args; + return ( + + {tc.status === "running" ? ( + + {prefix} ⏳ {tc.name}({truncatedArgs}) + + ) : ( + + {prefix} ✓ {tc.name}({truncatedArgs}) ( + {(tc.duration! / 1000).toFixed(1)}s) + + )} + + ); + })} + + + ); +} + interface MessageAreaProps { messages: MessageData[]; streamingText: string; isStreaming: boolean; - toolCallCount: number; + toolCalls: ToolCallInfo[]; startTime: number | null; scrollX: number; scrollY: number; @@ -33,7 +65,7 @@ export function MessageArea({ messages, streamingText, isStreaming, - toolCallCount, + toolCalls, startTime, scrollX, scrollY, @@ -62,6 +94,11 @@ export function MessageArea({ {(msg, i) => ( {msg.timestamp} + {msg.toolCalls && msg.toolCalls.length > 0 && ( + + + + )} {msg.role === "assistant" ? ( {msg.rendered} ) : ( @@ -92,15 +129,18 @@ export function MessageArea({ Thinking... {startTime && ` 🕝 ${Math.round((Date.now() - startTime) / 1000)}s`} - {toolCallCount > 0 && - ` | 🛠️ ${toolCallCount} tool call${toolCallCount > 1 ? "s" : ""}`} {" | "} [esc] to cancel - {streamingText && {renderMarkdown(streamingText)}} + + {streamingText && ( + 0 ? 1 : 0}> + {renderMarkdown(streamingText)} + + )} )} diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 318abf6..93112fd 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -137,30 +137,36 @@ describe("Logger", () => { logger = new Logger(config); }); - it("should have incrementToolCalls method", () => { - expect(typeof logger.incrementToolCalls).toBe("function"); - expect(() => logger.incrementToolCalls()).not.toThrow(); + it("should have toolCallStarted and toolCallCompleted methods", () => { + expect(typeof logger.toolCallStarted).toBe("function"); + expect(typeof logger.toolCallCompleted).toBe("function"); + expect(() => logger.toolCallStarted("id1", "test", "{}")).not.toThrow(); }); - it("should emit toolCall events", () => { - let called = 0; - logger.onToolCall(() => called++); + it("should emit toolCallUpdate events", () => { + const updates: any[] = []; + logger.onToolCallUpdate((info: any) => updates.push(info)); - logger.incrementToolCalls(); - logger.incrementToolCalls(); + logger.toolCallStarted("id1", "search", '{"q":"hello"}'); + logger.toolCallCompleted("id1", "search"); - expect(called).toBe(2); + expect(updates.length).toBe(2); + expect(updates[0].status).toBe("running"); + expect(updates[0].name).toBe("search"); + expect(updates[0].args).toBe('{"q":"hello"}'); + expect(updates[1].status).toBe("completed"); + expect(updates[1].callId).toBe("id1"); }); it("should allow unsubscribing from events", () => { - let called = 0; - const unsub = logger.onToolCall(() => called++); + const updates: any[] = []; + const unsub = logger.onToolCallUpdate((info: any) => updates.push(info)); - logger.incrementToolCalls(); + logger.toolCallStarted("id1", "test", "{}"); unsub(); - logger.incrementToolCalls(); + logger.toolCallStarted("id2", "test2", "{}"); - expect(called).toBe(1); + expect(updates.length).toBe(1); }); });