diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index d1600c7a..a357eaa4 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -132,7 +132,7 @@ export const AgentMentionAutocomplete = React.forwardRef - + {agents.length ? (
{agents.map((agent, index) => renderAgent(agent, index))} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 4a9147b6..55838646 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -19,6 +19,7 @@ import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete'; import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete'; +import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; import { cn } from '@/lib/utils'; import { ServerFilePicker } from './ServerFilePicker'; import { ModelControls } from './ModelControls'; @@ -121,12 +122,15 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const [commandQuery, setCommandQuery] = React.useState(''); const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false); const [agentQuery, setAgentQuery] = React.useState(''); + const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); + const [skillQuery, setSkillQuery] = React.useState(''); const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); const textareaRef = React.useRef(null); const dropZoneRef = React.useRef(null); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const agentRef = React.useRef(null); + const skillRef = React.useRef(null); const sendMessage = useSessionStore((state) => state.sendMessage); const currentSessionId = useSessionStore((state) => state.currentSessionId); @@ -567,6 +571,14 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } + if (showSkillAutocomplete && skillRef.current) { + if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { + e.preventDefault(); + skillRef.current.handleKeyDown(e.key); + return; + } + } + if (showFileMention && mentionRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); @@ -704,10 +716,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setShowCommandAutocomplete(true); setShowFileMention(false); setShowAgentAutocomplete(false); - } else { - setShowCommandAutocomplete(false); + setShowSkillAutocomplete(false); + return; } - return; } setShowCommandAutocomplete(false); @@ -732,6 +743,25 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setShowAgentAutocomplete(false); setAgentQuery(''); + const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); + if (lastSlashSymbol !== -1) { + const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null; + const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1); + const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n'); + const isWordBoundary = !charBefore || /\s/.test(charBefore); + + if (isWordBoundary && !hasSeparator) { + setSkillQuery(textAfterSlash); + setShowSkillAutocomplete(true); + setShowFileMention(false); + setShowAgentAutocomplete(false); + return; + } + } + + setShowSkillAutocomplete(false); + setSkillQuery(''); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); @@ -744,7 +774,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } else { setShowFileMention(false); } - }, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention]); + }, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]); const insertTextAtSelection = React.useCallback((text: string) => { if (!text) { @@ -891,6 +921,36 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo textareaRef.current?.focus(); }; + const handleSkillSelect = (skillName: string) => { + const textarea = textareaRef.current; + const cursorPosition = textarea?.selectionStart ?? message.length; + const textBeforeCursor = message.substring(0, cursorPosition); + const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); + + if (lastSlashSymbol !== -1) { + const newMessage = + message.substring(0, lastSlashSymbol) + + `${skillName} ` + + message.substring(cursorPosition); + setMessage(newMessage); + + const nextCursor = lastSlashSymbol + skillName.length + 1; + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.selectionStart = nextCursor; + textareaRef.current.selectionEnd = nextCursor; + } + adjustTextareaHeight(); + updateAutocompleteState(newMessage, nextCursor); + }); + } + + setShowSkillAutocomplete(false); + setSkillQuery(''); + + textareaRef.current?.focus(); + }; + const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => { setMessage(`/${command.name} `); @@ -1341,11 +1401,21 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo ref={agentRef} searchQuery={agentQuery} onAgentSelect={handleAgentSelect} - onClose={() => setShowAgentAutocomplete(false)} - /> - )} - {} - {showFileMention && ( + onClose={() => setShowAgentAutocomplete(false)} + /> + )} + + {showSkillAutocomplete && ( + setShowSkillAutocomplete(false)} + /> + )} + + {showFileMention && ( + - + {loading ? (
diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx new file mode 100644 index 00000000..a77e5e8e --- /dev/null +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { useSkillsStore } from '@/stores/useSkillsStore'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; + +interface SkillInfo { + name: string; + scope: string; + description?: string; +} + +export interface SkillAutocompleteHandle { + handleKeyDown: (key: string) => void; +} + +interface SkillAutocompleteProps { + searchQuery: string; + onSkillSelect: (skillName: string) => void; + onClose: () => void; +} + +export const SkillAutocomplete = React.forwardRef(({ + searchQuery, + onSkillSelect, + onClose, +}, ref) => { + const containerRef = React.useRef(null); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [filteredSkills, setFilteredSkills] = React.useState([]); + const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const { skills, loadSkills } = useSkillsStore(); + + React.useEffect(() => { + // Always trigger loadSkills when autocomplete opens to ensure project context is fresh + void loadSkills(); + }, [loadSkills]); + + React.useEffect(() => { + const normalizedQuery = searchQuery.trim().toLowerCase(); + const matches = normalizedQuery.length + ? skills.filter((skill) => skill.name.toLowerCase().includes(normalizedQuery)) + : skills; + + const sorted = [...matches].sort((a, b) => { + // Sort by project scope first, then name + if (a.scope === 'project' && b.scope !== 'project') return -1; + if (a.scope !== 'project' && b.scope === 'project') return 1; + return a.name.localeCompare(b.name); + }); + + setFilteredSkills(sorted); + setSelectedIndex(0); + }, [skills, searchQuery]); + + React.useEffect(() => { + itemRefs.current[selectedIndex]?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }, [selectedIndex]); + + React.useEffect(() => { + const handlePointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null; + if (!target || !containerRef.current) { + return; + } + if (!containerRef.current.contains(target)) { + onClose(); + } + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + }; + }, [onClose]); + + React.useImperativeHandle(ref, () => ({ + handleKeyDown: (key: string) => { + if (key === 'Escape') { + onClose(); + return; + } + + if (!filteredSkills.length) { + return; + } + + if (key === 'ArrowDown') { + setSelectedIndex((prev) => (prev + 1) % filteredSkills.length); + return; + } + + if (key === 'ArrowUp') { + setSelectedIndex((prev) => (prev - 1 + filteredSkills.length) % filteredSkills.length); + return; + } + + if (key === 'Enter' || key === 'Tab') { + const skill = filteredSkills[selectedIndex]; + if (skill) { + onSkillSelect(skill.name); + } + } + }, + }), [filteredSkills, onSkillSelect, onClose, selectedIndex]); + + const renderSkill = (skill: SkillInfo, index: number) => ( +
{ + itemRefs.current[index] = el; + }} + className={cn( + 'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', + index === selectedIndex && 'bg-muted' + )} + onClick={() => onSkillSelect(skill.name)} + onMouseEnter={() => setSelectedIndex(index)} + > +
+
+ {skill.name} + + {skill.scope} + +
+ {skill.description && ( +
+ {skill.description} +
+ )} +
+
+ ); + + return ( +
+ + {filteredSkills.length ? ( +
+ {filteredSkills.map((skill, index) => renderSkill(skill, index))} +
+ ) : ( +
+ No skills found +
+ )} +
+
+ ↑↓ navigate • Enter select • Esc close +
+
+ ); +}); + +SkillAutocomplete.displayName = 'SkillAutocomplete'; diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 5e45a93f..18d18ba1 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -62,6 +62,7 @@ export interface DiscoveredSkill { path: string; scope: SkillScope; source: SkillSource; + description?: string; } export interface SkillConfig { @@ -154,7 +155,14 @@ export const useSkillsStore = create()( } const data = await response.json(); - const skills = (data.skills || []) as DiscoveredSkill[]; + const rawSkills = data.skills || []; + const skills = rawSkills.map((s: any) => ({ + name: s.name, + path: s.path, + scope: s.scope, + source: s.source, + description: s.sources?.md?.description || '', + })) as DiscoveredSkill[]; set({ skills, isLoading: false }); return true;