models.go

  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}