From 0e5a6d850b12576ab7f7df2964d440093ad33daf Mon Sep 17 00:00:00 2001 From: Jeremy Nikolic Date: Tue, 14 Jul 2026 21:48:48 +0200 Subject: [PATCH 1/2] fix: render MCP tools via ToolExecutionComponent prototype patch MCP tool rendering broke on Pi 0.80.6+ for two independent reasons: 1. `pi.registerTool` is now a per-extension API object, so intercepting it on this extension's API no longer captures tools registered by other extensions (e.g. pi-mcp-adapter's direct tools). The interceptor only ever saw this extension's own registrations. 2. `pi.getAllTools()` returns shallow clones that omit `renderCall`, `renderResult`, and `label`, so the session_start/before_agent_start decoration sweep (`registerMcpToolOverrides`) mutated throwaway objects and never reached the live tool definitions in `_toolDefinitions`. As a result, MCP direct tools (and the `mcp` proxy tool) kept their original verbose renderers and `mcpOutputMode` had no effect on them. Built-in tools were unaffected because they are re-registered (not mutated). `ToolExecutionComponent` resolves the renderer per call via `getCallRenderer()` / `getResultRenderer()`, reading the LIVE `this.toolDefinition` (which still carries `label`). Patching those prototype methods lets pi-tool-display render MCP-candidate tools with its compact MCP renderers at render time, regardless of how the tool was registered or whether `getAllTools()` exposes renderers. This mirrors the existing `UserMessageComponent` prototype patch, and resolves against the same Pi instance via jiti's `@earendil-works/*` aliases. Built-in tools are left to the original resolver (guarded by `builtInToolDefinition` being undefined for MCP-only tools), so per-tool ownership and the diff/thinking renderers are unaffected. Tested on Pi 0.80.6 with pi-mcp-adapter direct tools (solo, linear) and the `mcp` proxy: call lines render as `MCP args`, and results honor `mcpOutputMode` (hidden/summary/preview) with Ctrl+O expansion. --- src/index.ts | 2 + src/tool-execution-patch.ts | 185 ++++++++++++++++++++++++++++++++++++ src/tool-overrides.ts | 6 +- 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/tool-execution-patch.ts diff --git a/src/index.ts b/src/index.ts index 3194dfa..ae41a9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { registerToolDisplayOverrides } from "./tool-overrides.js"; import { disposeAll, resetDisposed } from "./disposable.js"; import { registerThinkingLabeling } from "./thinking-label.js"; import registerNativeUserMessageBox from "./user-message-box-native.js"; +import registerToolExecutionMcpPatch from "./tool-execution-patch.js"; import { BUILT_IN_TOOL_OVERRIDE_NAMES, type ToolDisplayConfig, @@ -86,6 +87,7 @@ export default function toolDisplayExtension(pi: ExtensionAPI): void { registerToolDisplayOverrides(pi, getEffectiveConfig); registerNativeUserMessageBox(pi, getConfig); registerThinkingLabeling(pi); + registerToolExecutionMcpPatch(pi, getEffectiveConfig); pi.registerCommand("tool-display", { description: "Configure tool output rendering (OpenCode-style)", diff --git a/src/tool-execution-patch.ts b/src/tool-execution-patch.ts new file mode 100644 index 0000000..b404184 --- /dev/null +++ b/src/tool-execution-patch.ts @@ -0,0 +1,185 @@ +import { + type ExtensionAPI, + ToolExecutionComponent, +} from "@earendil-works/pi-coding-agent"; +import { getTextField, isMcpToolCandidate, toRecord } from "./tool-metadata.js"; +import { + type RenderTheme, + formatMcpCallLine, + renderMcpResult, +} from "./tool-overrides.js"; +import { onReloadShutdown } from "./extension-lifecycle.js"; +import type { ToolDisplayConfig } from "./types.js"; + +/** + * MCP tool rendering broke on Pi 0.80.6+ for two reasons: + * 1. `pi.registerTool` is now a per-extension API, so intercepting it on + * this extension's API no longer captures tools registered by other + * extensions (e.g. pi-mcp-adapter's direct tools). + * 2. `pi.getAllTools()` returns shallow clones that omit `renderCall`, + * `renderResult`, and `label`, so the session-start decoration sweep + * mutates throwaway objects and never reaches the live tool definitions. + * + * `ToolExecutionComponent` resolves the renderer per call via + * `getCallRenderer()` / `getResultRenderer()`, reading the LIVE + * `this.toolDefinition` (which still carries `label`). Patching those + * prototype methods lets us render MCP-candidate tools with pi-tool-display's + * compact renderers at render time, regardless of how the tool was registered + * or whether `getAllTools()` exposes renderers. This mirrors the existing + * `UserMessageComponent` prototype patch. + */ + +const PATCH_VERSION = 1; +const PATCH_OWNER = {}; + +type CallRenderer = ( + args: Record, + theme: RenderTheme, + context?: unknown, +) => unknown; + +type ResultRenderer = ( + result: { content?: unknown[]; details?: unknown }, + options: { expanded: boolean; isPartial: boolean }, + theme: RenderTheme, + context?: unknown, +) => unknown; + +interface ToolDefLike { + name?: string; + label?: string; + description?: string; + [key: string]: unknown; +} + +interface PatchableToolExecutionPrototype { + getCallRenderer: () => CallRenderer | undefined; + getResultRenderer: () => ResultRenderer | undefined; + __piToolDisplayOriginalGetCallRenderer?: () => CallRenderer | undefined; + __piToolDisplayOriginalGetResultRenderer?: () => ResultRenderer | undefined; + __piToolDisplayMcpPatchVersion?: number; + __piToolDisplayMcpPatchOwner?: object; + toolDefinition?: ToolDefLike; + builtInToolDefinition?: unknown; +} + +function getToolExecutionPrototype(): PatchableToolExecutionPrototype { + return ToolExecutionComponent.prototype as unknown as PatchableToolExecutionPrototype; +} + +function isMcpRenderCandidate(proto: PatchableToolExecutionPrototype): boolean { + const def = proto.toolDefinition; + if (!def) { + return false; + } + // Built-in tools are owned by pi-tool-display's own overrides; leave them to + // the original resolver so per-tool ownership and diff renderers still apply. + if (proto.builtInToolDefinition) { + return false; + } + return isMcpToolCandidate(def); +} + +function patchToolExecutionMcpRender( + getConfig: () => ToolDisplayConfig, +): void { + const proto = getToolExecutionPrototype(); + if ( + typeof proto.getCallRenderer !== "function" + || typeof proto.getResultRenderer !== "function" + ) { + return; + } + + const previousCall = proto.__piToolDisplayOriginalGetCallRenderer; + const previousResult = proto.__piToolDisplayOriginalGetResultRenderer; + const hasPreviousPatch = + typeof previousCall === "function" && previousCall !== proto.getCallRenderer; + const isCurrentPatch = proto.__piToolDisplayMcpPatchOwner === PATCH_OWNER; + + // Restore a stale patch left by a previous extension instance. + if (hasPreviousPatch && !isCurrentPatch && typeof previousCall === "function" && typeof previousResult === "function") { + proto.getCallRenderer = previousCall; + proto.getResultRenderer = previousResult; + delete proto.__piToolDisplayOriginalGetCallRenderer; + delete proto.__piToolDisplayOriginalGetResultRenderer; + delete proto.__piToolDisplayMcpPatchVersion; + delete proto.__piToolDisplayMcpPatchOwner; + } + + if ( + proto.__piToolDisplayMcpPatchVersion === PATCH_VERSION + && proto.__piToolDisplayMcpPatchOwner === PATCH_OWNER + && typeof proto.__piToolDisplayOriginalGetCallRenderer === "function" + ) { + return; + } + + if (!proto.__piToolDisplayOriginalGetCallRenderer) { + proto.__piToolDisplayOriginalGetCallRenderer = proto.getCallRenderer; + } + if (!proto.__piToolDisplayOriginalGetResultRenderer) { + proto.__piToolDisplayOriginalGetResultRenderer = proto.getResultRenderer; + } + + const originalGetCallRenderer = proto.__piToolDisplayOriginalGetCallRenderer; + const originalGetResultRenderer = proto.__piToolDisplayOriginalGetResultRenderer; + + proto.getCallRenderer = function (this: PatchableToolExecutionPrototype): CallRenderer | undefined { + if (isMcpRenderCandidate(this)) { + const def = this.toolDefinition; + const toolName = (def && getTextField(def, "name")) ?? "mcp"; + const toolLabel = + (def && getTextField(def, "label")) + ?? (toolName === "mcp" ? "MCP Proxy" : `MCP ${toolName}`); + return (args, theme) => formatMcpCallLine(toolName, toolLabel, toRecord(args), theme); + } + return originalGetCallRenderer?.call(this); + }; + + proto.getResultRenderer = function (this: PatchableToolExecutionPrototype): ResultRenderer | undefined { + if (isMcpRenderCandidate(this)) { + return (result, options, theme) => + renderMcpResult(result as never, options, getConfig(), theme); + } + return originalGetResultRenderer?.call(this); + }; + + proto.__piToolDisplayMcpPatchVersion = PATCH_VERSION; + proto.__piToolDisplayMcpPatchOwner = PATCH_OWNER; +} + +function restoreToolExecutionMcpRender(): void { + const proto = getToolExecutionPrototype(); + const originalCall = proto.__piToolDisplayOriginalGetCallRenderer; + const originalResult = proto.__piToolDisplayOriginalGetResultRenderer; + if (typeof originalCall === "function") { + proto.getCallRenderer = originalCall; + } + if (typeof originalResult === "function") { + proto.getResultRenderer = originalResult; + } + delete proto.__piToolDisplayOriginalGetCallRenderer; + delete proto.__piToolDisplayOriginalGetResultRenderer; + delete proto.__piToolDisplayMcpPatchVersion; + delete proto.__piToolDisplayMcpPatchOwner; +} + +export default function registerToolExecutionMcpPatch( + pi: ExtensionAPI, + getConfig: () => ToolDisplayConfig, +): void { + patchToolExecutionMcpRender(getConfig); + + onReloadShutdown(pi, () => { + restoreToolExecutionMcpRender(); + }); + + pi.on("before_agent_start", async () => { + patchToolExecutionMcpRender(getConfig); + }); + + pi.on("session_start", async () => { + patchToolExecutionMcpRender(getConfig); + }); +} \ No newline at end of file diff --git a/src/tool-overrides.ts b/src/tool-overrides.ts index e9b47bb..3ead6d5 100644 --- a/src/tool-overrides.ts +++ b/src/tool-overrides.ts @@ -91,7 +91,7 @@ interface RuntimeToolDefinition { [key: string]: unknown; } -interface RenderTheme { +export interface RenderTheme { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold(text: string): string; @@ -1153,7 +1153,7 @@ function formatArgCountSuffix(argCount: number, theme: RenderTheme): string { : theme.fg("muted", ` (${argCount} ${pluralize(argCount, "arg")})`); } -function formatMcpCallLine( +export function formatMcpCallLine( toolName: string, toolLabel: string, args: Record, @@ -1195,7 +1195,7 @@ function getMcpTruncationDetails(details: unknown): { }; } -function renderMcpResult( +export function renderMcpResult( result: ToolRenderInput, options: ToolRenderResultOptions, config: ToolDisplayConfig, From d6eda39abb63678e938ffd6ab3d873a7157f7d75 Mon Sep 17 00:00:00 2001 From: Jeremy Nikolic Date: Tue, 14 Jul 2026 22:39:06 +0200 Subject: [PATCH 2/2] chore: trim verbose comments in tool-execution-patch --- src/tool-execution-patch.ts | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/tool-execution-patch.ts b/src/tool-execution-patch.ts index b404184..89f9f97 100644 --- a/src/tool-execution-patch.ts +++ b/src/tool-execution-patch.ts @@ -11,23 +11,9 @@ import { import { onReloadShutdown } from "./extension-lifecycle.js"; import type { ToolDisplayConfig } from "./types.js"; -/** - * MCP tool rendering broke on Pi 0.80.6+ for two reasons: - * 1. `pi.registerTool` is now a per-extension API, so intercepting it on - * this extension's API no longer captures tools registered by other - * extensions (e.g. pi-mcp-adapter's direct tools). - * 2. `pi.getAllTools()` returns shallow clones that omit `renderCall`, - * `renderResult`, and `label`, so the session-start decoration sweep - * mutates throwaway objects and never reaches the live tool definitions. - * - * `ToolExecutionComponent` resolves the renderer per call via - * `getCallRenderer()` / `getResultRenderer()`, reading the LIVE - * `this.toolDefinition` (which still carries `label`). Patching those - * prototype methods lets us render MCP-candidate tools with pi-tool-display's - * compact renderers at render time, regardless of how the tool was registered - * or whether `getAllTools()` exposes renderers. This mirrors the existing - * `UserMessageComponent` prototype patch. - */ +// Render MCP tools at render time by patching ToolExecutionComponent's renderer +// accessors. Works around Pi 0.80.6+'s per-extension pi.registerTool and +// getAllTools() clones, which made the existing decoration paths miss MCP tools. const PATCH_VERSION = 1; const PATCH_OWNER = {}; @@ -72,8 +58,6 @@ function isMcpRenderCandidate(proto: PatchableToolExecutionPrototype): boolean { if (!def) { return false; } - // Built-in tools are owned by pi-tool-display's own overrides; leave them to - // the original resolver so per-tool ownership and diff renderers still apply. if (proto.builtInToolDefinition) { return false; } @@ -97,7 +81,6 @@ function patchToolExecutionMcpRender( typeof previousCall === "function" && previousCall !== proto.getCallRenderer; const isCurrentPatch = proto.__piToolDisplayMcpPatchOwner === PATCH_OWNER; - // Restore a stale patch left by a previous extension instance. if (hasPreviousPatch && !isCurrentPatch && typeof previousCall === "function" && typeof previousResult === "function") { proto.getCallRenderer = previousCall; proto.getResultRenderer = previousResult;