feat(coding-agent/tools): added default card fallback for tool rendering

- Extracted #formatToolExecution into a standalone formatDefaultToolExecution module.
- Updated xdev renderXdevCall and renderXdevResult to use the default card when no mounted renderer exists.
- Added fallback rendering that shows tool label, args, and output with appropriate theming.
- Added integration test verifying generic card renders for mounted tools without bespoke renderers.
This commit is contained in:
can1357
2026-07-24 08:19:13 +02:00
parent 269c267397
commit 75cc0a054f
4 changed files with 219 additions and 117 deletions
@@ -19,25 +19,10 @@ import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "..
import type { Theme } from "../../modes/theme/theme";
import { getThemeEpoch, theme } from "../../modes/theme/theme";
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
import { formatDefaultToolExecution } from "../../tools/default-renderer";
import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
import { isWaitingPollDetails } from "../../tools/hub";
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 {
formatExpandHint,
formatStatusIcon,
replaceTabs,
resolveImageOptions,
truncateToWidth,
} from "../../tools/render-utils";
import { formatStatusIcon, replaceTabs, resolveImageOptions } from "../../tools/render-utils";
import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
@@ -402,7 +387,21 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
// strips PLAIN-blank edges, so framed/minimal blocks (no bg set) drop these
// lines and keep their tight spacing — only tinted lines survive.
this.#contentBox = new Box(0, 1);
this.#contentText = new WidthAwareText(contentWidth => this.#formatToolExecution(contentWidth), 1, 1);
this.#contentText = new WidthAwareText(
contentWidth =>
formatDefaultToolExecution(
{
label: this.#toolLabel,
args: this.#args,
result: this.#result ? { output: this.#getTextOutput(), isError: this.#result.isError } : undefined,
options: this.#renderState,
},
contentWidth,
theme,
),
1,
1,
);
// Use Box for custom tools or built-in tools that have renderers
const hasRenderer = toolName in toolRenderers;
@@ -1353,96 +1352,4 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
return output;
}
/**
* Format a generic tool execution (fallback for tools without custom renderers)
*/
#formatToolExecution(contentWidth: number): string {
const lines: string[] = [];
const icon = this.#isPartial
? this.#spinnerFrame !== undefined
? "running"
: "pending"
: this.#result?.isError
? "error"
: "done";
lines.push(renderStatusLine({ icon, spinnerFrame: this.#spinnerFrame, title: this.#toolLabel }, theme));
const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record<string, unknown>) : null;
if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) {
// Budget the inline preview 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(argsObject, inlineBudget);
if (preview) {
lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`);
}
}
if (this.#expanded && this.#args !== undefined) {
lines.push("");
lines.push(theme.fg("dim", "Args"));
const tree = renderJsonTreeLines(
this.#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("");
}
if (!this.#result) {
return lines.join("\n");
}
const textContent = this.#getTextOutput().trimEnd();
if (!textContent) {
lines.push(theme.fg("dim", "(no output)"));
return lines.join("\n");
}
if (textContent.startsWith("{") || textContent.startsWith("[")) {
try {
const parsed = JSON.parse(textContent);
const maxDepth = this.#expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
const maxLines = this.#expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
const maxScalarLen = this.#expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen);
if (tree.lines.length > 0) {
lines.push(...tree.lines);
if (!this.#expanded) {
lines.push(formatExpandHint(theme, this.#expanded, true));
} else if (tree.truncated) {
lines.push(theme.fg("dim", "…"));
}
return lines.join("\n");
}
} catch {
// Fall through to raw output
}
}
const outputLines = textContent.split("\n");
const maxOutputLines = this.#expanded ? 12 : 4;
const displayLines = outputLines.slice(0, maxOutputLines);
for (const line of displayLines) {
lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), contentWidth)));
}
if (outputLines.length > maxOutputLines) {
const remaining = outputLines.length - maxOutputLines;
lines.push(`${theme.fg("dim", `… ${remaining} more lines`)} ${formatExpandHint(theme, this.#expanded, true)}`);
} else if (!this.#expanded) {
lines.push(formatExpandHint(theme, this.#expanded, true));
}
return lines.join("\n");
}
}
@@ -0,0 +1,139 @@
import type { Component } from "@oh-my-pi/pi-tui";
import { isRecord } from "@oh-my-pi/pi-utils";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import { renderStatusLine, WidthAwareText } from "../tui";
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 "./json-tree";
import { formatExpandHint, replaceTabs, truncateToWidth } from "./render-utils";
/** Inputs rendered by the fallback card used when a tool has no bespoke renderer. */
export interface DefaultToolRenderInput {
/** Human-readable tool label. */
label: string;
/** Tool arguments, shown inline when collapsed and as a tree when expanded. */
args: unknown;
/** Settled or streaming result; omitted while only the call is available. */
result?: {
output: string;
isError?: boolean;
};
/** Current expansion and lifecycle state. */
options: RenderResultOptions;
}
/** Format one generic tool call/result card at the available content width. */
export function formatDefaultToolExecution(
input: DefaultToolRenderInput,
contentWidth: number,
uiTheme: Theme,
): string {
const lines: string[] = [];
const { options, result } = input;
const icon = options.isPartial
? options.spinnerFrame !== undefined
? "running"
: "pending"
: result?.isError
? "error"
: "done";
lines.push(renderStatusLine({ icon, spinnerFrame: options.spinnerFrame, title: input.label }, uiTheme));
const args = isRecord(input.args) ? input.args : undefined;
if (!options.expanded && args && Object.keys(args).length > 0) {
const inlineBudget = Math.max(20, contentWidth - Bun.stringWidth(uiTheme.tree.last) - 2);
const preview = formatArgsInline(args, inlineBudget);
if (preview) {
lines.push(` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("dim", preview)}`);
}
}
if (options.expanded && input.args !== undefined) {
lines.push("");
lines.push(uiTheme.fg("dim", "Args"));
const tree = renderJsonTreeLines(
input.args,
uiTheme,
JSON_TREE_MAX_DEPTH_EXPANDED,
JSON_TREE_MAX_LINES_EXPANDED,
JSON_TREE_SCALAR_LEN_EXPANDED,
);
lines.push(...tree.lines);
if (tree.truncated) {
lines.push(uiTheme.fg("dim", "…"));
}
lines.push("");
}
if (!result) {
return lines.join("\n");
}
const textContent = result.output.trimEnd();
if (!textContent) {
lines.push(uiTheme.fg("dim", "(no output)"));
return lines.join("\n");
}
if (textContent.startsWith("{") || textContent.startsWith("[")) {
try {
const parsed = JSON.parse(textContent);
const maxDepth = options.expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
const maxLines = options.expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
const maxScalarLen = options.expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
const tree = renderJsonTreeLines(parsed, uiTheme, maxDepth, maxLines, maxScalarLen);
if (tree.lines.length > 0) {
lines.push(...tree.lines);
if (!options.expanded) {
lines.push(formatExpandHint(uiTheme, options.expanded, true));
} else if (tree.truncated) {
lines.push(uiTheme.fg("dim", "…"));
}
return lines.join("\n");
}
} catch {
// Non-JSON output that starts with a bracket is rendered as plain text.
}
}
const outputLines = textContent.split("\n");
const maxOutputLines = options.expanded ? 12 : 4;
const displayLines = outputLines.slice(0, maxOutputLines);
for (const line of displayLines) {
lines.push(uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), contentWidth)));
}
if (outputLines.length > maxOutputLines) {
const remaining = outputLines.length - maxOutputLines;
lines.push(
`${uiTheme.fg("dim", `… ${remaining} more lines`)} ${formatExpandHint(uiTheme, options.expanded, true)}`,
);
} else if (!options.expanded) {
lines.push(formatExpandHint(uiTheme, options.expanded, true));
}
return lines.join("\n");
}
/** Render the generic fallback as the state-tinted card used by direct custom tools. */
export function renderDefaultToolExecution(input: DefaultToolRenderInput, uiTheme: Theme): Component {
const component = new WidthAwareText(contentWidth => formatDefaultToolExecution(input, contentWidth, uiTheme), 1, 1);
const background = input.options.isPartial
? "toolPendingBg"
: input.result?.isError
? "toolErrorBg"
: "toolSuccessBg";
component.setCustomBgFn(text => uiTheme.bg(background, text));
component.setIgnoreTight(true);
return component;
}
+18 -7
View File
@@ -30,6 +30,7 @@ import { parseStreamingJson } from "@oh-my-pi/pi-utils";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { XD_URL_PREFIX } from "../internal-urls/xd-protocol";
import type { Theme } from "../modes/theme/theme";
import { renderDefaultToolExecution } from "./default-renderer";
import type { Tool } from "./index";
import { replaceTabs } from "./render-utils";
import type { ToolRenderer } from "./renderers";
@@ -440,9 +441,8 @@ export class XdevRegistry {
* map keyed by name. */
function resolveDeviceRenderer(
name: string,
resolveMounted: ((name: string) => Tool | undefined) | undefined,
mounted: Tool | undefined,
): Pick<ToolRenderer, "renderCall" | "renderResult" | "mergeCallAndResult"> | undefined {
const mounted = resolveMounted?.(name);
if (mounted && (mounted.renderCall || mounted.renderResult)) {
// A mounted AgentTool exposes the same renderCall/renderResult/mergeCallAndResult
// surface as a static ToolRenderer; only the parameter generics differ, so unify
@@ -464,11 +464,13 @@ export function renderXdevCall(
theme: Theme,
resolveMounted?: (name: string) => Tool | undefined,
): Component | undefined {
const renderer = resolveDeviceRenderer(name, resolveMounted);
const mounted = resolveMounted?.(name);
const renderer = resolveDeviceRenderer(name, mounted);
const args = decodeInnerArgs(content);
if (renderer?.renderCall) {
return renderer.renderCall(decodeInnerArgs(content), options, theme);
return renderer.renderCall(args, options, theme);
}
return new Text(theme.fg("toolTitle", theme.bold(`${XD_URL_PREFIX}${name}`)), 0, 0);
return renderDefaultToolExecution({ label: mounted?.label ?? name, args, options }, theme);
}
/** Forward an `xd://` dispatch result to the mounted tool's renderer. */
@@ -486,7 +488,8 @@ export function renderXdevResult(
if (dispatch.mode === "help") {
return text ? new Text(theme.fg("toolOutput", replaceTabs(text)), 0, 0) : undefined;
}
const renderer = resolveDeviceRenderer(dispatch.tool, resolveMounted);
const mounted = resolveMounted?.(dispatch.tool);
const renderer = resolveDeviceRenderer(dispatch.tool, mounted);
const innerResult = { content: result.content, details: dispatch.inner, isError: result.isError };
if (renderer?.renderResult) {
const parts: Component[] = [];
@@ -505,5 +508,13 @@ export function renderXdevResult(
return box;
}
}
return text ? new Text(theme.fg("toolOutput", replaceTabs(text)), 0, 0) : undefined;
return renderDefaultToolExecution(
{
label: mounted?.label ?? dispatch.tool,
args: dispatch.args ?? {},
result: { output: text, isError: result.isError },
options,
},
theme,
);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
@@ -228,6 +229,50 @@ describe("read and write route xd:// device URLs", () => {
expect(rendered).not.toContain("Write");
});
it("keeps the generic custom-tool card when a mounted device has no renderer", async () => {
await themeModule.initTheme();
const uiTheme = (await themeModule.getThemeByName("dark")) ?? (await themeModule.getThemeByName("light"));
if (!uiTheme) throw new Error("expected an initialized theme");
const weatherDevice: AgentTool = {
name: "weather",
label: "Weather",
description: "Gets the weather",
parameters: type({ query: "string" }),
async execute() {
return { content: [{ type: "text", text: "Tokyo: 22°C" }] };
},
};
const registry = new XdevRegistry([weatherDevice]);
const write = new WriteTool(xdevSession(process.cwd(), { xdevRegistry: registry }));
const content = JSON.stringify({ query: "Tokyo" });
const result = await write.execute("write-xdev-default-renderer", {
path: "xd://weather",
content,
});
const component = writeToolRenderer.renderResult(
result,
{
expanded: false,
isPartial: false,
renderContext: { resolveXdevMounted: name => registry.get(name) },
},
uiTheme,
{ path: "xd://weather", content },
);
const lines = component.render(80);
const rendered = Bun.stripANSI(lines.join("\n"));
const backgroundProbe = uiTheme.bg("toolSuccessBg", "|");
const backgroundPrefix = backgroundProbe.slice(0, backgroundProbe.indexOf("|"));
expect(rendered).toContain("Weather");
expect(rendered).toContain('query="Tokyo"');
expect(rendered).toContain("Tokyo: 22°C");
expect(backgroundPrefix).not.toBe("");
expect(lines.some(line => line.includes(backgroundPrefix))).toBe(true);
});
it("docsAll inlines small device docs and falls back to a listing past the caps", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-docs-"));
try {