feat(coding-agent): collapse grouped tool calls

This commit is contained in:
Armin Ronacher 2026-05-27 20:57:01 +02:00
parent 4bbe2959bd
commit 2676945130
12 changed files with 234 additions and 49 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added optional `toolGroup` metadata on `AgentTool` for UI grouping.
### Fixed
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).

View file

@ -361,6 +361,8 @@ export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/** Optional freeform group identifier for collapsed UI rendering. */
toolGroup?: string;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added collapsed tool grouping for consecutive `read`/`find` tool calls.
### Fixed
- Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)).

View file

@ -418,6 +418,8 @@ export interface ToolRenderContext<TState = any, TArgs = any> {
showImages: boolean;
/** Whether the current result is an error. */
isError: boolean;
/** True when renderCall is producing a collapsed grouped-tool summary. */
toolGroupSummary?: boolean;
}
/**
@ -436,6 +438,8 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
promptGuidelines?: string[];
/** Parameter schema (TypeBox) */
parameters: TParams;
/** Optional freeform group identifier for collapsed tool-call aggregation. */
toolGroup?: string;
/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */
renderShell?: "default" | "self";

View file

@ -119,6 +119,7 @@ export function createFindToolDefinition(
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
promptSnippet: "Find files by glob pattern (respects .gitignore)",
parameters: findSchema,
toolGroup: "explore",
async execute(
_toolCallId,
{ pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number },

View file

@ -144,8 +144,9 @@ function formatCompactReadCall(
classification: CompactReadClassification,
args: ReadRenderArgs | undefined,
theme: Theme,
showExpandHint = true,
): string {
const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`);
const expandHint = showExpandHint ? theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`) : "";
if (classification.kind === "skill") {
return (
theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
@ -216,6 +217,7 @@ export function createReadToolDefinition(
promptSnippet: "Read file contents",
promptGuidelines: ["Use read to examine files instead of cat or sed."],
parameters: readSchema,
toolGroup: "explore",
async execute(
_toolCallId,
{ path, offset, limit }: { path: string; offset?: number; limit?: number },
@ -344,7 +346,9 @@ export function createReadToolDefinition(
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined;
text.setText(
classification ? formatCompactReadCall(classification, args, theme) : formatReadCall(args, theme),
classification
? formatCompactReadCall(classification, args, theme, !context.toolGroupSummary)
: formatReadCall(args, theme),
);
return text;
},

View file

@ -11,6 +11,7 @@ export function wrapToolDefinition<TDetails = unknown>(
label: definition.label,
description: definition.description,
parameters: definition.parameters,
toolGroup: definition.toolGroup,
prepareArguments: definition.prepareArguments,
executionMode: definition.executionMode,
execute: (toolCallId, params, signal, onUpdate) =>
@ -38,6 +39,7 @@ export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDef
label: tool.label,
description: tool.description,
parameters: tool.parameters as any,
toolGroup: tool.toolGroup,
prepareArguments: tool.prepareArguments,
executionMode: tool.executionMode,
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),

View file

@ -26,6 +26,7 @@ export { SkillInvocationMessageComponent } from "./skill-invocation-message.ts";
export { ThemeSelectorComponent } from "./theme-selector.ts";
export { ThinkingSelectorComponent } from "./thinking-selector.ts";
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts";
export { ToolGroupComponent } from "./tool-group.ts";
export { TreeSelectorComponent } from "./tree-selector.ts";
export { UserMessageComponent } from "./user-message.ts";
export { UserMessageSelectorComponent } from "./user-message-selector.ts";

View file

@ -3,7 +3,7 @@ import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts";
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts";
import { convertToPng } from "../../../utils/image-convert.ts";
import { theme } from "../theme/theme.ts";
import { type ThemeBg, theme } from "../theme/theme.ts";
export interface ToolExecutionOptions {
showImages?: boolean;
@ -28,6 +28,7 @@ export class ToolExecutionComponent extends Container {
private isPartial = true;
private toolDefinition?: ToolDefinition<any, any>;
private builtInToolDefinition?: ToolDefinition<any, any>;
readonly toolGroup: string | undefined;
private ui: TUI;
private cwd: string;
private executionStarted = false;
@ -55,6 +56,7 @@ export class ToolExecutionComponent extends Container {
this.args = args;
this.toolDefinition = toolDefinition;
this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName];
this.toolGroup = this.toolDefinition?.toolGroup ?? this.builtInToolDefinition?.toolGroup;
this.showImages = options.showImages ?? true;
this.imageWidthCells = options.imageWidthCells ?? 60;
this.ui = ui;
@ -112,7 +114,7 @@ export class ToolExecutionComponent extends Container {
return this.toolDefinition.renderShell ?? this.builtInToolDefinition.renderShell ?? "default";
}
private getRenderContext(lastComponent: Component | undefined): ToolRenderContext {
private getRenderContext(lastComponent: Component | undefined, toolGroupSummary = false): ToolRenderContext {
return {
args: this.args,
toolCallId: this.toolCallId,
@ -129,6 +131,7 @@ export class ToolExecutionComponent extends Container {
expanded: this.expanded,
showImages: this.showImages,
isError: this.result?.isError ?? false,
toolGroupSummary,
};
}
@ -213,11 +216,32 @@ export class ToolExecutionComponent extends Container {
this.updateDisplay();
}
getBackgroundColor(): ThemeBg {
if (this.isPartial) return "toolPendingBg";
if (this.result?.isError) return "toolErrorBg";
return "toolSuccessBg";
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
renderCallSummary(width: number): string[] {
const callRenderer = this.getCallRenderer();
let component: Component;
if (!callRenderer) {
component = this.createCallFallback();
} else {
try {
component = callRenderer(this.args, theme, this.getRenderContext(undefined, true));
} catch {
component = this.createCallFallback();
}
}
return component.render(width);
}
override render(width: number): string[] {
if (this.hideComponent) {
return [];
@ -226,11 +250,7 @@ export class ToolExecutionComponent extends Container {
}
private updateDisplay(): void {
const bgFn = this.isPartial
? (text: string) => theme.bg("toolPendingBg", text)
: this.result?.isError
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
const bgFn = (text: string) => theme.bg(this.getBackgroundColor(), text);
let hasContent = false;
this.hideComponent = false;

View file

@ -0,0 +1,83 @@
import { Box, type Component } from "@earendil-works/pi-tui";
import { type ThemeBg, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
import type { ToolExecutionComponent } from "./tool-execution.ts";
export class ToolGroupComponent implements Component {
readonly toolGroup: string;
private readonly tools: ToolExecutionComponent[] = [];
private expanded = false;
constructor(toolGroup: string, tools: ToolExecutionComponent[] = []) {
this.toolGroup = toolGroup;
for (const tool of tools) {
this.addTool(tool);
}
}
addTool(tool: ToolExecutionComponent): void {
tool.setExpanded(this.expanded);
this.tools.push(tool);
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
for (const tool of this.tools) {
tool.setExpanded(expanded);
}
}
setShowImages(show: boolean): void {
for (const tool of this.tools) {
tool.setShowImages(show);
}
}
setImageWidthCells(width: number): void {
for (const tool of this.tools) {
tool.setImageWidthCells(width);
}
}
invalidate(): void {
for (const tool of this.tools) {
tool.invalidate();
}
}
render(width: number): string[] {
if (this.tools.length === 0) return [];
const safeWidth = Math.max(1, width);
if (this.expanded) {
return this.tools.flatMap((tool) => tool.render(safeWidth));
}
const box = new Box(1, 1, (text) => theme.bg(this.getBackgroundColor(), text));
box.addChild({
render: (contentWidth) => this.renderCollapsed(contentWidth),
invalidate: () => {},
});
return [" ".repeat(safeWidth), ...box.render(safeWidth)];
}
private renderCollapsed(width: number): string[] {
const lines = this.tools.flatMap((tool) =>
tool.renderCallSummary(width).map((line) => line.replace(/[ \t]+$/g, "")),
);
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i]!.trim().length > 0) {
lines[i] += theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`);
break;
}
}
return lines;
}
private getBackgroundColor(): ThemeBg {
const colors = this.tools.map((tool) => tool.getBackgroundColor());
if (colors.includes("toolErrorBg")) return "toolErrorBg";
if (colors.includes("toolPendingBg")) return "toolPendingBg";
return "toolSuccessBg";
}
}

View file

@ -118,6 +118,7 @@ import { SessionSelectorComponent } from "./components/session-selector.ts";
import { SettingsSelectorComponent } from "./components/settings-selector.ts";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";
import { ToolExecutionComponent } from "./components/tool-execution.ts";
import { ToolGroupComponent } from "./components/tool-group.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
@ -1620,6 +1621,47 @@ export class InteractiveMode {
return this.session.getToolDefinition(toolName);
}
private createToolExecutionComponent(toolName: string, toolCallId: string, args: unknown): ToolExecutionComponent {
const component = new ToolExecutionComponent(
toolName,
toolCallId,
args,
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
},
this.getRegisteredToolDefinition(toolName),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
return component;
}
private appendToolExecutionComponent(component: ToolExecutionComponent): void {
const toolGroup = component.toolGroup?.trim();
if (!toolGroup) {
this.chatContainer.addChild(component);
return;
}
const children = this.chatContainer.children;
const lastChild = children[children.length - 1];
if (lastChild instanceof ToolGroupComponent && lastChild.toolGroup === toolGroup) {
lastChild.addTool(component);
return;
}
if (lastChild instanceof ToolExecutionComponent && lastChild.toolGroup?.trim() === toolGroup) {
const group = new ToolGroupComponent(toolGroup, [lastChild, component]);
group.setExpanded(this.toolOutputExpanded);
children[children.length - 1] = group;
return;
}
this.chatContainer.addChild(component);
}
/**
* Set up keyboard shortcuts registered by extensions.
*/
@ -2729,20 +2771,12 @@ export class InteractiveMode {
for (const content of this.streamingMessage.content) {
if (content.type === "toolCall") {
if (!this.pendingTools.has(content.id)) {
const component = new ToolExecutionComponent(
const component = this.createToolExecutionComponent(
content.name,
content.id,
content.arguments,
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
},
this.getRegisteredToolDefinition(content.name),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
this.appendToolExecutionComponent(component);
this.pendingTools.set(content.id, component);
} else {
const component = this.pendingTools.get(content.id);
@ -2798,20 +2832,8 @@ export class InteractiveMode {
case "tool_execution_start": {
let component = this.pendingTools.get(event.toolCallId);
if (!component) {
component = new ToolExecutionComponent(
event.toolName,
event.toolCallId,
event.args,
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
},
this.getRegisteredToolDefinition(event.toolName),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
component = this.createToolExecutionComponent(event.toolName, event.toolCallId, event.args);
this.appendToolExecutionComponent(component);
this.pendingTools.set(event.toolCallId, component);
}
component.markExecutionStarted();
@ -3139,20 +3161,8 @@ export class InteractiveMode {
// Render tool call components
for (const content of message.content) {
if (content.type === "toolCall") {
const component = new ToolExecutionComponent(
content.name,
content.id,
content.arguments,
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
},
this.getRegisteredToolDefinition(content.name),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
const component = this.createToolExecutionComponent(content.name, content.id, content.arguments);
this.appendToolExecutionComponent(component);
if (message.stopReason === "aborted" || message.stopReason === "error") {
let errorMessage: string;
@ -3874,7 +3884,7 @@ export class InteractiveMode {
onShowImagesChange: (enabled) => {
this.settingsManager.setShowImages(enabled);
for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
if (child instanceof ToolExecutionComponent || child instanceof ToolGroupComponent) {
child.setShowImages(enabled);
}
}
@ -3882,7 +3892,7 @@ export class InteractiveMode {
onImageWidthCellsChange: (width) => {
this.settingsManager.setImageWidthCells(width);
for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
if (child instanceof ToolExecutionComponent || child instanceof ToolGroupComponent) {
child.setImageWidthCells(width);
}
}

View file

@ -5,9 +5,11 @@ import { beforeAll, describe, expect, test } from "vitest";
import { getReadmePath } from "../src/config.ts";
import type { ToolDefinition } from "../src/core/extensions/types.ts";
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.ts";
import { createFindToolDefinition } from "../src/core/tools/find.ts";
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.ts";
import { createWriteToolDefinition } from "../src/core/tools/write.ts";
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts";
import { ToolGroupComponent } from "../src/modes/interactive/components/tool-group.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
@ -88,6 +90,11 @@ describe("ToolExecutionComponent parity", () => {
expect(rendered).not.toContain(":1");
});
test("assigns explore tool group to read and find tools", () => {
expect(createReadToolDefinition(process.cwd()).toolGroup).toBe("explore");
expect(createFindToolDefinition(process.cwd()).toolGroup).toBe("explore");
});
test("preserves legacy file_path rendering compatibility for built-in tools", () => {
const component = new ToolExecutionComponent(
"read",
@ -457,6 +464,49 @@ describe("ToolExecutionComponent parity", () => {
});
}
test("renders collapsed tool groups as one card with the hint on the last summary", () => {
const readDefinition = createReadToolDefinition(process.cwd());
const skillRead = new ToolExecutionComponent(
"read",
"tool-group-skill",
{ path: join(process.cwd(), "attio", "SKILL.md") },
{},
readDefinition,
createFakeTui(),
process.cwd(),
);
const rangedRead = new ToolExecutionComponent(
"read",
"tool-group-ranged-read",
{ path: "packages/ai/CHANGELOG.md", offset: 1, limit: 90 },
{},
readDefinition,
createFakeTui(),
process.cwd(),
);
const group = new ToolGroupComponent("explore");
group.addTool(skillRead);
group.addTool(rangedRead);
const rendered = stripAnsi(group.render(120).join("\n"));
const renderedLines = rendered.split("\n");
expect(rendered).not.toContain("Explore");
expect(rendered).toContain("[skill] attio");
expect(rendered).toContain("read packages/ai/CHANGELOG.md:1-90");
expect(rendered.match(/to expand/g)?.length ?? 0).toBe(1);
expect(renderedLines.some((line) => line.startsWith(" [skill] attio") && !line.includes("to expand"))).toBe(true);
expect(
renderedLines.some(
(line) => line.startsWith(" read packages/ai/CHANGELOG.md:1-90") && line.includes("to expand"),
),
).toBe(true);
group.setExpanded(true);
const expanded = stripAnsi(group.render(120).join("\n"));
expect(expanded).not.toContain("Explore");
expect(expanded).not.toContain("to expand");
});
for (const scenario of [
{ title: "SKILL.md", path: join(process.cwd(), "attio", "SKILL.md"), compact: "[skill] attio:120-329" },
{ title: "Pi documentation", path: getReadmePath(), compact: "read docs README.md:120-329" },