diff --git a/cmd/cmd.go b/cmd/cmd.go index 702bbc1aa..873fb911f 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -58,10 +58,7 @@ import ( func init() { // Override default selectors to use Bubbletea TUI instead of raw terminal I/O. config.DefaultSingleSelector = func(title string, items []config.ModelItem) (string, error) { - tuiItems := make([]tui.SelectItem, len(items)) - for i, item := range items { - tuiItems[i] = tui.SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended} - } + tuiItems := tui.ReorderItems(tui.ConvertItems(items)) result, err := tui.SelectSingle(title, tuiItems) if errors.Is(err, tui.ErrCancelled) { return "", config.ErrCancelled @@ -70,10 +67,7 @@ func init() { } config.DefaultMultiSelector = func(title string, items []config.ModelItem, preChecked []string) ([]string, error) { - tuiItems := make([]tui.SelectItem, len(items)) - for i, item := range items { - tuiItems[i] = tui.SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended} - } + tuiItems := tui.ReorderItems(tui.ConvertItems(items)) result, err := tui.SelectMultiple(title, tuiItems, preChecked) if errors.Is(err, tui.ErrCancelled) { return nil, config.ErrCancelled @@ -2013,9 +2007,17 @@ func runInteractiveTUI(cmd *cobra.Command) { } case tui.SelectionChangeIntegration: _ = config.SetLastSelection(result.Integration) - // Use model from modal if selected, otherwise show picker - if result.Model != "" { - // Model already selected from modal - save and launch + if len(result.Models) > 0 { + // Multi-select from modal (Editor integrations) + if err := config.SaveAndEditIntegration(result.Integration, result.Models); err != nil { + fmt.Fprintf(os.Stderr, "Error configuring %s: %v\n", result.Integration, err) + continue + } + if err := config.LaunchIntegrationWithModel(result.Integration, result.Models[0]); err != nil { + fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", result.Integration, err) + } + } else if result.Model != "" { + // Single-select from modal - save and launch if err := config.SaveIntegrationModel(result.Integration, result.Model); err != nil { fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err) continue diff --git a/cmd/config/config.go b/cmd/config/config.go index 0691836ce..867b247ae 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -160,6 +160,15 @@ func IntegrationModel(appName string) string { return ic.Models[0] } +// IntegrationModels returns all configured models for an integration, or nil. +func IntegrationModels(appName string) []string { + ic, err := loadIntegration(appName) + if err != nil || len(ic.Models) == 0 { + return nil + } + return ic.Models +} + // LastModel returns the last model that was run, or empty string if none. func LastModel() string { cfg, err := load() diff --git a/cmd/config/integrations.go b/cmd/config/integrations.go index 283be365e..e9554a8ab 100644 --- a/cmd/config/integrations.go +++ b/cmd/config/integrations.go @@ -63,8 +63,8 @@ var integrations = map[string]Runner{ // recommendedModels are shown when the user has no models or as suggestions. // Order matters: local models first, then cloud models. var recommendedModels = []ModelItem{ + {Name: "glm-5:cloud", Description: "Reasoning and code generation", Recommended: true}, {Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true}, - {Name: "glm-4.7:cloud", Description: "Reasoning and code generation", Recommended: true}, {Name: "glm-4.7-flash", Description: "Reasoning and code generation locally", Recommended: true}, {Name: "qwen3:8b", Description: "Efficient all-purpose assistant", Recommended: true}, } @@ -171,6 +171,17 @@ func IsIntegrationInstalled(name string) bool { } } +// IsEditorIntegration returns true if the named integration uses multi-model +// selection (implements the Editor interface). +func IsEditorIntegration(name string) bool { + r, ok := integrations[strings.ToLower(name)] + if !ok { + return false + } + _, isEditor := r.(Editor) + return isEditor +} + // SelectModel lets the user select a model to run. // ModelItem represents a model for selection. type ModelItem struct { @@ -221,15 +232,22 @@ func SelectModelWithSelector(ctx context.Context, selector SingleSelector) (stri // If the selected model isn't installed, pull it first if !existingModels[selected] { - msg := fmt.Sprintf("Download %s?", selected) - if ok, err := confirmPrompt(msg); err != nil { - return "", err - } else if !ok { - return "", errCancelled - } - fmt.Fprintf(os.Stderr, "\n") - if err := pullModel(ctx, client, selected); err != nil { - return "", fmt.Errorf("failed to pull %s: %w", selected, err) + if cloudModels[selected] { + // Cloud models only pull a small manifest; no confirmation needed + if err := pullModel(ctx, client, selected); err != nil { + return "", fmt.Errorf("failed to pull %s: %w", selected, err) + } + } else { + msg := fmt.Sprintf("Download %s?", selected) + if ok, err := confirmPrompt(msg); err != nil { + return "", err + } else if !ok { + return "", errCancelled + } + fmt.Fprintf(os.Stderr, "\n") + if err := pullModel(ctx, client, selected); err != nil { + return "", fmt.Errorf("failed to pull %s: %w", selected, err) + } } } @@ -438,6 +456,11 @@ func ShowOrPull(ctx context.Context, client *api.Client, model string) error { if _, err := client.Show(ctx, &api.ShowRequest{Model: model}); err == nil { return nil } + // Cloud models only pull a small manifest; skip the download confirmation + // TODO(parthsareen): consolidate with cloud config changes + if strings.HasSuffix(model, "cloud") { + return pullModel(ctx, client, model) + } if ok, err := confirmPrompt(fmt.Sprintf("Download %s?", model)); err != nil { return err } else if !ok { @@ -647,6 +670,24 @@ func SaveIntegrationModel(name, modelName string) error { return saveIntegration(name, models) } +// SaveAndEditIntegration saves the models for an Editor integration and runs its Edit method +// to write the integration's config files. +func SaveAndEditIntegration(name string, models []string) error { + r, ok := integrations[strings.ToLower(name)] + if !ok { + return fmt.Errorf("unknown integration: %s", name) + } + if err := saveIntegration(name, models); err != nil { + return fmt.Errorf("failed to save: %w", err) + } + if editor, isEditor := r.(Editor); isEditor { + if err := editor.Edit(models); err != nil { + return fmt.Errorf("setup failed: %w", err) + } + } + return nil +} + // ConfigureIntegrationWithSelectors allows the user to select/change the model for an integration using custom selectors. func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single SingleSelector, multi MultiSelector) error { r, ok := integrations[name] diff --git a/cmd/config/integrations_test.go b/cmd/config/integrations_test.go index ec41219ab..f08a73f94 100644 --- a/cmd/config/integrations_test.go +++ b/cmd/config/integrations_test.go @@ -374,7 +374,7 @@ func TestParseArgs(t *testing.T) { func TestIsCloudModel(t *testing.T) { // isCloudModel now only uses Show API, so nil client always returns false t.Run("nil client returns false", func(t *testing.T) { - models := []string{"glm-4.7:cloud", "kimi-k2.5:cloud", "local-model"} + models := []string{"glm-5:cloud", "kimi-k2.5:cloud", "local-model"} for _, model := range models { if isCloudModel(context.Background(), nil, model) { t.Errorf("isCloudModel(%q) with nil client should return false", model) @@ -394,7 +394,7 @@ func names(items []ModelItem) []string { func TestBuildModelList_NoExistingModels(t *testing.T) { items, _, _, _ := buildModelList(nil, nil, "") - want := []string{"kimi-k2.5:cloud", "glm-4.7:cloud", "glm-4.7-flash", "qwen3:8b"} + want := []string{"glm-5:cloud", "kimi-k2.5:cloud", "glm-4.7-flash", "qwen3:8b"} if diff := cmp.Diff(want, names(items)); diff != "" { t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff) } @@ -416,7 +416,7 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) { got := names(items) // Recommended pinned at top (local recs first, then cloud recs when only-local), then installed non-recs - want := []string{"glm-4.7-flash", "qwen3:8b", "kimi-k2.5:cloud", "glm-4.7:cloud", "llama3.2", "qwen2.5"} + want := []string{"glm-4.7-flash", "qwen3:8b", "glm-5:cloud", "kimi-k2.5:cloud", "llama3.2", "qwen2.5"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("recs pinned at top, local recs before cloud recs (-want +got):\n%s", diff) } @@ -425,14 +425,14 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) { func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, - {Name: "glm-4.7:cloud", Remote: true}, + {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // All recs pinned at top (cloud before local in mixed case), then non-recs - want := []string{"kimi-k2.5:cloud", "glm-4.7:cloud", "glm-4.7-flash", "qwen3:8b", "llama3.2"} + want := []string{"glm-5:cloud", "kimi-k2.5:cloud", "glm-4.7-flash", "qwen3:8b", "llama3.2"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff) } @@ -441,7 +441,7 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) { func TestBuildModelList_PreCheckedFirst(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, - {Name: "glm-4.7:cloud", Remote: true}, + {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "") @@ -455,14 +455,14 @@ func TestBuildModelList_PreCheckedFirst(t *testing.T) { func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) { existing := []modelInfo{ {Name: "glm-4.7-flash", Remote: false}, - {Name: "glm-4.7:cloud", Remote: true}, + {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") for _, item := range items { switch item.Name { - case "glm-4.7-flash", "glm-4.7:cloud": + case "glm-4.7-flash", "glm-5:cloud": if strings.HasSuffix(item.Description, "install?") { t.Errorf("installed recommended %q should not have 'install?' suffix, got %q", item.Name, item.Description) } @@ -477,16 +477,16 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) { func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) { existing := []modelInfo{ {Name: "glm-4.7-flash", Remote: false}, - {Name: "glm-4.7:cloud", Remote: true}, + {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) - // glm-4.7-flash and glm-4.7:cloud are installed so they sort normally; + // glm-4.7-flash and glm-5:cloud are installed so they sort normally; // kimi-k2.5:cloud and qwen3:8b are not installed so they go to the bottom // All recs: cloud first in mixed case, then local, in rec order within each - want := []string{"kimi-k2.5:cloud", "glm-4.7:cloud", "glm-4.7-flash", "qwen3:8b"} + want := []string{"glm-5:cloud", "kimi-k2.5:cloud", "glm-4.7-flash", "qwen3:8b"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff) } @@ -504,7 +504,7 @@ func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *tes // kimi-k2.5:cloud is installed so it sorts normally; // the rest of the recommendations are not installed so they go to the bottom // All recs pinned at top (cloud first in mixed case), then non-recs - want := []string{"kimi-k2.5:cloud", "glm-4.7:cloud", "glm-4.7-flash", "qwen3:8b", "llama3.2"} + want := []string{"glm-5:cloud", "kimi-k2.5:cloud", "glm-4.7-flash", "qwen3:8b", "llama3.2"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff) } @@ -554,7 +554,7 @@ func TestBuildModelList_LatestTagStripped(t *testing.T) { func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, - {Name: "glm-4.7:cloud", Remote: true}, + {Name: "glm-5:cloud", Remote: true}, } _, _, existingModels, cloudModels := buildModelList(existing, nil, "") @@ -562,15 +562,15 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) { if !existingModels["llama3.2"] { t.Error("llama3.2 should be in existingModels") } - if !existingModels["glm-4.7:cloud"] { - t.Error("glm-4.7:cloud should be in existingModels") + if !existingModels["glm-5:cloud"] { + t.Error("glm-5:cloud should be in existingModels") } if existingModels["glm-4.7-flash"] { t.Error("glm-4.7-flash should not be in existingModels (it's a recommendation)") } - if !cloudModels["glm-4.7:cloud"] { - t.Error("glm-4.7:cloud should be in cloudModels") + if !cloudModels["glm-5:cloud"] { + t.Error("glm-5:cloud should be in cloudModels") } if !cloudModels["kimi-k2.5:cloud"] { t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)") @@ -590,7 +590,7 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) { for _, item := range items { switch item.Name { - case "glm-4.7-flash", "qwen3:8b", "glm-4.7:cloud", "kimi-k2.5:cloud": + case "glm-4.7-flash", "qwen3:8b", "glm-5:cloud", "kimi-k2.5:cloud": if !item.Recommended { t.Errorf("%q should have Recommended=true", item.Name) } @@ -605,14 +605,14 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) { func TestBuildModelList_MixedCase_CloudRecsFirst(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, - {Name: "glm-4.7:cloud", Remote: true}, + {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // Cloud recs should sort before local recs in mixed case - cloudIdx := slices.Index(got, "glm-4.7:cloud") + cloudIdx := slices.Index(got, "glm-5:cloud") localIdx := slices.Index(got, "glm-4.7-flash") if cloudIdx > localIdx { t.Errorf("cloud recs should be before local recs in mixed case, got %v", got) @@ -629,7 +629,7 @@ func TestBuildModelList_OnlyLocal_LocalRecsFirst(t *testing.T) { // Local recs should sort before cloud recs in only-local case localIdx := slices.Index(got, "glm-4.7-flash") - cloudIdx := slices.Index(got, "glm-4.7:cloud") + cloudIdx := slices.Index(got, "glm-5:cloud") if localIdx > cloudIdx { t.Errorf("local recs should be before cloud recs in only-local case, got %v", got) } @@ -648,7 +648,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) { lastRecIdx := -1 firstNonRecIdx := len(got) for i, name := range got { - isRec := name == "glm-4.7-flash" || name == "qwen3:8b" || name == "glm-4.7:cloud" || name == "kimi-k2.5:cloud" + isRec := name == "glm-4.7-flash" || name == "qwen3:8b" || name == "glm-5:cloud" || name == "kimi-k2.5:cloud" if isRec && i > lastRecIdx { lastRecIdx = i } @@ -664,7 +664,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) { func TestBuildModelList_CheckedBeforeRecs(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, - {Name: "glm-4.7:cloud", Remote: true}, + {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "") @@ -843,6 +843,43 @@ func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) { } } +func TestShowOrPull_CloudModel_SkipsConfirmation(t *testing.T) { + // Confirm prompt should NOT be called for cloud models + oldHook := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string) (bool, error) { + t.Error("confirm prompt should not be called for cloud models") + return false, nil + } + defer func() { DefaultConfirmPrompt = oldHook }() + + var pullCalled bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"error":"model not found"}`) + case "/api/pull": + pullCalled = true + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"success"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + err := ShowOrPull(context.Background(), client, "glm-5:cloud") + if err != nil { + t.Errorf("ShowOrPull should succeed for cloud model, got: %v", err) + } + if !pullCalled { + t.Error("expected pull to be called for cloud model without confirmation") + } +} + func TestConfirmPrompt_DelegatesToHook(t *testing.T) { oldHook := DefaultConfirmPrompt var hookCalled bool @@ -1164,3 +1201,56 @@ func TestLaunchIntegration_NotConfigured(t *testing.T) { t.Errorf("error should mention 'not configured', got: %v", err) } } + +func TestIsEditorIntegration(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"droid", true}, + {"opencode", true}, + {"openclaw", true}, + {"claude", false}, + {"codex", false}, + {"nonexistent", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsEditorIntegration(tt.name); got != tt.want { + t.Errorf("IsEditorIntegration(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestIntegrationModels(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + t.Run("returns nil when not configured", func(t *testing.T) { + if got := IntegrationModels("droid"); got != nil { + t.Errorf("expected nil, got %v", got) + } + }) + + t.Run("returns all saved models", func(t *testing.T) { + if err := saveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil { + t.Fatal(err) + } + got := IntegrationModels("droid") + want := []string{"llama3.2", "qwen3:8b"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("IntegrationModels mismatch (-want +got):\n%s", diff) + } + }) +} + +func TestSaveAndEditIntegration_UnknownIntegration(t *testing.T) { + err := SaveAndEditIntegration("nonexistent", []string{"model"}) + if err == nil { + t.Fatal("expected error for unknown integration") + } + if !strings.Contains(err.Error(), "unknown integration") { + t.Errorf("error should mention 'unknown integration', got: %v", err) + } +} diff --git a/cmd/tui/selector.go b/cmd/tui/selector.go index 24c133647..6791f44eb 100644 --- a/cmd/tui/selector.go +++ b/cmd/tui/selector.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/ollama/ollama/cmd/config" ) var ( @@ -34,12 +35,6 @@ var ( selectorInputStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}) - selectorCheckboxStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) - - selectorCheckboxCheckedStyle = lipgloss.NewStyle(). - Bold(true) - selectorDefaultTagStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}). Italic(true) @@ -69,6 +64,30 @@ type SelectItem struct { Recommended bool } +// ConvertItems converts config.ModelItem slice to SelectItem slice. +func ConvertItems(items []config.ModelItem) []SelectItem { + out := make([]SelectItem, len(items)) + for i, item := range items { + out[i] = SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended} + } + return out +} + +// ReorderItems returns a copy with recommended items first, then non-recommended, +// preserving relative order within each group. This ensures the data order matches +// the visual section layout (Recommended / More). +func ReorderItems(items []SelectItem) []SelectItem { + var rec, other []SelectItem + for _, item := range items { + if item.Recommended { + rec = append(rec, item) + } else { + other = append(other, item) + } + } + return append(rec, other...) +} + // selectorModel is the bubbletea model for single selection. type selectorModel struct { title string @@ -421,6 +440,50 @@ func (m multiSelectorModel) filteredItems() []SelectItem { return result } +// otherStart returns the index of the first non-recommended item in the filtered list. +func (m multiSelectorModel) otherStart() int { + if m.filter != "" { + return 0 + } + filtered := m.filteredItems() + for i, item := range filtered { + if !item.Recommended { + return i + } + } + return len(filtered) +} + +// updateScroll adjusts scrollOffset for section-based scrolling (matches single-select). +func (m *multiSelectorModel) updateScroll(otherStart int) { + if m.filter != "" { + if m.cursor < m.scrollOffset { + m.scrollOffset = m.cursor + } + if m.cursor >= m.scrollOffset+maxSelectorItems { + m.scrollOffset = m.cursor - maxSelectorItems + 1 + } + return + } + + if m.cursor < otherStart { + m.scrollOffset = 0 + return + } + + posInOthers := m.cursor - otherStart + maxOthers := maxSelectorItems - otherStart + if maxOthers < 3 { + maxOthers = 3 + } + if posInOthers < m.scrollOffset { + m.scrollOffset = posInOthers + } + if posInOthers >= m.scrollOffset+maxOthers { + m.scrollOffset = posInOthers - maxOthers + 1 + } +} + func (m *multiSelectorModel) toggleItem() { filtered := m.filteredItems() if len(filtered) == 0 || m.cursor >= len(filtered) { @@ -482,17 +545,13 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyUp: if m.cursor > 0 { m.cursor-- - if m.cursor < m.scrollOffset { - m.scrollOffset = m.cursor - } + m.updateScroll(m.otherStart()) } case tea.KeyDown: if m.cursor < len(filtered)-1 { m.cursor++ - if m.cursor >= m.scrollOffset+maxSelectorItems { - m.scrollOffset = m.cursor - maxSelectorItems + 1 - } + m.updateScroll(m.otherStart()) } case tea.KeyPgUp: @@ -500,19 +559,14 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor < 0 { m.cursor = 0 } - m.scrollOffset -= maxSelectorItems - if m.scrollOffset < 0 { - m.scrollOffset = 0 - } + m.updateScroll(m.otherStart()) case tea.KeyPgDown: m.cursor += maxSelectorItems if m.cursor >= len(filtered) { m.cursor = len(filtered) - 1 } - if m.cursor >= m.scrollOffset+maxSelectorItems { - m.scrollOffset = m.cursor - maxSelectorItems + 1 - } + m.updateScroll(m.otherStart()) case tea.KeyBackspace: if len(m.filter) > 0 { @@ -531,6 +585,34 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m multiSelectorModel) renderMultiItem(s *strings.Builder, item SelectItem, idx int) { + origIdx := m.itemIndex[item.Name] + + var check string + if m.checked[origIdx] { + check = "[x] " + } else { + check = "[ ] " + } + + suffix := "" + if len(m.checkOrder) > 0 && m.checkOrder[0] == origIdx { + suffix = " " + selectorDefaultTagStyle.Render("(default)") + } + + if idx == m.cursor { + s.WriteString(selectorSelectedItemStyle.Render("▸ " + check + item.Name)) + } else { + s.WriteString(selectorItemStyle.Render(check + item.Name)) + } + s.WriteString(suffix) + s.WriteString("\n") + if item.Description != "" { + s.WriteString(selectorDescLineStyle.Render(item.Description)) + s.WriteString("\n") + } +} + func (m multiSelectorModel) View() string { if m.cancelled || m.confirmed { return "" @@ -552,56 +634,65 @@ func (m multiSelectorModel) View() string { if len(filtered) == 0 { s.WriteString(selectorItemStyle.Render(selectorDescStyle.Render("(no matches)"))) s.WriteString("\n") - } else { + } else if m.filter != "" { + // Filtering: flat scroll through all matches displayCount := min(len(filtered), maxSelectorItems) - shownRecHeader := false - prevWasRec := false - for i := range displayCount { idx := m.scrollOffset + i if idx >= len(filtered) { break } - item := filtered[idx] - origIdx := m.itemIndex[item.Name] - - if m.filter == "" { - if item.Recommended && !shownRecHeader { - s.WriteString(sectionHeaderStyle.Render("Recommended")) - s.WriteString("\n") - shownRecHeader = true - } else if !item.Recommended && prevWasRec { - s.WriteString("\n") - } - prevWasRec = item.Recommended - } - - var checkbox string - if m.checked[origIdx] { - checkbox = selectorCheckboxCheckedStyle.Render("[x]") - } else { - checkbox = selectorCheckboxStyle.Render("[ ]") - } - - var line string - if idx == m.cursor { - line = selectorSelectedItemStyle.Render("▸ ") + checkbox + " " + selectorSelectedItemStyle.Render(item.Name) - } else { - line = " " + checkbox + " " + item.Name - } - - if len(m.checkOrder) > 0 && m.checkOrder[0] == origIdx { - line += " " + selectorDefaultTagStyle.Render("(default)") - } - - s.WriteString(line) - s.WriteString("\n") + m.renderMultiItem(&s, filtered[idx], idx) } if remaining := len(filtered) - m.scrollOffset - displayCount; remaining > 0 { s.WriteString(selectorMoreStyle.Render(fmt.Sprintf("... and %d more", remaining))) s.WriteString("\n") } + } else { + // Split into pinned recommended and scrollable others (matches single-select layout) + var recItems, otherItems []int + for i, item := range filtered { + if item.Recommended { + recItems = append(recItems, i) + } else { + otherItems = append(otherItems, i) + } + } + + // Always render all recommended items (pinned) + if len(recItems) > 0 { + s.WriteString(sectionHeaderStyle.Render("Recommended")) + s.WriteString("\n") + for _, idx := range recItems { + m.renderMultiItem(&s, filtered[idx], idx) + } + } + + if len(otherItems) > 0 { + s.WriteString("\n") + s.WriteString(sectionHeaderStyle.Render("More")) + s.WriteString("\n") + + maxOthers := maxSelectorItems - len(recItems) + if maxOthers < 3 { + maxOthers = 3 + } + displayCount := min(len(otherItems), maxOthers) + + for i := range displayCount { + idx := m.scrollOffset + i + if idx >= len(otherItems) { + break + } + m.renderMultiItem(&s, filtered[otherItems[idx]], otherItems[idx]) + } + + if remaining := len(otherItems) - m.scrollOffset - displayCount; remaining > 0 { + s.WriteString(selectorMoreStyle.Render(fmt.Sprintf("... and %d more", remaining))) + s.WriteString("\n") + } + } } s.WriteString("\n") diff --git a/cmd/tui/selector_test.go b/cmd/tui/selector_test.go index f03bf4cdb..095fd2866 100644 --- a/cmd/tui/selector_test.go +++ b/cmd/tui/selector_test.go @@ -382,6 +382,169 @@ func TestUpdateNavigation_Backspace(t *testing.T) { } } +// --- ReorderItems --- + +func TestReorderItems(t *testing.T) { + input := []SelectItem{ + {Name: "local-1"}, + {Name: "rec-a", Recommended: true}, + {Name: "local-2"}, + {Name: "rec-b", Recommended: true}, + } + got := ReorderItems(input) + want := []string{"rec-a", "rec-b", "local-1", "local-2"} + for i, item := range got { + if item.Name != want[i] { + t.Errorf("index %d: got %q, want %q", i, item.Name, want[i]) + } + } +} + +func TestReorderItems_AllRecommended(t *testing.T) { + input := recItems("a", "b", "c") + got := ReorderItems(input) + if len(got) != 3 { + t.Fatalf("expected 3 items, got %d", len(got)) + } + for i, item := range got { + if item.Name != input[i].Name { + t.Errorf("order should be preserved, index %d: got %q, want %q", i, item.Name, input[i].Name) + } + } +} + +func TestReorderItems_NoneRecommended(t *testing.T) { + input := items("x", "y") + got := ReorderItems(input) + if len(got) != 2 || got[0].Name != "x" || got[1].Name != "y" { + t.Errorf("order should be preserved, got %v", got) + } +} + +// --- Multi-select otherStart --- + +func TestMultiOtherStart(t *testing.T) { + tests := []struct { + name string + items []SelectItem + filter string + want int + }{ + {"all recommended", recItems("a", "b"), "", 2}, + {"none recommended", items("a", "b"), "", 0}, + {"mixed", mixedItems(), "", 2}, + {"with filter returns 0", mixedItems(), "other", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newMultiSelectorModel("test", tt.items, nil) + m.filter = tt.filter + if got := m.otherStart(); got != tt.want { + t.Errorf("otherStart() = %d, want %d", got, tt.want) + } + }) + } +} + +// --- Multi-select updateScroll --- + +func TestMultiUpdateScroll(t *testing.T) { + tests := []struct { + name string + cursor int + offset int + otherStart int + wantOffset int + }{ + {"cursor in recommended resets scroll", 1, 5, 3, 0}, + {"cursor at start of others", 2, 0, 2, 0}, + {"cursor scrolls down in others", 12, 0, 2, 3}, + {"cursor scrolls up in others", 4, 5, 2, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newMultiSelectorModel("test", nil, nil) + m.cursor = tt.cursor + m.scrollOffset = tt.offset + m.updateScroll(tt.otherStart) + if m.scrollOffset != tt.wantOffset { + t.Errorf("scrollOffset = %d, want %d", m.scrollOffset, tt.wantOffset) + } + }) + } +} + +// --- Multi-select View section headers --- + +func TestMultiView_SectionHeaders(t *testing.T) { + m := newMultiSelectorModel("Pick:", []SelectItem{ + {Name: "rec-a", Recommended: true}, + {Name: "other-1"}, + }, nil) + content := m.View() + + if !strings.Contains(content, "Recommended") { + t.Error("should contain 'Recommended' header") + } + if !strings.Contains(content, "More") { + t.Error("should contain 'More' header") + } +} + +func TestMultiView_CursorIndicator(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), nil) + m.cursor = 0 + content := m.View() + + if !strings.Contains(content, "▸") { + t.Error("should show ▸ cursor indicator") + } +} + +func TestMultiView_CheckedItemShowsX(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), []string{"a"}) + content := m.View() + + if !strings.Contains(content, "[x]") { + t.Error("checked item should show [x]") + } + if !strings.Contains(content, "[ ]") { + t.Error("unchecked item should show [ ]") + } +} + +func TestMultiView_DefaultTag(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), []string{"a"}) + content := m.View() + + if !strings.Contains(content, "(default)") { + t.Error("first checked item should have (default) tag") + } +} + +func TestMultiView_PinnedRecommended(t *testing.T) { + m := newMultiSelectorModel("Pick:", mixedItems(), nil) + m.cursor = 8 + m.scrollOffset = 3 + content := m.View() + + if !strings.Contains(content, "rec-a") { + t.Error("recommended items should always be visible (pinned)") + } + if !strings.Contains(content, "rec-b") { + t.Error("recommended items should always be visible (pinned)") + } +} + +func TestMultiView_OverflowIndicator(t *testing.T) { + m := newMultiSelectorModel("Pick:", mixedItems(), nil) + content := m.View() + + if !strings.Contains(content, "... and") { + t.Error("should show overflow indicator when more items than visible") + } +} + // Key message helpers for testing type keyType = int diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 1a1020eed..5e7e533d9 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -115,6 +115,7 @@ type model struct { quitting bool selected bool changeModel bool + changeModels []string // multi-select result for Editor integrations showOthers bool availableModels map[string]bool err error @@ -123,6 +124,9 @@ type model struct { modalSelector selectorModel modalItems []SelectItem + showingMultiModal bool + multiModalSelector multiSelectorModel + showingSignIn bool signInURL string signInModel string @@ -160,23 +164,50 @@ func (m *model) modelExists(name string) bool { func (m *model) buildModalItems() []SelectItem { modelItems, _ := config.GetModelItems(context.Background()) - var items []SelectItem - for _, item := range modelItems { - items = append(items, SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended}) - } - return items + return ReorderItems(ConvertItems(modelItems)) } -func (m *model) openModelModal() { +func (m *model) openModelModal(currentModel string) { m.modalItems = m.buildModalItems() + cursor := 0 + if currentModel != "" { + for i, item := range m.modalItems { + if item.Name == currentModel || strings.HasPrefix(item.Name, currentModel+":") || strings.HasPrefix(currentModel, item.Name+":") { + cursor = i + break + } + } + } m.modalSelector = selectorModel{ title: "Select model:", items: m.modalItems, + cursor: cursor, helpText: "↑/↓ navigate • enter select • ← back", } + m.modalSelector.updateScroll(m.modalSelector.otherStart()) m.showingModal = true } +func (m *model) openMultiModelModal(integration string) { + items := m.buildModalItems() + var preChecked []string + if models := config.IntegrationModels(integration); len(models) > 0 { + preChecked = models + } + m.multiModalSelector = newMultiSelectorModel("Select models:", items, preChecked) + // Set cursor to the first pre-checked (last used) model + if len(preChecked) > 0 { + for i, item := range items { + if item.Name == preChecked[0] { + m.multiModalSelector.cursor = i + m.multiModalSelector.updateScroll(m.multiModalSelector.otherStart()) + break + } + } + } + m.showingMultiModal = true +} + func isCloudModel(name string) bool { return strings.HasSuffix(name, ":cloud") } @@ -356,6 +387,39 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + if m.showingMultiModal { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyLeft { + m.showingMultiModal = false + return m, nil + } + updated, cmd := m.multiModalSelector.Update(msg) + m.multiModalSelector = updated.(multiSelectorModel) + + if m.multiModalSelector.cancelled { + m.showingMultiModal = false + return m, nil + } + if m.multiModalSelector.confirmed { + var selected []string + for _, idx := range m.multiModalSelector.checkOrder { + selected = append(selected, m.multiModalSelector.items[idx].Name) + } + if len(selected) > 0 { + m.changeModels = selected + m.changeModel = true + m.quitting = true + return m, tea.Quit + } + m.multiModalSelector.confirmed = false + return m, nil + } + return m, cmd + } + return m, nil + } + if m.showingModal { switch msg := msg.(type) { case tea.KeyMsg: @@ -442,7 +506,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if item.integration != "" && !config.IsIntegrationInstalled(item.integration) { return m, nil } - m.openModelModal() + if item.integration != "" && config.IsEditorIntegration(item.integration) { + m.openMultiModelModal(item.integration) + } else { + var currentModel string + if item.isRunModel { + currentModel = config.LastModel() + } else if item.integration != "" { + currentModel = config.IntegrationModel(item.integration) + } + m.openModelModal(currentModel) + } } } } @@ -459,6 +533,10 @@ func (m model) View() string { return m.renderSignInDialog() } + if m.showingMultiModal { + return m.multiModalSelector.View() + } + if m.showingModal { return m.renderModal() } @@ -554,8 +632,9 @@ const ( type Result struct { Selection Selection - Integration string // integration name if applicable - Model string // model name if selected from modal + Integration string // integration name if applicable + Model string // model name if selected from single-select modal + Models []string // models selected from multi-select modal (Editor integrations) } func Run() (Result, error) { @@ -589,6 +668,7 @@ func Run() (Result, error) { Selection: SelectionChangeIntegration, Integration: item.integration, Model: fm.modalSelector.selected, + Models: fm.changeModels, }, nil }