Detailed changes
@@ -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 |
@@ -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
@@ -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
+}
@@ -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]
@@ -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,
@@ -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
@@ -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{}
+}
@@ -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,