From 5520fec2512ce33653e17da59376ecb8abb01bb6 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sun, 1 Feb 2026 02:40:59 +0800 Subject: [PATCH 1/2] feat(cli): add detailed tool info display for glob, web_search, web_fetch - Add display name mappings for glob, web_search, web_fetch tools - Show relevant args: glob pattern, search query, URL hostname/path - Display result summaries: file count, search results count, page title/size - Add grep result summary showing match count Co-Authored-By: Claude Opus 4.5 --- src/agent/cli/output.ts | 106 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/src/agent/cli/output.ts b/src/agent/cli/output.ts index fe66b261..df8e15d7 100644 --- a/src/agent/cli/output.ts +++ b/src/agent/cli/output.ts @@ -32,6 +32,9 @@ function toolDisplayName(name: string): string { grep: "Grep", find: "FindFiles", ls: "ListDir", + glob: "Glob", + web_search: "WebSearch", + web_fetch: "WebFetch", }; return map[name] || name; } @@ -40,6 +43,7 @@ function formatToolArgs(name: string, args: unknown): string { if (!args || typeof args !== "object") return ""; const record = args as Record; const get = (key: string) => (record[key] !== undefined ? String(record[key]) : ""); + const truncate = (s: string, max: number) => (s.length > max ? s.slice(0, max) + "…" : s); switch (name) { case "read": return get("path") || get("file"); @@ -57,19 +61,111 @@ function formatToolArgs(name: string, args: unknown): string { return get("command"); case "process": return [get("action"), get("id")].filter(Boolean).join(" "); + case "glob": + return [get("pattern"), get("cwd")].filter(Boolean).join(" in "); + case "web_search": + return truncate(get("query"), 50); + case "web_fetch": { + const url = get("url"); + try { + const parsed = new URL(url); + return parsed.hostname + (parsed.pathname !== "/" ? truncate(parsed.pathname, 30) : ""); + } catch { + return truncate(url, 50); + } + } default: return ""; } } -function formatToolLine(name: string, args: unknown): string { +function formatToolLine(name: string, args: unknown, result?: unknown): string { const title = colors.toolName(toolDisplayName(name)); const argText = formatToolArgs(name, args); + const resultSummary = formatResultSummary(name, result); const bullet = colors.toolBullet("•"); + + let line = `${bullet} ${title}`; if (argText) { - return `${bullet} ${title} ${colors.toolArgs(`(${argText})`)}`; + line += ` ${colors.toolArgs(`(${argText})`)}`; + } + if (resultSummary) { + line += ` ${colors.toolArrow("→")} ${colors.toolArgs(resultSummary)}`; + } + return line; +} + +function extractResultDetails(result: unknown): Record | null { + if (!result || typeof result !== "object") return null; + + // Try to extract from AgentMessage content array (JSON result) + const msg = result as { content?: Array<{ type: string; text?: string }> }; + if (Array.isArray(msg.content)) { + for (const c of msg.content) { + if (c.type === "text" && c.text) { + try { + return JSON.parse(c.text) as Record; + } catch { + continue; + } + } + } + } + + // Try direct object access + return result as Record; +} + +function formatResultSummary(name: string, result: unknown): string { + const details = extractResultDetails(result); + if (!details) return ""; + + const truncate = (s: string, max: number) => (s.length > max ? s.slice(0, max) + "…" : s); + + switch (name) { + case "glob": { + const count = details.count ?? (Array.isArray(details.files) ? details.files.length : 0); + const truncated = details.truncated ? "+" : ""; + return `${count}${truncated} files`; + } + case "web_search": { + if (details.error) return `error: ${details.message || details.error}`; + const provider = details.provider || "search"; + if (details.content) { + // Perplexity result + const citations = Array.isArray(details.citations) ? details.citations.length : 0; + return `${citations} citations`; + } + // Brave result + const count = details.count ?? (Array.isArray(details.results) ? details.results.length : 0); + return `${count} results`; + } + case "web_fetch": { + if (details.error) return `error: ${details.message || details.error}`; + const parts: string[] = []; + if (details.title) { + parts.push(`"${truncate(String(details.title), 30)}"`); + } + if (typeof details.length === "number") { + const kb = (details.length / 1024).toFixed(1); + parts.push(`${kb}KB`); + } + if (details.cached) { + parts.push("cached"); + } + return parts.join(", "); + } + case "grep": { + // Try to count matches from result text + const text = extractText(result as AgentMessage | undefined); + if (text.includes("No matches found")) return "no matches"; + const lines = text.split("\n").filter((l) => l.trim()).length; + if (lines > 0) return `${lines} matches`; + return ""; + } + default: + return ""; } - return `${bullet} ${title}`; } export function createAgentOutput(params: { @@ -151,14 +247,14 @@ export function createAgentOutput(params: { break; } case "tool_execution_end": { - // Stop spinner and show final result + // Stop spinner and show final result with summary if (event.isError) { const errorText = extractText(event.result) || "Tool failed"; const bullet = colors.toolError("✗"); const title = colors.toolName(toolDisplayName(event.toolName)); spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`); } else { - spinner.stop(formatToolLine(event.toolName, pendingToolArgs)); + spinner.stop(formatToolLine(event.toolName, pendingToolArgs, event.result)); } pendingToolName = ""; pendingToolArgs = null; From 98a447b7a2c8db197cdf322f28eebea6ead9392f Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sun, 1 Feb 2026 02:53:23 +0800 Subject: [PATCH 2/2] test(cli): add comprehensive tests for output formatting functions Added 36 tests covering toolDisplayName, formatToolArgs, extractResultDetails, and formatResultSummary functions. Also refactored code to eliminate duplicate truncate function and remove unused variable. Co-Authored-By: Claude Haiku 4.5 --- src/agent/cli/output.test.ts | 244 +++++++++++++++++++++++++++++++++++ src/agent/cli/output.ts | 20 +-- 2 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 src/agent/cli/output.test.ts diff --git a/src/agent/cli/output.test.ts b/src/agent/cli/output.test.ts new file mode 100644 index 00000000..3a6f6030 --- /dev/null +++ b/src/agent/cli/output.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from "vitest"; +import { + toolDisplayName, + formatToolArgs, + extractResultDetails, + formatResultSummary, +} from "./output.js"; + +describe("output", () => { + describe("toolDisplayName", () => { + it("should map known tool names to display names", () => { + expect(toolDisplayName("read")).toBe("ReadFile"); + expect(toolDisplayName("write")).toBe("WriteFile"); + expect(toolDisplayName("edit")).toBe("EditFile"); + expect(toolDisplayName("glob")).toBe("Glob"); + expect(toolDisplayName("web_search")).toBe("WebSearch"); + expect(toolDisplayName("web_fetch")).toBe("WebFetch"); + }); + + it("should return original name for unknown tools", () => { + expect(toolDisplayName("custom_tool")).toBe("custom_tool"); + expect(toolDisplayName("unknown")).toBe("unknown"); + }); + }); + + describe("formatToolArgs", () => { + it("should return empty string for null/undefined args", () => { + expect(formatToolArgs("read", null)).toBe(""); + expect(formatToolArgs("read", undefined)).toBe(""); + }); + + it("should return empty string for non-object args", () => { + expect(formatToolArgs("read", "string")).toBe(""); + expect(formatToolArgs("read", 123)).toBe(""); + }); + + it("should format read tool args", () => { + expect(formatToolArgs("read", { path: "/foo/bar.ts" })).toBe("/foo/bar.ts"); + expect(formatToolArgs("read", { file: "/foo/bar.ts" })).toBe("/foo/bar.ts"); + }); + + it("should format glob tool args", () => { + expect(formatToolArgs("glob", { pattern: "**/*.ts" })).toBe("**/*.ts"); + expect(formatToolArgs("glob", { pattern: "**/*.ts", cwd: "/src" })).toBe("**/*.ts in /src"); + }); + + it("should format web_search tool args with truncation", () => { + expect(formatToolArgs("web_search", { query: "short query" })).toBe("short query"); + const longQuery = "a".repeat(60); + expect(formatToolArgs("web_search", { query: longQuery })).toBe("a".repeat(50) + "…"); + }); + + it("should format web_fetch tool args with URL parsing", () => { + expect(formatToolArgs("web_fetch", { url: "https://example.com" })).toBe("example.com"); + expect(formatToolArgs("web_fetch", { url: "https://example.com/" })).toBe("example.com"); + expect(formatToolArgs("web_fetch", { url: "https://example.com/path/to/page" })).toBe( + "example.com/path/to/page" + ); + }); + + it("should truncate long URL paths", () => { + const longPath = "/very/long/path/that/exceeds/thirty/characters/limit"; + expect(formatToolArgs("web_fetch", { url: `https://example.com${longPath}` })).toBe( + "example.com" + longPath.slice(0, 30) + "…" + ); + }); + + it("should handle invalid URLs gracefully", () => { + expect(formatToolArgs("web_fetch", { url: "not-a-valid-url" })).toBe("not-a-valid-url"); + const longInvalid = "x".repeat(60); + expect(formatToolArgs("web_fetch", { url: longInvalid })).toBe("x".repeat(50) + "…"); + }); + + it("should return empty string for unknown tools", () => { + expect(formatToolArgs("unknown_tool", { foo: "bar" })).toBe(""); + }); + }); + + describe("extractResultDetails", () => { + it("should return null for null/undefined", () => { + expect(extractResultDetails(null)).toBeNull(); + expect(extractResultDetails(undefined)).toBeNull(); + }); + + it("should return null for non-objects", () => { + expect(extractResultDetails("string")).toBeNull(); + expect(extractResultDetails(123)).toBeNull(); + }); + + it("should extract JSON from AgentMessage content array", () => { + const result = { + content: [{ type: "text", text: '{"count": 5, "files": ["a.ts", "b.ts"]}' }], + }; + expect(extractResultDetails(result)).toEqual({ count: 5, files: ["a.ts", "b.ts"] }); + }); + + it("should skip non-text content items", () => { + const result = { + content: [ + { type: "image", data: "..." }, + { type: "text", text: '{"value": 42}' }, + ], + }; + expect(extractResultDetails(result)).toEqual({ value: 42 }); + }); + + it("should handle invalid JSON gracefully", () => { + const result = { + content: [{ type: "text", text: "not json" }], + }; + // Falls back to returning the object itself + expect(extractResultDetails(result)).toEqual(result); + }); + + it("should return direct object if no content array", () => { + const result = { count: 10, truncated: true }; + expect(extractResultDetails(result)).toEqual({ count: 10, truncated: true }); + }); + }); + + describe("formatResultSummary", () => { + describe("glob", () => { + it("should format file count from count field", () => { + const result = { content: [{ type: "text", text: '{"count": 5}' }] }; + expect(formatResultSummary("glob", result)).toBe("5 files"); + }); + + it("should format file count from files array length", () => { + const result = { + content: [{ type: "text", text: '{"files": ["a.ts", "b.ts", "c.ts"]}' }], + }; + expect(formatResultSummary("glob", result)).toBe("3 files"); + }); + + it("should show + for truncated results", () => { + const result = { content: [{ type: "text", text: '{"count": 100, "truncated": true}' }] }; + expect(formatResultSummary("glob", result)).toBe("100+ files"); + }); + + it("should handle zero files", () => { + const result = { content: [{ type: "text", text: '{"count": 0, "files": []}' }] }; + expect(formatResultSummary("glob", result)).toBe("0 files"); + }); + }); + + describe("web_search", () => { + it("should format error results", () => { + const result = { content: [{ type: "text", text: '{"error": true, "message": "API error"}' }] }; + expect(formatResultSummary("web_search", result)).toBe("error: API error"); + }); + + it("should format Perplexity results with citations", () => { + const result = { + content: [ + { + type: "text", + text: '{"content": "answer text", "citations": ["url1", "url2", "url3"]}', + }, + ], + }; + expect(formatResultSummary("web_search", result)).toBe("3 citations"); + }); + + it("should format Brave results with count", () => { + const result = { content: [{ type: "text", text: '{"count": 10}' }] }; + expect(formatResultSummary("web_search", result)).toBe("10 results"); + }); + + it("should count results array if no count field", () => { + const result = { + content: [{ type: "text", text: '{"results": [{}, {}, {}]}' }], + }; + expect(formatResultSummary("web_search", result)).toBe("3 results"); + }); + }); + + describe("web_fetch", () => { + it("should format error results", () => { + const result = { + content: [{ type: "text", text: '{"error": true, "message": "404 Not Found"}' }], + }; + expect(formatResultSummary("web_fetch", result)).toBe("error: 404 Not Found"); + }); + + it("should format title", () => { + const result = { content: [{ type: "text", text: '{"title": "Example Page"}' }] }; + expect(formatResultSummary("web_fetch", result)).toBe('"Example Page"'); + }); + + it("should truncate long titles", () => { + const longTitle = "A".repeat(50); + const result = { content: [{ type: "text", text: `{"title": "${longTitle}"}` }] }; + expect(formatResultSummary("web_fetch", result)).toBe(`"${"A".repeat(30)}…"`); + }); + + it("should format content length in KB", () => { + const result = { content: [{ type: "text", text: '{"length": 2048}' }] }; + expect(formatResultSummary("web_fetch", result)).toBe("2.0KB"); + }); + + it("should show cached indicator", () => { + const result = { content: [{ type: "text", text: '{"cached": true}' }] }; + expect(formatResultSummary("web_fetch", result)).toBe("cached"); + }); + + it("should combine multiple fields", () => { + const result = { + content: [{ type: "text", text: '{"title": "Page", "length": 1024, "cached": true}' }], + }; + expect(formatResultSummary("web_fetch", result)).toBe('"Page", 1.0KB, cached'); + }); + }); + + describe("grep", () => { + it("should return 'no matches' for empty results", () => { + const result = { content: [{ type: "text", text: "No matches found" }] }; + expect(formatResultSummary("grep", result)).toBe("no matches"); + }); + + it("should count non-empty lines as matches", () => { + const result = { + content: [{ type: "text", text: "file.ts:1:match1\nfile.ts:2:match2\nfile.ts:3:match3" }], + }; + expect(formatResultSummary("grep", result)).toBe("3 matches"); + }); + + it("should ignore empty lines when counting", () => { + const result = { + content: [{ type: "text", text: "file.ts:1:match1\n\nfile.ts:2:match2\n" }], + }; + expect(formatResultSummary("grep", result)).toBe("2 matches"); + }); + }); + + it("should return empty string for unknown tools", () => { + const result = { content: [{ type: "text", text: '{"data": "value"}' }] }; + expect(formatResultSummary("unknown_tool", result)).toBe(""); + }); + + it("should return empty string for null result", () => { + expect(formatResultSummary("glob", null)).toBe(""); + }); + }); +}); diff --git a/src/agent/cli/output.ts b/src/agent/cli/output.ts index df8e15d7..3c6c9835 100644 --- a/src/agent/cli/output.ts +++ b/src/agent/cli/output.ts @@ -22,7 +22,12 @@ function extractText(message: AgentMessage | undefined): string { .join(""); } -function toolDisplayName(name: string): string { +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + "…" : s; +} + +// Exported for testing +export function toolDisplayName(name: string): string { const map: Record = { read: "ReadFile", write: "WriteFile", @@ -39,11 +44,11 @@ function toolDisplayName(name: string): string { return map[name] || name; } -function formatToolArgs(name: string, args: unknown): string { +// Exported for testing +export function formatToolArgs(name: string, args: unknown): string { if (!args || typeof args !== "object") return ""; const record = args as Record; const get = (key: string) => (record[key] !== undefined ? String(record[key]) : ""); - const truncate = (s: string, max: number) => (s.length > max ? s.slice(0, max) + "…" : s); switch (name) { case "read": return get("path") || get("file"); @@ -95,7 +100,8 @@ function formatToolLine(name: string, args: unknown, result?: unknown): string { return line; } -function extractResultDetails(result: unknown): Record | null { +// Exported for testing +export function extractResultDetails(result: unknown): Record | null { if (!result || typeof result !== "object") return null; // Try to extract from AgentMessage content array (JSON result) @@ -116,12 +122,11 @@ function extractResultDetails(result: unknown): Record | null { return result as Record; } -function formatResultSummary(name: string, result: unknown): string { +// Exported for testing +export function formatResultSummary(name: string, result: unknown): string { const details = extractResultDetails(result); if (!details) return ""; - const truncate = (s: string, max: number) => (s.length > max ? s.slice(0, max) + "…" : s); - switch (name) { case "glob": { const count = details.count ?? (Array.isArray(details.files) ? details.files.length : 0); @@ -130,7 +135,6 @@ function formatResultSummary(name: string, result: unknown): string { } case "web_search": { if (details.error) return `error: ${details.message || details.error}`; - const provider = details.provider || "search"; if (details.content) { // Perplexity result const citations = Array.isArray(details.citations) ? details.citations.length : 0;