From 26145a76b5f82158dc0d3ab7a4a513aed3a8b7e1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 17 Dec 2025 16:39:21 -0500 Subject: [PATCH] feat(ui): model selection dialog --- 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(-) create mode 100644 internal/ui/dialog/models.go create mode 100644 internal/ui/dialog/models_item.go create mode 100644 internal/ui/dialog/models_list.go diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 1475d123a514b75b930b9479ec59f5e5d8a19cf3..f2256676d47b5f5b706ca05e7e4d5a21d6d5867a 100644 --- a/internal/ui/common/elements.go +++ b/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 } diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 1d45064a1a716ae1a6736a27a15c6c358b2e108e..a2c0948f26b75ab7b9d597ca5da867b76c936867 100644 --- a/internal/ui/dialog/commands.go +++ b/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() diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go new file mode 100644 index 0000000000000000000000000000000000000000..66f5923dceccf2633becef38a282de42e9b86785 --- /dev/null +++ b/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 +} diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go new file mode 100644 index 0000000000000000000000000000000000000000..7dfe98d986a38cfefeee12151deb40722227287b --- /dev/null +++ b/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 +} diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go new file mode 100644 index 0000000000000000000000000000000000000000..13a1137bd2e1d076dbb95fe97d3c8850e9cca922 --- /dev/null +++ b/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 "" +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 99f75f98fbf666dda9d3b05ef985977f071c4e46..817c2ad5340f760f246e47aafe1f226998bf1659 100644 --- a/internal/ui/model/ui.go +++ b/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) { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 21ace6a1e588f97a4be586cc01c5ecf3f0a88984..a91e3810142e259dfd38ad7827e9577699a112ae 100644 --- a/internal/ui/styles/styles.go +++ b/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 }