diff --git a/cmd/background_unix.go b/cmd/background_unix.go new file mode 100644 index 000000000..a4eea48c5 --- /dev/null +++ b/cmd/background_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package cmd + +import "syscall" + +// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Unix. +// Setpgid prevents the server from being killed when the parent process exits. +func backgroundServerSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + Setpgid: true, + } +} diff --git a/cmd/background_windows.go b/cmd/background_windows.go new file mode 100644 index 000000000..fa43b7400 --- /dev/null +++ b/cmd/background_windows.go @@ -0,0 +1,12 @@ +package cmd + +import "syscall" + +// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Windows. +// CREATE_NO_WINDOW (0x08000000) prevents a console window from appearing. +func backgroundServerSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + CreationFlags: 0x08000000, + HideWindow: true, + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 5497d4e71..e88315be0 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -15,6 +15,7 @@ import ( "net" "net/http" "os" + "os/exec" "os/signal" "path/filepath" "runtime" @@ -37,6 +38,7 @@ import ( "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/tui" "github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/format" "github.com/ollama/ollama/parser" @@ -1804,6 +1806,190 @@ Environment Variables: cmd.SetUsageTemplate(cmd.UsageTemplate() + envUsage) } +// ensureServerRunning checks if the ollama server is running and starts it in the background if not. +func ensureServerRunning(ctx context.Context) error { + client, err := api.ClientFromEnvironment() + if err != nil { + return err + } + + // Check if server is already running + if err := client.Heartbeat(ctx); err == nil { + return nil // server is already running + } + + // Server not running, start it in the background + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("could not find executable: %w", err) + } + + serverCmd := exec.CommandContext(ctx, exe, "serve") + serverCmd.Env = os.Environ() + serverCmd.SysProcAttr = backgroundServerSysProcAttr() + if err := serverCmd.Start(); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + + // Wait for the server to be ready + for { + time.Sleep(500 * time.Millisecond) + if err := client.Heartbeat(ctx); err == nil { + return nil // server has started + } + } +} + +// runInteractiveTUI runs the main interactive TUI menu. +func runInteractiveTUI(cmd *cobra.Command) { + // Ensure the server is running before showing the TUI + if err := ensureServerRunning(cmd.Context()); err != nil { + fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) + 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} + } + result, err := tui.SelectSingle(title, tuiItems) + if errors.Is(err, tui.ErrCancelled) { + return "", errSelectionCancelled + } + return result, err + } + + 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} + } + result, err := tui.SelectMultiple(title, tuiItems, preChecked) + if errors.Is(err, tui.ErrCancelled) { + return nil, errSelectionCancelled + } + return result, err + } + + for { + result, err := tui.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return + } + + runModel := func(modelName string) { + _ = config.SetLastModel(modelName) + opts := runOptions{ + Model: modelName, + WordWrap: os.Getenv("TERM") == "xterm-256color", + Options: map[string]any{}, + ShowConnect: true, + } + if err := loadOrUnloadModel(cmd, &opts); err != nil { + fmt.Fprintf(os.Stderr, "Error loading model: %v\n", err) + return + } + if err := generateInteractive(cmd, opts); err != nil { + fmt.Fprintf(os.Stderr, "Error running model: %v\n", err) + } + } + + launchIntegration := func(name string) bool { + // If not configured or model no longer exists, prompt for model selection + configuredModel := config.IntegrationModel(name) + if configuredModel == "" || !config.ModelExists(cmd.Context(), configuredModel) { + err := config.ConfigureIntegrationWithSelectors(cmd.Context(), name, singleSelector, multiSelector) + if errors.Is(err, errSelectionCancelled) { + return false // Return to main menu + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error configuring %s: %v\n", name, err) + return true + } + } + if err := config.LaunchIntegration(name); err != nil { + fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", name, err) + } + return true + } + + switch result.Selection { + case tui.SelectionNone: + // User quit + 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) { + 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) { + continue // Return to main menu + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error selecting model: %v\n", err) + continue + } + runModel(modelName) + } + case tui.SelectionChangeRunModel: + _ = config.SetLastSelection("run") + // Use model from modal if selected, otherwise show picker + modelName := result.Model + if modelName == "" { + var err error + modelName, err = config.SelectModelWithSelector(cmd.Context(), singleSelector) + if errors.Is(err, errSelectionCancelled) { + continue // Return to main menu + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error selecting model: %v\n", err) + continue + } + } + runModel(modelName) + case tui.SelectionIntegration: + _ = config.SetLastSelection(result.Integration) + if !launchIntegration(result.Integration) { + continue // Return to main menu + } + case tui.SelectionChangeIntegration: + _ = config.SetLastSelection(result.Integration) + // Use model from modal if selected, otherwise show picker + if result.Model != "" { + // Model already selected from modal - save and launch + if err := config.SaveIntegrationModel(result.Integration, result.Model); err != nil { + fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err) + continue + } + if err := config.LaunchIntegrationWithModel(result.Integration, result.Model); err != nil { + fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", result.Integration, err) + } + } else { + err := config.ConfigureIntegrationWithSelectors(cmd.Context(), result.Integration, singleSelector, multiSelector) + if errors.Is(err, errSelectionCancelled) { + continue // Return to main menu + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error configuring %s: %v\n", result.Integration, err) + continue + } + if err := config.LaunchIntegration(result.Integration); err != nil { + fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", result.Integration, err) + } + } + } + } +} + func NewCLI() *cobra.Command { log.SetFlags(log.LstdFlags | log.Lshortfile) cobra.EnableCommandSorting = false @@ -1826,11 +2012,13 @@ func NewCLI() *cobra.Command { return } - cmd.Print(cmd.UsageString()) + runInteractiveTUI(cmd) }, } rootCmd.Flags().BoolP("version", "v", false, "Show version information") + rootCmd.Flags().Bool("verbose", false, "Show timings for response") + rootCmd.Flags().Bool("nowordwrap", false, "Don't wrap words to the next line automatically") createCmd := &cobra.Command{ Use: "create MODEL", @@ -2044,7 +2232,7 @@ func NewCLI() *cobra.Command { copyCmd, deleteCmd, runnerCmd, - config.LaunchCmd(checkServerHeartbeat), + config.LaunchCmd(checkServerHeartbeat, runInteractiveTUI), ) return rootCmd diff --git a/cmd/config/config.go b/cmd/config/config.go index 96552960b..0691836ce 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -3,12 +3,15 @@ package config import ( + "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" + + "github.com/ollama/ollama/api" ) type integration struct { @@ -17,7 +20,9 @@ type integration struct { } type config struct { - Integrations map[string]*integration `json:"integrations"` + Integrations map[string]*integration `json:"integrations"` + LastModel string `json:"last_model,omitempty"` + LastSelection string `json:"last_selection,omitempty"` // "run" or integration name } func configPath() (string, error) { @@ -146,6 +151,74 @@ func saveIntegration(appName string, models []string) error { return save(cfg) } +// IntegrationModel returns the first configured model for an integration, or empty string if not configured. +func IntegrationModel(appName string) string { + ic, err := loadIntegration(appName) + if err != nil || len(ic.Models) == 0 { + return "" + } + return ic.Models[0] +} + +// LastModel returns the last model that was run, or empty string if none. +func LastModel() string { + cfg, err := load() + if err != nil { + return "" + } + return cfg.LastModel +} + +// SetLastModel saves the last model that was run. +func SetLastModel(model string) error { + cfg, err := load() + if err != nil { + return err + } + cfg.LastModel = model + return save(cfg) +} + +// LastSelection returns the last menu selection ("run" or integration name), or empty string if none. +func LastSelection() string { + cfg, err := load() + if err != nil { + return "" + } + return cfg.LastSelection +} + +// SetLastSelection saves the last menu selection ("run" or integration name). +func SetLastSelection(selection string) error { + cfg, err := load() + if err != nil { + return err + } + cfg.LastSelection = selection + return save(cfg) +} + +// ModelExists checks if a model exists on the Ollama server. +func ModelExists(ctx context.Context, name string) bool { + if name == "" { + return false + } + client, err := api.ClientFromEnvironment() + if err != nil { + return false + } + models, err := client.List(ctx) + if err != nil { + return false + } + for _, m := range models.Models { + if m.Name == name || strings.HasPrefix(m.Name, name+":") { + return true + } + } + return false +} + func loadIntegration(appName string) (*integration, error) { cfg, err := load() if err != nil { diff --git a/cmd/config/integrations.go b/cmd/config/integrations.go index 7fb3667df..2193a135c 100644 --- a/cmd/config/integrations.go +++ b/cmd/config/integrations.go @@ -4,9 +4,12 @@ import ( "context" "errors" "fmt" + "io" "maps" + "net/http" "os" "os/exec" + "path/filepath" "runtime" "slices" "strings" @@ -61,7 +64,7 @@ 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 = []selectItem{ +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"}, @@ -74,6 +77,256 @@ var integrationAliases = map[string]bool{ "moltbot": 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", +} + +// CanInstallIntegration returns true if we have an install script for this integration. +func CanInstallIntegration(name string) bool { + _, ok := integrationInstallURLs[name] + return ok +} + +// IsIntegrationInstalled checks if an integration binary is installed. +func IsIntegrationInstalled(name string) bool { + switch name { + case "claude": + c := &Claude{} + _, err := c.findPath() + return err == nil + case "openclaw": + if _, err := exec.LookPath("openclaw"); err == nil { + return true + } + if _, err := exec.LookPath("clawdbot"); err == nil { + return true + } + return false + case "codex": + _, err := exec.LookPath("codex") + return err == nil + case "droid": + _, err := exec.LookPath("droid") + return err == nil + case "opencode": + _, err := exec.LookPath("opencode") + return err == nil + default: + return true // Assume installed for unknown integrations + } +} + +// 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 +} + +// SingleSelector is a function type for single item selection. +type SingleSelector func(title string, items []ModelItem) (string, error) + +// MultiSelector is a function type for multi item selection. +type MultiSelector func(title string, items []ModelItem, preChecked []string) ([]string, error) + +// SelectModelWithSelector prompts the user to select a model using the provided selector. +func SelectModelWithSelector(ctx context.Context, selector SingleSelector) (string, error) { + client, err := api.ClientFromEnvironment() + if err != nil { + return "", err + } + + models, err := client.List(ctx) + if err != nil { + return "", err + } + + var existing []modelInfo + for _, m := range models.Models { + existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""}) + } + + lastModel := LastModel() + var preChecked []string + if lastModel != "" { + preChecked = []string{lastModel} + } + + items, _, existingModels, cloudModels := buildModelList(existing, preChecked, lastModel) + + if len(items) == 0 { + 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 + } + + // If the selected model isn't installed, pull it first + if !existingModels[selected] { + msg := fmt.Sprintf("Download %s?", selected) + if ok, err := confirmPrompt(msg); err != nil { + return "", err + } else if !ok { + return "", errCancelled + } + fmt.Fprintf(os.Stderr, "\n") + if err := pullModel(ctx, client, selected); err != nil { + return "", fmt.Errorf("failed to pull %s: %w", selected, err) + } + } + + // If it's a cloud model, ensure user is signed in + if cloudModels[selected] { + user, err := client.Whoami(ctx) + if err == nil && user != nil && user.Name != "" { + return selected, nil + } + + var aErr api.AuthorizationError + if !errors.As(err, &aErr) || aErr.SigninURL == "" { + return "", err + } + + yes, err := confirmPrompt(fmt.Sprintf("sign in to use %s?", selected)) + if err != nil || !yes { + return "", fmt.Errorf("%s requires sign in", selected) + } + + fmt.Fprintf(os.Stderr, "\nTo sign in, navigate to:\n %s\n\n", aErr.SigninURL) + + // Auto-open browser (best effort, fail silently) + 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() + } + + spinnerFrames := []string{"|", "/", "-", "\\"} + frame := 0 + + fmt.Fprintf(os.Stderr, "\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[0]) + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + fmt.Fprintf(os.Stderr, "\r\033[K") + return "", ctx.Err() + case <-ticker.C: + frame++ + fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)]) + + // poll every 10th frame (~2 seconds) + if frame%10 == 0 { + u, err := client.Whoami(ctx) + if err == nil && u != nil && u.Name != "" { + fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", u.Name) + return selected, nil + } + } + } + } + } + + return selected, nil +} + +func SelectModel(ctx context.Context) (string, error) { + 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) +} + +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) +} + func selectIntegration() (string, error) { if len(integrations) == 0 { return "", fmt.Errorf("no integrations available") @@ -96,8 +349,8 @@ func selectIntegration() (string, error) { return selectPrompt("Select integration:", items) } -// selectModels lets the user select models for an integration -func selectModels(ctx context.Context, name, current string) ([]string, error) { +// selectModelsWithSelectors lets the user select models for an integration using provided selectors. +func selectModelsWithSelectors(ctx context.Context, name, current string, single SingleSelector, multi MultiSelector) ([]string, error) { r, ok := integrations[name] if !ok { return nil, fmt.Errorf("unknown integration: %s", name) @@ -133,7 +386,7 @@ func selectModels(ctx context.Context, name, current string) ([]string, error) { var selected []string if _, ok := r.(Editor); ok { - selected, err = multiSelectPrompt(fmt.Sprintf("Select models for %s:", r), items, preChecked) + selected, err = multi(fmt.Sprintf("Select models for %s:", r), items, preChecked) if err != nil { return nil, err } @@ -142,7 +395,7 @@ func selectModels(ctx context.Context, name, current string) ([]string, error) { if _, ok := r.(AliasConfigurer); ok { prompt = fmt.Sprintf("Select Primary model for %s:", r) } - model, err := selectPrompt(prompt, items) + model, err := single(prompt, items) if err != nil { return nil, err } @@ -227,12 +480,17 @@ func listModels(ctx context.Context) ([]selectItem, map[string]bool, map[string] }) } - items, _, existingModels, cloudModels := buildModelList(existing, nil, "") + modelItems, _, existingModels, cloudModels := buildModelList(existing, nil, "") - if len(items) == 0 { + if len(modelItems) == 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 } @@ -303,6 +561,11 @@ 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) +} + func runIntegration(name, modelName string, args []string) error { r, ok := integrations[name] if !ok { @@ -335,15 +598,110 @@ func syncAliases(ctx context.Context, client *api.Client, ac AliasConfigurer, na return saveAliases(name, aliases) } +// LaunchIntegration launches the named integration using saved config or prompts for setup. +func LaunchIntegration(name string) error { + r, ok := integrations[name] + if !ok { + return fmt.Errorf("unknown integration: %s", name) + } + + // Try to use saved config + if config, err := loadIntegration(name); err == nil && len(config.Models) > 0 { + return runIntegration(name, config.Models[0], nil) + } + + // No saved config - prompt user to run setup + return fmt.Errorf("%s is not configured. Run 'ollama launch %s' to set it up", r, name) +} + +// LaunchIntegrationWithModel launches the named integration with the specified model. +func LaunchIntegrationWithModel(name, modelName string) error { + return runIntegration(name, modelName, nil) +} + +// SaveIntegrationModel saves the model for an integration. +func SaveIntegrationModel(name, modelName string) error { + // Load existing models and prepend the new one + var models []string + if existing, err := loadIntegration(name); err == nil && len(existing.Models) > 0 { + models = existing.Models + // Remove the model if it already exists + for i, m := range models { + if m == modelName { + models = append(models[:i], models[i+1:]...) + break + } + } + } + // Prepend the new model + models = append([]string{modelName}, models...) + return saveIntegration(name, models) +} + +// ConfigureIntegrationWithSelectors allows the user to select/change the model for an integration using custom selectors. +func ConfigureIntegrationWithSelectors(ctx context.Context, name string, single SingleSelector, multi MultiSelector) error { + r, ok := integrations[name] + if !ok { + return fmt.Errorf("unknown integration: %s", name) + } + + models, err := selectModelsWithSelectors(ctx, name, "", single, multi) + if errors.Is(err, errCancelled) { + return nil + } + if err != nil { + return err + } + + if editor, isEditor := r.(Editor); isEditor { + paths := editor.Paths() + if len(paths) > 0 { + fmt.Fprintf(os.Stderr, "This will modify your %s configuration:\n", r) + for _, p := range paths { + fmt.Fprintf(os.Stderr, " %s\n", p) + } + fmt.Fprintf(os.Stderr, "Backups will be saved to %s/\n\n", backupDir()) + + if ok, _ := confirmPrompt("Proceed?"); !ok { + return nil + } + } + + if err := editor.Edit(models); err != nil { + return fmt.Errorf("setup failed: %w", err) + } + } + + if err := saveIntegration(name, models); err != nil { + return fmt.Errorf("failed to save: %w", err) + } + + if len(models) == 1 { + fmt.Fprintf(os.Stderr, "Configured %s with %s\n", r, models[0]) + } else { + fmt.Fprintf(os.Stderr, "Configured %s with %d models (default: %s)\n", r, len(models), models[0]) + } + + return nil +} + +// 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) +} + // LaunchCmd returns the cobra command for launching integrations. -func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) error) *cobra.Command { +// The runTUI callback is called when no arguments are provided (alias for main TUI). +func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) error, runTUI func(cmd *cobra.Command)) *cobra.Command { var modelFlag string var configFlag bool cmd := &cobra.Command{ Use: "launch [INTEGRATION] [-- [EXTRA_ARGS...]]", - Short: "Launch an integration with Ollama", - Long: `Launch an integration configured with Ollama models. + Short: "Launch the Ollama menu or an integration", + Long: `Launch the Ollama interactive menu, or directly launch a specific integration. + +Without arguments, this is equivalent to running 'ollama' directly. Supported integrations: claude Claude Code @@ -362,6 +720,12 @@ Examples: Args: cobra.ArbitraryArgs, PreRunE: checkServerHeartbeat, RunE: func(cmd *cobra.Command, args []string) error { + // No args - run the main TUI (same as 'ollama') + if len(args) == 0 && modelFlag == "" && !configFlag { + runTUI(cmd) + return nil + } + // Extract integration name and args to pass through using -- separator var name string var passArgs []string @@ -582,7 +946,7 @@ type modelInfo struct { // buildModelList merges existing models with recommendations, sorts them, and returns // the ordered items along with maps of existing and cloud model names. -func buildModelList(existing []modelInfo, preChecked []string, current string) (items []selectItem, orderedChecked []string, existingModels, cloudModels map[string]bool) { +func buildModelList(existing []modelInfo, preChecked []string, current string) (items []ModelItem, orderedChecked []string, existingModels, cloudModels map[string]bool) { existingModels = make(map[string]bool) cloudModels = make(map[string]bool) recommended := make(map[string]bool) @@ -602,7 +966,7 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) ( } displayName := strings.TrimSuffix(m.Name, ":latest") existingModels[displayName] = true - item := selectItem{Name: displayName} + item := ModelItem{Name: displayName} if recommended[displayName] { item.Description = "recommended" } @@ -651,7 +1015,7 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) ( } if hasLocalModel || hasCloudModel { - slices.SortStableFunc(items, func(a, b selectItem) int { + slices.SortStableFunc(items, func(a, b ModelItem) int { ac, bc := checked[a.Name], checked[b.Name] aNew, bNew := notInstalled[a.Name], notInstalled[b.Name] @@ -686,6 +1050,56 @@ func isCloudModel(ctx context.Context, client *api.Client, name string) bool { return resp.RemoteModel != "" } +// GetModelItems returns a list of model items including recommendations for the TUI. +// It includes all locally available models plus recommended models that aren't installed. +func GetModelItems(ctx context.Context) ([]ModelItem, map[string]bool) { + client, err := api.ClientFromEnvironment() + if err != nil { + return nil, nil + } + + models, err := client.List(ctx) + if err != nil { + return nil, nil + } + + var existing []modelInfo + for _, m := range models.Models { + existing = append(existing, modelInfo{Name: m.Name, Remote: m.RemoteModel != ""}) + } + + lastModel := LastModel() + var preChecked []string + if lastModel != "" { + preChecked = []string{lastModel} + } + + 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 +} + func pullModel(ctx context.Context, client *api.Client, model string) error { p := progress.NewProgress(os.Stderr) defer p.Stop() diff --git a/cmd/config/integrations_test.go b/cmd/config/integrations_test.go index 14d89e59a..e796c4065 100644 --- a/cmd/config/integrations_test.go +++ b/cmd/config/integrations_test.go @@ -94,8 +94,10 @@ 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) + cmd := LaunchCmd(mockCheck, mockTUI) t.Run("command structure", func(t *testing.T) { if cmd.Use != "launch [INTEGRATION] [-- [EXTRA_ARGS...]]" { @@ -128,6 +130,75 @@ func TestLaunchCmd(t *testing.T) { }) } +func TestLaunchCmd_TUICallback(t *testing.T) { + mockCheck := func(cmd *cobra.Command, args []string) error { + return nil + } + + t.Run("no args calls TUI", func(t *testing.T) { + tuiCalled := false + mockTUI := func(cmd *cobra.Command) { + tuiCalled = true + } + + cmd := LaunchCmd(mockCheck, mockTUI) + cmd.SetArgs([]string{}) + _ = cmd.Execute() + + if !tuiCalled { + t.Error("TUI callback should be called when no args provided") + } + }) + + t.Run("integration arg bypasses TUI", func(t *testing.T) { + tuiCalled := false + mockTUI := func(cmd *cobra.Command) { + tuiCalled = true + } + + cmd := LaunchCmd(mockCheck, mockTUI) + cmd.SetArgs([]string{"claude"}) + // Will error because claude isn't configured, but that's OK + _ = cmd.Execute() + + if tuiCalled { + t.Error("TUI callback should NOT be called when integration arg provided") + } + }) + + t.Run("--model flag bypasses TUI", func(t *testing.T) { + tuiCalled := false + mockTUI := func(cmd *cobra.Command) { + tuiCalled = true + } + + cmd := LaunchCmd(mockCheck, mockTUI) + cmd.SetArgs([]string{"--model", "test-model"}) + // Will error because no integration specified, but that's OK + _ = cmd.Execute() + + if tuiCalled { + t.Error("TUI callback should NOT be called when --model flag provided") + } + }) + + t.Run("--config flag bypasses TUI", func(t *testing.T) { + tuiCalled := false + mockTUI := func(cmd *cobra.Command) { + tuiCalled = true + } + + cmd := LaunchCmd(mockCheck, mockTUI) + cmd.SetArgs([]string{"--config"}) + // Will error because no integration specified, but that's OK + _ = cmd.Execute() + + if tuiCalled { + t.Error("TUI callback should NOT be called when --config flag provided") + } + }) +} + func TestRunIntegration_UnknownIntegration(t *testing.T) { err := runIntegration("unknown-integration", "model", nil) if err == nil { @@ -168,7 +239,7 @@ func TestHasLocalModel_DocumentsHeuristic(t *testing.T) { func TestLaunchCmd_NilHeartbeat(t *testing.T) { // This should not panic - cmd creation should work even with nil - cmd := LaunchCmd(nil) + cmd := LaunchCmd(nil, nil) if cmd == nil { t.Fatal("LaunchCmd returned nil") } @@ -314,7 +385,7 @@ func TestIsCloudModel(t *testing.T) { }) } -func names(items []selectItem) []string { +func names(items []ModelItem) []string { var out []string for _, item := range items { out = append(out, item.Name) diff --git a/cmd/tui/selector.go b/cmd/tui/selector.go new file mode 100644 index 000000000..4e64a5419 --- /dev/null +++ b/cmd/tui/selector.go @@ -0,0 +1,507 @@ +package tui + +import ( + "errors" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + selectorTitleStyle = lipgloss.NewStyle(). + Bold(true) + + selectorItemStyle = lipgloss.NewStyle(). + PaddingLeft(4) + + selectorSelectedItemStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Bold(true) + + selectorDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + selectorFilterStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Italic(true) + + selectorInputStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + selectorCheckboxStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + selectorCheckboxCheckedStyle = lipgloss.NewStyle(). + Bold(true) + + selectorDefaultTagStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Italic(true) + + selectorHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + selectorMoreStyle = lipgloss.NewStyle(). + PaddingLeft(4). + Foreground(lipgloss.Color("241")). + Italic(true) +) + +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 +} + +// selectorModel is the bubbletea model for single selection. +type selectorModel struct { + title string + items []SelectItem + filter string + cursor int + scrollOffset int + selected string + cancelled bool +} + +func (m selectorModel) filteredItems() []SelectItem { + if m.filter == "" { + return m.items + } + filterLower := strings.ToLower(m.filter) + var result []SelectItem + for _, item := range m.items { + if strings.Contains(strings.ToLower(item.Name), filterLower) { + result = append(result, item) + } + } + return result +} + +func (m selectorModel) Init() tea.Cmd { + return nil +} + +func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + filtered := m.filteredItems() + + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + return m, tea.Quit + + case tea.KeyEnter: + 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 + } + } + + return m, nil +} + +func (m selectorModel) View() string { + // Clear screen when exiting + if m.cancelled || m.selected != "" { + return "" + } + + var s strings.Builder + + // Title with filter + s.WriteString(selectorTitleStyle.Render(m.title)) + s.WriteString(" ") + if m.filter == "" { + s.WriteString(selectorFilterStyle.Render("Type to filter...")) + } else { + s.WriteString(selectorInputStyle.Render(m.filter)) + } + s.WriteString("\n\n") + + filtered := m.filteredItems() + + if len(filtered) == 0 { + s.WriteString(selectorItemStyle.Render(selectorDescStyle.Render("(no matches)"))) + s.WriteString("\n") + } else { + 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") + } + + if remaining := len(filtered) - 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")) + + return s.String() +} + +// SelectSingle prompts the user to select a single item from a list. +func SelectSingle(title string, items []SelectItem) (string, error) { + if len(items) == 0 { + return "", fmt.Errorf("no items to select from") + } + + m := selectorModel{ + title: title, + items: items, + } + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("error running selector: %w", err) + } + + fm := finalModel.(selectorModel) + if fm.cancelled { + return "", ErrCancelled + } + + return fm.selected, nil +} + +// multiSelectorModel is the bubbletea model for multi selection. +type multiSelectorModel struct { + title string + items []SelectItem + itemIndex map[string]int + filter string + cursor int + scrollOffset int + checked map[int]bool + checkOrder []int + cancelled bool + confirmed bool +} + +func newMultiSelectorModel(title string, items []SelectItem, preChecked []string) multiSelectorModel { + m := multiSelectorModel{ + title: title, + items: items, + itemIndex: make(map[string]int, len(items)), + checked: make(map[int]bool), + } + + for i, item := range items { + m.itemIndex[item.Name] = i + } + + for _, name := range preChecked { + if idx, ok := m.itemIndex[name]; ok { + m.checked[idx] = true + m.checkOrder = append(m.checkOrder, idx) + } + } + + return m +} + +func (m multiSelectorModel) filteredItems() []SelectItem { + if m.filter == "" { + return m.items + } + filterLower := strings.ToLower(m.filter) + var result []SelectItem + for _, item := range m.items { + if strings.Contains(strings.ToLower(item.Name), filterLower) { + result = append(result, item) + } + } + return result +} + +func (m *multiSelectorModel) toggleItem() { + filtered := m.filteredItems() + if len(filtered) == 0 || m.cursor >= len(filtered) { + return + } + + item := filtered[m.cursor] + origIdx := m.itemIndex[item.Name] + + if m.checked[origIdx] { + delete(m.checked, origIdx) + for i, idx := range m.checkOrder { + if idx == origIdx { + m.checkOrder = append(m.checkOrder[:i], m.checkOrder[i+1:]...) + break + } + } + } else { + m.checked[origIdx] = true + m.checkOrder = append(m.checkOrder, origIdx) + } +} + +func (m multiSelectorModel) selectedCount() int { + return len(m.checkOrder) +} + +func (m multiSelectorModel) Init() tea.Cmd { + return nil +} + +func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + filtered := m.filteredItems() + + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + 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: + 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 + } + } + + return m, nil +} + +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 == "" { + s.WriteString(selectorFilterStyle.Render("Type to filter...")) + } else { + s.WriteString(selectorInputStyle.Render(m.filter)) + } + s.WriteString("\n\n") + + filtered := m.filteredItems() + + if len(filtered) == 0 { + s.WriteString(selectorItemStyle.Render(selectorDescStyle.Render("(no matches)"))) + s.WriteString("\n") + } else { + displayCount := min(len(filtered), maxSelectorItems) + + for i := range displayCount { + idx := m.scrollOffset + i + if idx >= len(filtered) { + break + } + item := filtered[idx] + origIdx := m.itemIndex[item.Name] + + // Checkbox + var checkbox string + if m.checked[origIdx] { + checkbox = selectorCheckboxCheckedStyle.Render("[x]") + } else { + checkbox = selectorCheckboxStyle.Render("[ ]") + } + + // Cursor and name + var line string + if idx == m.cursor { + line = selectorSelectedItemStyle.Render("▸ ") + checkbox + " " + selectorSelectedItemStyle.Render(item.Name) + } else { + line = " " + checkbox + " " + item.Name + } + + // Default tag + if len(m.checkOrder) > 0 && m.checkOrder[0] == origIdx { + line += " " + selectorDefaultTagStyle.Render("(default)") + } + + s.WriteString(line) + s.WriteString("\n") + } + + if remaining := len(filtered) - m.scrollOffset - displayCount; remaining > 0 { + s.WriteString(selectorMoreStyle.Render(fmt.Sprintf("... and %d more", remaining))) + s.WriteString("\n") + } + } + + s.WriteString("\n") + + // Status line + count := m.selectedCount() + if count == 0 { + s.WriteString(selectorDescStyle.Render(" Select at least one model.")) + } else { + s.WriteString(selectorDescStyle.Render(fmt.Sprintf(" %d selected - press enter to continue", count))) + } + s.WriteString("\n\n") + + s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • enter confirm • esc cancel")) + + return s.String() +} + +// 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") + } + + m := newMultiSelectorModel(title, items, preChecked) + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("error running selector: %w", err) + } + + fm := finalModel.(multiSelectorModel) + if fm.cancelled { + return nil, ErrCancelled + } + + if !fm.confirmed { + return nil, ErrCancelled + } + + var result []string + for _, idx := range fm.checkOrder { + result = append(result, fm.items[idx].Name) + } + + return result, nil +} diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go new file mode 100644 index 000000000..59c983e3d --- /dev/null +++ b/cmd/tui/tui.go @@ -0,0 +1,731 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/version" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + MarginBottom(1) + + versionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) + + itemStyle = lipgloss.NewStyle(). + PaddingLeft(2) + + selectedStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Bold(true) + + greyedStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Foreground(lipgloss.Color("241")) + + greyedSelectedStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Foreground(lipgloss.Color("243")) + + descStyle = lipgloss.NewStyle(). + PaddingLeft(4). + Foreground(lipgloss.Color("241")) + + modelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) + + notInstalledStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Italic(true) +) + +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 +} + +var mainMenuItems = []menuItem{ + { + title: "Run a model", + description: "Start an interactive chat with a local model", + isRunModel: true, + }, + { + title: "Launch Claude Code", + description: "Open Claude Code AI assistant", + integration: "claude", + }, + { + title: "Launch Codex", + description: "Open Codex CLI", + integration: "codex", + }, + { + title: "Launch Open Claw", + description: "Open the Open Claw integration", + integration: "openclaw", + }, +} + +var othersMenuItem = menuItem{ + title: "Others...", + 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). +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", + }, + } +} + +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 + 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 + + // 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 + signInFromModal bool // true if sign-in was triggered from modal (not main menu) +} + +// 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. +func (m *model) modelExists(name string) bool { + if m.availableModels == nil || name == "" { + return false + } + if m.availableModels[name] { + return true + } + // Check for prefix match (e.g., "llama2" matches "llama2:latest") + for modelName := range m.availableModels { + if strings.HasPrefix(modelName, name+":") { + return true + } + } + 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}) + } + 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, + } + m.showingModal = true +} + +// isCloudModel returns true if the model name indicates a cloud model. +func isCloudModel(name string) bool { + return strings.HasSuffix(name, ":cloud") +} + +// checkCloudSignIn checks if a cloud model needs sign-in. +// Returns a command to start sign-in if needed, or nil if already signed in. +func (m *model) checkCloudSignIn(modelName string, fromModal bool) tea.Cmd { + if modelName == "" || !isCloudModel(modelName) { + return nil + } + client, err := api.ClientFromEnvironment() + if err != nil { + return nil + } + user, err := client.Whoami(context.Background()) + if err == nil && user != nil && user.Name != "" { + return nil // Already signed in + } + var aErr api.AuthorizationError + if errors.As(err, &aErr) && aErr.SigninURL != "" { + return m.startSignIn(modelName, aErr.SigninURL, fromModal) + } + return nil +} + +// startSignIn initiates the sign-in flow for a cloud model. +// fromModal indicates if this was triggered from the model picker modal. +func (m *model) startSignIn(modelName, signInURL string, fromModal bool) tea.Cmd { + m.showingModal = false + m.showingSignIn = true + m.signInURL = signInURL + m.signInModel = modelName + 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() + } + + // 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 { + return signInCheckMsg{signedIn: false} + } + user, err := client.Whoami(context.Background()) + if err == nil && user != nil && user.Name != "" { + return signInCheckMsg{signedIn: true, userName: user.Name} + } + 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() + if err != nil { + return + } + models, err := client.List(context.Background()) + if err != nil { + return + } + for _, mdl := range models.Models { + m.availableModels[mdl.Name] = true + } +} + +func (m *model) buildItems() { + others := getOtherIntegrations() + m.items = make([]menuItem, 0, len(mainMenuItems)+1+len(others)) + 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 + } + return false +} + +func initialModel() model { + m := model{ + cursor: 0, + } + m.loadAvailableModels() + + // Check last selection to determine if we need to expand "Others" + lastSelection := config.LastSelection() + if isOthersIntegration(lastSelection) { + m.showOthers = true + } + + m.buildItems() + + // Position cursor on last selection + if lastSelection != "" { + for i, item := range m.items { + if lastSelection == "run" && item.isRunModel { + m.cursor = i + break + } else if item.integration == lastSelection { + m.cursor = i + break + } + } + } + + return m +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle sign-in dialog + 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 + } + + case signInTickMsg: + m.signInSpinner++ + // Check sign-in status every 5th tick (~1 second) + if m.signInSpinner%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 { + // 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 + return m, tea.Quit + } + } + 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 + m.showingModal = false + return m, nil + + case tea.KeyEnter: + filtered := m.modalSelector.filteredItems() + if len(filtered) > 0 && m.modalSelector.cursor < len(filtered) { + m.modalSelector.selected = filtered[m.modalSelector.cursor].Name + } + if m.modalSelector.selected != "" { + 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 + } + } + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.quitting = true + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + + 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() + } else if item.integration != "" { + configuredModel = config.IntegrationModel(item.integration) + } + if cmd := m.checkCloudSignIn(configuredModel, false); cmd != nil { + return m, cmd + } + + m.selected = true + m.quitting = true + 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 + } + m.openModelModal() + } + } + } + + return m, nil +} + +func (m model) View() string { + if m.quitting { + 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" + + for i, item := range m.items { + cursor := " " + style := itemStyle + isInstalled := true + + if item.integration != "" { + isInstalled = config.IsIntegrationInstalled(item.integration) + } + + if m.cursor == i { + cursor = "▸ " + if isInstalled { + style = selectedStyle + } else { + style = greyedSelectedStyle + } + } else if !isInstalled && item.integration != "" { + style = greyedStyle + } + + title := item.title + 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 item.isRunModel { + if mdl := config.LastModel(); mdl != "" && m.modelExists(mdl) { + title += " " + modelStyle.Render("("+mdl+")") + } + } + + s += style.Render(cursor+title) + "\n" + s += descStyle.Render(item.description) + "\n\n" + } + + s += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("↑/↓ navigate • enter select • → change model • esc quit") + + 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) + + 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)) + } + 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()) +} + +// 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()) +} + +// Selection represents what the user selected +type Selection int + +const ( + SelectionNone Selection = iota + SelectionRunModel + SelectionChangeRunModel + SelectionIntegration // Generic integration selection + 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) + + finalModel, err := p.Run() + if err != nil { + return Result{Selection: SelectionNone}, fmt.Errorf("error running TUI: %w", err) + } + + fm := finalModel.(model) + if fm.err != nil { + 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{ + Selection: SelectionChangeRunModel, + Model: fm.modalSelector.selected, + }, nil + } + return Result{ + Selection: SelectionChangeIntegration, + Integration: item.integration, + Model: fm.modalSelector.selected, + }, nil + } + + // Handle selection + if item.isRunModel { + return Result{Selection: SelectionRunModel}, nil + } + + return Result{ + Selection: SelectionIntegration, + Integration: item.integration, + }, nil +} diff --git a/go.mod b/go.mod index 7a7f1ce4a..bf4c787d1 100644 --- a/go.mod +++ b/go.mod @@ -21,10 +21,12 @@ require ( require ( github.com/agnivade/levenshtein v1.1.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1 github.com/dlclark/regexp2 v1.11.4 github.com/emirpasic/gods/v2 v2.0.0-alpha - github.com/mattn/go-runewidth v0.0.14 + github.com/mattn/go-runewidth v0.0.16 github.com/nlpodyssey/gopickle v0.3.0 github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -38,22 +40,34 @@ require ( require ( github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chewxy/hm v1.0.0 // indirect github.com/chewxy/math32 v1.11.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/tkrajina/go-reflector v0.5.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtgo/set v1.0.0 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect diff --git a/go.sum b/go.sum index 483da574d..b34f17e0e 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6IC github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -24,6 +26,18 @@ github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1 github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chewxy/hm v1.0.0 h1:zy/TSv3LV2nD3dwUEQL2VhXeoXbb9QkpmdRAVUFiA6k= github.com/chewxy/hm v1.0.0/go.mod h1:qg9YI4q6Fkj/whwHR1D+bOGeF7SniIP40VweVepLjg0= github.com/chewxy/math32 v1.0.0/go.mod h1:Miac6hA1ohdDUTagnvJy/q+aNnEk16qWUdb8ZVhvCN0= @@ -59,6 +73,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -148,13 +164,17 @@ github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -162,6 +182,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nlpodyssey/gopickle v0.3.0 h1:BLUE5gxFLyyNOPzlXxt6GoHEMMxD0qhsE4p0CIQyoLw= github.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -182,8 +208,9 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= @@ -220,6 +247,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xtgo/set v1.0.0 h1:6BCNBRv3ORNDQ7fyoJXRv+tstJz3m1JVFQErfeZz2pY= github.com/xtgo/set v1.0.0/go.mod h1:d3NHzGzSa0NmB2NhFyECA+QdRp29oEn2xbT+TpeFoM8= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -306,6 +335,7 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=