mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-03 16:05:00 +01:00
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
This commit is contained in:
363
internal/tui/components/dialog/models.go
Normal file
363
internal/tui/components/dialog/models.go
Normal file
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user