feat: model selection for given provider (#57)

Aiden Cline created

* feat: model selection for given provider

* tweak: adjust cfg validation func, remove duplicated logic, consolidate agent updating into agent.go

* tweak: make the model dialog scrollable, adjust padding slightly for modal"

* feat: add provider selection, add hints, simplify some logic, add horizontal scrolling support, additional scroll indicators"

* remove nav help

* update docs

* increase number of visible models, make horizontal scroll "wrap"

* add provider popularity rankings

Change summary

README.md                                |  13 
internal/app/app.go                      |   1 
internal/config/config.go                | 253 ++++++++++-------
internal/llm/agent/agent.go              |  20 +
internal/llm/models/anthropic.go         |   6 
internal/llm/models/models.go            |   9 
internal/tui/components/dialog/models.go | 363 ++++++++++++++++++++++++++
internal/tui/tui.go                      |  66 ++++
8 files changed, 622 insertions(+), 109 deletions(-)

Detailed changes

README.md 🔗

@@ -168,7 +168,7 @@ OpenCode supports a variety of AI models from different providers:
 
 ### Groq
 
-- Llama 4 Maverick (17b-128e-instruct) 
+- Llama 4 Maverick (17b-128e-instruct)
 - Llama 4 Scout (17b-16e-instruct)
 - QWEN QWQ-32b
 - Deepseek R1 distill Llama 70b
@@ -216,6 +216,7 @@ opencode -c /path/to/project
 | `Ctrl+L` | View logs                                               |
 | `Ctrl+A` | Switch session                                          |
 | `Ctrl+K` | Command dialog                                          |
+| `Ctrl+O` | Toggle model selection dialog                           |
 | `Esc`    | Close current overlay/dialog or return to previous mode |
 
 ### Chat Page Shortcuts
@@ -245,6 +246,16 @@ opencode -c /path/to/project
 | `Enter`    | Select session   |
 | `Esc`      | Close dialog     |
 
+### Model Dialog Shortcuts
+
+| Shortcut   | Action            |
+| ---------- | ----------------- |
+| `↑` or `k` | Move up           |
+| `↓` or `j` | Move down         |
+| `←` or `h` | Previous provider |
+| `→` or `l` | Next provider     |
+| `Esc`      | Close dialog      |
+
 ### Permission Dialog Shortcuts
 
 | Shortcut                | Action                       |

internal/app/app.go 🔗

@@ -73,6 +73,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
 	return app, nil
 }
 
+
 // Shutdown performs a clean shutdown of the application
 func (app *App) Shutdown() {
 	// Cancel all watcher goroutines

internal/config/config.go 🔗

@@ -83,6 +83,8 @@ const (
 	defaultDataDirectory = ".opencode"
 	defaultLogLevel      = "info"
 	appName              = "opencode"
+
+	MaxTokensFallbackDefault = 4096
 )
 
 var defaultContextPaths = []string{
@@ -347,60 +349,33 @@ func applyDefaultValues() {
 	}
 }
 
-// Validate checks if the configuration is valid and applies defaults where needed.
 // It validates model IDs and providers, ensuring they are supported.
-func Validate() error {
-	if cfg == nil {
-		return fmt.Errorf("config not loaded")
-	}
-
-	// Validate agent models
-	for name, agent := range cfg.Agents {
-		// Check if model exists
-		model, modelExists := models.SupportedModels[agent.Model]
-		if !modelExists {
-			logging.Warn("unsupported model configured, reverting to default",
-				"agent", name,
-				"configured_model", agent.Model)
-
-			// Set default model based on available providers
-			if setDefaultModelForAgent(name) {
-				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
-			} else {
-				return fmt.Errorf("no valid provider available for agent %s", name)
-			}
-			continue
+func validateAgent(cfg *Config, name AgentName, agent Agent) error {
+	// Check if model exists
+	model, modelExists := models.SupportedModels[agent.Model]
+	if !modelExists {
+		logging.Warn("unsupported model configured, reverting to default",
+			"agent", name,
+			"configured_model", agent.Model)
+
+		// Set default model based on available providers
+		if setDefaultModelForAgent(name) {
+			logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
+		} else {
+			return fmt.Errorf("no valid provider available for agent %s", name)
 		}
+		return nil
+	}
 
-		// Check if provider for the model is configured
-		provider := model.Provider
-		providerCfg, providerExists := cfg.Providers[provider]
+	// Check if provider for the model is configured
+	provider := model.Provider
+	providerCfg, providerExists := cfg.Providers[provider]
 
-		if !providerExists {
-			// Provider not configured, check if we have environment variables
-			apiKey := getProviderAPIKey(provider)
-			if apiKey == "" {
-				logging.Warn("provider not configured for model, reverting to default",
-					"agent", name,
-					"model", agent.Model,
-					"provider", provider)
-
-				// Set default model based on available providers
-				if setDefaultModelForAgent(name) {
-					logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
-				} else {
-					return fmt.Errorf("no valid provider available for agent %s", name)
-				}
-			} else {
-				// Add provider with API key from environment
-				cfg.Providers[provider] = Provider{
-					APIKey: apiKey,
-				}
-				logging.Info("added provider from environment", "provider", provider)
-			}
-		} else if providerCfg.Disabled || providerCfg.APIKey == "" {
-			// Provider is disabled or has no API key
-			logging.Warn("provider is disabled or has no API key, reverting to default",
+	if !providerExists {
+		// Provider not configured, check if we have environment variables
+		apiKey := getProviderAPIKey(provider)
+		if apiKey == "" {
+			logging.Warn("provider not configured for model, reverting to default",
 				"agent", name,
 				"model", agent.Model,
 				"provider", provider)
@@ -411,75 +386,110 @@ func Validate() error {
 			} else {
 				return fmt.Errorf("no valid provider available for agent %s", name)
 			}
+		} else {
+			// Add provider with API key from environment
+			cfg.Providers[provider] = Provider{
+				APIKey: apiKey,
+			}
+			logging.Info("added provider from environment", "provider", provider)
+		}
+	} else if providerCfg.Disabled || providerCfg.APIKey == "" {
+		// Provider is disabled or has no API key
+		logging.Warn("provider is disabled or has no API key, reverting to default",
+			"agent", name,
+			"model", agent.Model,
+			"provider", provider)
+
+		// Set default model based on available providers
+		if setDefaultModelForAgent(name) {
+			logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
+		} else {
+			return fmt.Errorf("no valid provider available for agent %s", name)
 		}
+	}
 
-		// Validate max tokens
-		if agent.MaxTokens <= 0 {
-			logging.Warn("invalid max tokens, setting to default",
-				"agent", name,
-				"model", agent.Model,
-				"max_tokens", agent.MaxTokens)
+	// Validate max tokens
+	if agent.MaxTokens <= 0 {
+		logging.Warn("invalid max tokens, setting to default",
+			"agent", name,
+			"model", agent.Model,
+			"max_tokens", agent.MaxTokens)
 
-			// Update the agent with default max tokens
-			updatedAgent := cfg.Agents[name]
-			if model.DefaultMaxTokens > 0 {
-				updatedAgent.MaxTokens = model.DefaultMaxTokens
-			} else {
-				updatedAgent.MaxTokens = 4096 // Fallback default
-			}
-			cfg.Agents[name] = updatedAgent
-		} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
-			// Ensure max tokens doesn't exceed half the context window (reasonable limit)
-			logging.Warn("max tokens exceeds half the context window, adjusting",
+		// Update the agent with default max tokens
+		updatedAgent := cfg.Agents[name]
+		if model.DefaultMaxTokens > 0 {
+			updatedAgent.MaxTokens = model.DefaultMaxTokens
+		} else {
+			updatedAgent.MaxTokens = MaxTokensFallbackDefault
+		}
+		cfg.Agents[name] = updatedAgent
+	} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
+		// Ensure max tokens doesn't exceed half the context window (reasonable limit)
+		logging.Warn("max tokens exceeds half the context window, adjusting",
+			"agent", name,
+			"model", agent.Model,
+			"max_tokens", agent.MaxTokens,
+			"context_window", model.ContextWindow)
+
+		// Update the agent with adjusted max tokens
+		updatedAgent := cfg.Agents[name]
+		updatedAgent.MaxTokens = model.ContextWindow / 2
+		cfg.Agents[name] = updatedAgent
+	}
+
+	// Validate reasoning effort for models that support reasoning
+	if model.CanReason && provider == models.ProviderOpenAI {
+		if agent.ReasoningEffort == "" {
+			// Set default reasoning effort for models that support it
+			logging.Info("setting default reasoning effort for model that supports reasoning",
 				"agent", name,
-				"model", agent.Model,
-				"max_tokens", agent.MaxTokens,
-				"context_window", model.ContextWindow)
+				"model", agent.Model)
 
-			// Update the agent with adjusted max tokens
+			// Update the agent with default reasoning effort
 			updatedAgent := cfg.Agents[name]
-			updatedAgent.MaxTokens = model.ContextWindow / 2
+			updatedAgent.ReasoningEffort = "medium"
 			cfg.Agents[name] = updatedAgent
-		}
-
-		// Validate reasoning effort for models that support reasoning
-		if model.CanReason && provider == models.ProviderOpenAI {
-			if agent.ReasoningEffort == "" {
-				// Set default reasoning effort for models that support it
-				logging.Info("setting default reasoning effort for model that supports reasoning",
+		} else {
+			// Check if reasoning effort is valid (low, medium, high)
+			effort := strings.ToLower(agent.ReasoningEffort)
+			if effort != "low" && effort != "medium" && effort != "high" {
+				logging.Warn("invalid reasoning effort, setting to medium",
 					"agent", name,
-					"model", agent.Model)
+					"model", agent.Model,
+					"reasoning_effort", agent.ReasoningEffort)
 
-				// Update the agent with default reasoning effort
+				// Update the agent with valid reasoning effort
 				updatedAgent := cfg.Agents[name]
 				updatedAgent.ReasoningEffort = "medium"
 				cfg.Agents[name] = updatedAgent
-			} else {
-				// Check if reasoning effort is valid (low, medium, high)
-				effort := strings.ToLower(agent.ReasoningEffort)
-				if effort != "low" && effort != "medium" && effort != "high" {
-					logging.Warn("invalid reasoning effort, setting to medium",
-						"agent", name,
-						"model", agent.Model,
-						"reasoning_effort", agent.ReasoningEffort)
-
-					// Update the agent with valid reasoning effort
-					updatedAgent := cfg.Agents[name]
-					updatedAgent.ReasoningEffort = "medium"
-					cfg.Agents[name] = updatedAgent
-				}
 			}
-		} else if !model.CanReason && agent.ReasoningEffort != "" {
-			// Model doesn't support reasoning but reasoning effort is set
-			logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
-				"agent", name,
-				"model", agent.Model,
-				"reasoning_effort", agent.ReasoningEffort)
+		}
+	} else if !model.CanReason && agent.ReasoningEffort != "" {
+		// Model doesn't support reasoning but reasoning effort is set
+		logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
+			"agent", name,
+			"model", agent.Model,
+			"reasoning_effort", agent.ReasoningEffort)
 
-			// Update the agent to remove reasoning effort
-			updatedAgent := cfg.Agents[name]
-			updatedAgent.ReasoningEffort = ""
-			cfg.Agents[name] = updatedAgent
+		// Update the agent to remove reasoning effort
+		updatedAgent := cfg.Agents[name]
+		updatedAgent.ReasoningEffort = ""
+		cfg.Agents[name] = updatedAgent
+	}
+
+	return nil
+}
+
+// Validate checks if the configuration is valid and applies defaults where needed.
+func Validate() error {
+	if cfg == nil {
+		return fmt.Errorf("config not loaded")
+	}
+
+	// Validate agent models
+	for name, agent := range cfg.Agents {
+		if err := validateAgent(cfg, name, agent); err != nil {
+			return err
 		}
 	}
 
@@ -629,3 +639,36 @@ func WorkingDirectory() string {
 	}
 	return cfg.WorkingDir
 }
+
+func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
+	if cfg == nil {
+		panic("config not loaded")
+	}
+
+	existingAgentCfg := cfg.Agents[agentName]
+
+	model, ok := models.SupportedModels[modelID]
+	if !ok {
+		return fmt.Errorf("model %s not supported", modelID)
+	}
+
+	maxTokens := existingAgentCfg.MaxTokens
+	if model.DefaultMaxTokens > 0 {
+		maxTokens = model.DefaultMaxTokens
+	}
+
+	newAgentCfg := Agent{
+		Model:           modelID,
+		MaxTokens:       maxTokens,
+		ReasoningEffort: existingAgentCfg.ReasoningEffort,
+	}
+	cfg.Agents[agentName] = newAgentCfg
+
+	if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
+		// revert config update on failure
+		cfg.Agents[agentName] = existingAgentCfg
+		return fmt.Errorf("failed to update agent model: %w", err)
+	}
+
+	return nil
+}

internal/llm/agent/agent.go 🔗

@@ -42,6 +42,7 @@ type Service interface {
 	Cancel(sessionID string)
 	IsSessionBusy(sessionID string) bool
 	IsBusy() bool
+	Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
 }
 
 type agent struct {
@@ -436,6 +437,25 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
 	return nil
 }
 
+func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) {
+	if a.IsBusy() {
+		return models.Model{}, fmt.Errorf("cannot change model while processing requests")
+	}
+
+	if err := config.UpdateAgentModel(agentName, modelID); err != nil {
+		return models.Model{}, fmt.Errorf("failed to update config: %w", err)
+	}
+
+	provider, err := createAgentProvider(agentName)
+	if err != nil {
+		return models.Model{}, fmt.Errorf("failed to create provider for model %s: %w", modelID, err)
+	}
+
+	a.provider = provider
+
+	return a.provider.Model(), nil
+}
+
 func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
 	cfg := config.Get()
 	agentConfig, ok := cfg.Agents[agentName]

internal/llm/models/anthropic.go 🔗

@@ -11,8 +11,8 @@ const (
 	Claude3Opus    ModelID = "claude-3-opus"
 )
 
+// https://docs.anthropic.com/en/docs/about-claude/models/all-models
 var AnthropicModels = map[ModelID]Model{
-	// Anthropic
 	Claude35Sonnet: {
 		ID:                 Claude35Sonnet,
 		Name:               "Claude 3.5 Sonnet",
@@ -29,13 +29,13 @@ var AnthropicModels = map[ModelID]Model{
 		ID:                 Claude3Haiku,
 		Name:               "Claude 3 Haiku",
 		Provider:           ProviderAnthropic,
-		APIModel:           "claude-3-haiku-latest",
+		APIModel:           "claude-3-haiku-20240307", // doesn't support "-latest"
 		CostPer1MIn:        0.25,
 		CostPer1MInCached:  0.30,
 		CostPer1MOutCached: 0.03,
 		CostPer1MOut:       1.25,
 		ContextWindow:      200000,
-		DefaultMaxTokens:   5000,
+		DefaultMaxTokens:   4096,
 	},
 	Claude37Sonnet: {
 		ID:                 Claude37Sonnet,

internal/llm/models/models.go 🔗

@@ -33,6 +33,15 @@ const (
 	ProviderMock ModelProvider = "__mock"
 )
 
+// Providers in order of popularity
+var ProviderPopularity = map[ModelProvider]int{
+	ProviderAnthropic: 1,
+	ProviderOpenAI:    2,
+	ProviderGemini:    3,
+	ProviderGROQ:      4,
+	ProviderBedrock:   5,
+}
+
 var SupportedModels = map[ModelID]Model{
 	//
 	// // GEMINI

internal/tui/components/dialog/models.go 🔗

@@ -0,0 +1,363 @@
+package dialog
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/llm/models"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+const (
+	numVisibleModels = 10
+	maxDialogWidth   = 40
+)
+
+// ModelSelectedMsg is sent when a model is selected
+type ModelSelectedMsg struct {
+	Model models.Model
+}
+
+// CloseModelDialogMsg is sent when a model is selected
+type CloseModelDialogMsg struct{}
+
+// ModelDialog interface for the model selection dialog
+type ModelDialog interface {
+	tea.Model
+	layout.Bindings
+}
+
+type modelDialogCmp struct {
+	models             []models.Model
+	provider           models.ModelProvider
+	availableProviders []models.ModelProvider
+
+	selectedIdx     int
+	width           int
+	height          int
+	scrollOffset    int
+	hScrollOffset   int
+	hScrollPossible bool
+}
+
+type modelKeyMap struct {
+	Up     key.Binding
+	Down   key.Binding
+	Left   key.Binding
+	Right  key.Binding
+	Enter  key.Binding
+	Escape key.Binding
+	J      key.Binding
+	K      key.Binding
+	H      key.Binding
+	L      key.Binding
+}
+
+var modelKeys = modelKeyMap{
+	Up: key.NewBinding(
+		key.WithKeys("up"),
+		key.WithHelp("↑", "previous model"),
+	),
+	Down: key.NewBinding(
+		key.WithKeys("down"),
+		key.WithHelp("↓", "next model"),
+	),
+	Left: key.NewBinding(
+		key.WithKeys("left"),
+		key.WithHelp("←", "scroll left"),
+	),
+	Right: key.NewBinding(
+		key.WithKeys("right"),
+		key.WithHelp("→", "scroll right"),
+	),
+	Enter: key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "select model"),
+	),
+	Escape: key.NewBinding(
+		key.WithKeys("esc"),
+		key.WithHelp("esc", "close"),
+	),
+	J: key.NewBinding(
+		key.WithKeys("j"),
+		key.WithHelp("j", "next model"),
+	),
+	K: key.NewBinding(
+		key.WithKeys("k"),
+		key.WithHelp("k", "previous model"),
+	),
+	H: key.NewBinding(
+		key.WithKeys("h"),
+		key.WithHelp("h", "scroll left"),
+	),
+	L: key.NewBinding(
+		key.WithKeys("l"),
+		key.WithHelp("l", "scroll right"),
+	),
+}
+
+func (m *modelDialogCmp) Init() tea.Cmd {
+	m.setupModels()
+	return nil
+}
+
+func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
+			m.moveSelectionUp()
+		case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
+			m.moveSelectionDown()
+		case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
+			if m.hScrollPossible {
+				m.switchProvider(-1)
+			}
+		case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
+			if m.hScrollPossible {
+				m.switchProvider(1)
+			}
+		case key.Matches(msg, modelKeys.Enter):
+			util.ReportInfo(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
+			return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
+		case key.Matches(msg, modelKeys.Escape):
+			return m, util.CmdHandler(CloseModelDialogMsg{})
+		}
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+	}
+
+	return m, nil
+}
+
+// moveSelectionUp moves the selection up or wraps to bottom
+func (m *modelDialogCmp) moveSelectionUp() {
+	if m.selectedIdx > 0 {
+		m.selectedIdx--
+	} else {
+		m.selectedIdx = len(m.models) - 1
+		m.scrollOffset = max(0, len(m.models)-numVisibleModels)
+	}
+
+	// Keep selection visible
+	if m.selectedIdx < m.scrollOffset {
+		m.scrollOffset = m.selectedIdx
+	}
+}
+
+// moveSelectionDown moves the selection down or wraps to top
+func (m *modelDialogCmp) moveSelectionDown() {
+	if m.selectedIdx < len(m.models)-1 {
+		m.selectedIdx++
+	} else {
+		m.selectedIdx = 0
+		m.scrollOffset = 0
+	}
+
+	// Keep selection visible
+	if m.selectedIdx >= m.scrollOffset+numVisibleModels {
+		m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
+	}
+}
+
+func (m *modelDialogCmp) switchProvider(offset int) {
+	newOffset := m.hScrollOffset + offset
+
+	// Ensure we stay within bounds
+	if newOffset < 0 {
+		newOffset = len(m.availableProviders) - 1
+	}
+	if newOffset >= len(m.availableProviders) {
+		newOffset = 0
+	}
+
+	m.hScrollOffset = newOffset
+	m.provider = m.availableProviders[m.hScrollOffset]
+	m.setupModelsForProvider(m.provider)
+}
+
+func (m *modelDialogCmp) View() string {
+	// Capitalize first letter of provider name
+	providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:])
+	title := styles.BaseStyle.
+		Foreground(styles.PrimaryColor).
+		Bold(true).
+		Width(maxDialogWidth).
+		Padding(0, 0, 1).
+		Render(fmt.Sprintf("Select %s Model", providerName))
+
+	// Render visible models
+	endIdx := min(m.scrollOffset+numVisibleModels, len(m.models))
+	modelItems := make([]string, 0, endIdx-m.scrollOffset)
+
+	for i := m.scrollOffset; i < endIdx; i++ {
+		itemStyle := styles.BaseStyle.Width(maxDialogWidth)
+		if i == m.selectedIdx {
+			itemStyle = itemStyle.Background(styles.PrimaryColor).
+				Foreground(styles.Background).Bold(true)
+		}
+		modelItems = append(modelItems, itemStyle.Render(m.models[i].Name))
+	}
+
+	scrollIndicator := m.getScrollIndicators(maxDialogWidth)
+
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		title,
+		styles.BaseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
+		scrollIndicator,
+	)
+
+	return styles.BaseStyle.Padding(1, 2).
+		Border(lipgloss.RoundedBorder()).
+		BorderBackground(styles.Background).
+		BorderForeground(styles.ForgroundDim).
+		Width(lipgloss.Width(content) + 4).
+		Render(content)
+}
+
+func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
+	var indicator string
+
+	if len(m.models) > numVisibleModels {
+		if m.scrollOffset > 0 {
+			indicator += "↑ "
+		}
+		if m.scrollOffset+numVisibleModels < len(m.models) {
+			indicator += "↓ "
+		}
+	}
+
+	if m.hScrollPossible {
+		if m.hScrollOffset > 0 {
+			indicator = "← " + indicator
+		}
+		if m.hScrollOffset < len(m.availableProviders)-1 {
+			indicator += "→"
+		}
+	}
+
+	if indicator == "" {
+		return ""
+	}
+
+	return styles.BaseStyle.
+		Foreground(styles.PrimaryColor).
+		Width(maxWidth).
+		Align(lipgloss.Right).
+		Bold(true).
+		Render(indicator)
+}
+
+func (m *modelDialogCmp) BindingKeys() []key.Binding {
+	return layout.KeyMapToSlice(modelKeys)
+}
+
+func (m *modelDialogCmp) setupModels() {
+	cfg := config.Get()
+
+	m.availableProviders = getEnabledProviders(cfg)
+	m.hScrollPossible = len(m.availableProviders) > 1
+
+	agentCfg := cfg.Agents[config.AgentCoder]
+	selectedModelId := agentCfg.Model
+	modelInfo := models.SupportedModels[selectedModelId]
+
+	m.provider = modelInfo.Provider
+	m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
+
+	m.setupModelsForProvider(m.provider)
+}
+
+func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
+	var providers []models.ModelProvider
+	for providerId, provider := range cfg.Providers {
+		if !provider.Disabled {
+			providers = append(providers, providerId)
+		}
+	}
+
+	// Sort by provider popularity
+	slices.SortFunc(providers, func(a, b models.ModelProvider) int {
+		rA := models.ProviderPopularity[a]
+		rB := models.ProviderPopularity[b]
+
+		// models not included in popularity ranking default to last
+		if rA == 0 {
+			rA = 999
+		}
+		if rB == 0 {
+			rB = 999
+		}
+		return rA - rB
+	})
+	return providers
+}
+
+// findProviderIndex returns the index of the provider in the list, or -1 if not found
+func findProviderIndex(providers []models.ModelProvider, provider models.ModelProvider) int {
+	for i, p := range providers {
+		if p == provider {
+			return i
+		}
+	}
+	return -1
+}
+
+func (m *modelDialogCmp) setupModelsForProvider(provider models.ModelProvider) {
+	cfg := config.Get()
+	agentCfg := cfg.Agents[config.AgentCoder]
+	selectedModelId := agentCfg.Model
+
+	m.provider = provider
+	m.models = getModelsForProvider(provider)
+	m.selectedIdx = 0
+	m.scrollOffset = 0
+
+	// Try to select the current model if it belongs to this provider
+	if provider == models.SupportedModels[selectedModelId].Provider {
+		for i, model := range m.models {
+			if model.ID == selectedModelId {
+				m.selectedIdx = i
+				// Adjust scroll position to keep selected model visible
+				if m.selectedIdx >= numVisibleModels {
+					m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
+				}
+				break
+			}
+		}
+	}
+}
+
+func getModelsForProvider(provider models.ModelProvider) []models.Model {
+	var providerModels []models.Model
+	for _, model := range models.SupportedModels {
+		if model.Provider == provider {
+			providerModels = append(providerModels, model)
+		}
+	}
+
+	// reverse alphabetical order (if llm naming was consistent latest would appear first)
+	slices.SortFunc(providerModels, func(a, b models.Model) int {
+		if a.Name > b.Name {
+			return -1
+		} else if a.Name < b.Name {
+			return 1
+		}
+		return 0
+	})
+
+	return providerModels
+}
+
+func NewModelDialogCmp() ModelDialog {
+	return &modelDialogCmp{}
+}

internal/tui/tui.go 🔗

@@ -2,6 +2,7 @@ package tui
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
@@ -25,6 +26,7 @@ type keyMap struct {
 	Help          key.Binding
 	SwitchSession key.Binding
 	Commands      key.Binding
+	Models        key.Binding
 }
 
 var keys = keyMap{
@@ -51,6 +53,11 @@ var keys = keyMap{
 		key.WithKeys("ctrl+k"),
 		key.WithHelp("ctrl+k", "commands"),
 	),
+
+	Models: key.NewBinding(
+		key.WithKeys("ctrl+o"),
+		key.WithHelp("ctrl+o", "model selection"),
+	),
 }
 
 var helpEsc = key.NewBinding(
@@ -93,6 +100,9 @@ type appModel struct {
 	commandDialog     dialog.CommandDialog
 	commands          []dialog.Command
 
+	showModelDialog bool
+	modelDialog     dialog.ModelDialog
+
 	showInitDialog bool
 	initDialog     dialog.InitDialogCmp
 }
@@ -112,6 +122,8 @@ func (a appModel) Init() tea.Cmd {
 	cmds = append(cmds, cmd)
 	cmd = a.commandDialog.Init()
 	cmds = append(cmds, cmd)
+	cmd = a.modelDialog.Init()
+	cmds = append(cmds, cmd)
 	cmd = a.initDialog.Init()
 	cmds = append(cmds, cmd)
 
@@ -243,6 +255,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.showCommandDialog = false
 		return a, nil
 
+	case dialog.CloseModelDialogMsg:
+		a.showModelDialog = false
+		return a, nil
+
+	case dialog.ModelSelectedMsg:
+		a.showModelDialog = false
+
+		model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
+		if err != nil {
+			return a, util.ReportError(err)
+		}
+
+		return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
+
 	case dialog.ShowInitDialogMsg:
 		a.showInitDialog = msg.Show
 		return a, nil
@@ -298,6 +324,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if a.showCommandDialog {
 				a.showCommandDialog = false
 			}
+			if a.showModelDialog {
+				a.showModelDialog = false
+			}
 			return a, nil
 		case key.Matches(msg, keys.SwitchSession):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
@@ -325,6 +354,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, nil
 			}
 			return a, nil
+		case key.Matches(msg, keys.Models):
+			if a.showModelDialog {
+				a.showModelDialog = false
+				return a, nil
+			}
+
+			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+				a.showModelDialog = true
+				return a, nil
+			}
+			return a, nil
 		case key.Matches(msg, logsKeyReturnKey):
 			if a.currentPage == page.LogsPage {
 				return a, a.moveToPage(page.ChatPage)
@@ -405,6 +445,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 
+	if a.showModelDialog {
+		d, modelCmd := a.modelDialog.Update(msg)
+		a.modelDialog = d.(dialog.ModelDialog)
+		cmds = append(cmds, modelCmd)
+		// Only block key messages send all other messages down
+		if _, ok := msg.(tea.KeyMsg); ok {
+			return a, tea.Batch(cmds...)
+		}
+	}
+
 	if a.showInitDialog {
 		d, initCmd := a.initDialog.Update(msg)
 		a.initDialog = d.(dialog.InitDialogCmp)
@@ -538,6 +588,21 @@ func (a appModel) View() string {
 		)
 	}
 
+	if a.showModelDialog {
+		overlay := a.modelDialog.View()
+		row := lipgloss.Height(appView) / 2
+		row -= lipgloss.Height(overlay) / 2
+		col := lipgloss.Width(appView) / 2
+		col -= lipgloss.Width(overlay) / 2
+		appView = layout.PlaceOverlay(
+			col,
+			row,
+			overlay,
+			appView,
+			true,
+		)
+	}
+
 	if a.showCommandDialog {
 		overlay := a.commandDialog.View()
 		row := lipgloss.Height(appView) / 2
@@ -577,6 +642,7 @@ func New(app *app.App) tea.Model {
 		quit:          dialog.NewQuitCmp(),
 		sessionDialog: dialog.NewSessionDialogCmp(),
 		commandDialog: dialog.NewCommandDialogCmp(),
+		modelDialog:   dialog.NewModelDialogCmp(),
 		permissions:   dialog.NewPermissionDialogCmp(),
 		initDialog:    dialog.NewInitDialogCmp(),
 		app:           app,