package autocomplete import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/sahilm/fuzzy" ) type Autocomplete struct { input textinput.Model allSuggestions []string // All available suggestions (newest first) filteredSuggestions []string // Currently matching suggestions matchedIndexes [][]int // Matched character positions for each suggestion selectedIndex int // -1 = input focused, 0+ = suggestion selected showSuggestions bool // Whether to display suggestion box maxVisible int // Max suggestions to show minChars int // Min chars before showing suggestions focused bool width int placeholder string } // New creates a new autocomplete component func New(suggestions []string, minChars int, maxVisible int) *Autocomplete { ti := textinput.New() ti.Width = 50 return &Autocomplete{ input: ti, allSuggestions: suggestions, selectedIndex: -1, maxVisible: maxVisible, minChars: minChars, width: 50, } } // SetValue sets the input value func (a *Autocomplete) SetValue(value string) { a.input.SetValue(value) a.updateFilteredSuggestions() } // GetValue returns the current input value func (a *Autocomplete) GetValue() string { return a.input.Value() } // Focus focuses the autocomplete input func (a *Autocomplete) Focus() { a.focused = true a.input.Focus() } // Blur blurs the autocomplete input func (a *Autocomplete) Blur() { a.focused = false a.input.Blur() a.showSuggestions = false } // SetPlaceholder sets the placeholder text func (a *Autocomplete) SetPlaceholder(placeholder string) { a.placeholder = placeholder a.input.Placeholder = placeholder } // SetWidth sets the width of the autocomplete func (a *Autocomplete) SetWidth(width int) { a.width = width a.input.Width = width } // SetMaxVisible sets the maximum number of visible suggestions func (a *Autocomplete) SetMaxVisible(max int) { a.maxVisible = max } // SetMinChars sets the minimum characters required before showing suggestions func (a *Autocomplete) SetMinChars(min int) { a.minChars = min } // SetSuggestions updates the available suggestions func (a *Autocomplete) SetSuggestions(suggestions []string) { a.allSuggestions = suggestions a.updateFilteredSuggestions() } // HasSuggestions returns true if the autocomplete is currently showing suggestions func (a *Autocomplete) HasSuggestions() bool { return a.showSuggestions && len(a.filteredSuggestions) > 0 } // Init initializes the autocomplete func (a *Autocomplete) Init() tea.Cmd { return textinput.Blink } // Update handles messages for the autocomplete func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !a.focused { return a, nil } var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, key.NewBinding(key.WithKeys("down"))): if a.showSuggestions && len(a.filteredSuggestions) > 0 { a.selectedIndex++ if a.selectedIndex >= len(a.filteredSuggestions) { a.selectedIndex = 0 } return a, nil } case key.Matches(msg, key.NewBinding(key.WithKeys("up"))): if a.showSuggestions && len(a.filteredSuggestions) > 0 { a.selectedIndex-- if a.selectedIndex < 0 { a.selectedIndex = len(a.filteredSuggestions) - 1 } return a, nil } case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) { // Accept selected suggestion a.input.SetValue(a.filteredSuggestions[a.selectedIndex]) a.showSuggestions = false a.selectedIndex = -1 return a, nil } case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): if a.showSuggestions && len(a.filteredSuggestions) > 0 { // Accept first or selected suggestion if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) { a.input.SetValue(a.filteredSuggestions[a.selectedIndex]) } else { a.input.SetValue(a.filteredSuggestions[0]) } a.showSuggestions = false a.selectedIndex = -1 return a, nil } case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): if a.showSuggestions { a.showSuggestions = false a.selectedIndex = -1 return a, nil } default: // Handle regular text input prevValue := a.input.Value() a.input, cmd = a.input.Update(msg) // Update suggestions if value changed if a.input.Value() != prevValue { a.updateFilteredSuggestions() } return a, cmd } } a.input, cmd = a.input.Update(msg) return a, cmd } // View renders the autocomplete func (a *Autocomplete) View() string { // Input field inputView := a.input.View() if !a.showSuggestions || len(a.filteredSuggestions) == 0 { return inputView } // Suggestion box var suggestionViews []string for i, suggestion := range a.filteredSuggestions { if i >= a.maxVisible { break } prefix := " " baseStyle := lipgloss.NewStyle().Padding(0, 1) if i == a.selectedIndex { // Highlight selected suggestion baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12")) prefix = "→ " } // Build suggestion with highlighted matched characters var rendered string if i < len(a.matchedIndexes) { rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex) } else { rendered = suggestion } suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered)) } // Box style boxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("8")). Width(a.width) suggestionsBox := boxStyle.Render( lipgloss.JoinVertical(lipgloss.Left, suggestionViews...), ) return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox) } // renderWithHighlights renders a suggestion with matched characters highlighted func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string { if len(matchedIndexes) == 0 { return str } // Create a map for quick lookup matchedMap := make(map[int]bool) for _, idx := range matchedIndexes { matchedMap[idx] = true } // Choose highlight style based on selection state var highlightStyle lipgloss.Style if isSelected { // When selected, use underline to distinguish from selection bold highlightStyle = lipgloss.NewStyle().Underline(true) } else { // When not selected, use bold and accent color highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) } // Build the string with highlights var result string runes := []rune(str) for i, r := range runes { if matchedMap[i] { result += highlightStyle.Render(string(r)) } else { result += string(r) } } return result } // updateFilteredSuggestions filters suggestions based on current input func (a *Autocomplete) updateFilteredSuggestions() { value := a.input.Value() // Only show if >= minChars if len(value) < a.minChars { a.showSuggestions = false a.filteredSuggestions = nil a.matchedIndexes = nil a.selectedIndex = -1 return } // Fuzzy match using sahilm/fuzzy matches := fuzzy.Find(value, a.allSuggestions) var filtered []string var indexes [][]int for _, match := range matches { filtered = append(filtered, match.Str) indexes = append(indexes, match.MatchedIndexes) if len(filtered) >= a.maxVisible { break } } a.filteredSuggestions = filtered a.matchedIndexes = indexes a.showSuggestions = len(filtered) > 0 && a.focused a.selectedIndex = -1 // Reset to input }