mirror of
https://github.com/ollama/ollama.git
synced 2026-03-27 02:58:43 +07:00
cmd/tui: improve model picker UX (#14209)
This commit is contained in:
24
cmd/cmd.go
24
cmd/cmd.go
@@ -58,10 +58,7 @@ import (
|
|||||||
func init() {
|
func init() {
|
||||||
// Override default selectors to use Bubbletea TUI instead of raw terminal I/O.
|
// Override default selectors to use Bubbletea TUI instead of raw terminal I/O.
|
||||||
config.DefaultSingleSelector = func(title string, items []config.ModelItem) (string, error) {
|
config.DefaultSingleSelector = func(title string, items []config.ModelItem) (string, error) {
|
||||||
tuiItems := make([]tui.SelectItem, len(items))
|
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
|
||||||
for i, item := range items {
|
|
||||||
tuiItems[i] = tui.SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended}
|
|
||||||
}
|
|
||||||
result, err := tui.SelectSingle(title, tuiItems)
|
result, err := tui.SelectSingle(title, tuiItems)
|
||||||
if errors.Is(err, tui.ErrCancelled) {
|
if errors.Is(err, tui.ErrCancelled) {
|
||||||
return "", config.ErrCancelled
|
return "", config.ErrCancelled
|
||||||
@@ -70,10 +67,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.DefaultMultiSelector = func(title string, items []config.ModelItem, preChecked []string) ([]string, error) {
|
config.DefaultMultiSelector = func(title string, items []config.ModelItem, preChecked []string) ([]string, error) {
|
||||||
tuiItems := make([]tui.SelectItem, len(items))
|
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
|
||||||
for i, item := range items {
|
|
||||||
tuiItems[i] = tui.SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended}
|
|
||||||
}
|
|
||||||
result, err := tui.SelectMultiple(title, tuiItems, preChecked)
|
result, err := tui.SelectMultiple(title, tuiItems, preChecked)
|
||||||
if errors.Is(err, tui.ErrCancelled) {
|
if errors.Is(err, tui.ErrCancelled) {
|
||||||
return nil, config.ErrCancelled
|
return nil, config.ErrCancelled
|
||||||
@@ -2013,9 +2007,17 @@ func runInteractiveTUI(cmd *cobra.Command) {
|
|||||||
}
|
}
|
||||||
case tui.SelectionChangeIntegration:
|
case tui.SelectionChangeIntegration:
|
||||||
_ = config.SetLastSelection(result.Integration)
|
_ = config.SetLastSelection(result.Integration)
|
||||||
// Use model from modal if selected, otherwise show picker
|
if len(result.Models) > 0 {
|
||||||
if result.Model != "" {
|
// Multi-select from modal (Editor integrations)
|
||||||
// Model already selected from modal - save and launch
|
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 {
|
if err := config.SaveIntegrationModel(result.Integration, result.Model); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -160,6 +160,15 @@ func IntegrationModel(appName string) string {
|
|||||||
return ic.Models[0]
|
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.
|
// LastModel returns the last model that was run, or empty string if none.
|
||||||
func LastModel() string {
|
func LastModel() string {
|
||||||
cfg, err := load()
|
cfg, err := load()
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ var integrations = map[string]Runner{
|
|||||||
// recommendedModels are shown when the user has no models or as suggestions.
|
// recommendedModels are shown when the user has no models or as suggestions.
|
||||||
// Order matters: local models first, then cloud models.
|
// Order matters: local models first, then cloud models.
|
||||||
var recommendedModels = []ModelItem{
|
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: "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: "glm-4.7-flash", Description: "Reasoning and code generation locally", Recommended: true},
|
||||||
{Name: "qwen3:8b", Description: "Efficient all-purpose assistant", 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.
|
// SelectModel lets the user select a model to run.
|
||||||
// ModelItem represents a model for selection.
|
// ModelItem represents a model for selection.
|
||||||
type ModelItem struct {
|
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 the selected model isn't installed, pull it first
|
||||||
if !existingModels[selected] {
|
if !existingModels[selected] {
|
||||||
msg := fmt.Sprintf("Download %s?", selected)
|
if cloudModels[selected] {
|
||||||
if ok, err := confirmPrompt(msg); err != nil {
|
// Cloud models only pull a small manifest; no confirmation needed
|
||||||
return "", err
|
if err := pullModel(ctx, client, selected); err != nil {
|
||||||
} else if !ok {
|
return "", fmt.Errorf("failed to pull %s: %w", selected, err)
|
||||||
return "", errCancelled
|
}
|
||||||
}
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "\n")
|
msg := fmt.Sprintf("Download %s?", selected)
|
||||||
if err := pullModel(ctx, client, selected); err != nil {
|
if ok, err := confirmPrompt(msg); err != nil {
|
||||||
return "", fmt.Errorf("failed to pull %s: %w", selected, err)
|
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 {
|
if _, err := client.Show(ctx, &api.ShowRequest{Model: model}); err == nil {
|
||||||
return 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 {
|
if ok, err := confirmPrompt(fmt.Sprintf("Download %s?", model)); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
@@ -647,6 +670,24 @@ func SaveIntegrationModel(name, modelName string) error {
|
|||||||
return saveIntegration(name, models)
|
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.
|
// 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 {
|
func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single SingleSelector, multi MultiSelector) error {
|
||||||
r, ok := integrations[name]
|
r, ok := integrations[name]
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ func TestParseArgs(t *testing.T) {
|
|||||||
func TestIsCloudModel(t *testing.T) {
|
func TestIsCloudModel(t *testing.T) {
|
||||||
// isCloudModel now only uses Show API, so nil client always returns false
|
// isCloudModel now only uses Show API, so nil client always returns false
|
||||||
t.Run("nil client returns false", func(t *testing.T) {
|
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 {
|
for _, model := range models {
|
||||||
if isCloudModel(context.Background(), nil, model) {
|
if isCloudModel(context.Background(), nil, model) {
|
||||||
t.Errorf("isCloudModel(%q) with nil client should return false", 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) {
|
func TestBuildModelList_NoExistingModels(t *testing.T) {
|
||||||
items, _, _, _ := buildModelList(nil, nil, "")
|
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 != "" {
|
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)
|
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)
|
got := names(items)
|
||||||
|
|
||||||
// Recommended pinned at top (local recs first, then cloud recs when only-local), then installed non-recs
|
// 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 != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("recs pinned at top, local recs before cloud recs (-want +got):\n%s", 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) {
|
func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-4.7:cloud", Remote: true},
|
{Name: "glm-5:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
got := names(items)
|
got := names(items)
|
||||||
|
|
||||||
// All recs pinned at top (cloud before local in mixed case), then non-recs
|
// 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 != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", 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) {
|
func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{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"}, "")
|
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||||
@@ -455,14 +455,14 @@ func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
|||||||
func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "glm-4.7-flash", Remote: false},
|
{Name: "glm-4.7-flash", Remote: false},
|
||||||
{Name: "glm-4.7:cloud", Remote: true},
|
{Name: "glm-5:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
switch item.Name {
|
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?") {
|
if strings.HasSuffix(item.Description, "install?") {
|
||||||
t.Errorf("installed recommended %q should not have 'install?' suffix, got %q", item.Name, item.Description)
|
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) {
|
func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "glm-4.7-flash", Remote: false},
|
{Name: "glm-4.7-flash", Remote: false},
|
||||||
{Name: "glm-4.7:cloud", Remote: true},
|
{Name: "glm-5:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
got := names(items)
|
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
|
// 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
|
// 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 != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", 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;
|
// kimi-k2.5:cloud is installed so it sorts normally;
|
||||||
// the rest of the recommendations are not installed so they go to the bottom
|
// 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
|
// 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 != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", 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) {
|
func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-4.7:cloud", Remote: true},
|
{Name: "glm-5:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, existingModels, cloudModels := buildModelList(existing, nil, "")
|
_, _, existingModels, cloudModels := buildModelList(existing, nil, "")
|
||||||
@@ -562,15 +562,15 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
|||||||
if !existingModels["llama3.2"] {
|
if !existingModels["llama3.2"] {
|
||||||
t.Error("llama3.2 should be in existingModels")
|
t.Error("llama3.2 should be in existingModels")
|
||||||
}
|
}
|
||||||
if !existingModels["glm-4.7:cloud"] {
|
if !existingModels["glm-5:cloud"] {
|
||||||
t.Error("glm-4.7:cloud should be in existingModels")
|
t.Error("glm-5:cloud should be in existingModels")
|
||||||
}
|
}
|
||||||
if existingModels["glm-4.7-flash"] {
|
if existingModels["glm-4.7-flash"] {
|
||||||
t.Error("glm-4.7-flash should not be in existingModels (it's a recommendation)")
|
t.Error("glm-4.7-flash should not be in existingModels (it's a recommendation)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cloudModels["glm-4.7:cloud"] {
|
if !cloudModels["glm-5:cloud"] {
|
||||||
t.Error("glm-4.7:cloud should be in cloudModels")
|
t.Error("glm-5:cloud should be in cloudModels")
|
||||||
}
|
}
|
||||||
if !cloudModels["kimi-k2.5:cloud"] {
|
if !cloudModels["kimi-k2.5:cloud"] {
|
||||||
t.Error("kimi-k2.5:cloud should be in cloudModels (recommended 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 {
|
for _, item := range items {
|
||||||
switch item.Name {
|
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 {
|
if !item.Recommended {
|
||||||
t.Errorf("%q should have Recommended=true", item.Name)
|
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) {
|
func TestBuildModelList_MixedCase_CloudRecsFirst(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-4.7:cloud", Remote: true},
|
{Name: "glm-5:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
got := names(items)
|
got := names(items)
|
||||||
|
|
||||||
// Cloud recs should sort before local recs in mixed case
|
// 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")
|
localIdx := slices.Index(got, "glm-4.7-flash")
|
||||||
if cloudIdx > localIdx {
|
if cloudIdx > localIdx {
|
||||||
t.Errorf("cloud recs should be before local recs in mixed case, got %v", got)
|
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
|
// Local recs should sort before cloud recs in only-local case
|
||||||
localIdx := slices.Index(got, "glm-4.7-flash")
|
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 {
|
if localIdx > cloudIdx {
|
||||||
t.Errorf("local recs should be before cloud recs in only-local case, got %v", got)
|
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
|
lastRecIdx := -1
|
||||||
firstNonRecIdx := len(got)
|
firstNonRecIdx := len(got)
|
||||||
for i, name := range 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 {
|
if isRec && i > lastRecIdx {
|
||||||
lastRecIdx = i
|
lastRecIdx = i
|
||||||
}
|
}
|
||||||
@@ -664,7 +664,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
|||||||
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{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"}, "")
|
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) {
|
func TestConfirmPrompt_DelegatesToHook(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
var hookCalled bool
|
var hookCalled bool
|
||||||
@@ -1164,3 +1201,56 @@ func TestLaunchIntegration_NotConfigured(t *testing.T) {
|
|||||||
t.Errorf("error should mention 'not configured', got: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/ollama/ollama/cmd/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,12 +35,6 @@ var (
|
|||||||
selectorInputStyle = lipgloss.NewStyle().
|
selectorInputStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"})
|
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().
|
selectorDefaultTagStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}).
|
Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}).
|
||||||
Italic(true)
|
Italic(true)
|
||||||
@@ -69,6 +64,30 @@ type SelectItem struct {
|
|||||||
Recommended bool
|
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.
|
// selectorModel is the bubbletea model for single selection.
|
||||||
type selectorModel struct {
|
type selectorModel struct {
|
||||||
title string
|
title string
|
||||||
@@ -421,6 +440,50 @@ func (m multiSelectorModel) filteredItems() []SelectItem {
|
|||||||
return result
|
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() {
|
func (m *multiSelectorModel) toggleItem() {
|
||||||
filtered := m.filteredItems()
|
filtered := m.filteredItems()
|
||||||
if len(filtered) == 0 || m.cursor >= len(filtered) {
|
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:
|
case tea.KeyUp:
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
if m.cursor < m.scrollOffset {
|
m.updateScroll(m.otherStart())
|
||||||
m.scrollOffset = m.cursor
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyDown:
|
case tea.KeyDown:
|
||||||
if m.cursor < len(filtered)-1 {
|
if m.cursor < len(filtered)-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
if m.cursor >= m.scrollOffset+maxSelectorItems {
|
m.updateScroll(m.otherStart())
|
||||||
m.scrollOffset = m.cursor - maxSelectorItems + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyPgUp:
|
case tea.KeyPgUp:
|
||||||
@@ -500,19 +559,14 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.cursor < 0 {
|
if m.cursor < 0 {
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
}
|
}
|
||||||
m.scrollOffset -= maxSelectorItems
|
m.updateScroll(m.otherStart())
|
||||||
if m.scrollOffset < 0 {
|
|
||||||
m.scrollOffset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyPgDown:
|
case tea.KeyPgDown:
|
||||||
m.cursor += maxSelectorItems
|
m.cursor += maxSelectorItems
|
||||||
if m.cursor >= len(filtered) {
|
if m.cursor >= len(filtered) {
|
||||||
m.cursor = len(filtered) - 1
|
m.cursor = len(filtered) - 1
|
||||||
}
|
}
|
||||||
if m.cursor >= m.scrollOffset+maxSelectorItems {
|
m.updateScroll(m.otherStart())
|
||||||
m.scrollOffset = m.cursor - maxSelectorItems + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyBackspace:
|
case tea.KeyBackspace:
|
||||||
if len(m.filter) > 0 {
|
if len(m.filter) > 0 {
|
||||||
@@ -531,6 +585,34 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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 {
|
func (m multiSelectorModel) View() string {
|
||||||
if m.cancelled || m.confirmed {
|
if m.cancelled || m.confirmed {
|
||||||
return ""
|
return ""
|
||||||
@@ -552,56 +634,65 @@ func (m multiSelectorModel) View() string {
|
|||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
s.WriteString(selectorItemStyle.Render(selectorDescStyle.Render("(no matches)")))
|
s.WriteString(selectorItemStyle.Render(selectorDescStyle.Render("(no matches)")))
|
||||||
s.WriteString("\n")
|
s.WriteString("\n")
|
||||||
} else {
|
} else if m.filter != "" {
|
||||||
|
// Filtering: flat scroll through all matches
|
||||||
displayCount := min(len(filtered), maxSelectorItems)
|
displayCount := min(len(filtered), maxSelectorItems)
|
||||||
shownRecHeader := false
|
|
||||||
prevWasRec := false
|
|
||||||
|
|
||||||
for i := range displayCount {
|
for i := range displayCount {
|
||||||
idx := m.scrollOffset + i
|
idx := m.scrollOffset + i
|
||||||
if idx >= len(filtered) {
|
if idx >= len(filtered) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
item := filtered[idx]
|
m.renderMultiItem(&s, filtered[idx], 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining := len(filtered) - m.scrollOffset - displayCount; remaining > 0 {
|
if remaining := len(filtered) - m.scrollOffset - displayCount; remaining > 0 {
|
||||||
s.WriteString(selectorMoreStyle.Render(fmt.Sprintf("... and %d more", remaining)))
|
s.WriteString(selectorMoreStyle.Render(fmt.Sprintf("... and %d more", remaining)))
|
||||||
s.WriteString("\n")
|
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")
|
s.WriteString("\n")
|
||||||
|
|||||||
@@ -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
|
// Key message helpers for testing
|
||||||
|
|
||||||
type keyType = int
|
type keyType = int
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ type model struct {
|
|||||||
quitting bool
|
quitting bool
|
||||||
selected bool
|
selected bool
|
||||||
changeModel bool
|
changeModel bool
|
||||||
|
changeModels []string // multi-select result for Editor integrations
|
||||||
showOthers bool
|
showOthers bool
|
||||||
availableModels map[string]bool
|
availableModels map[string]bool
|
||||||
err error
|
err error
|
||||||
@@ -123,6 +124,9 @@ type model struct {
|
|||||||
modalSelector selectorModel
|
modalSelector selectorModel
|
||||||
modalItems []SelectItem
|
modalItems []SelectItem
|
||||||
|
|
||||||
|
showingMultiModal bool
|
||||||
|
multiModalSelector multiSelectorModel
|
||||||
|
|
||||||
showingSignIn bool
|
showingSignIn bool
|
||||||
signInURL string
|
signInURL string
|
||||||
signInModel string
|
signInModel string
|
||||||
@@ -160,23 +164,50 @@ func (m *model) modelExists(name string) bool {
|
|||||||
|
|
||||||
func (m *model) buildModalItems() []SelectItem {
|
func (m *model) buildModalItems() []SelectItem {
|
||||||
modelItems, _ := config.GetModelItems(context.Background())
|
modelItems, _ := config.GetModelItems(context.Background())
|
||||||
var items []SelectItem
|
return ReorderItems(ConvertItems(modelItems))
|
||||||
for _, item := range modelItems {
|
|
||||||
items = append(items, SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) openModelModal() {
|
func (m *model) openModelModal(currentModel string) {
|
||||||
m.modalItems = m.buildModalItems()
|
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{
|
m.modalSelector = selectorModel{
|
||||||
title: "Select model:",
|
title: "Select model:",
|
||||||
items: m.modalItems,
|
items: m.modalItems,
|
||||||
|
cursor: cursor,
|
||||||
helpText: "↑/↓ navigate • enter select • ← back",
|
helpText: "↑/↓ navigate • enter select • ← back",
|
||||||
}
|
}
|
||||||
|
m.modalSelector.updateScroll(m.modalSelector.otherStart())
|
||||||
m.showingModal = true
|
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 {
|
func isCloudModel(name string) bool {
|
||||||
return strings.HasSuffix(name, ":cloud")
|
return strings.HasSuffix(name, ":cloud")
|
||||||
}
|
}
|
||||||
@@ -356,6 +387,39 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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 {
|
if m.showingModal {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
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) {
|
if item.integration != "" && !config.IsIntegrationInstalled(item.integration) {
|
||||||
return m, nil
|
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()
|
return m.renderSignInDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.showingMultiModal {
|
||||||
|
return m.multiModalSelector.View()
|
||||||
|
}
|
||||||
|
|
||||||
if m.showingModal {
|
if m.showingModal {
|
||||||
return m.renderModal()
|
return m.renderModal()
|
||||||
}
|
}
|
||||||
@@ -554,8 +632,9 @@ const (
|
|||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Selection Selection
|
Selection Selection
|
||||||
Integration string // integration name if applicable
|
Integration string // integration name if applicable
|
||||||
Model string // model name if selected from modal
|
Model string // model name if selected from single-select modal
|
||||||
|
Models []string // models selected from multi-select modal (Editor integrations)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run() (Result, error) {
|
func Run() (Result, error) {
|
||||||
@@ -589,6 +668,7 @@ func Run() (Result, error) {
|
|||||||
Selection: SelectionChangeIntegration,
|
Selection: SelectionChangeIntegration,
|
||||||
Integration: item.integration,
|
Integration: item.integration,
|
||||||
Model: fm.modalSelector.selected,
|
Model: fm.modalSelector.selected,
|
||||||
|
Models: fm.changeModels,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user