From f08427c13804aa2a007c4489dc6efa5b885097d4 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Wed, 11 Feb 2026 10:18:41 -0800 Subject: [PATCH] cmd: TUI UX improvements (#14198) --- cmd/cmd.go | 78 ++- cmd/config/claude.go | 3 +- cmd/config/integrations.go | 343 ++++++------ cmd/config/integrations_test.go | 444 +++++++++++++++- cmd/config/selector.go | 485 +---------------- cmd/config/selector_test.go | 913 -------------------------------- cmd/tui/confirm.go | 109 ++++ cmd/tui/confirm_test.go | 208 ++++++++ cmd/tui/selector.go | 327 ++++++++---- cmd/tui/selector_test.go | 410 ++++++++++++++ cmd/tui/signin.go | 128 +++++ cmd/tui/signin_test.go | 175 ++++++ cmd/tui/tui.go | 411 +++++--------- 13 files changed, 2103 insertions(+), 1931 deletions(-) create mode 100644 cmd/tui/confirm.go create mode 100644 cmd/tui/confirm_test.go create mode 100644 cmd/tui/selector_test.go create mode 100644 cmd/tui/signin.go create mode 100644 cmd/tui/signin_test.go diff --git a/cmd/cmd.go b/cmd/cmd.go index c4bb47604..6ca13f1af 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -55,6 +55,49 @@ import ( "github.com/ollama/ollama/x/imagegen" ) +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} + } + result, err := tui.SelectSingle(title, tuiItems) + if errors.Is(err, tui.ErrCancelled) { + return "", config.ErrCancelled + } + return result, err + } + + 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} + } + result, err := tui.SelectMultiple(title, tuiItems, preChecked) + if errors.Is(err, tui.ErrCancelled) { + return nil, config.ErrCancelled + } + return result, err + } + + config.DefaultSignIn = func(modelName, signInURL string) (string, error) { + userName, err := tui.RunSignIn(modelName, signInURL) + if errors.Is(err, tui.ErrCancelled) { + return "", config.ErrCancelled + } + return userName, err + } + + config.DefaultConfirmPrompt = func(prompt string) (bool, error) { + ok, err := tui.RunConfirm(prompt) + if errors.Is(err, tui.ErrCancelled) { + return false, config.ErrCancelled + } + return ok, err + } +} + const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n" // ensureThinkingSupport emits a warning if the model does not advertise thinking support @@ -1848,18 +1891,15 @@ func runInteractiveTUI(cmd *cobra.Command) { return } - // errSelectionCancelled is returned when user cancels model selection - errSelectionCancelled := errors.New("cancelled") - // Selector adapters for tui singleSelector := 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} + tuiItems[i] = tui.SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended} } result, err := tui.SelectSingle(title, tuiItems) if errors.Is(err, tui.ErrCancelled) { - return "", errSelectionCancelled + return "", config.ErrCancelled } return result, err } @@ -1867,11 +1907,11 @@ func runInteractiveTUI(cmd *cobra.Command) { multiSelector := 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} + tuiItems[i] = tui.SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended} } result, err := tui.SelectMultiple(title, tuiItems, preChecked) if errors.Is(err, tui.ErrCancelled) { - return nil, errSelectionCancelled + return nil, config.ErrCancelled } return result, err } @@ -1884,6 +1924,18 @@ func runInteractiveTUI(cmd *cobra.Command) { } runModel := func(modelName string) { + client, err := api.ClientFromEnvironment() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return + } + if err := config.ShowOrPull(cmd.Context(), client, modelName); err != nil { + if errors.Is(err, config.ErrCancelled) { + return + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return + } _ = config.SetLastModel(modelName) opts := runOptions{ Model: modelName, @@ -1905,7 +1957,7 @@ func runInteractiveTUI(cmd *cobra.Command) { configuredModel := config.IntegrationModel(name) if configuredModel == "" || !config.ModelExists(cmd.Context(), configuredModel) { err := config.ConfigureIntegrationWithSelectors(cmd.Context(), name, singleSelector, multiSelector) - if errors.Is(err, errSelectionCancelled) { + if errors.Is(err, config.ErrCancelled) { return false // Return to main menu } if err != nil { @@ -1925,13 +1977,11 @@ func runInteractiveTUI(cmd *cobra.Command) { return case tui.SelectionRunModel: _ = config.SetLastSelection("run") - // Run last model directly if configured and still exists - if modelName := config.LastModel(); modelName != "" && config.ModelExists(cmd.Context(), modelName) { + if modelName := config.LastModel(); modelName != "" { runModel(modelName) } else { - // No last model or model no longer exists, show picker modelName, err := config.SelectModelWithSelector(cmd.Context(), singleSelector) - if errors.Is(err, errSelectionCancelled) { + if errors.Is(err, config.ErrCancelled) { continue // Return to main menu } if err != nil { @@ -1947,7 +1997,7 @@ func runInteractiveTUI(cmd *cobra.Command) { if modelName == "" { var err error modelName, err = config.SelectModelWithSelector(cmd.Context(), singleSelector) - if errors.Is(err, errSelectionCancelled) { + if errors.Is(err, config.ErrCancelled) { continue // Return to main menu } if err != nil { @@ -1975,7 +2025,7 @@ func runInteractiveTUI(cmd *cobra.Command) { } } else { err := config.ConfigureIntegrationWithSelectors(cmd.Context(), result.Integration, singleSelector, multiSelector) - if errors.Is(err, errSelectionCancelled) { + if errors.Is(err, config.ErrCancelled) { continue // Return to main menu } if err != nil { diff --git a/cmd/config/claude.go b/cmd/config/claude.go index d0d2c5c80..36913d17c 100644 --- a/cmd/config/claude.go +++ b/cmd/config/claude.go @@ -126,8 +126,7 @@ func (c *Claude) ConfigureAliases(ctx context.Context, model string, existingAli fmt.Fprintf(os.Stderr, "\n%sModel Configuration%s\n\n", ansiBold, ansiReset) if aliases["primary"] == "" || force { - primary, err := selectPrompt("Select model:", items) - fmt.Fprintf(os.Stderr, "\033[3A\033[J") + primary, err := DefaultSingleSelector("Select model:", items) if err != nil { return nil, false, err } diff --git a/cmd/config/integrations.go b/cmd/config/integrations.go index e268ade38..283be365e 100644 --- a/cmd/config/integrations.go +++ b/cmd/config/integrations.go @@ -4,12 +4,9 @@ import ( "context" "errors" "fmt" - "io" "maps" - "net/http" "os" "os/exec" - "path/filepath" "runtime" "slices" "strings" @@ -66,30 +63,83 @@ 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-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"}, + {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}, +} + +// recommendedVRAM maps local recommended models to their approximate VRAM requirement. +var recommendedVRAM = map[string]string{ + "glm-4.7-flash": "~25GB", + "qwen3:8b": "~11GB", } // integrationAliases are hidden from the interactive selector but work as CLI arguments. var integrationAliases = map[string]bool{ "clawdbot": true, "moltbot": true, + "pi": true, } -// integrationInstallURLs maps integration names to their install script URLs. -var integrationInstallURLs = map[string]string{ - "claude": "https://claude.ai/install.sh", - "openclaw": "https://openclaw.ai/install.sh", - "droid": "https://app.factory.ai/cli", - "opencode": "https://opencode.ai/install", +// integrationInstallHints maps integration names to install URLs. +var integrationInstallHints = map[string]string{ + "claude": "https://code.claude.com/docs/en/quickstart", + "openclaw": "https://docs.openclaw.ai", + "codex": "https://developers.openai.com/codex/cli/", + "droid": "https://docs.factory.ai/cli/getting-started/quickstart", + "opencode": "https://opencode.ai", } -// CanInstallIntegration returns true if we have an install script for this integration. -func CanInstallIntegration(name string) bool { - _, ok := integrationInstallURLs[name] - return ok +// hyperlink wraps text in an OSC 8 terminal hyperlink so it is cmd+clickable. +func hyperlink(url, text string) string { + return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) +} + +// IntegrationInfo contains display information about a registered integration. +type IntegrationInfo struct { + Name string // registry key, e.g. "claude" + DisplayName string // human-readable, e.g. "Claude Code" + Description string // short description, e.g. "Anthropic's agentic coding tool" +} + +// integrationDescriptions maps integration names to short descriptions. +var integrationDescriptions = map[string]string{ + "claude": "Anthropic's coding tool with subagents", + "codex": "OpenAI's open-source coding agent", + "openclaw": "Personal AI with 100+ skills", + "droid": "Factory's coding agent across terminal and IDEs", + "opencode": "Anomaly's open-source coding agent", +} + +// ListIntegrationInfos returns all non-alias registered integrations, sorted by name. +func ListIntegrationInfos() []IntegrationInfo { + var result []IntegrationInfo + for name, r := range integrations { + if integrationAliases[name] { + continue + } + result = append(result, IntegrationInfo{ + Name: name, + DisplayName: r.String(), + Description: integrationDescriptions[name], + }) + } + slices.SortFunc(result, func(a, b IntegrationInfo) int { + return strings.Compare(a.Name, b.Name) + }) + return result +} + +// IntegrationInstallHint returns a user-friendly install hint for the given integration, +// or an empty string if none is available. The URL is wrapped in an OSC 8 hyperlink +// so it is cmd+clickable in supported terminals. +func IntegrationInstallHint(name string) string { + url := integrationInstallHints[name] + if url == "" { + return "" + } + return "Install from " + hyperlink(url, url) } // IsIntegrationInstalled checks if an integration binary is installed. @@ -121,55 +171,12 @@ func IsIntegrationInstalled(name string) bool { } } -// InstallIntegration downloads and runs the install script for an integration. -func InstallIntegration(name string) error { - url, ok := integrationInstallURLs[name] - if !ok { - return fmt.Errorf("no install script available for %s", name) - } - - // Download the install script - resp, err := http.Get(url) - if err != nil { - return fmt.Errorf("failed to download install script: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download install script: HTTP %d", resp.StatusCode) - } - - script, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read install script: %w", err) - } - - // Create a temporary file for the script - tmpDir := os.TempDir() - scriptPath := filepath.Join(tmpDir, fmt.Sprintf("install-%s.sh", name)) - if err := os.WriteFile(scriptPath, script, 0o700); err != nil { - return fmt.Errorf("failed to write install script: %w", err) - } - defer os.Remove(scriptPath) - - // Execute the script with bash - cmd := exec.Command("bash", scriptPath) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("install script failed: %w", err) - } - - return nil -} - // SelectModel lets the user select a model to run. // ModelItem represents a model for selection. type ModelItem struct { Name string Description string + Recommended bool } // SingleSelector is a function type for single item selection. @@ -207,27 +214,6 @@ func SelectModelWithSelector(ctx context.Context, selector SingleSelector) (stri return "", fmt.Errorf("no models available, run 'ollama pull ' first") } - // Sort with last model first, then existing models, then recommendations - slices.SortStableFunc(items, func(a, b ModelItem) int { - aIsLast := a.Name == lastModel - bIsLast := b.Name == lastModel - if aIsLast != bIsLast { - if aIsLast { - return -1 - } - return 1 - } - aExists := existingModels[a.Name] - bExists := existingModels[b.Name] - if aExists != bExists { - if aExists { - return -1 - } - return 1 - } - return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) - }) - selected, err := selector("Select model to run:", items) if err != nil { return "", err @@ -309,32 +295,30 @@ func SelectModelWithSelector(ctx context.Context, selector SingleSelector) (stri } func SelectModel(ctx context.Context) (string, error) { - return SelectModelWithSelector(ctx, defaultSingleSelector) + return SelectModelWithSelector(ctx, DefaultSingleSelector) } -func defaultSingleSelector(title string, items []ModelItem) (string, error) { - selectItems := make([]selectItem, len(items)) - for i, item := range items { - selectItems[i] = selectItem(item) - } - return selectPrompt(title, selectItems) -} +// DefaultSingleSelector is the default single-select implementation. +var DefaultSingleSelector SingleSelector -func defaultMultiSelector(title string, items []ModelItem, preChecked []string) ([]string, error) { - selectItems := make([]selectItem, len(items)) - for i, item := range items { - selectItems[i] = selectItem(item) - } - return multiSelectPrompt(title, selectItems, preChecked) -} +// DefaultMultiSelector is the default multi-select implementation. +var DefaultMultiSelector MultiSelector + +// DefaultSignIn provides a TUI-based sign-in flow. +// When set, ensureAuth uses it instead of plain text prompts. +// Returns the signed-in username or an error. +var DefaultSignIn func(modelName, signInURL string) (string, error) func selectIntegration() (string, error) { + if DefaultSingleSelector == nil { + return "", fmt.Errorf("no selector configured") + } if len(integrations) == 0 { return "", fmt.Errorf("no integrations available") } names := slices.Sorted(maps.Keys(integrations)) - var items []selectItem + var items []ModelItem for _, name := range names { if integrationAliases[name] { continue @@ -344,10 +328,10 @@ func selectIntegration() (string, error) { if conn, err := loadIntegration(name); err == nil && len(conn.Models) > 0 { description = fmt.Sprintf("%s (%s)", r.String(), conn.Models[0]) } - items = append(items, selectItem{Name: name, Description: description}) + items = append(items, ModelItem{Name: name, Description: description}) } - return selectPrompt("Select integration:", items) + return DefaultSingleSelector("Select integration:", items) } // selectModelsWithSelectors lets the user select models for an integration using provided selectors. @@ -448,8 +432,9 @@ func pullIfNeeded(ctx context.Context, client *api.Client, existingModels map[st return nil } -// showOrPull checks if a model exists via client.Show and offers to pull it if not found. -func showOrPull(ctx context.Context, client *api.Client, model string) error { +// TODO(parthsareen): pull this out to tui package +// ShowOrPull checks if a model exists via client.Show and offers to pull it if not found. +func ShowOrPull(ctx context.Context, client *api.Client, model string) error { if _, err := client.Show(ctx, &api.ShowRequest{Model: model}); err == nil { return nil } @@ -462,7 +447,7 @@ func showOrPull(ctx context.Context, client *api.Client, model string) error { return pullModel(ctx, client, model) } -func listModels(ctx context.Context) ([]selectItem, map[string]bool, map[string]bool, *api.Client, error) { +func listModels(ctx context.Context) ([]ModelItem, map[string]bool, map[string]bool, *api.Client, error) { client, err := api.ClientFromEnvironment() if err != nil { return nil, nil, nil, nil, err @@ -481,20 +466,26 @@ func listModels(ctx context.Context) ([]selectItem, map[string]bool, map[string] }) } - modelItems, _, existingModels, cloudModels := buildModelList(existing, nil, "") + items, _, existingModels, cloudModels := buildModelList(existing, nil, "") - if len(modelItems) == 0 { + if len(items) == 0 { return nil, nil, nil, nil, fmt.Errorf("no models available, run 'ollama pull ' first") } - items := make([]selectItem, len(modelItems)) - for i, mi := range modelItems { - items[i] = selectItem(mi) - } - return items, existingModels, cloudModels, client, nil } +func OpenBrowser(url string) { + switch runtime.GOOS { + case "darwin": + _ = exec.Command("open", url).Start() + case "linux": + _ = exec.Command("xdg-open", url).Start() + case "windows": + _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + } +} + func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]bool, selected []string) error { var selectedCloudModels []string for _, m := range selected { @@ -517,6 +508,16 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string] } modelList := strings.Join(selectedCloudModels, ", ") + + if DefaultSignIn != nil { + _, err := DefaultSignIn(modelList, aErr.SigninURL) + if err != nil { + return fmt.Errorf("%s requires sign in", modelList) + } + return nil + } + + // Fallback: plain text sign-in flow yes, err := confirmPrompt(fmt.Sprintf("sign in to use %s?", modelList)) if err != nil || !yes { return fmt.Errorf("%s requires sign in", modelList) @@ -524,14 +525,7 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string] fmt.Fprintf(os.Stderr, "\nTo sign in, navigate to:\n %s\n\n", aErr.SigninURL) - switch runtime.GOOS { - case "darwin": - _ = exec.Command("open", aErr.SigninURL).Start() - case "linux": - _ = exec.Command("xdg-open", aErr.SigninURL).Start() - case "windows": - _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", aErr.SigninURL).Start() - } + OpenBrowser(aErr.SigninURL) spinnerFrames := []string{"|", "/", "-", "\\"} frame := 0 @@ -564,7 +558,7 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string] // selectModels lets the user select models for an integration using default selectors. func selectModels(ctx context.Context, name, current string) ([]string, error) { - return selectModelsWithSelectors(ctx, name, current, defaultSingleSelector, defaultMultiSelector) + return selectModelsWithSelectors(ctx, name, current, DefaultSingleSelector, DefaultMultiSelector) } func runIntegration(name, modelName string, args []string) error { @@ -607,8 +601,15 @@ func LaunchIntegration(name string) error { } // Try to use saved config - if config, err := loadIntegration(name); err == nil && len(config.Models) > 0 { - return runIntegration(name, config.Models[0], nil) + if ic, err := loadIntegration(name); err == nil && len(ic.Models) > 0 { + client, err := api.ClientFromEnvironment() + if err != nil { + return err + } + if err := ShowOrPull(context.Background(), client, ic.Models[0]); err != nil { + return err + } + return runIntegration(name, ic.Models[0], nil) } // No saved config - prompt user to run setup @@ -617,6 +618,13 @@ func LaunchIntegration(name string) error { // LaunchIntegrationWithModel launches the named integration with the specified model. func LaunchIntegrationWithModel(name, modelName string) error { + client, err := api.ClientFromEnvironment() + if err != nil { + return err + } + if err := ShowOrPull(context.Background(), client, modelName); err != nil { + return err + } return runIntegration(name, modelName, nil) } @@ -648,7 +656,7 @@ func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single models, err := selectModelsWithSelectors(ctx, name, "", single, multi) if errors.Is(err, errCancelled) { - return nil + return errCancelled } if err != nil { return err @@ -688,7 +696,7 @@ func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single // ConfigureIntegration allows the user to select/change the model for an integration. func ConfigureIntegration(ctx context.Context, name string) error { - return ConfigureIntegrationWithSelectors(ctx, name, defaultSingleSelector, defaultMultiSelector) + return ConfigureIntegrationWithSelectors(ctx, name, DefaultSingleSelector, DefaultMultiSelector) } // LaunchCmd returns the cobra command for launching integrations. @@ -721,7 +729,7 @@ Examples: Args: cobra.ArbitraryArgs, PreRunE: checkServerHeartbeat, RunE: func(cmd *cobra.Command, args []string) error { - // No args - run the main TUI (same as 'ollama') + // No args and no flags - show the full TUI (same as bare 'ollama') if len(args) == 0 && modelFlag == "" && !configFlag { runTUI(cmd) return nil @@ -776,7 +784,7 @@ Examples: // Validate --model flag if provided if modelFlag != "" { - if err := showOrPull(cmd.Context(), client, modelFlag); err != nil { + if err := ShowOrPull(cmd.Context(), client, modelFlag); err != nil { if errors.Is(err, errCancelled) { return nil } @@ -808,7 +816,7 @@ Examples: if model != "" && modelFlag == "" { if _, err := client.Show(cmd.Context(), &api.ShowRequest{Model: model}); err != nil { fmt.Fprintf(os.Stderr, "%sConfigured model %q not found%s\n\n", ansiGray, model, ansiReset) - if err := showOrPull(cmd.Context(), client, model); err != nil { + if err := ShowOrPull(cmd.Context(), client, model); err != nil { model = "" } } @@ -858,7 +866,7 @@ Examples: if err != nil { return err } - if err := showOrPull(cmd.Context(), client, modelFlag); err != nil { + if err := ShowOrPull(cmd.Context(), client, modelFlag); err != nil { if errors.Is(err, errCancelled) { return nil } @@ -953,8 +961,10 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) ( recommended := make(map[string]bool) var hasLocalModel, hasCloudModel bool + recDesc := make(map[string]string) for _, rec := range recommendedModels { recommended[rec.Name] = true + recDesc[rec.Name] = rec.Description } for _, m := range existing { @@ -967,10 +977,7 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) ( } displayName := strings.TrimSuffix(m.Name, ":latest") existingModels[displayName] = true - item := ModelItem{Name: displayName} - if recommended[displayName] { - item.Description = "recommended" - } + item := ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]} items = append(items, item) } @@ -1007,31 +1014,76 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) ( for i := range items { if !existingModels[items[i].Name] { notInstalled[items[i].Name] = true + var parts []string if items[i].Description != "" { - items[i].Description += ", install?" - } else { - items[i].Description = "install?" + parts = append(parts, items[i].Description) } + if vram := recommendedVRAM[items[i].Name]; vram != "" { + parts = append(parts, vram) + } + parts = append(parts, "install?") + items[i].Description = strings.Join(parts, ", ") } } + // Build a recommended rank map to preserve ordering within tiers. + recRank := make(map[string]int) + for i, rec := range recommendedModels { + recRank[rec.Name] = i + 1 // 1-indexed; 0 means not recommended + } + + onlyLocal := hasLocalModel && !hasCloudModel + if hasLocalModel || hasCloudModel { slices.SortStableFunc(items, func(a, b ModelItem) int { ac, bc := checked[a.Name], checked[b.Name] aNew, bNew := notInstalled[a.Name], notInstalled[b.Name] + aRec, bRec := recRank[a.Name] > 0, recRank[b.Name] > 0 + aCloud, bCloud := cloudModels[a.Name], cloudModels[b.Name] + // Checked/pre-selected always first if ac != bc { if ac { return -1 } return 1 } - if !ac && !bc && aNew != bNew { + + // Recommended above non-recommended + if aRec != bRec { + if aRec { + return -1 + } + return 1 + } + + // Both recommended + if aRec && bRec { + if aCloud != bCloud { + if onlyLocal { + // Local before cloud when only local installed + if aCloud { + return 1 + } + return -1 + } + // Cloud before local in mixed case + if aCloud { + return -1 + } + return 1 + } + return recRank[a.Name] - recRank[b.Name] + } + + // Both non-recommended: installed before not-installed + if aNew != bNew { if aNew { return 1 } return -1 } + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) } @@ -1077,27 +1129,6 @@ func GetModelItems(ctx context.Context) ([]ModelItem, map[string]bool) { items, _, existingModels, _ := buildModelList(existing, preChecked, lastModel) - // Sort with last model first, then existing models, then recommendations - slices.SortStableFunc(items, func(a, b ModelItem) int { - aIsLast := a.Name == lastModel - bIsLast := b.Name == lastModel - if aIsLast != bIsLast { - if aIsLast { - return -1 - } - return 1 - } - aExists := existingModels[a.Name] - bExists := existingModels[b.Name] - if aExists != bExists { - if aExists { - return -1 - } - return 1 - } - return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) - }) - return items, existingModels } diff --git a/cmd/config/integrations_test.go b/cmd/config/integrations_test.go index e796c4065..ec41219ab 100644 --- a/cmd/config/integrations_test.go +++ b/cmd/config/integrations_test.go @@ -94,9 +94,7 @@ func TestLaunchCmd(t *testing.T) { mockCheck := func(cmd *cobra.Command, args []string) error { return nil } - // Mock TUI function (not called in these tests) mockTUI := func(cmd *cobra.Command) {} - cmd := LaunchCmd(mockCheck, mockTUI) t.Run("command structure", func(t *testing.T) { @@ -396,7 +394,7 @@ func names(items []ModelItem) []string { 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"} + want := []string{"kimi-k2.5:cloud", "glm-4.7: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) } @@ -417,9 +415,10 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) { 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"} + // 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"} if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("cloud recs should be at bottom (-want +got):\n%s", diff) + t.Errorf("recs pinned at top, local recs before cloud recs (-want +got):\n%s", diff) } } @@ -432,9 +431,10 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) { 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"} + // 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"} if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("mixed models should be alphabetical (-want +got):\n%s", diff) + t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff) } } @@ -485,9 +485,10 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) { // 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"} + // 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"} if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("existing cloud models should sort normally (-want +got):\n%s", diff) + t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff) } } @@ -502,9 +503,10 @@ 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 - want := []string{"kimi-k2.5:cloud", "llama3.2", "glm-4.7-flash", "glm-4.7:cloud", "qwen3:8b"} + // 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"} if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("only non-installed models should be at bottom (-want +got):\n%s", diff) + t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff) } for _, item := range items { @@ -578,6 +580,101 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) { } } +func TestBuildModelList_RecommendedFieldSet(t *testing.T) { + existing := []modelInfo{ + {Name: "glm-4.7-flash", Remote: false}, + {Name: "llama3.2:latest", Remote: false}, + } + + items, _, _, _ := buildModelList(existing, nil, "") + + for _, item := range items { + switch item.Name { + case "glm-4.7-flash", "qwen3:8b", "glm-4.7:cloud", "kimi-k2.5:cloud": + if !item.Recommended { + t.Errorf("%q should have Recommended=true", item.Name) + } + case "llama3.2": + if item.Recommended { + t.Errorf("%q should have Recommended=false", item.Name) + } + } + } +} + +func TestBuildModelList_MixedCase_CloudRecsFirst(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) + + // Cloud recs should sort before local recs in mixed case + cloudIdx := slices.Index(got, "glm-4.7: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) + } +} + +func TestBuildModelList_OnlyLocal_LocalRecsFirst(t *testing.T) { + existing := []modelInfo{ + {Name: "llama3.2:latest", Remote: false}, + } + + items, _, _, _ := buildModelList(existing, nil, "") + got := names(items) + + // 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") + if localIdx > cloudIdx { + t.Errorf("local recs should be before cloud recs in only-local case, got %v", got) + } +} + +func TestBuildModelList_RecsAboveNonRecs(t *testing.T) { + existing := []modelInfo{ + {Name: "llama3.2:latest", Remote: false}, + {Name: "custom-model", Remote: false}, + } + + items, _, _, _ := buildModelList(existing, nil, "") + got := names(items) + + // All recommended models should appear before non-recommended installed models + 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" + if isRec && i > lastRecIdx { + lastRecIdx = i + } + if !isRec && i < firstNonRecIdx { + firstNonRecIdx = i + } + } + if lastRecIdx > firstNonRecIdx { + t.Errorf("all recs should be above non-recs, got %v", got) + } +} + +func TestBuildModelList_CheckedBeforeRecs(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("checked model should be first even before recs, got %v", got) + } +} + func TestEditorIntegration_SavedConfigSkipsSelection(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -630,7 +727,7 @@ func TestShowOrPull_ModelExists(t *testing.T) { u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) - err := showOrPull(context.Background(), client, "test-model") + err := ShowOrPull(context.Background(), client, "test-model") if err != nil { t.Errorf("showOrPull should return nil when model exists, got: %v", err) } @@ -647,7 +744,7 @@ func TestShowOrPull_ModelNotFound_NoTerminal(t *testing.T) { client := api.NewClient(u, srv.Client()) // confirmPrompt will fail in test (no terminal), so showOrPull should return an error - err := showOrPull(context.Background(), client, "missing-model") + err := ShowOrPull(context.Background(), client, "missing-model") if err == nil { t.Error("showOrPull should return error when model not found and no terminal available") } @@ -672,12 +769,104 @@ func TestShowOrPull_ShowCalledWithCorrectModel(t *testing.T) { u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) - _ = showOrPull(context.Background(), client, "qwen3:8b") + _ = ShowOrPull(context.Background(), client, "qwen3:8b") if receivedModel != "qwen3:8b" { t.Errorf("expected Show to be called with %q, got %q", "qwen3:8b", receivedModel) } } +func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) { + // Set up hook so confirmPrompt doesn't need a terminal + oldHook := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string) (bool, error) { + if !strings.Contains(prompt, "missing-model") { + t.Errorf("expected prompt to contain model name, got %q", prompt) + } + return true, 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, "missing-model") + if err != nil { + t.Errorf("ShowOrPull should succeed after pull, got: %v", err) + } + if !pullCalled { + t.Error("expected pull to be called when user confirms download") + } +} + +func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) { + oldHook := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string) (bool, error) { + return false, ErrCancelled + } + defer func() { DefaultConfirmPrompt = oldHook }() + + 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": + t.Error("pull should not be called when user declines") + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + err := ShowOrPull(context.Background(), client, "missing-model") + if err == nil { + t.Error("ShowOrPull should return error when user declines") + } +} + +func TestConfirmPrompt_DelegatesToHook(t *testing.T) { + oldHook := DefaultConfirmPrompt + var hookCalled bool + DefaultConfirmPrompt = func(prompt string) (bool, error) { + hookCalled = true + if prompt != "test prompt?" { + t.Errorf("expected prompt %q, got %q", "test prompt?", prompt) + } + return true, nil + } + defer func() { DefaultConfirmPrompt = oldHook }() + + ok, err := confirmPrompt("test prompt?") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !ok { + t.Error("expected true from hook") + } + if !hookCalled { + t.Error("expected DefaultConfirmPrompt hook to be called") + } +} + func TestEnsureAuth_NoCloudModels(t *testing.T) { // ensureAuth should be a no-op when no cloud models are selected srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -748,3 +937,230 @@ func TestEnsureAuth_SkipsWhenNoCloudSelected(t *testing.T) { t.Error("whoami should not be called when no cloud models are selected") } } + +func TestHyperlink(t *testing.T) { + tests := []struct { + name string + url string + text string + wantURL string + wantText string + }{ + { + name: "basic link", + url: "https://example.com", + text: "click here", + wantURL: "https://example.com", + wantText: "click here", + }, + { + name: "url with path", + url: "https://example.com/docs/install", + text: "install docs", + wantURL: "https://example.com/docs/install", + wantText: "install docs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hyperlink(tt.url, tt.text) + + // Should contain OSC 8 escape sequences + if !strings.Contains(got, "\033]8;;") { + t.Error("should contain OSC 8 open sequence") + } + if !strings.Contains(got, tt.wantURL) { + t.Errorf("should contain URL %q", tt.wantURL) + } + if !strings.Contains(got, tt.wantText) { + t.Errorf("should contain text %q", tt.wantText) + } + + // Should have closing OSC 8 sequence + wantSuffix := "\033]8;;\033\\" + if !strings.HasSuffix(got, wantSuffix) { + t.Error("should end with OSC 8 close sequence") + } + }) + } +} + +func TestIntegrationInstallHint(t *testing.T) { + tests := []struct { + name string + input string + wantEmpty bool + wantURL string + }{ + { + name: "claude has hint", + input: "claude", + wantURL: "https://code.claude.com/docs/en/quickstart", + }, + { + name: "codex has hint", + input: "codex", + wantURL: "https://developers.openai.com/codex/cli/", + }, + { + name: "openclaw has hint", + input: "openclaw", + wantURL: "https://docs.openclaw.ai", + }, + { + name: "unknown has no hint", + input: "unknown", + wantEmpty: true, + }, + { + name: "empty name has no hint", + input: "", + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IntegrationInstallHint(tt.input) + if tt.wantEmpty { + if got != "" { + t.Errorf("expected empty hint, got %q", got) + } + return + } + if !strings.Contains(got, "Install from") { + t.Errorf("hint should start with 'Install from', got %q", got) + } + if !strings.Contains(got, tt.wantURL) { + t.Errorf("hint should contain URL %q, got %q", tt.wantURL, got) + } + // Should be a clickable hyperlink + if !strings.Contains(got, "\033]8;;") { + t.Error("hint URL should be wrapped in OSC 8 hyperlink") + } + }) + } +} + +func TestListIntegrationInfos(t *testing.T) { + infos := ListIntegrationInfos() + + t.Run("excludes aliases", func(t *testing.T) { + for _, info := range infos { + if integrationAliases[info.Name] { + t.Errorf("alias %q should not appear in ListIntegrationInfos", info.Name) + } + } + }) + + t.Run("sorted by name", func(t *testing.T) { + for i := 1; i < len(infos); i++ { + if infos[i-1].Name >= infos[i].Name { + t.Errorf("not sorted: %q >= %q", infos[i-1].Name, infos[i].Name) + } + } + }) + + t.Run("all fields populated", func(t *testing.T) { + for _, info := range infos { + if info.Name == "" { + t.Error("Name should not be empty") + } + if info.DisplayName == "" { + t.Errorf("DisplayName for %q should not be empty", info.Name) + } + } + }) + + t.Run("includes known integrations", func(t *testing.T) { + known := map[string]bool{"claude": false, "codex": false, "opencode": false} + for _, info := range infos { + if _, ok := known[info.Name]; ok { + known[info.Name] = true + } + } + for name, found := range known { + if !found { + t.Errorf("expected %q in ListIntegrationInfos", name) + } + } + }) +} + +func TestBuildModelList_Descriptions(t *testing.T) { + t.Run("installed recommended has base description", func(t *testing.T) { + existing := []modelInfo{ + {Name: "qwen3:8b", Remote: false}, + } + items, _, _, _ := buildModelList(existing, nil, "") + + for _, item := range items { + if item.Name == "qwen3:8b" { + if strings.HasSuffix(item.Description, "install?") { + t.Errorf("installed model should not have 'install?' suffix, got %q", item.Description) + } + if item.Description == "" { + t.Error("installed recommended model should have a description") + } + return + } + } + t.Error("qwen3:8b not found in items") + }) + + t.Run("not-installed local rec has VRAM in description", func(t *testing.T) { + items, _, _, _ := buildModelList(nil, nil, "") + + for _, item := range items { + if item.Name == "qwen3:8b" { + if !strings.Contains(item.Description, "~11GB") { + t.Errorf("not-installed qwen3:8b should show VRAM hint, got %q", item.Description) + } + return + } + } + t.Error("qwen3:8b not found in items") + }) + + t.Run("installed local rec omits VRAM", func(t *testing.T) { + existing := []modelInfo{ + {Name: "qwen3:8b", Remote: false}, + } + items, _, _, _ := buildModelList(existing, nil, "") + + for _, item := range items { + if item.Name == "qwen3:8b" { + if strings.Contains(item.Description, "~11GB") { + t.Errorf("installed qwen3:8b should not show VRAM hint, got %q", item.Description) + } + return + } + } + t.Error("qwen3:8b not found in items") + }) +} + +func TestLaunchIntegration_UnknownIntegration(t *testing.T) { + err := LaunchIntegration("nonexistent-integration") + 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) + } +} + +func TestLaunchIntegration_NotConfigured(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + // Claude is a known integration but not configured in temp dir + err := LaunchIntegration("claude") + if err == nil { + t.Fatal("expected error when integration is not configured") + } + if !strings.Contains(err.Error(), "not configured") { + t.Errorf("error should mention 'not configured', got: %v", err) + } +} diff --git a/cmd/config/selector.go b/cmd/config/selector.go index 4bad85948..bcd0b749f 100644 --- a/cmd/config/selector.go +++ b/cmd/config/selector.go @@ -3,475 +3,34 @@ package config import ( "errors" "fmt" - "io" "os" - "strings" "golang.org/x/term" ) // ANSI escape sequences for terminal formatting. const ( - ansiHideCursor = "\033[?25l" - ansiShowCursor = "\033[?25h" - ansiBold = "\033[1m" - ansiReset = "\033[0m" - ansiGray = "\033[37m" - ansiGreen = "\033[32m" - ansiClearDown = "\033[J" + ansiBold = "\033[1m" + ansiReset = "\033[0m" + ansiGray = "\033[37m" + ansiGreen = "\033[32m" ) -const maxDisplayedItems = 10 +// ErrCancelled is returned when the user cancels a selection. +var ErrCancelled = errors.New("cancelled") -var errCancelled = errors.New("cancelled") +// errCancelled is kept as an alias for backward compatibility within the package. +var errCancelled = ErrCancelled -type selectItem struct { - Name string - Description string -} - -type inputEvent int - -const ( - eventNone inputEvent = iota - eventEnter - eventEscape - eventUp - eventDown - eventTab - eventBackspace - eventChar -) - -type selectState struct { - items []selectItem - filter string - selected int - scrollOffset int -} - -func newSelectState(items []selectItem) *selectState { - return &selectState{items: items} -} - -func (s *selectState) filtered() []selectItem { - return filterItems(s.items, s.filter) -} - -func (s *selectState) handleInput(event inputEvent, char byte) (done bool, result string, err error) { - filtered := s.filtered() - - switch event { - case eventEnter: - if len(filtered) > 0 && s.selected < len(filtered) { - return true, filtered[s.selected].Name, nil - } - case eventEscape: - return true, "", errCancelled - case eventBackspace: - if len(s.filter) > 0 { - s.filter = s.filter[:len(s.filter)-1] - s.selected = 0 - s.scrollOffset = 0 - } - case eventUp: - if s.selected > 0 { - s.selected-- - if s.selected < s.scrollOffset { - s.scrollOffset = s.selected - } - } - case eventDown: - if s.selected < len(filtered)-1 { - s.selected++ - if s.selected >= s.scrollOffset+maxDisplayedItems { - s.scrollOffset = s.selected - maxDisplayedItems + 1 - } - } - case eventChar: - s.filter += string(char) - s.selected = 0 - s.scrollOffset = 0 - } - - return false, "", nil -} - -type multiSelectState struct { - items []selectItem - itemIndex map[string]int - filter string - highlighted int - scrollOffset int - checked map[int]bool - checkOrder []int - focusOnButton bool -} - -func newMultiSelectState(items []selectItem, preChecked []string) *multiSelectState { - s := &multiSelectState{ - items: items, - itemIndex: make(map[string]int, len(items)), - checked: make(map[int]bool), - } - - for i, item := range items { - s.itemIndex[item.Name] = i - } - - for _, name := range preChecked { - if idx, ok := s.itemIndex[name]; ok { - s.checked[idx] = true - s.checkOrder = append(s.checkOrder, idx) - } - } - - return s -} - -func (s *multiSelectState) filtered() []selectItem { - return filterItems(s.items, s.filter) -} - -func (s *multiSelectState) toggleItem() { - filtered := s.filtered() - if len(filtered) == 0 || s.highlighted >= len(filtered) { - return - } - - item := filtered[s.highlighted] - origIdx := s.itemIndex[item.Name] - - if s.checked[origIdx] { - delete(s.checked, origIdx) - for i, idx := range s.checkOrder { - if idx == origIdx { - s.checkOrder = append(s.checkOrder[:i], s.checkOrder[i+1:]...) - break - } - } - } else { - s.checked[origIdx] = true - s.checkOrder = append(s.checkOrder, origIdx) - } -} - -func (s *multiSelectState) handleInput(event inputEvent, char byte) (done bool, result []string, err error) { - filtered := s.filtered() - - switch event { - case eventEnter: - if s.focusOnButton && len(s.checkOrder) > 0 { - var res []string - for _, idx := range s.checkOrder { - res = append(res, s.items[idx].Name) - } - return true, res, nil - } else if !s.focusOnButton { - s.toggleItem() - } - case eventTab: - if len(s.checkOrder) > 0 { - s.focusOnButton = !s.focusOnButton - } - case eventEscape: - return true, nil, errCancelled - case eventBackspace: - if len(s.filter) > 0 { - s.filter = s.filter[:len(s.filter)-1] - s.highlighted = 0 - s.scrollOffset = 0 - s.focusOnButton = false - } - case eventUp: - if s.focusOnButton { - s.focusOnButton = false - } else if s.highlighted > 0 { - s.highlighted-- - if s.highlighted < s.scrollOffset { - s.scrollOffset = s.highlighted - } - } - case eventDown: - if s.focusOnButton { - s.focusOnButton = false - } else if s.highlighted < len(filtered)-1 { - s.highlighted++ - if s.highlighted >= s.scrollOffset+maxDisplayedItems { - s.scrollOffset = s.highlighted - maxDisplayedItems + 1 - } - } - case eventChar: - s.filter += string(char) - s.highlighted = 0 - s.scrollOffset = 0 - s.focusOnButton = false - } - - return false, nil, nil -} - -func (s *multiSelectState) selectedCount() int { - return len(s.checkOrder) -} - -// Terminal I/O handling - -type terminalState struct { - fd int - oldState *term.State -} - -func enterRawMode() (*terminalState, error) { - fd := int(os.Stdin.Fd()) - oldState, err := term.MakeRaw(fd) - if err != nil { - return nil, err - } - fmt.Fprint(os.Stderr, ansiHideCursor) - return &terminalState{fd: fd, oldState: oldState}, nil -} - -func (t *terminalState) restore() { - fmt.Fprint(os.Stderr, ansiShowCursor) - term.Restore(t.fd, t.oldState) -} - -func clearLines(n int) { - if n > 0 { - fmt.Fprintf(os.Stderr, "\033[%dA", n) - fmt.Fprint(os.Stderr, ansiClearDown) - } -} - -func parseInput(r io.Reader) (inputEvent, byte, error) { - buf := make([]byte, 3) - n, err := r.Read(buf) - if err != nil { - return 0, 0, err - } - - switch { - case n == 1 && buf[0] == 13: - return eventEnter, 0, nil - case n == 1 && (buf[0] == 3 || buf[0] == 27): - return eventEscape, 0, nil - case n == 1 && buf[0] == 9: - return eventTab, 0, nil - case n == 1 && buf[0] == 127: - return eventBackspace, 0, nil - case n == 3 && buf[0] == 27 && buf[1] == 91 && buf[2] == 65: - return eventUp, 0, nil - case n == 3 && buf[0] == 27 && buf[1] == 91 && buf[2] == 66: - return eventDown, 0, nil - case n == 1 && buf[0] >= 32 && buf[0] < 127: - return eventChar, buf[0], nil - } - - return eventNone, 0, nil -} - -// Rendering - -func renderSelect(w io.Writer, prompt string, s *selectState) int { - filtered := s.filtered() - - if s.filter == "" { - fmt.Fprintf(w, "%s %sType to filter...%s\r\n", prompt, ansiGray, ansiReset) - } else { - fmt.Fprintf(w, "%s %s\r\n", prompt, s.filter) - } - lineCount := 1 - - if len(filtered) == 0 { - fmt.Fprintf(w, " %s(no matches)%s\r\n", ansiGray, ansiReset) - lineCount++ - } else { - displayCount := min(len(filtered), maxDisplayedItems) - - for i := range displayCount { - idx := s.scrollOffset + i - if idx >= len(filtered) { - break - } - item := filtered[idx] - prefix := " " - if idx == s.selected { - prefix = " " + ansiBold + "> " - } - if item.Description != "" { - fmt.Fprintf(w, "%s%s%s %s- %s%s\r\n", prefix, item.Name, ansiReset, ansiGray, item.Description, ansiReset) - } else { - fmt.Fprintf(w, "%s%s%s\r\n", prefix, item.Name, ansiReset) - } - lineCount++ - } - - if remaining := len(filtered) - s.scrollOffset - displayCount; remaining > 0 { - fmt.Fprintf(w, " %s... and %d more%s\r\n", ansiGray, remaining, ansiReset) - lineCount++ - } - } - - return lineCount -} - -func renderMultiSelect(w io.Writer, prompt string, s *multiSelectState) int { - filtered := s.filtered() - - if s.filter == "" { - fmt.Fprintf(w, "%s %sType to filter...%s\r\n", prompt, ansiGray, ansiReset) - } else { - fmt.Fprintf(w, "%s %s\r\n", prompt, s.filter) - } - lineCount := 1 - - if len(filtered) == 0 { - fmt.Fprintf(w, " %s(no matches)%s\r\n", ansiGray, ansiReset) - lineCount++ - } else { - displayCount := min(len(filtered), maxDisplayedItems) - - for i := range displayCount { - idx := s.scrollOffset + i - if idx >= len(filtered) { - break - } - item := filtered[idx] - origIdx := s.itemIndex[item.Name] - - checkbox := "[ ]" - if s.checked[origIdx] { - checkbox = "[x]" - } - - prefix := " " - suffix := "" - if idx == s.highlighted && !s.focusOnButton { - prefix = "> " - } - if len(s.checkOrder) > 0 && s.checkOrder[0] == origIdx { - 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%s\r\n", ansiBold, prefix, checkbox, item.Name, ansiReset, desc, suffix) - } else { - fmt.Fprintf(w, " %s %s %s%s%s\r\n", prefix, checkbox, item.Name, desc, suffix) - } - lineCount++ - } - - if remaining := len(filtered) - s.scrollOffset - displayCount; remaining > 0 { - fmt.Fprintf(w, " %s... and %d more%s\r\n", ansiGray, remaining, ansiReset) - lineCount++ - } - } - - fmt.Fprintf(w, "\r\n") - lineCount++ - count := s.selectedCount() - switch { - case count == 0: - fmt.Fprintf(w, " %sSelect at least one model.%s\r\n", ansiGray, ansiReset) - case s.focusOnButton: - fmt.Fprintf(w, " %s> [ Continue ]%s %s(%d selected)%s\r\n", ansiBold, ansiReset, ansiGray, count, ansiReset) - default: - fmt.Fprintf(w, " %s[ Continue ] (%d selected) - press Tab%s\r\n", ansiGray, count, ansiReset) - } - lineCount++ - - return lineCount -} - -// selectPrompt prompts the user to select a single item from a list. -func selectPrompt(prompt string, items []selectItem) (string, error) { - if len(items) == 0 { - return "", fmt.Errorf("no items to select from") - } - - ts, err := enterRawMode() - if err != nil { - return "", err - } - defer ts.restore() - - state := newSelectState(items) - var lastLineCount int - - render := func() { - clearLines(lastLineCount) - lastLineCount = renderSelect(os.Stderr, prompt, state) - } - - render() - - for { - event, char, err := parseInput(os.Stdin) - if err != nil { - return "", err - } - - done, result, err := state.handleInput(event, char) - if done { - clearLines(lastLineCount) - if err != nil { - return "", err - } - return result, nil - } - - render() - } -} - -// multiSelectPrompt prompts the user to select multiple items from a list. -func multiSelectPrompt(prompt string, items []selectItem, preChecked []string) ([]string, error) { - if len(items) == 0 { - return nil, fmt.Errorf("no items to select from") - } - - ts, err := enterRawMode() - if err != nil { - return nil, err - } - defer ts.restore() - - state := newMultiSelectState(items, preChecked) - var lastLineCount int - - render := func() { - clearLines(lastLineCount) - lastLineCount = renderMultiSelect(os.Stderr, prompt, state) - } - - render() - - for { - event, char, err := parseInput(os.Stdin) - if err != nil { - return nil, err - } - - done, result, err := state.handleInput(event, char) - if done { - clearLines(lastLineCount) - if err != nil { - return nil, err - } - return result, nil - } - - render() - } -} +// DefaultConfirmPrompt provides a TUI-based confirmation prompt. +// When set, confirmPrompt delegates to it instead of using raw terminal I/O. +var DefaultConfirmPrompt func(prompt string) (bool, error) func confirmPrompt(prompt string) (bool, error) { + if DefaultConfirmPrompt != nil { + return DefaultConfirmPrompt(prompt) + } + fd := int(os.Stdin.Fd()) oldState, err := term.MakeRaw(fd) if err != nil { @@ -497,17 +56,3 @@ func confirmPrompt(prompt string) (bool, error) { } } } - -func filterItems(items []selectItem, filter string) []selectItem { - if filter == "" { - return items - } - var result []selectItem - filterLower := strings.ToLower(filter) - for _, item := range items { - if strings.Contains(strings.ToLower(item.Name), filterLower) { - result = append(result, item) - } - } - return result -} diff --git a/cmd/config/selector_test.go b/cmd/config/selector_test.go index 39557a535..3e84d1b5d 100644 --- a/cmd/config/selector_test.go +++ b/cmd/config/selector_test.go @@ -1,670 +1,9 @@ package config import ( - "bytes" - "strings" "testing" ) -func TestFilterItems(t *testing.T) { - items := []selectItem{ - {Name: "llama3.2:latest"}, - {Name: "qwen2.5:7b"}, - {Name: "deepseek-v3:cloud"}, - {Name: "GPT-OSS:20b"}, - } - - t.Run("EmptyFilter_ReturnsAllItems", func(t *testing.T) { - result := filterItems(items, "") - if len(result) != len(items) { - t.Errorf("expected %d items, got %d", len(items), len(result)) - } - }) - - t.Run("CaseInsensitive_UppercaseFilterMatchesLowercase", func(t *testing.T) { - result := filterItems(items, "LLAMA") - if len(result) != 1 || result[0].Name != "llama3.2:latest" { - t.Errorf("expected llama3.2:latest, got %v", result) - } - }) - - t.Run("CaseInsensitive_LowercaseFilterMatchesUppercase", func(t *testing.T) { - result := filterItems(items, "gpt") - if len(result) != 1 || result[0].Name != "GPT-OSS:20b" { - t.Errorf("expected GPT-OSS:20b, got %v", result) - } - }) - - t.Run("PartialMatch", func(t *testing.T) { - result := filterItems(items, "deep") - if len(result) != 1 || result[0].Name != "deepseek-v3:cloud" { - t.Errorf("expected deepseek-v3:cloud, got %v", result) - } - }) - - t.Run("NoMatch_ReturnsEmpty", func(t *testing.T) { - result := filterItems(items, "nonexistent") - if len(result) != 0 { - t.Errorf("expected 0 items, got %d", len(result)) - } - }) -} - -func TestSelectState(t *testing.T) { - items := []selectItem{ - {Name: "item1"}, - {Name: "item2"}, - {Name: "item3"}, - } - - t.Run("InitialState", func(t *testing.T) { - s := newSelectState(items) - if s.selected != 0 { - t.Errorf("expected selected=0, got %d", s.selected) - } - if s.filter != "" { - t.Errorf("expected empty filter, got %q", s.filter) - } - if s.scrollOffset != 0 { - t.Errorf("expected scrollOffset=0, got %d", s.scrollOffset) - } - }) - - t.Run("Enter_SelectsCurrentItem", func(t *testing.T) { - s := newSelectState(items) - done, result, err := s.handleInput(eventEnter, 0) - if !done || result != "item1" || err != nil { - t.Errorf("expected (true, item1, nil), got (%v, %v, %v)", done, result, err) - } - }) - - t.Run("Enter_WithFilter_SelectsFilteredItem", func(t *testing.T) { - s := newSelectState(items) - s.filter = "item3" - done, result, err := s.handleInput(eventEnter, 0) - if !done || result != "item3" || err != nil { - t.Errorf("expected (true, item3, nil), got (%v, %v, %v)", done, result, err) - } - }) - - t.Run("Enter_EmptyFilteredList_DoesNothing", func(t *testing.T) { - s := newSelectState(items) - s.filter = "nonexistent" - done, result, err := s.handleInput(eventEnter, 0) - if done || result != "" || err != nil { - t.Errorf("expected (false, '', nil), got (%v, %v, %v)", done, result, err) - } - }) - - t.Run("Enter_EmptyFilteredList_EmptyFilter_DoesNothing", func(t *testing.T) { - s := newSelectState([]selectItem{}) - done, result, err := s.handleInput(eventEnter, 0) - if done || result != "" || err != nil { - t.Errorf("expected (false, '', nil), got (%v, %v, %v)", done, result, err) - } - }) - - t.Run("Escape_ReturnsCancelledError", func(t *testing.T) { - s := newSelectState(items) - done, result, err := s.handleInput(eventEscape, 0) - if !done || result != "" || err != errCancelled { - t.Errorf("expected (true, '', errCancelled), got (%v, %v, %v)", done, result, err) - } - }) - - t.Run("Down_MovesSelection", func(t *testing.T) { - s := newSelectState(items) - s.handleInput(eventDown, 0) - if s.selected != 1 { - t.Errorf("expected selected=1, got %d", s.selected) - } - }) - - t.Run("Down_AtBottom_StaysAtBottom", func(t *testing.T) { - s := newSelectState(items) - s.selected = 2 - s.handleInput(eventDown, 0) - if s.selected != 2 { - t.Errorf("expected selected=2 (stayed at bottom), got %d", s.selected) - } - }) - - t.Run("Up_MovesSelection", func(t *testing.T) { - s := newSelectState(items) - s.selected = 2 - s.handleInput(eventUp, 0) - if s.selected != 1 { - t.Errorf("expected selected=1, got %d", s.selected) - } - }) - - t.Run("Up_AtTop_StaysAtTop", func(t *testing.T) { - s := newSelectState(items) - s.handleInput(eventUp, 0) - if s.selected != 0 { - t.Errorf("expected selected=0 (stayed at top), got %d", s.selected) - } - }) - - t.Run("Char_AppendsToFilter", func(t *testing.T) { - s := newSelectState(items) - s.handleInput(eventChar, 'i') - s.handleInput(eventChar, 't') - s.handleInput(eventChar, 'e') - s.handleInput(eventChar, 'm') - s.handleInput(eventChar, '2') - if s.filter != "item2" { - t.Errorf("expected filter='item2', got %q", s.filter) - } - filtered := s.filtered() - if len(filtered) != 1 || filtered[0].Name != "item2" { - t.Errorf("expected [item2], got %v", filtered) - } - }) - - t.Run("Char_ResetsSelectionToZero", func(t *testing.T) { - s := newSelectState(items) - s.selected = 2 - s.handleInput(eventChar, 'x') - if s.selected != 0 { - t.Errorf("expected selected=0 after typing, got %d", s.selected) - } - }) - - t.Run("Backspace_RemovesLastFilterChar", func(t *testing.T) { - s := newSelectState(items) - s.filter = "test" - s.handleInput(eventBackspace, 0) - if s.filter != "tes" { - t.Errorf("expected filter='tes', got %q", s.filter) - } - }) - - t.Run("Backspace_EmptyFilter_DoesNothing", func(t *testing.T) { - s := newSelectState(items) - s.handleInput(eventBackspace, 0) - if s.filter != "" { - t.Errorf("expected filter='', got %q", s.filter) - } - }) - - t.Run("Backspace_ResetsSelectionToZero", func(t *testing.T) { - s := newSelectState(items) - s.filter = "test" - s.selected = 2 - s.handleInput(eventBackspace, 0) - if s.selected != 0 { - t.Errorf("expected selected=0 after backspace, got %d", s.selected) - } - }) - - t.Run("Scroll_DownPastVisibleItems_ScrollsViewport", func(t *testing.T) { - // maxDisplayedItems is 10, so with 15 items we need to scroll - manyItems := make([]selectItem, 15) - for i := range manyItems { - manyItems[i] = selectItem{Name: string(rune('a' + i))} - } - s := newSelectState(manyItems) - - // move down 12 times (past the 10-item viewport) - for range 12 { - s.handleInput(eventDown, 0) - } - - if s.selected != 12 { - t.Errorf("expected selected=12, got %d", s.selected) - } - if s.scrollOffset != 3 { - t.Errorf("expected scrollOffset=3 (12-10+1), got %d", s.scrollOffset) - } - }) - - t.Run("Scroll_UpPastScrollOffset_ScrollsViewport", func(t *testing.T) { - manyItems := make([]selectItem, 15) - for i := range manyItems { - manyItems[i] = selectItem{Name: string(rune('a' + i))} - } - s := newSelectState(manyItems) - s.selected = 5 - s.scrollOffset = 5 - - s.handleInput(eventUp, 0) - - if s.selected != 4 { - t.Errorf("expected selected=4, got %d", s.selected) - } - if s.scrollOffset != 4 { - t.Errorf("expected scrollOffset=4, got %d", s.scrollOffset) - } - }) -} - -func TestMultiSelectState(t *testing.T) { - items := []selectItem{ - {Name: "item1"}, - {Name: "item2"}, - {Name: "item3"}, - } - - t.Run("InitialState_NoPrechecked", func(t *testing.T) { - s := newMultiSelectState(items, nil) - if s.highlighted != 0 { - t.Errorf("expected highlighted=0, got %d", s.highlighted) - } - if s.selectedCount() != 0 { - t.Errorf("expected 0 selected, got %d", s.selectedCount()) - } - if s.focusOnButton { - t.Error("expected focusOnButton=false initially") - } - }) - - t.Run("InitialState_WithPrechecked", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item2", "item3"}) - if s.selectedCount() != 2 { - t.Errorf("expected 2 selected, got %d", s.selectedCount()) - } - if !s.checked[1] || !s.checked[2] { - t.Error("expected item2 and item3 to be checked") - } - }) - - t.Run("Prechecked_PreservesSelectionOrder", func(t *testing.T) { - // order matters: first checked = default model - s := newMultiSelectState(items, []string{"item3", "item1"}) - if len(s.checkOrder) != 2 { - t.Fatalf("expected 2 in checkOrder, got %d", len(s.checkOrder)) - } - if s.checkOrder[0] != 2 || s.checkOrder[1] != 0 { - t.Errorf("expected checkOrder=[2,0] (item3 first), got %v", s.checkOrder) - } - }) - - t.Run("Prechecked_IgnoresInvalidNames", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1", "nonexistent"}) - if s.selectedCount() != 1 { - t.Errorf("expected 1 selected (nonexistent ignored), got %d", s.selectedCount()) - } - }) - - t.Run("Toggle_ChecksUncheckedItem", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.toggleItem() - if !s.checked[0] { - t.Error("expected item1 to be checked after toggle") - } - }) - - t.Run("Toggle_UnchecksCheckedItem", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - s.toggleItem() - if s.checked[0] { - t.Error("expected item1 to be unchecked after toggle") - } - }) - - t.Run("Toggle_RemovesFromCheckOrder", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1", "item2", "item3"}) - s.highlighted = 1 // toggle item2 - s.toggleItem() - - if len(s.checkOrder) != 2 { - t.Fatalf("expected 2 in checkOrder, got %d", len(s.checkOrder)) - } - // should be [0, 2] (item1, item3) with item2 removed - if s.checkOrder[0] != 0 || s.checkOrder[1] != 2 { - t.Errorf("expected checkOrder=[0,2], got %v", s.checkOrder) - } - }) - - t.Run("Enter_TogglesWhenNotOnButton", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.handleInput(eventEnter, 0) - if !s.checked[0] { - t.Error("expected item1 to be checked after enter") - } - }) - - t.Run("Enter_OnButton_ReturnsSelection", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item2", "item1"}) - s.focusOnButton = true - - done, result, err := s.handleInput(eventEnter, 0) - - if !done || err != nil { - t.Errorf("expected done=true, err=nil, got done=%v, err=%v", done, err) - } - // result should preserve selection order - if len(result) != 2 || result[0] != "item2" || result[1] != "item1" { - t.Errorf("expected [item2, item1], got %v", result) - } - }) - - t.Run("Enter_OnButton_EmptySelection_DoesNothing", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.focusOnButton = true - done, result, err := s.handleInput(eventEnter, 0) - if done || result != nil || err != nil { - t.Errorf("expected (false, nil, nil), got (%v, %v, %v)", done, result, err) - } - }) - - t.Run("Tab_SwitchesToButton_WhenHasSelection", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - s.handleInput(eventTab, 0) - if !s.focusOnButton { - t.Error("expected focus on button after tab") - } - }) - - t.Run("Tab_DoesNothing_WhenNoSelection", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.handleInput(eventTab, 0) - if s.focusOnButton { - t.Error("tab should not focus button when nothing selected") - } - }) - - t.Run("Tab_TogglesButtonFocus", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - s.handleInput(eventTab, 0) - if !s.focusOnButton { - t.Error("expected focus on button after first tab") - } - s.handleInput(eventTab, 0) - if s.focusOnButton { - t.Error("expected focus back on list after second tab") - } - }) - - t.Run("Escape_ReturnsCancelledError", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - done, result, err := s.handleInput(eventEscape, 0) - if !done || result != nil || err != errCancelled { - t.Errorf("expected (true, nil, errCancelled), got (%v, %v, %v)", done, result, err) - } - }) - - t.Run("IsDefault_TrueForFirstChecked", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item2", "item1"}) - if !(len(s.checkOrder) > 0 && s.checkOrder[0] == 1) { - t.Error("expected item2 (idx 1) to be default (first checked)") - } - if len(s.checkOrder) > 0 && s.checkOrder[0] == 0 { - t.Error("expected item1 (idx 0) to NOT be default") - } - }) - - t.Run("IsDefault_FalseWhenNothingChecked", func(t *testing.T) { - s := newMultiSelectState(items, nil) - if len(s.checkOrder) > 0 && s.checkOrder[0] == 0 { - t.Error("expected isDefault=false when nothing checked") - } - }) - - t.Run("Down_MovesHighlight", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.handleInput(eventDown, 0) - if s.highlighted != 1 { - t.Errorf("expected highlighted=1, got %d", s.highlighted) - } - }) - - t.Run("Up_MovesHighlight", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.highlighted = 1 - s.handleInput(eventUp, 0) - if s.highlighted != 0 { - t.Errorf("expected highlighted=0, got %d", s.highlighted) - } - }) - - t.Run("Arrow_ReturnsFocusFromButton", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - s.focusOnButton = true - s.handleInput(eventDown, 0) - if s.focusOnButton { - t.Error("expected focus to return to list on arrow key") - } - }) - - t.Run("Char_AppendsToFilter", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.handleInput(eventChar, 'x') - if s.filter != "x" { - t.Errorf("expected filter='x', got %q", s.filter) - } - }) - - t.Run("Char_ResetsHighlightAndScroll", func(t *testing.T) { - manyItems := make([]selectItem, 15) - for i := range manyItems { - manyItems[i] = selectItem{Name: string(rune('a' + i))} - } - s := newMultiSelectState(manyItems, nil) - s.highlighted = 10 - s.scrollOffset = 5 - - s.handleInput(eventChar, 'x') - - if s.highlighted != 0 { - t.Errorf("expected highlighted=0, got %d", s.highlighted) - } - if s.scrollOffset != 0 { - t.Errorf("expected scrollOffset=0, got %d", s.scrollOffset) - } - }) - - t.Run("Backspace_RemovesLastFilterChar", func(t *testing.T) { - s := newMultiSelectState(items, nil) - s.filter = "test" - s.handleInput(eventBackspace, 0) - if s.filter != "tes" { - t.Errorf("expected filter='tes', got %q", s.filter) - } - }) - - t.Run("Backspace_RemovesFocusFromButton", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - s.filter = "x" - s.focusOnButton = true - s.handleInput(eventBackspace, 0) - if s.focusOnButton { - t.Error("expected focusOnButton=false after backspace") - } - }) -} - -func TestParseInput(t *testing.T) { - t.Run("Enter", func(t *testing.T) { - event, char, err := parseInput(bytes.NewReader([]byte{13})) - if err != nil || event != eventEnter || char != 0 { - t.Errorf("expected (eventEnter, 0, nil), got (%v, %v, %v)", event, char, err) - } - }) - - t.Run("Escape", func(t *testing.T) { - event, _, err := parseInput(bytes.NewReader([]byte{27})) - if err != nil || event != eventEscape { - t.Errorf("expected eventEscape, got %v", event) - } - }) - - t.Run("CtrlC_TreatedAsEscape", func(t *testing.T) { - event, _, err := parseInput(bytes.NewReader([]byte{3})) - if err != nil || event != eventEscape { - t.Errorf("expected eventEscape for Ctrl+C, got %v", event) - } - }) - - t.Run("Tab", func(t *testing.T) { - event, _, err := parseInput(bytes.NewReader([]byte{9})) - if err != nil || event != eventTab { - t.Errorf("expected eventTab, got %v", event) - } - }) - - t.Run("Backspace", func(t *testing.T) { - event, _, err := parseInput(bytes.NewReader([]byte{127})) - if err != nil || event != eventBackspace { - t.Errorf("expected eventBackspace, got %v", event) - } - }) - - t.Run("UpArrow", func(t *testing.T) { - event, _, err := parseInput(bytes.NewReader([]byte{27, 91, 65})) - if err != nil || event != eventUp { - t.Errorf("expected eventUp, got %v", event) - } - }) - - t.Run("DownArrow", func(t *testing.T) { - event, _, err := parseInput(bytes.NewReader([]byte{27, 91, 66})) - if err != nil || event != eventDown { - t.Errorf("expected eventDown, got %v", event) - } - }) - - t.Run("PrintableChars", func(t *testing.T) { - tests := []struct { - name string - char byte - }{ - {"lowercase", 'a'}, - {"uppercase", 'Z'}, - {"digit", '5'}, - {"space", ' '}, - {"tilde", '~'}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - event, char, err := parseInput(bytes.NewReader([]byte{tt.char})) - if err != nil || event != eventChar || char != tt.char { - t.Errorf("expected (eventChar, %q), got (%v, %q)", tt.char, event, char) - } - }) - } - }) -} - -func TestRenderSelect(t *testing.T) { - items := []selectItem{ - {Name: "item1", Description: "first item"}, - {Name: "item2"}, - } - - t.Run("ShowsPromptAndItems", func(t *testing.T) { - s := newSelectState(items) - var buf bytes.Buffer - lineCount := renderSelect(&buf, "Select:", s) - - output := buf.String() - if !strings.Contains(output, "Select:") { - t.Error("expected prompt in output") - } - if !strings.Contains(output, "item1") { - t.Error("expected item1 in output") - } - if !strings.Contains(output, "first item") { - t.Error("expected description in output") - } - if !strings.Contains(output, "item2") { - t.Error("expected item2 in output") - } - if lineCount != 3 { // 1 prompt + 2 items - t.Errorf("expected 3 lines, got %d", lineCount) - } - }) - - t.Run("EmptyFilteredList_ShowsNoMatches", func(t *testing.T) { - s := newSelectState(items) - s.filter = "xyz" - var buf bytes.Buffer - renderSelect(&buf, "Select:", s) - - output := buf.String() - if !strings.Contains(output, "no matches") { - t.Errorf("expected 'no matches' message, got: %s", output) - } - }) - - t.Run("EmptyFilteredList_EmptyFilter_ShowsNoMatches", func(t *testing.T) { - s := newSelectState([]selectItem{}) - var buf bytes.Buffer - renderSelect(&buf, "Select:", s) - - if !strings.Contains(buf.String(), "no matches") { - t.Error("expected 'no matches' message for empty list with no filter") - } - }) - - t.Run("LongList_ShowsRemainingCount", func(t *testing.T) { - manyItems := make([]selectItem, 15) - for i := range manyItems { - manyItems[i] = selectItem{Name: string(rune('a' + i))} - } - s := newSelectState(manyItems) - var buf bytes.Buffer - renderSelect(&buf, "Select:", s) - - // 15 items - 10 displayed = 5 more - if !strings.Contains(buf.String(), "5 more") { - t.Error("expected '5 more' indicator") - } - }) -} - -func TestRenderMultiSelect(t *testing.T) { - items := []selectItem{ - {Name: "item1"}, - {Name: "item2"}, - } - - t.Run("ShowsCheckboxes", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - var buf bytes.Buffer - renderMultiSelect(&buf, "Select:", s) - - output := buf.String() - if !strings.Contains(output, "[x]") { - t.Error("expected checked checkbox [x]") - } - if !strings.Contains(output, "[ ]") { - t.Error("expected unchecked checkbox [ ]") - } - }) - - t.Run("ShowsDefaultMarker", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1"}) - var buf bytes.Buffer - renderMultiSelect(&buf, "Select:", s) - - if !strings.Contains(buf.String(), "(default)") { - t.Error("expected (default) marker for first checked item") - } - }) - - t.Run("ShowsSelectedCount", func(t *testing.T) { - s := newMultiSelectState(items, []string{"item1", "item2"}) - var buf bytes.Buffer - renderMultiSelect(&buf, "Select:", s) - - if !strings.Contains(buf.String(), "2 selected") { - t.Error("expected '2 selected' in output") - } - }) - - t.Run("NoSelection_ShowsHelperText", func(t *testing.T) { - s := newMultiSelectState(items, nil) - var buf bytes.Buffer - renderMultiSelect(&buf, "Select:", s) - - if !strings.Contains(buf.String(), "Select at least one") { - t.Error("expected 'Select at least one' helper text") - } - }) -} - func TestErrCancelled(t *testing.T) { t.Run("NotNil", func(t *testing.T) { if errCancelled == nil { @@ -678,255 +17,3 @@ func TestErrCancelled(t *testing.T) { } }) } - -// Edge case tests for selector.go - -// TestSelectState_SingleItem verifies that single item list works without crash. -// List with only one item should still work. -func TestSelectState_SingleItem(t *testing.T) { - items := []selectItem{{Name: "only-one"}} - - s := newSelectState(items) - - // Down should do nothing (already at bottom) - s.handleInput(eventDown, 0) - if s.selected != 0 { - t.Errorf("down on single item: expected selected=0, got %d", s.selected) - } - - // Up should do nothing (already at top) - s.handleInput(eventUp, 0) - if s.selected != 0 { - t.Errorf("up on single item: expected selected=0, got %d", s.selected) - } - - // Enter should select the only item - done, result, err := s.handleInput(eventEnter, 0) - if !done || result != "only-one" || err != nil { - t.Errorf("enter on single item: expected (true, 'only-one', nil), got (%v, %q, %v)", done, result, err) - } -} - -// TestSelectState_ExactlyMaxItems verifies boundary condition at maxDisplayedItems. -// List with exactly maxDisplayedItems items should not scroll. -func TestSelectState_ExactlyMaxItems(t *testing.T) { - items := make([]selectItem, maxDisplayedItems) - for i := range items { - items[i] = selectItem{Name: string(rune('a' + i))} - } - - s := newSelectState(items) - - // Move to last item - for range maxDisplayedItems - 1 { - s.handleInput(eventDown, 0) - } - - if s.selected != maxDisplayedItems-1 { - t.Errorf("expected selected=%d, got %d", maxDisplayedItems-1, s.selected) - } - - // Should not scroll when exactly at max - if s.scrollOffset != 0 { - t.Errorf("expected scrollOffset=0 for exactly maxDisplayedItems, got %d", s.scrollOffset) - } - - // One more down should do nothing - s.handleInput(eventDown, 0) - if s.selected != maxDisplayedItems-1 { - t.Errorf("down at max: expected selected=%d, got %d", maxDisplayedItems-1, s.selected) - } -} - -// TestFilterItems_RegexSpecialChars verifies that filter is literal, not regex. -// User typing "model.v1" shouldn't match "modelsv1". -func TestFilterItems_RegexSpecialChars(t *testing.T) { - items := []selectItem{ - {Name: "model.v1"}, - {Name: "modelsv1"}, - {Name: "model-v1"}, - } - - // Filter with dot should only match literal dot - result := filterItems(items, "model.v1") - if len(result) != 1 { - t.Errorf("expected 1 exact match, got %d", len(result)) - } - if len(result) > 0 && result[0].Name != "model.v1" { - t.Errorf("expected 'model.v1', got %s", result[0].Name) - } - - // Other regex special chars should be literal too - items2 := []selectItem{ - {Name: "test[0]"}, - {Name: "test0"}, - {Name: "test(1)"}, - } - - result2 := filterItems(items2, "test[0]") - if len(result2) != 1 || result2[0].Name != "test[0]" { - t.Errorf("expected only 'test[0]', got %v", result2) - } -} - -// TestMultiSelectState_DuplicateNames documents handling of duplicate item names. -// itemIndex uses name as key - duplicates cause collision. This documents -// the current behavior: the last index for a duplicate name is stored -func TestMultiSelectState_DuplicateNames(t *testing.T) { - // Duplicate names - this is an edge case that shouldn't happen in practice - items := []selectItem{ - {Name: "duplicate"}, - {Name: "duplicate"}, - {Name: "unique"}, - } - - s := newMultiSelectState(items, nil) - - // DOCUMENTED BEHAVIOR: itemIndex maps name to LAST index - // When there are duplicates, only the last occurrence's index is stored - if s.itemIndex["duplicate"] != 1 { - t.Errorf("itemIndex should map 'duplicate' to last index (1), got %d", s.itemIndex["duplicate"]) - } - - // Toggle item at highlighted=0 (first "duplicate") - // Due to name collision, toggleItem uses itemIndex["duplicate"] = 1 - // So it actually toggles the SECOND duplicate item, not the first - s.toggleItem() - - // This documents the potentially surprising behavior: - // We toggled at highlighted=0, but itemIndex lookup returned 1 - if !s.checked[1] { - t.Error("toggle should check index 1 (due to name collision in itemIndex)") - } - if s.checked[0] { - t.Log("Note: index 0 is NOT checked, even though highlighted=0 (name collision behavior)") - } -} - -// TestSelectState_FilterReducesBelowSelection verifies selection resets when filter reduces list. -// Prevents index-out-of-bounds on next keystroke -func TestSelectState_FilterReducesBelowSelection(t *testing.T) { - items := []selectItem{ - {Name: "apple"}, - {Name: "banana"}, - {Name: "cherry"}, - } - - s := newSelectState(items) - s.selected = 2 // Select "cherry" - - // Type a filter that removes cherry from results - s.handleInput(eventChar, 'a') // Filter to "a" - matches "apple" and "banana" - - // Selection should reset to 0 - if s.selected != 0 { - t.Errorf("expected selected=0 after filter, got %d", s.selected) - } - - filtered := s.filtered() - if len(filtered) != 2 { - t.Errorf("expected 2 filtered items, got %d", len(filtered)) - } -} - -// TestFilterItems_UnicodeCharacters verifies filtering works with UTF-8. -// Model names might contain unicode characters -func TestFilterItems_UnicodeCharacters(t *testing.T) { - items := []selectItem{ - {Name: "llama-日本語"}, - {Name: "模型-chinese"}, - {Name: "émoji-🦙"}, - {Name: "regular-model"}, - } - - t.Run("filter japanese", func(t *testing.T) { - result := filterItems(items, "日本") - if len(result) != 1 || result[0].Name != "llama-日本語" { - t.Errorf("expected llama-日本語, got %v", result) - } - }) - - t.Run("filter chinese", func(t *testing.T) { - result := filterItems(items, "模型") - if len(result) != 1 || result[0].Name != "模型-chinese" { - t.Errorf("expected 模型-chinese, got %v", result) - } - }) - - t.Run("filter emoji", func(t *testing.T) { - result := filterItems(items, "🦙") - if len(result) != 1 || result[0].Name != "émoji-🦙" { - t.Errorf("expected émoji-🦙, got %v", result) - } - }) - - t.Run("filter accented char", func(t *testing.T) { - result := filterItems(items, "émoji") - if len(result) != 1 || result[0].Name != "émoji-🦙" { - t.Errorf("expected émoji-🦙, got %v", result) - } - }) -} - -// TestMultiSelectState_FilterReducesBelowHighlight verifies highlight resets when filter reduces list. -func TestMultiSelectState_FilterReducesBelowHighlight(t *testing.T) { - items := []selectItem{ - {Name: "apple"}, - {Name: "banana"}, - {Name: "cherry"}, - } - - s := newMultiSelectState(items, nil) - s.highlighted = 2 // Highlight "cherry" - - // Type a filter that removes cherry - s.handleInput(eventChar, 'a') - - if s.highlighted != 0 { - t.Errorf("expected highlighted=0 after filter, got %d", s.highlighted) - } -} - -// TestMultiSelectState_EmptyItems verifies handling of empty item list. -// Empty list should be handled gracefully. -func TestMultiSelectState_EmptyItems(t *testing.T) { - s := newMultiSelectState([]selectItem{}, nil) - - // Toggle should not panic on empty list - s.toggleItem() - - if s.selectedCount() != 0 { - t.Errorf("expected 0 selected for empty list, got %d", s.selectedCount()) - } - - // Render should handle empty list - var buf bytes.Buffer - lineCount := renderMultiSelect(&buf, "Select:", s) - if lineCount == 0 { - t.Error("renderMultiSelect should produce output even for empty list") - } - if !strings.Contains(buf.String(), "no matches") { - t.Error("expected 'no matches' for empty list") - } -} - -// TestSelectState_RenderWithDescriptions verifies rendering items with descriptions. -func TestSelectState_RenderWithDescriptions(t *testing.T) { - items := []selectItem{ - {Name: "item1", Description: "First item description"}, - {Name: "item2", Description: ""}, - {Name: "item3", Description: "Third item"}, - } - - s := newSelectState(items) - var buf bytes.Buffer - renderSelect(&buf, "Select:", s) - - output := buf.String() - if !strings.Contains(output, "First item description") { - t.Error("expected description to be rendered") - } - if !strings.Contains(output, "item2") { - t.Error("expected item without description to be rendered") - } -} diff --git a/cmd/tui/confirm.go b/cmd/tui/confirm.go new file mode 100644 index 000000000..b8f92b124 --- /dev/null +++ b/cmd/tui/confirm.go @@ -0,0 +1,109 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + confirmActiveStyle = lipgloss.NewStyle(). + Bold(true). + Background(lipgloss.AdaptiveColor{Light: "254", Dark: "236"}) + + confirmInactiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) +) + +type confirmModel struct { + prompt string + yes bool + confirmed bool + cancelled bool + width int +} + +func (m confirmModel) Init() tea.Cmd { + return nil +} + +func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + wasSet := m.width > 0 + m.width = msg.Width + if wasSet { + return m, tea.EnterAltScreen + } + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "n": + m.cancelled = true + return m, tea.Quit + case "y": + m.yes = true + m.confirmed = true + return m, tea.Quit + case "enter": + m.confirmed = true + return m, tea.Quit + case "left", "h": + m.yes = true + case "right", "l": + m.yes = false + case "tab": + m.yes = !m.yes + } + } + + return m, nil +} + +func (m confirmModel) View() string { + if m.confirmed || m.cancelled { + return "" + } + + var yesBtn, noBtn string + if m.yes { + yesBtn = confirmActiveStyle.Render(" Yes ") + noBtn = confirmInactiveStyle.Render(" No ") + } else { + yesBtn = confirmInactiveStyle.Render(" Yes ") + noBtn = confirmActiveStyle.Render(" No ") + } + + s := selectorTitleStyle.Render(m.prompt) + "\n\n" + s += " " + yesBtn + " " + noBtn + "\n\n" + s += selectorHelpStyle.Render("←/→ navigate • enter confirm • esc cancel") + + if m.width > 0 { + return lipgloss.NewStyle().MaxWidth(m.width).Render(s) + } + return s +} + +// RunConfirm shows a bubbletea yes/no confirmation prompt. +// Returns true if the user confirmed, false if cancelled. +func RunConfirm(prompt string) (bool, error) { + m := confirmModel{ + prompt: prompt, + yes: true, // default to yes + } + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return false, fmt.Errorf("error running confirm: %w", err) + } + + fm := finalModel.(confirmModel) + if fm.cancelled { + return false, ErrCancelled + } + + return fm.yes, nil +} diff --git a/cmd/tui/confirm_test.go b/cmd/tui/confirm_test.go new file mode 100644 index 000000000..4279d18eb --- /dev/null +++ b/cmd/tui/confirm_test.go @@ -0,0 +1,208 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestConfirmModel_DefaultsToYes(t *testing.T) { + m := confirmModel{prompt: "Download test?", yes: true} + if !m.yes { + t.Error("should default to yes") + } +} + +func TestConfirmModel_View_ContainsPrompt(t *testing.T) { + m := confirmModel{prompt: "Download qwen3:8b?", yes: true} + got := m.View() + if !strings.Contains(got, "Download qwen3:8b?") { + t.Error("should contain the prompt text") + } +} + +func TestConfirmModel_View_ContainsButtons(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + got := m.View() + if !strings.Contains(got, "Yes") { + t.Error("should contain Yes button") + } + if !strings.Contains(got, "No") { + t.Error("should contain No button") + } +} + +func TestConfirmModel_View_ContainsHelp(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + got := m.View() + if !strings.Contains(got, "enter confirm") { + t.Error("should contain help text") + } +} + +func TestConfirmModel_View_ClearsAfterConfirm(t *testing.T) { + m := confirmModel{prompt: "Download?", confirmed: true} + if m.View() != "" { + t.Error("View should return empty string after confirmation") + } +} + +func TestConfirmModel_View_ClearsAfterCancel(t *testing.T) { + m := confirmModel{prompt: "Download?", cancelled: true} + if m.View() != "" { + t.Error("View should return empty string after cancellation") + } +} + +func TestConfirmModel_EnterConfirmsYes(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + fm := updated.(confirmModel) + if !fm.confirmed { + t.Error("enter should set confirmed=true") + } + if !fm.yes { + t.Error("enter with yes selected should keep yes=true") + } + if cmd == nil { + t.Error("enter should return tea.Quit") + } +} + +func TestConfirmModel_EnterConfirmsNo(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: false} + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + fm := updated.(confirmModel) + if !fm.confirmed { + t.Error("enter should set confirmed=true") + } + if fm.yes { + t.Error("enter with no selected should keep yes=false") + } + if cmd == nil { + t.Error("enter should return tea.Quit") + } +} + +func TestConfirmModel_EscCancels(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + fm := updated.(confirmModel) + if !fm.cancelled { + t.Error("esc should set cancelled=true") + } + if cmd == nil { + t.Error("esc should return tea.Quit") + } +} + +func TestConfirmModel_CtrlCCancels(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + fm := updated.(confirmModel) + if !fm.cancelled { + t.Error("ctrl+c should set cancelled=true") + } + if cmd == nil { + t.Error("ctrl+c should return tea.Quit") + } +} + +func TestConfirmModel_NCancels(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + fm := updated.(confirmModel) + if !fm.cancelled { + t.Error("'n' should set cancelled=true") + } + if cmd == nil { + t.Error("'n' should return tea.Quit") + } +} + +func TestConfirmModel_YConfirmsYes(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: false} + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + fm := updated.(confirmModel) + if !fm.confirmed { + t.Error("'y' should set confirmed=true") + } + if !fm.yes { + t.Error("'y' should set yes=true") + } + if cmd == nil { + t.Error("'y' should return tea.Quit") + } +} + +func TestConfirmModel_ArrowKeysNavigate(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + + // Right moves to No + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + fm := updated.(confirmModel) + if fm.yes { + t.Error("right/l should move to No") + } + if fm.confirmed || fm.cancelled { + t.Error("navigation should not confirm or cancel") + } + + // Left moves back to Yes + updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + fm = updated.(confirmModel) + if !fm.yes { + t.Error("left/h should move to Yes") + } +} + +func TestConfirmModel_TabToggles(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true} + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + fm := updated.(confirmModel) + if fm.yes { + t.Error("tab should toggle from Yes to No") + } + + updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyTab}) + fm = updated.(confirmModel) + if !fm.yes { + t.Error("tab should toggle from No to Yes") + } +} + +func TestConfirmModel_WindowSizeUpdatesWidth(t *testing.T) { + m := confirmModel{prompt: "Download?"} + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + fm := updated.(confirmModel) + if fm.width != 100 { + t.Errorf("expected width 100, got %d", fm.width) + } +} + +func TestConfirmModel_ResizeEntersAltScreen(t *testing.T) { + m := confirmModel{prompt: "Download?", width: 80} + _, cmd := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + if cmd == nil { + t.Error("resize (width already set) should return a command") + } +} + +func TestConfirmModel_InitialWindowSizeNoAltScreen(t *testing.T) { + m := confirmModel{prompt: "Download?"} + _, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + if cmd != nil { + t.Error("initial WindowSizeMsg should not return a command") + } +} + +func TestConfirmModel_ViewMaxWidth(t *testing.T) { + m := confirmModel{prompt: "Download?", yes: true, width: 40} + got := m.View() + // Just ensure it doesn't panic and returns content + if got == "" { + t.Error("View with width set should still return content") + } +} diff --git a/cmd/tui/selector.go b/cmd/tui/selector.go index 4e64a5419..24c133647 100644 --- a/cmd/tui/selector.go +++ b/cmd/tui/selector.go @@ -18,35 +18,44 @@ var ( selectorSelectedItemStyle = lipgloss.NewStyle(). PaddingLeft(2). - Bold(true) + Bold(true). + Background(lipgloss.AdaptiveColor{Light: "254", Dark: "236"}) selectorDescStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) + + selectorDescLineStyle = selectorDescStyle. + PaddingLeft(6) selectorFilterStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}). Italic(true) selectorInputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")) + Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}) selectorCheckboxStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) selectorCheckboxCheckedStyle = lipgloss.NewStyle(). Bold(true) selectorDefaultTagStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}). Italic(true) selectorHelpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) + Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}) selectorMoreStyle = lipgloss.NewStyle(). - PaddingLeft(4). - Foreground(lipgloss.Color("241")). + PaddingLeft(6). + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}). Italic(true) + + sectionHeaderStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Bold(true). + Foreground(lipgloss.AdaptiveColor{Light: "240", Dark: "249"}) ) const maxSelectorItems = 10 @@ -54,10 +63,10 @@ const maxSelectorItems = 10 // ErrCancelled is returned when the user cancels the selection. var ErrCancelled = errors.New("cancelled") -// SelectItem represents an item that can be selected. type SelectItem struct { Name string Description string + Recommended bool } // selectorModel is the bubbletea model for single selection. @@ -69,6 +78,8 @@ type selectorModel struct { scrollOffset int selected string cancelled bool + helpText string + width int } func (m selectorModel) filteredItems() []SelectItem { @@ -89,83 +100,153 @@ func (m selectorModel) Init() tea.Cmd { return nil } +// otherStart returns the index of the first non-recommended item in the filtered list. +// When filtering, all items scroll together so this returns 0. +func (m selectorModel) otherStart() int { + if m.filter != "" { + return 0 + } + filtered := m.filteredItems() + for i, item := range filtered { + if !item.Recommended { + return i + } + } + return len(filtered) +} + +// updateNavigation handles navigation keys (up/down/pgup/pgdown/filter/backspace). +// It does NOT handle Enter, Esc, or CtrlC. This is used by both the standalone +// selector and the TUI modal (which intercepts Enter/Esc for its own logic). +func (m *selectorModel) updateNavigation(msg tea.KeyMsg) { + filtered := m.filteredItems() + otherStart := m.otherStart() + + switch msg.Type { + case tea.KeyUp: + if m.cursor > 0 { + m.cursor-- + m.updateScroll(otherStart) + } + + case tea.KeyDown: + if m.cursor < len(filtered)-1 { + m.cursor++ + m.updateScroll(otherStart) + } + + case tea.KeyPgUp: + m.cursor -= maxSelectorItems + if m.cursor < 0 { + m.cursor = 0 + } + m.updateScroll(otherStart) + + case tea.KeyPgDown: + m.cursor += maxSelectorItems + if m.cursor >= len(filtered) { + m.cursor = len(filtered) - 1 + } + m.updateScroll(otherStart) + + case tea.KeyBackspace: + if len(m.filter) > 0 { + m.filter = m.filter[:len(m.filter)-1] + m.cursor = 0 + m.scrollOffset = 0 + } + + case tea.KeyRunes: + m.filter += string(msg.Runes) + m.cursor = 0 + m.scrollOffset = 0 + } +} + +// updateScroll adjusts scrollOffset based on cursor position. +// When not filtering, scrollOffset is relative to the "More" (non-recommended) section. +// When filtering, it's relative to the full filtered list. +func (m *selectorModel) 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 + } + + // Cursor is in recommended section — reset "More" scroll to top + if m.cursor < otherStart { + m.scrollOffset = 0 + return + } + + // Cursor is in "More" section — scroll relative to others + 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 selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: - filtered := m.filteredItems() + case tea.WindowSizeMsg: + wasSet := m.width > 0 + m.width = msg.Width + if wasSet { + return m, tea.EnterAltScreen + } + return m, nil + case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc: m.cancelled = true return m, tea.Quit case tea.KeyEnter: + filtered := m.filteredItems() if len(filtered) > 0 && m.cursor < len(filtered) { m.selected = filtered[m.cursor].Name } return m, tea.Quit - case tea.KeyUp: - if m.cursor > 0 { - m.cursor-- - if m.cursor < m.scrollOffset { - m.scrollOffset = m.cursor - } - } - - case tea.KeyDown: - if m.cursor < len(filtered)-1 { - m.cursor++ - if m.cursor >= m.scrollOffset+maxSelectorItems { - m.scrollOffset = m.cursor - maxSelectorItems + 1 - } - } - - case tea.KeyPgUp: - m.cursor -= maxSelectorItems - if m.cursor < 0 { - m.cursor = 0 - } - m.scrollOffset -= maxSelectorItems - if m.scrollOffset < 0 { - m.scrollOffset = 0 - } - - 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 - } - - case tea.KeyBackspace: - if len(m.filter) > 0 { - m.filter = m.filter[:len(m.filter)-1] - m.cursor = 0 - m.scrollOffset = 0 - } - - case tea.KeyRunes: - m.filter += string(msg.Runes) - m.cursor = 0 - m.scrollOffset = 0 + default: + m.updateNavigation(msg) } } return m, nil } -func (m selectorModel) View() string { - // Clear screen when exiting - if m.cancelled || m.selected != "" { - return "" +func (m selectorModel) renderItem(s *strings.Builder, item SelectItem, idx int) { + if idx == m.cursor { + s.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name)) + } else { + s.WriteString(selectorItemStyle.Render(item.Name)) } + s.WriteString("\n") + if item.Description != "" { + s.WriteString(selectorDescLineStyle.Render(item.Description)) + s.WriteString("\n") + } +} +// renderContent renders the selector content (title, items, help text) without +// checking the cancelled/selected state. This is used by both View() (standalone mode) +// and by the TUI modal which embeds a selectorModel. +func (m selectorModel) renderContent() string { var s strings.Builder - // Title with filter s.WriteString(selectorTitleStyle.Render(m.title)) s.WriteString(" ") if m.filter == "" { @@ -180,42 +261,91 @@ func (m selectorModel) View() string { if len(filtered) == 0 { s.WriteString(selectorItemStyle.Render(selectorDescStyle.Render("(no matches)"))) s.WriteString("\n") - } else { - displayCount := min(len(filtered), maxSelectorItems) + } else if m.filter != "" { + s.WriteString(sectionHeaderStyle.Render("Top Results")) + s.WriteString("\n") + displayCount := min(len(filtered), maxSelectorItems) for i := range displayCount { idx := m.scrollOffset + i if idx >= len(filtered) { break } - item := filtered[idx] - - if idx == m.cursor { - s.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name)) - } else { - s.WriteString(selectorItemStyle.Render(item.Name)) - } - - if item.Description != "" { - s.WriteString(" ") - s.WriteString(selectorDescStyle.Render("- " + item.Description)) - } - s.WriteString("\n") + m.renderItem(&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 + 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.renderItem(&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.renderItem(&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(selectorHelpStyle.Render("↑/↓ navigate • enter select • esc cancel")) + help := "↑/↓ navigate • enter select • esc cancel" + if m.helpText != "" { + help = m.helpText + } + s.WriteString(selectorHelpStyle.Render(help)) return s.String() } -// SelectSingle prompts the user to select a single item from a list. +func (m selectorModel) View() string { + if m.cancelled || m.selected != "" { + return "" + } + + s := m.renderContent() + if m.width > 0 { + return lipgloss.NewStyle().MaxWidth(m.width).Render(s) + } + return s +} + func SelectSingle(title string, items []SelectItem) (string, error) { if len(items) == 0 { return "", fmt.Errorf("no items to select from") @@ -252,6 +382,7 @@ type multiSelectorModel struct { checkOrder []int cancelled bool confirmed bool + width int } func newMultiSelectorModel(title string, items []SelectItem, preChecked []string) multiSelectorModel { @@ -323,6 +454,14 @@ func (m multiSelectorModel) Init() tea.Cmd { func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + wasSet := m.width > 0 + m.width = msg.Width + if wasSet { + return m, tea.EnterAltScreen + } + return m, nil + case tea.KeyMsg: filtered := m.filteredItems() @@ -332,14 +471,12 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyEnter: - // Enter confirms if at least one item is selected if len(m.checkOrder) > 0 { m.confirmed = true return m, tea.Quit } case tea.KeySpace: - // Space always toggles selection m.toggleItem() case tea.KeyUp: @@ -395,14 +532,12 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m multiSelectorModel) View() string { - // Clear screen when exiting if m.cancelled || m.confirmed { return "" } var s strings.Builder - // Title with filter s.WriteString(selectorTitleStyle.Render(m.title)) s.WriteString(" ") if m.filter == "" { @@ -419,6 +554,8 @@ func (m multiSelectorModel) View() string { s.WriteString("\n") } else { displayCount := min(len(filtered), maxSelectorItems) + shownRecHeader := false + prevWasRec := false for i := range displayCount { idx := m.scrollOffset + i @@ -428,7 +565,17 @@ func (m multiSelectorModel) View() string { item := filtered[idx] origIdx := m.itemIndex[item.Name] - // Checkbox + 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]") @@ -436,7 +583,6 @@ func (m multiSelectorModel) View() string { checkbox = selectorCheckboxStyle.Render("[ ]") } - // Cursor and name var line string if idx == m.cursor { line = selectorSelectedItemStyle.Render("▸ ") + checkbox + " " + selectorSelectedItemStyle.Render(item.Name) @@ -444,7 +590,6 @@ func (m multiSelectorModel) View() string { line = " " + checkbox + " " + item.Name } - // Default tag if len(m.checkOrder) > 0 && m.checkOrder[0] == origIdx { line += " " + selectorDefaultTagStyle.Render("(default)") } @@ -461,7 +606,6 @@ func (m multiSelectorModel) View() string { s.WriteString("\n") - // Status line count := m.selectedCount() if count == 0 { s.WriteString(selectorDescStyle.Render(" Select at least one model.")) @@ -472,10 +616,13 @@ func (m multiSelectorModel) View() string { s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • enter confirm • esc cancel")) - return s.String() + result := s.String() + if m.width > 0 { + return lipgloss.NewStyle().MaxWidth(m.width).Render(result) + } + return result } -// SelectMultiple prompts the user to select multiple items from a list. func SelectMultiple(title string, items []SelectItem, preChecked []string) ([]string, error) { if len(items) == 0 { return nil, fmt.Errorf("no items to select from") diff --git a/cmd/tui/selector_test.go b/cmd/tui/selector_test.go new file mode 100644 index 000000000..f03bf4cdb --- /dev/null +++ b/cmd/tui/selector_test.go @@ -0,0 +1,410 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func items(names ...string) []SelectItem { + var out []SelectItem + for _, n := range names { + out = append(out, SelectItem{Name: n}) + } + return out +} + +func recItems(names ...string) []SelectItem { + var out []SelectItem + for _, n := range names { + out = append(out, SelectItem{Name: n, Recommended: true}) + } + return out +} + +func mixedItems() []SelectItem { + return []SelectItem{ + {Name: "rec-a", Recommended: true}, + {Name: "rec-b", Recommended: true}, + {Name: "other-1"}, + {Name: "other-2"}, + {Name: "other-3"}, + {Name: "other-4"}, + {Name: "other-5"}, + {Name: "other-6"}, + {Name: "other-7"}, + {Name: "other-8"}, + {Name: "other-9"}, + {Name: "other-10"}, + } +} + +func TestFilteredItems(t *testing.T) { + tests := []struct { + name string + items []SelectItem + filter string + want []string + }{ + { + name: "no filter returns all", + items: items("alpha", "beta", "gamma"), + filter: "", + want: []string{"alpha", "beta", "gamma"}, + }, + { + name: "filter matches substring", + items: items("llama3.2", "qwen3:8b", "llama2"), + filter: "llama", + want: []string{"llama3.2", "llama2"}, + }, + { + name: "filter is case insensitive", + items: items("Qwen3:8b", "llama3.2"), + filter: "QWEN", + want: []string{"Qwen3:8b"}, + }, + { + name: "no matches", + items: items("alpha", "beta"), + filter: "zzz", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := selectorModel{items: tt.items, filter: tt.filter} + got := m.filteredItems() + var gotNames []string + for _, item := range got { + gotNames = append(gotNames, item.Name) + } + if len(gotNames) != len(tt.want) { + t.Fatalf("got %v, want %v", gotNames, tt.want) + } + for i := range tt.want { + if gotNames[i] != tt.want[i] { + t.Errorf("index %d: got %q, want %q", i, gotNames[i], tt.want[i]) + } + } + }) + } +} + +func TestOtherStart(t *testing.T) { + tests := []struct { + name string + items []SelectItem + filter string + want int + }{ + { + name: "all recommended", + items: recItems("a", "b", "c"), + want: 3, + }, + { + name: "none recommended", + items: items("a", "b"), + want: 0, + }, + { + name: "mixed", + items: []SelectItem{ + {Name: "rec-a", Recommended: true}, + {Name: "rec-b", Recommended: true}, + {Name: "other-1"}, + {Name: "other-2"}, + }, + want: 2, + }, + { + name: "empty", + items: nil, + want: 0, + }, + { + name: "filtering returns 0", + items: []SelectItem{ + {Name: "rec-a", Recommended: true}, + {Name: "other-1"}, + }, + filter: "rec", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := selectorModel{items: tt.items, filter: tt.filter} + if got := m.otherStart(); got != tt.want { + t.Errorf("otherStart() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestUpdateScroll(t *testing.T) { + tests := []struct { + name string + cursor int + offset int + otherStart int + filter string + wantOffset int + }{ + { + name: "cursor in recommended resets scroll", + cursor: 1, + offset: 5, + otherStart: 3, + wantOffset: 0, + }, + { + name: "cursor at start of others", + cursor: 2, + offset: 0, + otherStart: 2, + wantOffset: 0, + }, + { + name: "cursor scrolls down in others", + cursor: 12, + offset: 0, + otherStart: 2, + wantOffset: 3, // posInOthers=10, maxOthers=8, 10-8+1=3 + }, + { + name: "cursor scrolls up in others", + cursor: 4, + offset: 5, + otherStart: 2, + wantOffset: 2, // posInOthers=2 < offset=5 + }, + { + name: "filter mode standard scroll down", + cursor: 12, + offset: 0, + filter: "x", + otherStart: 0, + wantOffset: 3, // 12 - 10 + 1 = 3 + }, + { + name: "filter mode standard scroll up", + cursor: 2, + offset: 5, + filter: "x", + otherStart: 0, + wantOffset: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := selectorModel{ + cursor: tt.cursor, + scrollOffset: tt.offset, + filter: tt.filter, + } + m.updateScroll(tt.otherStart) + if m.scrollOffset != tt.wantOffset { + t.Errorf("scrollOffset = %d, want %d", m.scrollOffset, tt.wantOffset) + } + }) + } +} + +func TestRenderContent_SectionHeaders(t *testing.T) { + m := selectorModel{ + title: "Pick:", + items: []SelectItem{ + {Name: "rec-a", Recommended: true}, + {Name: "other-1"}, + }, + } + content := m.renderContent() + + if !strings.Contains(content, "Recommended") { + t.Error("should contain 'Recommended' header") + } + if !strings.Contains(content, "More") { + t.Error("should contain 'More' header") + } +} + +func TestRenderContent_FilteredHeader(t *testing.T) { + m := selectorModel{ + title: "Pick:", + items: items("alpha", "beta", "alphabet"), + filter: "alpha", + } + content := m.renderContent() + + if !strings.Contains(content, "Top Results") { + t.Error("filtered view should contain 'Top Results' header") + } + if strings.Contains(content, "Recommended") { + t.Error("filtered view should not contain 'Recommended' header") + } +} + +func TestRenderContent_NoMatches(t *testing.T) { + m := selectorModel{ + title: "Pick:", + items: items("alpha"), + filter: "zzz", + } + content := m.renderContent() + + if !strings.Contains(content, "(no matches)") { + t.Error("should show '(no matches)' when filter has no results") + } +} + +func TestRenderContent_SelectedItemIndicator(t *testing.T) { + m := selectorModel{ + title: "Pick:", + items: items("alpha", "beta"), + cursor: 0, + } + content := m.renderContent() + + if !strings.Contains(content, "▸") { + t.Error("selected item should have ▸ indicator") + } +} + +func TestRenderContent_Description(t *testing.T) { + m := selectorModel{ + title: "Pick:", + items: []SelectItem{ + {Name: "alpha", Description: "the first letter"}, + }, + } + content := m.renderContent() + + if !strings.Contains(content, "the first letter") { + t.Error("should render item description") + } +} + +func TestRenderContent_PinnedRecommended(t *testing.T) { + m := selectorModel{ + title: "Pick:", + items: mixedItems(), + // cursor deep in "More" section + cursor: 8, + scrollOffset: 3, + } + content := m.renderContent() + + // Recommended items should always be visible (pinned) + if !strings.Contains(content, "rec-a") { + t.Error("recommended items should always be rendered (pinned)") + } + if !strings.Contains(content, "rec-b") { + t.Error("recommended items should always be rendered (pinned)") + } +} + +func TestRenderContent_MoreOverflowIndicator(t *testing.T) { + m := selectorModel{ + title: "Pick:", + items: mixedItems(), // 2 rec + 10 other = 12 total, maxSelectorItems=10 + } + content := m.renderContent() + + if !strings.Contains(content, "... and") { + t.Error("should show overflow indicator when more items than visible") + } +} + +func TestUpdateNavigation_CursorBounds(t *testing.T) { + m := selectorModel{ + items: items("a", "b", "c"), + cursor: 0, + } + + // Up at top stays at 0 + m.updateNavigation(keyMsg(KeyUp)) + if m.cursor != 0 { + t.Errorf("cursor should stay at 0 when pressing up at top, got %d", m.cursor) + } + + // Down moves to 1 + m.updateNavigation(keyMsg(KeyDown)) + if m.cursor != 1 { + t.Errorf("cursor should be 1 after down, got %d", m.cursor) + } + + // Down to end + m.updateNavigation(keyMsg(KeyDown)) + m.updateNavigation(keyMsg(KeyDown)) + if m.cursor != 2 { + t.Errorf("cursor should be 2 at bottom, got %d", m.cursor) + } +} + +func TestUpdateNavigation_FilterResetsState(t *testing.T) { + m := selectorModel{ + items: items("alpha", "beta"), + cursor: 1, + scrollOffset: 5, + } + + m.updateNavigation(runeMsg('x')) + if m.filter != "x" { + t.Errorf("filter should be 'x', got %q", m.filter) + } + if m.cursor != 0 { + t.Errorf("cursor should reset to 0 on filter, got %d", m.cursor) + } + if m.scrollOffset != 0 { + t.Errorf("scrollOffset should reset to 0 on filter, got %d", m.scrollOffset) + } +} + +func TestUpdateNavigation_Backspace(t *testing.T) { + m := selectorModel{ + items: items("alpha"), + filter: "abc", + cursor: 1, + } + + m.updateNavigation(keyMsg(KeyBackspace)) + if m.filter != "ab" { + t.Errorf("filter should be 'ab' after backspace, got %q", m.filter) + } + if m.cursor != 0 { + t.Errorf("cursor should reset to 0 on backspace, got %d", m.cursor) + } +} + +// Key message helpers for testing + +type keyType = int + +const ( + KeyUp keyType = iota + KeyDown keyType = iota + KeyBackspace keyType = iota +) + +func keyMsg(k keyType) tea.KeyMsg { + switch k { + case KeyUp: + return tea.KeyMsg{Type: tea.KeyUp} + case KeyDown: + return tea.KeyMsg{Type: tea.KeyDown} + case KeyBackspace: + return tea.KeyMsg{Type: tea.KeyBackspace} + default: + return tea.KeyMsg{} + } +} + +func runeMsg(r rune) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} +} diff --git a/cmd/tui/signin.go b/cmd/tui/signin.go new file mode 100644 index 000000000..118dbdf1c --- /dev/null +++ b/cmd/tui/signin.go @@ -0,0 +1,128 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ollama/ollama/cmd/config" +) + +type signInModel struct { + modelName string + signInURL string + spinner int + width int + userName string + cancelled bool +} + +func (m signInModel) Init() tea.Cmd { + return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { + return signInTickMsg{} + }) +} + +func (m signInModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + wasSet := m.width > 0 + m.width = msg.Width + if wasSet { + return m, tea.EnterAltScreen + } + return m, nil + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + return m, tea.Quit + } + + case signInTickMsg: + m.spinner++ + if m.spinner%5 == 0 { + return m, tea.Batch( + tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { + return signInTickMsg{} + }), + checkSignIn, + ) + } + return m, tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { + return signInTickMsg{} + }) + + case signInCheckMsg: + if msg.signedIn { + m.userName = msg.userName + return m, tea.Quit + } + } + + return m, nil +} + +func (m signInModel) View() string { + if m.userName != "" { + return "" + } + return renderSignIn(m.modelName, m.signInURL, m.spinner, m.width) +} + +func renderSignIn(modelName, signInURL string, spinner, width int) string { + spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + frame := spinnerFrames[spinner%len(spinnerFrames)] + + urlColor := lipgloss.NewStyle(). + Foreground(lipgloss.Color("117")) + urlWrap := lipgloss.NewStyle().PaddingLeft(2) + if width > 4 { + urlWrap = urlWrap.Width(width - 4) + } + + var s strings.Builder + + fmt.Fprintf(&s, "To use %s, please sign in.\n\n", selectorSelectedItemStyle.Render(modelName)) + + // Wrap in OSC 8 hyperlink so the entire URL is clickable even when wrapped. + // Padding is outside the hyperlink so spaces don't get underlined. + link := fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", signInURL, urlColor.Render(signInURL)) + s.WriteString("Navigate to:\n") + s.WriteString(urlWrap.Render(link)) + s.WriteString("\n\n") + + s.WriteString(lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}).Render( + frame + " Waiting for sign in to complete...")) + s.WriteString("\n\n") + + s.WriteString(selectorHelpStyle.Render("esc cancel")) + + return lipgloss.NewStyle().PaddingLeft(2).Render(s.String()) +} + +// RunSignIn shows a bubbletea sign-in dialog and polls until the user signs in or cancels. +func RunSignIn(modelName, signInURL string) (string, error) { + config.OpenBrowser(signInURL) + + m := signInModel{ + modelName: modelName, + signInURL: signInURL, + } + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("error running sign-in: %w", err) + } + + fm := finalModel.(signInModel) + if fm.cancelled { + return "", ErrCancelled + } + + return fm.userName, nil +} diff --git a/cmd/tui/signin_test.go b/cmd/tui/signin_test.go new file mode 100644 index 000000000..0af9ddc6e --- /dev/null +++ b/cmd/tui/signin_test.go @@ -0,0 +1,175 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestRenderSignIn_ContainsModelName(t *testing.T) { + got := renderSignIn("glm-4.7:cloud", "https://example.com/signin", 0, 80) + if !strings.Contains(got, "glm-4.7:cloud") { + t.Error("should contain model name") + } + if !strings.Contains(got, "please sign in") { + t.Error("should contain sign-in prompt") + } +} + +func TestRenderSignIn_ContainsURL(t *testing.T) { + url := "https://ollama.com/connect?key=abc123" + got := renderSignIn("test:cloud", url, 0, 120) + if !strings.Contains(got, url) { + t.Errorf("should contain URL %q", url) + } +} + +func TestRenderSignIn_OSC8Hyperlink(t *testing.T) { + url := "https://ollama.com/connect?key=abc123" + got := renderSignIn("test:cloud", url, 0, 120) + + // Should contain OSC 8 open sequence with the URL + osc8Open := "\033]8;;" + url + "\033\\" + if !strings.Contains(got, osc8Open) { + t.Error("should contain OSC 8 open sequence with URL") + } + + // Should contain OSC 8 close sequence + osc8Close := "\033]8;;\033\\" + if !strings.Contains(got, osc8Close) { + t.Error("should contain OSC 8 close sequence") + } +} + +func TestRenderSignIn_ContainsSpinner(t *testing.T) { + got := renderSignIn("test:cloud", "https://example.com", 0, 80) + if !strings.Contains(got, "Waiting for sign in to complete") { + t.Error("should contain waiting message") + } + if !strings.Contains(got, "⠋") { + t.Error("should contain first spinner frame at spinner=0") + } +} + +func TestRenderSignIn_SpinnerAdvances(t *testing.T) { + got0 := renderSignIn("test:cloud", "https://example.com", 0, 80) + got1 := renderSignIn("test:cloud", "https://example.com", 1, 80) + if got0 == got1 { + t.Error("different spinner values should produce different output") + } +} + +func TestRenderSignIn_ContainsEscHelp(t *testing.T) { + got := renderSignIn("test:cloud", "https://example.com", 0, 80) + if !strings.Contains(got, "esc cancel") { + t.Error("should contain esc cancel help text") + } +} + +func TestSignInModel_EscCancels(t *testing.T) { + m := signInModel{ + modelName: "test:cloud", + signInURL: "https://example.com", + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + fm := updated.(signInModel) + if !fm.cancelled { + t.Error("esc should set cancelled=true") + } + if cmd == nil { + t.Error("esc should return tea.Quit") + } +} + +func TestSignInModel_CtrlCCancels(t *testing.T) { + m := signInModel{ + modelName: "test:cloud", + signInURL: "https://example.com", + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + fm := updated.(signInModel) + if !fm.cancelled { + t.Error("ctrl+c should set cancelled=true") + } + if cmd == nil { + t.Error("ctrl+c should return tea.Quit") + } +} + +func TestSignInModel_SignedInQuitsClean(t *testing.T) { + m := signInModel{ + modelName: "test:cloud", + signInURL: "https://example.com", + } + + updated, cmd := m.Update(signInCheckMsg{signedIn: true, userName: "alice"}) + fm := updated.(signInModel) + if fm.userName != "alice" { + t.Errorf("expected userName 'alice', got %q", fm.userName) + } + if cmd == nil { + t.Error("successful sign-in should return tea.Quit") + } +} + +func TestSignInModel_SignedInViewClears(t *testing.T) { + m := signInModel{ + modelName: "test:cloud", + signInURL: "https://example.com", + userName: "alice", + } + + got := m.View() + if got != "" { + t.Errorf("View should return empty string after sign-in, got %q", got) + } +} + +func TestSignInModel_NotSignedInContinues(t *testing.T) { + m := signInModel{ + modelName: "test:cloud", + signInURL: "https://example.com", + } + + updated, _ := m.Update(signInCheckMsg{signedIn: false}) + fm := updated.(signInModel) + if fm.userName != "" { + t.Error("should not set userName when not signed in") + } + if fm.cancelled { + t.Error("should not cancel when check returns not signed in") + } +} + +func TestSignInModel_WindowSizeUpdatesWidth(t *testing.T) { + m := signInModel{ + modelName: "test:cloud", + signInURL: "https://example.com", + } + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + fm := updated.(signInModel) + if fm.width != 120 { + t.Errorf("expected width 120, got %d", fm.width) + } +} + +func TestSignInModel_TickAdvancesSpinner(t *testing.T) { + m := signInModel{ + modelName: "test:cloud", + signInURL: "https://example.com", + spinner: 0, + } + + updated, cmd := m.Update(signInTickMsg{}) + fm := updated.(signInModel) + if fm.spinner != 1 { + t.Errorf("expected spinner=1, got %d", fm.spinner) + } + if cmd == nil { + t.Error("tick should return a command") + } +} diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 41e1b630a..1a1020eed 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "os/exec" - "runtime" "strings" "time" @@ -17,37 +15,30 @@ import ( ) var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - MarginBottom(1) - versionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) + Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "250"}) - itemStyle = lipgloss.NewStyle(). + menuItemStyle = lipgloss.NewStyle(). PaddingLeft(2) - selectedStyle = lipgloss.NewStyle(). - PaddingLeft(2). - Bold(true) + menuSelectedItemStyle = lipgloss.NewStyle(). + Bold(true). + Background(lipgloss.AdaptiveColor{Light: "254", Dark: "236"}) - greyedStyle = lipgloss.NewStyle(). - PaddingLeft(2). - Foreground(lipgloss.Color("241")) + menuDescStyle = selectorDescStyle. + PaddingLeft(4) - greyedSelectedStyle = lipgloss.NewStyle(). - PaddingLeft(2). - Foreground(lipgloss.Color("243")) + greyedStyle = menuItemStyle. + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) - descStyle = lipgloss.NewStyle(). - PaddingLeft(4). - Foreground(lipgloss.Color("241")) + greyedSelectedStyle = menuSelectedItemStyle. + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) modelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) + Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "250"}) notInstalledStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}). Italic(true) ) @@ -55,94 +46,102 @@ type menuItem struct { title string description string integration string // integration name for loading model config, empty if not an integration - isRunModel bool // true for the "Run a model" option - isOthers bool // true for the "Others..." toggle item + isRunModel bool + isOthers bool } var mainMenuItems = []menuItem{ { title: "Run a model", - description: "Start an interactive chat with a local model", + description: "Start an interactive chat with a model", isRunModel: true, }, { title: "Launch Claude Code", - description: "Open Claude Code AI assistant", + description: "Agentic coding across large codebases", integration: "claude", }, { title: "Launch Codex", - description: "Open Codex CLI", + description: "OpenAI's open-source coding agent", integration: "codex", }, { - title: "Launch Open Claw", - description: "Open the Open Claw integration", + title: "Launch OpenClaw", + description: "Personal AI with 100+ skills", integration: "openclaw", }, } var othersMenuItem = menuItem{ - title: "Others...", + title: "More...", description: "Show additional integrations", isOthers: true, } -// getOtherIntegrations returns the list of other integrations, filtering out -// Codex if it's not installed (since it requires npm install). +// getOtherIntegrations dynamically builds the "Others" list from the integration +// registry, excluding any integrations already present in the pinned mainMenuItems. func getOtherIntegrations() []menuItem { - return []menuItem{ - { - title: "Launch Droid", - description: "Open Droid integration", - integration: "droid", - }, - { - title: "Launch Open Code", - description: "Open Open Code integration", - integration: "opencode", - }, - { - title: "Launch Pi", - description: "Open Pi coding agent", - integration: "pi", - }, + pinned := map[string]bool{ + "run": true, // not an integration but in the pinned list } + for _, item := range mainMenuItems { + if item.integration != "" { + pinned[item.integration] = true + } + } + + var others []menuItem + for _, info := range config.ListIntegrationInfos() { + if pinned[info.Name] { + continue + } + desc := info.Description + if desc == "" { + desc = "Open " + info.DisplayName + " integration" + } + others = append(others, menuItem{ + title: "Launch " + info.DisplayName, + description: desc, + integration: info.Name, + }) + } + return others } type model struct { items []menuItem cursor int quitting bool - selected bool // true if user made a selection (enter/space) - changeModel bool // true if user pressed right arrow to change model - showOthers bool // true if "Others..." is expanded - availableModels map[string]bool // cache of available model names + selected bool + changeModel bool + showOthers bool + availableModels map[string]bool err error - // Modal state - showingModal bool // true when model picker modal is visible - modalSelector selectorModel // the selector model for the modal - modalItems []SelectItem // cached items for the modal + showingModal bool + modalSelector selectorModel + modalItems []SelectItem - // Sign-in dialog state - showingSignIn bool // true when sign-in dialog is visible - signInURL string // URL for sign-in - signInModel string // model that requires sign-in - signInSpinner int // spinner frame index + showingSignIn bool + signInURL string + signInModel string + signInSpinner int signInFromModal bool // true if sign-in was triggered from modal (not main menu) + + width int // terminal width from WindowSizeMsg + statusMsg string // temporary status message shown near help text } -// signInTickMsg is sent to animate the sign-in spinner type signInTickMsg struct{} -// signInCheckMsg is sent to check if sign-in is complete type signInCheckMsg struct { signedIn bool userName string } -// modelExists checks if a model exists in the cached available models. +type clearStatusMsg struct{} + func (m *model) modelExists(name string) bool { if m.availableModels == nil || name == "" { return false @@ -159,27 +158,25 @@ func (m *model) modelExists(name string) bool { return false } -// buildModalItems creates the list of models for the modal selector. 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}) + items = append(items, SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended}) } return items } -// openModelModal opens the model picker modal. func (m *model) openModelModal() { m.modalItems = m.buildModalItems() m.modalSelector = selectorModel{ - title: "Select model:", - items: m.modalItems, + title: "Select model:", + items: m.modalItems, + helpText: "↑/↓ navigate • enter select • ← back", } m.showingModal = true } -// isCloudModel returns true if the model name indicates a cloud model. func isCloudModel(name string) bool { return strings.HasSuffix(name, ":cloud") } @@ -196,7 +193,7 @@ func (m *model) checkCloudSignIn(modelName string, fromModal bool) tea.Cmd { } user, err := client.Whoami(context.Background()) if err == nil && user != nil && user.Name != "" { - return nil // Already signed in + return nil } var aErr api.AuthorizationError if errors.As(err, &aErr) && aErr.SigninURL != "" { @@ -215,23 +212,13 @@ func (m *model) startSignIn(modelName, signInURL string, fromModal bool) tea.Cmd m.signInSpinner = 0 m.signInFromModal = fromModal - // Open browser (best effort) - switch runtime.GOOS { - case "darwin": - _ = exec.Command("open", signInURL).Start() - case "linux": - _ = exec.Command("xdg-open", signInURL).Start() - case "windows": - _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", signInURL).Start() - } + config.OpenBrowser(signInURL) - // Start the spinner tick return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { return signInTickMsg{} }) } -// checkSignIn checks if the user has completed sign-in. func checkSignIn() tea.Msg { client, err := api.ClientFromEnvironment() if err != nil { @@ -244,7 +231,6 @@ func checkSignIn() tea.Msg { return signInCheckMsg{signedIn: false} } -// loadAvailableModels fetches and caches the list of available models. func (m *model) loadAvailableModels() { m.availableModels = make(map[string]bool) client, err := api.ClientFromEnvironment() @@ -266,24 +252,17 @@ func (m *model) buildItems() { m.items = append(m.items, mainMenuItems...) if m.showOthers { - // Change "Others..." to "Hide others..." - hideItem := menuItem{ - title: "Hide others...", - description: "Hide additional integrations", - isOthers: true, - } - m.items = append(m.items, hideItem) m.items = append(m.items, others...) } else { m.items = append(m.items, othersMenuItem) } } -// isOthersIntegration returns true if the integration is in the "Others" menu func isOthersIntegration(name string) bool { - switch name { - case "droid", "opencode": - return true + for _, item := range getOtherIntegrations() { + if item.integration == name { + return true + } } return false } @@ -294,7 +273,6 @@ func initialModel() model { } m.loadAvailableModels() - // Check last selection to determine if we need to expand "Others" lastSelection := config.LastSelection() if isOthersIntegration(lastSelection) { m.showOthers = true @@ -302,7 +280,6 @@ func initialModel() model { m.buildItems() - // Position cursor on last selection if lastSelection != "" { for i, item := range m.items { if lastSelection == "run" && item.isRunModel { @@ -323,18 +300,29 @@ func (m model) Init() tea.Cmd { } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Handle sign-in dialog + if wmsg, ok := msg.(tea.WindowSizeMsg); ok { + wasSet := m.width > 0 + m.width = wmsg.Width + if wasSet { + return m, tea.EnterAltScreen + } + return m, nil + } + + if _, ok := msg.(clearStatusMsg); ok { + m.statusMsg = "" + return m, nil + } + if m.showingSignIn { switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc: - // Cancel sign-in and go back m.showingSignIn = false if m.signInFromModal { m.showingModal = true } - // If from main menu, just return to main menu (default state) return m, nil } @@ -355,13 +343,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case signInCheckMsg: if msg.signedIn { - // Sign-in complete - proceed with selection if m.signInFromModal { - // Came from modal - set changeModel m.modalSelector.selected = m.signInModel m.changeModel = true } else { - // Came from main menu - just select m.selected = true } m.quitting = true @@ -371,13 +356,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - // Handle modal input if modal is showing if m.showingModal { switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - // Close modal without selection + case tea.KeyCtrlC, tea.KeyEsc, tea.KeyLeft: m.showingModal = false return m, nil @@ -390,63 +373,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.checkCloudSignIn(m.modalSelector.selected, true); cmd != nil { return m, cmd } - // Selection made - exit with changeModel m.changeModel = true m.quitting = true return m, tea.Quit } return m, nil - case tea.KeyUp: - if m.modalSelector.cursor > 0 { - m.modalSelector.cursor-- - if m.modalSelector.cursor < m.modalSelector.scrollOffset { - m.modalSelector.scrollOffset = m.modalSelector.cursor - } - } - - case tea.KeyDown: - filtered := m.modalSelector.filteredItems() - if m.modalSelector.cursor < len(filtered)-1 { - m.modalSelector.cursor++ - if m.modalSelector.cursor >= m.modalSelector.scrollOffset+maxSelectorItems { - m.modalSelector.scrollOffset = m.modalSelector.cursor - maxSelectorItems + 1 - } - } - - case tea.KeyPgUp: - filtered := m.modalSelector.filteredItems() - m.modalSelector.cursor -= maxSelectorItems - if m.modalSelector.cursor < 0 { - m.modalSelector.cursor = 0 - } - m.modalSelector.scrollOffset -= maxSelectorItems - if m.modalSelector.scrollOffset < 0 { - m.modalSelector.scrollOffset = 0 - } - _ = filtered // suppress unused warning - - case tea.KeyPgDown: - filtered := m.modalSelector.filteredItems() - m.modalSelector.cursor += maxSelectorItems - if m.modalSelector.cursor >= len(filtered) { - m.modalSelector.cursor = len(filtered) - 1 - } - if m.modalSelector.cursor >= m.modalSelector.scrollOffset+maxSelectorItems { - m.modalSelector.scrollOffset = m.modalSelector.cursor - maxSelectorItems + 1 - } - - case tea.KeyBackspace: - if len(m.modalSelector.filter) > 0 { - m.modalSelector.filter = m.modalSelector.filter[:len(m.modalSelector.filter)-1] - m.modalSelector.cursor = 0 - m.modalSelector.scrollOffset = 0 - } - - case tea.KeyRunes: - m.modalSelector.filter += string(msg.Runes) - m.modalSelector.cursor = 0 - m.modalSelector.scrollOffset = 0 + default: + // Delegate navigation (up/down/pgup/pgdown/filter/backspace) to selectorModel + m.modalSelector.updateNavigation(msg) } } return m, nil @@ -463,32 +398,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } + // Auto-collapse "Others" when cursor moves back into pinned items + if m.showOthers && m.cursor < len(mainMenuItems) { + m.showOthers = false + m.buildItems() + } case "down", "j": if m.cursor < len(m.items)-1 { m.cursor++ } + // Auto-expand "Others..." when cursor lands on it + if m.cursor < len(m.items) && m.items[m.cursor].isOthers && !m.showOthers { + m.showOthers = true + m.buildItems() + // cursor now points at the first "other" integration + } case "enter", " ": item := m.items[m.cursor] - // Handle "Others..." toggle - if item.isOthers { - m.showOthers = !m.showOthers - m.buildItems() - // Keep cursor on the Others/Hide item - if m.cursor >= len(m.items) { - m.cursor = len(m.items) - 1 - } - return m, nil - } - - // Don't allow selecting uninstalled integrations if item.integration != "" && !config.IsIntegrationInstalled(item.integration) { return m, nil } - // Check if a cloud model is configured and needs sign-in var configuredModel string if item.isRunModel { configuredModel = config.LastModel() @@ -504,10 +437,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "right", "l": - // Allow model change for integrations and run model item := m.items[m.cursor] if item.integration != "" || item.isRunModel { - // Don't allow for uninstalled integrations if item.integration != "" && !config.IsIntegrationInstalled(item.integration) { return m, nil } @@ -524,21 +455,19 @@ func (m model) View() string { return "" } - // Render sign-in dialog if showing if m.showingSignIn { return m.renderSignInDialog() } - // Render modal overlay if showing - replaces main view if m.showingModal { return m.renderModal() } - s := titleStyle.Render(" Ollama "+versionStyle.Render("v"+version.Version)) + "\n\n" + s := selectorTitleStyle.Render("Ollama "+versionStyle.Render(version.Version)) + "\n\n" for i, item := range m.items { - cursor := " " - style := itemStyle + cursor := "" + style := menuItemStyle isInstalled := true if item.integration != "" { @@ -548,7 +477,7 @@ func (m model) View() string { if m.cursor == i { cursor = "▸ " if isInstalled { - style = selectedStyle + style = menuSelectedItemStyle } else { style = greyedSelectedStyle } @@ -557,119 +486,62 @@ func (m model) View() string { } title := item.title + var modelSuffix string if item.integration != "" { if !isInstalled { title += " " + notInstalledStyle.Render("(not installed)") - } else if mdl := config.IntegrationModel(item.integration); mdl != "" && m.modelExists(mdl) { - title += " " + modelStyle.Render("("+mdl+")") + } else if m.cursor == i { + if mdl := config.IntegrationModel(item.integration); mdl != "" && m.modelExists(mdl) { + modelSuffix = " " + modelStyle.Render("("+mdl+")") + } } - } else if item.isRunModel { + } else if item.isRunModel && m.cursor == i { if mdl := config.LastModel(); mdl != "" && m.modelExists(mdl) { - title += " " + modelStyle.Render("("+mdl+")") + modelSuffix = " " + modelStyle.Render("("+mdl+")") } } - s += style.Render(cursor+title) + "\n" - s += descStyle.Render(item.description) + "\n\n" + s += style.Render(cursor+title) + modelSuffix + "\n" + + desc := item.description + if !isInstalled && item.integration != "" && m.cursor == i { + if hint := config.IntegrationInstallHint(item.integration); hint != "" { + desc = hint + } else { + desc = "not installed" + } + } + s += menuDescStyle.Render(desc) + "\n\n" } - s += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("↑/↓ navigate • enter select • → change model • esc quit") + if m.statusMsg != "" { + s += "\n" + lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "124", Dark: "210"}).Render(m.statusMsg) + "\n" + } + s += "\n" + selectorHelpStyle.Render("↑/↓ navigate • enter launch • → change model • esc quit") + + if m.width > 0 { + return lipgloss.NewStyle().MaxWidth(m.width).Render(s) + } return s } -// renderModal renders the model picker modal. func (m model) renderModal() string { modalStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("245")). - Padding(1, 2). - MarginLeft(2) + PaddingBottom(1). + PaddingRight(2) - var content strings.Builder - - // Title with filter - content.WriteString(selectorTitleStyle.Render(m.modalSelector.title)) - content.WriteString(" ") - if m.modalSelector.filter == "" { - content.WriteString(selectorFilterStyle.Render("Type to filter...")) - } else { - content.WriteString(selectorInputStyle.Render(m.modalSelector.filter)) + s := modalStyle.Render(m.modalSelector.renderContent()) + if m.width > 0 { + return lipgloss.NewStyle().MaxWidth(m.width).Render(s) } - content.WriteString("\n\n") - - filtered := m.modalSelector.filteredItems() - - if len(filtered) == 0 { - content.WriteString(selectorItemStyle.Render(selectorDescStyle.Render("(no matches)"))) - content.WriteString("\n") - } else { - displayCount := min(len(filtered), maxSelectorItems) - - for i := range displayCount { - idx := m.modalSelector.scrollOffset + i - if idx >= len(filtered) { - break - } - item := filtered[idx] - - if idx == m.modalSelector.cursor { - content.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name)) - } else { - content.WriteString(selectorItemStyle.Render(item.Name)) - } - - if item.Description != "" { - content.WriteString(" ") - content.WriteString(selectorDescStyle.Render("- " + item.Description)) - } - content.WriteString("\n") - } - - if remaining := len(filtered) - m.modalSelector.scrollOffset - displayCount; remaining > 0 { - content.WriteString(selectorMoreStyle.Render(fmt.Sprintf("... and %d more", remaining))) - content.WriteString("\n") - } - } - - content.WriteString("\n") - content.WriteString(selectorHelpStyle.Render("↑/↓ navigate • enter select • esc cancel")) - - return modalStyle.Render(content.String()) + return s } -// renderSignInDialog renders the sign-in dialog. func (m model) renderSignInDialog() string { - dialogStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("245")). - Padding(1, 2). - MarginLeft(2) - - spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - spinner := spinnerFrames[m.signInSpinner%len(spinnerFrames)] - - var content strings.Builder - - content.WriteString(selectorTitleStyle.Render("Sign in required")) - content.WriteString("\n\n") - - content.WriteString(fmt.Sprintf("To use %s, please sign in.\n\n", selectedStyle.Render(m.signInModel))) - - content.WriteString("Navigate to:\n") - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("117")).Render(" " + m.signInURL)) - content.WriteString("\n\n") - - content.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render( - fmt.Sprintf("%s Waiting for sign in to complete...", spinner))) - content.WriteString("\n\n") - - content.WriteString(selectorHelpStyle.Render("esc cancel")) - - return dialogStyle.Render(content.String()) + return renderSignIn(m.signInModel, m.signInURL, m.signInSpinner, m.width) } -// Selection represents what the user selected type Selection int const ( @@ -680,14 +552,12 @@ const ( SelectionChangeIntegration // Generic change model for integration ) -// Result contains the selection and any associated data type Result struct { Selection Selection Integration string // integration name if applicable Model string // model name if selected from modal } -// Run starts the TUI and returns the user's selection func Run() (Result, error) { m := initialModel() p := tea.NewProgram(m) @@ -702,14 +572,12 @@ func Run() (Result, error) { return Result{Selection: SelectionNone}, fm.err } - // User quit without selecting if !fm.selected && !fm.changeModel { return Result{Selection: SelectionNone}, nil } item := fm.items[fm.cursor] - // Handle model change request if fm.changeModel { if item.isRunModel { return Result{ @@ -724,7 +592,6 @@ func Run() (Result, error) { }, nil } - // Handle selection if item.isRunModel { return Result{Selection: SelectionRunModel}, nil }