| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- package dialog
- import (
- "context"
- "fmt"
- "sort"
- "time"
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
- )
- const (
- numVisibleModels = 10
- minDialogWidth = 40
- maxDialogWidth = 80
- )
- // ModelDialog interface for the model selection dialog
- type ModelDialog interface {
- layout.Modal
- }
- type modelDialog struct {
- 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.BackgroundPanel()).
- Width(width).
- PaddingLeft(1).
- Render(displayText)
- } else {
- modelStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundPanel())
- providerStyle := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel())
- modelPart := modelStyle.Render(m.ModelName)
- providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
- combinedText := modelPart + providerPart
- return styles.NewStyle().
- Background(t.BackgroundPanel()).
- PaddingLeft(1).
- Render(combinedText)
- }
- }
- type modelKeyMap struct {
- Enter key.Binding
- Escape key.Binding
- }
- var modelKeys = modelKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select model"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
- }
- func (m *modelDialog) Init() tea.Cmd {
- m.setupAllModels()
- return nil
- }
- func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, modelKeys.Enter):
- _, 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, util.CmdHandler(modal.CloseModalMsg{})
- case key.Matches(msg, modelKeys.Escape):
- return m, util.CmdHandler(modal.CloseModalMsg{})
- }
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- }
- // Update the list component
- updatedList, cmd := m.modelList.Update(msg)
- m.modelList = updatedList.(list.List[ModelItem])
- return m, cmd
- }
- func (m *modelDialog) View() string {
- return m.modelList.View()
- }
- func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
- maxWidth := minDialogWidth
- 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 maxWidth > maxDialogWidth {
- maxWidth = maxDialogWidth
- }
- return maxWidth
- }
- func (m *modelDialog) setupAllModels() {
- providers, _ := m.app.ListProviders(context.Background())
- m.allModels = make([]ModelWithProvider, 0)
- for _, provider := range providers {
- for _, model := range provider.Models {
- m.allModels = append(m.allModels, ModelWithProvider{
- Model: model,
- Provider: provider,
- })
- }
- }
- 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,
- }
- }
- 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)
- }
- // If only one has usage time, it goes first
- if !usageA.IsZero() && usageB.IsZero() {
- return true
- }
- if usageA.IsZero() && !usageB.IsZero() {
- return false
- }
- // 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 {
- return m.modal.Render(m.View(), background)
- }
- func (s *modelDialog) Close() tea.Cmd {
- return nil
- }
- func NewModelDialog(app *app.App) ModelDialog {
- dialog := &modelDialog{
- app: app,
- }
- dialog.setupAllModels()
- dialog.modal = modal.New(
- modal.WithTitle("Select Model"),
- modal.WithMaxWidth(dialog.dialogWidth+4),
- )
- return dialog
- }
|