diff --git a/cmd/config/integrations.go b/cmd/config/integrations.go index fb13202d5..714eae625 100644 --- a/cmd/config/integrations.go +++ b/cmd/config/integrations.go @@ -13,6 +13,7 @@ import ( "time" "github.com/ollama/ollama/api" + "github.com/ollama/ollama/progress" "github.com/spf13/cobra" ) @@ -49,6 +50,15 @@ var integrations = map[string]Runner{ "openclaw": &Openclaw{}, } +// recommendedModels are shown when the user has no models or as suggestions. +// Order matters: local models first, then cloud models. +var recommendedModels = []selectItem{ + {Name: "glm-4.7-flash", Description: "Recommended (requires ~25GB VRAM)"}, + {Name: "qwen3:8b", Description: "Recommended (requires ~11GB VRAM)"}, + {Name: "glm-4.7:cloud", Description: "Recommended"}, + {Name: "kimi-k2.5:cloud", Description: "Recommended"}, +} + // integrationAliases are hidden from the interactive selector but work as CLI arguments. var integrationAliases = map[string]bool{ "clawdbot": true, @@ -94,62 +104,25 @@ func selectModels(ctx context.Context, name, current string) ([]string, error) { return nil, err } - if len(models.Models) == 0 { - return nil, fmt.Errorf("no models available, run 'ollama pull ' first") - } - - var items []selectItem - cloudModels := make(map[string]bool) + var existing []modelInfo for _, m := range models.Models { - if m.RemoteModel != "" { - cloudModels[m.Name] = true - } - items = append(items, selectItem{Name: m.Name}) + existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""}) } - if len(items) == 0 { - return nil, fmt.Errorf("no local models available, run 'ollama pull ' first") - } - - // Get previously configured models (saved config takes precedence) var preChecked []string if saved, err := loadIntegration(name); err == nil { preChecked = saved.Models } else if editor, ok := r.(Editor); ok { preChecked = editor.Models() } - checked := make(map[string]bool, len(preChecked)) - for _, n := range preChecked { - checked[n] = true - } - // Resolve current to full name (e.g., "llama3.2" -> "llama3.2:latest") - for _, item := range items { - if item.Name == current || strings.HasPrefix(item.Name, current+":") { - current = item.Name - break - } - } + items, preChecked, existingModels, cloudModels := buildModelList(existing, preChecked, current) - // If current model is configured, move to front of preChecked - if checked[current] { - preChecked = append([]string{current}, slices.DeleteFunc(preChecked, func(m string) bool { return m == current })...) + if len(items) == 0 { + return nil, fmt.Errorf("no models available") } - // Sort: checked first, then alphabetical - slices.SortFunc(items, func(a, b selectItem) int { - ac, bc := checked[a.Name], checked[b.Name] - if ac != bc { - if ac { - return -1 - } - return 1 - } - return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) - }) - var selected []string - // only editors support multi-model selection if _, ok := r.(Editor); ok { selected, err = multiSelectPrompt(fmt.Sprintf("Select models for %s:", r), items, preChecked) if err != nil { @@ -163,7 +136,27 @@ func selectModels(ctx context.Context, name, current string) ([]string, error) { selected = []string{model} } - // if any model in selected is a cloud model, ensure signed in + var toPull []string + for _, m := range selected { + if !existingModels[m] { + toPull = append(toPull, m) + } + } + if len(toPull) > 0 { + msg := fmt.Sprintf("Download %s?", strings.Join(toPull, ", ")) + if ok, err := confirmPrompt(msg); err != nil { + return nil, err + } else if !ok { + return nil, errCancelled + } + for _, m := range toPull { + fmt.Fprintf(os.Stderr, "\n") + if err := pullModel(ctx, client, m); err != nil { + return nil, fmt.Errorf("failed to pull %s: %w", m, err) + } + } + } + var selectedCloudModels []string for _, m := range selected { if cloudModels[m] { @@ -309,7 +302,6 @@ Examples: return fmt.Errorf("unknown integration: %s", name) } - // If launching without --model, use saved config if available if !configFlag && modelFlag == "" { if config, err := loadIntegration(name); err == nil && len(config.Models) > 0 { return runIntegration(name, config.Models[0], passArgs) @@ -318,7 +310,6 @@ Examples: var models []string if modelFlag != "" { - // When --model is specified, merge with existing models (new model becomes default) models = []string{modelFlag} if existing, err := loadIntegration(name); err == nil && len(existing.Models) > 0 { for _, m := range existing.Models { @@ -387,3 +378,154 @@ Examples: cmd.Flags().BoolVar(&configFlag, "config", false, "Configure without launching") return cmd } + +type modelInfo struct { + Name string + Remote bool +} + +// buildModelList merges existing models with recommendations, sorts them, and returns +// the ordered items along with maps of existing and cloud model names. +func buildModelList(existing []modelInfo, preChecked []string, current string) (items []selectItem, orderedChecked []string, existingModels, cloudModels map[string]bool) { + existingModels = make(map[string]bool) + cloudModels = make(map[string]bool) + recommended := make(map[string]bool) + var hasLocalModel, hasCloudModel bool + + for _, rec := range recommendedModels { + recommended[rec.Name] = true + } + + for _, m := range existing { + existingModels[m.Name] = true + if m.Remote { + cloudModels[m.Name] = true + hasCloudModel = true + } else { + hasLocalModel = true + } + displayName := strings.TrimSuffix(m.Name, ":latest") + existingModels[displayName] = true + item := selectItem{Name: displayName} + if recommended[displayName] { + item.Description = "recommended" + } + items = append(items, item) + } + + for _, rec := range recommendedModels { + if existingModels[rec.Name] || existingModels[rec.Name+":latest"] { + continue + } + items = append(items, rec) + if isCloudModel(rec.Name) { + cloudModels[rec.Name] = true + } + } + + checked := make(map[string]bool, len(preChecked)) + for _, n := range preChecked { + checked[n] = true + } + + // Resolve current to full name (e.g., "llama3.2" -> "llama3.2:latest") + for _, item := range items { + if item.Name == current || strings.HasPrefix(item.Name, current+":") { + current = item.Name + break + } + } + + if checked[current] { + preChecked = append([]string{current}, slices.DeleteFunc(preChecked, func(m string) bool { return m == current })...) + } + + // Non-existing models get "install?" suffix and are pushed to the bottom. + // When user has no models, preserve recommended order. + notInstalled := make(map[string]bool) + for i := range items { + if !existingModels[items[i].Name] { + notInstalled[items[i].Name] = true + if items[i].Description != "" { + items[i].Description += ", install?" + } else { + items[i].Description = "install?" + } + } + } + + if hasLocalModel || hasCloudModel { + slices.SortStableFunc(items, func(a, b selectItem) int { + ac, bc := checked[a.Name], checked[b.Name] + aNew, bNew := notInstalled[a.Name], notInstalled[b.Name] + + if ac != bc { + if ac { + return -1 + } + return 1 + } + if !ac && !bc && aNew != bNew { + if aNew { + return 1 + } + return -1 + } + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + } + + return items, preChecked, existingModels, cloudModels +} + +func isCloudModel(name string) bool { + return strings.HasSuffix(name, ":cloud") +} + +func pullModel(ctx context.Context, client *api.Client, model string) error { + p := progress.NewProgress(os.Stderr) + defer p.Stop() + + bars := make(map[string]*progress.Bar) + var status string + var spinner *progress.Spinner + + fn := func(resp api.ProgressResponse) error { + if resp.Digest != "" { + if resp.Completed == 0 { + return nil + } + + if spinner != nil { + spinner.Stop() + } + + bar, ok := bars[resp.Digest] + if !ok { + name, isDigest := strings.CutPrefix(resp.Digest, "sha256:") + name = strings.TrimSpace(name) + if isDigest { + name = name[:min(12, len(name))] + } + bar = progress.NewBar(fmt.Sprintf("pulling %s:", name), resp.Total, resp.Completed) + bars[resp.Digest] = bar + p.Add(resp.Digest, bar) + } + + bar.Set(resp.Completed) + } else if status != resp.Status { + if spinner != nil { + spinner.Stop() + } + + status = resp.Status + spinner = progress.NewSpinner(status) + p.Add(status, spinner) + } + + return nil + } + + request := api.PullRequest{Name: model} + return client.Pull(ctx, &request, fn) +} diff --git a/cmd/config/integrations_test.go b/cmd/config/integrations_test.go index e460142c4..dd2056e98 100644 --- a/cmd/config/integrations_test.go +++ b/cmd/config/integrations_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/spf13/cobra" ) @@ -175,14 +176,10 @@ func TestLaunchCmd_NilHeartbeat(t *testing.T) { func TestAllIntegrations_HaveRequiredMethods(t *testing.T) { for name, r := range integrations { t.Run(name, func(t *testing.T) { - // Test String() doesn't panic and returns non-empty displayName := r.String() if displayName == "" { t.Error("String() should not return empty") } - - // Test Run() exists (we can't call it without actually running the command) - // Just verify the method is available var _ func(string, []string) error = r.Run }) } @@ -298,3 +295,217 @@ func TestParseArgs(t *testing.T) { }) } } + +func TestIsCloudModel(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"glm-4.7:cloud", true}, + {"kimi-k2.5:cloud", true}, + {"glm-4.7-flash", false}, + {"glm-4.7-flash:latest", false}, + {"cloud-model", false}, + {"model:cloudish", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isCloudModel(tt.name); got != tt.want { + t.Errorf("isCloudModel(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func names(items []selectItem) []string { + var out []string + for _, item := range items { + out = append(out, item.Name) + } + return out +} + +func TestBuildModelList_NoExistingModels(t *testing.T) { + items, _, _, _ := buildModelList(nil, nil, "") + + want := []string{"glm-4.7-flash", "qwen3:8b", "glm-4.7:cloud", "kimi-k2.5:cloud"} + 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) + } + + for _, item := range items { + if !strings.HasSuffix(item.Description, "install?") { + t.Errorf("item %q should have description ending with 'install?', got %q", item.Name, item.Description) + } + } +} + +func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) { + existing := []modelInfo{ + {Name: "llama3.2:latest", Remote: false}, + {Name: "qwen2.5:latest", Remote: false}, + } + + items, _, _, _ := buildModelList(existing, nil, "") + got := names(items) + + want := []string{"llama3.2", "qwen2.5", "glm-4.7-flash", "glm-4.7:cloud", "kimi-k2.5:cloud", "qwen3:8b"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("cloud recs should be at bottom (-want +got):\n%s", diff) + } +} + +func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) { + existing := []modelInfo{ + {Name: "llama3.2:latest", Remote: false}, + {Name: "glm-4.7:cloud", Remote: true}, + } + + items, _, _, _ := buildModelList(existing, nil, "") + got := names(items) + + want := []string{"glm-4.7:cloud", "llama3.2", "glm-4.7-flash", "kimi-k2.5:cloud", "qwen3:8b"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mixed models should be alphabetical (-want +got):\n%s", diff) + } +} + +func TestBuildModelList_PreCheckedFirst(t *testing.T) { + existing := []modelInfo{ + {Name: "llama3.2:latest", Remote: false}, + {Name: "glm-4.7:cloud", Remote: true}, + } + + items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "") + got := names(items) + + if got[0] != "llama3.2" { + t.Errorf("pre-checked model should be first, got %v", got) + } +} + +func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) { + existing := []modelInfo{ + {Name: "glm-4.7-flash", Remote: false}, + {Name: "glm-4.7:cloud", Remote: true}, + } + + items, _, _, _ := buildModelList(existing, nil, "") + + for _, item := range items { + switch item.Name { + case "glm-4.7-flash", "glm-4.7:cloud": + if strings.HasSuffix(item.Description, "install?") { + t.Errorf("installed recommended %q should not have 'install?' suffix, got %q", item.Name, item.Description) + } + case "kimi-k2.5:cloud", "qwen3:8b": + if !strings.HasSuffix(item.Description, "install?") { + t.Errorf("non-installed recommended %q should have 'install?' suffix, got %q", item.Name, item.Description) + } + } + } +} + +func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) { + existing := []modelInfo{ + {Name: "glm-4.7-flash", Remote: false}, + {Name: "glm-4.7: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; + // kimi-k2.5:cloud and qwen3:8b are not installed so they go to the bottom + want := []string{"glm-4.7-flash", "glm-4.7:cloud", "kimi-k2.5:cloud", "qwen3:8b"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("existing cloud models should sort normally (-want +got):\n%s", diff) + } +} + +func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *testing.T) { + existing := []modelInfo{ + {Name: "llama3.2:latest", Remote: false}, + {Name: "kimi-k2.5:cloud", Remote: true}, + } + + items, _, _, _ := buildModelList(existing, nil, "") + got := names(items) + + // kimi-k2.5:cloud is installed so it sorts normally; + // the rest of the recommendations are not installed so they go to the bottom + want := []string{"kimi-k2.5:cloud", "llama3.2", "glm-4.7-flash", "glm-4.7:cloud", "qwen3:8b"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("only non-installed models should be at bottom (-want +got):\n%s", diff) + } + + for _, item := range items { + if !slices.Contains([]string{"kimi-k2.5:cloud", "llama3.2"}, item.Name) { + if !strings.HasSuffix(item.Description, "install?") { + t.Errorf("non-installed %q should have 'install?' suffix, got %q", item.Name, item.Description) + } + } + } +} + +func TestBuildModelList_LatestTagStripped(t *testing.T) { + existing := []modelInfo{ + {Name: "glm-4.7-flash:latest", Remote: false}, + {Name: "llama3.2:latest", Remote: false}, + } + + items, _, existingModels, _ := buildModelList(existing, nil, "") + got := names(items) + + // :latest should be stripped from display names + for _, name := range got { + if strings.HasSuffix(name, ":latest") { + t.Errorf("name %q should not have :latest suffix", name) + } + } + + // glm-4.7-flash should not be duplicated (existing :latest matches the recommendation) + count := 0 + for _, name := range got { + if name == "glm-4.7-flash" { + count++ + } + } + if count != 1 { + t.Errorf("glm-4.7-flash should appear exactly once, got %d in %v", count, got) + } + + // Stripped name should be in existingModels so it won't be pulled + if !existingModels["glm-4.7-flash"] { + t.Error("glm-4.7-flash should be in existingModels") + } +} + +func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) { + existing := []modelInfo{ + {Name: "llama3.2:latest", Remote: false}, + {Name: "glm-4.7:cloud", Remote: true}, + } + + _, _, existingModels, cloudModels := buildModelList(existing, nil, "") + + 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-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["kimi-k2.5:cloud"] { + t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)") + } + if cloudModels["llama3.2"] { + t.Error("llama3.2 should not be in cloudModels") + } +} diff --git a/cmd/config/selector.go b/cmd/config/selector.go index 8117be9db..956e1f1ea 100644 --- a/cmd/config/selector.go +++ b/cmd/config/selector.go @@ -353,10 +353,15 @@ func renderMultiSelect(w io.Writer, prompt string, s *multiSelectState) int { suffix = " " + ansiGray + "(default)" + ansiReset } + desc := "" + if item.Description != "" { + desc = " " + ansiGray + "- " + item.Description + ansiReset + } + if idx == s.highlighted && !s.focusOnButton { - fmt.Fprintf(w, " %s%s %s %s%s%s\r\n", ansiBold, prefix, checkbox, item.Name, ansiReset, suffix) + fmt.Fprintf(w, " %s%s %s %s%s%s%s\r\n", ansiBold, prefix, checkbox, item.Name, ansiReset, desc, suffix) } else { - fmt.Fprintf(w, " %s %s %s%s\r\n", prefix, checkbox, item.Name, suffix) + fmt.Fprintf(w, " %s %s %s%s%s\r\n", prefix, checkbox, item.Name, desc, suffix) } lineCount++ }