From 267694513055a022153887615e9124f7ae5f5721 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 20:57:01 +0200 Subject: [PATCH] feat(coding-agent): collapse grouped tool calls --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/types.ts | 2 + packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/core/extensions/types.ts | 4 + packages/coding-agent/src/core/tools/find.ts | 1 + packages/coding-agent/src/core/tools/read.ts | 8 +- .../src/core/tools/tool-definition-wrapper.ts | 2 + .../src/modes/interactive/components/index.ts | 1 + .../interactive/components/tool-execution.ts | 34 +++++-- .../interactive/components/tool-group.ts | 83 +++++++++++++++++ .../src/modes/interactive/interactive-mode.ts | 90 ++++++++++--------- .../test/tool-execution-component.test.ts | 50 +++++++++++ 12 files changed, 234 insertions(+), 49 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/tool-group.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 8996003cd..c06b08641 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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)). diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index f24d24963..20e592ee2 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -361,6 +361,8 @@ export type AgentToolUpdateCallback = (partialResult: AgentToolResult extends Tool { /** 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`. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8dac9c718..cafa6f8c2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 58b852273..cb8778b4f 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -418,6 +418,8 @@ export interface ToolRenderContext { 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( 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): 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), diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index d9b5d773e..63b67353a 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index b941dfc88..3589330f8 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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; private builtInToolDefinition?: ToolDefinition; + 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; diff --git a/packages/coding-agent/src/modes/interactive/components/tool-group.ts b/packages/coding-agent/src/modes/interactive/components/tool-group.ts new file mode 100644 index 000000000..a890df466 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/tool-group.ts @@ -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"; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3068c89cf..8ead85f99 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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); } } diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 7a4a813da..d062f8b25 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -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" },