215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
/**
|
|
* TUI rendering for MCP tools.
|
|
*
|
|
* Provides structured display of MCP tool calls and results,
|
|
* showing args and output in JSON tree format similar to task tool.
|
|
*/
|
|
import { type Component, Markdown } from "@oh-my-pi/pi-tui";
|
|
import { settings } from "../config/settings";
|
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
|
|
import {
|
|
formatArgsInline,
|
|
JSON_TREE_MAX_DEPTH_COLLAPSED,
|
|
JSON_TREE_MAX_DEPTH_EXPANDED,
|
|
JSON_TREE_MAX_LINES_COLLAPSED,
|
|
JSON_TREE_MAX_LINES_EXPANDED,
|
|
JSON_TREE_SCALAR_LEN_COLLAPSED,
|
|
JSON_TREE_SCALAR_LEN_EXPANDED,
|
|
renderJsonTreeLines,
|
|
} from "../tools/json-tree";
|
|
import { formatStyledTruncationWarning, stripOutputNotice } from "../tools/output-meta";
|
|
import { formatExpandHint, truncateToWidth } from "../tools/render-utils";
|
|
import { renderStatusLine, WidthAwareText } from "../tui";
|
|
import type { MCPToolDetails } from "./tool-bridge";
|
|
|
|
/**
|
|
* Render MCP tool call.
|
|
*/
|
|
export function renderMCPCall(args: Record<string, unknown>, theme: Theme, label: string): Component {
|
|
return new WidthAwareText(
|
|
contentWidth => {
|
|
const lines: string[] = [];
|
|
lines.push(renderStatusLine({ icon: "pending", title: label }, theme));
|
|
|
|
if (args && typeof args === "object" && Object.keys(args).length > 0) {
|
|
// Inline preview budgeted against the render width, leaving room for
|
|
// the ` └─ ` connector prefix instead of a fixed cap.
|
|
const inlineBudget = Math.max(20, contentWidth - Bun.stringWidth(theme.tree.last) - 2);
|
|
const preview = formatArgsInline(args, inlineBudget);
|
|
if (preview) {
|
|
lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
},
|
|
0,
|
|
0,
|
|
);
|
|
}
|
|
|
|
/** Render an MCP status/args prefix followed by Markdown-aware text output. */
|
|
function renderMarkdownMCPResult(
|
|
result: { details?: MCPToolDetails; isError?: boolean },
|
|
trimmedOutput: string,
|
|
truncationWarning: string | null,
|
|
options: RenderResultOptions,
|
|
theme: Theme,
|
|
args?: Record<string, unknown>,
|
|
): Component {
|
|
const markdown = new Markdown(trimmedOutput, 0, 0, getMarkdownTheme(), {
|
|
color: text => theme.fg("toolOutput", text),
|
|
});
|
|
return {
|
|
render(contentWidth: number): readonly string[] {
|
|
const lines: string[] = [];
|
|
const isError = result.isError ?? result.details?.isError ?? false;
|
|
const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP";
|
|
lines.push(
|
|
renderStatusLine(
|
|
isError ? { icon: "error", title } : { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title },
|
|
theme,
|
|
),
|
|
);
|
|
|
|
if (options.expanded && args && Object.keys(args).length > 0) {
|
|
lines.push(theme.fg("dim", "Args"));
|
|
const tree = renderJsonTreeLines(
|
|
args,
|
|
theme,
|
|
JSON_TREE_MAX_DEPTH_EXPANDED,
|
|
JSON_TREE_MAX_LINES_EXPANDED,
|
|
JSON_TREE_SCALAR_LEN_EXPANDED,
|
|
);
|
|
lines.push(...tree.lines);
|
|
if (tree.truncated) lines.push(theme.fg("dim", "…"));
|
|
lines.push("");
|
|
}
|
|
|
|
const rendered = markdown.render(Math.max(1, contentWidth));
|
|
const maxOutputLines = options.expanded ? 12 : 4;
|
|
lines.push(...rendered.slice(0, maxOutputLines));
|
|
if (rendered.length > maxOutputLines) {
|
|
lines.push(
|
|
`${theme.fg("dim", `… ${rendered.length - maxOutputLines} more lines`)} ${formatExpandHint(theme, options.expanded, true)}`,
|
|
);
|
|
} else if (!options.expanded) {
|
|
lines.push(formatExpandHint(theme, options.expanded, true));
|
|
}
|
|
if (truncationWarning) lines.push(truncationWarning);
|
|
return lines;
|
|
},
|
|
invalidate(): void {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render MCP tool result.
|
|
*/
|
|
export function renderMCPResult(
|
|
result: { content: Array<{ type: string; text?: string }>; details?: MCPToolDetails; isError?: boolean },
|
|
options: RenderResultOptions,
|
|
theme: Theme,
|
|
args?: Record<string, unknown>,
|
|
): Component {
|
|
const { expanded } = options;
|
|
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
|
|
const truncationWarning = result.details?.meta?.truncation
|
|
? formatStyledTruncationWarning(result.details.meta, theme)
|
|
: null;
|
|
let parsedOutput: unknown;
|
|
let isJsonOutput = false;
|
|
if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
|
|
try {
|
|
parsedOutput = JSON.parse(trimmedOutput);
|
|
isJsonOutput = true;
|
|
} catch {
|
|
// Non-JSON text beginning with a bracket is still eligible for Markdown.
|
|
}
|
|
}
|
|
if (trimmedOutput && settings.get("mcp.renderMarkdownResults") && !isJsonOutput) {
|
|
return renderMarkdownMCPResult(result, trimmedOutput, truncationWarning, options, theme, args);
|
|
}
|
|
return new WidthAwareText(
|
|
contentWidth => {
|
|
const lines: string[] = [];
|
|
const isError = result.isError ?? result.details?.isError ?? false;
|
|
const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP";
|
|
const success = !isError;
|
|
lines.push(
|
|
renderStatusLine(
|
|
success ? { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title } : { icon: "error", title },
|
|
theme,
|
|
),
|
|
);
|
|
|
|
// 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. The body and spill metadata are normalized before
|
|
// component selection so the opt-in Markdown path can use its own renderer.
|
|
|
|
if (!trimmedOutput) {
|
|
lines.push(theme.fg("dim", "(no output)"));
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// Preserve the existing structured JSON renderer regardless of the
|
|
// Markdown preference; JSON trees remain more useful than styled source.
|
|
if (isJsonOutput) {
|
|
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(parsedOutput, theme, maxDepth, maxLines, maxScalarLen);
|
|
|
|
if (tree.lines.length > 0) {
|
|
lines.push(...tree.lines);
|
|
if (!expanded) {
|
|
lines.push(formatExpandHint(theme, expanded, true));
|
|
} else if (tree.truncated) {
|
|
lines.push(theme.fg("dim", "…"));
|
|
}
|
|
if (truncationWarning) lines.push(truncationWarning);
|
|
return lines.join("\n");
|
|
}
|
|
}
|
|
|
|
// 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, contentWidth)));
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
if (truncationWarning) lines.push(truncationWarning);
|
|
return lines.join("\n");
|
|
},
|
|
0,
|
|
0,
|
|
);
|
|
}
|