mirror of
https://github.com/sst/opencode.git
synced 2025-08-24 06:54:09 +00:00
457 lines
12 KiB
Go
457 lines
12 KiB
Go
package dialog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/v2/key"
|
|
tea "github.com/charmbracelet/bubbletea/v2"
|
|
"github.com/lithammer/fuzzysearch/fuzzy"
|
|
"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
|
|
maxRecentModels = 5
|
|
)
|
|
|
|
// 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
|
|
searchDialog *SearchDialog
|
|
dialogWidth int
|
|
}
|
|
|
|
type ModelWithProvider struct {
|
|
Model opencode.Model
|
|
Provider opencode.Provider
|
|
}
|
|
|
|
// modelItem is a custom list item for model selections
|
|
type modelItem struct {
|
|
model ModelWithProvider
|
|
}
|
|
|
|
func (m modelItem) Render(
|
|
selected bool,
|
|
width int,
|
|
baseStyle styles.Style,
|
|
) string {
|
|
t := theme.CurrentTheme()
|
|
|
|
itemStyle := baseStyle.
|
|
Background(t.BackgroundPanel()).
|
|
Foreground(t.Text())
|
|
|
|
if selected {
|
|
itemStyle = itemStyle.Foreground(t.Primary())
|
|
}
|
|
|
|
providerStyle := baseStyle.
|
|
Foreground(t.TextMuted()).
|
|
Background(t.BackgroundPanel())
|
|
|
|
modelPart := itemStyle.Render(m.model.Model.Name)
|
|
providerPart := providerStyle.Render(fmt.Sprintf(" %s", m.model.Provider.Name))
|
|
|
|
combinedText := modelPart + providerPart
|
|
return baseStyle.
|
|
Background(t.BackgroundPanel()).
|
|
PaddingLeft(1).
|
|
Render(combinedText)
|
|
}
|
|
|
|
func (m modelItem) Selectable() bool {
|
|
return true
|
|
}
|
|
|
|
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 m.searchDialog.Init()
|
|
}
|
|
|
|
func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case SearchSelectionMsg:
|
|
// Handle selection from search dialog
|
|
if item, ok := msg.Item.(modelItem); ok {
|
|
return m, tea.Sequence(
|
|
util.CmdHandler(modal.CloseModalMsg{}),
|
|
util.CmdHandler(
|
|
app.ModelSelectedMsg{
|
|
Provider: item.model.Provider,
|
|
Model: item.model.Model,
|
|
}),
|
|
)
|
|
}
|
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
|
case SearchCancelledMsg:
|
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
|
|
|
case SearchRemoveItemMsg:
|
|
if item, ok := msg.Item.(modelItem); ok {
|
|
if m.isModelInRecentSection(item.model, msg.Index) {
|
|
m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
|
|
items := m.buildDisplayList(m.searchDialog.GetQuery())
|
|
m.searchDialog.SetItems(items)
|
|
return m, m.app.SaveState()
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case SearchQueryChangedMsg:
|
|
// Update the list based on search query
|
|
items := m.buildDisplayList(msg.Query)
|
|
m.searchDialog.SetItems(items)
|
|
return m, nil
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.searchDialog.SetWidth(m.dialogWidth)
|
|
m.searchDialog.SetHeight(msg.Height)
|
|
}
|
|
|
|
updatedDialog, cmd := m.searchDialog.Update(msg)
|
|
m.searchDialog = updatedDialog.(*SearchDialog)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *modelDialog) View() string {
|
|
return m.searchDialog.View()
|
|
}
|
|
|
|
func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
|
|
maxWidth := minDialogWidth
|
|
|
|
for _, model := range models {
|
|
// Calculate the width needed for this item: "ModelName (ProviderName)"
|
|
// Add 4 for the parentheses, space, and some padding
|
|
itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 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()
|
|
|
|
// Calculate optimal width based on all models
|
|
m.dialogWidth = m.calculateOptimalWidth(m.allModels)
|
|
|
|
// Initialize search dialog
|
|
m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
|
|
m.searchDialog.SetWidth(m.dialogWidth)
|
|
|
|
// Build initial display list (empty query shows grouped view)
|
|
items := m.buildDisplayList("")
|
|
m.searchDialog.SetItems(items)
|
|
}
|
|
|
|
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{}
|
|
}
|
|
|
|
// buildDisplayList creates the list items based on search query
|
|
func (m *modelDialog) buildDisplayList(query string) []list.Item {
|
|
if query != "" {
|
|
// Search mode: use fuzzy matching
|
|
return m.buildSearchResults(query)
|
|
} else {
|
|
// Grouped mode: show Recent section and provider groups
|
|
return m.buildGroupedResults()
|
|
}
|
|
}
|
|
|
|
// buildSearchResults creates a flat list of search results using fuzzy matching
|
|
func (m *modelDialog) buildSearchResults(query string) []list.Item {
|
|
type modelMatch struct {
|
|
model ModelWithProvider
|
|
score int
|
|
}
|
|
|
|
modelNames := []string{}
|
|
modelMap := make(map[string]ModelWithProvider)
|
|
|
|
// Create search strings and perform fuzzy matching
|
|
for _, model := range m.allModels {
|
|
searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
|
|
modelNames = append(modelNames, searchStr)
|
|
modelMap[searchStr] = model
|
|
|
|
searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
|
|
modelNames = append(modelNames, searchStr)
|
|
modelMap[searchStr] = model
|
|
}
|
|
|
|
matches := fuzzy.RankFindFold(query, modelNames)
|
|
sort.Sort(matches)
|
|
|
|
items := []list.Item{}
|
|
seenModels := make(map[string]bool)
|
|
|
|
for _, match := range matches {
|
|
model := modelMap[match.Target]
|
|
// Create a unique key to avoid duplicates
|
|
key := fmt.Sprintf("%s:%s", model.Provider.ID, model.Model.ID)
|
|
if seenModels[key] {
|
|
continue
|
|
}
|
|
seenModels[key] = true
|
|
items = append(items, modelItem{model: model})
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
// buildGroupedResults creates a grouped list with Recent section and provider groups
|
|
func (m *modelDialog) buildGroupedResults() []list.Item {
|
|
var items []list.Item
|
|
|
|
// Add Recent section
|
|
recentModels := m.getRecentModels(maxRecentModels)
|
|
if len(recentModels) > 0 {
|
|
items = append(items, list.HeaderItem("Recent"))
|
|
for _, model := range recentModels {
|
|
items = append(items, modelItem{model: model})
|
|
}
|
|
}
|
|
|
|
// Group models by provider
|
|
providerGroups := make(map[string][]ModelWithProvider)
|
|
for _, model := range m.allModels {
|
|
providerName := model.Provider.Name
|
|
providerGroups[providerName] = append(providerGroups[providerName], model)
|
|
}
|
|
|
|
// Get sorted provider names for consistent order
|
|
var providerNames []string
|
|
for name := range providerGroups {
|
|
providerNames = append(providerNames, name)
|
|
}
|
|
sort.Strings(providerNames)
|
|
|
|
// Add provider groups
|
|
for _, providerName := range providerNames {
|
|
models := providerGroups[providerName]
|
|
|
|
// Sort models within provider group
|
|
sort.Slice(models, func(i, j int) bool {
|
|
modelA := models[i]
|
|
modelB := models[j]
|
|
|
|
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
|
|
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
|
|
|
|
// Sort by usage time first, then by release date, then alphabetically
|
|
if !usageA.IsZero() && !usageB.IsZero() {
|
|
return usageA.After(usageB)
|
|
}
|
|
if !usageA.IsZero() && usageB.IsZero() {
|
|
return true
|
|
}
|
|
if usageA.IsZero() && !usageB.IsZero() {
|
|
return false
|
|
}
|
|
|
|
// Sort by release date 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)
|
|
}
|
|
}
|
|
|
|
return modelA.Model.Name < modelB.Model.Name
|
|
})
|
|
|
|
// Add provider header
|
|
items = append(items, list.HeaderItem(providerName))
|
|
|
|
// Add models in this provider group
|
|
for _, model := range models {
|
|
items = append(items, modelItem{model: model})
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
// getRecentModels returns the most recently used models
|
|
func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
|
|
var recentModels []ModelWithProvider
|
|
|
|
// Get recent models from app state
|
|
for _, usage := range m.app.State.RecentlyUsedModels {
|
|
if len(recentModels) >= limit {
|
|
break
|
|
}
|
|
|
|
// Find the corresponding model
|
|
for _, model := range m.allModels {
|
|
if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
|
|
recentModels = append(recentModels, model)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return recentModels
|
|
}
|
|
|
|
func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) bool {
|
|
// Only check if we're in grouped mode (no search query)
|
|
if m.searchDialog.GetQuery() != "" {
|
|
return false
|
|
}
|
|
|
|
recentModels := m.getRecentModels(maxRecentModels)
|
|
if len(recentModels) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Index 0 is the "Recent" header, so recent models are at indices 1 to len(recentModels)
|
|
if index >= 1 && index <= len(recentModels) {
|
|
if index-1 < len(recentModels) {
|
|
recentModel := recentModels[index-1]
|
|
return recentModel.Provider.ID == model.Provider.ID &&
|
|
recentModel.Model.ID == model.Model.ID
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|