From ca77a0574030daafd1caec3fb9b59985d272bc73 Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 21 Jul 2026 09:06:28 +0200 Subject: [PATCH] fix: decorate late tools before registration - decorate configured late tools before Pi snapshots registration - preserve native call renderers when explicitly configured - harden object-identity tracking, pending queues, and reload cleanup --- CHANGELOG.md | 7 ++ README.md | 7 +- config/config.example.json | 3 +- src/config-store.ts | 1 + src/index.ts | 7 +- src/tool-overrides.ts | 113 +++++++++++++--------- src/types.ts | 1 + tests/custom-tool-overrides.test.ts | 103 +++++++++++++++++++- tests/tool-display-api-consumer.test.ts | 70 ++++++++++++++ tests/tool-overrides-registration.test.ts | 27 ++++++ tool-display-api-consumer.d.ts | 29 +++++- tool-display-api-consumer.js | 6 ++ 12 files changed, 313 insertions(+), 61 deletions(-) create mode 100644 tests/tool-display-api-consumer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 601d63d..1e6db76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added `preserveCallRenderer` for custom tool overrides that should retain a useful native call header while compacting or hiding only result output. + +### Fixed +- Decorate tools registered by later-loaded extensions before Pi snapshots their definitions. +- Track decorated tool objects by identity and restore interception state across reloads and defensive double loads. + ## [0.5.0] - 2026-07-03 ### Added diff --git a/README.md b/README.md index 6a83c8a..2b6922b 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ Use `customToolOverrides` when another extension registers a noisy top-level too "custom_mcp_gateway": { "enabled": true, "kind": "mcp", - "outputMode": "preview" + "outputMode": "preview", + "preserveCallRenderer": true } } } @@ -210,6 +211,7 @@ Each entry supports: | `enabled` | boolean | `true` | Whether `pi-tool-display` should decorate this custom tool | | `kind` | string | `"generic"` | `generic` for plain compact output, or `mcp` for MCP-style call labels and result handling | | `outputMode` | string | `"summary"` | `hidden`, `summary`, or `preview` for this custom tool's result output | +| `preserveCallRenderer` | boolean | `false` | Keep the tool's native call/header renderer while overriding only its result output | Boolean shorthand is also accepted: @@ -252,7 +254,8 @@ Notes: "custom_mcp_gateway": { "enabled": true, "kind": "mcp", - "outputMode": "preview" + "outputMode": "preview", + "preserveCallRenderer": true } }, "enableNativeUserMessageBox": true, diff --git a/config/config.example.json b/config/config.example.json index fc650b7..cc64621 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -13,7 +13,8 @@ "ide_find_symbol": { "enabled": false, "kind": "generic", - "outputMode": "summary" + "outputMode": "summary", + "preserveCallRenderer": true }, "custom_mcp_gateway": { "enabled": false, diff --git a/src/config-store.ts b/src/config-store.ts index bf53590..ab01365 100644 --- a/src/config-store.ts +++ b/src/config-store.ts @@ -176,6 +176,7 @@ export function normalizeCustomToolOverrideEntry(rawEntry: unknown): CustomToolO enabled: toBoolean(source.enabled, true), kind: toCustomToolOverrideKind(source.kind), outputMode: toCustomToolOutputMode(source.outputMode), + ...(source.preserveCallRenderer === true ? { preserveCallRenderer: true } : {}), }; } diff --git a/src/index.ts b/src/index.ts index 3194dfa..d8e7cc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,13 +33,16 @@ function ownershipChanged( } export default function toolDisplayExtension(pi: ExtensionAPI): void { + // Pi normally calls session_shutdown before reload, but dispose a prior + // generation defensively when loaders invoke this entrypoint twice. + disposeAll(); + resetDisposed(); + const initial = loadToolDisplayConfig(); if (!initial.config.enabled) { return; } - resetDisposed(); - pi.on("session_shutdown", (event: { reason: string }) => { if (event.reason === "reload") { disposeAll(); diff --git a/src/tool-overrides.ts b/src/tool-overrides.ts index e9b47bb..c49bf0d 100644 --- a/src/tool-overrides.ts +++ b/src/tool-overrides.ts @@ -140,6 +140,7 @@ interface BashToolOverrideOptions { const builtInToolCache = new Map(); const RTK_COMPACTION_LABEL = "compacted by RTK"; export const WRITE_EXECUTION_META_LIMIT = 100; +const TOOL_DISPLAY_PENDING_DECORATIONS_LIMIT = 100; const WRITE_EXECUTION_META_STATE_KEY = "__piToolDisplayWriteExecutionMeta"; const EDIT_PENDING_PREVIEW_STATE_KEY = "__piToolDisplayEditPendingPreview"; const WRITE_PENDING_PREVIEW_STATE_KEY = "__piToolDisplayWritePendingPreview"; @@ -166,6 +167,8 @@ export interface ToolDisplayAdapter { toolName?: string; kind?: ToolDisplayKind; overrideExistingRenderers?: boolean; + /** Preserve a supplied tool's call renderer while still applying result rendering. */ + preserveCallRenderer?: boolean; pathFields?: string[]; getPath?: (args: unknown) => string | undefined; getEditLineCount?: (args: unknown) => number; @@ -1478,6 +1481,12 @@ function drainPendingToolDisplayDecorations(api: ToolDisplayApi): void { return; } + // Consumer extensions may load before this extension. Keep only the newest + // bounded set so a long-lived pre-load queue cannot retain arbitrary tools. + if (pendingDecorations.length > TOOL_DISPLAY_PENDING_DECORATIONS_LIMIT) { + pendingDecorations.splice(0, pendingDecorations.length - TOOL_DISPLAY_PENDING_DECORATIONS_LIMIT); + } + const entries = pendingDecorations.splice(0); for (const entry of entries) { if (!entry?.tool || typeof entry.tool !== "object") { @@ -1509,20 +1518,23 @@ function installToolDisplayApi(getConfig: ConfigGetter): ToolDisplayApi { const resolvedAdapter = resolveAdapter(tool, adapter); const kind = getAdapterKind(tool, resolvedAdapter); const overrideExisting = resolvedAdapter.overrideExistingRenderers === true; + const preserveCallRenderer = resolvedAdapter.preserveCallRenderer === true; const decorated: RuntimeToolDefinition = { ...tool }; - if (resolvedAdapter.renderCall && (overrideExisting || typeof decorated.renderCall !== "function")) { - decorated.renderCall = resolvedAdapter.renderCall; - } else if (kind === "read" && (overrideExisting || typeof decorated.renderCall !== "function")) { - decorated.renderCall = (args: unknown, theme: RenderTheme) => renderReadDisplayCall(args, theme, resolvedAdapter); - } else if (kind === "edit" && (overrideExisting || typeof decorated.renderCall !== "function")) { - decorated.renderCall = (args: unknown, theme: RenderTheme, context: ToolRenderContextLike) => renderEditDisplayCall(args, theme, context, resolvedAdapter, getConfig); - } else if (kind === "mcp" && (overrideExisting || typeof decorated.renderCall !== "function")) { - decorated.renderCall = (args: unknown, theme: RenderTheme) => { - const toolName = getTextField(decorated, "name") ?? "mcp"; - const toolLabel = getTextField(decorated, "label") ?? (toolName === "mcp" ? "MCP Proxy" : `MCP ${toolName}`); - return formatMcpCallLine(toolName, toolLabel, toRecord(args), theme); - }; + if (!preserveCallRenderer) { + if (resolvedAdapter.renderCall && (overrideExisting || typeof decorated.renderCall !== "function")) { + decorated.renderCall = resolvedAdapter.renderCall; + } else if (kind === "read" && (overrideExisting || typeof decorated.renderCall !== "function")) { + decorated.renderCall = (args: unknown, theme: RenderTheme) => renderReadDisplayCall(args, theme, resolvedAdapter); + } else if (kind === "edit" && (overrideExisting || typeof decorated.renderCall !== "function")) { + decorated.renderCall = (args: unknown, theme: RenderTheme, context: ToolRenderContextLike) => renderEditDisplayCall(args, theme, context, resolvedAdapter, getConfig); + } else if (kind === "mcp" && (overrideExisting || typeof decorated.renderCall !== "function")) { + decorated.renderCall = (args: unknown, theme: RenderTheme) => { + const toolName = getTextField(decorated, "name") ?? "mcp"; + const toolLabel = getTextField(decorated, "label") ?? (toolName === "mcp" ? "MCP Proxy" : `MCP ${toolName}`); + return formatMcpCallLine(toolName, toolLabel, toRecord(args), theme); + }; + } } if (resolvedAdapter.renderResult && (overrideExisting || typeof decorated.renderResult !== "function")) { @@ -1905,8 +1917,7 @@ export function registerToolDisplayOverrides( }); }); - const wrappedCustomToolNames = new Set(); - registerCleanup(() => wrappedCustomToolNames.clear()); + const decoratedCustomTools = new WeakSet(); const getCustomOverrideForCandidate = (candidate: unknown): { toolName: string; @@ -1927,42 +1938,48 @@ export function registerToolDisplayOverrides( const decorateCustomToolOverrideCandidate = (candidate: unknown): boolean => { const customOverride = getCustomOverrideForCandidate(candidate); - if (!customOverride || wrappedCustomToolNames.has(customOverride.toolName)) { - return customOverride !== undefined; + if (!customOverride) { + return false; } const { toolName, override } = customOverride; const runtimeTool = candidate as RuntimeToolDefinition; - applyToolDisplayDecorationInPlace( - runtimeTool, - toolDisplayApi, - { - kind: override.kind, - overrideExistingRenderers: true, - renderCall(args, theme) { - if (override.kind === "mcp") { - return formatMcpCallLine("mcp", "MCP Proxy", toRecord(args), theme); - } - return formatGenericToolCallLine(toolName, args, theme); - }, - renderResult(result, options, theme) { - return renderCustomToolResult( - result as ToolRenderInput, - options, - getConfig(), - override.outputMode, - theme, - ); - }, + if (decoratedCustomTools.has(runtimeTool)) { + return true; + } + + const adapter: ToolDisplayAdapter = { + kind: override.kind, + overrideExistingRenderers: true, + preserveCallRenderer: override.preserveCallRenderer === true, + renderResult(result, options, theme) { + return renderCustomToolResult( + result as ToolRenderInput, + options, + getConfig(), + override.outputMode, + theme, + ); }, - ); + }; + if (!override.preserveCallRenderer) { + adapter.renderCall = (args, theme) => { + if (override.kind === "mcp") { + return formatMcpCallLine("mcp", "MCP Proxy", toRecord(args), theme); + } + return formatGenericToolCallLine(toolName, args, theme); + }; + } - wrappedCustomToolNames.add(toolName); + if (!applyToolDisplayDecorationInPlace(runtimeTool, toolDisplayApi, adapter)) { + return false; + } + + decoratedCustomTools.add(runtimeTool); return true; }; - const wrappedMcpToolNames = new Set(); - registerCleanup(() => wrappedMcpToolNames.clear()); + const decoratedMcpTools = new WeakSet(); const decorateMcpToolCandidate = (candidate: unknown): void => { if (getCustomOverrideForCandidate(candidate)) { @@ -1974,7 +1991,8 @@ export function registerToolDisplayOverrides( } const toolName = getTextField(candidate, "name"); - if (!toolName || wrappedMcpToolNames.has(toolName)) { + const runtimeTool = candidate as RuntimeToolDefinition; + if (!toolName || decoratedMcpTools.has(runtimeTool)) { return; } @@ -2003,8 +2021,7 @@ export function registerToolDisplayOverrides( ), }; - const runtimeTool = candidate as RuntimeToolDefinition; - applyToolDisplayDecorationInPlace( + if (!applyToolDisplayDecorationInPlace( runtimeTool, toolDisplayApi, { @@ -2022,7 +2039,9 @@ export function registerToolDisplayOverrides( ); }, }, - ); + )) { + return; + } Object.assign(runtimeTool, { label: toolLabel, description: toolDescription, @@ -2031,7 +2050,7 @@ export function registerToolDisplayOverrides( prepareArguments: prepareArgumentsDelegate, }); - wrappedMcpToolNames.add(toolName); + decoratedMcpTools.add(runtimeTool); }; const installMcpRegistrationInterceptor = (): void => { @@ -2047,7 +2066,6 @@ export function registerToolDisplayOverrides( this: ExtensionAPI, tool: ToolDefinition, ): void { - originalRegisterTool.call(this, tool); try { if (!decorateCustomToolOverrideCandidate(tool)) { decorateMcpToolCandidate(tool); @@ -2055,6 +2073,7 @@ export function registerToolDisplayOverrides( } catch (error) { logToolDisplayDebug("Tool display registration decoration failed.", error); } + originalRegisterTool.call(this, tool); } as ExtensionAPI["registerTool"]; pi.registerTool = wrappedRegisterTool; diff --git a/src/types.ts b/src/types.ts index eb6d39c..b310902 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export interface CustomToolOverrideConfig { enabled: boolean; kind: CustomToolOverrideKind; outputMode: CustomToolOutputMode; + preserveCallRenderer?: boolean; } export interface ToolDisplayConfig { diff --git a/tests/custom-tool-overrides.test.ts b/tests/custom-tool-overrides.test.ts index b33cd98..cc0d33f 100644 --- a/tests/custom-tool-overrides.test.ts +++ b/tests/custom-tool-overrides.test.ts @@ -53,7 +53,7 @@ function buildConfigWithCustomOverrides( } as ToolDisplayConfig; } -function createExtensionApiStub(allTools: RuntimeTool[] = []): { +function createExtensionApiStub(allTools: RuntimeTool[] = [], cloneOnRegister = false): { api: ExtensionAPI; registeredTools: RuntimeTool[]; runtimeTools: RuntimeTool[]; @@ -63,7 +63,7 @@ function createExtensionApiStub(allTools: RuntimeTool[] = []): { const eventHandlers: ToolEventHandlers = {}; const api = { registerTool(tool: RuntimeTool): void { - registeredTools.push(tool); + registeredTools.push(cloneOnRegister ? { ...tool } : tool); }, on(event: keyof ToolEventHandlers, handler: () => Promise | void): void { eventHandlers[event] = handler; @@ -246,6 +246,48 @@ test("custom generic tool override honors per-tool hidden output mode", async () assert.equal(renderToolResult(quietTool, "secret\nnoisy\noutput\n"), ""); }); +test("custom tool override can preserve a tool's native call renderer while hiding results", async () => { + const quietShell: RuntimeTool = { + name: "quiet_shell", + description: "Shell tool with a useful native command header.", + parameters: {}, + execute: () => {}, + renderCall: (args: unknown) => ({ render: () => [`quiet_shell $ ${(args as { command?: string }).command}`] }), + renderResult: () => ({ render: () => ["RAW RESULT"] }), + }; + const config = buildConfigWithCustomOverrides({ + quiet_shell: { enabled: true, outputMode: "hidden", preserveCallRenderer: true }, + }); + const { api, eventHandlers } = createExtensionApiStub([quietShell]); + + registerToolDisplayOverrides(api, () => config); + await runLifecycle(eventHandlers); + + assert.equal(renderToText(quietShell.renderCall?.({ command: "git status" }, createTheme())), "quiet_shell $ git status"); + assert.equal(renderToolResult(quietShell, "noisy output"), ""); +}); + +test("custom MCP tool override preserves a native call renderer while hiding results", async () => { + const nativeMcp: RuntimeTool = { + name: "native_mcp", + description: "MCP proxy with a useful native call header.", + parameters: {}, + execute: () => {}, + renderCall: () => ({ render: () => ["NATIVE MCP CALL"] }), + renderResult: () => ({ render: () => ["RAW RESULT"] }), + }; + const config = buildConfigWithCustomOverrides({ + native_mcp: { enabled: true, kind: "mcp", outputMode: "hidden", preserveCallRenderer: true }, + }); + const { api, eventHandlers } = createExtensionApiStub([nativeMcp]); + + registerToolDisplayOverrides(api, () => config); + await runLifecycle(eventHandlers); + + assert.equal(renderToText(nativeMcp.renderCall?.({}, createTheme())), "NATIVE MCP CALL"); + assert.equal(renderToolResult(nativeMcp, "noisy output"), ""); +}); + test("custom tool overrides ignore missing tools instead of registering phantom tools", async () => { const config = buildConfigWithCustomOverrides({ missing_tool: { enabled: true, outputMode: "summary" }, @@ -273,14 +315,14 @@ test("normalizeToolDisplayConfig preserves supported custom output modes and dro const config = normalizeToolDisplayConfig({ customToolOverrides: { hidden_tool: { enabled: true, outputMode: "hidden", label: "Ignored Label" }, - summary_tool: { enabled: true, outputMode: "summary", pathFields: ["file_path"] }, + summary_tool: { enabled: true, outputMode: "summary", preserveCallRenderer: true, pathFields: ["file_path"] }, preview_tool: { enabled: true, outputMode: "preview", renderShell: "self" }, }, }) as ToolDisplayConfigWithCustomOverrides; assert.deepEqual(config.customToolOverrides, { hidden_tool: { enabled: true, kind: "generic", outputMode: "hidden" }, - summary_tool: { enabled: true, kind: "generic", outputMode: "summary" }, + summary_tool: { enabled: true, kind: "generic", outputMode: "summary", preserveCallRenderer: true }, preview_tool: { enabled: true, kind: "generic", outputMode: "preview" }, }); }); @@ -412,3 +454,56 @@ test("custom tool registered after lifecycle is decorated when it is explicitly assert.equal(typeof lateTool.renderResult, "function"); assert.equal(renderToText(lateTool.renderCall?.({ query: "late" }, createTheme())), "late_custom_tool (1 arg)"); }); + +test("decorates a late custom tool before Pi snapshots its registration", async () => { + const config = buildConfigWithCustomOverrides({ + hypa_shell: { enabled: true, outputMode: "hidden" }, + }); + const { api, registeredTools, eventHandlers } = createExtensionApiStub([], true); + + registerToolDisplayOverrides(api, () => config); + await runLifecycle(eventHandlers); + + const hypaShell: RuntimeTool = { + name: "hypa_shell", + description: "Run shell commands through Hypa compression.", + parameters: {}, + execute: () => {}, + renderCall: () => ({ render: () => ["RAW HYPA CALL"] }), + renderResult: () => ({ render: () => ["RAW HYPA RESULT"] }), + }; + (api as unknown as { registerTool(tool: RuntimeTool): void }).registerTool(hypaShell); + + const registeredHypaShell = registeredTools.find((tool) => tool.name === "hypa_shell"); + assert.ok(registeredHypaShell); + assert.equal(renderToText(registeredHypaShell.renderCall?.({ command: "git status" }, createTheme())), "hypa_shell (1 arg)"); + assert.equal(renderToolResult(registeredHypaShell, "large noisy output"), ""); +}); + +test("decorates distinct late tool objects with the same configured name before each snapshot", async () => { + const config = buildConfigWithCustomOverrides({ + hypa_shell: { enabled: true, outputMode: "hidden" }, + }); + const { api, registeredTools, eventHandlers } = createExtensionApiStub([], true); + + registerToolDisplayOverrides(api, () => config); + await runLifecycle(eventHandlers); + + for (const nativeCall of ["FIRST NATIVE CALL", "SECOND NATIVE CALL"]) { + (api as unknown as { registerTool(tool: RuntimeTool): void }).registerTool({ + name: "hypa_shell", + description: "Run shell commands through Hypa compression.", + parameters: {}, + execute: () => {}, + renderCall: () => ({ render: () => [nativeCall] }), + renderResult: () => ({ render: () => ["RAW HYPA RESULT"] }), + }); + } + + const registeredHypaTools = registeredTools.filter((tool) => tool.name === "hypa_shell"); + assert.equal(registeredHypaTools.length, 2); + for (const registeredTool of registeredHypaTools) { + assert.equal(renderToText(registeredTool.renderCall?.({ command: "git status" }, createTheme())), "hypa_shell (1 arg)"); + assert.equal(renderToolResult(registeredTool, "large noisy output"), ""); + } +}); diff --git a/tests/tool-display-api-consumer.test.ts b/tests/tool-display-api-consumer.test.ts new file mode 100644 index 0000000..b8119bc --- /dev/null +++ b/tests/tool-display-api-consumer.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + decorateToolForDisplay, + getToolDisplayApi, + queueToolDisplayDecoration, +} from "../tool-display-api-consumer.js"; + +const API_KEY = Symbol.for("pi-tool-display.api.v1"); +const PENDING_KEY = Symbol.for("pi-tool-display.pendingDecorations.v1"); + +type ConsumerGlobal = typeof globalThis & { + [API_KEY]?: unknown; + [PENDING_KEY]?: Array<{ tool: Record; adapter?: unknown }>; +}; + +function restoreGlobal(key: symbol, value: unknown): void { + if (value === undefined) { + delete (globalThis as Record)[key]; + } else { + (globalThis as Record)[key] = value; + } +} + +test("consumer queues only the newest bounded decorations until a compatible API is available", () => { + const runtime = globalThis as ConsumerGlobal; + const previousApi = runtime[API_KEY]; + const previousPending = runtime[PENDING_KEY]; + delete runtime[API_KEY]; + delete runtime[PENDING_KEY]; + + try { + assert.equal(getToolDisplayApi(), undefined); + for (let index = 0; index < 101; index++) { + queueToolDisplayDecoration({ name: `queued-${index}` }, { kind: "generic" }); + } + + assert.equal(runtime[PENDING_KEY]?.length, 100); + assert.equal(runtime[PENDING_KEY]?.[0]?.tool.name, "queued-1"); + assert.equal(runtime[PENDING_KEY]?.at(-1)?.tool.name, "queued-100"); + } finally { + restoreGlobal(API_KEY, previousApi); + restoreGlobal(PENDING_KEY, previousPending); + } +}); + +test("consumer decorates immediately only through the compatible v1 contract", () => { + const runtime = globalThis as ConsumerGlobal; + const previousApi = runtime[API_KEY]; + const previousPending = runtime[PENDING_KEY]; + let calls = 0; + + try { + runtime[API_KEY] = { version: 2, decorateTool() {} }; + assert.equal(getToolDisplayApi(), undefined); + + runtime[API_KEY] = { + version: 1, + decorateTool(tool: Record) { + calls++; + return { ...tool, decorated: true }; + }, + }; + assert.deepEqual(decorateToolForDisplay({ name: "immediate" }), { name: "immediate", decorated: true }); + assert.equal(calls, 1); + } finally { + restoreGlobal(API_KEY, previousApi); + restoreGlobal(PENDING_KEY, previousPending); + } +}); diff --git a/tests/tool-overrides-registration.test.ts b/tests/tool-overrides-registration.test.ts index f870f4d..60ef572 100644 --- a/tests/tool-overrides-registration.test.ts +++ b/tests/tool-overrides-registration.test.ts @@ -16,6 +16,7 @@ import { import { registerToolDisplayOverrides } from "../src/tool-overrides.ts"; import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../src/types.ts"; +const TOOL_DISPLAY_API_KEY = Symbol.for("pi-tool-display.api.v1"); const TOOL_DISPLAY_PENDING_DECORATIONS_KEY = Symbol.for("pi-tool-display.pendingDecorations.v1"); interface RegisteredToolLike { @@ -269,6 +270,32 @@ test("bash override uses shellCommandPrefix from Pi settings", async () => { }); }); +test("runtime API registers and unregisters typed adapter contracts", () => { + type GlobalWithToolDisplayApi = typeof globalThis & { + [TOOL_DISPLAY_API_KEY]?: { + version: number; + decorateTool(tool: Record): Record; + registerAdapter(adapter: { toolName?: string; kind?: "mcp"; overrideExistingRenderers?: boolean }): string; + unregisterAdapter(id: string): boolean; + }; + }; + const { api } = createExtensionApiStub(); + registerToolDisplayOverrides(api, () => DEFAULT_TOOL_DISPLAY_CONFIG); + const displayApi = (globalThis as GlobalWithToolDisplayApi)[TOOL_DISPLAY_API_KEY]; + assert.ok(displayApi); + + const id = displayApi.registerAdapter({ + toolName: "adapter_mcp", + kind: "mcp", + overrideExistingRenderers: true, + }); + const decorated = displayApi.decorateTool({ name: "adapter_mcp" }); + assert.equal(typeof decorated.renderCall, "function"); + assert.equal(typeof decorated.renderResult, "function"); + assert.equal(displayApi.unregisterAdapter(id), true); + assert.equal(displayApi.unregisterAdapter(id), false); +}); + test("registerToolDisplayOverrides drains pending display decorations from early-loading extensions", () => { type GlobalWithPendingDecorations = typeof globalThis & { [TOOL_DISPLAY_PENDING_DECORATIONS_KEY]?: Array<{ diff --git a/tool-display-api-consumer.d.ts b/tool-display-api-consumer.d.ts index 8424dbd..ba40958 100644 --- a/tool-display-api-consumer.d.ts +++ b/tool-display-api-consumer.d.ts @@ -1,13 +1,28 @@ export type RuntimeToolDefinition = Record; +export type ToolDisplayKind = "read" | "edit" | "mcp" | "generic"; + export interface ToolDisplayAdapter { - kind?: "read" | "edit" | "mcp" | "generic"; + /** Stable id for later removal; defaults to toolName or an internal id. */ + id?: string; + /** Name used when registering an adapter for later decorateTool calls. */ + toolName?: string; + kind?: ToolDisplayKind; overrideExistingRenderers?: boolean; + /** Preserve a native call renderer while replacing the result renderer. */ + preserveCallRenderer?: boolean; + pathFields?: string[]; + getPath?: (args: unknown) => string | undefined; + getEditLineCount?: (args: unknown) => number; + renderCall?: (args: unknown, theme: unknown, context: unknown) => unknown; + renderResult?: (result: unknown, options: unknown, theme: unknown, context?: unknown) => unknown; } export interface ToolDisplayApi { version: 1; - decorateTool(tool: T, adapter?: ToolDisplayAdapter | Record): T; + decorateTool(tool: T, adapter?: ToolDisplayAdapter): T; + registerAdapter(adapter: ToolDisplayAdapter): string; + unregisterAdapter(id: string): boolean; } export interface DecorateToolForDisplayOptions { @@ -16,14 +31,18 @@ export interface DecorateToolForDisplayOptions { export declare function getToolDisplayApi(): ToolDisplayApi | undefined; +/** + * Queues a decoration until pi-tool-display installs its runtime API. The queue + * retains at most the 100 most recently requested decorations. + */ export declare function queueToolDisplayDecoration( tool: T, - adapter?: ToolDisplayAdapter | Record, + adapter?: ToolDisplayAdapter, ): void; -export declare function decorateToolForDisplay( +export declare function decorateToolForDisplay( tool: T, - adapter?: ToolDisplayAdapter | Record, + adapter?: ToolDisplayAdapter, options?: DecorateToolForDisplayOptions, ): T; diff --git a/tool-display-api-consumer.js b/tool-display-api-consumer.js index 5d5fb88..f0ee0bd 100644 --- a/tool-display-api-consumer.js +++ b/tool-display-api-consumer.js @@ -1,5 +1,8 @@ +// These symbols are the stable, cross-package API boundary. Keep this small +// consumer module dependency-free so extensions can load before pi-tool-display. const TOOL_DISPLAY_API_KEY = Symbol.for("pi-tool-display.api.v1"); const TOOL_DISPLAY_PENDING_DECORATIONS_KEY = Symbol.for("pi-tool-display.pendingDecorations.v1"); +const PENDING_DECORATIONS_LIMIT = 100; export function getToolDisplayApi() { const api = globalThis[TOOL_DISPLAY_API_KEY]; @@ -14,6 +17,9 @@ export function queueToolDisplayDecoration(tool, adapter) { const existing = globalThis[TOOL_DISPLAY_PENDING_DECORATIONS_KEY]; const queue = Array.isArray(existing) ? existing : []; queue.push({ tool, adapter }); + if (queue.length > PENDING_DECORATIONS_LIMIT) { + queue.splice(0, queue.length - PENDING_DECORATIONS_LIMIT); + } globalThis[TOOL_DISPLAY_PENDING_DECORATIONS_KEY] = queue; }