AI

Tool Catalog

A searchable registry of the tools an agent may call — source facets with live counts, per-tool allow switches, parameter tables that open themselves when the query lands inside a signature, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronRight, RefreshCw, Search, SearchX, Server, Wrench, X } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type {
  ToolCatalogData,
  ToolCatalogItem,
  ToolCatalogParam,
  ToolCatalogSource,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/tool-catalog.json

Prompt

Build a React + TypeScript + Tailwind "ToolCatalog" component with zod and
lucide-react, reusing shadcn's Input, Button and Badge primitives.

Contract
- A zod schema (`toolCatalogSchema` in a sibling contract file) is the single
  source of truth: { status: "loading" | "empty" | "error" | "ready",
  items: ToolCatalogItem[] }.
- ToolCatalogItem = { id, name, source: { id, name, kind: "builtin" | "server" },
  description, params: ToolCatalogParam[], enabled: boolean }.
- ToolCatalogParam = { name, type, required, description?, defaultValue? }.
  `type` is a STRING, never an enum: signatures arrive from JSON Schema, a Python
  annotation or a TS type, so "string[]", "'json' | 'ndjson'" and
  "Record<string, unknown>" all have to render verbatim. Parsing them would
  quietly lie about half the registry.
- `source.id` is the facet key. Chips are built by grouping items on it, so two
  servers may share a display name without collapsing into one chip.
- `enabled` is DATA, not component state. The switch reports intent through
  onToggleEnabled(id, next) and only moves when the consumer hands back a new
  items array — a catalog that flips its own switch lies the moment the write
  fails and drifts from the policy the backend actually enforces.
- Props: status; items; query?/defaultQuery?/onQueryChange? (controlled or
  uncontrolled search); selectedSources?/defaultSelectedSources?/
  onSelectedSourcesChange? (controlled or uncontrolled facets, source ids, empty
  = every source); onToggleEnabled?; defaultOpen? (false); onRetry?; emptyState?;
  errorMessage?; label? ("Tool catalog"); skeletonRows? (4, clamped to 1..8);
  className plus the rest of the element props, ref forwarded to the <section>.

Behavior
- SEARCH SPANS THE SIGNATURE, not just the label. The haystack of a row is its
  name + description + source name + source kind + every parameter's name, type,
  description and default. "which tool takes a repo?" is the question a catalog
  exists to answer, and a search that only reads names cannot.
- The query is split on whitespace into tokens that are ANDed. Adding a word must
  never ADD results, or the reader learns to distrust the box.
- Matching uses indexOf on lowercased text, never a RegExp: the query is text a
  human typed, and "a|b(" would either throw or match something nobody asked for.
  Every occurrence of every token is highlighted; overlapping ranges are MERGED
  before rendering so <mark> never nests inside <mark>.
- A row whose parameters were hit OPENS ITSELF and says how many matched ("2
  matches in parameters"). Otherwise it stays collapsed. An explicit toggle by the
  reader wins over that rule and is remembered per tool id — a row that re-opens
  under the cursor on the next keystroke is a row nobody can read. Those overrides
  are scoped to the current items array and pruned when it changes (adjust state
  during render, no effect), so a refreshed registry can never apply the previous
  one's disclosure to a recycled id.
- Facet chips are multi-select aria-pressed toggles, not tabs: several sources can
  be on at once and a tablist would promise exactly one. Their counts follow the
  TEXT search but ignore which chips are pressed — counts that reacted to the
  chips would read 0 for every source you did not pick, which is arithmetic, not
  information. A chip at 0 stays on screen; it is the answer to "is it worth
  looking there".
- NO-RESULTS IS NOT THE EMPTY STATE. status="empty" (or ready with zero items)
  means the registry is genuinely empty and the advice is to connect a server. A
  filter that excluded everything is a different screen inside `ready`: it names
  what was searched and offers one button that clears the query and the chips and
  returns focus to the search box. Conflating them sends the reader to fix the
  wrong thing.
- The parameter panel is UNMOUNTED when closed (a height-0 collapse leaves every
  cell reachable by Tab — an invisible keyboard trap), and aria-controls is only
  set while open so it never points at an id that is not in the document. A tool
  with no parameters renders no disclosure button at all: no chevron, no empty
  panel, just the row.
- The switch is a <button role="switch" aria-checked> with a visible On/Off word
  next to it, so state never depends on the track's colour. With no
  onToggleEnabled the switch is replaced by the words Enabled / Disabled — a
  control that moves under your finger and changes nothing is worse than a label.
- The component owns no clock, no fetch and no timers: filtering is derived during
  render from the props plus the query, the chip selection and the disclosure
  overrides, so there is nothing to clean up on unmount and the server and the
  browser render the same markup. Do not add a debounce — the work is a substring
  scan over a list a human is expected to read, and a delayed list is a list that
  feels broken.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary for the pressed chips and the on-state track, bg-primary/20 for the
  highlight <mark> (the UA yellow is a hard-coded colour that survives no theme),
  bg-muted/30 for a disabled row, bg-muted-foreground/40 for the off-state track,
  text-destructive for "Required" and for the error branch. No hard-coded colours.
- Required is a WORD in its own column, never a red dot; the source badge carries
  a shape (server vs wrench) plus an sr-only expansion of what the kind means. All
  three survive a monochrome theme and a colour-blind reader.
- Long names use `wrap-anywhere` (overflow-wrap: anywhere) plus `min-w-0`, NOT
  break-words: only the former lowers the element's min-content width, which is
  what stops a 70-character tool name from widening the whole panel. The parameter
  table keeps its own horizontal scroll so four columns of signature text can be
  read in a narrow sidebar without pushing the card wider.
- One element carries the counts and is also role="status" ("Showing 3 of 12
  tools · 8 enabled"): visible for everyone, announced for a screen reader, and
  the only thing that tells a non-sighted reader their keystroke changed the list.
  The search input is aria-describedby that same element.
- loading is a skeleton with aria-busy plus an sr-only role="status"; error is
  role="alert" with the reason rendered whitespace-pre-wrap and a Retry that
  appears only when onRetry was passed. Every transition and pulse is
  motion-reduce-guarded; nothing decorative is load-bearing.
- cn() merges the consumer's className into the root <section>, which also spreads
  the remaining element props and forwards its ref.

Customization levers
- Ownership: pass query/onQueryChange and selectedSources/onSelectedSourcesChange
  to put the filters in the URL (?q=&source=) or in a store; omit them and the
  catalog owns both. The two are independent — controlled search with uncontrolled
  chips is fine.
- Policy surface: omit onToggleEnabled for a read-only capability list (docs, a
  support view); pass it to make the catalog the place where access is granted.
  Add a `pending` look by rendering the row from an items array your mutation
  layer marks — the component never guesses.
- Disclosure density: defaultOpen=true for a documentation page where every
  signature should be visible, false (default) for a settings panel where the
  automatic parameter-match rule does the opening.
- Grouping: items are rendered in the exact order given and never re-sorted, so
  sort by source, by name or by "recently used" in your data layer and the chips
  follow automatically.
- Chrome: label names the region, emptyState replaces the empty body with your own
  connect-a-server CTA, errorMessage + onRetry own the envelope failure,
  skeletonRows matches the placeholder count to your typical registry size.
- Columns: the parameter table is four columns (name / type / required /
  description); drop the description column for a dense panel, or add one for
  scopes or cost by extending the param schema — the contract is the only place
  that has to change.

Concepts

  • Search reaches into the signature — the haystack is the name, the description, the source and every parameter's name, type, description and default. "Which tool takes a repo?" is the question a catalog exists to answer, and a search that reads only labels answers none of them.
  • The row explains why it matched — when the hit is inside a parameter list the row opens itself and states the count, so the reader never has to expand ten rows hunting for the word they typed. One explicit toggle outranks that rule and is remembered per tool id, because a panel that re-opens on the next keystroke is unreadable.
  • Facet counts ignore the facets — chip counts follow the text search only. If pressing "GitHub" rewrote every other chip to 0, the numbers would describe the click you just made instead of where else the answer might be; a chip sitting at 0 is information, and it stays on screen.
  • No results is not empty — an empty registry is a connection problem ("connect a server"); a filter that excluded everything is a reading problem ("clear the search"). Same rectangle, opposite advice, so they are two different screens and only one of them is a contract state.
  • The switch reports intent, the data decidesonToggleEnabled(id, next) says what the human asked for; the switch only moves when a new items array comes back. That is the difference between a catalog that shows the policy your backend enforces and one that shows what somebody clicked before the request failed.
  • State is a word, never a colour — Required, On/Off, Enabled/Disabled and the built-in/server shapes are all readable in a monochrome theme, by a colour-blind reader and in a screenshot. Colour is the second signal here, never the first.

On This Page