package autocomplete import ( "strings" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type Autocomplete struct { input textinput.Model allSuggestions []string // All available suggestions (newest first) filteredSuggestions []string // Currently matching suggestions 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) *Autocomplete { ti := textinput.New() ti.Width = 50 return &Autocomplete{ input: ti, allSuggestions: suggestions, selectedIndex: -1, maxVisible: 5, 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 } // 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 := " " style := lipgloss.NewStyle().Padding(0, 1) if i == a.selectedIndex { // Highlight selected suggestion style = style.Bold(true).Foreground(lipgloss.Color("12")) prefix = "→ " } suggestionViews = append(suggestionViews, style.Render(prefix+suggestion)) } // 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) } // updateFilteredSuggestions filters suggestions based on current input func (a *Autocomplete) updateFilteredSuggestions() { value := strings.ToLower(a.input.Value()) // Only show if >= minChars if len(value) < a.minChars { a.showSuggestions = false a.filteredSuggestions = nil a.selectedIndex = -1 return } // Substring match (case-insensitive) var filtered []string for _, suggestion := range a.allSuggestions { if strings.Contains(strings.ToLower(suggestion), value) { filtered = append(filtered, suggestion) if len(filtered) >= a.maxVisible { break } } } a.filteredSuggestions = filtered a.showSuggestions = len(filtered) > 0 && a.focused a.selectedIndex = -1 // Reset to input }