summaryrefslogtreecommitdiffhomepage
path: root/internal/tui
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2025-04-28 12:25:06 -0500
committerGitHub <[email protected]>2025-04-28 19:25:06 +0200
commitb3c0285db3dd5d5140481bf5118812e8dbc89795 (patch)
tree3ead4b4447743c57cec2eef672d18615b7bdb2cb /internal/tui
parent805aeff83cad4c17e25acdd671d2731be104b3e0 (diff)
downloadopencode-b3c0285db3dd5d5140481bf5118812e8dbc89795.tar.gz
opencode-b3c0285db3dd5d5140481bf5118812e8dbc89795.zip
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
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/components/dialog/models.go363
-rw-r--r--internal/tui/tui.go66
2 files changed, 429 insertions, 0 deletions
diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go
new file mode 100644
index 000000000..d10d5c8cc
--- /dev/null
+++ b/internal/tui/components/dialog/models.go
@@ -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{}
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 0e4afbbb3..186f812c4 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -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,