import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useConversationSkills } from "#/hooks/query/use-conversation-skills"; import { SkillInfo } from "#/types/settings"; import { BUILT_IN_COMMANDS, MODEL_COMMAND } from "#/utils/constants"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; import { useFreeModels } from "#/hooks/query/use-free-models"; import { formatModelNameForDisplay } from "#/utils/format-model-name"; export type SlashCommandSkill = SkillInfo; export interface SlashCommandItem { skill: SlashCommandSkill; /** The slash command string, e.g. "/random-number" */ command: string; } type SlashCompletionKind = "command" | "model-profile"; /** Get the cursor's character offset within a contentEditable element. */ function getCursorOffset(element: HTMLElement): number { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return -1; const range = selection.getRangeAt(0); const preRange = range.cloneRange(); preRange.selectNodeContents(element); preRange.setEnd(range.startContainer, range.startOffset); return preRange.toString().length; } /** * Hook for managing slash command autocomplete in the chat input. * Detects when user types "/" and provides filtered skill suggestions. * Only skills with explicit "/" triggers (TaskTrigger) appear in the menu. */ export const useSlashCommand = ( chatInputRef: React.RefObject, ) => { // Scope the skill catalog to this conversation's attached workspace so the // slash menu lists the same project skills that were loaded into it. const { data: skills, isLoading: isSkillsLoading } = useConversationSkills(); const isCloud = useActiveBackend().backend.kind === "cloud"; const { data: profilesData, isLoading: isProfilesLoading } = useLlmProfiles(); const freeModels = useFreeModels(); const [isMenuOpen, setIsMenuOpen] = useState(false); const [filterText, setFilterText] = useState(""); const [completionKind, setCompletionKind] = useState("command"); const [selectedIndex, setSelectedIndex] = useState(0); // Build slash command items from built-in commands + skills: // - Built-in commands (like /new) are included for V1 conversations // - /new is cloud-only — local backends don't surface it // - /model lists/switches LLM profiles; both local and cloud support them // - Skills with explicit "/" triggers use those triggers // - AgentSkills without "/" triggers get a derived "/" command const slashItems = useMemo(() => { const items: SlashCommandItem[] = BUILT_IN_COMMANDS.filter((cmd) => { if (cmd.command === "/new") return isCloud; return true; }); // Wait for skills to finish initial load so all commands appear together if (isSkillsLoading) return items; if (!skills) return items; skills.forEach((skill) => { const triggers = skill.triggers || []; const slashTriggers = triggers.filter((t) => t.startsWith("/")); if (slashTriggers.length > 0) { // Skill has explicit slash triggers slashTriggers.forEach((trigger) => { items.push({ skill, command: trigger }); }); } else if (skill.type === "agentskills") { // AgentSkills without slash triggers get a derived command items.push({ skill, command: `/${skill.name}` }); } }); return items; }, [skills, isSkillsLoading, isCloud]); const modelProfileItems = useMemo(() => { return (profilesData?.profiles ?? []).map((profile) => { const command = `${MODEL_COMMAND} ${profile.name}`; return { command, skill: { name: profile.name, type: "agentskills", source: null, content: profile.model ? `Switch to ${formatModelNameForDisplay(profile.model, freeModels)}` : "Switch to this LLM profile", triggers: [command], }, }; }); }, [profilesData?.profiles, freeModels]); // Filter items based on user input after "/" const filteredItems = useMemo(() => { const sourceItems = completionKind === "model-profile" ? modelProfileItems : slashItems; if (!filterText) return sourceItems; const lower = filterText.toLowerCase(); return sourceItems.filter( (item) => item.command.toLowerCase().includes(lower) || item.skill.name.toLowerCase().includes(lower) || item.skill.content?.toLowerCase().includes(lower), ); }, [completionKind, modelProfileItems, slashItems, filterText]); // Keep refs in sync so handleSlashKeyDown always reads the latest values, // avoiding stale closures from React's batched state updates. const isMenuOpenRef = useRef(isMenuOpen); isMenuOpenRef.current = isMenuOpen; const filteredItemsRef = useRef(filteredItems); filteredItemsRef.current = filteredItems; const selectedIndexRef = useRef(selectedIndex); selectedIndexRef.current = selectedIndex; // Reset selected index when the filter text changes useEffect(() => { setSelectedIndex(0); }, [filterText]); // Track the character range of the current slash word so selectItem can // replace only that portion instead of wiping the entire input. const slashRangeRef = useRef<{ start: number; end: number } | null>(null); // Detect a slash word at the cursor position. // Returns the filter text (characters after "/") and the range of the // slash word within the full input text, or null if no slash word found. const getSlashText = useCallback((): { kind: SlashCompletionKind; text: string; start: number; end: number; } | null => { const element = chatInputRef.current; if (!element) return null; // Strip trailing newlines that contentEditable can produce, but preserve // spaces so "/command " (after selection) won't re-trigger the menu. const text = (element.innerText || "").replace(/[\n\r]+$/, ""); const cursor = getCursorOffset(element); if (cursor < 0) return null; const textBeforeCursor = text.slice(0, cursor); const modelMatch = textBeforeCursor.match(/(^|\s)(\/model(?:\s+\S*)?)$/); if (modelMatch) { const modelCommand = modelMatch[2]; const start = textBeforeCursor.length - modelCommand.length; const afterCursor = text.slice(cursor); const trailing = afterCursor.match(/^\S*/); const end = cursor + (trailing ? trailing[0].length : 0); return { kind: "model-profile", text: modelCommand.replace(/^\/model(?:\s+)?/, ""), start, end, }; } // Match a "/" preceded by whitespace or at position 0, followed by // non-whitespace characters, ending right at the cursor. const match = textBeforeCursor.match(/(^|\s)(\/\S*)$/); if (!match) return null; const slashWord = match[2]; // e.g. "/hel" const start = textBeforeCursor.length - slashWord.length; // The end of the slash word extends past the cursor to include any // contiguous non-whitespace characters (covers the case where the // cursor sits in the middle of a word). const afterCursor = text.slice(cursor); const trailing = afterCursor.match(/^\S*/); const end = cursor + (trailing ? trailing[0].length : 0); return { kind: "command", text: slashWord.slice(1), start, end }; // strip leading "/" }, [chatInputRef]); // Update the menu state based on current input const updateSlashMenu = useCallback(() => { const result = getSlashText(); const hasItems = result?.kind === "model-profile" ? modelProfileItems.length > 0 || isProfilesLoading : slashItems.length > 0; if (result !== null && hasItems) { setCompletionKind(result.kind); setFilterText(result.text); slashRangeRef.current = { start: result.start, end: result.end }; setIsMenuOpen(true); } else { setIsMenuOpen(false); setFilterText(""); setCompletionKind("command"); slashRangeRef.current = null; } }, [ getSlashText, isProfilesLoading, modelProfileItems.length, slashItems.length, ]); // Select an item and replace only the slash word with the command const selectItem = useCallback( (item: SlashCommandItem) => { const element = chatInputRef.current; if (!element) return; const slashRange = slashRangeRef.current; const currentText = (element.innerText || "").replace(/[\n\r]+$/, ""); const replacement = `${item.command} `; if (slashRange) { // Splice the command into the text, replacing only the slash word element.textContent = currentText.slice(0, slashRange.start) + replacement + currentText.slice(slashRange.end); // Position cursor right after the inserted command + space const cursorPos = slashRange.start + replacement.length; const textNode = element.firstChild; if (textNode) { const range = document.createRange(); const sel = window.getSelection(); const offset = Math.min(cursorPos, textNode.textContent!.length); range.setStart(textNode, offset); range.collapse(true); sel?.removeAllRanges(); sel?.addRange(range); } } else { // Fallback: replace everything (e.g. if range tracking failed) element.textContent = replacement; const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(element); range.collapse(false); sel?.removeAllRanges(); sel?.addRange(range); } setIsMenuOpen(false); setFilterText(""); setCompletionKind("command"); setSelectedIndex(0); slashRangeRef.current = null; // Trigger a native InputEvent so React's onInput fires (for smartResize etc.) element.dispatchEvent(new InputEvent("input", { bubbles: true })); // Restore focus so keyboard events (Enter to submit) work after selection element.focus(); }, [chatInputRef], ); // Handle keyboard navigation in the menu. // Uses refs to always read the latest state, avoiding stale closures. const handleSlashKeyDown = useCallback( (e: React.KeyboardEvent): boolean => { const items = filteredItemsRef.current; if (!isMenuOpenRef.current || items.length === 0) return false; switch (e.key) { case "ArrowDown": e.preventDefault(); setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0)); return true; case "ArrowUp": e.preventDefault(); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)); return true; case "Enter": case "Tab": { const item = items[selectedIndexRef.current]; if (!item) return false; e.preventDefault(); selectItem(item); return true; } case "Escape": e.preventDefault(); setIsMenuOpen(false); return true; // Cursor-movement keys: close the menu to avoid acting on a stale // slash-word range, but don't consume the event so the cursor moves. case "ArrowLeft": case "ArrowRight": case "Home": case "End": setIsMenuOpen(false); return false; default: return false; } }, [selectItem], ); const closeMenu = useCallback(() => setIsMenuOpen(false), []); return { isMenuOpen, filteredItems, selectedIndex, updateSlashMenu, selectItem, handleSlashKeyDown, closeMenu, }; };