File size: 11,842 Bytes
3d700dd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | 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,
};
};
|