openhands / src /hooks /chat /use-slash-command.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3d700dd verified
Raw History Blame Contribute Delete
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,
};
};