feat(tui): even better model selector
This commit is contained in:
@@ -34,13 +34,13 @@ type CompletionItemI interface {
|
||||
GetRaw() any
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
func (ci *CompletionItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
|
||||
|
||||
backgroundColor := t.BackgroundElement()
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
if ci.backgroundColor != nil {
|
||||
backgroundColor = *ci.backgroundColor
|
||||
}
|
||||
@@ -73,6 +73,10 @@ func (ci *CompletionItem) GetRaw() any {
|
||||
return ci.Raw
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type CompletionItemOption func(*CompletionItem)
|
||||
|
||||
func WithBackgroundColor(color compat.AdaptiveColor) CompletionItemOption {
|
||||
@@ -81,7 +85,10 @@ func WithBackgroundColor(color compat.AdaptiveColor) CompletionItemOption {
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompletionItem(completionItem CompletionItem, opts ...CompletionItemOption) CompletionItemI {
|
||||
func NewCompletionItem(
|
||||
completionItem CompletionItem,
|
||||
opts ...CompletionItemOption,
|
||||
) CompletionItemI {
|
||||
for _, opt := range opts {
|
||||
opt(&completionItem)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package dialog
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"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"
|
||||
@@ -30,13 +32,13 @@ type ModelDialog interface {
|
||||
}
|
||||
|
||||
type modelDialog struct {
|
||||
app *app.App
|
||||
allModels []ModelWithProvider
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
modelList list.List[ModelItem]
|
||||
dialogWidth int
|
||||
app *app.App
|
||||
allModels []ModelWithProvider
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
type ModelWithProvider struct {
|
||||
@@ -49,7 +51,7 @@ type ModelItem struct {
|
||||
ProviderName string
|
||||
}
|
||||
|
||||
func (m ModelItem) Render(selected bool, width int) string {
|
||||
func (m *ModelItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
if selected {
|
||||
@@ -79,6 +81,10 @@ func (m ModelItem) Render(selected bool, width int) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ModelItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type modelKeyMap struct {
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
@@ -97,43 +103,53 @@ var modelKeys = modelKeyMap{
|
||||
|
||||
func (m *modelDialog) Init() tea.Cmd {
|
||||
m.setupAllModels()
|
||||
return nil
|
||||
return m.searchDialog.Init()
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
)
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog
|
||||
if modelItem, ok := msg.Item.(*ModelItem); ok {
|
||||
// Find the corresponding ModelWithProvider
|
||||
for _, model := range m.allModels {
|
||||
if model.Model.Name == modelItem.ModelName && model.Provider.Name == modelItem.ProviderName {
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
app.ModelSelectedMsg{
|
||||
Provider: model.Provider,
|
||||
Model: model.Model,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case key.Matches(msg, modelKeys.Escape):
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
|
||||
case SearchCancelledMsg:
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Update the list component
|
||||
updatedList, cmd := m.modelList.Update(msg)
|
||||
m.modelList = updatedList.(list.List[ModelItem])
|
||||
updatedDialog, cmd := m.searchDialog.Update(msg)
|
||||
m.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *modelDialog) View() string {
|
||||
return m.modelList.View()
|
||||
return m.searchDialog.View()
|
||||
}
|
||||
|
||||
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
|
||||
@@ -170,6 +186,7 @@ func (m *modelDialog) setupAllModels() {
|
||||
|
||||
m.sortModels()
|
||||
|
||||
// Calculate optimal width based on all models
|
||||
modelItems := make([]ModelItem, len(m.allModels))
|
||||
for i, modelWithProvider := range m.allModels {
|
||||
modelItems[i] = ModelItem{
|
||||
@@ -177,15 +194,15 @@ func (m *modelDialog) setupAllModels() {
|
||||
ProviderName: modelWithProvider.Provider.Name,
|
||||
}
|
||||
}
|
||||
|
||||
m.dialogWidth = m.calculateOptimalWidth(modelItems)
|
||||
|
||||
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
|
||||
m.modelList.SetMaxWidth(m.dialogWidth)
|
||||
// Initialize search dialog
|
||||
m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
|
||||
m.searchDialog.SetWidth(m.dialogWidth)
|
||||
|
||||
if len(m.allModels) > 0 {
|
||||
m.modelList.SetSelectedIndex(0)
|
||||
}
|
||||
// Build initial display list (empty query shows grouped view)
|
||||
items := m.buildDisplayList("")
|
||||
m.searchDialog.SetItems(items)
|
||||
}
|
||||
|
||||
func (m *modelDialog) sortModels() {
|
||||
@@ -248,6 +265,163 @@ func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// buildDisplayList creates the list items based on search query
|
||||
func (m *modelDialog) buildDisplayList(query string) []list.ListItem {
|
||||
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.ListItem {
|
||||
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.ListItem{}
|
||||
for _, match := range matches {
|
||||
model := modelMap[match.Target]
|
||||
existingItem := slices.IndexFunc(items, func(item list.ListItem) bool {
|
||||
castedItem := item.(*ModelItem)
|
||||
return castedItem.ModelName == model.Model.Name &&
|
||||
castedItem.ProviderName == model.Provider.Name
|
||||
})
|
||||
if existingItem != -1 {
|
||||
continue
|
||||
}
|
||||
items = append(items, &ModelItem{
|
||||
ModelName: model.Model.Name,
|
||||
ProviderName: model.Provider.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// buildGroupedResults creates a grouped list with Recent section and provider groups
|
||||
func (m *modelDialog) buildGroupedResults() []list.ListItem {
|
||||
var items []list.ListItem
|
||||
|
||||
// Add Recent section
|
||||
recentModels := m.getRecentModels(5)
|
||||
if len(recentModels) > 0 {
|
||||
items = append(items, list.HeaderItem("Recent"))
|
||||
for _, model := range recentModels {
|
||||
items = append(items, &ModelItem{
|
||||
ModelName: model.Model.Name,
|
||||
ProviderName: model.Provider.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
ModelName: model.Model.Name,
|
||||
ProviderName: model.Provider.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) Render(background string) string {
|
||||
return m.modal.Render(m.View(), background)
|
||||
}
|
||||
|
||||
221
packages/tui/internal/components/dialog/search.go
Normal file
221
packages/tui/internal/components/dialog/search.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// SearchQueryChangedMsg is emitted when the search query changes
|
||||
type SearchQueryChangedMsg struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
// SearchSelectionMsg is emitted when an item is selected
|
||||
type SearchSelectionMsg struct {
|
||||
Item interface{}
|
||||
Index int
|
||||
}
|
||||
|
||||
// SearchCancelledMsg is emitted when the search is cancelled
|
||||
type SearchCancelledMsg struct{}
|
||||
|
||||
// SearchDialog is a reusable component that combines a text input with a list
|
||||
type SearchDialog struct {
|
||||
textInput textinput.Model
|
||||
list list.List[list.ListItem]
|
||||
width int
|
||||
height int
|
||||
focused bool
|
||||
}
|
||||
|
||||
type searchKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
var searchKeys = searchKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "previous item"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "next item"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
}
|
||||
|
||||
// NewSearchDialog creates a new SearchDialog
|
||||
func NewSearchDialog(placeholder string, maxVisibleItems int) *SearchDialog {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Blurred.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Prompt = styles.NewStyle().
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Cursor.Color = t.Primary()
|
||||
ti.VirtualCursor = true
|
||||
|
||||
ti.Prompt = " "
|
||||
ti.CharLimit = -1
|
||||
ti.Focus()
|
||||
|
||||
emptyList := list.NewListComponent(
|
||||
[]list.ListItem{},
|
||||
maxVisibleItems,
|
||||
" No items",
|
||||
false,
|
||||
)
|
||||
|
||||
return &SearchDialog{
|
||||
textInput: ti,
|
||||
list: emptyList,
|
||||
focused: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
value := s.textInput.Value()
|
||||
if value == "" {
|
||||
return s, nil
|
||||
}
|
||||
s.textInput.Reset()
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: ""}
|
||||
})
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, searchKeys.Escape):
|
||||
return s, func() tea.Msg { return SearchCancelledMsg{} }
|
||||
|
||||
case key.Matches(msg, searchKeys.Enter):
|
||||
if selectedItem, idx := s.list.GetSelectedItem(); selectedItem != nil {
|
||||
return s, func() tea.Msg {
|
||||
return SearchSelectionMsg{Item: selectedItem, Index: idx}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Up):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.ListItem])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Down):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.ListItem])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
default:
|
||||
oldValue := s.textInput.Value()
|
||||
var cmd tea.Cmd
|
||||
s.textInput, cmd = s.textInput.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if newValue := s.textInput.Value(); newValue != oldValue {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: newValue}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (s *SearchDialog) View() string {
|
||||
s.list.SetMaxWidth(s.width)
|
||||
listView := s.list.View()
|
||||
listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleItems(), lipgloss.Top, listView)
|
||||
textinput := s.textInput.View()
|
||||
return textinput + "\n\n" + listView
|
||||
}
|
||||
|
||||
// SetWidth sets the width of the search dialog
|
||||
func (s *SearchDialog) SetWidth(width int) {
|
||||
s.width = width
|
||||
s.textInput.SetWidth(width - 2) // Account for padding and borders
|
||||
}
|
||||
|
||||
// SetHeight sets the height of the search dialog
|
||||
func (s *SearchDialog) SetHeight(height int) {
|
||||
s.height = height
|
||||
}
|
||||
|
||||
// SetItems updates the list items
|
||||
func (s *SearchDialog) SetItems(items []list.ListItem) {
|
||||
s.list.SetItems(items)
|
||||
}
|
||||
|
||||
// GetQuery returns the current search query
|
||||
func (s *SearchDialog) GetQuery() string {
|
||||
return s.textInput.Value()
|
||||
}
|
||||
|
||||
// SetQuery sets the search query
|
||||
func (s *SearchDialog) SetQuery(query string) {
|
||||
s.textInput.SetValue(query)
|
||||
}
|
||||
|
||||
// Focus focuses the search dialog
|
||||
func (s *SearchDialog) Focus() {
|
||||
s.focused = true
|
||||
s.textInput.Focus()
|
||||
}
|
||||
|
||||
// Blur removes focus from the search dialog
|
||||
func (s *SearchDialog) Blur() {
|
||||
s.focused = false
|
||||
s.textInput.Blur()
|
||||
}
|
||||
@@ -30,7 +30,7 @@ type sessionItem struct {
|
||||
isDeleteConfirming bool
|
||||
}
|
||||
|
||||
func (s sessionItem) Render(selected bool, width int) string {
|
||||
func (s sessionItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle()
|
||||
|
||||
@@ -75,6 +75,10 @@ func (s sessionItem) Render(selected bool, width int) string {
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (s sessionItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type sessionDialog struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
Reference in New Issue
Block a user