From 315cd3595873615bd0cc4fad4aac5ced75a68754 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 3 Feb 2026 21:59:54 +0100 Subject: [PATCH] feat(coding-agent/mcp): added TUI rendering for MCP tool calls and results with JSON tree display - Added renderCall and renderResult methods to MCPTool and DeferredMCPTool classes for TUI rendering of tool calls and results. - Created new mcp/render.ts module providing JSON tree rendering functionality with configurable depth and line limits for terminal display. - Updated renderResult signature in custom tool and extension types to accept optional args parameter for accessing original tool arguments during result rendering. - Fixed method binding in extension and hook tool wrappers by applying .bind(tool) to renderCall and renderResult methods to preserve correct 'this' context. - Added filter to skip .git directories in fuzzy_find_sync function to improve search performance. --- biome.json | 2 +- bun.lock | 2 +- crates/pi-natives/src/grep.rs | 6 + package.json | 2 +- packages/coding-agent/CHANGELOG.md | 10 + packages/coding-agent/docs/config-usage.md | 82 ++--- packages/coding-agent/src/cli/args.ts | 4 +- packages/coding-agent/src/config.ts | 5 +- .../src/extensibility/custom-tools/types.ts | 7 +- .../src/extensibility/custom-tools/wrapper.ts | 9 +- .../src/extensibility/extensions/types.ts | 7 +- .../src/extensibility/extensions/wrapper.ts | 7 +- .../src/extensibility/hooks/tool-wrapper.ts | 4 +- packages/coding-agent/src/mcp/render.ts | 339 ++++++++++++++++++ packages/coding-agent/src/mcp/tool-bridge.ts | 25 +- packages/coding-agent/src/sdk.ts | 1 - 16 files changed, 451 insertions(+), 61 deletions(-) create mode 100644 packages/coding-agent/src/mcp/render.ts diff --git a/biome.json b/biome.json index bd49b8eab..4dbc5078f 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "linter": { "enabled": true, "rules": { diff --git a/bun.lock b/bun.lock index 5425e7c4d..211d08144 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@sinclair/typebox": "^0.34.48", }, "devDependencies": { - "@biomejs/biome": "^2.3.13", + "@biomejs/biome": "2.3.14", "@bufbuild/protoc-gen-es": "^2.11.0", "@types/bun": "^1.3.7", "@types/node": "^25.0.10", diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index 4f676099c..42c9ea1c0 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -619,6 +619,9 @@ fn collect_files( .sort_by_file_path(|a, b| a.cmp(b)); let mut entries = Vec::new(); + // Skip .git directories entirely + builder.filter_entry(|entry| entry.file_name().to_str() != Some(".git")); + for entry in builder.build() { let Ok(entry) = entry else { continue }; let file_type = entry.file_type(); @@ -1176,6 +1179,9 @@ fn fuzzy_find_sync(config: FuzzyFindConfig, ct: task::CancelToken) -> Result { renderCall?: (args: Static, theme: Theme) => Component; /** Custom rendering for tool result display - return a Component */ - renderResult?: (result: CustomToolResult, options: RenderResultOptions, theme: Theme) => Component; + renderResult?: ( + result: CustomToolResult, + options: RenderResultOptions, + theme: Theme, + args?: Static, + ) => Component; } /** Factory function that creates a custom tool or array of tools */ diff --git a/packages/coding-agent/src/extensibility/custom-tools/wrapper.ts b/packages/coding-agent/src/extensibility/custom-tools/wrapper.ts index f5c09def3..23587545b 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/wrapper.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/wrapper.ts @@ -47,8 +47,13 @@ export class CustomToolAdapter, options: RenderResultOptions, theme: TTheme): Component | undefined { - return this.tool.renderResult?.(result, options, theme); + renderResult( + result: AgentToolResult, + options: RenderResultOptions, + theme: TTheme, + args?: Static, + ): Component | undefined { + return this.tool.renderResult?.(result, options, theme, args); } /** diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index d16ff4139..4a4f7c34f 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -255,7 +255,12 @@ export interface ToolDefinition, theme: Theme) => Component; /** Custom rendering for tool result display */ - renderResult?: (result: AgentToolResult, options: ToolRenderResultOptions, theme: Theme) => Component; + renderResult?: ( + result: AgentToolResult, + options: ToolRenderResultOptions, + theme: Theme, + args?: Static, + ) => Component; } // ============================================================================ diff --git a/packages/coding-agent/src/extensibility/extensions/wrapper.ts b/packages/coding-agent/src/extensibility/extensions/wrapper.ts index 653bb8b20..6835c4624 100644 --- a/packages/coding-agent/src/extensibility/extensions/wrapper.ts +++ b/packages/coding-agent/src/extensibility/extensions/wrapper.ts @@ -42,11 +42,12 @@ export class RegisteredToolAdapter implements AgentTool { return this.registeredTool.definition.renderCall?.(args, theme as Theme); } - renderResult?(result: any, options: any, theme: any) { + renderResult?(result: any, options: any, theme: any, args?: any) { return this.registeredTool.definition.renderResult?.( result, { expanded: options.expanded, isPartial: options.isPartial, spinnerFrame: options.spinnerFrame }, theme as Theme, + args, ); } } @@ -82,8 +83,8 @@ export class ExtensionToolWrapper, private runner: ExtensionRunner, ) { - this.renderCall = tool.renderCall; - this.renderResult = tool.renderResult; + this.renderCall = tool.renderCall?.bind(tool); + this.renderResult = tool.renderResult?.bind(tool); this.mergeCallAndResult = (tool as { mergeCallAndResult?: boolean }).mergeCallAndResult; this.inline = (tool as { inline?: boolean }).inline; } diff --git a/packages/coding-agent/src/extensibility/hooks/tool-wrapper.ts b/packages/coding-agent/src/extensibility/hooks/tool-wrapper.ts index fcb1bb8e7..2d9edd943 100644 --- a/packages/coding-agent/src/extensibility/hooks/tool-wrapper.ts +++ b/packages/coding-agent/src/extensibility/hooks/tool-wrapper.ts @@ -34,8 +34,8 @@ export class HookToolWrapper, maxWidth: number): string { + const entries = Object.entries(args); + if (entries.length === 0) return ""; + + // Single arg: show key=value + if (entries.length === 1) { + const [key, value] = entries[0]; + return `${key}=${formatScalar(value, maxWidth - key.length - 1)}`; + } + + // Multiple args: show key=value, key=value... + const pairs: string[] = []; + let totalLen = 0; + + for (const [key, value] of entries) { + const valueStr = formatScalar(value, 24); + const pairStr = `${key}=${valueStr}`; + const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length; + + if (totalLen + addLen > maxWidth && pairs.length > 0) { + pairs.push("…"); + break; + } + + pairs.push(pairStr); + totalLen += addLen; + } + + return pairs.join(", "); +} + +/** + * Build tree prefix for nested rendering. + */ +function buildTreePrefix(ancestors: boolean[], theme: Theme): string { + return ancestors.map(hasNext => (hasNext ? `${theme.tree.vertical} ` : " ")).join(""); +} + +/** + * Render a JSON value as tree lines. + */ +function renderJsonTreeLines( + value: unknown, + theme: Theme, + maxDepth: number, + maxLines: number, + maxScalarLen: number, +): { lines: string[]; truncated: boolean } { + const lines: string[] = []; + let truncated = false; + + const iconObject = theme.styledSymbol("icon.folder", "muted"); + const iconArray = theme.styledSymbol("icon.package", "muted"); + const iconScalar = theme.styledSymbol("icon.file", "muted"); + + const pushLine = (line: string): boolean => { + if (lines.length >= maxLines) { + truncated = true; + return false; + } + lines.push(line); + return true; + }; + + const renderNode = (val: unknown, key: string | undefined, ancestors: boolean[], isLast: boolean, depth: number) => { + if (lines.length >= maxLines) { + truncated = true; + return; + } + + const connector = isLast ? theme.tree.last : theme.tree.branch; + const prefix = `${buildTreePrefix(ancestors, theme)}${theme.fg("dim", connector)} `; + + // Handle scalars + if (val === null || val === undefined || typeof val !== "object") { + const label = key ? theme.fg("muted", key) : theme.fg("muted", "value"); + + // Special handling for multiline strings + if (typeof val === "string" && val.includes("\n")) { + const strLines = val.split("\n"); + const maxStrLines = Math.min(strLines.length, Math.max(1, maxLines - lines.length - 1)); + const continuePrefix = buildTreePrefix([...ancestors, !isLast], theme); + + // First line with label + const firstLine = truncateToWidth(strLines[0], maxScalarLen); + pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", `"${firstLine}`)}`); + + // Subsequent lines indented + for (let i = 1; i < maxStrLines; i++) { + if (lines.length >= maxLines) { + truncated = true; + break; + } + const line = truncateToWidth(strLines[i], maxScalarLen); + pushLine(`${continuePrefix} ${theme.fg("dim", ` ${line}`)}`); + } + + // Show truncation and closing quote + if (strLines.length > maxStrLines) { + truncated = true; + pushLine(`${continuePrefix} ${theme.fg("dim", ` …(${strLines.length - maxStrLines} more lines)"`)}`); + } else { + // Add closing quote to last line - need to modify the last pushed line + const lastIdx = lines.length - 1; + lines[lastIdx] = `${lines[lastIdx]}${theme.fg("dim", '"')}`; + } + return; + } + + const scalar = formatScalar(val, maxScalarLen); + pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", scalar)}`); + return; + } + + // Handle arrays + if (Array.isArray(val)) { + const header = key ? theme.fg("muted", key) : theme.fg("muted", "array"); + pushLine(`${prefix}${iconArray} ${header}`); + if (val.length === 0) { + pushLine( + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "[]")}`, + ); + return; + } + if (depth >= maxDepth) { + pushLine( + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "…")}`, + ); + return; + } + const nextAncestors = [...ancestors, !isLast]; + for (let i = 0; i < val.length; i++) { + renderNode(val[i], `[${i}]`, nextAncestors, i === val.length - 1, depth + 1); + if (lines.length >= maxLines) { + truncated = true; + return; + } + } + return; + } + + // Handle objects + const header = key ? theme.fg("muted", key) : theme.fg("muted", "object"); + pushLine(`${prefix}${iconObject} ${header}`); + const entries = Object.entries(val as Record); + if (entries.length === 0) { + pushLine( + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "{}")}`, + ); + return; + } + if (depth >= maxDepth) { + pushLine( + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "…")}`, + ); + return; + } + const nextAncestors = [...ancestors, !isLast]; + for (let i = 0; i < entries.length; i++) { + const [childKey, child] = entries[i]; + renderNode(child, childKey, nextAncestors, i === entries.length - 1, depth + 1); + if (lines.length >= maxLines) { + truncated = true; + return; + } + } + }; + + // Render root level + if (value && typeof value === "object" && !Array.isArray(value)) { + const entries = Object.entries(value as Record); + for (let i = 0; i < entries.length; i++) { + const [childKey, child] = entries[i]; + renderNode(child, childKey, [], i === entries.length - 1, 1); + if (lines.length >= maxLines) { + truncated = true; + break; + } + } + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + renderNode(value[i], `[${i}]`, [], i === value.length - 1, 1); + if (lines.length >= maxLines) { + truncated = true; + break; + } + } + } else { + renderNode(value, undefined, [], true, 0); + } + + return { lines, truncated }; +} + +/** + * Render MCP tool call. + */ +export function renderMCPCall(args: Record, theme: Theme, label: string): Component { + const lines: string[] = []; + lines.push(renderStatusLine({ icon: "pending", title: label }, theme)); + + if (args && typeof args === "object" && Object.keys(args).length > 0) { + // Show args inline preview + const preview = formatArgsInline(args, 70); + if (preview) { + lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`); + } + } + + return new Text(lines.join("\n"), 0, 0); +} + +/** + * Render MCP tool result. + */ +export function renderMCPResult( + result: { content: Array<{ type: string; text?: string }>; details?: MCPToolDetails; isError?: boolean }, + options: RenderResultOptions, + theme: Theme, + args?: Record, +): Component { + const { expanded } = options; + const lines: string[] = []; + + // Args section (when expanded) + if (expanded && args && typeof args === "object" && Object.keys(args).length > 0) { + lines.push(`${theme.fg("dim", "Args")}`); + const maxDepth = JSON_TREE_MAX_DEPTH_EXPANDED; + const maxLines = JSON_TREE_MAX_LINES_EXPANDED; + const tree = renderJsonTreeLines(args, theme, maxDepth, maxLines, JSON_TREE_SCALAR_LEN_EXPANDED); + for (const line of tree.lines) { + lines.push(line); + } + if (tree.truncated) { + lines.push(theme.fg("dim", "…")); + } + lines.push(""); // Blank line before output + } + + // Output section + const textContent = result.content?.find(c => c.type === "text")?.text ?? ""; + const trimmedOutput = textContent.trimEnd(); + + if (!trimmedOutput) { + lines.push(theme.fg("dim", "(no output)")); + return new Text(lines.join("\n"), 0, 0); + } + + // Try to parse as JSON for structured display + if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) { + try { + const parsed = JSON.parse(trimmedOutput); + const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED; + const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED; + const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED; + const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen); + + if (tree.lines.length > 0) { + for (const line of tree.lines) { + lines.push(line); + } + // Always show expand hint when collapsed (expanded view shows longer values and deeper nesting) + if (!expanded) { + lines.push(formatExpandHint(theme, expanded, true)); + } else if (tree.truncated) { + lines.push(theme.fg("dim", "…")); + } + return new Text(lines.join("\n"), 0, 0); + } + } catch { + // Fall through to raw output + } + } + + // Raw text output + const outputLines = trimmedOutput.split("\n"); + const maxOutputLines = expanded ? 12 : 4; + const displayLines = outputLines.slice(0, maxOutputLines); + + for (const line of displayLines) { + lines.push(theme.fg("toolOutput", truncateToWidth(line, 80))); + } + + if (outputLines.length > maxOutputLines) { + const remaining = outputLines.length - maxOutputLines; + lines.push(`${theme.fg("dim", `… ${remaining} more lines`)} ${formatExpandHint(theme, expanded, true)}`); + } else if (!expanded) { + // Show expand hint when collapsed even if all lines shown (lines may be truncated) + lines.push(formatExpandHint(theme, expanded, true)); + } + + return new Text(lines.join("\n"), 0, 0); +} diff --git a/packages/coding-agent/src/mcp/tool-bridge.ts b/packages/coding-agent/src/mcp/tool-bridge.ts index 8ef4a742e..df34b3228 100644 --- a/packages/coding-agent/src/mcp/tool-bridge.ts +++ b/packages/coding-agent/src/mcp/tool-bridge.ts @@ -6,8 +6,15 @@ import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { TSchema } from "@sinclair/typebox"; import type { SourceMeta } from "../capability/types"; -import type { CustomTool, CustomToolContext, CustomToolResult } from "../extensibility/custom-tools/types"; +import type { + CustomTool, + CustomToolContext, + CustomToolResult, + RenderResultOptions, +} from "../extensibility/custom-tools/types"; +import type { Theme } from "../modes/theme/theme"; import { callTool } from "./client"; +import { renderMCPCall, renderMCPResult } from "./render"; import type { MCPContent, MCPServerConnection, MCPToolDefinition } from "./types"; /** Details included in MCP tool results for rendering */ @@ -135,6 +142,14 @@ export class MCPTool implements CustomTool { this.mcpServerName = connection.name; } + renderCall(args: unknown, theme: Theme) { + return renderMCPCall((args ?? {}) as Record, theme, this.label); + } + + renderResult(result: CustomToolResult, options: RenderResultOptions, theme: Theme, args?: unknown) { + return renderMCPResult(result, options, theme, (args ?? {}) as Record); + } + async execute( _toolCallId: string, params: unknown, @@ -223,6 +238,14 @@ export class DeferredMCPTool implements CustomTool { this.fallbackProviderName = source?.providerName; } + renderCall(args: unknown, theme: Theme) { + return renderMCPCall((args ?? {}) as Record, theme, this.label); + } + + renderResult(result: CustomToolResult, options: RenderResultOptions, theme: Theme, args?: unknown) { + return renderMCPResult(result, options, theme, (args ?? {}) as Record); + } + async execute( _toolCallId: string, params: unknown, diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index ee7537917..caeb44a93 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -33,7 +33,6 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { logger, postmortem } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import chalk from "chalk"; -// Import discovery to register all providers on startup import { loadCapability } from "./capability"; import { type Rule, ruleCapability } from "./capability/rule"; import { getAgentDir, getConfigDirPaths } from "./config";