Skip to content
Merged
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
39 changes: 34 additions & 5 deletions classes/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,17 +87,35 @@ 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) {
this.on("log", callback);
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);
}
}
68 changes: 55 additions & 13 deletions components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ToolCallInfo[]>([]);
const toolCallsRef = useRef<ToolCallInfo[]>([]);
const [startTime, setStartTime] = useState<number | null>(null);
const processingRef = useRef(false);
const queueRef = useRef<string[]>([]);
Expand Down Expand Up @@ -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 () => {
Expand All @@ -152,23 +171,44 @@ export function App({

setIsStreaming(true);
setStreamingText("");
setToolCallCount(0);
setToolCalls([]);
toolCallsRef.current = [];
setStartTime(Date.now());

const abort = new AbortController();
abortRef.current = abort;

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) {
Expand Down Expand Up @@ -213,6 +253,7 @@ export function App({
content,
rendered: renderMarkdown(content),
timestamp: logger.getTimestamp(),
toolCalls: toolCallsRef.current,
},
]);
} catch (err) {
Expand All @@ -231,7 +272,8 @@ export function App({
setIsStreaming(false);
setStreamingText("");
setStartTime(null);
setToolCallCount(0);
setToolCalls([]);
toolCallsRef.current = [];
}
},
[agent, mcpServer, logger],
Expand Down Expand Up @@ -300,7 +342,7 @@ export function App({
messages={messages}
streamingText={streamingText}
isStreaming={isStreaming}
toolCallCount={toolCallCount}
toolCalls={toolCalls}
startTime={startTime}
scrollX={scrollX}
scrollY={scrollY}
Expand Down
1 change: 1 addition & 0 deletions components/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface MessageData {
content: string;
rendered: string;
timestamp: string;
toolCalls?: import("../classes/logger.js").ToolCallInfo[];
}

interface MessageProps extends MessageData {
Expand Down
50 changes: 45 additions & 5 deletions components/MessageArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -15,11 +16,42 @@ const dashedBorder: BoxStyle = {
left: "β•Ž",
};

function ToolCallTree({ toolCalls }: { toolCalls: ToolCallInfo[] }) {
if (toolCalls.length === 0) return null;
return (
<Box flexDirection="column">
<Text dimColor>πŸ› οΈ Tool calls:</Text>
<Box flexDirection="column" marginLeft={2}>
{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 (
<Box key={tc.callId || i} flexDirection="column">
{tc.status === "running" ? (
<Text color="yellow">
{prefix} ⏳ {tc.name}({truncatedArgs})
</Text>
) : (
<Text color="green">
{prefix} βœ“ {tc.name}({truncatedArgs}) (
{(tc.duration! / 1000).toFixed(1)}s)
</Text>
)}
</Box>
);
})}
</Box>
</Box>
);
}

interface MessageAreaProps {
messages: MessageData[];
streamingText: string;
isStreaming: boolean;
toolCallCount: number;
toolCalls: ToolCallInfo[];
startTime: number | null;
scrollX: number;
scrollY: number;
Expand All @@ -33,7 +65,7 @@ export function MessageArea({
messages,
streamingText,
isStreaming,
toolCallCount,
toolCalls,
startTime,
scrollX,
scrollY,
Expand Down Expand Up @@ -62,6 +94,11 @@ export function MessageArea({
{(msg, i) => (
<Box key={`msg-${i}`} flexDirection="column" marginBottom={1}>
<Text dimColor>{msg.timestamp}</Text>
{msg.toolCalls && msg.toolCalls.length > 0 && (
<Box marginBottom={1}>
<ToolCallTree toolCalls={msg.toolCalls} />
</Box>
)}
{msg.role === "assistant" ? (
<Text>{msg.rendered}</Text>
) : (
Expand Down Expand Up @@ -92,15 +129,18 @@ export function MessageArea({
Thinking...
{startTime &&
` πŸ• ${Math.round((Date.now() - startTime) / 1000)}s`}
{toolCallCount > 0 &&
` | πŸ› οΈ ${toolCallCount} tool call${toolCallCount > 1 ? "s" : ""}`}
{" | "}
</Text>
<Text color="yellow" dimColor>
[esc] to cancel
</Text>
</Box>
{streamingText && <Text>{renderMarkdown(streamingText)}</Text>}
<ToolCallTree toolCalls={toolCalls} />
{streamingText && (
<Box marginTop={toolCalls.length > 0 ? 1 : 0}>
<Text>{renderMarkdown(streamingText)}</Text>
</Box>
)}
</Box>
)}

Expand Down
34 changes: 20 additions & 14 deletions tests/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down