summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTimo Clasen <[email protected]>2025-07-01 12:28:32 +0200
committerGitHub <[email protected]>2025-07-01 05:28:32 -0500
commit8f3d7b403840c932250ba10e1ea2c6e3d0f78f87 (patch)
treef4568c6a1f3bed6811e0d5e19d510b849d1cb34c
parentde15e67834d89334be89901657b4a1290db2c05d (diff)
downloadopencode-8f3d7b403840c932250ba10e1ea2c6e3d0f78f87.tar.gz
opencode-8f3d7b403840c932250ba10e1ea2c6e3d0f78f87.zip
feat: better model dialog with sorting by release date (#563)
-rw-r--r--packages/tui/internal/components/dialog/models.go293
-rw-r--r--packages/tui/internal/config/config.go45
-rw-r--r--packages/tui/internal/tui/tui.go1
3 files changed, 212 insertions, 127 deletions
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
index b43d48156..4ebf572eb 100644
--- a/packages/tui/internal/components/dialog/models.go
+++ b/packages/tui/internal/components/dialog/models.go
@@ -3,13 +3,11 @@ package dialog
import (
"context"
"fmt"
- "maps"
- "slices"
- "strings"
+ "sort"
+ "time"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
@@ -21,8 +19,9 @@ import (
)
const (
- numVisibleModels = 6
- maxDialogWidth = 40
+ numVisibleModels = 10
+ minDialogWidth = 40
+ maxDialogWidth = 80
)
// ModelDialog interface for the model selection dialog
@@ -31,33 +30,61 @@ type ModelDialog interface {
}
type modelDialog struct {
- app *app.App
- availableProviders []opencode.Provider
- provider opencode.Provider
- width int
- height int
- hScrollOffset int
- hScrollPossible bool
- modal *modal.Modal
- modelList list.List[list.StringItem]
+ app *app.App
+ allModels []ModelWithProvider
+ width int
+ height int
+ modal *modal.Modal
+ modelList list.List[ModelItem]
+ dialogWidth int
+}
+
+type ModelWithProvider struct {
+ Model opencode.Model
+ Provider opencode.Provider
+}
+
+type ModelItem struct {
+ ModelName string
+ ProviderName string
+}
+
+func (m ModelItem) Render(selected bool, width int) string {
+ t := theme.CurrentTheme()
+
+ if selected {
+ displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
+ return styles.NewStyle().
+ Background(t.Primary()).
+ Foreground(t.BackgroundElement()).
+ Width(width).
+ PaddingLeft(1).
+ Render(displayText)
+ } else {
+ modelStyle := styles.NewStyle().
+ Foreground(t.Text()).
+ Background(t.BackgroundElement())
+ providerStyle := styles.NewStyle().
+ Foreground(t.TextMuted()).
+ Background(t.BackgroundElement())
+
+ modelPart := modelStyle.Render(m.ModelName)
+ providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
+
+ combinedText := modelPart + providerPart
+ return styles.NewStyle().
+ Background(t.BackgroundElement()).
+ PaddingLeft(1).
+ Render(combinedText)
+ }
}
type modelKeyMap struct {
- Left key.Binding
- Right key.Binding
Enter key.Binding
Escape key.Binding
}
var modelKeys = modelKeyMap{
- Left: key.NewBinding(
- key.WithKeys("left", "h"),
- key.WithHelp("←", "scroll left"),
- ),
- Right: key.NewBinding(
- key.WithKeys("right", "l"),
- key.WithHelp("→", "scroll right"),
- ),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select model"),
@@ -69,7 +96,7 @@ var modelKeys = modelKeyMap{
}
func (m *modelDialog) Init() tea.Cmd {
- m.setupModelsForProvider(m.provider.ID)
+ m.setupAllModels()
return nil
}
@@ -77,34 +104,20 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
- case key.Matches(msg, modelKeys.Left):
- if m.hScrollPossible {
- m.switchProvider(-1)
- }
- return m, nil
- case key.Matches(msg, modelKeys.Right):
- if m.hScrollPossible {
- m.switchProvider(1)
- }
- return m, nil
case key.Matches(msg, modelKeys.Enter):
- selectedItem, _ := m.modelList.GetSelectedItem()
- models := m.models()
- var selectedModel opencode.Model
- for _, model := range models {
- if model.Name == string(selectedItem) {
- selectedModel = model
- break
- }
+ _, selectedIndex := m.modelList.GetSelectedItem()
+ if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
+ selectedModel := m.allModels[selectedIndex]
+ return m, tea.Sequence(
+ util.CmdHandler(modal.CloseModalMsg{}),
+ util.CmdHandler(
+ app.ModelSelectedMsg{
+ Provider: selectedModel.Provider,
+ Model: selectedModel.Model,
+ }),
+ )
}
- return m, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(
- app.ModelSelectedMsg{
- Provider: m.provider,
- Model: selectedModel,
- }),
- )
+ return m, util.CmdHandler(modal.CloseModalMsg{})
case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(modal.CloseModalMsg{})
}
@@ -115,74 +128,124 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Update the list component
updatedList, cmd := m.modelList.Update(msg)
- m.modelList = updatedList.(list.List[list.StringItem])
+ m.modelList = updatedList.(list.List[ModelItem])
return m, cmd
}
-func (m *modelDialog) models() []opencode.Model {
- models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b opencode.Model) int {
- return strings.Compare(a.Name, b.Name)
- })
- return models
+func (m *modelDialog) View() string {
+ return m.modelList.View()
}
-func (m *modelDialog) switchProvider(offset int) {
- newOffset := m.hScrollOffset + offset
+func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
+ maxWidth := minDialogWidth
- if newOffset < 0 {
- newOffset = len(m.availableProviders) - 1
+ for _, item := range modelItems {
+ // Calculate the width needed for this item: "ModelName (ProviderName)"
+ // Add 4 for the parentheses, space, and some padding
+ itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
+ if itemWidth > maxWidth {
+ maxWidth = itemWidth
+ }
}
- if newOffset >= len(m.availableProviders) {
- newOffset = 0
+
+ if maxWidth > maxDialogWidth {
+ maxWidth = maxDialogWidth
}
- m.hScrollOffset = newOffset
- m.provider = m.availableProviders[m.hScrollOffset]
- m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
- m.setupModelsForProvider(m.provider.ID)
+ return maxWidth
}
-func (m *modelDialog) View() string {
- listView := m.modelList.View()
- scrollIndicator := m.getScrollIndicators(maxDialogWidth)
- return strings.Join([]string{listView, scrollIndicator}, "\n")
-}
+func (m *modelDialog) setupAllModels() {
+ providers, _ := m.app.ListProviders(context.Background())
-func (m *modelDialog) getScrollIndicators(maxWidth int) string {
- var indicator string
- if m.hScrollPossible {
- indicator = "← → (switch provider) "
+ m.allModels = make([]ModelWithProvider, 0)
+ for _, provider := range providers {
+ for _, model := range provider.Models {
+ m.allModels = append(m.allModels, ModelWithProvider{
+ Model: model,
+ Provider: provider,
+ })
+ }
}
- if indicator == "" {
- return ""
+
+ m.sortModels()
+
+ modelItems := make([]ModelItem, len(m.allModels))
+ for i, modelWithProvider := range m.allModels {
+ modelItems[i] = ModelItem{
+ ModelName: modelWithProvider.Model.Name,
+ ProviderName: modelWithProvider.Provider.Name,
+ }
}
- t := theme.CurrentTheme()
- return styles.NewStyle().
- Foreground(t.TextMuted()).
- Width(maxWidth).
- Align(lipgloss.Right).
- Render(indicator)
-}
-
-func (m *modelDialog) setupModelsForProvider(providerId string) {
- models := m.models()
- modelNames := make([]string, len(models))
- for i, model := range models {
- modelNames[i] = model.Name
+ m.dialogWidth = m.calculateOptimalWidth(modelItems)
+
+ m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
+ m.modelList.SetMaxWidth(m.dialogWidth)
+
+ if len(m.allModels) > 0 {
+ m.modelList.SetSelectedIndex(0)
}
+}
+
+func (m *modelDialog) sortModels() {
+ sort.Slice(m.allModels, func(i, j int) bool {
+ modelA := m.allModels[i]
+ modelB := m.allModels[j]
+
+ usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
+ usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
+
+ // If both have usage times, sort by most recent first
+ if !usageA.IsZero() && !usageB.IsZero() {
+ return usageA.After(usageB)
+ }
- m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
- m.modelList.SetMaxWidth(maxDialogWidth)
+ // If only one has usage time, it goes first
+ if !usageA.IsZero() && usageB.IsZero() {
+ return true
+ }
+ if usageA.IsZero() && !usageB.IsZero() {
+ return false
+ }
- if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.ID == providerId {
- for i, model := range models {
- if model.ID == m.app.Model.ID {
- m.modelList.SetSelectedIndex(i)
- break
+ // If neither has usage time, sort by release date desc if available
+ if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
+ dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
+ dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
+ if !dateA.IsZero() && !dateB.IsZero() {
+ return dateA.After(dateB)
}
}
+
+ // If only one has release date, it goes first
+ if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
+ return true
+ }
+ if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
+ return false
+ }
+
+ // If neither has usage time nor release date, fall back to alphabetical sorting
+ return modelA.Model.Name < modelB.Model.Name
+ })
+}
+
+func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
+ if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
+ return parsed
+ }
+
+ return time.Time{}
+}
+
+func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
+ for _, usage := range m.app.State.RecentlyUsedModels {
+ if usage.ProviderID == providerID && usage.ModelID == modelID {
+ return usage.LastUsed
+ }
}
+ return time.Time{}
}
func (m *modelDialog) Render(background string) string {
@@ -194,32 +257,16 @@ func (s *modelDialog) Close() tea.Cmd {
}
func NewModelDialog(app *app.App) ModelDialog {
- availableProviders, _ := app.ListProviders(context.Background())
-
- currentProvider := availableProviders[0]
- hScrollOffset := 0
- if app.Provider != nil {
- for i, provider := range availableProviders {
- if provider.ID == app.Provider.ID {
- currentProvider = provider
- hScrollOffset = i
- break
- }
- }
- }
-
dialog := &modelDialog{
- app: app,
- availableProviders: availableProviders,
- hScrollOffset: hScrollOffset,
- hScrollPossible: len(availableProviders) > 1,
- provider: currentProvider,
- modal: modal.New(
- modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
- modal.WithMaxWidth(maxDialogWidth+4),
- ),
+ app: app,
}
- dialog.setupModelsForProvider(currentProvider.ID)
+ dialog.setupAllModels()
+
+ dialog.modal = modal.New(
+ modal.WithTitle("Select Model"),
+ modal.WithMaxWidth(dialog.dialogWidth+4),
+ )
+
return dialog
}
diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go
index 258007a47..502f55310 100644
--- a/packages/tui/internal/config/config.go
+++ b/packages/tui/internal/config/config.go
@@ -5,19 +5,56 @@ import (
"fmt"
"log/slog"
"os"
+ "time"
"github.com/BurntSushi/toml"
)
+type ModelUsage struct {
+ ProviderID string `toml:"provider_id"`
+ ModelID string `toml:"model_id"`
+ LastUsed time.Time `toml:"last_used"`
+}
+
type State struct {
- Theme string `toml:"theme"`
- Provider string `toml:"provider"`
- Model string `toml:"model"`
+ Theme string `toml:"theme"`
+ Provider string `toml:"provider"`
+ Model string `toml:"model"`
+ RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
}
func NewState() *State {
return &State{
- Theme: "opencode",
+ Theme: "opencode",
+ RecentlyUsedModels: make([]ModelUsage, 0),
+ }
+}
+
+// UpdateModelUsage updates the recently used models list with the specified model
+func (s *State) UpdateModelUsage(providerID, modelID string) {
+ now := time.Now()
+
+ // Check if this model is already in the list
+ for i, usage := range s.RecentlyUsedModels {
+ if usage.ProviderID == providerID && usage.ModelID == modelID {
+ s.RecentlyUsedModels[i].LastUsed = now
+ usage := s.RecentlyUsedModels[i]
+ copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
+ s.RecentlyUsedModels[0] = usage
+ return
+ }
+ }
+
+ newUsage := ModelUsage{
+ ProviderID: providerID,
+ ModelID: modelID,
+ LastUsed: now,
+ }
+
+ // Prepend to slice and limit to last 50 entries
+ s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
+ if len(s.RecentlyUsedModels) > 50 {
+ s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
}
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 106c135d0..2a54bbbe0 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -378,6 +378,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Model = &msg.Model
a.app.State.Provider = msg.Provider.ID
a.app.State.Model = msg.Model.ID
+ a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
a.app.SaveState()
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName