diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a07deb78..09cd6821 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Breaking Changes + +- Changed `ToolDefinition.renderCall` and `renderResult` semantics. Fallback rendering now happens only when a renderer is not defined for that slot. If `renderCall` or `renderResult` is defined, it must return a `Component`. + +### Changed + +- Built-in tools now work like custom tools in extensions. To get built-in tool definitions, import `readToolDefinition` / `createReadToolDefinition()` and the equivalent `bash`, `edit`, `write`, `grep`, `find`, and `ls` exports from `@mariozechner/pi-coding-agent`. +- Cleaned up `buildSystemPrompt()` so built-in tool snippets and tool-local guidelines come from built-in `ToolDefinition` metadata, while cross-tool and global prompt rules stay in system prompt construction. + ## [0.61.1] - 2026-03-20 ### New Features diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 178a7c7a..8ca32c08 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -976,8 +976,8 @@ pi.registerTool({ }, // Optional: Custom rendering - renderCall(args, theme) { ... }, - renderResult(result, options, theme) { ... }, + renderCall(args, theme, context) { ... }, + renderResult(result, options, theme, context) { ... }, }); ``` @@ -1427,8 +1427,8 @@ pi.registerTool({ }, // Optional: Custom rendering - renderCall(args, theme) { ... }, - renderResult(result, options, theme) { ... }, + renderCall(args, theme, context) { ... }, + renderResult(result, options, theme, context) { ... }, }); ``` @@ -1463,7 +1463,9 @@ pi --no-tools -e ./my-extension.ts See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control. -**Rendering:** If your override doesn't provide custom `renderCall`/`renderResult` functions, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI. +**Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI. + +**Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly. **Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking. @@ -1597,44 +1599,52 @@ export default function (pi: ExtensionAPI) { ### Custom Rendering -Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how built-in tools render. +Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how tool rows are composed. -Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`). +Tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, `tool-execution.ts` uses fallback rendering for that slot. + +`renderCall` and `renderResult` each receive a `context` object with: +- `args` - the current tool call arguments +- `state` - shared row-local state across `renderCall` and `renderResult` +- `lastComponent` - the previously returned component for that slot, if any +- `invalidate()` - request a rerender of this tool row +- `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError` + +Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders. #### renderCall -Renders the tool call (before/during execution): +Renders the tool call or header: ```typescript import { Text } from "@mariozechner/pi-tui"; -renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("my_tool ")); - text += theme.fg("muted", args.action); +renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + let content = theme.fg("toolTitle", theme.bold("my_tool ")); + content += theme.fg("muted", args.action); if (args.text) { - text += " " + theme.fg("dim", `"${args.text}"`); + content += " " + theme.fg("dim", `"${args.text}"`); } - return new Text(text, 0, 0); // 0,0 padding - Box handles it + text.setText(content); + return text; } ``` #### renderResult -Renders the tool result: +Renders the tool result or output: ```typescript -renderResult(result, { expanded, isPartial }, theme) { - // Handle streaming +renderResult(result, { expanded, isPartial }, theme, context) { if (isPartial) { return new Text(theme.fg("warning", "Processing..."), 0, 0); } - // Handle errors if (result.details?.error) { return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); } - // Normal result - support expanded view (Ctrl+O) let text = theme.fg("success", "✓ Done"); if (expanded && result.details?.items) { for (const item of result.details.items) { @@ -1645,6 +1655,8 @@ renderResult(result, { expanded, isPartial }, theme) { } ``` +If a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`. + #### Keybinding Hints Use `keyHint()` to display keybinding hints that respect the active keybinding configuration: @@ -1652,7 +1664,7 @@ Use `keyHint()` to display keybinding hints that respect the active keybinding c ```typescript import { keyHint } from "@mariozechner/pi-coding-agent"; -renderResult(result, { expanded }, theme) { +renderResult(result, { expanded }, theme, context) { let text = theme.fg("success", "✓ Done"); if (!expanded) { text += ` (${keyHint("app.tools.expand", "to expand")})`; @@ -1676,16 +1688,19 @@ Custom editors and `ctx.ui.custom()` components receive `keybindings: Keybinding #### Best Practices -- Use `Text` with padding `(0, 0)` - the Box handles padding -- Use `\n` for multi-line content -- Handle `isPartial` for streaming progress -- Support `expanded` for detail on demand -- Keep default view compact +- Use `Text` with padding `(0, 0)`. The Box handles padding. +- Use `\n` for multi-line content. +- Handle `isPartial` for streaming progress. +- Support `expanded` for detail on demand. +- Keep default view compact. +- Read `context.args` in `renderResult` instead of copying args into `context.state`. +- Use `context.state` only for data that must be shared across call and result slots. +- Reuse `context.lastComponent` when the same component instance can be updated in place. #### Fallback -If `renderCall`/`renderResult` is not defined or throws: -- `renderCall`: Shows tool name +If a slot renderer is not defined or throws: +- `renderCall`: Shows the tool name - `renderResult`: Shows raw text from `content` ## Custom UI diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 6c086e41..ccd0dd7c 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -394,7 +394,7 @@ Components accept theme objects for styling. **In `renderCall`/`renderResult`**, use the `theme` parameter: ```typescript -renderResult(result, options, theme) { +renderResult(result, options, theme, context) { // Use theme.fg() for foreground colors return new Text(theme.fg("success", "Done!"), 0, 0); @@ -428,7 +428,7 @@ renderResult(result, options, theme) { import { getMarkdownTheme } from "@mariozechner/pi-coding-agent"; import { Markdown } from "@mariozechner/pi-tui"; -renderResult(result, options, theme) { +renderResult(result, options, theme, context) { const mdTheme = getMarkdownTheme(); return new Markdown(result.details.markdown, 0, 0, mdTheme); } diff --git a/packages/coding-agent/examples/extensions/built-in-tool-renderer.ts b/packages/coding-agent/examples/extensions/built-in-tool-renderer.ts index 514cd001..bb358084 100644 --- a/packages/coding-agent/examples/extensions/built-in-tool-renderer.ts +++ b/packages/coding-agent/examples/extensions/built-in-tool-renderer.ts @@ -42,7 +42,7 @@ export default function (pi: ExtensionAPI) { return originalRead.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("read ")); text += theme.fg("accent", args.path); if (args.offset || args.limit) { @@ -54,7 +54,7 @@ export default function (pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, { expanded, isPartial }, theme) { + renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) return new Text(theme.fg("warning", "Reading..."), 0, 0); const details = result.details as ReadToolDetails | undefined; @@ -101,7 +101,7 @@ export default function (pi: ExtensionAPI) { return originalBash.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("$ ")); const cmd = args.command.length > 80 ? `${args.command.slice(0, 77)}...` : args.command; text += theme.fg("accent", cmd); @@ -111,7 +111,7 @@ export default function (pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, { expanded, isPartial }, theme) { + renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) return new Text(theme.fg("warning", "Running..."), 0, 0); const details = result.details as BashToolDetails | undefined; @@ -160,13 +160,13 @@ export default function (pi: ExtensionAPI) { return originalEdit.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("edit ")); text += theme.fg("accent", args.path); return new Text(text, 0, 0); }, - renderResult(result, { expanded, isPartial }, theme) { + renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) return new Text(theme.fg("warning", "Editing..."), 0, 0); const details = result.details as EditToolDetails | undefined; @@ -224,7 +224,7 @@ export default function (pi: ExtensionAPI) { return originalWrite.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("write ")); text += theme.fg("accent", args.path); const lineCount = args.content.split("\n").length; @@ -232,7 +232,7 @@ export default function (pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, { isPartial }, theme) { + renderResult(result, { isPartial }, theme, _context) { if (isPartial) return new Text(theme.fg("warning", "Writing..."), 0, 0); const content = result.content[0]; diff --git a/packages/coding-agent/examples/extensions/minimal-mode.ts b/packages/coding-agent/examples/extensions/minimal-mode.ts index 529afe41..591e2a35 100644 --- a/packages/coding-agent/examples/extensions/minimal-mode.ts +++ b/packages/coding-agent/examples/extensions/minimal-mode.ts @@ -80,7 +80,7 @@ export default function (pi: ExtensionAPI) { return tools.read.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const path = shortenPath(args.path || ""); let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); @@ -94,7 +94,7 @@ export default function (pi: ExtensionAPI) { return new Text(`${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { // Minimal mode: show nothing in collapsed state if (!expanded) { return new Text("", 0, 0); @@ -127,7 +127,7 @@ export default function (pi: ExtensionAPI) { return tools.bash.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const command = args.command || "..."; const timeout = args.timeout as number | undefined; const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; @@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) { return new Text(theme.fg("toolTitle", theme.bold(`$ ${command}`)) + timeoutSuffix, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { // Minimal mode: show nothing in collapsed state if (!expanded) { return new Text("", 0, 0); @@ -176,7 +176,7 @@ export default function (pi: ExtensionAPI) { return tools.write.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const path = shortenPath(args.path || ""); const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); const lineCount = args.content ? args.content.split("\n").length : 0; @@ -185,7 +185,7 @@ export default function (pi: ExtensionAPI) { return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}${lineInfo}`, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { // Minimal mode: show nothing (file was written) if (!expanded) { return new Text("", 0, 0); @@ -218,14 +218,14 @@ export default function (pi: ExtensionAPI) { return tools.edit.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const path = shortenPath(args.path || ""); const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); return new Text(`${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { // Minimal mode: show nothing in collapsed state if (!expanded) { return new Text("", 0, 0); @@ -263,7 +263,7 @@ export default function (pi: ExtensionAPI) { return tools.find.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const pattern = args.pattern || ""; const path = shortenPath(args.path || "."); const limit = args.limit; @@ -277,7 +277,7 @@ export default function (pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { if (!expanded) { // Minimal: just show count const textContent = result.content.find((c) => c.type === "text"); @@ -321,7 +321,7 @@ export default function (pi: ExtensionAPI) { return tools.grep.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const pattern = args.pattern || ""; const path = shortenPath(args.path || "."); const glob = args.glob; @@ -339,7 +339,7 @@ export default function (pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { if (!expanded) { // Minimal: just show match count const textContent = result.content.find((c) => c.type === "text"); @@ -383,7 +383,7 @@ export default function (pi: ExtensionAPI) { return tools.ls.execute(toolCallId, params, signal, onUpdate); }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const path = shortenPath(args.path || "."); const limit = args.limit; @@ -395,7 +395,7 @@ export default function (pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { if (!expanded) { // Minimal: just show entry count const textContent = result.content.find((c) => c.type === "text"); diff --git a/packages/coding-agent/examples/extensions/question.ts b/packages/coding-agent/examples/extensions/question.ts index 73a52b9e..f812d33d 100644 --- a/packages/coding-agent/examples/extensions/question.ts +++ b/packages/coding-agent/examples/extensions/question.ts @@ -227,7 +227,7 @@ export default function question(pi: ExtensionAPI) { }; }, - renderCall(args, theme) { + renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question); const opts = Array.isArray(args.options) ? args.options : []; if (opts.length) { @@ -238,7 +238,7 @@ export default function question(pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, _options, theme) { + renderResult(result, _options, theme, _context) { const details = result.details as QuestionDetails | undefined; if (!details) { const text = result.content[0]; diff --git a/packages/coding-agent/examples/extensions/questionnaire.ts b/packages/coding-agent/examples/extensions/questionnaire.ts index c73fa76a..746f5a89 100644 --- a/packages/coding-agent/examples/extensions/questionnaire.ts +++ b/packages/coding-agent/examples/extensions/questionnaire.ts @@ -393,7 +393,7 @@ export default function questionnaire(pi: ExtensionAPI) { }; }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const qs = (args.questions as Question[]) || []; const count = qs.length; const labels = qs.map((q) => q.label || q.id).join(", "); @@ -405,7 +405,7 @@ export default function questionnaire(pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, _options, theme) { + renderResult(result, _options, theme, _context) { const details = result.details as QuestionnaireResult | undefined; if (!details) { const text = result.content[0]; diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index d7d7c43d..7e5276d6 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -668,7 +668,7 @@ export default function (pi: ExtensionAPI) { }; }, - renderCall(args, theme) { + renderCall(args, theme, _context) { const scope: AgentScope = args.agentScope ?? "user"; if (args.chain && args.chain.length > 0) { let text = @@ -712,7 +712,7 @@ export default function (pi: ExtensionAPI) { return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { const details = result.details as SubagentDetails | undefined; if (!details || details.results.length === 0) { const text = result.content[0]; diff --git a/packages/coding-agent/examples/extensions/todo.ts b/packages/coding-agent/examples/extensions/todo.ts index 0f41abb8..0d739633 100644 --- a/packages/coding-agent/examples/extensions/todo.ts +++ b/packages/coding-agent/examples/extensions/todo.ts @@ -220,14 +220,14 @@ export default function (pi: ExtensionAPI) { } }, - renderCall(args, theme) { + renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action); if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`; if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, _context) { const details = result.details as TodoDetails | undefined; if (!details) { const text = result.content[0]; diff --git a/packages/coding-agent/examples/extensions/truncated-tool.ts b/packages/coding-agent/examples/extensions/truncated-tool.ts index 3a79a550..4d11c6bf 100644 --- a/packages/coding-agent/examples/extensions/truncated-tool.ts +++ b/packages/coding-agent/examples/extensions/truncated-tool.ts @@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) { }, // Custom rendering of the tool call (shown before/during execution) - renderCall(args, theme) { + renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("rg ")); text += theme.fg("accent", `"${args.pattern}"`); if (args.path) { @@ -148,7 +148,7 @@ export default function (pi: ExtensionAPI) { }, // Custom rendering of the tool result - renderResult(result, { expanded, isPartial }, theme) { + renderResult(result, { expanded, isPartial }, theme, _context) { const details = result.details as RgDetails | undefined; // Handle streaming/partial results diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4f44c22a..f3f96a69 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -78,7 +78,8 @@ import type { SettingsManager } from "./settings-manager.js"; import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js"; import { buildSystemPrompt } from "./system-prompt.js"; import type { BashOperations } from "./tools/bash.js"; -import { createAllTools } from "./tools/index.js"; +import { createAllToolDefinitions } from "./tools/index.js"; +import { createToolDefinitionFromAgentTool, wrapToolDefinition } from "./tools/tool-definition-wrapper.js"; // ============================================================================ // Skill Block Parsing @@ -143,7 +144,12 @@ export interface AgentSessionConfig { modelRegistry: ModelRegistry; /** Initial active built-in tool names. Default: [read, bash, edit, write] */ initialActiveToolNames?: string[]; - /** Override base tools (useful for custom runtimes). */ + /** + * Override base tools (useful for custom runtimes). + * + * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep + * a definition-first registry even when callers provide plain AgentTool instances. + */ baseToolsOverride?: Record; /** Mutable ref used by Agent to access the current ExtensionRunner */ extensionRunnerRef?: { current?: ExtensionRunner }; @@ -252,7 +258,7 @@ export class AgentSession { private _resourceLoader: ResourceLoader; private _customTools: ToolDefinition[]; - private _baseToolRegistry: Map = new Map(); + private _baseToolDefinitions: Map = new Map(); private _cwd: string; private _extensionRunnerRef?: { current?: ExtensionRunner }; private _initialActiveToolNames?: string[]; @@ -268,6 +274,7 @@ export class AgentSession { // Tool registry for extension getTools/setTools private _toolRegistry: Map = new Map(); + private _toolDefinitions: Map = new Map(); private _toolPromptSnippets: Map = new Map(); private _toolPromptGuidelines: Map = new Map(); @@ -706,13 +713,17 @@ export class AgentSession { * Get all configured tools with name, description, and parameter schema. */ getAllTools(): ToolInfo[] { - return Array.from(this._toolRegistry.values()).map((t) => ({ + return Array.from(this._toolDefinitions.values()).map((t) => ({ name: t.name, description: t.description, parameters: t.parameters, })); } + getToolDefinition(name: string): ToolDefinition | undefined { + return this._toolDefinitions.get(name); + } + /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. @@ -2202,19 +2213,24 @@ export class AgentSession { ...registeredTools, ...this._customTools.map((def) => ({ definition: def, extensionPath: "" })), ]; + const definitionRegistry = new Map(this._baseToolDefinitions); + for (const { definition } of allCustomTools) { + definitionRegistry.set(definition.name, definition); + } + this._toolDefinitions = definitionRegistry; this._toolPromptSnippets = new Map( - allCustomTools - .map((registeredTool) => { - const snippet = this._normalizePromptSnippet(registeredTool.definition.promptSnippet); - return snippet ? ([registeredTool.definition.name, snippet] as const) : undefined; + Array.from(definitionRegistry.values()) + .map((definition) => { + const snippet = this._normalizePromptSnippet(definition.promptSnippet); + return snippet ? ([definition.name, snippet] as const) : undefined; }) .filter((entry): entry is readonly [string, string] => entry !== undefined), ); this._toolPromptGuidelines = new Map( - allCustomTools - .map((registeredTool) => { - const guidelines = this._normalizePromptGuidelines(registeredTool.definition.promptGuidelines); - return guidelines.length > 0 ? ([registeredTool.definition.name, guidelines] as const) : undefined; + Array.from(definitionRegistry.values()) + .map((definition) => { + const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines); + return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined; }) .filter((entry): entry is readonly [string, string[]] => entry !== undefined), ); @@ -2222,7 +2238,12 @@ export class AgentSession { ? wrapRegisteredTools(allCustomTools, this._extensionRunner) : []; - const toolRegistry = new Map(this._baseToolRegistry); + const toolRegistry = new Map( + Array.from(this._baseToolDefinitions.values()).map((definition) => [ + definition.name, + wrapToolDefinition(definition), + ]), + ); for (const tool of wrappedExtensionTools as AgentTool[]) { toolRegistry.set(tool.name, tool); } @@ -2254,14 +2275,21 @@ export class AgentSession { }): void { const autoResizeImages = this.settingsManager.getImageAutoResize(); const shellCommandPrefix = this.settingsManager.getShellCommandPrefix(); - const baseTools = this._baseToolsOverride - ? this._baseToolsOverride - : createAllTools(this._cwd, { + const baseToolDefinitions = this._baseToolsOverride + ? Object.fromEntries( + Object.entries(this._baseToolsOverride).map(([name, tool]) => [ + name, + createToolDefinitionFromAgentTool(tool), + ]), + ) + : createAllToolDefinitions(this._cwd, { read: { autoResizeImages }, bash: { commandPrefix: shellCommandPrefix }, }); - this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool])); + this._baseToolDefinitions = new Map( + Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]), + ); const extensionsResult = this._resourceLoader.getExtensions(); if (options.flagValues) { @@ -3052,13 +3080,10 @@ export class AgentSession { const themeName = this.settingsManager.getTheme(); // Create tool renderer if we have an extension runner (for custom tool HTML rendering) - let toolRenderer: ToolHtmlRenderer | undefined; - if (this._extensionRunner) { - toolRenderer = createToolHtmlRenderer({ - getToolDefinition: (name) => this._extensionRunner!.getToolDefinition(name), - theme, - }); - } + const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ + getToolDefinition: (name) => this.getToolDefinition(name), + theme, + }); return await exportSessionToHtml(this.sessionManager, this.state, { outputPath, diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index a4cb5fe6..7ead340d 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -13,9 +13,10 @@ import { SessionManager } from "../session-manager.js"; */ export interface ToolHtmlRenderer { /** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */ - renderCall(toolName: string, args: unknown): string | undefined; + renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined; /** Render a tool result to HTML. Returns collapsed/expanded or undefined if tool has no custom renderer. */ renderResult( + toolCallId: string, toolName: string, result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>, details: unknown, @@ -191,7 +192,7 @@ function preRenderCustomTools( if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "toolCall" && !BUILTIN_TOOLS.has(block.name)) { - const callHtml = toolRenderer.renderCall(block.name, block.arguments); + const callHtml = toolRenderer.renderCall(block.id, block.name, block.arguments); if (callHtml) { renderedTools[block.id] = { callHtml }; } @@ -205,7 +206,13 @@ function preRenderCustomTools( // Only render if we have a pre-rendered call OR it's not a built-in tool const existing = renderedTools[msg.toolCallId]; if (existing || !BUILTIN_TOOLS.has(toolName)) { - const rendered = toolRenderer.renderResult(toolName, msg.content, msg.details, msg.isError || false); + const rendered = toolRenderer.renderResult( + msg.toolCallId, + toolName, + msg.content, + msg.details, + msg.isError || false, + ); if (rendered) { renderedTools[msg.toolCallId] = { ...existing, diff --git a/packages/coding-agent/src/core/export-html/tool-renderer.ts b/packages/coding-agent/src/core/export-html/tool-renderer.ts index b1f241ff..9b4c4d0f 100644 --- a/packages/coding-agent/src/core/export-html/tool-renderer.ts +++ b/packages/coding-agent/src/core/export-html/tool-renderer.ts @@ -6,8 +6,9 @@ */ import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; +import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; -import type { ToolDefinition } from "../extensions/types.js"; +import type { ToolDefinition, ToolRenderContext } from "../extensions/types.js"; import { ansiLinesToHtml } from "./ansi-to-html.js"; export interface ToolHtmlRendererDeps { @@ -21,9 +22,10 @@ export interface ToolHtmlRendererDeps { export interface ToolHtmlRenderer { /** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */ - renderCall(toolName: string, args: unknown): string | undefined; + renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined; /** Render a tool result to collapsed/expanded HTML. Returns undefined if tool has no custom renderer. */ renderResult( + toolCallId: string, toolName: string, result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>, details: unknown, @@ -40,27 +42,68 @@ export interface ToolHtmlRenderer { export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer { const { getToolDefinition, theme, width = 100 } = deps; + const renderedCallComponents = new Map(); + const renderedResultComponents = new Map(); + const renderedStates = new Map(); + const renderedArgs = new Map(); + + const getState = (toolCallId: string): any => { + let state = renderedStates.get(toolCallId); + if (!state) { + state = {}; + renderedStates.set(toolCallId, state); + } + return state; + }; + + const createRenderContext = ( + toolCallId: string, + lastComponent: Component | undefined, + expanded: boolean, + isPartial: boolean, + isError: boolean, + ): ToolRenderContext => { + return { + args: renderedArgs.get(toolCallId), + toolCallId, + invalidate: () => {}, + lastComponent, + state: getState(toolCallId), + cwd: process.cwd(), + executionStarted: true, + argsComplete: true, + isPartial, + expanded, + showImages: false, + isError, + }; + }; + return { - renderCall(toolName: string, args: unknown): string | undefined { + renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined { try { + renderedArgs.set(toolCallId, args); const toolDef = getToolDefinition(toolName); if (!toolDef?.renderCall) { return undefined; } - const component = toolDef.renderCall(args, theme); - if (!component) { - return undefined; - } + const component = toolDef.renderCall( + args, + theme, + createRenderContext(toolCallId, renderedCallComponents.get(toolCallId), false, true, false), + ); + renderedCallComponents.set(toolCallId, component); const lines = component.render(width); return ansiLinesToHtml(lines); } catch { - // On error, return undefined to trigger JSON fallback + // On error, return undefined so HTML export can fall back to structured result rendering return undefined; } }, renderResult( + toolCallId: string, toolName: string, result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>, details: unknown, @@ -85,28 +128,27 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend agentToolResult, { expanded: false, isPartial: false }, theme, + createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), false, false, isError), ); - const collapsed = collapsedComponent ? ansiLinesToHtml(collapsedComponent.render(width)) : undefined; + renderedResultComponents.set(toolCallId, collapsedComponent); + const collapsed = ansiLinesToHtml(collapsedComponent.render(width)); // Render expanded const expandedComponent = toolDef.renderResult( agentToolResult, { expanded: true, isPartial: false }, theme, + createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), true, false, isError), ); - const expanded = expandedComponent ? ansiLinesToHtml(expandedComponent.render(width)) : undefined; - - // Return collapsed only if it exists and differs from expanded - if (!expanded) { - return undefined; - } + renderedResultComponents.set(toolCallId, expandedComponent); + const expanded = ansiLinesToHtml(expandedComponent.render(width)); return { ...(collapsed && collapsed !== expanded ? { collapsed } : {}), expanded, }; } catch { - // On error, return undefined to trigger JSON fallback + // On error, return undefined so HTML export can fall back to structured result rendering return undefined; } }, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index b8b1a8b2..65c66e59 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -329,10 +329,38 @@ export interface ToolRenderResultOptions { isPartial: boolean; } +/** Context passed to tool renderers. */ +export interface ToolRenderContext { + /** Current tool call arguments. Shared across call/result renders for the same tool call. */ + args: TArgs; + /** Unique id for this tool execution. Stable across call/result renders for the same tool call. */ + toolCallId: string; + /** Invalidate just this tool execution component for redraw. */ + invalidate: () => void; + /** Previously returned component for this render slot, if any. */ + lastComponent: Component | undefined; + /** Shared renderer state for this tool row. Initialized by tool-execution.ts. */ + state: TState; + /** Working directory for this tool execution. */ + cwd: string; + /** Whether the tool execution has started. */ + executionStarted: boolean; + /** Whether the tool call arguments are complete. */ + argsComplete: boolean; + /** Whether the tool result is partial/streaming. */ + isPartial: boolean; + /** Whether the result view is expanded. */ + expanded: boolean; + /** Whether inline images are currently shown in the TUI. */ + showImages: boolean; + /** Whether the current result is an error. */ + isError: boolean; +} + /** * Tool definition for registerTool(). */ -export interface ToolDefinition { +export interface ToolDefinition { /** Tool name (used in LLM tool calls) */ name: string; /** Human-readable label for UI */ @@ -356,14 +384,15 @@ export interface ToolDefinition>; /** Custom rendering for tool call display */ - renderCall?: (args: Static, theme: Theme) => Component | undefined; + renderCall?: (args: Static, theme: Theme, context: ToolRenderContext>) => Component; /** Custom rendering for tool result display */ renderResult?: ( result: AgentToolResult, options: ToolRenderResultOptions, theme: Theme, - ) => Component | undefined; + context: ToolRenderContext>, + ) => Component; } // ============================================================================ @@ -997,7 +1026,9 @@ export interface ExtensionAPI { // ========================================================================= /** Register a tool that the LLM can call. */ - registerTool(tool: ToolDefinition): void; + registerTool( + tool: ToolDefinition, + ): void; // ========================================================================= // Command, Shortcut, Flag Registration diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts index bd2ed13d..ca06a94e 100644 --- a/packages/coding-agent/src/core/extensions/wrapper.ts +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -6,6 +6,7 @@ */ import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { wrapToolDefinition } from "../tools/tool-definition-wrapper.js"; import type { ExtensionRunner } from "./runner.js"; import type { RegisteredTool } from "./types.js"; @@ -14,15 +15,7 @@ import type { RegisteredTool } from "./types.js"; * Uses the runner's createContext() for consistent context across tools and event handlers. */ export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool { - const { definition } = registeredTool; - return { - name: definition.name, - label: definition.label, - description: definition.description, - parameters: definition.parameters, - execute: (toolCallId, params, signal, onUpdate) => - definition.execute(toolCallId, params, signal, onUpdate, runner.createContext()), - }; + return wrapToolDefinition(registeredTool.definition, () => runner.createContext()); } /** diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 2cd0bc0b..99cbea82 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -5,17 +5,6 @@ import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js"; import { formatSkillsForPrompt, type Skill } from "./skills.js"; -/** Tool descriptions for system prompt */ -const toolDescriptions: Record = { - read: "Read file contents", - bash: "Execute bash commands (ls, grep, find, etc.)", - edit: "Make surgical edits to files (find exact text and replace)", - write: "Create or overwrite files", - grep: "Search file contents for patterns (respects .gitignore)", - find: "Find files by glob pattern (respects .gitignore)", - ls: "List directory contents", -}; - export interface BuildSystemPromptOptions { /** Custom system prompt (replaces default). */ customPrompt?: string; @@ -92,18 +81,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const examplesPath = getExamplesPath(); // Build tools list based on selected tools. - // Built-ins use toolDescriptions. Custom tools can provide one-line snippets. + // A tool appears in Available tools only when the caller provides a one-line snippet. const tools = selectedTools || ["read", "bash", "edit", "write"]; - const visibleTools = tools.filter((name) => name in toolDescriptions || toolSnippets?.[name]); + const visibleTools = tools.filter((name) => !!toolSnippets?.[name]); const toolsList = - visibleTools.length > 0 - ? visibleTools - .map((name) => { - const snippet = toolSnippets?.[name] ?? toolDescriptions[name] ?? name; - return `- ${name}: ${snippet}`; - }) - .join("\n") - : "(none)"; + visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join("\n") : "(none)"; // Build guidelines based on which tools are actually available const guidelinesList: string[] = []; @@ -117,8 +99,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin }; const hasBash = tools.includes("bash"); - const hasEdit = tools.includes("edit"); - const hasWrite = tools.includes("write"); const hasGrep = tools.includes("grep"); const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); @@ -131,28 +111,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin addGuideline("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)"); } - // Read before edit guideline - if (hasRead && hasEdit) { - addGuideline("Use read to examine files before editing. You must use this tool instead of cat or sed."); - } - - // Edit guideline - if (hasEdit) { - addGuideline("Use edit for precise changes (old text must match exactly)"); - } - - // Write guideline - if (hasWrite) { - addGuideline("Use write only for new files or complete rewrites"); - } - - // Output guideline (only when actually writing or executing) - if (hasEdit || hasWrite) { - addGuideline( - "When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did", - ); - } - for (const guideline of promptGuidelines ?? []) { const normalized = guideline.trim(); if (normalized.length > 0) { diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 6685efca..675bf873 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -3,14 +3,21 @@ import { createWriteStream, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { spawn } from "child_process"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js"; +import { theme } from "../../modes/interactive/theme/theme.js"; import { waitForChildProcess } from "../../utils/child-process.js"; import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { getTextOutput, invalidArgText, str } from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; /** - * Generate a unique temp file path for bash output + * Generate a unique temp file path for bash output. */ function getTempFilePath(): string { const id = randomBytes(8).toString("hex"); @@ -31,14 +38,14 @@ export interface BashToolDetails { /** * Pluggable operations for the bash tool. - * Override these to delegate command execution to remote systems (e.g., SSH). + * Override these to delegate command execution to remote systems (for example SSH). */ export interface BashOperations { /** * Execute a command and stream output. - * @param command - The command to execute - * @param cwd - Working directory - * @param options - Execution options + * @param command The command to execute + * @param cwd Working directory + * @param options Execution options * @returns Promise resolving to exit code (null if killed) */ exec: ( @@ -56,81 +63,58 @@ export interface BashOperations { /** * Create bash operations using pi's built-in local shell execution backend. * - * This is useful for extensions that intercept user_bash and want to keep - * pi's standard local shell behavior while still wrapping or rewriting - * commands before execution. + * This is useful for extensions that intercept user_bash and still want pi's + * standard local shell behavior while wrapping or rewriting commands. */ export function createLocalBashOperations(): BashOperations { return { exec: (command, cwd, { onData, signal, timeout, env }) => { return new Promise((resolve, reject) => { const { shell, args } = getShellConfig(); - if (!existsSync(cwd)) { reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); return; } - const child = spawn(shell, [...args, command], { cwd, detached: true, env: env ?? getShellEnv(), stdio: ["ignore", "pipe", "pipe"], }); - let timedOut = false; - - // Set timeout if provided let timeoutHandle: NodeJS.Timeout | undefined; + // Set timeout if provided. if (timeout !== undefined && timeout > 0) { timeoutHandle = setTimeout(() => { timedOut = true; - if (child.pid) { - killProcessTree(child.pid); - } + if (child.pid) killProcessTree(child.pid); }, timeout * 1000); } - - // Stream stdout and stderr - if (child.stdout) { - child.stdout.on("data", onData); - } - if (child.stderr) { - child.stderr.on("data", onData); - } - - // Handle abort signal - kill entire process tree + // Stream stdout and stderr. + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + // Handle abort signal by killing the entire process tree. const onAbort = () => { - if (child.pid) { - killProcessTree(child.pid); - } + if (child.pid) killProcessTree(child.pid); }; - if (signal) { - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); } - // Handle shell spawn errors and wait for the process to terminate without hanging // on inherited stdio handles held by detached descendants. waitForChildProcess(child) .then((code) => { if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); - if (signal?.aborted) { reject(new Error("aborted")); return; } - if (timedOut) { reject(new Error(`timeout:${timeout}`)); return; } - resolve({ exitCode: code }); }) .catch((err) => { @@ -152,85 +136,182 @@ export interface BashSpawnContext { export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext; function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext { - const baseContext: BashSpawnContext = { - command, - cwd, - env: { ...getShellEnv() }, - }; - + const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } }; return spawnHook ? spawnHook(baseContext) : baseContext; } export interface BashToolOptions { /** Custom operations for command execution. Default: local shell */ operations?: BashOperations; - /** Command prefix prepended to every command (e.g., "shopt -s expand_aliases" for alias support) */ + /** Command prefix prepended to every command (for example shell setup commands) */ commandPrefix?: string; /** Hook to adjust command, cwd, or env before execution */ spawnHook?: BashSpawnHook; } -export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { +const BASH_PREVIEW_LINES = 5; + +type BashRenderState = { + startedAt: number | undefined; + endedAt: number | undefined; + interval: NodeJS.Timeout | undefined; +}; + +type BashResultRenderState = { + cachedWidth: number | undefined; + cachedLines: string[] | undefined; + cachedSkipped: number | undefined; +}; + +class BashResultRenderComponent extends Container { + state: BashResultRenderState = { + cachedWidth: undefined, + cachedLines: undefined, + cachedSkipped: undefined, + }; +} + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { + const command = str(args?.command); + const timeout = args?.timeout as number | undefined; + const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; + const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); + return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; +} + +function rebuildBashResultRenderComponent( + component: BashResultRenderComponent, + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: BashToolDetails; + }, + options: ToolRenderResultOptions, + showImages: boolean, + startedAt: number | undefined, + endedAt: number | undefined, +): void { + const state = component.state; + component.clear(); + + const output = getTextOutput(result as any, showImages).trim(); + + if (output) { + const styledOutput = output + .split("\n") + .map((line) => theme.fg("toolOutput", line)) + .join("\n"); + + if (options.expanded) { + component.addChild(new Text(`\n${styledOutput}`, 0, 0)); + } else { + component.addChild({ + render: (width: number) => { + if (state.cachedLines === undefined || state.cachedWidth !== width) { + const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width); + state.cachedLines = preview.visualLines; + state.cachedSkipped = preview.skippedCount; + state.cachedWidth = width; + } + if (state.cachedSkipped && state.cachedSkipped > 0) { + const hint = + theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + + ` ${keyHint("app.tools.expand", "to expand")})`; + return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; + } + return ["", ...(state.cachedLines ?? [])]; + }, + invalidate: () => { + state.cachedWidth = undefined; + state.cachedLines = undefined; + state.cachedSkipped = undefined; + }, + }); + } + } + + const truncation = result.details?.truncation; + const fullOutputPath = result.details?.fullOutputPath; + if (truncation?.truncated || fullOutputPath) { + const warnings: string[] = []; + if (fullOutputPath) { + warnings.push(`Full output: ${fullOutputPath}`); + } + if (truncation?.truncated) { + if (truncation.truncatedBy === "lines") { + warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); + } else { + warnings.push( + `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, + ); + } + } + component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0)); + } + + if (startedAt !== undefined) { + const label = options.isPartial ? "Elapsed" : "Took"; + const endTime = endedAt ?? Date.now(); + component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0)); + } +} + +export function createBashToolDefinition( + cwd: string, + options?: BashToolOptions, +): ToolDefinition { const ops = options?.operations ?? createLocalBashOperations(); const commandPrefix = options?.commandPrefix; const spawnHook = options?.spawnHook; - return { name: "bash", label: "bash", description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + promptSnippet: "Execute bash commands (ls, grep, find, etc.)", parameters: bashSchema, - execute: async ( - _toolCallId: string, + async execute( + _toolCallId, { command, timeout }: { command: string; timeout?: number }, signal?: AbortSignal, onUpdate?, - ) => { - // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) + _ctx?, + ) { const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook); - + if (onUpdate) { + onUpdate({ content: [], details: undefined }); + } return new Promise((resolve, reject) => { - // We'll stream to a temp file if output gets large let tempFilePath: string | undefined; let tempFileStream: ReturnType | undefined; let totalBytes = 0; - - // Keep a rolling buffer of the last chunk for tail truncation const chunks: Buffer[] = []; let chunksBytes = 0; - // Keep more than we need so we have enough for truncation const maxChunksBytes = DEFAULT_MAX_BYTES * 2; const handleData = (data: Buffer) => { totalBytes += data.length; - - // Start writing to temp file once we exceed the threshold + // Start writing to a temp file once output exceeds the in-memory threshold. if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) { tempFilePath = getTempFilePath(); tempFileStream = createWriteStream(tempFilePath); - // Write all buffered chunks to the file - for (const chunk of chunks) { - tempFileStream.write(chunk); - } + // Write all buffered chunks to the file. + for (const chunk of chunks) tempFileStream.write(chunk); } - - // Write to temp file if we have one - if (tempFileStream) { - tempFileStream.write(data); - } - - // Keep rolling buffer of recent data + // Write to temp file if we have one. + if (tempFileStream) tempFileStream.write(data); + // Keep a rolling buffer of recent output for tail truncation. chunks.push(data); chunksBytes += data.length; - - // Trim old chunks if buffer is too large + // Trim old chunks if the rolling buffer grows too large. while (chunksBytes > maxChunksBytes && chunks.length > 1) { const removed = chunks.shift()!; chunksBytes -= removed.length; } - - // Stream partial output to callback (truncated rolling buffer) + // Stream partial output using the rolling tail buffer. if (onUpdate) { const fullBuffer = Buffer.concat(chunks); const fullText = fullBuffer.toString("utf-8"); @@ -252,34 +333,22 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo env: spawnContext.env, }) .then(({ exitCode }) => { - // Close temp file stream - if (tempFileStream) { - tempFileStream.end(); - } - - // Combine all buffered chunks + // Close temp file stream before building the final result. + if (tempFileStream) tempFileStream.end(); + // Combine the rolling buffer chunks. const fullBuffer = Buffer.concat(chunks); const fullOutput = fullBuffer.toString("utf-8"); - - // Apply tail truncation + // Apply tail truncation for the final display payload. const truncation = truncateTail(fullOutput); let outputText = truncation.content || "(no output)"; - - // Build details with truncation info let details: BashToolDetails | undefined; - if (truncation.truncated) { - details = { - truncation, - fullOutputPath: tempFilePath, - }; - - // Build actionable notice + // Build truncation details and an actionable notice. + details = { truncation, fullOutputPath: tempFilePath }; const startLine = truncation.totalLines - truncation.outputLines + 1; const endLine = truncation.totalLines; - if (truncation.lastLinePartial) { - // Edge case: last line alone > 30KB + // Edge case: the last line alone is larger than the byte limit. const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8")); outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`; } else if (truncation.truncatedBy === "lines") { @@ -288,7 +357,6 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`; } } - if (exitCode !== 0 && exitCode !== null) { outputText += `\n\nCommand exited with code ${exitCode}`; reject(new Error(outputText)); @@ -297,15 +365,10 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo } }) .catch((err: Error) => { - // Close temp file stream - if (tempFileStream) { - tempFileStream.end(); - } - - // Combine all buffered chunks for error output + // Close temp file stream and include buffered output in the error message. + if (tempFileStream) tempFileStream.end(); const fullBuffer = Buffer.concat(chunks); let output = fullBuffer.toString("utf-8"); - if (err.message === "aborted") { if (output) output += "\n\n"; output += "Command aborted"; @@ -321,8 +384,48 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo }); }); }, + renderCall(args, _theme, context) { + const state = context.state; + if (context.executionStarted && state.startedAt === undefined) { + state.startedAt = Date.now(); + state.endedAt = undefined; + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatBashCall(args)); + return text; + }, + renderResult(result, options, _theme, context) { + const state = context.state; + if (state.startedAt !== undefined && options.isPartial && !state.interval) { + state.interval = setInterval(() => context.invalidate(), 1000); + } + if (!options.isPartial || context.isError) { + state.endedAt ??= Date.now(); + if (state.interval) { + clearInterval(state.interval); + state.interval = undefined; + } + } + const component = + (context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent(); + rebuildBashResultRenderComponent( + component, + result as any, + options, + context.showImages, + state.startedAt, + state.endedAt, + ); + component.invalidate(); + return component; + }, }; } -/** Default bash tool using process.cwd() - for backwards compatibility */ +export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { + return wrapToolDefinition(createBashToolDefinition(cwd, options)); +} + +/** Default bash tool using process.cwd() for backwards compatibility. */ +export const bashToolDefinition = createBashToolDefinition(process.cwd()); export const bashTool = createBashTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index badca2db..0ee47a08 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -1,9 +1,15 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Container, Text } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises"; +import { renderDiff } from "../../modes/interactive/components/diff.js"; +import type { ToolDefinition } from "../extensions/types.js"; import { + computeEditDiff, detectLineEnding, + type EditDiffError, + type EditDiffResult, fuzzyFindText, generateDiffString, normalizeForFuzzyMatch, @@ -13,6 +19,13 @@ import { } from "./edit-diff.js"; import { withFileMutationQueue } from "./file-mutation-queue.js"; import { resolveToCwd } from "./path-utils.js"; +import { invalidArgText, shortenPath, str } from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; + +type EditRenderState = { + argsKey?: string; + preview?: EditDiffResult | EditDiffError; +}; const editSchema = Type.Object({ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), @@ -31,7 +44,7 @@ export interface EditToolDetails { /** * Pluggable operations for the edit tool. - * Override these to delegate file editing to remote systems (e.g., SSH). + * Override these to delegate file editing to remote systems (for example SSH). */ export interface EditOperations { /** Read file contents as a Buffer */ @@ -53,20 +66,75 @@ export interface EditToolOptions { operations?: EditOperations; } -export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool { - const ops = options?.operations ?? defaultEditOperations; +function formatEditCall( + args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined, + state: EditRenderState, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const invalidArg = invalidArgText(theme); + const rawPath = str(args?.file_path ?? args?.path); + const path = rawPath !== null ? shortenPath(rawPath) : null; + const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; + if (state.preview) { + if ("error" in state.preview) { + text += `\n\n${theme.fg("error", state.preview.error)}`; + } else if (state.preview.diff) { + text += `\n\n${renderDiff(state.preview.diff, { filePath: rawPath ?? undefined })}`; + } + } + + return text; +} + +function formatEditResult( + args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined, + state: EditRenderState, + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: EditToolDetails; + }, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + isError: boolean, +): string | undefined { + const rawPath = str(args?.file_path ?? args?.path); + if (isError) { + const errorText = result.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join("\n"); + return errorText ? `\n${theme.fg("error", errorText)}` : undefined; + } + + const previewDiff = state.preview && !("error" in state.preview) ? state.preview.diff : undefined; + const resultDiff = result.details?.diff; + if (!resultDiff || resultDiff === previewDiff) { + return undefined; + } + return `\n${renderDiff(resultDiff, { filePath: rawPath ?? undefined })}`; +} + +export function createEditToolDefinition( + cwd: string, + options?: EditToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultEditOperations; return { name: "edit", label: "edit", description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.", + promptSnippet: "Make surgical edits to files (find exact text and replace)", + promptGuidelines: ["Use edit for precise changes (old text must match exactly)."], parameters: editSchema, - execute: async ( - _toolCallId: string, + async execute( + _toolCallId, { path, oldText, newText }: { path: string; oldText: string; newText: string }, signal?: AbortSignal, - ) => { + _onUpdate?, + _ctx?, + ) { const absolutePath = resolveToCwd(path, cwd); return withFileMutationQueue( @@ -76,7 +144,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo content: Array<{ type: "text"; text: string }>; details: EditToolDetails | undefined; }>((resolve, reject) => { - // Check if already aborted + // Check if already aborted. if (signal?.aborted) { reject(new Error("Operation aborted")); return; @@ -84,7 +152,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo let aborted = false; - // Set up abort handler + // Set up abort handler. const onAbort = () => { aborted = true; reject(new Error("Operation aborted")); @@ -94,10 +162,10 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo signal.addEventListener("abort", onAbort, { once: true }); } - // Perform the edit operation + // Perform the edit operation. (async () => { try { - // Check if file exists + // Check if file exists. try { await ops.access(absolutePath); } catch { @@ -108,21 +176,21 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo return; } - // Check if aborted before reading + // Check if aborted before reading. if (aborted) { return; } - // Read the file + // Read the file. const buffer = await ops.readFile(absolutePath); const rawContent = buffer.toString("utf-8"); - // Check if aborted after reading + // Check if aborted after reading. if (aborted) { return; } - // Strip BOM before matching (LLM won't include invisible BOM in oldText) + // Strip BOM before matching. The model will not include an invisible BOM in oldText. const { bom, text: content } = stripBom(rawContent); const originalEnding = detectLineEnding(content); @@ -130,7 +198,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo const normalizedOldText = normalizeToLF(oldText); const normalizedNewText = normalizeToLF(newText); - // Find the old text using fuzzy matching (tries exact match first, then fuzzy) + // Find the old text using fuzzy matching. This tries exact match first, then a normalized fallback. const matchResult = fuzzyFindText(normalizedContent, normalizedOldText); if (!matchResult.found) { @@ -145,7 +213,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo return; } - // Count occurrences using fuzzy-normalized content for consistency + // Count occurrences using fuzzy-normalized content for consistency with the matcher. const fuzzyContent = normalizeForFuzzyMatch(normalizedContent); const fuzzyOldText = normalizeForFuzzyMatch(normalizedOldText); const occurrences = fuzzyContent.split(fuzzyOldText).length - 1; @@ -162,20 +230,20 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo return; } - // Check if aborted before writing + // Check if aborted before writing. if (aborted) { return; } - // Perform replacement using the matched text position - // When fuzzy matching was used, contentForReplacement is the normalized version + // Perform replacement using the matched text position. + // When fuzzy matching was used, contentForReplacement is the normalized version. const baseContent = matchResult.contentForReplacement; const newContent = baseContent.substring(0, matchResult.index) + normalizedNewText + baseContent.substring(matchResult.index + matchResult.matchLength); - // Verify the replacement actually changed something + // Verify the replacement actually changed something. if (baseContent === newContent) { if (signal) { signal.removeEventListener("abort", onAbort); @@ -191,12 +259,12 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo const finalContent = bom + restoreLineEndings(newContent, originalEnding); await ops.writeFile(absolutePath, finalContent); - // Check if aborted after writing + // Check if aborted after writing. if (aborted) { return; } - // Clean up abort handler + // Clean up abort handler. if (signal) { signal.removeEventListener("abort", onAbort); } @@ -212,7 +280,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }, }); } catch (error: any) { - // Clean up abort handler + // Clean up abort handler. if (signal) { signal.removeEventListener("abort", onAbort); } @@ -225,8 +293,43 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo }), ); }, + renderCall(args, theme, context) { + const isSingleMode = + typeof args?.path === "string" && typeof args?.oldText === "string" && typeof args?.newText === "string"; + if (context.argsComplete && isSingleMode) { + const argsKey = JSON.stringify({ path: args.path, oldText: args.oldText, newText: args.newText }); + if (context.state.argsKey !== argsKey) { + context.state.argsKey = argsKey; + computeEditDiff(args.path!, args.oldText!, args.newText!, context.cwd).then((preview) => { + if (context.state.argsKey === argsKey) { + context.state.preview = preview; + context.invalidate(); + } + }); + } + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatEditCall(args, context.state, theme)); + return text; + }, + renderResult(result, _options, theme, context) { + const output = formatEditResult(context.args, context.state, result as any, theme, context.isError); + if (!output) { + const component = (context.lastComponent as Container | undefined) ?? new Container(); + component.clear(); + return component; + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(output); + return text; + }, }; } -/** Default edit tool using process.cwd() - for backwards compatibility */ +export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool { + return wrapToolDefinition(createEditToolDefinition(cwd, options)); +} + +/** Default edit tool using process.cwd() for backwards compatibility. */ +export const editToolDefinition = createEditToolDefinition(process.cwd()); export const editTool = createEditTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index f70b2f99..dd71434f 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -1,11 +1,16 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Text } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { spawnSync } from "child_process"; import { existsSync } from "fs"; import { globSync } from "glob"; import path from "path"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import { ensureTool } from "../../utils/tools-manager.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; import { resolveToCwd } from "./path-utils.js"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; function toPosixPath(value: string): string { @@ -31,41 +36,97 @@ export interface FindToolDetails { /** * Pluggable operations for the find tool. - * Override these to delegate file search to remote systems (e.g., SSH). + * Override these to delegate file search to remote systems (for example SSH). */ export interface FindOperations { /** Check if path exists */ exists: (absolutePath: string) => Promise | boolean; - /** Find files matching glob pattern. Returns relative paths. */ + /** Find files matching glob pattern. Returns relative or absolute paths. */ glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise | string[]; } const defaultFindOperations: FindOperations = { exists: existsSync, - glob: (_pattern, _searchCwd, _options) => { - // This is a placeholder - actual fd execution happens in execute - return []; - }, + // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. + glob: () => [], }; export interface FindToolOptions { - /** Custom operations for find. Default: local filesystem + fd */ + /** Custom operations for find. Default: local filesystem plus fd */ operations?: FindOperations; } -export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool { - const customOps = options?.operations; +function formatFindCall( + args: { pattern: string; path?: string; limit?: number } | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const pattern = str(args?.pattern); + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("find")) + + " " + + (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); + if (limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${limit})`); + } + return text; +} +function formatFindResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: FindToolDetails; + }, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + const resultLimit = result.details?.resultLimitReached; + const truncation = result.details?.truncation; + if (resultLimit || truncation?.truncated) { + const warnings: string[] = []; + if (resultLimit) warnings.push(`${resultLimit} results limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createFindToolDefinition( + cwd: string, + options?: FindToolOptions, +): ToolDefinition { + const customOps = options?.operations; return { name: "find", label: "find", 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, - execute: async ( - _toolCallId: string, + async execute( + _toolCallId, { pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number }, signal?: AbortSignal, - ) => { + _onUpdate?, + _ctx?, + ) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Operation aborted")); @@ -81,20 +142,17 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo const effectiveLimit = limit ?? DEFAULT_LIMIT; const ops = customOps ?? defaultFindOperations; - // If custom operations provided with glob, use that + // If custom operations provide glob(), use that instead of fd. if (customOps?.glob) { if (!(await ops.exists(searchPath))) { reject(new Error(`Path not found: ${searchPath}`)); return; } - const results = await ops.glob(pattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit: effectiveLimit, }); - signal?.removeEventListener("abort", onAbort); - if (results.length === 0) { resolve({ content: [{ type: "text", text: "No files found matching pattern" }], @@ -103,36 +161,28 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo return; } - // Relativize paths + // Relativize paths against the search root for stable output. const relativized = results.map((p) => { - if (p.startsWith(searchPath)) { - return toPosixPath(p.slice(searchPath.length + 1)); - } + if (p.startsWith(searchPath)) return toPosixPath(p.slice(searchPath.length + 1)); return toPosixPath(path.relative(searchPath, p)); }); - const resultLimitReached = relativized.length >= effectiveLimit; const rawOutput = relativized.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - let resultOutput = truncation.content; const details: FindToolDetails = {}; const notices: string[] = []; - if (resultLimitReached) { notices.push(`${effectiveLimit} results limit reached`); details.resultLimitReached = effectiveLimit; } - if (truncation.truncated) { notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); details.truncation = truncation; } - if (notices.length > 0) { resultOutput += `\n\n[${notices.join(". ")}]`; } - resolve({ content: [{ type: "text", text: resultOutput }], details: Object.keys(details).length > 0 ? details : undefined, @@ -140,14 +190,14 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo return; } - // Default: use fd + // Default implementation uses fd. const fdPath = await ensureTool("fd", true); if (!fdPath) { reject(new Error("fd is not available and could not be downloaded")); return; } - // Build fd arguments + // Build fd arguments. const args: string[] = [ "--glob", "--color=never", @@ -155,14 +205,10 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo "--max-results", String(effectiveLimit), ]; - - // Include .gitignore files + // Include .gitignore files from the search tree. const gitignoreFiles = new Set(); const rootGitignore = path.join(searchPath, ".gitignore"); - if (existsSync(rootGitignore)) { - gitignoreFiles.add(rootGitignore); - } - + if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore); try { const nestedGitignores = globSync("**/.gitignore", { cwd: searchPath, @@ -170,33 +216,21 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo absolute: true, ignore: ["**/node_modules/**", "**/.git/**"], }); - for (const file of nestedGitignores) { - gitignoreFiles.add(file); - } + for (const file of nestedGitignores) gitignoreFiles.add(file); } catch { - // Ignore glob errors + // ignore } - - for (const gitignorePath of gitignoreFiles) { - args.push("--ignore-file", gitignorePath); - } - + for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath); args.push(pattern, searchPath); - const result = spawnSync(fdPath, args, { - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }); - + const result = spawnSync(fdPath, args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }); signal?.removeEventListener("abort", onAbort); - if (result.error) { reject(new Error(`Failed to run fd: ${result.error.message}`)); return; } const output = result.stdout?.trim() || ""; - if (result.status !== 0) { const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`; if (!output) { @@ -204,7 +238,6 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo return; } } - if (!output) { resolve({ content: [{ type: "text", text: "No files found matching pattern" }], @@ -215,11 +248,9 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo const lines = output.split("\n"); const relativized: string[] = []; - for (const rawLine of lines) { const line = rawLine.replace(/\r$/, "").trim(); if (!line) continue; - const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\"); let relativePath = line; if (line.startsWith(searchPath)) { @@ -227,38 +258,29 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo } else { relativePath = path.relative(searchPath, line); } - - if (hadTrailingSlash && !relativePath.endsWith("/")) { - relativePath += "/"; - } - + if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/"; relativized.push(toPosixPath(relativePath)); } const resultLimitReached = relativized.length >= effectiveLimit; const rawOutput = relativized.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - let resultOutput = truncation.content; const details: FindToolDetails = {}; const notices: string[] = []; - if (resultLimitReached) { notices.push( `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, ); details.resultLimitReached = effectiveLimit; } - if (truncation.truncated) { notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); details.truncation = truncation; } - if (notices.length > 0) { resultOutput += `\n\n[${notices.join(". ")}]`; } - resolve({ content: [{ type: "text", text: resultOutput }], details: Object.keys(details).length > 0 ? details : undefined, @@ -270,8 +292,23 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo })(); }); }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatFindCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatFindResult(result as any, options, theme, context.showImages)); + return text; + }, }; } -/** Default find tool using process.cwd() - for backwards compatibility */ +export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool { + return wrapToolDefinition(createFindToolDefinition(cwd, options)); +} + +/** Default find tool using process.cwd() for backwards compatibility. */ +export const findToolDefinition = createFindToolDefinition(process.cwd()); export const findTool = createFindTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 61431912..6802027e 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -1,11 +1,16 @@ import { createInterface } from "node:readline"; import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Text } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { readFileSync, statSync } from "fs"; import path from "path"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import { ensureTool } from "../../utils/tools-manager.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; import { resolveToCwd } from "./path-utils.js"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, formatSize, @@ -30,7 +35,6 @@ const grepSchema = Type.Object({ }); export type GrepToolInput = Static; - const DEFAULT_LIMIT = 100; export interface GrepToolDetails { @@ -41,10 +45,10 @@ export interface GrepToolDetails { /** * Pluggable operations for the grep tool. - * Override these to delegate search to remote systems (e.g., SSH). + * Override these to delegate search to remote systems (for example SSH). */ export interface GrepOperations { - /** Check if path is a directory. Throws if path doesn't exist. */ + /** Check if path is a directory. Throws if path does not exist. */ isDirectory: (absolutePath: string) => Promise | boolean; /** Read file contents for context lines */ readFile: (absolutePath: string) => Promise | string; @@ -56,20 +60,78 @@ const defaultGrepOperations: GrepOperations = { }; export interface GrepToolOptions { - /** Custom operations for grep. Default: local filesystem + ripgrep */ + /** Custom operations for grep. Default: local filesystem plus ripgrep */ operations?: GrepOperations; } -export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool { - const customOps = options?.operations; +function formatGrepCall( + args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const pattern = str(args?.pattern); + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const glob = str(args?.glob); + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("grep")) + + " " + + (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); + if (glob) text += theme.fg("toolOutput", ` (${glob})`); + if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`); + return text; +} +function formatGrepResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: GrepToolDetails; + }, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 15; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + const matchLimit = result.details?.matchLimitReached; + const truncation = result.details?.truncation; + const linesTruncated = result.details?.linesTruncated; + if (matchLimit || truncation?.truncated || linesTruncated) { + const warnings: string[] = []; + if (matchLimit) warnings.push(`${matchLimit} matches limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + if (linesTruncated) warnings.push("some lines truncated"); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createGrepToolDefinition( + cwd: string, + options?: GrepToolOptions, +): ToolDefinition { + const customOps = options?.operations; return { name: "grep", label: "grep", description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, + promptSnippet: "Search file contents for patterns (respects .gitignore)", parameters: grepSchema, - execute: async ( - _toolCallId: string, + async execute( + _toolCallId, { pattern, path: searchDir, @@ -88,13 +150,14 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo limit?: number; }, signal?: AbortSignal, - ) => { + _onUpdate?, + _ctx?, + ) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Operation aborted")); return; } - let settled = false; const settle = (fn: () => void) => { if (!settled) { @@ -113,17 +176,16 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo const searchPath = resolveToCwd(searchDir || ".", cwd); const ops = customOps ?? defaultGrepOperations; - let isDirectory: boolean; try { isDirectory = await ops.isDirectory(searchPath); - } catch (_err) { + } catch { settle(() => reject(new Error(`Path not found: ${searchPath}`))); return; } + const contextValue = context && context > 0 ? context : 0; const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); - const formatPath = (filePath: string): string => { if (isDirectory) { const relative = path.relative(searchPath, filePath); @@ -150,19 +212,9 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo }; const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"]; - - if (ignoreCase) { - args.push("--ignore-case"); - } - - if (literal) { - args.push("--fixed-strings"); - } - - if (glob) { - args.push("--glob", glob); - } - + if (ignoreCase) args.push("--ignore-case"); + if (literal) args.push("--fixed-strings"); + if (glob) args.push("--glob", glob); args.push(pattern, searchPath); const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] }); @@ -179,21 +231,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo rl.close(); signal?.removeEventListener("abort", onAbort); }; - - const stopChild = (dueToLimit: boolean = false) => { + const stopChild = (dueToLimit = false) => { if (!child.killed) { killedDueToLimit = dueToLimit; child.kill(); } }; - const onAbort = () => { aborted = true; stopChild(); }; - signal?.addEventListener("abort", onAbort, { once: true }); - child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); @@ -201,59 +249,38 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo const formatBlock = async (filePath: string, lineNumber: number): Promise => { const relativePath = formatPath(filePath); const lines = await getFileLines(filePath); - if (!lines.length) { - return [`${relativePath}:${lineNumber}: (unable to read file)`]; - } - + if (!lines.length) return [`${relativePath}:${lineNumber}: (unable to read file)`]; const block: string[] = []; const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber; const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber; - for (let current = start; current <= end; current++) { const lineText = lines[current - 1] ?? ""; const sanitized = lineText.replace(/\r/g, ""); const isMatchLine = current === lineNumber; - - // Truncate long lines + // Truncate long lines so grep output stays compact. const { text: truncatedText, wasTruncated } = truncateLine(sanitized); - if (wasTruncated) { - linesTruncated = true; - } - - if (isMatchLine) { - block.push(`${relativePath}:${current}: ${truncatedText}`); - } else { - block.push(`${relativePath}-${current}- ${truncatedText}`); - } + if (wasTruncated) linesTruncated = true; + if (isMatchLine) block.push(`${relativePath}:${current}: ${truncatedText}`); + else block.push(`${relativePath}-${current}- ${truncatedText}`); } - return block; }; - // Collect matches during streaming, format after + // Collect matches during streaming, then format them after rg exits. const matches: Array<{ filePath: string; lineNumber: number }> = []; - rl.on("line", (line) => { - if (!line.trim() || matchCount >= effectiveLimit) { - return; - } - + if (!line.trim() || matchCount >= effectiveLimit) return; let event: any; try { event = JSON.parse(line); } catch { return; } - if (event.type === "match") { matchCount++; const filePath = event.data?.path?.text; const lineNumber = event.data?.line_number; - - if (filePath && typeof lineNumber === "number") { - matches.push({ filePath, lineNumber }); - } - + if (filePath && typeof lineNumber === "number") matches.push({ filePath, lineNumber }); if (matchCount >= effectiveLimit) { matchLimitReached = true; stopChild(true); @@ -265,21 +292,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo cleanup(); settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`))); }); - child.on("close", async (code) => { cleanup(); - if (aborted) { settle(() => reject(new Error("Operation aborted"))); return; } - if (!killedDueToLimit && code !== 0 && code !== 1) { const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`; settle(() => reject(new Error(errorMsg))); return; } - if (matchCount === 0) { settle(() => resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }), @@ -287,45 +310,36 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo return; } - // Format matches (async to support remote file reading) + // Format matches after streaming finishes so custom readFile() backends can be async. for (const match of matches) { const block = await formatBlock(match.filePath, match.lineNumber); outputLines.push(...block); } - // Apply byte truncation (no line limit since we already have match limit) const rawOutput = outputLines.join("\n"); + // Apply byte truncation. There is no line limit here because the match limit already capped rows. const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - let output = truncation.content; const details: GrepToolDetails = {}; - - // Build notices + // Build actionable notices for truncation and match limits. const notices: string[] = []; - if (matchLimitReached) { notices.push( `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, ); details.matchLimitReached = effectiveLimit; } - if (truncation.truncated) { notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); details.truncation = truncation; } - if (linesTruncated) { notices.push( `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`, ); details.linesTruncated = true; } - - if (notices.length > 0) { - output += `\n\n[${notices.join(". ")}]`; - } - + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; settle(() => resolve({ content: [{ type: "text", text: output }], @@ -339,8 +353,23 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo })(); }); }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatGrepCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatGrepResult(result as any, options, theme, context.showImages)); + return text; + }, }; } -/** Default grep tool using process.cwd() - for backwards compatibility */ +export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool { + return wrapToolDefinition(createGrepToolDefinition(cwd, options)); +} + +/** Default grep tool using process.cwd() for backwards compatibility. */ +export const grepToolDefinition = createGrepToolDefinition(process.cwd()); export const grepTool = createGrepTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index b3f86338..6c853345 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -6,49 +6,61 @@ export { type BashToolInput, type BashToolOptions, bashTool, + bashToolDefinition, createBashTool, + createBashToolDefinition, createLocalBashOperations, } from "./bash.js"; export { createEditTool, + createEditToolDefinition, type EditOperations, type EditToolDetails, type EditToolInput, type EditToolOptions, editTool, + editToolDefinition, } from "./edit.js"; export { withFileMutationQueue } from "./file-mutation-queue.js"; export { createFindTool, + createFindToolDefinition, type FindOperations, type FindToolDetails, type FindToolInput, type FindToolOptions, findTool, + findToolDefinition, } from "./find.js"; export { createGrepTool, + createGrepToolDefinition, type GrepOperations, type GrepToolDetails, type GrepToolInput, type GrepToolOptions, grepTool, + grepToolDefinition, } from "./grep.js"; export { createLsTool, + createLsToolDefinition, type LsOperations, type LsToolDetails, type LsToolInput, type LsToolOptions, lsTool, + lsToolDefinition, } from "./ls.js"; export { createReadTool, + createReadToolDefinition, type ReadOperations, type ReadToolDetails, type ReadToolInput, type ReadToolOptions, readTool, + readToolDefinition, } from "./read.js"; export { DEFAULT_MAX_BYTES, @@ -62,31 +74,42 @@ export { } from "./truncate.js"; export { createWriteTool, + createWriteToolDefinition, type WriteOperations, type WriteToolInput, type WriteToolOptions, writeTool, + writeToolDefinition, } from "./write.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { type BashToolOptions, bashTool, createBashTool } from "./bash.js"; -import { createEditTool, editTool } from "./edit.js"; -import { createFindTool, findTool } from "./find.js"; -import { createGrepTool, grepTool } from "./grep.js"; -import { createLsTool, lsTool } from "./ls.js"; -import { createReadTool, type ReadToolOptions, readTool } from "./read.js"; -import { createWriteTool, writeTool } from "./write.js"; +import type { ToolDefinition } from "../extensions/types.js"; +import { + type BashToolOptions, + bashTool, + bashToolDefinition, + createBashTool, + createBashToolDefinition, +} from "./bash.js"; +import { createEditTool, createEditToolDefinition, editTool, editToolDefinition } from "./edit.js"; +import { createFindTool, createFindToolDefinition, findTool, findToolDefinition } from "./find.js"; +import { createGrepTool, createGrepToolDefinition, grepTool, grepToolDefinition } from "./grep.js"; +import { createLsTool, createLsToolDefinition, lsTool, lsToolDefinition } from "./ls.js"; +import { + createReadTool, + createReadToolDefinition, + type ReadToolOptions, + readTool, + readToolDefinition, +} from "./read.js"; +import { createWriteTool, createWriteToolDefinition, writeTool, writeToolDefinition } from "./write.js"; -/** Tool type (AgentTool from pi-ai) */ export type Tool = AgentTool; +export type ToolDef = ToolDefinition; -// Default tools for full access mode (using process.cwd()) export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool]; - -// Read-only tools for exploration without modification (using process.cwd()) export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool]; -// All available tools (using process.cwd()) export const allTools = { read: readTool, bash: bashTool, @@ -97,18 +120,53 @@ export const allTools = { ls: lsTool, }; +export const allToolDefinitions = { + read: readToolDefinition, + bash: bashToolDefinition, + edit: editToolDefinition, + write: writeToolDefinition, + grep: grepToolDefinition, + find: findToolDefinition, + ls: lsToolDefinition, +}; + export type ToolName = keyof typeof allTools; export interface ToolsOptions { - /** Options for the read tool */ read?: ReadToolOptions; - /** Options for the bash tool */ bash?: BashToolOptions; } -/** - * Create coding tools configured for a specific working directory. - */ +export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { + return [ + createReadToolDefinition(cwd, options?.read), + createBashToolDefinition(cwd, options?.bash), + createEditToolDefinition(cwd), + createWriteToolDefinition(cwd), + ]; +} + +export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { + return [ + createReadToolDefinition(cwd, options?.read), + createGrepToolDefinition(cwd), + createFindToolDefinition(cwd), + createLsToolDefinition(cwd), + ]; +} + +export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): Record { + return { + read: createReadToolDefinition(cwd, options?.read), + bash: createBashToolDefinition(cwd, options?.bash), + edit: createEditToolDefinition(cwd), + write: createWriteToolDefinition(cwd), + grep: createGrepToolDefinition(cwd), + find: createFindToolDefinition(cwd), + ls: createLsToolDefinition(cwd), + }; +} + export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] { return [ createReadTool(cwd, options?.read), @@ -118,16 +176,10 @@ export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] { ]; } -/** - * Create read-only tools configured for a specific working directory. - */ export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] { return [createReadTool(cwd, options?.read), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)]; } -/** - * Create all tools configured for a specific working directory. - */ export function createAllTools(cwd: string, options?: ToolsOptions): Record { return { read: createReadTool(cwd, options?.read), diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index cc8117d8..68dd9eef 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,8 +1,13 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Text } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { existsSync, readdirSync, statSync } from "fs"; import nodePath from "path"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; import { resolveToCwd } from "./path-utils.js"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; const lsSchema = Type.Object({ @@ -21,12 +26,12 @@ export interface LsToolDetails { /** * Pluggable operations for the ls tool. - * Override these to delegate directory listing to remote systems (e.g., SSH). + * Override these to delegate directory listing to remote systems (for example SSH). */ export interface LsOperations { /** Check if path exists */ exists: (absolutePath: string) => Promise | boolean; - /** Get file/directory stats. Throws if not found. */ + /** Get file or directory stats. Throws if not found. */ stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean }; /** Read directory entries */ readdir: (absolutePath: string) => Promise | string[]; @@ -43,19 +48,72 @@ export interface LsToolOptions { operations?: LsOperations; } -export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool { - const ops = options?.operations ?? defaultLsOperations; +function formatLsCall( + args: { path?: string; limit?: number } | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`; + if (limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${limit})`); + } + return text; +} +function formatLsResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: LsToolDetails; + }, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + const entryLimit = result.details?.entryLimitReached; + const truncation = result.details?.truncation; + if (entryLimit || truncation?.truncated) { + const warnings: string[] = []; + if (entryLimit) warnings.push(`${entryLimit} entries limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createLsToolDefinition( + cwd: string, + options?: LsToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultLsOperations; return { name: "ls", label: "ls", description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: "List directory contents", parameters: lsSchema, - execute: async ( - _toolCallId: string, + async execute( + _toolCallId, { path, limit }: { path?: string; limit?: number }, signal?: AbortSignal, - ) => { + _onUpdate?, + _ctx?, + ) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Operation aborted")); @@ -70,20 +128,20 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool a.toLowerCase().localeCompare(b.toLowerCase())); - // Format entries with directory indicators + // Format entries with directory indicators. const results: string[] = []; let entryLimitReached = false; - for (const entry of entries) { if (results.length >= effectiveLimit) { entryLimitReached = true; @@ -107,17 +164,13 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool 0) { output += `\n\n[${notices.join(". ")}]`; } @@ -163,8 +211,23 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool { + return wrapToolDefinition(createLsToolDefinition(cwd, options)); +} + +/** Default ls tool using process.cwd() for backwards compatibility. */ +export const lsToolDefinition = createLsToolDefinition(process.cwd()); export const lsTool = createLsTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index c90a54ea..d23b4313 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -1,11 +1,17 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; +import { Text } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile } from "fs/promises"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js"; import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; import { resolveReadPath } from "./path-utils.js"; +import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; const readSchema = Type.Object({ @@ -22,14 +28,14 @@ export interface ReadToolDetails { /** * Pluggable operations for the read tool. - * Override these to delegate file reading to remote systems (e.g., SSH). + * Override these to delegate file reading to remote systems (for example SSH). */ export interface ReadOperations { /** Read file contents as a Buffer */ readFile: (absolutePath: string) => Promise; /** Check if file is readable (throw if not) */ access: (absolutePath: string) => Promise; - /** Detect image MIME type, return null/undefined for non-images */ + /** Detect image MIME type, return null or undefined for non-images */ detectImageMimeType?: (absolutePath: string) => Promise; } @@ -46,104 +52,143 @@ export interface ReadToolOptions { operations?: ReadOperations; } -export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { +function formatReadCall( + args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const rawPath = str(args?.file_path ?? args?.path); + const path = rawPath !== null ? shortenPath(rawPath) : null; + const offset = args?.offset; + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + if (offset !== undefined || limit !== undefined) { + const startLine = offset ?? 1; + const endLine = limit !== undefined ? startLine + limit - 1 : ""; + pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); + } + return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`; +} + +function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0 && lines[end - 1] === "") { + end--; + } + return lines.slice(0, end); +} + +function formatReadResult( + args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined, + result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails }, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + showImages: boolean, +): string { + const rawPath = str(args?.file_path ?? args?.path); + const output = getTextOutput(result as any, showImages); + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); + const lines = trimTrailingEmptyLines(renderedLines); + const maxLines = options.expanded ? lines.length : 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + + const truncation = result.details?.truncation; + if (truncation?.truncated) { + if (truncation.firstLineExceedsLimit) { + text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`; + } else if (truncation.truncatedBy === "lines") { + text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`; + } else { + text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`; + } + } + return text; +} + +export function createReadToolDefinition( + cwd: string, + options?: ReadToolOptions, +): ToolDefinition { const autoResizeImages = options?.autoResizeImages ?? true; const ops = options?.operations ?? defaultReadOperations; - return { name: "read", label: "read", description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + promptSnippet: "Read file contents", + promptGuidelines: ["Use read to examine files instead of cat or sed."], parameters: readSchema, - execute: async ( - _toolCallId: string, + async execute( + _toolCallId, { path, offset, limit }: { path: string; offset?: number; limit?: number }, signal?: AbortSignal, - ) => { + _onUpdate?, + _ctx?, + ) { const absolutePath = resolveReadPath(path, cwd); - return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( (resolve, reject) => { - // Check if already aborted if (signal?.aborted) { reject(new Error("Operation aborted")); return; } - let aborted = false; - - // Set up abort handler const onAbort = () => { aborted = true; reject(new Error("Operation aborted")); }; + signal?.addEventListener("abort", onAbort, { once: true }); - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - - // Perform the read operation (async () => { try { - // Check if file exists + // Check if file exists and is readable. await ops.access(absolutePath); - - // Check if aborted before reading - if (aborted) { - return; - } - + if (aborted) return; const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined; - - // Read the file based on type let content: (TextContent | ImageContent)[]; let details: ReadToolDetails | undefined; - if (mimeType) { - // Read as image (binary) + // Read image as binary. const buffer = await ops.readFile(absolutePath); const base64 = buffer.toString("base64"); - if (autoResizeImages) { - // Resize image if needed + // Resize image if needed before sending it back to the model. const resized = await resizeImage({ type: "image", data: base64, mimeType }); const dimensionNote = formatDimensionNote(resized); - let textNote = `Read image file [${resized.mimeType}]`; - if (dimensionNote) { - textNote += `\n${dimensionNote}`; - } - + if (dimensionNote) textNote += `\n${dimensionNote}`; content = [ { type: "text", text: textNote }, { type: "image", data: resized.data, mimeType: resized.mimeType }, ]; } else { - const textNote = `Read image file [${mimeType}]`; content = [ - { type: "text", text: textNote }, + { type: "text", text: `Read image file [${mimeType}]` }, { type: "image", data: base64, mimeType }, ]; } } else { - // Read as text + // Read text content. const buffer = await ops.readFile(absolutePath); const textContent = buffer.toString("utf-8"); const allLines = textContent.split("\n"); const totalFileLines = allLines.length; - - // Apply offset if specified (1-indexed to 0-indexed) + // Apply offset if specified. Convert from 1-indexed input to 0-indexed array access. const startLine = offset ? Math.max(0, offset - 1) : 0; - const startLineDisplay = startLine + 1; // For display (1-indexed) - - // Check if offset is out of bounds + const startLineDisplay = startLine + 1; + // Check if offset is out of bounds. if (startLine >= allLines.length) { throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); } - - // If limit is specified by user, use it; otherwise we'll let truncateHead decide let selectedContent: string; let userLimitedLines: number | undefined; + // If limit is specified by the user, honor it first. Otherwise truncateHead decides. if (limit !== undefined) { const endLine = Math.min(startLine + limit, allLines.length); selectedContent = allLines.slice(startLine, endLine).join("\n"); @@ -151,24 +196,19 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo } else { selectedContent = allLines.slice(startLine).join("\n"); } - - // Apply truncation (respects both line and byte limits) + // Apply truncation, respecting both line and byte limits. const truncation = truncateHead(selectedContent); - let outputText: string; - if (truncation.firstLineExceedsLimit) { - // First line at offset exceeds 30KB - tell model to use bash + // First line alone exceeds the byte limit. Point the model at a bash fallback. const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; details = { truncation }; } else if (truncation.truncated) { - // Truncation occurred - build actionable notice + // Truncation occurred. Build an actionable continuation notice. const endLineDisplay = startLineDisplay + truncation.outputLines - 1; const nextOffset = endLineDisplay + 1; - outputText = truncation.content; - if (truncation.truncatedBy === "lines") { outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; } else { @@ -176,47 +216,45 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo } details = { truncation }; } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { - // User specified limit, there's more content, but no truncation + // User-specified limit stopped early, but the file still has more content. const remaining = allLines.length - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; - - outputText = truncation.content; - outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; + outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; } else { - // No truncation, no user limit exceeded + // No truncation and no remaining user-limited content. outputText = truncation.content; } - content = [{ type: "text", text: outputText }]; } - // Check if aborted after reading - if (aborted) { - return; - } - - // Clean up abort handler - if (signal) { - signal.removeEventListener("abort", onAbort); - } - + if (aborted) return; + signal?.removeEventListener("abort", onAbort); resolve({ content, details }); } catch (error: any) { - // Clean up abort handler - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - if (!aborted) { - reject(error); - } + signal?.removeEventListener("abort", onAbort); + if (!aborted) reject(error); } })(); }, ); }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatReadCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatReadResult(context.args, result as any, options, theme, context.showImages)); + return text; + }, }; } -/** Default read tool using process.cwd() - for backwards compatibility */ +export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { + return wrapToolDefinition(createReadToolDefinition(cwd, options)); +} + +/** Default read tool using process.cwd() for backwards compatibility. */ +export const readToolDefinition = createReadToolDefinition(process.cwd()); export const readTool = createReadTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts new file mode 100644 index 00000000..a71522a4 --- /dev/null +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -0,0 +1,64 @@ +import * as os from "node:os"; +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; +import { getCapabilities, getImageDimensions, imageFallback } from "@mariozechner/pi-tui"; +import stripAnsi from "strip-ansi"; +import { sanitizeBinaryOutput } from "../../utils/shell.js"; + +export function shortenPath(path: unknown): string { + if (typeof path !== "string") return ""; + const home = os.homedir(); + if (path.startsWith(home)) { + return `~${path.slice(home.length)}`; + } + return path; +} + +export function str(value: unknown): string | null { + if (typeof value === "string") return value; + if (value == null) return ""; + return null; +} + +export function replaceTabs(text: string): string { + return text.replace(/\t/g, " "); +} + +export function normalizeDisplayText(text: string): string { + return text.replace(/\r/g, ""); +} + +export function getTextOutput( + result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } | undefined, + showImages: boolean, +): string { + if (!result) return ""; + + const textBlocks = result.content.filter((c) => c.type === "text"); + const imageBlocks = result.content.filter((c) => c.type === "image"); + + let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n"); + + const caps = getCapabilities(); + if (imageBlocks.length > 0 && (!caps.images || !showImages)) { + const imageIndicators = imageBlocks + .map((img) => { + const mimeType = img.mimeType ?? "image/unknown"; + const dims = + img.data && img.mimeType ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined; + return imageFallback(mimeType, dims); + }) + .join("\n"); + output = output ? `${output}\n${imageIndicators}` : imageIndicators; + } + + return output; +} + +export type ToolRenderResultLike = { + content: (TextContent | ImageContent)[]; + details: TDetails; +}; + +export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string { + return theme.fg("error", "[invalid arg]"); +} diff --git a/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts b/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts new file mode 100644 index 00000000..913fbac1 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts @@ -0,0 +1,41 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ExtensionContext, ToolDefinition } from "../extensions/types.js"; + +/** Wrap a ToolDefinition into an AgentTool for the core runtime. */ +export function wrapToolDefinition( + definition: ToolDefinition, + ctxFactory?: () => ExtensionContext, +): AgentTool { + return { + name: definition.name, + label: definition.label, + description: definition.description, + parameters: definition.parameters, + execute: (toolCallId, params, signal, onUpdate) => + definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext), + }; +} + +/** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */ +export function wrapToolDefinitions( + definitions: ToolDefinition[], + ctxFactory?: () => ExtensionContext, +): AgentTool[] { + return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory)); +} + +/** + * Synthesize a minimal ToolDefinition from an AgentTool. + * + * This keeps AgentSession's internal registry definition-first even when a caller + * provides plain AgentTool overrides that do not include prompt metadata or renderers. + */ +export function createToolDefinitionFromAgentTool(tool: AgentTool): ToolDefinition { + return { + name: tool.name, + label: tool.label, + description: tool.description, + parameters: tool.parameters as any, + execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate), + }; +} diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index c44ecb4e..a03da5f6 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -1,9 +1,15 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Container, Text } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; import { dirname } from "path"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; import { withFileMutationQueue } from "./file-mutation-queue.js"; import { resolveToCwd } from "./path-utils.js"; +import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; const writeSchema = Type.Object({ path: Type.String({ description: "Path to the file to write (relative or absolute)" }), @@ -14,12 +20,12 @@ export type WriteToolInput = Static; /** * Pluggable operations for the write tool. - * Override these to delegate file writing to remote systems (e.g., SSH). + * Override these to delegate file writing to remote systems (for example SSH). */ export interface WriteOperations { /** Write content to a file */ writeFile: (absolutePath: string, content: string) => Promise; - /** Create directory (recursively) */ + /** Create directory recursively */ mkdir: (dir: string) => Promise; } @@ -33,70 +39,191 @@ export interface WriteToolOptions { operations?: WriteOperations; } -export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool { - const ops = options?.operations ?? defaultWriteOperations; +type WriteHighlightCache = { + rawPath: string | null; + lang: string; + rawContent: string; + normalizedLines: string[]; + highlightedLines: string[]; +}; +class WriteCallRenderComponent extends Text { + cache?: WriteHighlightCache; + + constructor() { + super("", 0, 0); + } +} + +const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50; + +function highlightSingleLine(line: string, lang: string): string { + const highlighted = highlightCode(line, lang); + return highlighted[0] ?? ""; +} + +function refreshWriteHighlightPrefix(cache: WriteHighlightCache): void { + const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length); + if (prefixCount === 0) return; + const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n"); + const prefixHighlighted = highlightCode(prefixSource, cache.lang); + for (let i = 0; i < prefixCount; i++) { + cache.highlightedLines[i] = + prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang); + } +} + +function rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + if (!lang) return undefined; + const displayContent = normalizeDisplayText(fileContent); + const normalized = replaceTabs(displayContent); + return { + rawPath, + lang, + rawContent: fileContent, + normalizedLines: normalized.split("\n"), + highlightedLines: highlightCode(normalized, lang), + }; +} + +function updateWriteHighlightCacheIncremental( + cache: WriteHighlightCache | undefined, + rawPath: string | null, + fileContent: string, +): WriteHighlightCache | undefined { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + if (!lang) return undefined; + if (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent); + if (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent); + if (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent); + if (fileContent.length === cache.rawContent.length) return cache; + + const deltaRaw = fileContent.slice(cache.rawContent.length); + const deltaDisplay = normalizeDisplayText(deltaRaw); + const deltaNormalized = replaceTabs(deltaDisplay); + cache.rawContent = fileContent; + if (cache.normalizedLines.length === 0) { + cache.normalizedLines.push(""); + cache.highlightedLines.push(""); + } + + const segments = deltaNormalized.split("\n"); + const lastIndex = cache.normalizedLines.length - 1; + cache.normalizedLines[lastIndex] += segments[0]; + cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang); + for (let i = 1; i < segments.length; i++) { + cache.normalizedLines.push(segments[i]); + cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang)); + } + refreshWriteHighlightPrefix(cache); + return cache; +} + +function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0 && lines[end - 1] === "") { + end--; + } + return lines.slice(0, end); +} + +function formatWriteCall( + args: { path?: string; file_path?: string; content?: string } | undefined, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + cache: WriteHighlightCache | undefined, +): string { + const rawPath = str(args?.file_path ?? args?.path); + const fileContent = str(args?.content); + const path = rawPath !== null ? shortenPath(rawPath) : null; + const invalidArg = invalidArgText(theme); + let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`; + + if (fileContent === null) { + text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`; + } else if (fileContent) { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + const renderedLines = lang + ? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang)) + : normalizeDisplayText(fileContent).split("\n"); + const lines = trimTrailingEmptyLines(renderedLines); + const totalLines = lines.length; + const maxLines = options.expanded ? lines.length : 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + return text; +} + +function formatWriteResult( + result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string | undefined { + if (!result.isError) { + return undefined; + } + const output = result.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join("\n"); + if (!output) { + return undefined; + } + return `\n${theme.fg("error", output)}`; +} + +export function createWriteToolDefinition( + cwd: string, + options?: WriteToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultWriteOperations; return { name: "write", label: "write", description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", + promptSnippet: "Create or overwrite files", + promptGuidelines: ["Use write only for new files or complete rewrites."], parameters: writeSchema, - execute: async ( - _toolCallId: string, + async execute( + _toolCallId, { path, content }: { path: string; content: string }, signal?: AbortSignal, - ) => { + _onUpdate?, + _ctx?, + ) { const absolutePath = resolveToCwd(path, cwd); const dir = dirname(absolutePath); - return withFileMutationQueue( absolutePath, () => new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>( (resolve, reject) => { - // Check if already aborted if (signal?.aborted) { reject(new Error("Operation aborted")); return; } - let aborted = false; - - // Set up abort handler const onAbort = () => { aborted = true; reject(new Error("Operation aborted")); }; - - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - - // Perform the write operation + signal?.addEventListener("abort", onAbort, { once: true }); (async () => { try { - // Create parent directories if needed + // Create parent directories if needed. await ops.mkdir(dir); - - // Check if aborted before writing - if (aborted) { - return; - } - - // Write the file + if (aborted) return; + // Write the file contents. await ops.writeFile(absolutePath, content); - - // Check if aborted after writing - if (aborted) { - return; - } - - // Clean up abort handler - if (signal) { - signal.removeEventListener("abort", onAbort); - } - + if (aborted) return; + signal?.removeEventListener("abort", onAbort); resolve({ content: [ { type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }, @@ -104,22 +231,55 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT details: undefined, }); } catch (error: any) { - // Clean up abort handler - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - if (!aborted) { - reject(error); - } + signal?.removeEventListener("abort", onAbort); + if (!aborted) reject(error); } })(); }, ), ); }, + renderCall(args, theme, context) { + const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined; + const rawPath = str(renderArgs?.file_path ?? renderArgs?.path); + const fileContent = str(renderArgs?.content); + const component = + (context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent(); + if (fileContent !== null) { + component.cache = context.argsComplete + ? rebuildWriteHighlightCacheFull(rawPath, fileContent) + : updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent); + } else { + component.cache = undefined; + } + component.setText( + formatWriteCall( + renderArgs, + { expanded: context.expanded, isPartial: context.isPartial }, + theme, + component.cache, + ), + ); + return component; + }, + renderResult(result, _options, theme, context) { + const output = formatWriteResult({ ...result, isError: context.isError }, theme); + if (!output) { + const component = (context.lastComponent as Container | undefined) ?? new Container(); + component.clear(); + return component; + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(output); + return text; + }, }; } -/** Default write tool using process.cwd() - for backwards compatibility */ +export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool { + return wrapToolDefinition(createWriteToolDefinition(cwd, options)); +} + +/** Default write tool using process.cwd() for backwards compatibility. */ +export const writeToolDefinition = createWriteToolDefinition(process.cwd()); export const writeTool = createWriteTool(process.cwd()); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 64567d4b..4465ee67 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -223,8 +223,16 @@ export { type BashToolInput, type BashToolOptions, bashTool, + bashToolDefinition, codingTools, + createBashToolDefinition, + createEditToolDefinition, + createFindToolDefinition, + createGrepToolDefinition, createLocalBashOperations, + createLsToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type EditOperations, @@ -232,27 +240,32 @@ export { type EditToolInput, type EditToolOptions, editTool, + editToolDefinition, type FindOperations, type FindToolDetails, type FindToolInput, type FindToolOptions, findTool, + findToolDefinition, formatSize, type GrepOperations, type GrepToolDetails, type GrepToolInput, type GrepToolOptions, grepTool, + grepToolDefinition, type LsOperations, type LsToolDetails, type LsToolInput, type LsToolOptions, lsTool, + lsToolDefinition, type ReadOperations, type ReadToolDetails, type ReadToolInput, type ReadToolOptions, readTool, + readToolDefinition, type ToolsOptions, type TruncationOptions, type TruncationResult, @@ -264,6 +277,7 @@ export { type WriteToolOptions, withFileMutationQueue, writeTool, + writeToolDefinition, } from "./core/tools/index.js"; // Main entry point export { main } from "./main.js"; 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 28b15479..765a8437 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -1,138 +1,69 @@ -import * as os from "node:os"; -import { - Box, - Container, - getCapabilities, - getImageDimensions, - Image, - imageFallback, - Spacer, - Text, - type TUI, - truncateToWidth, -} from "@mariozechner/pi-tui"; -import stripAnsi from "strip-ansi"; -import type { ToolDefinition } from "../../../core/extensions/types.js"; -import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js"; -import { allTools } from "../../../core/tools/index.js"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; +import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.js"; +import { allToolDefinitions } from "../../../core/tools/index.js"; +import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js"; import { convertToPng } from "../../../utils/image-convert.js"; -import { sanitizeBinaryOutput } from "../../../utils/shell.js"; -import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; -import { renderDiff } from "./diff.js"; -import { keyHint } from "./keybinding-hints.js"; -import { truncateToVisualLines } from "./visual-truncate.js"; - -// Preview line limit for bash when not expanded -const BASH_PREVIEW_LINES = 5; -// During partial write tool-call streaming, re-highlight the first N lines fully -// to keep multiline tokenization mostly correct without re-highlighting the full file. -const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50; - -/** - * Convert absolute path to tilde notation if it's in home directory - */ -function shortenPath(path: unknown): string { - if (typeof path !== "string") return ""; - const home = os.homedir(); - if (path.startsWith(home)) { - return `~${path.slice(home.length)}`; - } - return path; -} - -/** - * Replace tabs with spaces for consistent rendering - */ -function replaceTabs(text: string): string { - return text.replace(/\t/g, " "); -} - -/** - * Normalize control characters for terminal preview rendering. - * Keep tool arguments unchanged, sanitize only display text. - */ -function normalizeDisplayText(text: string): string { - return text.replace(/\r/g, ""); -} - -/** Safely coerce value to string for display. Returns null if invalid type. */ -function str(value: unknown): string | null { - if (typeof value === "string") return value; - if (value == null) return ""; - return null; // Invalid type -} +import { theme } from "../theme/theme.js"; export interface ToolExecutionOptions { - showImages?: boolean; // default: true (only used if terminal supports images) + showImages?: boolean; } -type WriteHighlightCache = { - rawPath: string | null; - lang: string; - rawContent: string; - normalizedLines: string[]; - highlightedLines: string[]; -}; - -/** - * Component that renders a tool call with its result (updateable) - */ export class ToolExecutionComponent extends Container { - private contentBox: Box; // Used for custom tools and bash visual truncation - private contentText: Text; // For built-in tools (with its own padding/bg) + private contentBox: Box; + private contentText: Text; + private callRendererComponent?: Component; + private resultRendererComponent?: Component; + private rendererState: any = {}; private imageComponents: Image[] = []; private imageSpacers: Spacer[] = []; private toolName: string; + private toolCallId: string; private args: any; private expanded = false; private showImages: boolean; private isPartial = true; - private toolDefinition?: ToolDefinition; + private toolDefinition?: ToolDefinition; + private builtInToolDefinition?: ToolDefinition; private ui: TUI; private cwd: string; + private executionStarted = false; + private argsComplete = false; private result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError: boolean; details?: any; }; - // Cached edit diff preview (computed when args arrive, before tool executes) - private editDiffPreview?: EditDiffResult | EditDiffError; - private editDiffArgsKey?: string; // Track which args the preview is for - // Cached converted images for Kitty protocol (which requires PNG), keyed by index private convertedImages: Map = 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; - private bashStartedAt?: number; - private bashElapsedInterval?: NodeJS.Timeout; constructor( toolName: string, + toolCallId: string, args: any, options: ToolExecutionOptions = {}, - toolDefinition: ToolDefinition | undefined, + toolDefinition: ToolDefinition | undefined, ui: TUI, cwd: string = process.cwd(), ) { super(); this.toolName = toolName; + this.toolCallId = toolCallId; this.args = args; - this.showImages = options.showImages ?? true; this.toolDefinition = toolDefinition; + this.builtInToolDefinition = allToolDefinitions[toolName as keyof typeof allToolDefinitions]; + this.showImages = options.showImages ?? true; this.ui = ui; this.cwd = cwd; this.addChild(new Spacer(1)); - // Always create both - contentBox for custom tools/bash, contentText for other built-ins + // Always create both. contentBox is used for tools with renderer-based call/result composition. + // contentText is reserved for generic fallback rendering when no tool definition exists. this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); - // Use contentBox for bash (visual truncation) or custom tools with custom renderers - // Use contentText for built-in tools (including overrides without custom renderers) - if (toolName === "bash" || (toolDefinition && !this.shouldUseBuiltInRenderer())) { + if (this.hasRendererDefinition()) { this.addChild(this.contentBox); } else { this.addChild(this.contentText); @@ -141,195 +72,85 @@ export class ToolExecutionComponent extends Container { this.updateDisplay(); } - /** - * Check if we should use built-in rendering for this tool. - * Returns true if the tool name is a built-in AND either there's no toolDefinition - * or the toolDefinition doesn't provide custom renderers. - */ - private shouldUseBuiltInRenderer(): boolean { - const isBuiltInName = this.toolName in allTools; - const hasCustomRenderers = this.toolDefinition?.renderCall || this.toolDefinition?.renderResult; - return isBuiltInName && !hasCustomRenderers; + private isBuiltInDefinition(definition: ToolDefinition | undefined): boolean { + return ( + definition !== undefined && + this.builtInToolDefinition !== undefined && + definition.parameters === this.builtInToolDefinition.parameters + ); + } + + private getCallRenderer(): ToolDefinition["renderCall"] | undefined { + if (!this.builtInToolDefinition) { + return this.toolDefinition?.renderCall; + } + if (!this.toolDefinition || this.isBuiltInDefinition(this.toolDefinition)) { + return this.builtInToolDefinition.renderCall; + } + return this.toolDefinition.renderCall ?? this.builtInToolDefinition.renderCall; + } + + private getResultRenderer(): ToolDefinition["renderResult"] | undefined { + if (!this.builtInToolDefinition) { + return this.toolDefinition?.renderResult; + } + if (!this.toolDefinition || this.isBuiltInDefinition(this.toolDefinition)) { + return this.builtInToolDefinition.renderResult; + } + return this.toolDefinition.renderResult ?? this.builtInToolDefinition.renderResult; + } + + private hasRendererDefinition(): boolean { + return this.builtInToolDefinition !== undefined || this.toolDefinition !== undefined; + } + + private getRenderContext(lastComponent: Component | undefined): ToolRenderContext { + return { + args: this.args, + toolCallId: this.toolCallId, + invalidate: () => { + this.invalidate(); + this.ui.requestRender(); + }, + lastComponent, + state: this.rendererState, + cwd: this.cwd, + executionStarted: this.executionStarted, + argsComplete: this.argsComplete, + isPartial: this.isPartial, + expanded: this.expanded, + showImages: this.showImages, + isError: this.result?.isError ?? false, + }; + } + + private createCallFallback(): Component { + return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0); + } + + private createResultFallback(): Component | undefined { + const output = this.getTextOutput(); + if (!output) { + return undefined; + } + return new Text(theme.fg("toolOutput", output), 0, 0); } updateArgs(args: any): void { this.args = args; - if (this.toolName === "write" && this.isPartial) { - this.updateWriteHighlightCacheIncremental(); - } this.updateDisplay(); } markExecutionStarted(): void { - if (this.toolName !== "bash" || this.bashStartedAt !== undefined) return; - this.bashStartedAt = Date.now(); - this.ensureBashElapsedTimer(); + this.executionStarted = true; this.updateDisplay(); this.ui.requestRender(); } - private ensureBashElapsedTimer(): void { - if (this.toolName !== "bash" || !this.isPartial || this.bashStartedAt === undefined || this.bashElapsedInterval) - return; - this.bashElapsedInterval = setInterval(() => { - this.updateDisplay(); - this.ui.requestRender(); - }, 1000); - } - - private stopBashElapsedTimer(): void { - if (!this.bashElapsedInterval) return; - clearInterval(this.bashElapsedInterval); - this.bashElapsedInterval = undefined; - } - - private getBashDurationMs(): number | undefined { - if (this.toolName !== "bash" || this.bashStartedAt === undefined) return undefined; - return Date.now() - this.bashStartedAt; - } - - private formatDuration(ms: number): string { - return `${(ms / 1000).toFixed(1)}s`; - } - - private highlightSingleLine(line: string, lang: string): string { - const highlighted = highlightCode(line, lang); - return highlighted[0] ?? ""; - } - - private refreshWriteHighlightPrefix(cache: WriteHighlightCache): void { - const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length); - if (prefixCount === 0) return; - - const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n"); - const prefixHighlighted = highlightCode(prefixSource, cache.lang); - for (let i = 0; i < prefixCount; i++) { - cache.highlightedLines[i] = - prefixHighlighted[i] ?? this.highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang); - } - } - - private rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): void { - const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; - if (!lang) { - this.writeHighlightCache = undefined; - return; - } - - const displayContent = normalizeDisplayText(fileContent); - const normalized = replaceTabs(displayContent); - this.writeHighlightCache = { - rawPath, - lang, - rawContent: fileContent, - normalizedLines: normalized.split("\n"), - highlightedLines: highlightCode(normalized, lang), - }; - } - - private updateWriteHighlightCacheIncremental(): void { - const rawPath = str(this.args?.file_path ?? this.args?.path); - const fileContent = str(this.args?.content); - if (rawPath === null || fileContent === null) { - this.writeHighlightCache = undefined; - return; - } - - const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; - if (!lang) { - this.writeHighlightCache = undefined; - return; - } - - if (!this.writeHighlightCache) { - this.rebuildWriteHighlightCacheFull(rawPath, fileContent); - return; - } - - const cache = this.writeHighlightCache; - if (cache.lang !== lang || cache.rawPath !== rawPath) { - this.rebuildWriteHighlightCacheFull(rawPath, fileContent); - return; - } - - if (!fileContent.startsWith(cache.rawContent)) { - this.rebuildWriteHighlightCacheFull(rawPath, fileContent); - return; - } - - if (fileContent.length === cache.rawContent.length) { - return; - } - - const deltaRaw = fileContent.slice(cache.rawContent.length); - const deltaDisplay = normalizeDisplayText(deltaRaw); - const deltaNormalized = replaceTabs(deltaDisplay); - cache.rawContent = fileContent; - - if (cache.normalizedLines.length === 0) { - cache.normalizedLines.push(""); - cache.highlightedLines.push(""); - } - - const segments = deltaNormalized.split("\n"); - const lastIndex = cache.normalizedLines.length - 1; - cache.normalizedLines[lastIndex] += segments[0]; - cache.highlightedLines[lastIndex] = this.highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang); - - for (let i = 1; i < segments.length; i++) { - cache.normalizedLines.push(segments[i]); - cache.highlightedLines.push(this.highlightSingleLine(segments[i], cache.lang)); - } - - this.refreshWriteHighlightPrefix(cache); - } - - /** - * Signal that args are complete (tool is about to execute). - * This triggers diff computation for edit tool. - */ setArgsComplete(): void { - if (this.toolName === "write") { - const rawPath = str(this.args?.file_path ?? this.args?.path); - const fileContent = str(this.args?.content); - if (rawPath !== null && fileContent !== null) { - this.rebuildWriteHighlightCacheFull(rawPath, fileContent); - } - } - this.maybeComputeEditDiff(); - } - - /** - * Compute edit diff preview when we have complete args. - * This runs async and updates display when done. - */ - private maybeComputeEditDiff(): void { - if (this.toolName !== "edit") return; - - const path = this.args?.path; - const oldText = this.args?.oldText; - const newText = this.args?.newText; - - // Need all three params to compute diff - if (!path || oldText === undefined || newText === undefined) return; - - // Create a key to track which args this computation is for - const argsKey = JSON.stringify({ path, oldText, newText }); - - // Skip if we already computed for these exact args - if (this.editDiffArgsKey === argsKey) return; - - this.editDiffArgsKey = argsKey; - - // Compute diff async - computeEditDiff(path, oldText, newText, this.cwd).then((result) => { - // Only update if args haven't changed since we started - if (this.editDiffArgsKey === argsKey) { - this.editDiffPreview = result; - this.updateDisplay(); - this.ui.requestRender(); - } - }); + this.argsComplete = true; + this.updateDisplay(); + this.ui.requestRender(); } updateResult( @@ -342,45 +163,22 @@ export class ToolExecutionComponent extends Container { ): void { this.result = result; this.isPartial = isPartial; - if (this.toolName === "bash") { - if (isPartial) { - this.ensureBashElapsedTimer(); - } else { - this.stopBashElapsedTimer(); - } - } - if (this.toolName === "write" && !isPartial) { - const rawPath = str(this.args?.file_path ?? this.args?.path); - const fileContent = str(this.args?.content); - if (rawPath !== null && fileContent !== null) { - this.rebuildWriteHighlightCacheFull(rawPath, fileContent); - } - } this.updateDisplay(); - // Convert non-PNG images to PNG for Kitty protocol (async) this.maybeConvertImagesForKitty(); } - /** - * Convert non-PNG images to PNG for Kitty graphics protocol. - * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display. - */ private maybeConvertImagesForKitty(): void { const caps = getCapabilities(); - // Only needed for Kitty protocol if (caps.images !== "kitty") return; if (!this.result) return; - const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; - + const imageBlocks = this.result.content.filter((c) => c.type === "image"); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; if (!img.data || !img.mimeType) continue; - // Skip if already PNG or already converted if (img.mimeType === "image/png") continue; if (this.convertedImages.has(i)) continue; - // Convert async const index = i; convertToPng(img.data, img.mimeType).then((converted) => { if (converted) { @@ -415,88 +213,70 @@ export class ToolExecutionComponent extends Container { } private updateDisplay(): void { - // Set background based on state 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 useBuiltInRenderer = this.shouldUseBuiltInRenderer(); - let customRendererHasContent = false; + let hasContent = false; this.hideComponent = false; - - // Use built-in rendering for built-in tools (or overrides without custom renderers) - if (useBuiltInRenderer) { - if (this.toolName === "bash") { - // Bash uses Box with visual line truncation - this.contentBox.setBgFn(bgFn); - this.contentBox.clear(); - this.renderBashContent(); - } else { - // Other built-in tools: use Text directly with caching - this.contentText.setCustomBgFn(bgFn); - this.contentText.setText(this.formatToolExecution()); - } - } else if (this.toolDefinition) { - // Custom tools use Box for flexible component rendering + if (this.hasRendererDefinition()) { this.contentBox.setBgFn(bgFn); this.contentBox.clear(); - // Render call component - if (this.toolDefinition.renderCall) { - try { - const callComponent = this.toolDefinition.renderCall(this.args, theme); - 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; - } + const callRenderer = this.getCallRenderer(); + if (!callRenderer) { + this.contentBox.addChild(this.createCallFallback()); + hasContent = true; } else { - // No custom renderCall, show tool name - this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0)); - customRendererHasContent = true; + try { + const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent)); + this.callRendererComponent = component; + this.contentBox.addChild(component); + hasContent = true; + } catch { + this.callRendererComponent = undefined; + this.contentBox.addChild(this.createCallFallback()); + hasContent = true; + } } - // Render result component if we have a result - if (this.result && this.toolDefinition.renderResult) { - try { - const resultComponent = this.toolDefinition.renderResult( - { content: this.result.content as any, details: this.result.details }, - { expanded: this.expanded, isPartial: this.isPartial }, - theme, - ); - if (resultComponent !== undefined) { - this.contentBox.addChild(resultComponent); - customRendererHasContent = true; + if (this.result) { + const resultRenderer = this.getResultRenderer(); + if (!resultRenderer) { + const component = this.createResultFallback(); + if (component) { + this.contentBox.addChild(component); + hasContent = 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 { + try { + const component = resultRenderer( + { content: this.result.content as any, details: this.result.details }, + { expanded: this.expanded, isPartial: this.isPartial }, + theme, + this.getRenderContext(this.resultRendererComponent), + ); + this.resultRendererComponent = component; + this.contentBox.addChild(component); + hasContent = true; + } catch { + this.resultRendererComponent = undefined; + const component = this.createResultFallback(); + if (component) { + this.contentBox.addChild(component); + hasContent = true; + } } } - } else if (this.result) { - // Has result but no custom renderResult - 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()); + hasContent = true; } - // Handle images (same for both custom and built-in) for (const img of this.imageComponents) { this.removeChild(img); } @@ -507,21 +287,15 @@ export class ToolExecutionComponent extends Container { this.imageSpacers = []; if (this.result) { - const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; + const imageBlocks = this.result.content.filter((c) => c.type === "image"); const caps = getCapabilities(); - for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; if (caps.images && this.showImages && img.data && img.mimeType) { - // Use converted PNG for Kitty protocol if available const converted = this.convertedImages.get(i); const imageData = converted?.data ?? img.data; const imageMimeType = converted?.mimeType ?? img.mimeType; - - // For Kitty, skip non-PNG images that haven't been converted yet - if (caps.images === "kitty" && imageMimeType !== "image/png") { - continue; - } + if (caps.images === "kitty" && imageMimeType !== "image/png") continue; const spacer = new Spacer(1); this.addChild(spacer); @@ -538,428 +312,25 @@ export class ToolExecutionComponent extends Container { } } - if (!useBuiltInRenderer && this.toolDefinition) { - this.hideComponent = !customRendererHasContent && this.imageComponents.length === 0; - } - } - - /** - * Render bash content using visual line truncation (like bash-execution.ts) - */ - private renderBashContent(): void { - const command = str(this.args?.command); - const timeout = this.args?.timeout as number | undefined; - - // Header - const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; - const commandDisplay = - command === null ? theme.fg("error", "[invalid arg]") : command ? command : theme.fg("toolOutput", "..."); - this.contentBox.addChild( - new Text(theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix, 0, 0), - ); - - if (this.result) { - const output = this.getTextOutput().trim(); - - if (output) { - // Style each line for the output - const styledOutput = output - .split("\n") - .map((line) => theme.fg("toolOutput", line)) - .join("\n"); - - if (this.expanded) { - // Show all lines when expanded - this.contentBox.addChild(new Text(`\n${styledOutput}`, 0, 0)); - } else { - // Use visual line truncation when collapsed with width-aware caching - let cachedWidth: number | undefined; - let cachedLines: string[] | undefined; - let cachedSkipped: number | undefined; - - this.contentBox.addChild({ - render: (width: number) => { - if (cachedLines === undefined || cachedWidth !== width) { - const result = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width); - cachedLines = result.visualLines; - cachedSkipped = result.skippedCount; - cachedWidth = width; - } - if (cachedSkipped && cachedSkipped > 0) { - const hint = - theme.fg("muted", `... (${cachedSkipped} earlier lines,`) + - ` ${keyHint("app.tools.expand", "to expand")})`; - return ["", truncateToWidth(hint, width, "..."), ...cachedLines]; - } - // Add blank line for spacing (matches expanded case) - return ["", ...cachedLines]; - }, - invalidate: () => { - cachedWidth = undefined; - cachedLines = undefined; - cachedSkipped = undefined; - }, - }); - } - } - - // Truncation warnings - const truncation = this.result.details?.truncation; - const fullOutputPath = this.result.details?.fullOutputPath; - if (truncation?.truncated || fullOutputPath) { - const warnings: string[] = []; - if (fullOutputPath) { - warnings.push(`Full output: ${fullOutputPath}`); - } - if (truncation?.truncated) { - if (truncation.truncatedBy === "lines") { - warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); - } else { - warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, - ); - } - } - this.contentBox.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0)); - } - } - - const bashDurationMs = this.getBashDurationMs(); - if (bashDurationMs !== undefined) { - const label = this.isPartial ? "Elapsed" : "Took"; - this.contentBox.addChild( - new Text(`\n${theme.fg("muted", `${label} ${this.formatDuration(bashDurationMs)}`)}`, 0, 0), - ); + if (this.hasRendererDefinition() && !hasContent && this.imageComponents.length === 0) { + this.hideComponent = true; } } private getTextOutput(): string { - if (!this.result) return ""; - - const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || []; - const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; - - let output = textBlocks - .map((c: any) => { - // Use sanitizeBinaryOutput to handle binary data that crashes string-width - return sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, ""); - }) - .join("\n"); - - const caps = getCapabilities(); - if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) { - const imageIndicators = imageBlocks - .map((img: any) => { - const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined; - return imageFallback(img.mimeType, dims); - }) - .join("\n"); - output = output ? `${output}\n${imageIndicators}` : imageIndicators; - } - - return output; + return getRenderedTextOutput(this.result, this.showImages); } private formatToolExecution(): string { - let text = ""; - const invalidArg = theme.fg("error", "[invalid arg]"); - - if (this.toolName === "read") { - const rawPath = str(this.args?.file_path ?? this.args?.path); - const path = rawPath !== null ? shortenPath(rawPath) : null; - const offset = this.args?.offset; - const limit = this.args?.limit; - - let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); - if (offset !== undefined || limit !== undefined) { - const startLine = offset ?? 1; - const endLine = limit !== undefined ? startLine + limit - 1 : ""; - pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); - } - - text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`; - - if (this.result) { - const output = this.getTextOutput(); - const rawPath = str(this.args?.file_path ?? this.args?.path); - const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; - const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); - - const maxLines = this.expanded ? lines.length : 10; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - text += - "\n\n" + - displayLines - .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) - .join("\n"); - if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; - } - - const truncation = this.result.details?.truncation; - if (truncation?.truncated) { - if (truncation.firstLineExceedsLimit) { - text += - "\n" + - theme.fg( - "warning", - `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`, - ); - } else if (truncation.truncatedBy === "lines") { - text += - "\n" + - theme.fg( - "warning", - `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`, - ); - } else { - text += - "\n" + - theme.fg( - "warning", - `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`, - ); - } - } - } - } else if (this.toolName === "write") { - const rawPath = str(this.args?.file_path ?? this.args?.path); - const fileContent = str(this.args?.content); - const path = rawPath !== null ? shortenPath(rawPath) : null; - - text = - theme.fg("toolTitle", theme.bold("write")) + - " " + - (path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); - - if (fileContent === null) { - text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`; - } else if (fileContent) { - const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; - - let lines: string[]; - if (lang) { - const cache = this.writeHighlightCache; - if (cache && cache.lang === lang && cache.rawPath === rawPath && cache.rawContent === fileContent) { - lines = cache.highlightedLines; - } else { - const displayContent = normalizeDisplayText(fileContent); - const normalized = replaceTabs(displayContent); - lines = highlightCode(normalized, lang); - this.writeHighlightCache = { - rawPath, - lang, - rawContent: fileContent, - normalizedLines: normalized.split("\n"), - highlightedLines: lines, - }; - } - } else { - lines = normalizeDisplayText(fileContent).split("\n"); - this.writeHighlightCache = undefined; - } - - const totalLines = lines.length; - const maxLines = this.expanded ? lines.length : 10; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - text += - "\n\n" + - displayLines.map((line: string) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n"); - if (remaining > 0) { - text += - theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`) + - ` ${keyHint("app.tools.expand", "to expand")})`; - } - } - - // Show error if tool execution failed - if (this.result?.isError) { - const errorText = this.getTextOutput(); - if (errorText) { - text += `\n\n${theme.fg("error", errorText)}`; - } - } - } else if (this.toolName === "edit") { - const rawPath = str(this.args?.file_path ?? this.args?.path); - const path = rawPath !== null ? shortenPath(rawPath) : null; - - // Build path display, appending :line if we have diff info - let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); - const firstChangedLine = - (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview - ? this.editDiffPreview.firstChangedLine - : undefined) || - (this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined); - if (firstChangedLine) { - pathDisplay += theme.fg("warning", `:${firstChangedLine}`); - } - - text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; - - if (this.result?.isError) { - // Show error from result - const errorText = this.getTextOutput(); - if (errorText) { - text += `\n\n${theme.fg("error", errorText)}`; - } - } else if (this.result?.details?.diff) { - // Tool executed successfully - use the diff from result - // This takes priority over editDiffPreview which may have a stale error - // due to race condition (async preview computed after file was modified) - text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath ?? undefined })}`; - } else if (this.editDiffPreview) { - // Use cached diff preview (before tool executes) - if ("error" in this.editDiffPreview) { - text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`; - } else if (this.editDiffPreview.diff) { - text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath ?? undefined })}`; - } - } - } else if (this.toolName === "ls") { - const rawPath = str(this.args?.path); - const path = rawPath !== null ? shortenPath(rawPath || ".") : null; - const limit = this.args?.limit; - - text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`; - if (limit !== undefined) { - text += theme.fg("toolOutput", ` (limit ${limit})`); - } - - if (this.result) { - const output = this.getTextOutput().trim(); - if (output) { - const lines = output.split("\n"); - const maxLines = this.expanded ? lines.length : 20; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; - if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; - } - } - - const entryLimit = this.result.details?.entryLimitReached; - const truncation = this.result.details?.truncation; - if (entryLimit || truncation?.truncated) { - const warnings: string[] = []; - if (entryLimit) { - warnings.push(`${entryLimit} entries limit`); - } - if (truncation?.truncated) { - warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); - } - text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; - } - } - } else if (this.toolName === "find") { - const pattern = str(this.args?.pattern); - const rawPath = str(this.args?.path); - const path = rawPath !== null ? shortenPath(rawPath || ".") : null; - const limit = this.args?.limit; - - text = - theme.fg("toolTitle", theme.bold("find")) + - " " + - (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) + - theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); - if (limit !== undefined) { - text += theme.fg("toolOutput", ` (limit ${limit})`); - } - - if (this.result) { - const output = this.getTextOutput().trim(); - if (output) { - const lines = output.split("\n"); - const maxLines = this.expanded ? lines.length : 20; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; - if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; - } - } - - const resultLimit = this.result.details?.resultLimitReached; - const truncation = this.result.details?.truncation; - if (resultLimit || truncation?.truncated) { - const warnings: string[] = []; - if (resultLimit) { - warnings.push(`${resultLimit} results limit`); - } - if (truncation?.truncated) { - warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); - } - text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; - } - } - } else if (this.toolName === "grep") { - const pattern = str(this.args?.pattern); - const rawPath = str(this.args?.path); - const path = rawPath !== null ? shortenPath(rawPath || ".") : null; - const glob = str(this.args?.glob); - const limit = this.args?.limit; - - text = - theme.fg("toolTitle", theme.bold("grep")) + - " " + - (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) + - theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); - if (glob) { - text += theme.fg("toolOutput", ` (${glob})`); - } - if (limit !== undefined) { - text += theme.fg("toolOutput", ` limit ${limit}`); - } - - if (this.result) { - const output = this.getTextOutput().trim(); - if (output) { - const lines = output.split("\n"); - const maxLines = this.expanded ? lines.length : 15; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; - if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; - } - } - - const matchLimit = this.result.details?.matchLimitReached; - const truncation = this.result.details?.truncation; - const linesTruncated = this.result.details?.linesTruncated; - if (matchLimit || truncation?.truncated || linesTruncated) { - const warnings: string[] = []; - if (matchLimit) { - warnings.push(`${matchLimit} matches limit`); - } - if (truncation?.truncated) { - warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); - } - if (linesTruncated) { - warnings.push("some lines truncated"); - } - text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; - } - } - } else { - // Generic tool (shouldn't reach here for custom tools) - text = theme.fg("toolTitle", theme.bold(this.toolName)); - - const content = JSON.stringify(this.args, null, 2); + let text = theme.fg("toolTitle", theme.bold(this.toolName)); + const content = JSON.stringify(this.args, null, 2); + if (content) { text += `\n\n${content}`; - const output = this.getTextOutput(); - if (output) { - text += `\n${output}`; - } } - + const output = this.getTextOutput(); + if (output) { + text += `\n${output}`; + } return text; } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f42ab501..c35705c3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1181,9 +1181,7 @@ export class InteractiveMode { * Get a registered tool definition by name (for custom rendering). */ private getRegisteredToolDefinition(toolName: string) { - const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? []; - const registeredTool = tools.find((t) => t.definition.name === toolName); - return registeredTool?.definition; + return this.session.getToolDefinition(toolName); } /** @@ -2198,6 +2196,7 @@ export class InteractiveMode { if (!this.pendingTools.has(content.id)) { const component = new ToolExecutionComponent( content.name, + content.id, content.arguments, { showImages: this.settingsManager.getShowImages(), @@ -2264,6 +2263,7 @@ export class InteractiveMode { if (!component) { component = new ToolExecutionComponent( event.toolName, + event.toolCallId, event.args, { showImages: this.settingsManager.getShowImages(), @@ -2568,6 +2568,7 @@ export class InteractiveMode { if (content.type === "toolCall") { const component = new ToolExecutionComponent( content.name, + content.id, content.arguments, { showImages: this.settingsManager.getShowImages() }, this.getRegisteredToolDefinition(content.name), diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 65b0e655..ce60df9a 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -25,8 +25,14 @@ describe("buildSystemPrompt", () => { }); describe("default tools", () => { - test("includes all default tools", () => { + test("includes all default tools when snippets are provided", () => { const prompt = buildSystemPrompt({ + toolSnippets: { + read: "Read file contents", + bash: "Execute bash commands", + edit: "Make surgical edits", + write: "Create or overwrite files", + }, contextFiles: [], skills: [], }); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 20dfd6ff..658b447f 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -3,13 +3,16 @@ 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 { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js"; +import { createReadToolDefinition } from "../src/core/tools/read.js"; +import { createWriteToolDefinition } from "../src/core/tools/write.js"; import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js"; import { initTheme } from "../src/modes/interactive/theme/theme.js"; -function createBaseToolDefinition(): ToolDefinition { +function createBaseToolDefinition(name = "custom_tool"): ToolDefinition { return { - name: "custom_tool", - label: "custom_tool", + name, + label: name, description: "custom tool", parameters: Type.Any(), execute: async () => ({ @@ -25,48 +28,235 @@ function createFakeTui(): TUI { } as unknown as TUI; } -describe("ToolExecutionComponent custom renderer suppression", () => { +describe("ToolExecutionComponent parity", () => { beforeAll(() => { initTheme("dark"); }); - test("renders no lines when custom renderers return undefined", () => { + test("stacks custom call and result renderers like the old implementation", () => { const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), - renderCall: () => undefined, - renderResult: () => undefined, + renderCall: () => new Text("custom call", 0, 0), + renderResult: () => new Text("custom result", 0, 0), }; - const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui()); - expect(component.render(120)).toEqual([]); + const component = new ToolExecutionComponent("custom_tool", "tool-1", {}, {}, toolDefinition, createFakeTui()); + expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call"); component.updateResult( { - content: [{ type: "text", text: "hidden" }], + content: [{ type: "text", text: "done" }], 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"); + expect(rendered).toContain("custom result"); + }); + + test("uses built-in rendering for built-in overrides without custom renderers", () => { + const overrideDefinition: ToolDefinition = { + ...createBaseToolDefinition("edit"), + }; + + const component = new ToolExecutionComponent( + "edit", + "tool-2", + { path: "README.md", oldText: "before", newText: "after" }, + {}, + overrideDefinition, + createFakeTui(), + ); + component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false }); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("edit"); + expect(rendered).toContain("README.md"); + expect(rendered).not.toContain(":1"); + }); + + test("preserves legacy file_path rendering compatibility for built-in tools", () => { + const component = new ToolExecutionComponent( + "read", + "tool-3", + { file_path: "README.md" }, + {}, + undefined, + createFakeTui(), + ); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("read"); + expect(rendered).toContain("README.md"); + }); + + test("bash execute emits an initial empty partial update before output arrives", async () => { + const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = []; + const operations: BashOperations = { + exec: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { exitCode: 0 }; + }, + }; + const tool = createBashToolDefinition(process.cwd(), { operations }); + const promise = tool.execute( + "tool-bash-1", + { command: "sleep 10" }, + undefined, + (update) => updates.push(update as { content: Array<{ type: string; text?: string }>; details?: unknown }), + {} as never, + ); + expect(updates).toEqual([{ content: [], details: undefined }]); + await promise; + }); + + test("does not duplicate built-in headers when passed the active built-in definition", () => { + const component = new ToolExecutionComponent( + "read", + "tool-4", + { path: "README.md" }, + {}, + createReadToolDefinition(process.cwd()), + createFakeTui(), + ); + component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered.match(/\bread\b/g)?.length ?? 0).toBe(1); + }); + + test("inherits missing built-in result renderer slot from the built-in tool", () => { + const overrideDefinition: ToolDefinition = { + ...createBaseToolDefinition("read"), + renderCall: () => new Text("override call", 0, 0), + }; + + const component = new ToolExecutionComponent( + "read", + "tool-4b", + { path: "README.md" }, + {}, + overrideDefinition, + createFakeTui(), + ); + component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("override call"); + expect(rendered).toContain("hello"); + }); + + test("inherits missing built-in call renderer slot from the built-in tool", () => { + const overrideDefinition: ToolDefinition = { + ...createBaseToolDefinition("read"), + renderResult: () => new Text("override result", 0, 0), + }; + + const component = new ToolExecutionComponent( + "read", + "tool-4c", + { path: "README.md" }, + {}, + overrideDefinition, + createFakeTui(), + ); + component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("read"); + expect(rendered).toContain("README.md"); + expect(rendered).toContain("override result"); + }); + + test("shares renderer state across custom call and result slots", () => { + type RenderState = { token?: string }; + const toolDefinition: ToolDefinition = { + ...createBaseToolDefinition(), + renderCall: (_args, _theme, context) => { + context.state.token ??= "shared-token"; + return new Text(`custom call ${context.state.token}`, 0, 0); + }, + renderResult: (_result, _options, _theme, context) => { + return new Text(`custom result ${context.state.token}`, 0, 0); + }, + }; + + const component = new ToolExecutionComponent("custom_tool", "tool-5", {}, {}, toolDefinition, createFakeTui()); + component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("custom call shared-token"); + expect(rendered).toContain("custom result shared-token"); + }); + + test("exposes args in render result context", () => { + const toolDefinition: ToolDefinition = { + ...createBaseToolDefinition(), + renderCall: () => new Text("call", 0, 0), + renderResult: (_result, _options, _theme, context) => + new Text(`arg:${String((context.args as { foo: string }).foo)}`, 0, 0), + }; + + const component = new ToolExecutionComponent( + "custom_tool", + "tool-5b", + { foo: "bar" }, + {}, + toolDefinition, + createFakeTui(), + ); + component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("arg:bar"); + }); + + test("falls back when custom renderers are absent", () => { + const toolDefinition: ToolDefinition = { + ...createBaseToolDefinition(), + }; + + const component = new ToolExecutionComponent( + "custom_tool", + "tool-6", + { foo: "bar" }, + {}, + toolDefinition, + createFakeTui(), + ); + component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("custom_tool"); + expect(rendered).toContain("done"); + }); + + test("trims trailing blank display lines from write previews", () => { + const component = new ToolExecutionComponent( + "write", + "tool-7", + { path: "README.md", content: "one\ntwo\n" }, + {}, + createWriteToolDefinition(process.cwd()), + createFakeTui(), + ); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("one"); + expect(rendered).toContain("two"); + expect(rendered).not.toContain("two\n\n"); + }); + + test("trims trailing blank display lines from read results", () => { + const component = new ToolExecutionComponent( + "read", + "tool-8", + { path: "README.md" }, + {}, + createReadToolDefinition(process.cwd()), + createFakeTui(), + ); + component.updateResult( + { content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false }, + false, + ); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).toContain("one"); + expect(rendered).toContain("two"); + expect(rendered).not.toContain("two\n\n"); }); }); diff --git a/scripts/edit-tool-stats.mjs b/scripts/edit-tool-stats.mjs new file mode 100644 index 00000000..2c2c7cea --- /dev/null +++ b/scripts/edit-tool-stats.mjs @@ -0,0 +1,834 @@ +#!/usr/bin/env node + +import { createReadStream } from "node:fs"; +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline"; + +const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions"); +const DEFAULT_ACTIVE_EDIT_EXTENSION_PATH = path.join(homedir(), ".pi/agent/extensions/edit.ts"); +const DEFAULT_TOP = 20; + +function parseArgs(argv) { + const options = { + sessionsDir: DEFAULT_SESSIONS_DIR, + json: false, + includeRecords: false, + failedOnly: false, + modelFilter: undefined, + extFilter: undefined, + top: DEFAULT_TOP, + help: false, + allSessions: false, + since: undefined, + autoSincePath: DEFAULT_ACTIVE_EDIT_EXTENSION_PATH, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + options.help = true; + } else if (arg === "--json") { + options.json = true; + } else if (arg === "--include-records") { + options.includeRecords = true; + } else if (arg === "--failed-only") { + options.failedOnly = true; + } else if (arg === "--model") { + options.modelFilter = argv[++i]; + } else if (arg === "--ext") { + options.extFilter = argv[++i]?.toLowerCase(); + } else if (arg === "--top") { + const value = Number.parseInt(argv[++i] ?? "", 10); + if (!Number.isFinite(value) || value <= 0) { + throw new Error("--top must be a positive integer"); + } + options.top = value; + } else if (arg === "--sessions-dir") { + options.sessionsDir = argv[++i]; + } else if (arg === "--all-sessions") { + options.allSessions = true; + } else if (arg === "--since") { + options.since = argv[++i]; + } else if (arg === "--auto-since-path") { + options.autoSincePath = argv[++i]; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/edit-tool-stats.mjs [options] + +Options: + --sessions-dir Sessions directory (default: ~/.pi/agent/sessions) + --model Filter provider/model by substring + --ext Filter by file extension, e.g. .ts + --failed-only Include only failed edit calls + --top Number of examples to show (default: ${DEFAULT_TOP}) + --since Only scan session files created at or after this ISO time + --all-sessions Disable the automatic since filter + --auto-since-path

Use birth time of this file for the automatic since filter + --json Print JSON summary instead of human report + --include-records Include raw records in JSON output + -h, --help Show this help +`); +} + +function parseSessionFileTimestamp(sessionFile) { + const base = path.basename(sessionFile); + const rawTimestamp = base.split("_")[0]; + if (!rawTimestamp) return null; + const isoTimestamp = rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z"); + const ms = Date.parse(isoTimestamp); + if (!Number.isFinite(ms)) return null; + return ms; +} + +function formatIso(ms) { + return new Date(ms).toISOString(); +} + +async function resolveAutoSinceMs(options) { + if (options.allSessions) return null; + if (options.since) { + const ms = Date.parse(options.since); + if (!Number.isFinite(ms)) { + throw new Error(`Invalid --since value: ${options.since}`); + } + return { ms, source: `--since ${options.since}` }; + } + if (!options.autoSincePath) return null; + try { + const stats = await fs.stat(options.autoSincePath); + const birthtimeMs = Number.isFinite(stats.birthtimeMs) && stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.mtimeMs; + if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0) return null; + return { ms: birthtimeMs, source: `birth time of ${options.autoSincePath}` }; + } catch { + return null; + } +} + +async function* walkJsonlFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walkJsonlFiles(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + yield fullPath; + } + } +} + +function getPathExtension(filePath) { + if (typeof filePath !== "string" || filePath.length === 0) return "[unknown]"; + const ext = path.extname(filePath).toLowerCase(); + if (ext) return ext; + const base = path.basename(filePath); + if (base.startsWith(".") && !base.slice(1).includes(".")) return base.toLowerCase(); + return "[no_ext]"; +} + +function utf8Bytes(value) { + return Buffer.byteLength(value ?? "", "utf8"); +} + +function longestCommonPrefixLength(a, b) { + const max = Math.min(a.length, b.length); + let index = 0; + while (index < max && a[index] === b[index]) index++; + return index; +} + +function longestCommonSuffixLength(a, b) { + const max = Math.min(a.length, b.length); + let index = 0; + while (index < max && a[a.length - 1 - index] === b[b.length - 1 - index]) index++; + return index; +} + +function analyzeReplacement(oldText, newText) { + const prefixChars = longestCommonPrefixLength(oldText, newText); + const oldRemainder = oldText.slice(prefixChars); + const newRemainder = newText.slice(prefixChars); + const suffixChars = longestCommonSuffixLength(oldRemainder, newRemainder); + const oldCore = suffixChars > 0 ? oldRemainder.slice(0, -suffixChars) : oldRemainder; + const newCore = suffixChars > 0 ? newRemainder.slice(0, -suffixChars) : newRemainder; + const prefix = oldText.slice(0, prefixChars); + const suffix = suffixChars > 0 ? oldRemainder.slice(-suffixChars) : ""; + + const oldBytes = utf8Bytes(oldText); + const newBytes = utf8Bytes(newText); + const sharedPrefixBytes = utf8Bytes(prefix); + const sharedSuffixBytes = utf8Bytes(suffix); + const sharedContextBytes = sharedPrefixBytes + sharedSuffixBytes; + const coreOldBytes = utf8Bytes(oldCore); + const coreNewBytes = utf8Bytes(newCore); + const coreBytes = coreOldBytes + coreNewBytes; + const totalEditBytes = oldBytes + newBytes; + const wrapperPayloadBytes = totalEditBytes - coreBytes; + const inflationRatio = coreBytes === 0 ? null : totalEditBytes / coreBytes; + + return { + oldBytes, + newBytes, + totalEditBytes, + sharedPrefixBytes, + sharedSuffixBytes, + sharedContextBytes, + coreOldBytes, + coreNewBytes, + coreBytes, + wrapperPayloadBytes, + inflationRatio, + noCoreChange: coreBytes === 0, + }; +} + +function median(numbers) { + return quantile(numbers, 0.5); +} + +function quantile(numbers, q) { + const finite = numbers.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); + if (finite.length === 0) return null; + if (finite.length === 1) return finite[0]; + const position = (finite.length - 1) * q; + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return finite[lower]; + const weight = position - lower; + return finite[lower] * (1 - weight) + finite[upper] * weight; +} + +function formatInt(value) { + return new Intl.NumberFormat("en-US").format(value); +} + +function formatPercent(part, total) { + if (total === 0) return "n/a"; + return `${((part / total) * 100).toFixed(1)}%`; +} + +function formatRatio(value) { + if (value === null) return "no-core-change"; + if (!Number.isFinite(value)) return "∞"; + if (value >= 100) return `${value.toFixed(0)}x`; + if (value >= 10) return `${value.toFixed(1)}x`; + return `${value.toFixed(2)}x`; +} + +function formatBytes(value) { + if (value < 1024) return `${value}B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(value >= 10 * 1024 ? 0 : 1)}KB`; + return `${(value / (1024 * 1024)).toFixed(1)}MB`; +} + +function extractTextContent(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((block) => block?.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join("\n"); +} + +function classifyErrorKind(text, isError, matchedResult) { + if (!matchedResult) return "missing_result"; + if (!isError) return null; + const normalized = text.toLowerCase(); + if (normalized.includes("file not found")) return "file_not_found"; + if (normalized.includes("could not find the exact text")) return "not_found_exact_text"; + if (normalized.includes("found multiple occurrences") || /^found \d+ occurrences/m.test(normalized)) { + return "multiple_occurrences"; + } + if (normalized.includes("no changes made")) return "no_changes_made"; + if (normalized.includes("input is invalid")) return "invalid_input"; + if (normalized.includes("must not overlap")) return "overlapping_edits"; + if (normalized.includes("aborted")) return "aborted"; + return "other"; +} + +function getArgStyle(args) { + const hasEdits = Array.isArray(args?.edits); + const hasOldText = typeof args?.oldText === "string" || typeof args?.newText === "string"; + const hasOldString = typeof args?.old_string === "string" || typeof args?.new_string === "string"; + if (hasEdits) return "edits"; + if (hasOldText && hasOldString) return "mixed"; + if (hasOldText) return "oldText/newText"; + if (hasOldString) return "old_string/new_string"; + return "unknown"; +} + +function analyzeToolArguments(args) { + const normalizedArgs = args && typeof args === "object" ? args : {}; + const filePath = typeof normalizedArgs.path === "string" ? normalizedArgs.path : ""; + const extension = getPathExtension(filePath); + const argStyle = getArgStyle(normalizedArgs); + + if (Array.isArray(normalizedArgs.edits)) { + const perEdit = normalizedArgs.edits.map((edit) => + analyzeReplacement(typeof edit?.oldText === "string" ? edit.oldText : "", typeof edit?.newText === "string" ? edit.newText : "") + ); + const inflations = perEdit.map((edit) => edit.inflationRatio).filter((value) => value !== null); + const totals = perEdit.reduce( + (acc, edit) => ({ + oldBytes: acc.oldBytes + edit.oldBytes, + newBytes: acc.newBytes + edit.newBytes, + totalEditBytes: acc.totalEditBytes + edit.totalEditBytes, + sharedPrefixBytes: acc.sharedPrefixBytes + edit.sharedPrefixBytes, + sharedSuffixBytes: acc.sharedSuffixBytes + edit.sharedSuffixBytes, + sharedContextBytes: acc.sharedContextBytes + edit.sharedContextBytes, + coreOldBytes: acc.coreOldBytes + edit.coreOldBytes, + coreNewBytes: acc.coreNewBytes + edit.coreNewBytes, + coreBytes: acc.coreBytes + edit.coreBytes, + wrapperPayloadBytes: acc.wrapperPayloadBytes + edit.wrapperPayloadBytes, + noCoreChangeCount: acc.noCoreChangeCount + (edit.noCoreChange ? 1 : 0), + }), + { + oldBytes: 0, + newBytes: 0, + totalEditBytes: 0, + sharedPrefixBytes: 0, + sharedSuffixBytes: 0, + sharedContextBytes: 0, + coreOldBytes: 0, + coreNewBytes: 0, + coreBytes: 0, + wrapperPayloadBytes: 0, + noCoreChangeCount: 0, + } + ); + return { + path: filePath, + extension, + mode: "multi", + argStyle, + editsCount: normalizedArgs.edits.length, + ...totals, + inflationRatio: totals.coreBytes === 0 ? null : totals.totalEditBytes / totals.coreBytes, + medianEditInflationRatio: median(inflations), + maxEditInflationRatio: inflations.length > 0 ? Math.max(...inflations) : null, + perEdit, + }; + } + + const oldText = typeof normalizedArgs.oldText === "string" ? normalizedArgs.oldText : typeof normalizedArgs.old_string === "string" ? normalizedArgs.old_string : ""; + const newText = typeof normalizedArgs.newText === "string" ? normalizedArgs.newText : typeof normalizedArgs.new_string === "string" ? normalizedArgs.new_string : ""; + const replacement = analyzeReplacement(oldText, newText); + return { + path: filePath, + extension, + mode: "single", + argStyle, + editsCount: 1, + ...replacement, + medianEditInflationRatio: replacement.inflationRatio, + maxEditInflationRatio: replacement.inflationRatio, + perEdit: [replacement], + }; +} + +function groupCounts(records, keyFn) { + const counts = new Map(); + for (const record of records) { + const key = keyFn(record); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +} + +function collectInflations(records) { + return records.map((record) => record.inflationRatio).filter((value) => value !== null); +} + +function summarizeGroups(records, keyFn) { + const groups = new Map(); + for (const record of records) { + const key = keyFn(record); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(record); + } + + return [...groups.entries()] + .map(([key, group]) => { + const resolved = group.filter((record) => record.success !== null); + const success = resolved.filter((record) => record.success).length; + const failed = resolved.filter((record) => record.success === false).length; + const multi = group.filter((record) => record.mode === "multi").length; + const inflations = collectInflations(group); + return { + key, + calls: group.length, + resolved: resolved.length, + success, + failed, + unresolved: group.length - resolved.length, + multi, + multiRate: group.length === 0 ? null : multi / group.length, + successRate: resolved.length === 0 ? null : success / resolved.length, + medianInflation: quantile(inflations, 0.5), + p95Inflation: quantile(inflations, 0.95), + }; + }) + .sort((a, b) => b.calls - a.calls || a.key.localeCompare(b.key)); +} + +function buildSameFileClusterStats(records) { + const groups = new Map(); + for (const record of records) { + const key = `${record.sessionFile}::${record.assistantEntryId}::${record.path}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(record); + } + + const clusters = [...groups.values()].filter((group) => group.length >= 2); + const assistantMessagesWithCluster = new Set(clusters.map((group) => `${group[0].sessionFile}::${group[0].assistantEntryId}`)); + const assistantMessagesWithMultiEdit = new Set( + records + .filter((record) => record.mode === "multi" && record.editsCount > 1) + .map((record) => `${record.sessionFile}::${record.assistantEntryId}`) + ); + const callsInsideClusters = clusters.reduce((sum, group) => sum + group.length, 0); + + return { + clustersCount: clusters.length, + assistantMessagesWithCluster: assistantMessagesWithCluster.size, + assistantMessagesWithMultiEdit: assistantMessagesWithMultiEdit.size, + callsInsideClusters, + averageCallsPerCluster: clusters.length === 0 ? null : callsInsideClusters / clusters.length, + ratioClusterAssistantMessagesToMultiEditAssistantMessages: + assistantMessagesWithMultiEdit.size === 0 ? null : assistantMessagesWithCluster.size / assistantMessagesWithMultiEdit.size, + }; +} + +function buildInflationBuckets(records) { + const buckets = [ + { key: "no_core_change", label: "no-core-change", test: (record) => record.inflationRatio === null }, + { key: "lt4", label: "<4x", test: (record) => record.inflationRatio !== null && record.inflationRatio < 4 }, + { key: "4to10", label: "4-10x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 4 && record.inflationRatio < 10 }, + { key: "10to25", label: "10-25x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 10 && record.inflationRatio < 25 }, + { key: "gte25", label: "25x+", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 25 }, + ]; + + return buckets.map((bucket) => { + const bucketRecords = records.filter(bucket.test); + const resolved = bucketRecords.filter((record) => record.success !== null); + const failed = resolved.filter((record) => record.success === false).length; + return { + key: bucket.key, + label: bucket.label, + count: bucketRecords.length, + resolved: resolved.length, + failed, + failureRate: resolved.length === 0 ? null : failed / resolved.length, + }; + }); +} + +function buildHugeReplacementStats(records) { + const thresholds = [1024, 4096, 16384, 65536]; + return thresholds.map((threshold) => ({ + threshold, + count: records.filter((record) => record.totalEditBytes > threshold).length, + })); +} + +function buildWorstExamples(records, top) { + const scored = [...records].sort((a, b) => { + const aScore = a.inflationRatio === null ? Number.POSITIVE_INFINITY : a.inflationRatio; + const bScore = b.inflationRatio === null ? Number.POSITIVE_INFINITY : b.inflationRatio; + if (aScore !== bScore) return bScore - aScore; + if (a.totalEditBytes !== b.totalEditBytes) return b.totalEditBytes - a.totalEditBytes; + return a.path.localeCompare(b.path); + }); + + return scored.slice(0, top).map((record) => ({ + providerModel: record.providerModel, + extension: record.extension, + path: record.path, + inflationRatio: record.inflationRatio, + totalEditBytes: record.totalEditBytes, + coreBytes: record.coreBytes, + mode: record.mode, + editsCount: record.editsCount, + failed: record.success === false, + errorKind: record.errorKind, + sessionFile: record.sessionFile, + })); +} + +function buildSummary(records, meta, options) { + const uniqueAssistantMessages = new Set(records.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)).size; + const resolved = records.filter((record) => record.success !== null); + const success = resolved.filter((record) => record.success).length; + const failed = resolved.filter((record) => record.success === false).length; + const unresolved = records.length - resolved.length; + const single = records.filter((record) => record.mode === "single").length; + const multi = records.filter((record) => record.mode === "multi").length; + const modeStats = ["single", "multi"].map((mode) => { + const modeRecords = records.filter((record) => record.mode === mode); + const modeResolved = modeRecords.filter((record) => record.success !== null); + const modeSuccess = modeResolved.filter((record) => record.success).length; + const modeFailed = modeResolved.filter((record) => record.success === false).length; + return { + mode, + calls: modeRecords.length, + resolved: modeResolved.length, + success: modeSuccess, + failed: modeFailed, + unresolved: modeRecords.length - modeResolved.length, + successRate: modeResolved.length === 0 ? null : modeSuccess / modeResolved.length, + failureRate: modeResolved.length === 0 ? null : modeFailed / modeResolved.length, + }; + }); + const multiEditLengthBuckets = [ + { key: "1", label: "edits.length === 1", test: (record) => record.mode === "multi" && record.editsCount === 1 }, + { key: "2", label: "edits.length === 2", test: (record) => record.mode === "multi" && record.editsCount === 2 }, + { key: "3plus", label: "edits.length >= 3", test: (record) => record.mode === "multi" && record.editsCount >= 3 }, + ].map((bucket) => { + const bucketRecords = records.filter(bucket.test); + const bucketResolved = bucketRecords.filter((record) => record.success !== null); + const bucketSuccess = bucketResolved.filter((record) => record.success).length; + const bucketFailed = bucketResolved.filter((record) => record.success === false).length; + return { + key: bucket.key, + label: bucket.label, + calls: bucketRecords.length, + resolved: bucketResolved.length, + success: bucketSuccess, + failed: bucketFailed, + unresolved: bucketRecords.length - bucketResolved.length, + successRate: bucketResolved.length === 0 ? null : bucketSuccess / bucketResolved.length, + failureRate: bucketResolved.length === 0 ? null : bucketFailed / bucketResolved.length, + }; + }); + const argStyles = [...groupCounts(records, (record) => record.argStyle).entries()] + .map(([style, count]) => ({ style, count })) + .sort((a, b) => b.count - a.count || a.style.localeCompare(b.style)); + const providerStats = summarizeGroups(records, (record) => record.providerModel); + const extensionStats = summarizeGroups(records, (record) => record.extension); + const inflations = collectInflations(records); + const noCoreChange = records.filter((record) => record.inflationRatio === null).length; + const pathologicalThresholds = [10, 25, 100].map((threshold) => ({ + threshold, + count: records.filter((record) => record.inflationRatio !== null && record.inflationRatio >= threshold).length, + })); + const failureKinds = [...groupCounts(records.filter((record) => record.success === false), (record) => record.errorKind ?? "other").entries()] + .map(([kind, count]) => ({ kind, count })) + .sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind)); + + return { + filters: { + model: options.modelFilter ?? null, + extension: options.extFilter ?? null, + failedOnly: options.failedOnly, + }, + scan: { + sessionsDir: meta.sessionsDir, + sessionFilesScanned: meta.sessionFilesScanned, + sessionFilesIncluded: meta.sessionFilesIncluded, + sessionFilesSkippedOlderThanSince: meta.sessionFilesSkippedOlderThanSince, + sessionFilesWithEditCalls: meta.sessionFilesWithEditCalls, + since: meta.since ? { ms: meta.since.ms, iso: formatIso(meta.since.ms), source: meta.since.source } : null, + malformedLines: meta.malformedLines, + unmatchedToolResults: meta.unmatchedToolResults, + }, + counts: { + assistantMessagesWithEditCalls: uniqueAssistantMessages, + totalEditCalls: records.length, + resolvedEditCalls: resolved.length, + success, + failed, + unresolved, + single, + multi, + noCoreChange, + }, + modeStats, + multiEditLengthBuckets, + argStyles, + providerStats, + extensionStats, + inflation: { + median: quantile(inflations, 0.5), + p90: quantile(inflations, 0.9), + p95: quantile(inflations, 0.95), + p99: quantile(inflations, 0.99), + pathologicalThresholds, + hugeReplacements: buildHugeReplacementStats(records), + failureByBucket: buildInflationBuckets(records), + }, + sameFileClusters: buildSameFileClusterStats(records), + failureKinds, + worstExamples: buildWorstExamples(records, options.top), + }; +} + +function printGroupTable(title, groups, formatter) { + if (groups.length === 0) return; + console.log(`\n${title}`); + for (const group of groups) { + console.log(formatter(group)); + } +} + +function printHumanReport(summary) { + const { scan, counts, modeStats, multiEditLengthBuckets, argStyles, providerStats, extensionStats, inflation, sameFileClusters, failureKinds, worstExamples, filters } = summary; + console.log(`Scanned ${formatInt(scan.sessionFilesIncluded)} session files in ${scan.sessionsDir}`); + if (scan.since) { + console.log(`Session filter: files created at or after ${scan.since.iso} (${scan.since.source})`); + console.log(`Skipped older session files: ${formatInt(scan.sessionFilesSkippedOlderThanSince)} of ${formatInt(scan.sessionFilesScanned)}`); + } + console.log(`Found ${formatInt(counts.totalEditCalls)} edit tool calls in ${formatInt(counts.assistantMessagesWithEditCalls)} assistant messages`); + if (filters.model || filters.extension || filters.failedOnly) { + const filterParts = []; + if (filters.model) filterParts.push(`model contains \"${filters.model}\"`); + if (filters.extension) filterParts.push(`extension = ${filters.extension}`); + if (filters.failedOnly) filterParts.push("failed only"); + console.log(`Filters: ${filterParts.join(", ")}`); + } + + console.log("\nSuccess rate"); + console.log(` success: ${formatInt(counts.success)} ${formatPercent(counts.success, counts.resolvedEditCalls)}`); + console.log(` failed: ${formatInt(counts.failed)} ${formatPercent(counts.failed, counts.resolvedEditCalls)}`); + console.log(` unresolved: ${formatInt(counts.unresolved)}`); + + console.log("\nMode usage"); + console.log(` single replacement: ${formatInt(counts.single)} ${formatPercent(counts.single, counts.totalEditCalls)}`); + console.log(` multi-edit (edits): ${formatInt(counts.multi)} ${formatPercent(counts.multi, counts.totalEditCalls)}`); + + console.log("\nFailures by edit type"); + for (const mode of modeStats) { + console.log( + ` ${mode.mode.padEnd(6)} calls=${formatInt(mode.calls).padStart(4)} success=${mode.successRate === null ? "n/a" : formatPercent(mode.success, mode.resolved).padStart(6)} failed=${mode.failureRate === null ? "n/a" : formatPercent(mode.failed, mode.resolved).padStart(6)} unresolved=${formatInt(mode.unresolved)}` + ); + } + + console.log("\nMulti-edit bucket split"); + for (const bucket of multiEditLengthBuckets) { + console.log( + ` ${bucket.label.padEnd(20)} ${formatInt(bucket.calls).padStart(4)} calls success=${bucket.successRate === null ? "n/a" : formatPercent(bucket.success, bucket.resolved).padStart(6)} failed=${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)}` + ); + } + + console.log("\nArgument style"); + for (const entry of argStyles) { + console.log(` ${entry.style.padEnd(22)} ${formatInt(entry.count).padStart(8)} ${formatPercent(entry.count, counts.totalEditCalls)}`); + } + + printGroupTable("By provider/model", providerStats, (group) => { + return [ + ` ${group.key}`, + ` calls: ${formatInt(group.calls)}`, + ` success: ${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved)}`, + ` multi-edit: ${group.multiRate === null ? "n/a" : formatPercent(group.multi, group.calls)}`, + ` median inflation: ${formatRatio(group.medianInflation)}`, + ` p95 inflation: ${formatRatio(group.p95Inflation)}`, + ].join("\n"); + }); + + printGroupTable("By file extension", extensionStats, (group) => { + return ` ${group.key.padEnd(10)} calls=${formatInt(group.calls).padStart(6)} success=${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved).padStart(6)} medianInflation=${formatRatio(group.medianInflation)}`; + }); + + console.log("\nContext inflation"); + console.log(` median inflation: ${formatRatio(inflation.median)}`); + console.log(` p90 inflation: ${formatRatio(inflation.p90)}`); + console.log(` p95 inflation: ${formatRatio(inflation.p95)}`); + console.log(` p99 inflation: ${formatRatio(inflation.p99)}`); + console.log(` no-core-change: ${formatInt(counts.noCoreChange)}`); + + console.log("\nHuge replacements"); + for (const entry of inflation.hugeReplacements) { + console.log(` >${formatBytes(entry.threshold).padEnd(6)} ${formatInt(entry.count).padStart(8)}`); + } + + console.log("\nPathological wrappers"); + for (const entry of inflation.pathologicalThresholds) { + console.log(` inflation >= ${String(entry.threshold).padEnd(3)}x ${formatInt(entry.count).padStart(8)}`); + } + + console.log("\nSame-file multi-call behavior"); + console.log(` assistant msgs with 2+ edit calls to same file: ${formatInt(sameFileClusters.assistantMessagesWithCluster)}`); + console.log(` total same-file clusters: ${formatInt(sameFileClusters.clustersCount)}`); + console.log(` calls inside those clusters: ${formatInt(sameFileClusters.callsInsideClusters)}`); + console.log(` average calls per cluster: ${sameFileClusters.averageCallsPerCluster === null ? "n/a" : sameFileClusters.averageCallsPerCluster.toFixed(2)}`); + console.log(` assistant msgs using one multi-edit call: ${formatInt(sameFileClusters.assistantMessagesWithMultiEdit)}`); + console.log(` ratio multi-call / multi-edit assistant msgs: ${sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages === null ? "n/a" : sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages.toFixed(2)}`); + + console.log("\nFailures by kind"); + if (failureKinds.length === 0) { + console.log(" none"); + } else { + for (const failure of failureKinds) { + console.log(` ${failure.kind.padEnd(22)} ${formatInt(failure.count).padStart(8)}`); + } + } + + console.log("\nFailure rate by inflation bucket"); + for (const bucket of inflation.failureByBucket) { + console.log(` ${bucket.label.padEnd(14)} ${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)} (${formatInt(bucket.count)} calls)`); + } + + console.log(`\nWorst ${formatInt(worstExamples.length)} examples`); + for (let i = 0; i < worstExamples.length; i++) { + const example = worstExamples[i]; + console.log( + ` ${i + 1}. ${example.providerModel} ${example.extension} inflation=${formatRatio(example.inflationRatio)} failed=${example.failed ? example.errorKind : "false"}` + ); + console.log(` path: ${example.path}`); + console.log(` totalEditBytes=${formatBytes(example.totalEditBytes)} coreBytes=${formatBytes(example.coreBytes)} mode=${example.mode} edits=${example.editsCount}`); + } + + if (scan.malformedLines > 0 || scan.unmatchedToolResults > 0) { + console.log("\nParser notes"); + if (scan.malformedLines > 0) console.log(` malformed lines skipped: ${formatInt(scan.malformedLines)}`); + if (scan.unmatchedToolResults > 0) console.log(` unmatched edit tool results: ${formatInt(scan.unmatchedToolResults)}`); + } +} + +async function scanSessions(sessionsDir, since) { + const records = []; + const meta = { + sessionsDir, + sessionFilesScanned: 0, + sessionFilesIncluded: 0, + sessionFilesSkippedOlderThanSince: 0, + sessionFilesWithEditCalls: 0, + since, + malformedLines: 0, + unmatchedToolResults: 0, + }; + + for await (const sessionFile of walkJsonlFiles(sessionsDir)) { + meta.sessionFilesScanned++; + const sessionTimestampMs = parseSessionFileTimestamp(sessionFile); + if (since && sessionTimestampMs !== null && sessionTimestampMs < since.ms) { + meta.sessionFilesSkippedOlderThanSince++; + continue; + } + meta.sessionFilesIncluded++; + const pending = new Map(); + let fileHadEditCall = false; + const input = createReadStream(sessionFile, { encoding: "utf8" }); + const rl = createInterface({ input, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line.trim()) continue; + let entry; + try { + entry = JSON.parse(line); + } catch { + meta.malformedLines++; + continue; + } + + if (entry?.type !== "message" || !entry.message) continue; + const message = entry.message; + + if (message.role === "assistant" && Array.isArray(message.content)) { + for (const block of message.content) { + if (block?.type !== "toolCall" || block.name !== "edit") continue; + fileHadEditCall = true; + const analysis = analyzeToolArguments(block.arguments); + const record = { + sessionFile, + assistantEntryId: entry.id, + toolCallId: typeof block.id === "string" ? block.id : "", + timestamp: entry.timestamp, + api: typeof message.api === "string" ? message.api : null, + provider: typeof message.provider === "string" ? message.provider : "[unknown]", + model: typeof message.model === "string" ? message.model : "[unknown]", + providerModel: `${typeof message.provider === "string" ? message.provider : "[unknown]"}/${typeof message.model === "string" ? message.model : "[unknown]"}`, + success: null, + errorKind: null, + errorText: "", + resultSummary: "", + matchedResult: false, + ...analysis, + }; + records.push(record); + if (record.toolCallId) pending.set(record.toolCallId, record); + } + } + + if (message.role === "toolResult" && message.toolName === "edit") { + const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : ""; + const record = pending.get(toolCallId); + if (!record) { + meta.unmatchedToolResults++; + continue; + } + const text = extractTextContent(message.content); + record.matchedResult = true; + record.success = message.isError === true ? false : true; + record.resultSummary = text; + record.errorText = message.isError === true ? text : ""; + record.errorKind = classifyErrorKind(text, message.isError === true, true); + pending.delete(toolCallId); + } + } + + for (const record of pending.values()) { + record.matchedResult = false; + record.success = null; + record.errorKind = classifyErrorKind("", false, false); + } + + if (fileHadEditCall) meta.sessionFilesWithEditCalls++; + } + + return { records, meta }; +} + +function applyFilters(records, options) { + return records.filter((record) => { + if (options.modelFilter && !record.providerModel.toLowerCase().includes(options.modelFilter.toLowerCase())) { + return false; + } + if (options.extFilter && record.extension !== options.extFilter) { + return false; + } + if (options.failedOnly && record.success !== false) { + return false; + } + return true; + }); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const sessionsDir = path.resolve(options.sessionsDir); + await fs.access(sessionsDir); + const since = await resolveAutoSinceMs(options); + + const { records, meta } = await scanSessions(sessionsDir, since); + const filteredRecords = applyFilters(records, options); + const summary = buildSummary(filteredRecords, meta, options); + + if (options.json) { + const output = options.includeRecords ? { summary, records: filteredRecords } : { summary }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + printHumanReport(summary); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +});