matrx-extend-tool-display
Tool-call row display registry for the matrx-extend side panel. Use when making a tool 'show up nicely', editing `src/features/chat/tool-display/`, changing a row's icon, label, args, or result view,
SKILL.md.claude/skills/matrx-extend-tool-display/SKILL.md
GitHubmatrx-extend Tool Display Registry
Per-tool customization for how a tool call shows up in the chat surface of the matrx-extend Chrome extension (the agent harness side panel). Anything not registered keeps rendering exactly as it does today — the registry only intercepts on opt-in.
Scope guardrail: this only applies to src/features/chat/tool-display/ in the matrx-extend repo. The Matrx admin app, dashboard, workflow-studio, etc. are separate surfaces with their own rendering pipelines. Do NOT confuse with server-side tool catalog generation (pnpm catalog:tools) — that's about advertising tools to the LLM, not displaying their results to humans.
When to use this skill
Trigger when the user asks to:
- Add a new tool to the display registry / "make X tool show up nicely"
- Customize what shows in the inline header (icon, prefix, "Getting X" labels, info hint)
- Render a tool's result with custom components instead of raw JSON
- Plug in a fully custom React component for one tool
- Debug a registered tool that's silently falling back to the default
Branch files — read only when your run reaches them
- Setting
CustomComponent(charts, comparison views, interactive forms, answer-back tools) → read custom-component.md. - Rendering every item of an array result (e.g. a list of
{title, url}objects) —keysInfoonly addresses single values → read cookbook.md (CustomComponent or reshape on the server). - Want a ready-made config pattern (tense prefixes, counts, hide-while-running, shimmering labels, per-category or favicon icons) → read cookbook.md.
- Want a complete registered entry to copy (
ctx_get,take_screenshot) → read worked-examples.md.
Mental model in 30 seconds
chat → ToolTimelineRow (client tools) ─┐
chat → ServerToolRow (server tools) ─┴→ toolDisplayRegistry[toolName] ?
├─ no → default rendering (unchanged)
├─ yes + cfg.CustomComponent → that
└─ yes + config → ConfigurableToolRow
(wrapped in ToolDisplayBoundary)
Three failure modes are all silent — they log console.warn and degrade gracefully:
- Bad path/key in config → that field is skipped, others render.
- Throwing transform / field component → that field is skipped, others render.
- Throwing custom component → entire row falls back to default.
The user never sees an error UI. Verify your config by checking the rendered row, not by waiting for an exception.
Quick start — minimal entry
For a tool whose result has a label and content field that should render nicely:
// src/features/chat/tool-display/registry.tsx
export const toolDisplayRegistry: Record<string, ToolDisplayEntry> = {
my_tool_name: {
inline: {
prefix: { started: 'Doing', completed: 'Did' },
info: { path: 'args.target', transform: 'snakeToTitle' },
},
results: {
displayType: 'custom',
keysInfo: [
{ key: 'label', component: 'BoldLabel' },
{ key: 'content', component: 'Markdown', transform: 'textClean' },
],
},
},
};
That's it. The tool name is the raw toolName string the SW dispatcher uses (e.g. ctx_get, read_page, seo_get_keyword_data) — not the title-cased label. Find it by triggering the tool once and reading the inline row.
File map
| File | Purpose | When to edit |
|---|---|---|
src/features/chat/tool-display/registry.tsx |
The actual entries map | Adding a new tool — almost always the only file you touch |
src/features/chat/tool-display/types.ts |
TS shapes for the config | Only when adding a new top-level config field (rare) |
src/features/chat/tool-display/registry-transforms.ts |
Named transforms (titleCase, textClean, …) |
Adding a reusable string transformer |
src/features/chat/tool-display/registry-components.tsx |
Field components (BoldLabel, Markdown, …) |
Adding a new way to render a single value |
src/features/chat/tool-display/helpers.ts |
Resolution + error boundary | Almost never |
src/features/chat/tool-display/ConfigurableToolRow.tsx |
The renderer | Almost never — config-driven |
src/features/chat/ToolTimelineRow.tsx |
Client-tool dispatch (do NOT touch the default branch) | Only if changing the dispatcher itself |
src/features/chat/ServerToolRow.tsx |
Server-tool dispatch (same) | Same |
One golden rule: edits go to registry.tsx (and maybe -transforms.ts / -components.tsx). Never modify the default render paths in ToolTimelineRow / ServerToolRow — they are the safety net for every unregistered tool plus the fallback for any registered tool whose config blows up.
The full config shape
Every visual segment is PhaseAware<T>: pass a single value to apply to all phases, or { started, completed, error } to vary per phase.
interface ToolDisplayEntry {
inline?: {
hidden?: PhaseAware<boolean>;
icon?: PhaseAware<IconName | InfoSpec>; // lucide name OR InfoSpec resolving to a
// lucide name OR a URL (rendered as <img>)
prefix?: PhaseAware<string>; // "Getting" / "Got" / "Failed to get"
name?: PhaseAware<string | InfoSpec>; // default: titleCase(toolName); '' to suppress;
// pass an InfoSpec to pull from args/output
suffix?: PhaseAware<string>;
info?: PhaseAware<string | InfoSpec>; // string shorthand = { path: string }
color?: PhaseAware<ColorToken>;
isMultiline?: boolean; // wrap vs truncate the info segment
spinIcon?: PhaseAware<boolean>; // default: true on 'started' only
shimmerOnRunning?: boolean; // shimmer the label while phase=started; default true
};
args?: {
hidden?: PhaseAware<boolean>;
displayType?: 'json' | 'key-value' | 'values-only'; // default 'json'
};
results?: {
hidden?: PhaseAware<boolean>;
displayType?: 'json' | 'key-value' | 'values-only' | 'custom';
keysInfo?: KeyDisplay[]; // required when displayType === 'custom'
alwaysShow?: boolean; // render result content visibly under the row,
// no click required (use for screenshots, charts)
};
CustomComponent?: ComponentType<{ entry: ToolTimelineEntry; kind: 'server' | 'client' }>;
}
interface InfoSpec {
path: string; // see "Path expressions" below
transform?: TransformName | TransformName[];
fallback?: string; // shown when path is nullish/empty
}
interface KeyDisplay {
key: string; // dot path within the result object
component: FieldComponentName;
className?: string; // appended to component default classes
transform?: TransformName | TransformName[] | null;
fallback?: string;
}
Path expressions
Used in inline.info.path and (with shallow-only resolution) in keysInfo[].key. Recognized roots for the inline path:
args.<key>...— the model-supplied inputoutput.<key>...(orresult.<key>..., alias) — the tool's return payloadmessage— error message string (only set inerrorphase)toolName,callId— for completeness; rarely useful
Numeric segments index into arrays: output.items.0.title. Misses are silent — render skips that segment.
Transforms (current set)
In registry-transforms.ts. Add new ones there — same (unknown) => unknown contract, never throw.
| Name | Effect |
|---|---|
titleCase / snakeToTitle / kebabToTitle |
clean_content_markdown → Clean Content Markdown (aliases — same impl) |
textClean |
Strips markdown escapes (\_, \*, \`), trims whitespace |
truncate80 / truncate200 |
Truncate long strings with an ellipsis |
lowercase / uppercase |
Case conversion |
formatImageDimensions |
{ width, height, ... } → "WxH" (e.g. 2576×1911). Use with info.path: 'output' for image tools. |
formatBytes |
Number → "123 KB" / "4.5 MB" |
browserCategoryIcon |
Browser-tools category name → distinct lucide icon (core → Wrench, forms → FormInput, cookies → Cookie, …). Use as the transform on inline.icon with path: 'args.category'. |
Chain by passing an array: transform: ['textClean', 'truncate80'].
Field components (current set)
In registry-components.tsx. Each receives { value, className? }.
| Name | Renders as |
|---|---|
BoldLabel |
<div> with bold 12px text |
TextDisplay |
Plain 12px <div> |
Markdown |
<MarkdownView density="compact"> (full markdown w/ remark-gfm) |
Code |
Pre-formatted scrollable code block |
Json |
Pretty-printed JSON in a code block |
Image |
<img> — value must be a URL or data-URI string |
Base64Image |
<img> from base64 — accepts a string OR an object like { image_base64, media_type, width?, height?, byte_length? } (the screenshot result shape). Renders the image plus a small caption with dimensions and size. |
Badge |
Small pill — good for counts, statuses |
Chips |
An array of strings rendered as a wrapped row of monospace pills. Good for tool lists, tag arrays, etc. Non-array values are coerced to a single chip. |
TabCard |
Favicon + title + clickable URL card — built for get_active_tab-style payloads. Reads fav_icon_url (or favIconUrl/favicon), title, and url keys; falls back to a Globe icon if the favicon fails to load. Use with key: '' to pass the whole result. |
When adding a new component: keep it small, accept unknown for value, never throw.
Whole-result key convention
keysInfo[].key normally points at one field of the result. To pass the entire result object to the field component, set key: '' (or key: '*'). This is how Base64Image gets at both image_base64 and media_type in one render — no need to combine fields with a transform.
keysInfo: [{ key: '', component: 'Base64Image' }]
Visual design philosophy
The chat surface treats tool calls like the Reasoning block — slim, inline, low-noise. No borders, no card backgrounds, just py-0.5 rows with hover affordance. The visual heaviness of a tool row should match its semantic weight: a typical tool call is one log line, not a dashboard widget.
What this means concretely:
- Inline rows are tiny. Don't bulk them up with extra info segments unless they earn their space.
- Most "rich" content goes in the expanded body. Click to inspect args + result JSON.
- A tool whose result IS the point (screenshots, charts, favicons) should hoist that content with
results.alwaysShow: trueOR by putting the visual signal in the inline icon (favicons viaInfoSpec). - Long-running tools (
find, AI tools, network-heavy actions) get a shimmering label automatically while phase=started — keepshimmerOnRunningenabled (it's the default).
results.alwaysShow — render the result without a click
Set when the result payload IS the user-facing point of the tool. The keysInfo content renders directly under the row, visible by default. The click-to-expand still works for inspecting args + raw JSON.
// take_screenshot — image is the star, never hide it
results: {
displayType: 'custom',
keysInfo: [{ key: '', component: 'Base64Image' }],
alwaysShow: true,
}
When to use it:
- Yes: screenshots, generated images, charts, single-pane media.
- Maybe: a TabCard or Chips list — but consider hoisting the signal into the inline row instead (favicon as icon, count in info).
- No: long text, JSON dumps, list of items — these are better behind a click so the chat doesn't sprawl.
Result display modes
results.displayType controls how the (typically object) result is shown:
'json'(default) — pretty-printed JSON in a<pre>. Same as today's default.'key-value'— two-column grid: bold key, value. One row per top-level key. Non-object payloads fall back to JSON.'values-only'— bulleted list of just the values. Loses keys. Useful when keys are noise.'custom'— iteratekeysInfo; each entrygetByPath→applyTransforms→ render withcomponent. Missing keys with nofallbackare silently skipped + warned.
args.displayType supports the same modes minus 'custom'.
Icons + colors
Icons: any export name from lucide-react. Examples: HandGrab, Database, Search, Globe, Cookie, Camera, MousePointerClick, Keyboard, FileText, BookOpenText, Wrench, Bug, Sparkles, Plug, AppWindow. Unknown names log a warning and fall back to the phase default (Loader2 / CheckCircle2 / AlertTriangle).
Dynamic icons (favicons, per-arg icons): pass an InfoSpec instead of a literal name. The resolved string is auto-detected as a lucide name OR a URL:
// Favicon as the inline icon (URL → <img>)
icon: { completed: { path: 'output.fav_icon_url', fallback: 'Globe' } }
// Per-arg icon via a transform that maps a value to a lucide name
icon: { completed: { path: 'args.category', transform: 'browserCategoryIcon' } }
When the URL fails to load (favicon 404, CSP block, etc.) the renderer falls back to a Globe icon. When an InfoSpec resolves to nothing, falls back to the phase-default lucide icon (use fallback: 'SomeLucideName' to override).
Colors: pick a ColorToken — blue, sky, emerald, amber, red, violet, slate, primary, muted. Resolves to a text-{color}-600 dark:text-{color}-400 class. Error phase always wins (forced red) regardless of the override — keeps error visuals consistent across the app.
name accepting an InfoSpec
Two patterns where this matters:
Mid-sentence values (the dynamic word lives between static prefix/suffix):
// load_chrome_tools — header: "Loading my core browser tools"
inline: {
prefix: { started: 'Loading my', completed: 'Loaded my', error: 'Failed to load my' },
name: { path: 'args.category' }, // resolves "core" / "page" / "interact" / …
suffix: 'browser tools',
}
Title-as-name (a full descriptive title takes over the label after success):
// get_active_tab — header: "Reading active tab" → "<Page Title>" → "Couldn't read…"
inline: {
prefix: { started: 'Reading active tab', error: "Couldn't read active tab" },
name: {
started: '', // suppress
completed: { path: 'output.title', transform: 'truncate80' },
error: '', // suppress
},
}
Why both '' and undefined matter:
name: undefined→ falls back totitleCase(entry.toolName)(e.g.Get Active Tab)name: ''→ suppresses the segment entirely (no fallback)name: { path: '...' }resolving to nothing → empty (no fallback to titleCase)
When you only want a name in some phases, set the others to '' explicitly — otherwise the auto title-case sneaks back in.
The CustomComponent escape hatch
When the config isn't expressive enough, a full React component owns the whole row. Setting CustomComponent → read custom-component.md — its contract, the fallback on throw, and when interactive tools need it.
Adding a new tool — checklist
- [ ] 1. Find the raw toolName. Trigger the tool once; the default row shows
the snake_case name. (For server tools, ServerToolRow titleCases it,
so use the catalog: types/tool-catalog.json or src/lib/tools/handlers/*.)
- [ ] 2. Decide the inline shape:
- prefix (phase-aware verb: "Getting" / "Got" / "Failed to get")
- icon (a lucide name that suggests the action)
- color (one of the eight ColorTokens; error is always red)
- info (the most identifying arg or result field, transformed for humans)
- [ ] 3. Decide the result shape:
- Simple? Use 'key-value'.
- Has a label + body? Use 'custom' with BoldLabel + Markdown/TextDisplay.
- Already pretty as JSON? Leave default ('json').
- [ ] 4. Add the entry to toolDisplayRegistry in registry.tsx.
- [ ] 5. pnpm tsc --noEmit — confirm types check.
- [ ] 6. Reload the extension, trigger the tool, walk all three phases:
- while running: prefix + icon + spinning + info text correct
- after success: prefix swap + icon swap + color correct
- on error: prefix swap + red icon, no crash, message visible
(Force an error by passing invalid args or breaking the network.)
Common patterns (cookbook)
Copy-ready inline snippets for recurring shapes. Need one → read cookbook.md.
Verification
After every registry change:
- Typecheck:
pnpm tsc --noEmit(must exit 0). - Build:
pnpm wxt build(extension must build cleanly). - Visual sweep: open the side panel, trigger the tool, walk all three phases (
started→completed→error). Compare against an unregistered tool to confirm the default still works for everything else. - Console check: open DevTools console while triggering. Any
[tool-display] ...warning means a path/transform/icon is wrong — silent in the UI but logged. - Catalog regen (only if you changed tool handlers, not just display):
pnpm catalog:tools:mdand commit. Display registry edits do NOT need catalog regen.
Anti-patterns
- ❌ Modifying
DefaultToolTimelineRow/DefaultServerToolRow. The defaults are the safety net — touch them and every fallback inherits your bug. - ❌ Adding error UI in the configurable renderer. Failures should be silent + logged. The user should never see "config error" — they should see the default rendering.
- ❌ Putting tool-specific logic inside
helpers.tsorConfigurableToolRow.tsx. That's what the registry config +CustomComponentare for. - ❌ Using the
result.alias inkeysInfo[].key.keysInfopaths are scoped to the result object already — just uselabel(ornested.field), notresult.label. - ❌ Registering a tool name that doesn't exist. The dispatcher silently falls through to the default; you'll think your config doesn't work when really the tool never ran.
- ❌ Importing from outside
src/features/chat/tool-display/into the registry maps. Registry files should be a flat description of behavior — pull in shared UI throughregistry-components.tsxinstead.
Reference: the ctx_get worked example
Full registered entries with plain-English readings: ctx_get (the canonical starting template) and take_screenshot (whole-result key + Base64Image). Starting a new entry from a real one → read worked-examples.md.
Universal copy button
Every row (default and configurable, server and client) has a clipboard icon on the right that appears on hover. Clicking copies the full payload as pretty-printed JSON:
{
"tool": "ctx_get",
"phase": "completed",
"args": { "key": "clean_content_markdown" },
"result": { "label": "...", "content": "..." },
"duration_ms": 263,
"callId": "..."
}
Lives in CopyToolButton.tsx. Each renderer constructs a ToolCopyData object and passes it as data. The button stops click propagation so it never toggles the row open. Don't add per-tool overrides for it — the universal payload is the right shape for users (paste into bug reports, share with another agent, etc.).