fix(coding-agent): allow suppressing custom tool transcript blocks

This commit is contained in:
Aljosa Asanovic 2026-03-02 10:22:41 -05:00
parent 95276df060
commit 480d6bc62d
No known key found for this signature in database
GPG key ID: 8AE0F719C49B8338
4 changed files with 114 additions and 5 deletions

View file

@ -49,6 +49,9 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
}
const component = toolDef.renderCall(args, theme);
if (!component) {
return undefined;
}
const lines = component.render(width);
return ansiLinesToHtml(lines);
} catch {
@ -79,6 +82,9 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
// Always render expanded, client-side will apply truncation
const component = toolDef.renderResult(agentToolResult, { expanded: true, isPartial: false }, theme);
if (!component) {
return undefined;
}
const lines = component.render(width);
return ansiLinesToHtml(lines);
} catch {

View file

@ -352,10 +352,14 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
): Promise<AgentToolResult<TDetails>>;
/** Custom rendering for tool call display */
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
renderCall?: (args: Static<TParams>, theme: Theme) => Component | undefined;
/** Custom rendering for tool result display */
renderResult?: (result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme) => Component;
renderResult?: (
result: AgentToolResult<TDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) => Component | undefined;
}
// ============================================================================

View file

@ -95,6 +95,8 @@ export class ToolExecutionComponent extends Container {
private convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
// Incremental syntax highlighting cache for write tool call args
private writeHighlightCache?: WriteHighlightCache;
// When true, this component intentionally renders no lines
private hideComponent = false;
constructor(
toolName: string,
@ -354,6 +356,13 @@ export class ToolExecutionComponent extends Container {
this.updateDisplay();
}
override render(width: number): string[] {
if (this.hideComponent) {
return [];
}
return super.render(width);
}
private updateDisplay(): void {
// Set background based on state
const bgFn = this.isPartial
@ -362,8 +371,12 @@ export class ToolExecutionComponent extends Container {
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
const useBuiltInRenderer = this.shouldUseBuiltInRenderer();
let customRendererHasContent = false;
this.hideComponent = false;
// Use built-in rendering for built-in tools (or overrides without custom renderers)
if (this.shouldUseBuiltInRenderer()) {
if (useBuiltInRenderer) {
if (this.toolName === "bash") {
// Bash uses Box with visual line truncation
this.contentBox.setBgFn(bgFn);
@ -383,16 +396,19 @@ export class ToolExecutionComponent extends Container {
if (this.toolDefinition.renderCall) {
try {
const callComponent = this.toolDefinition.renderCall(this.args, theme);
if (callComponent) {
if (callComponent !== undefined) {
this.contentBox.addChild(callComponent);
customRendererHasContent = true;
}
} catch {
// Fall back to default on error
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
customRendererHasContent = true;
}
} else {
// No custom renderCall, show tool name
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
customRendererHasContent = true;
}
// Render result component if we have a result
@ -403,14 +419,16 @@ export class ToolExecutionComponent extends Container {
{ expanded: this.expanded, isPartial: this.isPartial },
theme,
);
if (resultComponent) {
if (resultComponent !== undefined) {
this.contentBox.addChild(resultComponent);
customRendererHasContent = true;
}
} catch {
// Fall back to showing raw output on error
const output = this.getTextOutput();
if (output) {
this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
customRendererHasContent = true;
}
}
} else if (this.result) {
@ -418,8 +436,13 @@ export class ToolExecutionComponent extends Container {
const output = this.getTextOutput();
if (output) {
this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
customRendererHasContent = true;
}
}
} else {
// Unknown tool with no registered definition - show generic fallback
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
}
// Handle images (same for both custom and built-in)
@ -463,6 +486,10 @@ export class ToolExecutionComponent extends Container {
}
}
}
if (!useBuiltInRenderer && this.toolDefinition) {
this.hideComponent = !customRendererHasContent && this.imageComponents.length === 0;
}
}
/**

View file

@ -0,0 +1,72 @@
import { Text, type TUI } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import stripAnsi from "strip-ansi";
import { beforeAll, describe, expect, test } from "vitest";
import type { ToolDefinition } from "../src/core/extensions/types.js";
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";
import { initTheme } from "../src/modes/interactive/theme/theme.js";
function createBaseToolDefinition(): ToolDefinition {
return {
name: "custom_tool",
label: "custom_tool",
description: "custom tool",
parameters: Type.Any(),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
};
}
function createFakeTui(): TUI {
return {
requestRender: () => {},
} as unknown as TUI;
}
describe("ToolExecutionComponent custom renderer suppression", () => {
beforeAll(() => {
initTheme("dark");
});
test("renders no lines when custom renderers return undefined", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: () => undefined,
renderResult: () => undefined,
};
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
expect(component.render(120)).toEqual([]);
component.updateResult(
{
content: [{ type: "text", text: "hidden" }],
details: {},
isError: false,
},
false,
);
expect(component.render(120)).toEqual([]);
});
test("keeps built-in tool rendering visible", () => {
const component = new ToolExecutionComponent("read", { path: "README.md" }, {}, undefined, createFakeTui());
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read");
});
test("keeps custom tool rendering visible when renderer returns a component", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: () => new Text("custom call", 0, 0),
renderResult: () => undefined,
};
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom call");
});
});