From 75cc0a054fe830a00a9942fad62e08d50882412e Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 24 Jul 2026 08:19:13 +0200 Subject: [PATCH] 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. --- .../src/modes/components/tool-execution.ts | 127 +++------------- .../src/tools/default-renderer.ts | 139 ++++++++++++++++++ packages/coding-agent/src/tools/xdev.ts | 25 +++- .../test/write-xdev-dispatch.test.ts | 45 ++++++ 4 files changed, 219 insertions(+), 117 deletions(-) create mode 100644 packages/coding-agent/src/tools/default-renderer.ts diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index cfc74778d..ce624971b 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -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) : 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"); - } } diff --git a/packages/coding-agent/src/tools/default-renderer.ts b/packages/coding-agent/src/tools/default-renderer.ts new file mode 100644 index 000000000..0b06fc916 --- /dev/null +++ b/packages/coding-agent/src/tools/default-renderer.ts @@ -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; +} diff --git a/packages/coding-agent/src/tools/xdev.ts b/packages/coding-agent/src/tools/xdev.ts index 2bbe72f38..03fa136dc 100644 --- a/packages/coding-agent/src/tools/xdev.ts +++ b/packages/coding-agent/src/tools/xdev.ts @@ -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 | 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, + ); } diff --git a/packages/coding-agent/test/write-xdev-dispatch.test.ts b/packages/coding-agent/test/write-xdev-dispatch.test.ts index f293b3738..23ef78d35 100644 --- a/packages/coding-agent/test/write-xdev-dispatch.test.ts +++ b/packages/coding-agent/test/write-xdev-dispatch.test.ts @@ -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 {