import React, { useState, useEffect, useMemo, useRef } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Paper from '@mui/material/Paper'; import PsychologyIcon from '@mui/icons-material/Psychology'; import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; import LanguageIcon from '@mui/icons-material/Language'; import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; import SvgIcon from '@mui/material/SvgIcon'; import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined'; import { useAppSelector, useAppDispatch } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice'; import { fetchSkills } from '@/shared/state/skillsSlice'; const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => ( ); const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => ( ); const TOOL_GROUP_ICONS: Record> = { Google: GoogleIcon, Reddit: RedditIcon, Web: LanguageIcon, View: ViewQuiltOutlinedIcon, }; export function getToolGroupIcon(groupName: string, size: number = 15): React.ReactNode { const Icon = TOOL_GROUP_ICONS[groupName]; if (Icon) return ; return ; } export interface CommandPickerItem { id: string; type: 'skill' | 'mode' | 'context'; category: string; name: string; description: string; command: string; icon: React.ReactNode; toolNames?: string[]; iconKey?: string; } interface Props { trigger: '/' | '@'; filter: string; onSelect: (item: CommandPickerItem) => void; onClose: () => void; visible: boolean; } const MODE_ICON_MAP: Record> = { smart_toy: SmartToyOutlinedIcon, question_answer: QuestionAnswerOutlinedIcon, map: MapOutlinedIcon, category: CategoryOutlinedIcon, tune: TuneOutlinedIcon, }; function highlightMatch(text: string, query: string, color: string): React.ReactNode { if (!query) return text; const idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); } const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, visible }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const skills = useAppSelector((s) => s.skills.items); const modesMap = useAppSelector((s) => s.modes.items); const builtinTools = useAppSelector((s) => s.tools.builtinTools); const customTools = useAppSelector((s) => s.tools.items); const [selectedIndex, setSelectedIndex] = useState(0); const containerRef = useRef(null); const toolsLoaded = useAppSelector((s) => s.tools.loaded); const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded); const skillsLoaded = useAppSelector((s) => s.skills.loaded); useEffect(() => { if (!builtinLoaded) dispatch(fetchBuiltinTools()); if (!toolsLoaded) dispatch(fetchTools()); if (!skillsLoaded) dispatch(fetchSkills()); }, [dispatch, builtinLoaded, toolsLoaded, skillsLoaded]); const items: CommandPickerItem[] = useMemo(() => { let all: CommandPickerItem[] = []; if (trigger === '/') { const skillItems: CommandPickerItem[] = Object.values(skills).map((s) => ({ id: s.id, type: 'skill' as const, category: 'Skills', name: s.name, description: s.description || 'Skill', command: s.command || s.id, icon: , })); const modeItems: CommandPickerItem[] = Object.values(modesMap).map((m) => { const IconComp = MODE_ICON_MAP[m.icon] || SmartToyOutlinedIcon; return { id: m.id, type: 'mode' as const, category: 'Modes', name: m.name, description: m.description || 'Switch to this mode', command: m.name.toLowerCase().replace(/\s+/g, '-'), icon: , }; }); all = [...skillItems, ...modeItems]; } else { const atItems: CommandPickerItem[] = [ { id: 'file', type: 'context' as const, category: 'Context', name: 'File', description: 'Attach a file or folder as context', command: 'file', icon: , }, ]; const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred); const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred); if (hasWebSearch || hasWebFetch) { const webTools = [hasWebSearch && 'WebSearch', hasWebFetch && 'WebFetch'].filter(Boolean) as string[]; atItems.push({ id: 'web', type: 'context' as const, category: 'Actions', name: 'Web', description: 'Search the web and fetch URLs', command: 'web', icon: , toolNames: webTools, iconKey: 'Web', }); } for (const tool of Object.values(customTools)) { if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue; const services = tool.tool_permissions?._services as Record | undefined; if (!services) continue; const perms = tool.tool_permissions as Record; const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record; const enabledServices: { name: string; tools: string[] }[] = []; for (const [serviceName, serviceTools] of Object.entries(services)) { const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])]; const enabled = allToolNames.filter((name) => perms[name] !== 'deny'); if (enabled.length > 0) enabledServices.push({ name: serviceName, tools: enabled }); } if (enabledServices.length === 0) continue; const groupEntries = Object.entries(serviceGroups); const emittedServices = new Set(); for (const [groupName, groupServiceNames] of groupEntries) { const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-'); const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name)); if (groupServices.length === 0) continue; groupServices.forEach((s) => emittedServices.add(s.name)); const groupIcon = getToolGroupIcon(groupName); if (groupServices.length >= 2) { const allTools = groupServices.flatMap((s) => s.tools); atItems.push({ id: `mcp-${tool.id}-group-${groupName}`, type: 'context' as const, category: tool.name, name: groupName, description: `Use all ${groupName} actions`, command: groupCmd, icon: groupIcon, toolNames: allTools, iconKey: groupName, }); for (const svc of groupServices) { atItems.push({ id: `mcp-${tool.id}-${svc.name}`, type: 'context' as const, category: tool.name, name: svc.name, description: `Use ${svc.name} actions from ${tool.name}`, command: `${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`, icon: groupIcon, toolNames: svc.tools, iconKey: groupName, }); } } else { const svc = groupServices[0]; atItems.push({ id: `mcp-${tool.id}-${svc.name}`, type: 'context' as const, category: tool.name, name: svc.name, description: `Use ${svc.name} actions from ${tool.name}`, command: svc.name.toLowerCase().replace(/\s+/g, '-'), icon: groupIcon, toolNames: svc.tools, iconKey: groupName, }); } } for (const svc of enabledServices) { if (emittedServices.has(svc.name)) continue; atItems.push({ id: `mcp-${tool.id}-${svc.name}`, type: 'context' as const, category: tool.name, name: svc.name, description: `Use ${svc.name} actions from ${tool.name}`, command: svc.name.toLowerCase().replace(/\s+/g, '-'), icon: , toolNames: svc.tools, }); } } all = atItems; } if (!filter) return all; const lower = filter.toLowerCase(); return all.filter( (item) => item.name.toLowerCase().includes(lower) || item.command.toLowerCase().includes(lower) || item.description.toLowerCase().includes(lower), ); }, [trigger, skills, modesMap, builtinTools, customTools, filter]); const flatItems = useMemo(() => { const result: { item: CommandPickerItem; isGroupStart: boolean; category: string }[] = []; let lastCat = ''; for (const item of items) { result.push({ item, isGroupStart: item.category !== lastCat, category: item.category }); lastCat = item.category; } return result; }, [items]); const getIconColor = (item: CommandPickerItem): string => { switch (item.type) { case 'skill': return c.status.success; case 'mode': { const mode = modesMap[item.id]; return mode?.color || c.accent.primary; } case 'context': return c.text.tertiary; default: return c.text.tertiary; } }; useEffect(() => { setSelectedIndex(0); }, [filter, trigger]); useEffect(() => { if (!containerRef.current) return; const el = containerRef.current.querySelector(`[data-picker-idx="${selectedIndex}"]`); if (el) el.scrollIntoView({ block: 'nearest' }); }, [selectedIndex]); useEffect(() => { if (!visible) return; const handler = (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex((p) => (p < items.length - 1 ? p + 1 : p)); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex((p) => (p > 0 ? p - 1 : p)); break; case 'Enter': case 'Tab': if (items[selectedIndex]) { e.preventDefault(); onSelect(items[selectedIndex]); } break; case 'Escape': e.preventDefault(); onClose(); break; } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [visible, items, selectedIndex, onSelect, onClose]); if (!visible || items.length === 0) return null; return ( {flatItems.map(({ item, isGroupStart, category }, idx) => ( {isGroupStart && ( {category} )} onSelect(item)} onMouseEnter={() => setSelectedIndex(idx)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.25, py: 0.5, mx: 0.5, borderRadius: '8px', cursor: 'pointer', bgcolor: idx === selectedIndex ? `${c.accent.primary}0a` : 'transparent', '&:hover': { bgcolor: `${c.accent.primary}0a` }, transition: 'background-color 60ms ease', }} > {item.icon} {trigger}{highlightMatch(item.command, filter, c.accent.primary)} {item.description} ))} {[ { keys: '↑↓', label: 'navigate' }, { keys: '↵', label: 'select' }, { keys: 'esc', label: 'dismiss' }, ].map(({ keys, label }) => ( {keys} {label} ))} ); }; export default CommandPicker;