import { useEffect, useMemo, useRef, useState } from "react"; import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; import { Button } from "./button"; import { Input } from "./input"; import { cn } from "../lib/utils"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ToolSummary { readonly id: string; readonly name: string; readonly pluginKey: string; readonly description?: string; } type TreeNode = { segment: string; path: string; tool?: ToolSummary; children: Map; }; type Row = | { kind: "leaf"; depth: number; path: string; tool: ToolSummary } | { kind: "group"; depth: number; path: string; segment: string; count: number; open: boolean; }; // --------------------------------------------------------------------------- // Tree builder // --------------------------------------------------------------------------- const buildTree = (tools: readonly ToolSummary[]): TreeNode => { const root: TreeNode = { segment: "", path: "", children: new Map() }; for (const tool of tools) { const parts = tool.name.split("."); let node = root; let path = ""; for (const part of parts) { path = path ? `${path}.${part}` : part; let child = node.children.get(part); if (!child) { child = { segment: part, path, children: new Map() }; node.children.set(part, child); } node = child; } node.tool = tool; } return root; }; const countLeaves = (node: TreeNode): number => { let count = node.tool ? 1 : 0; for (const child of node.children.values()) { count += countLeaves(child); } return count; }; const collectGroupPaths = (node: TreeNode, acc: Set): void => { for (const child of node.children.values()) { if (child.children.size > 0) { acc.add(child.path); collectGroupPaths(child, acc); } } }; const flattenTree = ( node: TreeNode, depth: number, openSet: ReadonlySet, acc: Row[], ): void => { const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); for (const child of sorted) { const hasChildren = child.children.size > 0; const isLeaf = !!child.tool && !hasChildren; if (isLeaf) { acc.push({ kind: "leaf", depth, path: child.path, tool: child.tool! }); continue; } const open = openSet.has(child.path); acc.push({ kind: "group", depth, path: child.path, segment: child.segment, count: countLeaves(child), open, }); if (open) { flattenTree(child, depth + 1, openSet, acc); } } }; // --------------------------------------------------------------------------- // Highlight // --------------------------------------------------------------------------- const highlightMatch = (text: string, search: string) => { if (!search.trim()) return text; const terms = search.trim().toLowerCase().split(/\s+/); const lower = text.toLowerCase(); const ranges: [number, number][] = []; for (const term of terms) { let idx = 0; while (idx < lower.length) { const found = lower.indexOf(term, idx); if (found === -1) break; ranges.push([found, found + term.length]); idx = found + 1; } } if (ranges.length === 0) return text; ranges.sort((a, b) => a[0] - b[0]); const merged: [number, number][] = [ranges[0]!]; for (let i = 1; i < ranges.length; i++) { const last = merged[merged.length - 1]!; const cur = ranges[i]!; if (cur[0] <= last[1]) { last[1] = Math.max(last[1], cur[1]); } else { merged.push(cur); } } const parts: { text: string; hl: boolean }[] = []; let cursor = 0; for (const [start, end] of merged) { if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false }); parts.push({ text: text.slice(start, end), hl: true }); cursor = end; } if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false }); return ( <> {parts.map((p, i) => p.hl ? ( {p.text} ) : ( {p.text} ), )} ); }; // --------------------------------------------------------------------------- // ToolTree — main export // --------------------------------------------------------------------------- export function ToolTree(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; onSelect: (toolId: string) => void; }) { const { tools, selectedToolId, onSelect } = props; const [search, setSearch] = useState(""); const [manualOpen, setManualOpen] = useState>(() => new Set()); const searchRef = useRef(null); const selectedRowRef = useRef(null); const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); const filteredTools = useMemo(() => { if (terms.length === 0) return tools; return tools.filter((t) => { const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); return terms.every((term) => corpus.includes(term)); }); }, [tools, terms]); const tree = useMemo(() => buildTree(filteredTools), [filteredTools]); // When searching, expand everything so matches are visible. // Also auto-expand groups that contain the selected tool. const openSet = useMemo(() => { if (terms.length > 0) { const all = new Set(); collectGroupPaths(tree, all); return all; } const set = new Set(manualOpen); if (selectedToolId) { const parts = selectedToolId.split("."); // Progressively add ancestor paths (best-effort, based on dotted name). let acc = ""; for (let i = 0; i < parts.length - 1; i++) { acc = acc ? `${acc}.${parts[i]}` : parts[i]!; set.add(acc); } } return set; }, [tree, manualOpen, selectedToolId, terms.length]); const rows = useMemo(() => { const acc: Row[] = []; flattenTree(tree, 0, openSet, acc); return acc; }, [tree, openSet]); const toggleGroup = (path: string) => { setManualOpen((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; // Keyboard shortcuts — `/` focuses search, Escape clears useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { e.preventDefault(); searchRef.current?.focus(); } if (e.key === "Escape") { searchRef.current?.blur(); if (search.length > 0) setSearch(""); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [search]); // Scroll the selected row into view when it changes useEffect(() => { if (!selectedToolId) return; selectedRowRef.current?.scrollIntoView({ block: "nearest" }); }, [selectedToolId, rows]); return (
setSearch(e.target.value)} placeholder={`Filter ${tools.length} tools…`} aria-label="Filter tools" className="h-auto min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-xs shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0" /> {search.length > 0 && ( )}
{filteredTools.length === 0 ? (
{terms.length > 0 ? "No tools match your filter" : "No tools available"}
) : ( rows.map((row) => row.kind === "leaf" ? ( onSelect(row.tool.id)} search={search} /> ) : ( toggleGroup(row.path)} search={search} /> ), ) )}
); } // --------------------------------------------------------------------------- // Row renderers // --------------------------------------------------------------------------- const rowIndent = (depth: number) => 12 + depth * 16; const rowBaseClasses = "relative flex h-auto w-full items-center justify-start gap-2 rounded-none py-2 text-xs font-normal transition-[background-color] duration-150"; function ToolGroupRow(props: { segment: string; depth: number; count: number; open: boolean; onToggle: () => void; search: string; }) { return ( ); } function ToolLeafRow(props: { buttonRef?: React.Ref; tool: ToolSummary; depth: number; active: boolean; onSelect: () => void; search: string; }) { const label = props.tool.name.split(".").pop() ?? props.tool.name; return ( ); }