Skip to content

Tool renderer registry

源码版本v0.73.1

pi-web-ui decouples the UI rendering of tool calls: a tool's execute is defined in AgentTool, while rendering is implemented by a ToolRenderer interface — the two are paired by tool name in the toolRenderers Map. renderTool is the single entry point that looks up the registry by tool name; on a hit it uses the custom renderer, on a miss it falls back to DefaultRenderer. Built-in renderers cover bash, extract_document, javascript_repl, and others; hosts can also inject business-specific renderers via registerToolRenderer.

Responsibilities

  1. Global registry: the toolRenderers Map stores ToolRenderer instances by toolName, see packages/web-ui/src/tools/renderer-registry.ts:9-16.
  2. Single entry point: renderTool(toolName, params, result, isStreaming) queries the registry first, uses it if found, falls back to DefaultRenderer otherwise; it also supports a showJsonMode global toggle that forces the default JSON renderer, see packages/web-ui/src/tools/index.ts:28-44.
  3. Header helpers: renderHeader and renderCollapsibleHeader unify the icon, text, and collapse interaction across three states (inprogress/complete/error), see packages/web-ui/src/tools/renderer-registry.ts:29-130.
  4. Inline rendering: ToolMessage.render calls renderTool, then decides whether to wrap the result in a card based on isCustom, see packages/web-ui/src/components/Messages.ts:258-276.
  5. Built-in renderers: extract-document.ts and javascript-repl.ts auto-register at the end of their files via registerToolRenderer(name, renderer), see packages/web-ui/src/tools/extract-document.ts:275 and packages/web-ui/src/tools/javascript-repl.ts:293.

Design motivation

Why not just add a render method on AgentTool? Because the tool's execute lives in the pi-agent-core layer as backend logic, while rendering lives in the pi-web-ui layer as frontend logic. The two cannot be coupled — the same bash tool might be used in CLI, TUI, and web UIs; execute is reused, rendering is written per UI. The registry pattern lets web-ui register a renderer for any tool name, even if the tool itself is in another package.

Why does renderTool return {content, isCustom} instead of a TemplateResult? Because some renderers want to control the entire layout themselves (e.g. the artifacts tool is inlined onto the canvas and does not want a card frame); isCustom: true makes ToolMessage skip the card wrapper and return the content directly; isCustom: false wraps it in a border rounded-md card. This "custom content plus default shell" choice gives renderers freedom while keeping a consistent look for most tools.

Why do extract-document and javascript-repl keep renderer and tool in the same file? Because they are paired: the shape of the tool's result.details is defined by the tool, and the renderer consumes it directly. Co-locating them reduces the risk of drift, and the registerToolRenderer at the end of the file is "auto-register" — tools/index.ts uses import "./javascript-repl.js" to trigger the side effect; the host does not need to call anything manually.

Key files

renderTool is the single entry point, with very short logic:

typescript
// packages/web-ui/src/tools/index.ts:28-44
export function renderTool(
    toolName: string,
    params: any | undefined,
    result: ToolResultMessage | undefined,
    isStreaming?: boolean,
): ToolRenderResult {
    if (showJsonMode) {
        return defaultRenderer.render(params, result, isStreaming);
    }

    const renderer = getToolRenderer(toolName);
    if (renderer) {
        return renderer.render(params, result, isStreaming);
    }
    return defaultRenderer.render(params, result, isStreaming);
}

The auto-register at the end of javascript-repl.ts is the "import as registration" pattern:

typescript
// packages/web-ui/src/tools/javascript-repl.ts:292-293
// Auto-register the renderer
registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);

Data flow

The rendering path of a tool call, from AssistantMessage triggering it to the renderer returning:

Edges and failures

  • Tool has no registered renderer: renderTool falls back to DefaultRenderer, displaying params and result as JSON so the slot is not empty, see packages/web-ui/src/tools/index.ts:39-43.
  • Result not yet arrived during streaming: the renderer receives result === undefined and uses renderHeader(state="inprogress", ...) to show a spinner plus text, see packages/web-ui/src/tools/extract-document.ts:249-264.
  • Aborted tool call: ToolMessage synthesizes an isError ToolResultMessage as a placeholder when aborted, and the renderer renders it in the error state, see packages/web-ui/src/components/Messages.ts:248-258.
  • CORS failure fallback: when extract_document direct fetch fails, isCorsError matches and a corsProxyUrl is configured, it retries via the proxy, see packages/web-ui/src/tools/extract-document.ts:103-135.
  • showJsonMode debug toggle: after setShowJsonMode(true), every tool is forced through DefaultRenderer's JSON view, which helps inspect tool return values, see packages/web-ui/src/tools/index.ts:21-23.
  • Duplicate renderer registration: registerToolRenderer just does Map.set, overwriting; the later registration wins, no error. A host injecting a custom renderer can override the built-in implementation.

Summary

renderer-registry decouples a tool's execute from its rendering: renderTool looks up the Map by toolName, uses the custom renderer on a hit, otherwise DefaultRenderer. extract_document and javascript_repl use the "import as registration" pattern to auto-register. The rendering entry point is ToolMessage in Message rendering components; the assembly of tool instances happens in ChatPanel top-level element.