1package dialog
2
3import (
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
18const (
19 numVisibleModels = 10
20 maxDialogWidth = 40
21)
22
23// ModelSelectedMsg is sent when a model is selected
24type ModelSelectedMsg struct {
25 Model models.Model
26}
27
28// CloseModelDialogMsg is sent when a model is selected
29type CloseModelDialogMsg struct{}
30
31// ModelDialog interface for the model selection dialog
32type ModelDialog interface {
33 tea.Model
34 layout.Bindings
35}
36
37type 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
50type 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
63var 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
106func (m *modelDialogCmp) Init() tea.Cmd {
107 m.setupModels()
108 return nil
109}
110
111func (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
142func (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
157func (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
171func (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
187func (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
227func (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
260func (m *modelDialogCmp) BindingKeys() []key.Binding {
261 return layout.KeyMapToSlice(modelKeys)
262}
263
264func (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
280func 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
306func 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
315func (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
340func 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
361func NewModelDialogCmp() ModelDialog {
362 return &modelDialogCmp{}
363}