/ src / hooks / useSlashCommandTypeahead.ts
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  }