package tui import ( "context" "errors" "fmt" "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/internal/modelref" "github.com/ollama/ollama/version" ) var ( versionStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "250"}) menuItemStyle = lipgloss.NewStyle(). PaddingLeft(2) menuSelectedItemStyle = lipgloss.NewStyle(). Bold(true). Background(lipgloss.AdaptiveColor{Light: "254", Dark: "236"}) menuDescStyle = selectorDescStyle. PaddingLeft(4) greyedStyle = menuItemStyle. Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) greyedSelectedStyle = menuSelectedItemStyle. Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) modelStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "250"}) notInstalledStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}). Italic(true) ) type menuItem struct { title string description string integration string // integration name for loading model config, empty if not an integration isRunModel bool isOthers bool } var mainMenuItems = []menuItem{ { title: "Run a model", description: "Start an interactive chat with a model", isRunModel: true, }, { title: "Launch Claude Code", description: "Agentic coding across large codebases", integration: "claude", }, { title: "Launch Codex", description: "OpenAI's open-source coding agent", integration: "codex", }, { title: "Launch OpenClaw", description: "Personal AI with 100+ skills", integration: "openclaw", }, } var othersMenuItem = menuItem{ title: "More...", description: "Show additional integrations", isOthers: true, } // getOtherIntegrations dynamically builds the "Others" list from the integration // registry, excluding any integrations already present in the pinned mainMenuItems. func getOtherIntegrations() []menuItem { 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 changeModel bool changeModels []string // multi-select result for Editor integrations showOthers bool availableModels map[string]bool err error showingModal bool modalSelector selectorModel modalItems []SelectItem showingMultiModal bool multiModalSelector multiSelectorModel 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 } type signInTickMsg struct{} type signInCheckMsg struct { signedIn bool userName string } type clearStatusMsg struct{} func (m *model) modelExists(name string) bool { if name == "" { return false } if modelref.HasExplicitCloudSource(name) { return true } if m.availableModels == nil { 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 } func (m *model) buildModalItems() []SelectItem { modelItems, _ := config.GetModelItems(context.Background()) return ReorderItems(ConvertItems(modelItems)) } func (m *model) openModelModal(currentModel string) { m.modalItems = m.buildModalItems() cursor := 0 if currentModel != "" { for i, item := range m.modalItems { if item.Name == currentModel || strings.HasPrefix(item.Name, currentModel+":") || strings.HasPrefix(currentModel, item.Name+":") { cursor = i break } } } m.modalSelector = selectorModel{ title: "Select model:", items: m.modalItems, cursor: cursor, helpText: "↑/↓ navigate • enter select • ← back", } m.modalSelector.updateScroll(m.modalSelector.otherStart()) m.showingModal = true } func (m *model) openMultiModelModal(integration string) { items := m.buildModalItems() var preChecked []string if models := config.IntegrationModels(integration); len(models) > 0 { preChecked = models } m.multiModalSelector = newMultiSelectorModel("Select models:", items, preChecked) // Set cursor to the first pre-checked (last used) model if len(preChecked) > 0 { for i, item := range items { if item.Name == preChecked[0] { m.multiModalSelector.cursor = i m.multiModalSelector.updateScroll(m.multiModalSelector.otherStart()) break } } } m.showingMultiModal = true } func isCloudModel(name string) bool { return modelref.HasExplicitCloudSource(name) } func cloudStatusDisabled(client *api.Client) bool { status, err := client.CloudStatusExperimental(context.Background()) if err != nil { return false } return status.Cloud.Disabled } func cloudModelDisabled(name string) bool { if !isCloudModel(name) { return false } client, err := api.ClientFromEnvironment() if err != nil { return false } return cloudStatusDisabled(client) } // 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 } if cloudStatusDisabled(client) { return nil } user, err := client.Whoami(context.Background()) if err == nil && user != nil && user.Name != "" { return nil } 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 config.OpenBrowser(signInURL) return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { return signInTickMsg{} }) } 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} } 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 } cloudDisabled := cloudStatusDisabled(client) for _, mdl := range models.Models { if cloudDisabled && mdl.RemoteModel != "" { continue } 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 { m.items = append(m.items, others...) } else { m.items = append(m.items, othersMenuItem) } } func isOthersIntegration(name string) bool { for _, item := range getOtherIntegrations() { if item.integration == name { return true } } return false } func initialModel() model { m := model{ cursor: 0, } m.loadAvailableModels() lastSelection := config.LastSelection() if isOthersIntegration(lastSelection) { m.showOthers = true } m.buildItems() 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) { 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: m.showingSignIn = false if m.signInFromModal { m.showingModal = true } 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 { if m.signInFromModal { m.modalSelector.selected = m.signInModel m.changeModel = true } else { m.selected = true } m.quitting = true return m, tea.Quit } } return m, nil } if m.showingMultiModal { switch msg := msg.(type) { case tea.KeyMsg: if msg.Type == tea.KeyLeft { m.showingMultiModal = false return m, nil } updated, cmd := m.multiModalSelector.Update(msg) m.multiModalSelector = updated.(multiSelectorModel) if m.multiModalSelector.cancelled { m.showingMultiModal = false return m, nil } if m.multiModalSelector.confirmed { var selected []string if m.multiModalSelector.singleAdd != "" { // Single-add mode: prepend picked model, keep existing deduped selected = []string{m.multiModalSelector.singleAdd} for _, name := range config.IntegrationModels(m.items[m.cursor].integration) { if name != m.multiModalSelector.singleAdd { selected = append(selected, name) } } } else { // Last checked is default (first in result) co := m.multiModalSelector.checkOrder last := co[len(co)-1] selected = []string{m.multiModalSelector.items[last].Name} for _, idx := range co { if idx != last { selected = append(selected, m.multiModalSelector.items[idx].Name) } } } if len(selected) > 0 { m.changeModels = selected m.changeModel = true m.quitting = true return m, tea.Quit } m.multiModalSelector.confirmed = false return m, nil } return m, cmd } return m, nil } if m.showingModal { switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc, tea.KeyLeft: 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 } m.changeModel = true m.quitting = true return m, tea.Quit } return m, nil default: // Delegate navigation (up/down/pgup/pgdown/filter/backspace) to selectorModel m.modalSelector.updateNavigation(msg) } } 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-- } // 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] if item.integration != "" && !config.IsIntegrationInstalled(item.integration) && !config.AutoInstallable(item.integration) { return m, nil } 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 } if configuredModel != "" && isCloudModel(configuredModel) && cloudModelDisabled(configuredModel) { if item.integration != "" && config.IsEditorIntegration(item.integration) { m.openMultiModelModal(item.integration) } else { m.openModelModal(configuredModel) } return m, nil } m.selected = true m.quitting = true return m, tea.Quit case "right", "l": item := m.items[m.cursor] if item.integration != "" || item.isRunModel { if item.integration != "" && !config.IsIntegrationInstalled(item.integration) { if config.AutoInstallable(item.integration) { // Auto-installable: select to trigger install flow m.selected = true m.quitting = true return m, tea.Quit } return m, nil } if item.integration != "" && config.IsEditorIntegration(item.integration) { m.openMultiModelModal(item.integration) } else { var currentModel string if item.isRunModel { currentModel = config.LastModel() } else if item.integration != "" { currentModel = config.IntegrationModel(item.integration) } m.openModelModal(currentModel) } } } } return m, nil } func (m model) View() string { if m.quitting { return "" } if m.showingSignIn { return m.renderSignInDialog() } if m.showingMultiModal { return m.multiModalSelector.View() } if m.showingModal { return m.renderModal() } s := selectorTitleStyle.Render("Ollama "+versionStyle.Render(version.Version)) + "\n\n" for i, item := range m.items { cursor := "" style := menuItemStyle isInstalled := true if item.integration != "" { isInstalled = config.IsIntegrationInstalled(item.integration) } if m.cursor == i { cursor = "▸ " if isInstalled { style = menuSelectedItemStyle } else { style = greyedSelectedStyle } } else if !isInstalled && item.integration != "" { style = greyedStyle } title := item.title var modelSuffix string if item.integration != "" { if !isInstalled { if config.AutoInstallable(item.integration) { title += " " + notInstalledStyle.Render("(install)") } else { title += " " + notInstalledStyle.Render("(not installed)") } } else if m.cursor == i { if mdl := config.IntegrationModel(item.integration); mdl != "" && m.modelExists(mdl) { modelSuffix = " " + modelStyle.Render("("+mdl+")") } } } else if item.isRunModel && m.cursor == i { if mdl := config.LastModel(); mdl != "" && m.modelExists(mdl) { modelSuffix = " " + modelStyle.Render("("+mdl+")") } } s += style.Render(cursor+title) + modelSuffix + "\n" desc := item.description if !isInstalled && item.integration != "" && m.cursor == i { if config.AutoInstallable(item.integration) { desc = "Press enter to install" } else if hint := config.IntegrationInstallHint(item.integration); hint != "" { desc = hint } else { desc = "not installed" } } s += menuDescStyle.Render(desc) + "\n\n" } 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 } func (m model) renderModal() string { modalStyle := lipgloss.NewStyle(). PaddingBottom(1). PaddingRight(2) s := modalStyle.Render(m.modalSelector.renderContent()) if m.width > 0 { return lipgloss.NewStyle().MaxWidth(m.width).Render(s) } return s } func (m model) renderSignInDialog() string { return renderSignIn(m.signInModel, m.signInURL, m.signInSpinner, m.width) } type Selection int const ( SelectionNone Selection = iota SelectionRunModel SelectionChangeRunModel SelectionIntegration // Generic integration selection SelectionChangeIntegration // Generic change model for integration ) type Result struct { Selection Selection Integration string // integration name if applicable Model string // model name if selected from single-select modal Models []string // models selected from multi-select modal (Editor integrations) } func Run() (Result, error) { 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 } if !fm.selected && !fm.changeModel { return Result{Selection: SelectionNone}, nil } item := fm.items[fm.cursor] 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, Models: fm.changeModels, }, nil } if item.isRunModel { return Result{Selection: SelectionRunModel}, nil } return Result{ Selection: SelectionIntegration, Integration: item.integration, }, nil }