Files
oh-my-pi/packages/coding-agent/src/tools/default-renderer.ts
T
roboomp ebd84d4f85 fix(tui): render mid-turn steering skips as info, not errors
A tool call aborted mid-batch to service queued steering/peer input emits a synthetic placeholder result with isError:true so the model retries it. The TUI keyed all error styling (red ✘, red frame/text) off that flag, so a normal steering skip rendered identically to a real tool failure.

Mark the skip placeholder with the existing SyntheticToolResultDetails discriminator (source: "interrupt_skipped", executed:false) and render benign skips through the neutral generic card (info glyph, dim text, neutral background), bypassing any bespoke error frame. Genuine failures keep their error styling.

Fixes #7199
2026-07-31 21:06:07 +00:00

155 lines
4.9 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;
/** Synthetic placeholder for a call skipped mid-batch to service steering/peer
* input — the tool never ran, so it renders neutral (info) rather than as an error. */
skipped?: 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?.skipped
? "info"
: result?.isError
? "error"
: "done";
lines.push(
renderStatusLine(
{
icon,
spinnerFrame: options.spinnerFrame,
title: input.label,
...(result?.skipped ? { titleColor: "muted" as const } : {}),
},
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;
}