package ui import ( "fmt" "sort" "strings" tea "charm.land/bubbletea/v2" "github.com/mark3labs/kit/internal/models" ) // ModelEntry holds display metadata for a single model in the selector. type ModelEntry struct { Provider string ModelID string Name string // human-friendly name (e.g. "Claude Haiku 4.5") ContextLimit int Reasoning bool } // ModelSelectedMsg is sent when the user selects a model from the selector. type ModelSelectedMsg struct { ModelString string // "provider/model-id" } // ModelSelectorCancelledMsg is sent when the user cancels the selector. type ModelSelectorCancelledMsg struct{} // ModelSelectorComponent is a Bubble Tea component that displays a filterable // list of available models as a centered overlay popup. It delegates rendering // and keyboard navigation to PopupList and converts results into the // ModelSelectedMsg / ModelSelectorCancelledMsg messages expected by AppModel. type ModelSelectorComponent struct { popup *PopupList allModels []ModelEntry // kept for the custom filter callback currentModel string // "provider/model" of the active model width int height int active bool } // NewModelSelector creates a model selector populated from the global registry, // filtered to only providers with configured API keys. func NewModelSelector(currentModel string, width, height int) *ModelSelectorComponent { registry := models.GetGlobalRegistry() var allModels []ModelEntry for _, providerID := range registry.GetLLMProviders() { // Only include providers with valid API keys configured. if err := registry.ValidateEnvironment(providerID, ""); err != nil { continue } modelsMap, err := registry.GetModelsForProvider(providerID) if err != nil { continue } // For the custom provider, skip the built-in "custom" stub when // user-defined models are present — the stub is a fallback for // --provider-url usage and would just clutter the list. userDefinedCustomModels := 0 if providerID == "custom" { for modelID := range modelsMap { if modelID != "custom" { userDefinedCustomModels++ } } } for modelID, info := range modelsMap { if providerID == "custom" && modelID == "custom" && userDefinedCustomModels > 0 { continue } allModels = append(allModels, ModelEntry{ Provider: providerID, ModelID: modelID, Name: info.Name, ContextLimit: info.Limit.Context, Reasoning: info.Reasoning, }) } } // Sort: alphabetically by model ID, grouped by provider. sort.Slice(allModels, func(i, j int) bool { if allModels[i].Provider != allModels[j].Provider { return allModels[i].Provider < allModels[j].Provider } return allModels[i].ModelID < allModels[j].ModelID }) // Build PopupItems from model entries. items := make([]PopupItem, len(allModels)) for i, m := range allModels { items[i] = PopupItem{ Label: m.ModelID, Description: fmt.Sprintf("[%s]", m.Provider), Active: m.Provider+"/"+m.ModelID == currentModel, Meta: m, } } popup := NewPopupList("Model Selector", items, width, height) popup.Subtitle = "Only showing models with configured API keys" popup.FilterFunc = func(query string, allItems []PopupItem) []PopupItem { return filterModels(query, allItems) } return &ModelSelectorComponent{ popup: popup, allModels: allModels, currentModel: currentModel, width: width, height: height, active: true, } } // Init implements tea.Model. func (ms *ModelSelectorComponent) Init() tea.Cmd { return nil } // Update implements tea.Model. func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: ms.width = msg.Width ms.height = msg.Height ms.popup.SetSize(msg.Width, msg.Height) return ms, nil case tea.KeyPressMsg: result := ms.popup.HandleKey(msg.String(), msg.Text) if result.Selected != nil { ms.active = false entry := result.Selected.Meta.(ModelEntry) modelStr := entry.Provider + "/" + entry.ModelID return ms, func() tea.Msg { return ModelSelectedMsg{ModelString: modelStr} } } if result.Cancelled { ms.active = false return ms, func() tea.Msg { return ModelSelectorCancelledMsg{} } } } return ms, nil } // View implements tea.Model — not used for overlay rendering. // Use RenderOverlay for the centered overlay approach. func (ms *ModelSelectorComponent) View() tea.View { // Fallback full-screen rendering (unused when rendered as overlay). v := tea.NewView(ms.popup.RenderCentered(ms.width, ms.height)) v.AltScreen = true return v } // RenderOverlay returns the popup as a centered overlay string, ready to be // composited on top of the main content via overlayContent(). func (ms *ModelSelectorComponent) RenderOverlay(termWidth, termHeight int) string { return ms.popup.RenderCentered(termWidth, termHeight) } // IsActive returns whether the selector is still accepting input. func (ms *ModelSelectorComponent) IsActive() bool { return ms.active } // --- Model-specific fuzzy filter --- // filterModels scores and filters PopupItems whose Meta is a ModelEntry. func filterModels(query string, items []PopupItem) []PopupItem { if query == "" { return items } q := strings.ToLower(query) type scored struct { item PopupItem score int } var matches []scored for _, item := range items { entry, ok := item.Meta.(ModelEntry) if !ok { continue } s := fuzzyScoreModelEntry(q, entry) if s > 0 { matches = append(matches, scored{item: item, score: s}) } } sort.Slice(matches, func(i, j int) bool { if matches[i].score != matches[j].score { return matches[i].score > matches[j].score } a := matches[i].item.Meta.(ModelEntry) b := matches[j].item.Meta.(ModelEntry) return a.ModelID < b.ModelID }) result := make([]PopupItem, len(matches)) for i, m := range matches { result[i] = m.item } return result } // fuzzyScoreModelEntry scores a model entry against the search query. func fuzzyScoreModelEntry(query string, entry ModelEntry) int { modelID := strings.ToLower(entry.ModelID) provider := strings.ToLower(entry.Provider) name := strings.ToLower(entry.Name) combined := provider + "/" + modelID // Exact match on combined provider/model. if combined == query { return 1000 } // Exact match on model ID. if modelID == query { return 950 } // Prefix match on model ID. if strings.HasPrefix(modelID, query) { return 800 - len(modelID) + len(query) } // Prefix match on combined. if strings.HasPrefix(combined, query) { return 750 - len(combined) + len(query) } // Contains match on model ID. if strings.Contains(modelID, query) { return 600 } // Contains match on combined. if strings.Contains(combined, query) { return 550 } // Contains match on name. if strings.Contains(name, query) { return 400 } // Character-by-character fuzzy match on model ID. if s := fuzzyCharacterMatch(query, modelID); s > 0 { return s } // Fuzzy match on combined. if s := fuzzyCharacterMatch(query, combined); s > 0 { return s - 20 } return 0 }