Download src/hooks/chat/use-slash-command.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 11.8 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/chat/use-slash-command.ts
- Command line
-
hf download hf://SaylorTwift/openhands/src/hooks/chat/use-slash-command.ts
-
curl -L -o use-slash-command.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/chat/use-slash-command.ts
11.8 kB
| 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<HTMLDivElement | null>, | |
| ) => { | |
| // 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<SlashCompletionKind>("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 "/<name>" 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<SlashCommandItem[]>(() => { | |
| 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, | |
| }; | |
| }; | |