75cc0a054f
- 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.
140 lines
4.6 KiB
TypeScript
140 lines
4.6 KiB
TypeScript
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;
|
|
}
|