Browse Source

feat: better model dialog with sorting by release date (#563)

Timo Clasen 7 months ago
parent
commit
8f3d7b4038

+ 170 - 123
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
 }

+ 41 - 4
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]
 	}
 }
 

+ 1 - 0
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