feat(ui): model selection dialog

Ayman Bagabas created

Change summary

internal/ui/common/elements.go    |  14 
internal/ui/dialog/commands.go    |  14 
internal/ui/dialog/models.go      | 445 +++++++++++++++++++++++++++++++++
internal/ui/dialog/models_item.go |  95 +++++++
internal/ui/dialog/models_list.go | 163 ++++++++++++
internal/ui/model/ui.go           |  23 +
internal/ui/styles/styles.go      |  24 +
7 files changed, 760 insertions(+), 18 deletions(-)

Detailed changes

internal/ui/common/elements.go 🔗

@@ -134,13 +134,23 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string {
 
 // Section renders a section header with a title and a horizontal line filling
 // the remaining width.
-func Section(t *styles.Styles, text string, width int) string {
+func Section(t *styles.Styles, text string, width int, info ...string) string {
 	char := styles.SectionSeparator
 	length := lipgloss.Width(text) + 1
 	remainingWidth := width - length
+
+	var infoText string
+	if len(info) > 0 {
+		infoText = strings.Join(info, " ")
+		if len(infoText) > 0 {
+			infoText = " " + infoText
+			remainingWidth -= lipgloss.Width(infoText)
+		}
+	}
+
 	text = t.Section.Title.Render(text)
 	if remainingWidth > 0 {
-		text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth))
+		text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText
 	}
 	return text
 }

internal/ui/dialog/commands.go 🔗

@@ -197,22 +197,23 @@ func (c *Commands) Cursor() *tea.Cursor {
 	return InputCursor(c.com.Styles, c.input.Cursor())
 }
 
-// radioView generates the command type selector radio buttons.
-func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
+// commandsRadioView generates the command type selector radio buttons.
+func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
 	if !hasUserCmds && !hasMCPPrompts {
 		return ""
 	}
 
 	selectedFn := func(t uicmd.CommandType) string {
 		if t == selected {
-			return " ◉ " + t.String()
+			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
 		}
-		return " ○ " + t.String()
+		return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
 	}
 
 	parts := []string{
 		selectedFn(uicmd.SystemCommands),
 	}
+
 	if hasUserCmds {
 		parts = append(parts, selectedFn(uicmd.UserCommands))
 	}
@@ -220,14 +221,13 @@ func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, h
 		parts = append(parts, selectedFn(uicmd.MCPPrompts))
 	}
 
-	radio := strings.Join(parts, " ")
-	return t.Dialog.Commands.CommandTypeSelector.Render(radio)
+	return strings.Join(parts, " ")
 }
 
 // View implements [Dialog].
 func (c *Commands) View() string {
 	t := c.com.Styles
-	radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
+	radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
 	titleStyle := t.Dialog.Title
 	dialogStyle := t.Dialog.View.Width(c.width)
 	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()

internal/ui/dialog/models.go 🔗

@@ -0,0 +1,445 @@
+package dialog
+
+import (
+	"cmp"
+	"fmt"
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+)
+
+// ModelType represents the type of model to select.
+type ModelType int
+
+const (
+	ModelTypeLarge ModelType = iota
+	ModelTypeSmall
+)
+
+// String returns the string representation of the [ModelType].
+func (mt ModelType) String() string {
+	switch mt {
+	case ModelTypeLarge:
+		return "Large Task"
+	case ModelTypeSmall:
+		return "Small Task"
+	default:
+		return "Unknown"
+	}
+}
+
+const (
+	largeModelInputPlaceholder = "Choose a model for large, complex tasks"
+	smallModelInputPlaceholder = "Choose a model for small, simple tasks"
+)
+
+// ModelsID is the identifier for the model selection dialog.
+const ModelsID = "models"
+
+// Models represents a model selection dialog.
+type Models struct {
+	com *common.Common
+
+	modelType ModelType
+	providers []catwalk.Provider
+
+	width, height int
+
+	keyMap struct {
+		Tab      key.Binding
+		UpDown   key.Binding
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		Close    key.Binding
+	}
+	list  *ModelsList
+	input textinput.Model
+	help  help.Model
+}
+
+var _ Dialog = (*Models)(nil)
+
+// NewModels creates a new Models dialog.
+func NewModels(com *common.Common) (*Models, error) {
+	t := com.Styles
+	m := &Models{}
+	m.com = com
+	help := help.New()
+	help.Styles = t.DialogHelpStyles()
+
+	m.help = help
+	m.list = NewModelsList(t)
+	m.list.Focus()
+	m.list.SetSelected(0)
+
+	m.input = textinput.New()
+	m.input.SetVirtualCursor(false)
+	m.input.Placeholder = largeModelInputPlaceholder
+	m.input.SetStyles(com.Styles.TextInput)
+	m.input.Focus()
+
+	m.keyMap.Tab = key.NewBinding(
+		key.WithKeys("tab", "shift+tab"),
+		key.WithHelp("tab", "toggle type"),
+	)
+	m.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	m.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	m.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	m.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	m.keyMap.Close = CloseKey
+
+	providers, err := getFilteredProviders(com.Config())
+	if err != nil {
+		return nil, fmt.Errorf("failed to get providers: %w", err)
+	}
+
+	m.providers = providers
+	if err := m.setProviderItems(); err != nil {
+		return nil, fmt.Errorf("failed to set provider items: %w", err)
+	}
+
+	return m, nil
+}
+
+// SetSize sets the size of the dialog.
+func (m *Models) SetSize(width, height int) {
+	t := m.com.Styles
+	m.width = width
+	m.height = height
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	m.list.SetSize(innerWidth, height-heightOffset)
+	m.help.SetWidth(width)
+}
+
+// ID implements Dialog.
+func (m *Models) ID() string {
+	return ModelsID
+}
+
+// Update implements Dialog.
+func (m *Models) Update(msg tea.Msg) tea.Msg {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Close):
+			return CloseMsg{}
+		case key.Matches(msg, m.keyMap.Previous):
+			m.list.Focus()
+			if m.list.IsSelectedFirst() {
+				m.list.SelectLast()
+				m.list.ScrollToBottom()
+				break
+			}
+			m.list.SelectPrev()
+			m.list.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Next):
+			m.list.Focus()
+			if m.list.IsSelectedLast() {
+				m.list.SelectFirst()
+				m.list.ScrollToTop()
+				break
+			}
+			m.list.SelectNext()
+			m.list.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Select):
+			if selectedItem := m.list.SelectedItem(); selectedItem != nil {
+				// TODO: Handle model selection confirmation.
+			}
+		case key.Matches(msg, m.keyMap.Tab):
+			if m.modelType == ModelTypeLarge {
+				m.modelType = ModelTypeSmall
+			} else {
+				m.modelType = ModelTypeLarge
+			}
+			if err := m.setProviderItems(); err != nil {
+				return uiutil.ReportError(err)
+			}
+		default:
+			var cmd tea.Cmd
+			m.input, cmd = m.input.Update(msg)
+			value := m.input.Value()
+			m.list.SetFilter(value)
+			m.list.ScrollToSelected()
+			return cmd
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor for the dialog.
+func (m *Models) Cursor() *tea.Cursor {
+	return InputCursor(m.com.Styles, m.input.Cursor())
+}
+
+// modelTypeRadioView returns the radio view for model type selection.
+func (m *Models) modelTypeRadioView() string {
+	t := m.com.Styles
+	textStyle := t.HalfMuted
+	largeRadioStyle := t.RadioOff
+	smallRadioStyle := t.RadioOff
+	if m.modelType == ModelTypeLarge {
+		largeRadioStyle = t.RadioOn
+	} else {
+		smallRadioStyle = t.RadioOn
+	}
+
+	largeRadio := largeRadioStyle.Padding(0, 1).Render()
+	smallRadio := smallRadioStyle.Padding(0, 1).Render()
+
+	return fmt.Sprintf("%s%s  %s%s",
+		largeRadio, textStyle.Render(ModelTypeLarge.String()),
+		smallRadio, textStyle.Render(ModelTypeSmall.String()))
+}
+
+// View implements Dialog.
+func (m *Models) View() string {
+	t := m.com.Styles
+	titleStyle := t.Dialog.Title
+	dialogStyle := t.Dialog.View
+
+	radios := m.modelTypeRadioView()
+
+	headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
+		dialogStyle.GetHorizontalFrameSize()
+
+	header := common.DialogTitle(t, "Switch Model", m.width-headerOffset) + radios
+
+	return HeaderInputListHelpView(t, m.width, m.list.Height(), header,
+		m.input.View(), m.list.Render(), m.help.View(m))
+}
+
+// ShortHelp returns the short help view.
+func (m *Models) ShortHelp() []key.Binding {
+	return []key.Binding{
+		m.keyMap.UpDown,
+		m.keyMap.Tab,
+		m.keyMap.Select,
+		m.keyMap.Close,
+	}
+}
+
+// FullHelp returns the full help view.
+func (m *Models) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			m.keyMap.Select,
+			m.keyMap.Next,
+			m.keyMap.Previous,
+			m.keyMap.Tab,
+		},
+		{
+			m.keyMap.Close,
+		},
+	}
+}
+
+// setProviderItems sets the provider items in the list.
+func (m *Models) setProviderItems() error {
+	t := m.com.Styles
+	cfg := m.com.Config()
+
+	selectedType := config.SelectedModelTypeLarge
+	if m.modelType == ModelTypeLarge {
+		selectedType = config.SelectedModelTypeLarge
+	} else {
+		selectedType = config.SelectedModelTypeSmall
+	}
+
+	var selectedItemID string
+	currentModel := cfg.Models[selectedType]
+	recentItems := cfg.RecentModels[selectedType]
+
+	// Track providers already added to avoid duplicates
+	addedProviders := make(map[string]bool)
+
+	// Get a list of known providers to compare against
+	knownProviders, err := config.Providers(cfg)
+	if err != nil {
+		return fmt.Errorf("failed to get providers: %w", err)
+	}
+
+	containsProviderFunc := func(id string) func(p catwalk.Provider) bool {
+		return func(p catwalk.Provider) bool {
+			return p.ID == catwalk.InferenceProvider(id)
+		}
+	}
+
+	// itemsMap contains the keys of added model items.
+	itemsMap := make(map[string]*ModelItem)
+	groups := []ModelGroup{}
+	for id, p := range cfg.Providers.Seq2() {
+		if p.Disable {
+			continue
+		}
+
+		// Check if this provider is not in the known providers list
+		if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) ||
+			!slices.ContainsFunc(m.providers, containsProviderFunc(id)) {
+			provider := p.ToProvider()
+
+			// Add this unknown provider to the list
+			name := p.Name
+			if name == "" {
+				name = id
+			}
+
+			addedProviders[id] = true
+
+			group := NewModelGroup(t, name, true)
+			for _, model := range p.Models {
+				item := NewModelItem(t, provider, model)
+				group.AppendItems(item)
+				itemsMap[item.ID()] = item
+				if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+					selectedItemID = item.ID()
+				}
+			}
+		}
+	}
+
+	// Now add known providers from the predefined list
+	for _, provider := range m.providers {
+		providerID := string(provider.ID)
+		if addedProviders[providerID] {
+			continue
+		}
+
+		providerConfig, providerConfigured := cfg.Providers.Get(providerID)
+		if providerConfigured && providerConfig.Disable {
+			continue
+		}
+
+		displayProvider := provider
+		if providerConfigured {
+			displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
+			modelIndex := make(map[string]int, len(displayProvider.Models))
+			for i, model := range displayProvider.Models {
+				modelIndex[model.ID] = i
+			}
+			for _, model := range providerConfig.Models {
+				if model.ID == "" {
+					continue
+				}
+				if idx, ok := modelIndex[model.ID]; ok {
+					if model.Name != "" {
+						displayProvider.Models[idx].Name = model.Name
+					}
+					continue
+				}
+				if model.Name == "" {
+					model.Name = model.ID
+				}
+				displayProvider.Models = append(displayProvider.Models, model)
+				modelIndex[model.ID] = len(displayProvider.Models) - 1
+			}
+		}
+
+		name := displayProvider.Name
+		if name == "" {
+			name = providerID
+		}
+
+		group := NewModelGroup(t, name, providerConfigured)
+		for _, model := range displayProvider.Models {
+			item := NewModelItem(t, provider, model)
+			group.AppendItems(item)
+			itemsMap[item.ID()] = item
+			if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+				selectedItemID = item.ID()
+			}
+		}
+
+		groups = append(groups, group)
+	}
+
+	if len(recentItems) > 0 {
+		recentGroup := NewModelGroup(t, "Recently used", false)
+
+		var validRecentItems []config.SelectedModel
+		for _, recent := range recentItems {
+			key := modelKey(recent.Provider, recent.Model)
+			item, ok := itemsMap[key]
+			if !ok {
+				continue
+			}
+
+			validRecentItems = append(validRecentItems, recent)
+			recentGroup.AppendItems(item)
+			if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
+				selectedItemID = item.ID()
+			}
+		}
+
+		if len(validRecentItems) != len(recentItems) {
+			// FIXME: Does this need to be here? Is it mutating the config during a read?
+			if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
+				return fmt.Errorf("failed to update recent models: %w", err)
+			}
+		}
+
+		if len(recentGroup.Items) > 0 {
+			groups = append([]ModelGroup{recentGroup}, groups...)
+		}
+	}
+
+	// Set model groups in the list.
+	m.list.SetGroups(groups...)
+	m.list.SetSelectedItem(selectedItemID)
+	// Update placeholder based on model type
+	if m.modelType == ModelTypeLarge {
+		m.input.Placeholder = largeModelInputPlaceholder
+	} else {
+		m.input.Placeholder = smallModelInputPlaceholder
+	}
+
+	return nil
+}
+
+func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
+	providers, err := config.Providers(cfg)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get providers: %w", err)
+	}
+	filteredProviders := []catwalk.Provider{}
+	for _, p := range providers {
+		hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
+		if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
+			filteredProviders = append(filteredProviders, p)
+		}
+	}
+	return filteredProviders, nil
+}
+
+func modelKey(providerID, modelID string) string {
+	if providerID == "" || modelID == "" {
+		return ""
+	}
+	return providerID + ":" + modelID
+}

internal/ui/dialog/models_item.go 🔗

@@ -0,0 +1,95 @@
+package dialog
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/sahilm/fuzzy"
+)
+
+// ModelGroup represents a group of model items.
+type ModelGroup struct {
+	Title      string
+	Items      []*ModelItem
+	configured bool
+	t          *styles.Styles
+}
+
+// NewModelGroup creates a new ModelGroup.
+func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
+	return ModelGroup{
+		Title: title,
+		Items: items,
+		t:     t,
+	}
+}
+
+// AppendItems appends [ModelItem]s to the group.
+func (m *ModelGroup) AppendItems(items ...*ModelItem) {
+	m.Items = append(m.Items, items...)
+}
+
+// Render implements [list.Item].
+func (m *ModelGroup) Render(width int) string {
+	var configured string
+	if m.configured {
+		configuredIcon := m.t.ToolCallSuccess.Render()
+		configuredText := m.t.Subtle.Render("Configured")
+		configured = configuredIcon + " " + configuredText
+	}
+
+	title := " " + m.Title + " "
+	title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…")
+
+	return common.Section(m.t, title, width, configured)
+}
+
+// ModelItem represents a list item for a model type.
+type ModelItem struct {
+	prov  catwalk.Provider
+	model catwalk.Model
+
+	cache   map[int]string
+	t       *styles.Styles
+	m       fuzzy.Match
+	focused bool
+}
+
+var _ ListItem = &ModelItem{}
+
+// NewModelItem creates a new ModelItem.
+func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model) *ModelItem {
+	return &ModelItem{
+		prov:  prov,
+		model: model,
+		t:     t,
+		cache: make(map[int]string),
+	}
+}
+
+// Filter implements ListItem.
+func (m *ModelItem) Filter() string {
+	return m.model.Name
+}
+
+// ID implements ListItem.
+func (m *ModelItem) ID() string {
+	return modelKey(string(m.prov.ID), m.model.ID)
+}
+
+// Render implements ListItem.
+func (m *ModelItem) Render(width int) string {
+	return renderItem(m.t, m.model.Name, 0, m.focused, width, m.cache, &m.m)
+}
+
+// SetFocused implements ListItem.
+func (m *ModelItem) SetFocused(focused bool) {
+	m.focused = focused
+}
+
+// SetMatch implements ListItem.
+func (m *ModelItem) SetMatch(fm fuzzy.Match) {
+	m.m = fm
+}

internal/ui/dialog/models_list.go 🔗

@@ -0,0 +1,163 @@
+package dialog
+
+import (
+	"slices"
+
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/sahilm/fuzzy"
+)
+
+// ModelsList is a list specifically for model items and groups.
+type ModelsList struct {
+	*list.List
+	groups []ModelGroup
+	items  []list.Item
+	query  string
+	t      *styles.Styles
+}
+
+// NewModelsList creates a new list suitable for model items and groups.
+func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
+	f := &ModelsList{
+		List:   list.NewList(),
+		groups: groups,
+		t:      sty,
+	}
+	return f
+}
+
+// SetGroups sets the model groups and updates the list items.
+func (f *ModelsList) SetGroups(groups ...ModelGroup) {
+	f.groups = groups
+}
+
+// SetFilter sets the filter query and updates the list items.
+func (f *ModelsList) SetFilter(q string) {
+	f.query = q
+}
+
+// SetSelectedItem sets the selected item in the list by item ID.
+func (f *ModelsList) SetSelectedItem(itemID string) {
+	count := 0
+	for _, g := range f.groups {
+		for _, item := range g.Items {
+			if item.ID() == itemID {
+				f.List.SetSelected(count)
+				return
+			}
+			count++
+		}
+	}
+}
+
+// SelectNext selects the next selectable item in the list.
+func (f *ModelsList) SelectNext() bool {
+	for f.List.SelectNext() {
+		if _, ok := f.List.SelectedItem().(*ModelItem); ok {
+			return true
+		}
+	}
+	return false
+}
+
+// SelectPrev selects the previous selectable item in the list.
+func (f *ModelsList) SelectPrev() bool {
+	for f.List.SelectPrev() {
+		if _, ok := f.List.SelectedItem().(*ModelItem); ok {
+			return true
+		}
+	}
+	return false
+}
+
+// VisibleItems returns the visible items after filtering.
+func (f *ModelsList) VisibleItems() []list.Item {
+	if len(f.query) == 0 {
+		// No filter, return all items with group headers
+		items := []list.Item{}
+		for _, g := range f.groups {
+			items = append(items, &g)
+			for _, item := range g.Items {
+				items = append(items, item)
+			}
+			// Add a space separator after each provider section
+			items = append(items, list.NewSpacerItem(1))
+		}
+		return items
+	}
+
+	groupItems := map[int][]*ModelItem{}
+	filterableItems := []list.FilterableItem{}
+	for i, g := range f.groups {
+		for _, item := range g.Items {
+			filterableItems = append(filterableItems, item)
+			groupItems[i] = append(groupItems[i], item)
+		}
+	}
+
+	matches := fuzzy.FindFrom(f.query, list.FilterableItemsSource(filterableItems))
+	for _, match := range matches {
+		item := filterableItems[match.Index]
+		if ms, ok := item.(list.MatchSettable); ok {
+			ms.SetMatch(match)
+			item = ms.(list.FilterableItem)
+		}
+		filterableItems = append(filterableItems, item)
+	}
+
+	items := []list.Item{}
+	visitedGroups := map[int]bool{}
+
+	// Reconstruct groups with matched items
+	for _, match := range matches {
+		item := filterableItems[match.Index]
+		// Find which group this item belongs to
+		for gi, g := range f.groups {
+			if slices.Contains(groupItems[gi], item.(*ModelItem)) {
+				if !visitedGroups[gi] {
+					// Add section header
+					items = append(items, &g)
+					visitedGroups[gi] = true
+				}
+				// Add the matched item
+				if ms, ok := item.(list.MatchSettable); ok {
+					ms.SetMatch(match)
+					item = ms.(list.FilterableItem)
+				}
+				// Add a space separator after each provider section
+				items = append(items, item, list.NewSpacerItem(1))
+				break
+			}
+		}
+	}
+
+	return items
+}
+
+// Render renders the filterable list.
+func (f *ModelsList) Render() string {
+	f.List.SetItems(f.VisibleItems()...)
+	return f.List.Render()
+}
+
+type modelGroups []ModelGroup
+
+func (m modelGroups) Len() int {
+	n := 0
+	for _, g := range m {
+		n += len(g.Items)
+	}
+	return n
+}
+
+func (m modelGroups) String(i int) string {
+	count := 0
+	for _, g := range m {
+		if i < count+len(g.Items) {
+			return g.Items[i-count].Filter()
+		}
+		count += len(g.Items)
+	}
+	return ""
+}

internal/ui/model/ui.go 🔗

@@ -494,7 +494,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			}
 			return true
 		case key.Matches(msg, m.keyMap.Models):
-			// TODO: Implement me
+			if cmd := m.openModelsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
 			return true
 		case key.Matches(msg, m.keyMap.Sessions):
 			if m.dialog.ContainsDialog(dialog.SessionsID) {
@@ -1375,6 +1377,25 @@ func (m *UI) openQuitDialog() tea.Cmd {
 	return nil
 }
 
+// openModelsDialog opens the models dialog.
+func (m *UI) openModelsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.ModelsID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.ModelsID)
+		return nil
+	}
+
+	modelsDialog, err := dialog.NewModels(m.com)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	modelsDialog.SetSize(min(60, m.width-8), 30)
+	m.dialog.OpenDialog(modelsDialog)
+
+	return nil
+}
+
 // openCommandsDialog opens the commands dialog.
 func (m *UI) openCommandsDialog() tea.Cmd {
 	if m.dialog.ContainsDialog(dialog.CommandsID) {

internal/ui/styles/styles.go 🔗

@@ -32,6 +32,9 @@ const (
 	ToolSuccess string = "✓"
 	ToolError   string = "×"
 
+	RadioOn  string = "◉"
+	RadioOff string = "○"
+
 	BorderThin  string = "│"
 	BorderThick string = "▌"
 
@@ -51,9 +54,10 @@ type Styles struct {
 	WindowTooSmall lipgloss.Style
 
 	// Reusable text styles
-	Base   lipgloss.Style
-	Muted  lipgloss.Style
-	Subtle lipgloss.Style
+	Base      lipgloss.Style
+	Muted     lipgloss.Style
+	HalfMuted lipgloss.Style
+	Subtle    lipgloss.Style
 
 	// Tags
 	TagBase  lipgloss.Style
@@ -123,6 +127,10 @@ type Styles struct {
 	EditorPromptYoloDotsFocused lipgloss.Style
 	EditorPromptYoloDotsBlurred lipgloss.Style
 
+	// Radio
+	RadioOn  lipgloss.Style
+	RadioOff lipgloss.Style
+
 	// Background
 	Background color.Color
 
@@ -291,9 +299,7 @@ type Styles struct {
 
 		List lipgloss.Style
 
-		Commands struct {
-			CommandTypeSelector lipgloss.Style
-		}
+		Commands struct{}
 	}
 }
 
@@ -917,6 +923,7 @@ func DefaultStyles() Styles {
 	// text presets
 	s.Base = lipgloss.NewStyle().Foreground(fgBase)
 	s.Muted = lipgloss.NewStyle().Foreground(fgMuted)
+	s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted)
 	s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle)
 
 	s.WindowTooSmall = s.Muted
@@ -1008,6 +1015,9 @@ func DefaultStyles() Styles {
 	s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::")
 	s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
 
+	s.RadioOn = s.HalfMuted.SetString(RadioOn)
+	s.RadioOff = s.HalfMuted.SetString(RadioOff)
+
 	// Logo colors
 	s.LogoFieldColor = primary
 	s.LogoTitleColorA = secondary
@@ -1095,8 +1105,6 @@ func DefaultStyles() Styles {
 
 	s.Dialog.List = base.Margin(0, 0, 1, 0)
 
-	s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)
-
 	return s
 }