2
0

models.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. package dialog
  2. import (
  3. "context"
  4. "fmt"
  5. "slices"
  6. "sort"
  7. "time"
  8. "github.com/charmbracelet/bubbles/v2/key"
  9. tea "github.com/charmbracelet/bubbletea/v2"
  10. "github.com/lithammer/fuzzysearch/fuzzy"
  11. "github.com/sst/opencode-sdk-go"
  12. "github.com/sst/opencode/internal/app"
  13. "github.com/sst/opencode/internal/components/list"
  14. "github.com/sst/opencode/internal/components/modal"
  15. "github.com/sst/opencode/internal/layout"
  16. "github.com/sst/opencode/internal/styles"
  17. "github.com/sst/opencode/internal/theme"
  18. "github.com/sst/opencode/internal/util"
  19. )
  20. const (
  21. numVisibleModels = 10
  22. minDialogWidth = 40
  23. maxDialogWidth = 80
  24. )
  25. // ModelDialog interface for the model selection dialog
  26. type ModelDialog interface {
  27. layout.Modal
  28. }
  29. type modelDialog struct {
  30. app *app.App
  31. allModels []ModelWithProvider
  32. width int
  33. height int
  34. modal *modal.Modal
  35. searchDialog *SearchDialog
  36. dialogWidth int
  37. }
  38. type ModelWithProvider struct {
  39. Model opencode.Model
  40. Provider opencode.Provider
  41. }
  42. type ModelItem struct {
  43. ModelName string
  44. ProviderName string
  45. }
  46. func (m *ModelItem) Render(selected bool, width int, isFirstInViewport bool) string {
  47. t := theme.CurrentTheme()
  48. if selected {
  49. displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
  50. return styles.NewStyle().
  51. Background(t.Primary()).
  52. Foreground(t.BackgroundPanel()).
  53. Width(width).
  54. PaddingLeft(1).
  55. Render(displayText)
  56. } else {
  57. modelStyle := styles.NewStyle().
  58. Foreground(t.Text()).
  59. Background(t.BackgroundPanel())
  60. providerStyle := styles.NewStyle().
  61. Foreground(t.TextMuted()).
  62. Background(t.BackgroundPanel())
  63. modelPart := modelStyle.Render(m.ModelName)
  64. providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
  65. combinedText := modelPart + providerPart
  66. return styles.NewStyle().
  67. Background(t.BackgroundPanel()).
  68. PaddingLeft(1).
  69. Render(combinedText)
  70. }
  71. }
  72. func (m *ModelItem) Selectable() bool {
  73. return true
  74. }
  75. type modelKeyMap struct {
  76. Enter key.Binding
  77. Escape key.Binding
  78. }
  79. var modelKeys = modelKeyMap{
  80. Enter: key.NewBinding(
  81. key.WithKeys("enter"),
  82. key.WithHelp("enter", "select model"),
  83. ),
  84. Escape: key.NewBinding(
  85. key.WithKeys("esc"),
  86. key.WithHelp("esc", "close"),
  87. ),
  88. }
  89. func (m *modelDialog) Init() tea.Cmd {
  90. m.setupAllModels()
  91. return m.searchDialog.Init()
  92. }
  93. func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  94. switch msg := msg.(type) {
  95. case SearchSelectionMsg:
  96. // Handle selection from search dialog
  97. if modelItem, ok := msg.Item.(*ModelItem); ok {
  98. // Find the corresponding ModelWithProvider
  99. for _, model := range m.allModels {
  100. if model.Model.Name == modelItem.ModelName && model.Provider.Name == modelItem.ProviderName {
  101. return m, tea.Sequence(
  102. util.CmdHandler(modal.CloseModalMsg{}),
  103. util.CmdHandler(
  104. app.ModelSelectedMsg{
  105. Provider: model.Provider,
  106. Model: model.Model,
  107. }),
  108. )
  109. }
  110. }
  111. }
  112. return m, util.CmdHandler(modal.CloseModalMsg{})
  113. case SearchCancelledMsg:
  114. return m, util.CmdHandler(modal.CloseModalMsg{})
  115. case SearchQueryChangedMsg:
  116. // Update the list based on search query
  117. items := m.buildDisplayList(msg.Query)
  118. m.searchDialog.SetItems(items)
  119. return m, nil
  120. case tea.WindowSizeMsg:
  121. m.width = msg.Width
  122. m.height = msg.Height
  123. m.searchDialog.SetWidth(m.dialogWidth)
  124. m.searchDialog.SetHeight(msg.Height)
  125. }
  126. updatedDialog, cmd := m.searchDialog.Update(msg)
  127. m.searchDialog = updatedDialog.(*SearchDialog)
  128. return m, cmd
  129. }
  130. func (m *modelDialog) View() string {
  131. return m.searchDialog.View()
  132. }
  133. func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
  134. maxWidth := minDialogWidth
  135. for _, item := range modelItems {
  136. // Calculate the width needed for this item: "ModelName (ProviderName)"
  137. // Add 4 for the parentheses, space, and some padding
  138. itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
  139. if itemWidth > maxWidth {
  140. maxWidth = itemWidth
  141. }
  142. }
  143. if maxWidth > maxDialogWidth {
  144. maxWidth = maxDialogWidth
  145. }
  146. return maxWidth
  147. }
  148. func (m *modelDialog) setupAllModels() {
  149. providers, _ := m.app.ListProviders(context.Background())
  150. m.allModels = make([]ModelWithProvider, 0)
  151. for _, provider := range providers {
  152. for _, model := range provider.Models {
  153. m.allModels = append(m.allModels, ModelWithProvider{
  154. Model: model,
  155. Provider: provider,
  156. })
  157. }
  158. }
  159. m.sortModels()
  160. // Calculate optimal width based on all models
  161. modelItems := make([]ModelItem, len(m.allModels))
  162. for i, modelWithProvider := range m.allModels {
  163. modelItems[i] = ModelItem{
  164. ModelName: modelWithProvider.Model.Name,
  165. ProviderName: modelWithProvider.Provider.Name,
  166. }
  167. }
  168. m.dialogWidth = m.calculateOptimalWidth(modelItems)
  169. // Initialize search dialog
  170. m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
  171. m.searchDialog.SetWidth(m.dialogWidth)
  172. // Build initial display list (empty query shows grouped view)
  173. items := m.buildDisplayList("")
  174. m.searchDialog.SetItems(items)
  175. }
  176. func (m *modelDialog) sortModels() {
  177. sort.Slice(m.allModels, func(i, j int) bool {
  178. modelA := m.allModels[i]
  179. modelB := m.allModels[j]
  180. usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
  181. usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
  182. // If both have usage times, sort by most recent first
  183. if !usageA.IsZero() && !usageB.IsZero() {
  184. return usageA.After(usageB)
  185. }
  186. // If only one has usage time, it goes first
  187. if !usageA.IsZero() && usageB.IsZero() {
  188. return true
  189. }
  190. if usageA.IsZero() && !usageB.IsZero() {
  191. return false
  192. }
  193. // If neither has usage time, sort by release date desc if available
  194. if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
  195. dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
  196. dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
  197. if !dateA.IsZero() && !dateB.IsZero() {
  198. return dateA.After(dateB)
  199. }
  200. }
  201. // If only one has release date, it goes first
  202. if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
  203. return true
  204. }
  205. if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
  206. return false
  207. }
  208. // If neither has usage time nor release date, fall back to alphabetical sorting
  209. return modelA.Model.Name < modelB.Model.Name
  210. })
  211. }
  212. func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
  213. if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
  214. return parsed
  215. }
  216. return time.Time{}
  217. }
  218. func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
  219. for _, usage := range m.app.State.RecentlyUsedModels {
  220. if usage.ProviderID == providerID && usage.ModelID == modelID {
  221. return usage.LastUsed
  222. }
  223. }
  224. return time.Time{}
  225. }
  226. // buildDisplayList creates the list items based on search query
  227. func (m *modelDialog) buildDisplayList(query string) []list.ListItem {
  228. if query != "" {
  229. // Search mode: use fuzzy matching
  230. return m.buildSearchResults(query)
  231. } else {
  232. // Grouped mode: show Recent section and provider groups
  233. return m.buildGroupedResults()
  234. }
  235. }
  236. // buildSearchResults creates a flat list of search results using fuzzy matching
  237. func (m *modelDialog) buildSearchResults(query string) []list.ListItem {
  238. type modelMatch struct {
  239. model ModelWithProvider
  240. score int
  241. }
  242. modelNames := []string{}
  243. modelMap := make(map[string]ModelWithProvider)
  244. // Create search strings and perform fuzzy matching
  245. for _, model := range m.allModels {
  246. searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
  247. modelNames = append(modelNames, searchStr)
  248. modelMap[searchStr] = model
  249. searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
  250. modelNames = append(modelNames, searchStr)
  251. modelMap[searchStr] = model
  252. }
  253. matches := fuzzy.RankFindFold(query, modelNames)
  254. sort.Sort(matches)
  255. items := []list.ListItem{}
  256. for _, match := range matches {
  257. model := modelMap[match.Target]
  258. existingItem := slices.IndexFunc(items, func(item list.ListItem) bool {
  259. castedItem := item.(*ModelItem)
  260. return castedItem.ModelName == model.Model.Name &&
  261. castedItem.ProviderName == model.Provider.Name
  262. })
  263. if existingItem != -1 {
  264. continue
  265. }
  266. items = append(items, &ModelItem{
  267. ModelName: model.Model.Name,
  268. ProviderName: model.Provider.Name,
  269. })
  270. }
  271. return items
  272. }
  273. // buildGroupedResults creates a grouped list with Recent section and provider groups
  274. func (m *modelDialog) buildGroupedResults() []list.ListItem {
  275. var items []list.ListItem
  276. // Add Recent section
  277. recentModels := m.getRecentModels(5)
  278. if len(recentModels) > 0 {
  279. items = append(items, list.HeaderItem("Recent"))
  280. for _, model := range recentModels {
  281. items = append(items, &ModelItem{
  282. ModelName: model.Model.Name,
  283. ProviderName: model.Provider.Name,
  284. })
  285. }
  286. }
  287. // Group models by provider
  288. providerGroups := make(map[string][]ModelWithProvider)
  289. for _, model := range m.allModels {
  290. providerName := model.Provider.Name
  291. providerGroups[providerName] = append(providerGroups[providerName], model)
  292. }
  293. // Get sorted provider names for consistent order
  294. var providerNames []string
  295. for name := range providerGroups {
  296. providerNames = append(providerNames, name)
  297. }
  298. sort.Strings(providerNames)
  299. // Add provider groups
  300. for _, providerName := range providerNames {
  301. models := providerGroups[providerName]
  302. // Sort models within provider group
  303. sort.Slice(models, func(i, j int) bool {
  304. modelA := models[i]
  305. modelB := models[j]
  306. usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
  307. usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
  308. // Sort by usage time first, then by release date, then alphabetically
  309. if !usageA.IsZero() && !usageB.IsZero() {
  310. return usageA.After(usageB)
  311. }
  312. if !usageA.IsZero() && usageB.IsZero() {
  313. return true
  314. }
  315. if usageA.IsZero() && !usageB.IsZero() {
  316. return false
  317. }
  318. // Sort by release date if available
  319. if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
  320. dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
  321. dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
  322. if !dateA.IsZero() && !dateB.IsZero() {
  323. return dateA.After(dateB)
  324. }
  325. }
  326. return modelA.Model.Name < modelB.Model.Name
  327. })
  328. // Add provider header
  329. items = append(items, list.HeaderItem(providerName))
  330. // Add models in this provider group
  331. for _, model := range models {
  332. items = append(items, &ModelItem{
  333. ModelName: model.Model.Name,
  334. ProviderName: model.Provider.Name,
  335. })
  336. }
  337. }
  338. return items
  339. }
  340. // getRecentModels returns the most recently used models
  341. func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
  342. var recentModels []ModelWithProvider
  343. // Get recent models from app state
  344. for _, usage := range m.app.State.RecentlyUsedModels {
  345. if len(recentModels) >= limit {
  346. break
  347. }
  348. // Find the corresponding model
  349. for _, model := range m.allModels {
  350. if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
  351. recentModels = append(recentModels, model)
  352. break
  353. }
  354. }
  355. }
  356. return recentModels
  357. }
  358. func (m *modelDialog) Render(background string) string {
  359. return m.modal.Render(m.View(), background)
  360. }
  361. func (s *modelDialog) Close() tea.Cmd {
  362. return nil
  363. }
  364. func NewModelDialog(app *app.App) ModelDialog {
  365. dialog := &modelDialog{
  366. app: app,
  367. }
  368. dialog.setupAllModels()
  369. dialog.modal = modal.New(
  370. modal.WithTitle("Select Model"),
  371. modal.WithMaxWidth(dialog.dialogWidth+4),
  372. )
  373. return dialog
  374. }