on main help sign in
feat(ui): model selection dialog
commit26145a76b5f82158dc0d3ab7a4a513aed3a8b7e1 authorAyman Bagabas <ayman.bagabas@gmail.com> dateWed 17 Dec 2025, 16:39 (1 year ago) parent8645586
7 files changed+760−18
internal/ui/common/elements.go+12−2 internal/ui/dialog/commands.go+7−7 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+22−1 internal/ui/styles/styles.go+16−8
internal/ui/common/elements.go+12−2
@@ -134,13 +134,23 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string {
134134
135135// Section renders a section header with a title and a horizontal line filling
136136// the remaining width.
137−func Section(t *styles.Styles, text string, width int) string {
137+func Section(t *styles.Styles, text string, width int, info ...string) string {
138138 char := styles.SectionSeparator
139139 length := lipgloss.Width(text) + 1
140140 remainingWidth := width - length
141+
142+ var infoText string
143+ if len(info) > 0 {
144+ infoText = strings.Join(info, " ")
145+ if len(infoText) > 0 {
146+ infoText = " " + infoText
147+ remainingWidth -= lipgloss.Width(infoText)
148+ }
149+ }
150+
141151 text = t.Section.Title.Render(text)
142152 if remainingWidth > 0 {
143− text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth))
153+ text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText
144154 }
145155 return text
146156}
internal/ui/dialog/commands.go+7−7
@@ -197,22 +197,23 @@ func (c *Commands) Cursor() *tea.Cursor {
197197 return InputCursor(c.com.Styles, c.input.Cursor())
198198}
199199
200−// radioView generates the command type selector radio buttons.
201−func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
200+// commandsRadioView generates the command type selector radio buttons.
201+func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
202202 if !hasUserCmds && !hasMCPPrompts {
203203 return ""
204204 }
205205
206206 selectedFn := func(t uicmd.CommandType) string {
207207 if t == selected {
208− return " ◉ " + t.String()
208+ return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
209209 }
210− return " ○ " + t.String()
210+ return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
211211 }
212212
213213 parts := []string{
214214 selectedFn(uicmd.SystemCommands),
215215 }
216+
216217 if hasUserCmds {
217218 parts = append(parts, selectedFn(uicmd.UserCommands))
218219 }
@@ -220,14 +221,13 @@ func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, h
220221 parts = append(parts, selectedFn(uicmd.MCPPrompts))
221222 }
222223
223− radio := strings.Join(parts, " ")
224− return t.Dialog.Commands.CommandTypeSelector.Render(radio)
224+ return strings.Join(parts, " ")
225225}
226226
227227// View implements [Dialog].
228228func (c *Commands) View() string {
229229 t := c.com.Styles
230− radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
230+ radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
231231 titleStyle := t.Dialog.Title
232232 dialogStyle := t.Dialog.View.Width(c.width)
233233 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
internal/ui/dialog/models.gonew file+445
@@ -0,0 +1,445 @@
1+package dialog
2+
3+import (
4+ "cmp"
5+ "fmt"
6+ "slices"
7+ "strings"
8+
9+ "charm.land/bubbles/v2/help"
10+ "charm.land/bubbles/v2/key"
11+ "charm.land/bubbles/v2/textinput"
12+ tea "charm.land/bubbletea/v2"
13+ "charm.land/lipgloss/v2"
14+ "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ "github.com/charmbracelet/crush/internal/uiutil"
18+)
19+
20+// ModelType represents the type of model to select.
21+type ModelType int
22+
23+const (
24+ ModelTypeLarge ModelType = iota
25+ ModelTypeSmall
26+)
27+
28+// String returns the string representation of the [ModelType].
29+func (mt ModelType) String() string {
30+ switch mt {
31+ case ModelTypeLarge:
32+ return "Large Task"
33+ case ModelTypeSmall:
34+ return "Small Task"
35+ default:
36+ return "Unknown"
37+ }
38+}
39+
40+const (
41+ largeModelInputPlaceholder = "Choose a model for large, complex tasks"
42+ smallModelInputPlaceholder = "Choose a model for small, simple tasks"
43+)
44+
45+// ModelsID is the identifier for the model selection dialog.
46+const ModelsID = "models"
47+
48+// Models represents a model selection dialog.
49+type Models struct {
50+ com *common.Common
51+
52+ modelType ModelType
53+ providers []catwalk.Provider
54+
55+ width, height int
56+
57+ keyMap struct {
58+ Tab key.Binding
59+ UpDown key.Binding
60+ Select key.Binding
61+ Next key.Binding
62+ Previous key.Binding
63+ Close key.Binding
64+ }
65+ list *ModelsList
66+ input textinput.Model
67+ help help.Model
68+}
69+
70+var _ Dialog = (*Models)(nil)
71+
72+// NewModels creates a new Models dialog.
73+func NewModels(com *common.Common) (*Models, error) {
74+ t := com.Styles
75+ m := &Models{}
76+ m.com = com
77+ help := help.New()
78+ help.Styles = t.DialogHelpStyles()
79+
80+ m.help = help
81+ m.list = NewModelsList(t)
82+ m.list.Focus()
83+ m.list.SetSelected(0)
84+
85+ m.input = textinput.New()
86+ m.input.SetVirtualCursor(false)
87+ m.input.Placeholder = largeModelInputPlaceholder
88+ m.input.SetStyles(com.Styles.TextInput)
89+ m.input.Focus()
90+
91+ m.keyMap.Tab = key.NewBinding(
92+ key.WithKeys("tab", "shift+tab"),
93+ key.WithHelp("tab", "toggle type"),
94+ )
95+ m.keyMap.Select = key.NewBinding(
96+ key.WithKeys("enter", "ctrl+y"),
97+ key.WithHelp("enter", "confirm"),
98+ )
99+ m.keyMap.UpDown = key.NewBinding(
100+ key.WithKeys("up", "down"),
101+ key.WithHelp("↑/↓", "choose"),
102+ )
103+ m.keyMap.Next = key.NewBinding(
104+ key.WithKeys("down", "ctrl+n"),
105+ key.WithHelp("↓", "next item"),
106+ )
107+ m.keyMap.Previous = key.NewBinding(
108+ key.WithKeys("up", "ctrl+p"),
109+ key.WithHelp("↑", "previous item"),
110+ )
111+ m.keyMap.Close = CloseKey
112+
113+ providers, err := getFilteredProviders(com.Config())
114+ if err != nil {
115+ return nil, fmt.Errorf("failed to get providers: %w", err)
116+ }
117+
118+ m.providers = providers
119+ if err := m.setProviderItems(); err != nil {
120+ return nil, fmt.Errorf("failed to set provider items: %w", err)
121+ }
122+
123+ return m, nil
124+}
125+
126+// SetSize sets the size of the dialog.
127+func (m *Models) SetSize(width, height int) {
128+ t := m.com.Styles
129+ m.width = width
130+ m.height = height
131+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
132+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
133+ t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
134+ t.Dialog.HelpView.GetVerticalFrameSize() +
135+ t.Dialog.View.GetVerticalFrameSize()
136+ m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
137+ m.list.SetSize(innerWidth, height-heightOffset)
138+ m.help.SetWidth(width)
139+}
140+
141+// ID implements Dialog.
142+func (m *Models) ID() string {
143+ return ModelsID
144+}
145+
146+// Update implements Dialog.
147+func (m *Models) Update(msg tea.Msg) tea.Msg {
148+ switch msg := msg.(type) {
149+ case tea.KeyPressMsg:
150+ switch {
151+ case key.Matches(msg, m.keyMap.Close):
152+ return CloseMsg{}
153+ case key.Matches(msg, m.keyMap.Previous):
154+ m.list.Focus()
155+ if m.list.IsSelectedFirst() {
156+ m.list.SelectLast()
157+ m.list.ScrollToBottom()
158+ break
159+ }
160+ m.list.SelectPrev()
161+ m.list.ScrollToSelected()
162+ case key.Matches(msg, m.keyMap.Next):
163+ m.list.Focus()
164+ if m.list.IsSelectedLast() {
165+ m.list.SelectFirst()
166+ m.list.ScrollToTop()
167+ break
168+ }
169+ m.list.SelectNext()
170+ m.list.ScrollToSelected()
171+ case key.Matches(msg, m.keyMap.Select):
172+ if selectedItem := m.list.SelectedItem(); selectedItem != nil {
173+ // TODO: Handle model selection confirmation.
174+ }
175+ case key.Matches(msg, m.keyMap.Tab):
176+ if m.modelType == ModelTypeLarge {
177+ m.modelType = ModelTypeSmall
178+ } else {
179+ m.modelType = ModelTypeLarge
180+ }
181+ if err := m.setProviderItems(); err != nil {
182+ return uiutil.ReportError(err)
183+ }
184+ default:
185+ var cmd tea.Cmd
186+ m.input, cmd = m.input.Update(msg)
187+ value := m.input.Value()
188+ m.list.SetFilter(value)
189+ m.list.ScrollToSelected()
190+ return cmd
191+ }
192+ }
193+ return nil
194+}
195+
196+// Cursor returns the cursor for the dialog.
197+func (m *Models) Cursor() *tea.Cursor {
198+ return InputCursor(m.com.Styles, m.input.Cursor())
199+}
200+
201+// modelTypeRadioView returns the radio view for model type selection.
202+func (m *Models) modelTypeRadioView() string {
203+ t := m.com.Styles
204+ textStyle := t.HalfMuted
205+ largeRadioStyle := t.RadioOff
206+ smallRadioStyle := t.RadioOff
207+ if m.modelType == ModelTypeLarge {
208+ largeRadioStyle = t.RadioOn
209+ } else {
210+ smallRadioStyle = t.RadioOn
211+ }
212+
213+ largeRadio := largeRadioStyle.Padding(0, 1).Render()
214+ smallRadio := smallRadioStyle.Padding(0, 1).Render()
215+
216+ return fmt.Sprintf("%s%s %s%s",
217+ largeRadio, textStyle.Render(ModelTypeLarge.String()),
218+ smallRadio, textStyle.Render(ModelTypeSmall.String()))
219+}
220+
221+// View implements Dialog.
222+func (m *Models) View() string {
223+ t := m.com.Styles
224+ titleStyle := t.Dialog.Title
225+ dialogStyle := t.Dialog.View
226+
227+ radios := m.modelTypeRadioView()
228+
229+ headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
230+ dialogStyle.GetHorizontalFrameSize()
231+
232+ header := common.DialogTitle(t, "Switch Model", m.width-headerOffset) + radios
233+
234+ return HeaderInputListHelpView(t, m.width, m.list.Height(), header,
235+ m.input.View(), m.list.Render(), m.help.View(m))
236+}
237+
238+// ShortHelp returns the short help view.
239+func (m *Models) ShortHelp() []key.Binding {
240+ return []key.Binding{
241+ m.keyMap.UpDown,
242+ m.keyMap.Tab,
243+ m.keyMap.Select,
244+ m.keyMap.Close,
245+ }
246+}
247+
248+// FullHelp returns the full help view.
249+func (m *Models) FullHelp() [][]key.Binding {
250+ return [][]key.Binding{
251+ {
252+ m.keyMap.Select,
253+ m.keyMap.Next,
254+ m.keyMap.Previous,
255+ m.keyMap.Tab,
256+ },
257+ {
258+ m.keyMap.Close,
259+ },
260+ }
261+}
262+
263+// setProviderItems sets the provider items in the list.
264+func (m *Models) setProviderItems() error {
265+ t := m.com.Styles
266+ cfg := m.com.Config()
267+
268+ selectedType := config.SelectedModelTypeLarge
269+ if m.modelType == ModelTypeLarge {
270+ selectedType = config.SelectedModelTypeLarge
271+ } else {
272+ selectedType = config.SelectedModelTypeSmall
273+ }
274+
275+ var selectedItemID string
276+ currentModel := cfg.Models[selectedType]
277+ recentItems := cfg.RecentModels[selectedType]
278+
279+ // Track providers already added to avoid duplicates
280+ addedProviders := make(map[string]bool)
281+
282+ // Get a list of known providers to compare against
283+ knownProviders, err := config.Providers(cfg)
284+ if err != nil {
285+ return fmt.Errorf("failed to get providers: %w", err)
286+ }
287+
288+ containsProviderFunc := func(id string) func(p catwalk.Provider) bool {
289+ return func(p catwalk.Provider) bool {
290+ return p.ID == catwalk.InferenceProvider(id)
291+ }
292+ }
293+
294+ // itemsMap contains the keys of added model items.
295+ itemsMap := make(map[string]*ModelItem)
296+ groups := []ModelGroup{}
297+ for id, p := range cfg.Providers.Seq2() {
298+ if p.Disable {
299+ continue
300+ }
301+
302+ // Check if this provider is not in the known providers list
303+ if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) ||
304+ !slices.ContainsFunc(m.providers, containsProviderFunc(id)) {
305+ provider := p.ToProvider()
306+
307+ // Add this unknown provider to the list
308+ name := p.Name
309+ if name == "" {
310+ name = id
311+ }
312+
313+ addedProviders[id] = true
314+
315+ group := NewModelGroup(t, name, true)
316+ for _, model := range p.Models {
317+ item := NewModelItem(t, provider, model)
318+ group.AppendItems(item)
319+ itemsMap[item.ID()] = item
320+ if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
321+ selectedItemID = item.ID()
322+ }
323+ }
324+ }
325+ }
326+
327+ // Now add known providers from the predefined list
328+ for _, provider := range m.providers {
329+ providerID := string(provider.ID)
330+ if addedProviders[providerID] {
331+ continue
332+ }
333+
334+ providerConfig, providerConfigured := cfg.Providers.Get(providerID)
335+ if providerConfigured && providerConfig.Disable {
336+ continue
337+ }
338+
339+ displayProvider := provider
340+ if providerConfigured {
341+ displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
342+ modelIndex := make(map[string]int, len(displayProvider.Models))
343+ for i, model := range displayProvider.Models {
344+ modelIndex[model.ID] = i
345+ }
346+ for _, model := range providerConfig.Models {
347+ if model.ID == "" {
348+ continue
349+ }
350+ if idx, ok := modelIndex[model.ID]; ok {
351+ if model.Name != "" {
352+ displayProvider.Models[idx].Name = model.Name
353+ }
354+ continue
355+ }
356+ if model.Name == "" {
357+ model.Name = model.ID
358+ }
359+ displayProvider.Models = append(displayProvider.Models, model)
360+ modelIndex[model.ID] = len(displayProvider.Models) - 1
361+ }
362+ }
363+
364+ name := displayProvider.Name
365+ if name == "" {
366+ name = providerID
367+ }
368+
369+ group := NewModelGroup(t, name, providerConfigured)
370+ for _, model := range displayProvider.Models {
371+ item := NewModelItem(t, provider, model)
372+ group.AppendItems(item)
373+ itemsMap[item.ID()] = item
374+ if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
375+ selectedItemID = item.ID()
376+ }
377+ }
378+
379+ groups = append(groups, group)
380+ }
381+
382+ if len(recentItems) > 0 {
383+ recentGroup := NewModelGroup(t, "Recently used", false)
384+
385+ var validRecentItems []config.SelectedModel
386+ for _, recent := range recentItems {
387+ key := modelKey(recent.Provider, recent.Model)
388+ item, ok := itemsMap[key]
389+ if !ok {
390+ continue
391+ }
392+
393+ validRecentItems = append(validRecentItems, recent)
394+ recentGroup.AppendItems(item)
395+ if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
396+ selectedItemID = item.ID()
397+ }
398+ }
399+
400+ if len(validRecentItems) != len(recentItems) {
401+ // FIXME: Does this need to be here? Is it mutating the config during a read?
402+ if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
403+ return fmt.Errorf("failed to update recent models: %w", err)
404+ }
405+ }
406+
407+ if len(recentGroup.Items) > 0 {
408+ groups = append([]ModelGroup{recentGroup}, groups...)
409+ }
410+ }
411+
412+ // Set model groups in the list.
413+ m.list.SetGroups(groups...)
414+ m.list.SetSelectedItem(selectedItemID)
415+ // Update placeholder based on model type
416+ if m.modelType == ModelTypeLarge {
417+ m.input.Placeholder = largeModelInputPlaceholder
418+ } else {
419+ m.input.Placeholder = smallModelInputPlaceholder
420+ }
421+
422+ return nil
423+}
424+
425+func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
426+ providers, err := config.Providers(cfg)
427+ if err != nil {
428+ return nil, fmt.Errorf("failed to get providers: %w", err)
429+ }
430+ filteredProviders := []catwalk.Provider{}
431+ for _, p := range providers {
432+ hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
433+ if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
434+ filteredProviders = append(filteredProviders, p)
435+ }
436+ }
437+ return filteredProviders, nil
438+}
439+
440+func modelKey(providerID, modelID string) string {
441+ if providerID == "" || modelID == "" {
442+ return ""
443+ }
444+ return providerID + ":" + modelID
445+}
internal/ui/dialog/models_item.gonew file+95
@@ -0,0 +1,95 @@
1+package dialog
2+
3+import (
4+ "charm.land/lipgloss/v2"
5+ "github.com/charmbracelet/catwalk/pkg/catwalk"
6+ "github.com/charmbracelet/crush/internal/ui/common"
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+ "github.com/charmbracelet/x/ansi"
9+ "github.com/sahilm/fuzzy"
10+)
11+
12+// ModelGroup represents a group of model items.
13+type ModelGroup struct {
14+ Title string
15+ Items []*ModelItem
16+ configured bool
17+ t *styles.Styles
18+}
19+
20+// NewModelGroup creates a new ModelGroup.
21+func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
22+ return ModelGroup{
23+ Title: title,
24+ Items: items,
25+ t: t,
26+ }
27+}
28+
29+// AppendItems appends [ModelItem]s to the group.
30+func (m *ModelGroup) AppendItems(items ...*ModelItem) {
31+ m.Items = append(m.Items, items...)
32+}
33+
34+// Render implements [list.Item].
35+func (m *ModelGroup) Render(width int) string {
36+ var configured string
37+ if m.configured {
38+ configuredIcon := m.t.ToolCallSuccess.Render()
39+ configuredText := m.t.Subtle.Render("Configured")
40+ configured = configuredIcon + " " + configuredText
41+ }
42+
43+ title := " " + m.Title + " "
44+ title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…")
45+
46+ return common.Section(m.t, title, width, configured)
47+}
48+
49+// ModelItem represents a list item for a model type.
50+type ModelItem struct {
51+ prov catwalk.Provider
52+ model catwalk.Model
53+
54+ cache map[int]string
55+ t *styles.Styles
56+ m fuzzy.Match
57+ focused bool
58+}
59+
60+var _ ListItem = &ModelItem{}
61+
62+// NewModelItem creates a new ModelItem.
63+func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model) *ModelItem {
64+ return &ModelItem{
65+ prov: prov,
66+ model: model,
67+ t: t,
68+ cache: make(map[int]string),
69+ }
70+}
71+
72+// Filter implements ListItem.
73+func (m *ModelItem) Filter() string {
74+ return m.model.Name
75+}
76+
77+// ID implements ListItem.
78+func (m *ModelItem) ID() string {
79+ return modelKey(string(m.prov.ID), m.model.ID)
80+}
81+
82+// Render implements ListItem.
83+func (m *ModelItem) Render(width int) string {
84+ return renderItem(m.t, m.model.Name, 0, m.focused, width, m.cache, &m.m)
85+}
86+
87+// SetFocused implements ListItem.
88+func (m *ModelItem) SetFocused(focused bool) {
89+ m.focused = focused
90+}
91+
92+// SetMatch implements ListItem.
93+func (m *ModelItem) SetMatch(fm fuzzy.Match) {
94+ m.m = fm
95+}
internal/ui/dialog/models_list.gonew file+163
@@ -0,0 +1,163 @@
1+package dialog
2+
3+import (
4+ "slices"
5+
6+ "github.com/charmbracelet/crush/internal/ui/list"
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+ "github.com/sahilm/fuzzy"
9+)
10+
11+// ModelsList is a list specifically for model items and groups.
12+type ModelsList struct {
13+ *list.List
14+ groups []ModelGroup
15+ items []list.Item
16+ query string
17+ t *styles.Styles
18+}
19+
20+// NewModelsList creates a new list suitable for model items and groups.
21+func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
22+ f := &ModelsList{
23+ List: list.NewList(),
24+ groups: groups,
25+ t: sty,
26+ }
27+ return f
28+}
29+
30+// SetGroups sets the model groups and updates the list items.
31+func (f *ModelsList) SetGroups(groups ...ModelGroup) {
32+ f.groups = groups
33+}
34+
35+// SetFilter sets the filter query and updates the list items.
36+func (f *ModelsList) SetFilter(q string) {
37+ f.query = q
38+}
39+
40+// SetSelectedItem sets the selected item in the list by item ID.
41+func (f *ModelsList) SetSelectedItem(itemID string) {
42+ count := 0
43+ for _, g := range f.groups {
44+ for _, item := range g.Items {
45+ if item.ID() == itemID {
46+ f.List.SetSelected(count)
47+ return
48+ }
49+ count++
50+ }
51+ }
52+}
53+
54+// SelectNext selects the next selectable item in the list.
55+func (f *ModelsList) SelectNext() bool {
56+ for f.List.SelectNext() {
57+ if _, ok := f.List.SelectedItem().(*ModelItem); ok {
58+ return true
59+ }
60+ }
61+ return false
62+}
63+
64+// SelectPrev selects the previous selectable item in the list.
65+func (f *ModelsList) SelectPrev() bool {
66+ for f.List.SelectPrev() {
67+ if _, ok := f.List.SelectedItem().(*ModelItem); ok {
68+ return true
69+ }
70+ }
71+ return false
72+}
73+
74+// VisibleItems returns the visible items after filtering.
75+func (f *ModelsList) VisibleItems() []list.Item {
76+ if len(f.query) == 0 {
77+ // No filter, return all items with group headers
78+ items := []list.Item{}
79+ for _, g := range f.groups {
80+ items = append(items, &g)
81+ for _, item := range g.Items {
82+ items = append(items, item)
83+ }
84+ // Add a space separator after each provider section
85+ items = append(items, list.NewSpacerItem(1))
86+ }
87+ return items
88+ }
89+
90+ groupItems := map[int][]*ModelItem{}
91+ filterableItems := []list.FilterableItem{}
92+ for i, g := range f.groups {
93+ for _, item := range g.Items {
94+ filterableItems = append(filterableItems, item)
95+ groupItems[i] = append(groupItems[i], item)
96+ }
97+ }
98+
99+ matches := fuzzy.FindFrom(f.query, list.FilterableItemsSource(filterableItems))
100+ for _, match := range matches {
101+ item := filterableItems[match.Index]
102+ if ms, ok := item.(list.MatchSettable); ok {
103+ ms.SetMatch(match)
104+ item = ms.(list.FilterableItem)
105+ }
106+ filterableItems = append(filterableItems, item)
107+ }
108+
109+ items := []list.Item{}
110+ visitedGroups := map[int]bool{}
111+
112+ // Reconstruct groups with matched items
113+ for _, match := range matches {
114+ item := filterableItems[match.Index]
115+ // Find which group this item belongs to
116+ for gi, g := range f.groups {
117+ if slices.Contains(groupItems[gi], item.(*ModelItem)) {
118+ if !visitedGroups[gi] {
119+ // Add section header
120+ items = append(items, &g)
121+ visitedGroups[gi] = true
122+ }
123+ // Add the matched item
124+ if ms, ok := item.(list.MatchSettable); ok {
125+ ms.SetMatch(match)
126+ item = ms.(list.FilterableItem)
127+ }
128+ // Add a space separator after each provider section
129+ items = append(items, item, list.NewSpacerItem(1))
130+ break
131+ }
132+ }
133+ }
134+
135+ return items
136+}
137+
138+// Render renders the filterable list.
139+func (f *ModelsList) Render() string {
140+ f.List.SetItems(f.VisibleItems()...)
141+ return f.List.Render()
142+}
143+
144+type modelGroups []ModelGroup
145+
146+func (m modelGroups) Len() int {
147+ n := 0
148+ for _, g := range m {
149+ n += len(g.Items)
150+ }
151+ return n
152+}
153+
154+func (m modelGroups) String(i int) string {
155+ count := 0
156+ for _, g := range m {
157+ if i < count+len(g.Items) {
158+ return g.Items[i-count].Filter()
159+ }
160+ count += len(g.Items)
161+ }
162+ return ""
163+}
internal/ui/model/ui.go+22−1
@@ -494,7 +494,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
494494 }
495495 return true
496496 case key.Matches(msg, m.keyMap.Models):
497− // TODO: Implement me
497+ if cmd := m.openModelsDialog(); cmd != nil {
498+ cmds = append(cmds, cmd)
499+ }
498500 return true
499501 case key.Matches(msg, m.keyMap.Sessions):
500502 if m.dialog.ContainsDialog(dialog.SessionsID) {
@@ -1375,6 +1377,25 @@ func (m *UI) openQuitDialog() tea.Cmd {
13751377 return nil
13761378}
13771379
1380+// openModelsDialog opens the models dialog.
1381+func (m *UI) openModelsDialog() tea.Cmd {
1382+ if m.dialog.ContainsDialog(dialog.ModelsID) {
1383+ // Bring to front
1384+ m.dialog.BringToFront(dialog.ModelsID)
1385+ return nil
1386+ }
1387+
1388+ modelsDialog, err := dialog.NewModels(m.com)
1389+ if err != nil {
1390+ return uiutil.ReportError(err)
1391+ }
1392+
1393+ modelsDialog.SetSize(min(60, m.width-8), 30)
1394+ m.dialog.OpenDialog(modelsDialog)
1395+
1396+ return nil
1397+}
1398+
13781399// openCommandsDialog opens the commands dialog.
13791400func (m *UI) openCommandsDialog() tea.Cmd {
13801401 if m.dialog.ContainsDialog(dialog.CommandsID) {
internal/ui/styles/styles.go+16−8
@@ -32,6 +32,9 @@ const (
3232 ToolSuccess string = "✓"
3333 ToolError string = "×"
3434
35+ RadioOn string = "◉"
36+ RadioOff string = "○"
37+
3538 BorderThin string = "│"
3639 BorderThick string = "▌"
3740
@@ -51,9 +54,10 @@ type Styles struct {
5154 WindowTooSmall lipgloss.Style
5255
5356 // Reusable text styles
54− Base lipgloss.Style
55− Muted lipgloss.Style
56− Subtle lipgloss.Style
57+ Base lipgloss.Style
58+ Muted lipgloss.Style
59+ HalfMuted lipgloss.Style
60+ Subtle lipgloss.Style
5761
5862 // Tags
5963 TagBase lipgloss.Style
@@ -123,6 +127,10 @@ type Styles struct {
123127 EditorPromptYoloDotsFocused lipgloss.Style
124128 EditorPromptYoloDotsBlurred lipgloss.Style
125129
130+ // Radio
131+ RadioOn lipgloss.Style
132+ RadioOff lipgloss.Style
133+
126134 // Background
127135 Background color.Color
128136
@@ -291,9 +299,7 @@ type Styles struct {
291299
292300 List lipgloss.Style
293301
294− Commands struct {
295− CommandTypeSelector lipgloss.Style
296− }
302+ Commands struct{}
297303 }
298304}
299305
@@ -917,6 +923,7 @@ func DefaultStyles() Styles {
917923 // text presets
918924 s.Base = lipgloss.NewStyle().Foreground(fgBase)
919925 s.Muted = lipgloss.NewStyle().Foreground(fgMuted)
926+ s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted)
920927 s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle)
921928
922929 s.WindowTooSmall = s.Muted
@@ -1008,6 +1015,9 @@ func DefaultStyles() Styles {
10081015 s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::")
10091016 s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
10101017
1018+ s.RadioOn = s.HalfMuted.SetString(RadioOn)
1019+ s.RadioOff = s.HalfMuted.SetString(RadioOff)
1020+
10111021 // Logo colors
10121022 s.LogoFieldColor = primary
10131023 s.LogoTitleColorA = secondary
@@ -1095,8 +1105,6 @@ func DefaultStyles() Styles {
10951105
10961106 s.Dialog.List = base.Margin(0, 0, 1, 0)
10971107
1098− s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)
1099−
11001108 return s
11011109}
11021110