import { useEffect, useMemo, useState } from "react"; import { settingsManager } from "../../settings-manager"; import { commands } from "../commands/registry"; import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation"; import { AutocompleteBox, AutocompleteItem } from "./Autocomplete"; import { Text } from "./Text"; import type { AutocompleteProps, CommandMatch } from "./types/autocomplete"; const VISIBLE_COMMANDS = 8; // Number of commands visible at once // Compute filtered command list (excluding hidden commands), sorted by order const _allCommands: CommandMatch[] = Object.entries(commands) .filter(([, { hidden }]) => !hidden) .map(([cmd, { desc, order }]) => ({ cmd, desc, order: order ?? 100, // Default order for commands without explicit order })) .sort((a, b) => a.order - b.order); // Extract the text after the "/" symbol where the cursor is positioned function extractSearchQuery( input: string, cursor: number, ): { query: string; hasSpaceAfter: boolean } | null { if (!input.startsWith("/")) return null; const afterSlash = input.slice(1); const spaceIndex = afterSlash.indexOf(" "); const endPos = spaceIndex === -1 ? input.length : 1 + spaceIndex; // Check if cursor is within this /command if (cursor < 0 || cursor > endPos) { return null; } const query = spaceIndex === -1 ? afterSlash : afterSlash.slice(0, spaceIndex); const hasSpaceAfter = spaceIndex !== -1; return { query, hasSpaceAfter }; } export function SlashCommandAutocomplete({ currentInput, cursorPosition = currentInput.length, onSelect, onAutocomplete, onActiveChange, agentId, workingDirectory = process.cwd(), }: AutocompleteProps) { const [matches, setMatches] = useState([]); const [customCommands, setCustomCommands] = useState([]); // Load custom commands once on mount useEffect(() => { import("../commands/custom.js").then(({ getCustomCommands }) => { getCustomCommands().then((customs) => { const matches: CommandMatch[] = customs.map((cmd) => ({ cmd: `/${cmd.id}`, // Include source/namespace in description for disambiguation desc: `${cmd.description} (${cmd.source}${cmd.namespace ? `:${cmd.namespace}` : ""})`, order: 200 + (cmd.source === "project" ? 0 : 100), })); setCustomCommands(matches); }); }); }, []); // Check pin status to conditionally show/hide pin/unpin commands, merge with custom commands const allCommands = useMemo(() => { let builtins = _allCommands; if (agentId) { try { const globalPinned = settingsManager.getGlobalPinnedAgents(); const localPinned = settingsManager.getLocalPinnedAgents(workingDirectory); const isPinnedGlobally = globalPinned.includes(agentId); const isPinnedLocally = localPinned.includes(agentId); const isPinnedAnywhere = isPinnedGlobally || isPinnedLocally; const isPinnedBoth = isPinnedGlobally && isPinnedLocally; builtins = _allCommands.filter((cmd) => { // Hide /pin if agent is pinned both locally AND globally if (cmd.cmd === "/pin" && isPinnedBoth) { return false; } // Hide /unpin if agent is not pinned anywhere if (cmd.cmd === "/unpin" && !isPinnedAnywhere) { return false; } return true; }); } catch (_error) { // If settings aren't loaded, just use all builtins builtins = _allCommands; } } // Merge with custom commands and sort by order return [...builtins, ...customCommands].sort( (a, b) => (a.order ?? 100) - (b.order ?? 100), ); }, [agentId, workingDirectory, customCommands]); const { selectedIndex } = useAutocompleteNavigation({ matches, onSelect: onSelect ? (item) => onSelect(item.cmd) : undefined, onAutocomplete: onAutocomplete ? (item) => onAutocomplete(item.cmd) : undefined, onActiveChange, }); // Update matches when input changes useEffect(() => { const result = extractSearchQuery(currentInput, cursorPosition); if (!result) { setMatches([]); return; } const { query, hasSpaceAfter } = result; // If there's a space after the command, user has moved on - hide autocomplete if (hasSpaceAfter) { setMatches([]); return; } let newMatches: CommandMatch[]; // If query is empty (just typed "/"), show all commands if (query.length === 0) { newMatches = allCommands; } else { // Filter commands that contain the query (case-insensitive) // Match against the command name without the leading "/" const lowerQuery = query.toLowerCase(); newMatches = allCommands.filter((item) => { const cmdName = item.cmd.slice(1).toLowerCase(); // Remove leading "/" return cmdName.includes(lowerQuery); }); } setMatches(newMatches); }, [currentInput, cursorPosition, allCommands]); // Don't show if input doesn't start with "/" if (!currentInput.startsWith("/")) { return null; } // Don't show if no matches if (matches.length === 0) { return null; } // Calculate visible window based on selected index const totalMatches = matches.length; const needsScrolling = totalMatches > VISIBLE_COMMANDS; let startIndex = 0; if (needsScrolling) { // Keep selected item visible, preferring to show it in the middle const halfWindow = Math.floor(VISIBLE_COMMANDS / 2); startIndex = Math.max(0, selectedIndex - halfWindow); startIndex = Math.min(startIndex, totalMatches - VISIBLE_COMMANDS); } const visibleMatches = matches.slice( startIndex, startIndex + VISIBLE_COMMANDS, ); const showScrollDown = startIndex + VISIBLE_COMMANDS < totalMatches; return ( {visibleMatches.map((item, idx) => { const actualIndex = startIndex + idx; return ( {item.cmd.padEnd(14)}{" "} {item.desc} ); })} {showScrollDown ? ( {" "}↓ {totalMatches - startIndex - VISIBLE_COMMANDS} more below ) : needsScrolling ? ( ) : null} ); }