useSlashCommandTypeahead.ts
1 import { useInput } from 'ink' 2 import { useState, useCallback } from 'react' 3 import { Command, getCommand } from '../commands.js' 4 5 type Props = { 6 commands: Command[] 7 onInputChange: (value: string) => void 8 onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void 9 setCursorOffset: (offset: number) => void 10 } 11 12 export function useSlashCommandTypeahead({ 13 commands, 14 onInputChange, 15 onSubmit, 16 setCursorOffset, 17 }: Props): { 18 suggestions: string[] 19 selectedSuggestion: number 20 updateSuggestions: (value: string) => void 21 clearSuggestions: () => void 22 } { 23 const [suggestions, setSuggestions] = useState<string[]>([]) 24 const [selectedSuggestion, setSelectedSuggestion] = useState(-1) 25 26 function updateSuggestions(value: string) { 27 if (value.startsWith('/')) { 28 const query = value.slice(1).toLowerCase() 29 30 // Find commands whose name or alias matches the query 31 const matchingCommands = commands 32 .filter(cmd => !cmd.isHidden) 33 .filter(cmd => { 34 const names = [cmd.userFacingName()] 35 if (cmd.aliases) { 36 names.push(...cmd.aliases) 37 } 38 return names.some(name => name.toLowerCase().startsWith(query)) 39 }) 40 41 // For each matching command, include its primary name 42 const filtered = matchingCommands.map(cmd => cmd.userFacingName()) 43 setSuggestions(filtered) 44 45 // Try to preserve the selected suggestion 46 const newIndex = 47 selectedSuggestion > -1 48 ? filtered.indexOf(suggestions[selectedSuggestion]!) 49 : 0 50 if (newIndex > -1) { 51 setSelectedSuggestion(newIndex) 52 } else { 53 setSelectedSuggestion(0) 54 } 55 } else { 56 setSuggestions([]) 57 setSelectedSuggestion(-1) 58 } 59 } 60 61 useInput((_, key) => { 62 if (suggestions.length > 0) { 63 // Handle suggestion navigation (up/down arrows) 64 if (key.downArrow) { 65 setSelectedSuggestion(prev => 66 prev >= suggestions.length - 1 ? 0 : prev + 1, 67 ) 68 return true 69 } else if (key.upArrow) { 70 setSelectedSuggestion(prev => 71 prev <= 0 ? suggestions.length - 1 : prev - 1, 72 ) 73 return true 74 } 75 76 // Handle selection completion via tab or return 77 else if (key.tab || (key.return && selectedSuggestion >= 0)) { 78 // Ensure a suggestion is selected 79 if (selectedSuggestion === -1 && key.tab) { 80 setSelectedSuggestion(0) 81 } 82 83 const suggestionIndex = selectedSuggestion >= 0 ? selectedSuggestion : 0 84 const suggestion = suggestions[suggestionIndex] 85 if (!suggestion) return true 86 87 const input = '/' + suggestion + ' ' 88 onInputChange(input) 89 // Manually move cursor to end 90 setCursorOffset(input.length) 91 setSuggestions([]) 92 setSelectedSuggestion(-1) 93 94 // If return was pressed and command doesn't take arguments, just run it 95 if (key.return) { 96 const command = getCommand(suggestion, commands) 97 if ( 98 command.type !== 'prompt' || 99 (command.argNames ?? []).length === 0 100 ) { 101 onSubmit(input, /* isSubmittingSlashCommand */ true) 102 } 103 } 104 105 return true 106 } 107 } 108 }) 109 110 const clearSuggestions = useCallback(() => { 111 setSuggestions([]) 112 setSelectedSuggestion(-1) 113 }, []) 114 115 return { 116 suggestions, 117 selectedSuggestion, 118 updateSuggestions, 119 clearSuggestions, 120 } 121 }