on main help sign in
feat: model selection for given provider (#57)

* 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

commitb3c0285db3dd5d5140481bf5118812e8dbc89795 authorAiden Cline <63023139+rekram1-node@users.noreply.github.com> dateMon 28 Apr 2025, 12:25 (2 years ago) parent805aeff
8 files changed+622−109
README.md+12−1 internal/app/app.go+1 internal/config/config.go+148−105 internal/llm/agent/agent.go+20 internal/llm/models/anthropic.go+3−3 internal/llm/models/models.go+9 internal/tui/components/dialog/models.go+363 internal/tui/tui.go+66
README.md+12−1
@@ -168,7 +168,7 @@ OpenCode supports a variety of AI models from different providers:
168168
169169### Groq
170170
171−- Llama 4 Maverick (17b-128e-instruct)
171+- Llama 4 Maverick (17b-128e-instruct)
172172- Llama 4 Scout (17b-16e-instruct)
173173- QWEN QWQ-32b
174174- Deepseek R1 distill Llama 70b
@@ -216,6 +216,7 @@ opencode -c /path/to/project
216216| `Ctrl+L` | View logs |
217217| `Ctrl+A` | Switch session |
218218| `Ctrl+K` | Command dialog |
219+| `Ctrl+O` | Toggle model selection dialog |
219220| `Esc` | Close current overlay/dialog or return to previous mode |
220221
221222### Chat Page Shortcuts
@@ -245,6 +246,16 @@ opencode -c /path/to/project
245246| `Enter` | Select session |
246247| `Esc` | Close dialog |
247248
249+### Model Dialog Shortcuts
250+
251+| Shortcut | Action |
252+| ---------- | ----------------- |
253+| `↑` or `k` | Move up |
254+| `↓` or `j` | Move down |
255+| `←` or `h` | Previous provider |
256+| `→` or `l` | Next provider |
257+| `Esc` | Close dialog |
258+
248259### Permission Dialog Shortcuts
249260
250261| Shortcut | Action |
internal/app/app.go+1
@@ -73,6 +73,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
7373 return app, nil
7474}
7575
76+
7677// Shutdown performs a clean shutdown of the application
7778func (app *App) Shutdown() {
7879 // Cancel all watcher goroutines
internal/config/config.go+148−105
@@ -83,6 +83,8 @@ const (
8383 defaultDataDirectory = ".opencode"
8484 defaultLogLevel = "info"
8585 appName = "opencode"
86+
87+ MaxTokensFallbackDefault = 4096
8688)
8789
8890var defaultContextPaths = []string{
@@ -347,60 +349,33 @@ func applyDefaultValues() {
347349 }
348350}
349351
350−// Validate checks if the configuration is valid and applies defaults where needed.
351352// It validates model IDs and providers, ensuring they are supported.
352−func Validate() error {
353− if cfg == nil {
354− return fmt.Errorf("config not loaded")
355− }
356−
357− // Validate agent models
358− for name, agent := range cfg.Agents {
359− // Check if model exists
360− model, modelExists := models.SupportedModels[agent.Model]
361− if !modelExists {
362− logging.Warn("unsupported model configured, reverting to default",
363− "agent", name,
364− "configured_model", agent.Model)
365−
366− // Set default model based on available providers
367− if setDefaultModelForAgent(name) {
368− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
369− } else {
370− return fmt.Errorf("no valid provider available for agent %s", name)
371− }
372− continue
353+func validateAgent(cfg *Config, name AgentName, agent Agent) error {
354+ // Check if model exists
355+ model, modelExists := models.SupportedModels[agent.Model]
356+ if !modelExists {
357+ logging.Warn("unsupported model configured, reverting to default",
358+ "agent", name,
359+ "configured_model", agent.Model)
360+
361+ // Set default model based on available providers
362+ if setDefaultModelForAgent(name) {
363+ logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
364+ } else {
365+ return fmt.Errorf("no valid provider available for agent %s", name)
373366 }
367+ return nil
368+ }
374369
375− // Check if provider for the model is configured
376− provider := model.Provider
377− providerCfg, providerExists := cfg.Providers[provider]
370+ // Check if provider for the model is configured
371+ provider := model.Provider
372+ providerCfg, providerExists := cfg.Providers[provider]
378373
379− if !providerExists {
380− // Provider not configured, check if we have environment variables
381− apiKey := getProviderAPIKey(provider)
382− if apiKey == "" {
383− logging.Warn("provider not configured for model, reverting to default",
384− "agent", name,
385− "model", agent.Model,
386− "provider", provider)
387−
388− // Set default model based on available providers
389− if setDefaultModelForAgent(name) {
390− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
391− } else {
392− return fmt.Errorf("no valid provider available for agent %s", name)
393− }
394− } else {
395− // Add provider with API key from environment
396− cfg.Providers[provider] = Provider{
397− APIKey: apiKey,
398− }
399− logging.Info("added provider from environment", "provider", provider)
400− }
401− } else if providerCfg.Disabled || providerCfg.APIKey == "" {
402− // Provider is disabled or has no API key
403− logging.Warn("provider is disabled or has no API key, reverting to default",
374+ if !providerExists {
375+ // Provider not configured, check if we have environment variables
376+ apiKey := getProviderAPIKey(provider)
377+ if apiKey == "" {
378+ logging.Warn("provider not configured for model, reverting to default",
404379 "agent", name,
405380 "model", agent.Model,
406381 "provider", provider)
@@ -411,75 +386,110 @@ func Validate() error {
411386 } else {
412387 return fmt.Errorf("no valid provider available for agent %s", name)
413388 }
389+ } else {
390+ // Add provider with API key from environment
391+ cfg.Providers[provider] = Provider{
392+ APIKey: apiKey,
393+ }
394+ logging.Info("added provider from environment", "provider", provider)
395+ }
396+ } else if providerCfg.Disabled || providerCfg.APIKey == "" {
397+ // Provider is disabled or has no API key
398+ logging.Warn("provider is disabled or has no API key, reverting to default",
399+ "agent", name,
400+ "model", agent.Model,
401+ "provider", provider)
402+
403+ // Set default model based on available providers
404+ if setDefaultModelForAgent(name) {
405+ logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
406+ } else {
407+ return fmt.Errorf("no valid provider available for agent %s", name)
414408 }
409+ }
415410
416− // Validate max tokens
417− if agent.MaxTokens <= 0 {
418− logging.Warn("invalid max tokens, setting to default",
419− "agent", name,
420− "model", agent.Model,
421− "max_tokens", agent.MaxTokens)
411+ // Validate max tokens
412+ if agent.MaxTokens <= 0 {
413+ logging.Warn("invalid max tokens, setting to default",
414+ "agent", name,
415+ "model", agent.Model,
416+ "max_tokens", agent.MaxTokens)
422417
423− // Update the agent with default max tokens
424− updatedAgent := cfg.Agents[name]
425− if model.DefaultMaxTokens > 0 {
426− updatedAgent.MaxTokens = model.DefaultMaxTokens
427− } else {
428− updatedAgent.MaxTokens = 4096 // Fallback default
429− }
430− cfg.Agents[name] = updatedAgent
431− } else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
432− // Ensure max tokens doesn't exceed half the context window (reasonable limit)
433− logging.Warn("max tokens exceeds half the context window, adjusting",
418+ // Update the agent with default max tokens
419+ updatedAgent := cfg.Agents[name]
420+ if model.DefaultMaxTokens > 0 {
421+ updatedAgent.MaxTokens = model.DefaultMaxTokens
422+ } else {
423+ updatedAgent.MaxTokens = MaxTokensFallbackDefault
424+ }
425+ cfg.Agents[name] = updatedAgent
426+ } else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
427+ // Ensure max tokens doesn't exceed half the context window (reasonable limit)
428+ logging.Warn("max tokens exceeds half the context window, adjusting",
429+ "agent", name,
430+ "model", agent.Model,
431+ "max_tokens", agent.MaxTokens,
432+ "context_window", model.ContextWindow)
433+
434+ // Update the agent with adjusted max tokens
435+ updatedAgent := cfg.Agents[name]
436+ updatedAgent.MaxTokens = model.ContextWindow / 2
437+ cfg.Agents[name] = updatedAgent
438+ }
439+
440+ // Validate reasoning effort for models that support reasoning
441+ if model.CanReason && provider == models.ProviderOpenAI {
442+ if agent.ReasoningEffort == "" {
443+ // Set default reasoning effort for models that support it
444+ logging.Info("setting default reasoning effort for model that supports reasoning",
434445 "agent", name,
435− "model", agent.Model,
436− "max_tokens", agent.MaxTokens,
437− "context_window", model.ContextWindow)
446+ "model", agent.Model)
438447
439− // Update the agent with adjusted max tokens
448+ // Update the agent with default reasoning effort
440449 updatedAgent := cfg.Agents[name]
441− updatedAgent.MaxTokens = model.ContextWindow / 2
450+ updatedAgent.ReasoningEffort = "medium"
442451 cfg.Agents[name] = updatedAgent
443− }
444−
445− // Validate reasoning effort for models that support reasoning
446− if model.CanReason && provider == models.ProviderOpenAI {
447− if agent.ReasoningEffort == "" {
448− // Set default reasoning effort for models that support it
449− logging.Info("setting default reasoning effort for model that supports reasoning",
452+ } else {
453+ // Check if reasoning effort is valid (low, medium, high)
454+ effort := strings.ToLower(agent.ReasoningEffort)
455+ if effort != "low" && effort != "medium" && effort != "high" {
456+ logging.Warn("invalid reasoning effort, setting to medium",
450457 "agent", name,
451− "model", agent.Model)
458+ "model", agent.Model,
459+ "reasoning_effort", agent.ReasoningEffort)
452460
453− // Update the agent with default reasoning effort
461+ // Update the agent with valid reasoning effort
454462 updatedAgent := cfg.Agents[name]
455463 updatedAgent.ReasoningEffort = "medium"
456464 cfg.Agents[name] = updatedAgent
457− } else {
458− // Check if reasoning effort is valid (low, medium, high)
459− effort := strings.ToLower(agent.ReasoningEffort)
460− if effort != "low" && effort != "medium" && effort != "high" {
461− logging.Warn("invalid reasoning effort, setting to medium",
462− "agent", name,
463− "model", agent.Model,
464− "reasoning_effort", agent.ReasoningEffort)
465−
466− // Update the agent with valid reasoning effort
467− updatedAgent := cfg.Agents[name]
468− updatedAgent.ReasoningEffort = "medium"
469− cfg.Agents[name] = updatedAgent
470− }
471465 }
472− } else if !model.CanReason && agent.ReasoningEffort != "" {
473− // Model doesn't support reasoning but reasoning effort is set
474− logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
475− "agent", name,
476− "model", agent.Model,
477− "reasoning_effort", agent.ReasoningEffort)
466+ }
467+ } else if !model.CanReason && agent.ReasoningEffort != "" {
468+ // Model doesn't support reasoning but reasoning effort is set
469+ logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
470+ "agent", name,
471+ "model", agent.Model,
472+ "reasoning_effort", agent.ReasoningEffort)
478473
479− // Update the agent to remove reasoning effort
480− updatedAgent := cfg.Agents[name]
481− updatedAgent.ReasoningEffort = ""
482− cfg.Agents[name] = updatedAgent
474+ // Update the agent to remove reasoning effort
475+ updatedAgent := cfg.Agents[name]
476+ updatedAgent.ReasoningEffort = ""
477+ cfg.Agents[name] = updatedAgent
478+ }
479+
480+ return nil
481+}
482+
483+// Validate checks if the configuration is valid and applies defaults where needed.
484+func Validate() error {
485+ if cfg == nil {
486+ return fmt.Errorf("config not loaded")
487+ }
488+
489+ // Validate agent models
490+ for name, agent := range cfg.Agents {
491+ if err := validateAgent(cfg, name, agent); err != nil {
492+ return err
483493 }
484494 }
485495
@@ -629,3 +639,36 @@ func WorkingDirectory() string {
629639 }
630640 return cfg.WorkingDir
631641}
642+
643+func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
644+ if cfg == nil {
645+ panic("config not loaded")
646+ }
647+
648+ existingAgentCfg := cfg.Agents[agentName]
649+
650+ model, ok := models.SupportedModels[modelID]
651+ if !ok {
652+ return fmt.Errorf("model %s not supported", modelID)
653+ }
654+
655+ maxTokens := existingAgentCfg.MaxTokens
656+ if model.DefaultMaxTokens > 0 {
657+ maxTokens = model.DefaultMaxTokens
658+ }
659+
660+ newAgentCfg := Agent{
661+ Model: modelID,
662+ MaxTokens: maxTokens,
663+ ReasoningEffort: existingAgentCfg.ReasoningEffort,
664+ }
665+ cfg.Agents[agentName] = newAgentCfg
666+
667+ if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
668+ // revert config update on failure
669+ cfg.Agents[agentName] = existingAgentCfg
670+ return fmt.Errorf("failed to update agent model: %w", err)
671+ }
672+
673+ return nil
674+}
internal/llm/agent/agent.go+20
@@ -42,6 +42,7 @@ type Service interface {
4242 Cancel(sessionID string)
4343 IsSessionBusy(sessionID string) bool
4444 IsBusy() bool
45+ Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
4546}
4647
4748type agent struct {
@@ -436,6 +437,25 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
436437 return nil
437438}
438439
440+func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) {
441+ if a.IsBusy() {
442+ return models.Model{}, fmt.Errorf("cannot change model while processing requests")
443+ }
444+
445+ if err := config.UpdateAgentModel(agentName, modelID); err != nil {
446+ return models.Model{}, fmt.Errorf("failed to update config: %w", err)
447+ }
448+
449+ provider, err := createAgentProvider(agentName)
450+ if err != nil {
451+ return models.Model{}, fmt.Errorf("failed to create provider for model %s: %w", modelID, err)
452+ }
453+
454+ a.provider = provider
455+
456+ return a.provider.Model(), nil
457+}
458+
439459func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
440460 cfg := config.Get()
441461 agentConfig, ok := cfg.Agents[agentName]
internal/llm/models/anthropic.go+3−3
@@ -11,8 +11,8 @@ const (
1111 Claude3Opus ModelID = "claude-3-opus"
1212)
1313
14+// https://docs.anthropic.com/en/docs/about-claude/models/all-models
1415var AnthropicModels = map[ModelID]Model{
15− // Anthropic
1616 Claude35Sonnet: {
1717 ID: Claude35Sonnet,
1818 Name: "Claude 3.5 Sonnet",
@@ -29,13 +29,13 @@ var AnthropicModels = map[ModelID]Model{
2929 ID: Claude3Haiku,
3030 Name: "Claude 3 Haiku",
3131 Provider: ProviderAnthropic,
32− APIModel: "claude-3-haiku-latest",
32+ APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
3333 CostPer1MIn: 0.25,
3434 CostPer1MInCached: 0.30,
3535 CostPer1MOutCached: 0.03,
3636 CostPer1MOut: 1.25,
3737 ContextWindow: 200000,
38− DefaultMaxTokens: 5000,
38+ DefaultMaxTokens: 4096,
3939 },
4040 Claude37Sonnet: {
4141 ID: Claude37Sonnet,
internal/llm/models/models.go+9
@@ -33,6 +33,15 @@ const (
3333 ProviderMock ModelProvider = "__mock"
3434)
3535
36+// Providers in order of popularity
37+var ProviderPopularity = map[ModelProvider]int{
38+ ProviderAnthropic: 1,
39+ ProviderOpenAI: 2,
40+ ProviderGemini: 3,
41+ ProviderGROQ: 4,
42+ ProviderBedrock: 5,
43+}
44+
3645var SupportedModels = map[ModelID]Model{
3746 //
3847 // // GEMINI
internal/tui/components/dialog/models.gonew file+363
@@ -0,0 +1,363 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/key"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/opencode-ai/opencode/internal/llm/models"
13+ "github.com/opencode-ai/opencode/internal/tui/layout"
14+ "github.com/opencode-ai/opencode/internal/tui/styles"
15+ "github.com/opencode-ai/opencode/internal/tui/util"
16+)
17+
18+const (
19+ numVisibleModels = 10
20+ maxDialogWidth = 40
21+)
22+
23+// ModelSelectedMsg is sent when a model is selected
24+type ModelSelectedMsg struct {
25+ Model models.Model
26+}
27+
28+// CloseModelDialogMsg is sent when a model is selected
29+type CloseModelDialogMsg struct{}
30+
31+// ModelDialog interface for the model selection dialog
32+type ModelDialog interface {
33+ tea.Model
34+ layout.Bindings
35+}
36+
37+type modelDialogCmp struct {
38+ models []models.Model
39+ provider models.ModelProvider
40+ availableProviders []models.ModelProvider
41+
42+ selectedIdx int
43+ width int
44+ height int
45+ scrollOffset int
46+ hScrollOffset int
47+ hScrollPossible bool
48+}
49+
50+type modelKeyMap struct {
51+ Up key.Binding
52+ Down key.Binding
53+ Left key.Binding
54+ Right key.Binding
55+ Enter key.Binding
56+ Escape key.Binding
57+ J key.Binding
58+ K key.Binding
59+ H key.Binding
60+ L key.Binding
61+}
62+
63+var modelKeys = modelKeyMap{
64+ Up: key.NewBinding(
65+ key.WithKeys("up"),
66+ key.WithHelp("↑", "previous model"),
67+ ),
68+ Down: key.NewBinding(
69+ key.WithKeys("down"),
70+ key.WithHelp("↓", "next model"),
71+ ),
72+ Left: key.NewBinding(
73+ key.WithKeys("left"),
74+ key.WithHelp("←", "scroll left"),
75+ ),
76+ Right: key.NewBinding(
77+ key.WithKeys("right"),
78+ key.WithHelp("→", "scroll right"),
79+ ),
80+ Enter: key.NewBinding(
81+ key.WithKeys("enter"),
82+ key.WithHelp("enter", "select model"),
83+ ),
84+ Escape: key.NewBinding(
85+ key.WithKeys("esc"),
86+ key.WithHelp("esc", "close"),
87+ ),
88+ J: key.NewBinding(
89+ key.WithKeys("j"),
90+ key.WithHelp("j", "next model"),
91+ ),
92+ K: key.NewBinding(
93+ key.WithKeys("k"),
94+ key.WithHelp("k", "previous model"),
95+ ),
96+ H: key.NewBinding(
97+ key.WithKeys("h"),
98+ key.WithHelp("h", "scroll left"),
99+ ),
100+ L: key.NewBinding(
101+ key.WithKeys("l"),
102+ key.WithHelp("l", "scroll right"),
103+ ),
104+}
105+
106+func (m *modelDialogCmp) Init() tea.Cmd {
107+ m.setupModels()
108+ return nil
109+}
110+
111+func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112+ switch msg := msg.(type) {
113+ case tea.KeyMsg:
114+ switch {
115+ case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
116+ m.moveSelectionUp()
117+ case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
118+ m.moveSelectionDown()
119+ case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
120+ if m.hScrollPossible {
121+ m.switchProvider(-1)
122+ }
123+ case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
124+ if m.hScrollPossible {
125+ m.switchProvider(1)
126+ }
127+ case key.Matches(msg, modelKeys.Enter):
128+ util.ReportInfo(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
129+ return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
130+ case key.Matches(msg, modelKeys.Escape):
131+ return m, util.CmdHandler(CloseModelDialogMsg{})
132+ }
133+ case tea.WindowSizeMsg:
134+ m.width = msg.Width
135+ m.height = msg.Height
136+ }
137+
138+ return m, nil
139+}
140+
141+// moveSelectionUp moves the selection up or wraps to bottom
142+func (m *modelDialogCmp) moveSelectionUp() {
143+ if m.selectedIdx > 0 {
144+ m.selectedIdx--
145+ } else {
146+ m.selectedIdx = len(m.models) - 1
147+ m.scrollOffset = max(0, len(m.models)-numVisibleModels)
148+ }
149+
150+ // Keep selection visible
151+ if m.selectedIdx < m.scrollOffset {
152+ m.scrollOffset = m.selectedIdx
153+ }
154+}
155+
156+// moveSelectionDown moves the selection down or wraps to top
157+func (m *modelDialogCmp) moveSelectionDown() {
158+ if m.selectedIdx < len(m.models)-1 {
159+ m.selectedIdx++
160+ } else {
161+ m.selectedIdx = 0
162+ m.scrollOffset = 0
163+ }
164+
165+ // Keep selection visible
166+ if m.selectedIdx >= m.scrollOffset+numVisibleModels {
167+ m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
168+ }
169+}
170+
171+func (m *modelDialogCmp) switchProvider(offset int) {
172+ newOffset := m.hScrollOffset + offset
173+
174+ // Ensure we stay within bounds
175+ if newOffset < 0 {
176+ newOffset = len(m.availableProviders) - 1
177+ }
178+ if newOffset >= len(m.availableProviders) {
179+ newOffset = 0
180+ }
181+
182+ m.hScrollOffset = newOffset
183+ m.provider = m.availableProviders[m.hScrollOffset]
184+ m.setupModelsForProvider(m.provider)
185+}
186+
187+func (m *modelDialogCmp) View() string {
188+ // Capitalize first letter of provider name
189+ providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:])
190+ title := styles.BaseStyle.
191+ Foreground(styles.PrimaryColor).
192+ Bold(true).
193+ Width(maxDialogWidth).
194+ Padding(0, 0, 1).
195+ Render(fmt.Sprintf("Select %s Model", providerName))
196+
197+ // Render visible models
198+ endIdx := min(m.scrollOffset+numVisibleModels, len(m.models))
199+ modelItems := make([]string, 0, endIdx-m.scrollOffset)
200+
201+ for i := m.scrollOffset; i < endIdx; i++ {
202+ itemStyle := styles.BaseStyle.Width(maxDialogWidth)
203+ if i == m.selectedIdx {
204+ itemStyle = itemStyle.Background(styles.PrimaryColor).
205+ Foreground(styles.Background).Bold(true)
206+ }
207+ modelItems = append(modelItems, itemStyle.Render(m.models[i].Name))
208+ }
209+
210+ scrollIndicator := m.getScrollIndicators(maxDialogWidth)
211+
212+ content := lipgloss.JoinVertical(
213+ lipgloss.Left,
214+ title,
215+ styles.BaseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
216+ scrollIndicator,
217+ )
218+
219+ return styles.BaseStyle.Padding(1, 2).
220+ Border(lipgloss.RoundedBorder()).
221+ BorderBackground(styles.Background).
222+ BorderForeground(styles.ForgroundDim).
223+ Width(lipgloss.Width(content) + 4).
224+ Render(content)
225+}
226+
227+func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
228+ var indicator string
229+
230+ if len(m.models) > numVisibleModels {
231+ if m.scrollOffset > 0 {
232+ indicator += "↑ "
233+ }
234+ if m.scrollOffset+numVisibleModels < len(m.models) {
235+ indicator += "↓ "
236+ }
237+ }
238+
239+ if m.hScrollPossible {
240+ if m.hScrollOffset > 0 {
241+ indicator = "← " + indicator
242+ }
243+ if m.hScrollOffset < len(m.availableProviders)-1 {
244+ indicator += "→"
245+ }
246+ }
247+
248+ if indicator == "" {
249+ return ""
250+ }
251+
252+ return styles.BaseStyle.
253+ Foreground(styles.PrimaryColor).
254+ Width(maxWidth).
255+ Align(lipgloss.Right).
256+ Bold(true).
257+ Render(indicator)
258+}
259+
260+func (m *modelDialogCmp) BindingKeys() []key.Binding {
261+ return layout.KeyMapToSlice(modelKeys)
262+}
263+
264+func (m *modelDialogCmp) setupModels() {
265+ cfg := config.Get()
266+
267+ m.availableProviders = getEnabledProviders(cfg)
268+ m.hScrollPossible = len(m.availableProviders) > 1
269+
270+ agentCfg := cfg.Agents[config.AgentCoder]
271+ selectedModelId := agentCfg.Model
272+ modelInfo := models.SupportedModels[selectedModelId]
273+
274+ m.provider = modelInfo.Provider
275+ m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
276+
277+ m.setupModelsForProvider(m.provider)
278+}
279+
280+func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
281+ var providers []models.ModelProvider
282+ for providerId, provider := range cfg.Providers {
283+ if !provider.Disabled {
284+ providers = append(providers, providerId)
285+ }
286+ }
287+
288+ // Sort by provider popularity
289+ slices.SortFunc(providers, func(a, b models.ModelProvider) int {
290+ rA := models.ProviderPopularity[a]
291+ rB := models.ProviderPopularity[b]
292+
293+ // models not included in popularity ranking default to last
294+ if rA == 0 {
295+ rA = 999
296+ }
297+ if rB == 0 {
298+ rB = 999
299+ }
300+ return rA - rB
301+ })
302+ return providers
303+}
304+
305+// findProviderIndex returns the index of the provider in the list, or -1 if not found
306+func findProviderIndex(providers []models.ModelProvider, provider models.ModelProvider) int {
307+ for i, p := range providers {
308+ if p == provider {
309+ return i
310+ }
311+ }
312+ return -1
313+}
314+
315+func (m *modelDialogCmp) setupModelsForProvider(provider models.ModelProvider) {
316+ cfg := config.Get()
317+ agentCfg := cfg.Agents[config.AgentCoder]
318+ selectedModelId := agentCfg.Model
319+
320+ m.provider = provider
321+ m.models = getModelsForProvider(provider)
322+ m.selectedIdx = 0
323+ m.scrollOffset = 0
324+
325+ // Try to select the current model if it belongs to this provider
326+ if provider == models.SupportedModels[selectedModelId].Provider {
327+ for i, model := range m.models {
328+ if model.ID == selectedModelId {
329+ m.selectedIdx = i
330+ // Adjust scroll position to keep selected model visible
331+ if m.selectedIdx >= numVisibleModels {
332+ m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
333+ }
334+ break
335+ }
336+ }
337+ }
338+}
339+
340+func getModelsForProvider(provider models.ModelProvider) []models.Model {
341+ var providerModels []models.Model
342+ for _, model := range models.SupportedModels {
343+ if model.Provider == provider {
344+ providerModels = append(providerModels, model)
345+ }
346+ }
347+
348+ // reverse alphabetical order (if llm naming was consistent latest would appear first)
349+ slices.SortFunc(providerModels, func(a, b models.Model) int {
350+ if a.Name > b.Name {
351+ return -1
352+ } else if a.Name < b.Name {
353+ return 1
354+ }
355+ return 0
356+ })
357+
358+ return providerModels
359+}
360+
361+func NewModelDialogCmp() ModelDialog {
362+ return &modelDialogCmp{}
363+}
internal/tui/tui.go+66
@@ -2,6 +2,7 @@ package tui
22
33import (
44 "context"
5+ "fmt"
56
67 "github.com/charmbracelet/bubbles/key"
78 tea "github.com/charmbracelet/bubbletea"
@@ -25,6 +26,7 @@ type keyMap struct {
2526 Help key.Binding
2627 SwitchSession key.Binding
2728 Commands key.Binding
29+ Models key.Binding
2830}
2931
3032var keys = keyMap{
@@ -51,6 +53,11 @@ var keys = keyMap{
5153 key.WithKeys("ctrl+k"),
5254 key.WithHelp("ctrl+k", "commands"),
5355 ),
56+
57+ Models: key.NewBinding(
58+ key.WithKeys("ctrl+o"),
59+ key.WithHelp("ctrl+o", "model selection"),
60+ ),
5461}
5562
5663var helpEsc = key.NewBinding(
@@ -93,6 +100,9 @@ type appModel struct {
93100 commandDialog dialog.CommandDialog
94101 commands []dialog.Command
95102
103+ showModelDialog bool
104+ modelDialog dialog.ModelDialog
105+
96106 showInitDialog bool
97107 initDialog dialog.InitDialogCmp
98108}
@@ -112,6 +122,8 @@ func (a appModel) Init() tea.Cmd {
112122 cmds = append(cmds, cmd)
113123 cmd = a.commandDialog.Init()
114124 cmds = append(cmds, cmd)
125+ cmd = a.modelDialog.Init()
126+ cmds = append(cmds, cmd)
115127 cmd = a.initDialog.Init()
116128 cmds = append(cmds, cmd)
117129
@@ -243,6 +255,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
243255 a.showCommandDialog = false
244256 return a, nil
245257
258+ case dialog.CloseModelDialogMsg:
259+ a.showModelDialog = false
260+ return a, nil
261+
262+ case dialog.ModelSelectedMsg:
263+ a.showModelDialog = false
264+
265+ model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
266+ if err != nil {
267+ return a, util.ReportError(err)
268+ }
269+
270+ return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
271+
246272 case dialog.ShowInitDialogMsg:
247273 a.showInitDialog = msg.Show
248274 return a, nil
@@ -298,6 +324,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
298324 if a.showCommandDialog {
299325 a.showCommandDialog = false
300326 }
327+ if a.showModelDialog {
328+ a.showModelDialog = false
329+ }
301330 return a, nil
302331 case key.Matches(msg, keys.SwitchSession):
303332 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) {
325354 return a, nil
326355 }
327356 return a, nil
357+ case key.Matches(msg, keys.Models):
358+ if a.showModelDialog {
359+ a.showModelDialog = false
360+ return a, nil
361+ }
362+
363+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
364+ a.showModelDialog = true
365+ return a, nil
366+ }
367+ return a, nil
328368 case key.Matches(msg, logsKeyReturnKey):
329369 if a.currentPage == page.LogsPage {
330370 return a, a.moveToPage(page.ChatPage)
@@ -405,6 +445,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
405445 }
406446 }
407447
448+ if a.showModelDialog {
449+ d, modelCmd := a.modelDialog.Update(msg)
450+ a.modelDialog = d.(dialog.ModelDialog)
451+ cmds = append(cmds, modelCmd)
452+ // Only block key messages send all other messages down
453+ if _, ok := msg.(tea.KeyMsg); ok {
454+ return a, tea.Batch(cmds...)
455+ }
456+ }
457+
408458 if a.showInitDialog {
409459 d, initCmd := a.initDialog.Update(msg)
410460 a.initDialog = d.(dialog.InitDialogCmp)
@@ -538,6 +588,21 @@ func (a appModel) View() string {
538588 )
539589 }
540590
591+ if a.showModelDialog {
592+ overlay := a.modelDialog.View()
593+ row := lipgloss.Height(appView) / 2
594+ row -= lipgloss.Height(overlay) / 2
595+ col := lipgloss.Width(appView) / 2
596+ col -= lipgloss.Width(overlay) / 2
597+ appView = layout.PlaceOverlay(
598+ col,
599+ row,
600+ overlay,
601+ appView,
602+ true,
603+ )
604+ }
605+
541606 if a.showCommandDialog {
542607 overlay := a.commandDialog.View()
543608 row := lipgloss.Height(appView) / 2
@@ -577,6 +642,7 @@ func New(app *app.App) tea.Model {
577642 quit: dialog.NewQuitCmp(),
578643 sessionDialog: dialog.NewSessionDialogCmp(),
579644 commandDialog: dialog.NewCommandDialogCmp(),
645+ modelDialog: dialog.NewModelDialogCmp(),
580646 permissions: dialog.NewPermissionDialogCmp(),
581647 initDialog: dialog.NewInitDialogCmp(),
582648 app: app,