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:
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user