summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-14 11:44:19 -0500
committeradamdotdevin <[email protected]>2025-07-14 12:15:46 -0500
commitf1e7e7c1383075c87859cdbb1d08d510398302a9 (patch)
tree9639d16863bccc13314e97c38eb79e42893608b7
parent80b77caec04da4d5a97a92109c294c930f5e3c0f (diff)
downloadopencode-f1e7e7c1383075c87859cdbb1d08d510398302a9.tar.gz
opencode-f1e7e7c1383075c87859cdbb1d08d510398302a9.zip
feat(tui): even better model selector
-rw-r--r--packages/tui/internal/completions/commands.go4
-rw-r--r--packages/tui/internal/completions/files.go6
-rw-r--r--packages/tui/internal/components/dialog/complete.go13
-rw-r--r--packages/tui/internal/components/dialog/models.go246
-rw-r--r--packages/tui/internal/components/dialog/search.go221
-rw-r--r--packages/tui/internal/components/dialog/session.go6
-rw-r--r--packages/tui/internal/components/list/list.go242
-rw-r--r--packages/tui/internal/components/modal/modal.go4
8 files changed, 675 insertions, 67 deletions
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go
index d7e21419b..79d2230c8 100644
--- a/packages/tui/internal/completions/commands.go
+++ b/packages/tui/internal/completions/commands.go
@@ -43,7 +43,7 @@ func (c *CommandCompletionProvider) getCommandCompletionItem(
Title: title,
Value: value,
ProviderID: c.GetId(),
- })
+ }, dialog.WithBackgroundColor(t.BackgroundElement()))
}
func (c *CommandCompletionProvider) GetChildEntries(
@@ -91,7 +91,7 @@ func (c *CommandCompletionProvider) GetChildEntries(
}
// Find fuzzy matches
- matches := fuzzy.RankFind(query, commandNames)
+ matches := fuzzy.RankFindFold(query, commandNames)
// Sort by score (best matches first)
sort.Sort(matches)
diff --git a/packages/tui/internal/completions/files.go b/packages/tui/internal/completions/files.go
index 77b653f8a..f69163c77 100644
--- a/packages/tui/internal/completions/files.go
+++ b/packages/tui/internal/completions/files.go
@@ -30,7 +30,7 @@ func (cg *filesContextGroup) GetEmptyMessage() string {
func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
t := theme.CurrentTheme()
items := make([]dialog.CompletionItemI, 0)
- base := styles.NewStyle().Background(t.BackgroundPanel())
+ base := styles.NewStyle().Background(t.BackgroundElement())
green := base.Foreground(t.Success()).Render
red := base.Foreground(t.Error()).Render
@@ -55,7 +55,7 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
ProviderID: cg.GetId(),
Raw: file,
},
- dialog.WithBackgroundColor(t.BackgroundPanel()),
+ dialog.WithBackgroundColor(t.BackgroundElement()),
)
items = append(items, item)
}
@@ -103,7 +103,7 @@ func (cg *filesContextGroup) GetChildEntries(
ProviderID: cg.GetId(),
Raw: file,
},
- dialog.WithBackgroundColor(theme.CurrentTheme().BackgroundPanel()),
+ dialog.WithBackgroundColor(theme.CurrentTheme().BackgroundElement()),
)
items = append(items, item)
}
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index 58ec2fef1..9da79472d 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/packages/tui/internal/components/dialog/complete.go
@@ -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)
}
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
index f8cda82a8..6dbda865c 100644
--- a/packages/tui/internal/components/dialog/models.go
+++ b/packages/tui/internal/components/dialog/models.go
@@ -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)
}
diff --git a/packages/tui/internal/components/dialog/search.go b/packages/tui/internal/components/dialog/search.go
new file mode 100644
index 000000000..53f3c67b7
--- /dev/null
+++ b/packages/tui/internal/components/dialog/search.go
@@ -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()
+}
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
index 418b9dd51..9348647c3 100644
--- a/packages/tui/internal/components/dialog/session.go
+++ b/packages/tui/internal/components/dialog/session.go
@@ -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
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go
index 8a18d28fc..2405440fe 100644
--- a/packages/tui/internal/components/list/list.go
+++ b/packages/tui/internal/components/list/list.go
@@ -11,7 +11,8 @@ import (
)
type ListItem interface {
- Render(selected bool, width int) string
+ Render(selected bool, width int, isFirstInViewport bool) string
+ Selectable() bool
}
type List[T ListItem] interface {
@@ -24,6 +25,8 @@ type List[T ListItem] interface {
SetSelectedIndex(idx int)
SetEmptyMessage(msg string)
IsEmpty() bool
+ GetMaxVisibleItems() int
+ GetActualHeight() int
}
type listComponent[T ListItem] struct {
@@ -72,14 +75,10 @@ func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch {
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
- if c.selectedIdx > 0 {
- c.selectedIdx--
- }
+ c.moveUp()
return c, nil
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
- if c.selectedIdx < len(c.items)-1 {
- c.selectedIdx++
- }
+ c.moveDown()
return c, nil
}
}
@@ -87,8 +86,50 @@ func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, nil
}
+// moveUp moves the selection up, skipping non-selectable items
+func (c *listComponent[T]) moveUp() {
+ if len(c.items) == 0 {
+ return
+ }
+
+ // Find the previous selectable item
+ for i := c.selectedIdx - 1; i >= 0; i-- {
+ if c.items[i].Selectable() {
+ c.selectedIdx = i
+ return
+ }
+ }
+
+ // If no selectable item found above, stay at current position
+}
+
+// moveDown moves the selection down, skipping non-selectable items
+func (c *listComponent[T]) moveDown() {
+ if len(c.items) == 0 {
+ return
+ }
+
+ originalIdx := c.selectedIdx
+ for {
+ if c.selectedIdx < len(c.items)-1 {
+ c.selectedIdx++
+ } else {
+ break
+ }
+
+ if c.items[c.selectedIdx].Selectable() {
+ return
+ }
+
+ // Prevent infinite loop
+ if c.selectedIdx == originalIdx {
+ break
+ }
+ }
+}
+
func (c *listComponent[T]) GetSelectedItem() (T, int) {
- if len(c.items) > 0 {
+ if len(c.items) > 0 && c.items[c.selectedIdx].Selectable() {
return c.items[c.selectedIdx], c.selectedIdx
}
@@ -97,8 +138,13 @@ func (c *listComponent[T]) GetSelectedItem() (T, int) {
}
func (c *listComponent[T]) SetItems(items []T) {
- c.selectedIdx = 0
c.items = items
+ c.selectedIdx = 0
+
+ // Ensure initial selection is on a selectable item
+ if len(items) > 0 && !items[0].Selectable() {
+ c.moveDown()
+ }
}
func (c *listComponent[T]) GetItems() []T {
@@ -123,19 +169,19 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
}
}
-func (c *listComponent[T]) View() string {
+func (c *listComponent[T]) GetMaxVisibleItems() int {
+ return c.maxVisibleItems
+}
+
+func (c *listComponent[T]) GetActualHeight() int {
items := c.items
- maxWidth := c.maxWidth
- if maxWidth == 0 {
- maxWidth = 80 // Default width if not set
+ if len(items) == 0 {
+ return 1 // For empty message
}
+
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
- if len(items) <= 0 {
- return c.fallbackMsg
- }
-
if len(items) > maxVisibleItems {
halfVisible := maxVisibleItems / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
@@ -147,17 +193,142 @@ func (c *listComponent[T]) View() string {
endIdx := min(startIdx+maxVisibleItems, len(items))
- listItems := make([]string, 0, maxVisibleItems)
+ height := 0
+ for i := startIdx; i < endIdx; i++ {
+ item := items[i]
+ isFirstInViewport := (i == startIdx)
+
+ // Check if this is a HeaderItem and calculate its height
+ if _, ok := any(item).(HeaderItem); ok {
+ if isFirstInViewport {
+ height += 1 // No top margin
+ } else {
+ height += 2 // With top margin
+ }
+ } else {
+ height += 1 // Regular items take 1 line
+ }
+ }
+
+ return height
+}
+
+func (c *listComponent[T]) View() string {
+ items := c.items
+ maxWidth := c.maxWidth
+ if maxWidth == 0 {
+ maxWidth = 80 // Default width if not set
+ }
+
+ if len(items) <= 0 {
+ return c.fallbackMsg
+ }
+
+ // Calculate viewport based on actual heights, not item counts
+ startIdx, endIdx := c.calculateViewport()
+
+ listItems := make([]string, 0, endIdx-startIdx)
for i := startIdx; i < endIdx; i++ {
item := items[i]
- title := item.Render(i == c.selectedIdx, maxWidth)
+ isFirstInViewport := (i == startIdx)
+ title := item.Render(i == c.selectedIdx, maxWidth, isFirstInViewport)
listItems = append(listItems, title)
}
return strings.Join(listItems, "\n")
}
+// calculateViewport determines which items to show based on available height
+func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
+ items := c.items
+ if len(items) == 0 {
+ return 0, 0
+ }
+
+ // Helper function to calculate height of an item at given position
+ getItemHeight := func(idx int, isFirst bool) int {
+ if _, ok := any(items[idx]).(HeaderItem); ok {
+ if isFirst {
+ return 1 // No top margin
+ } else {
+ return 2 // With top margin
+ }
+ }
+ return 1 // Regular items
+ }
+
+ // If we have fewer items than max, show all
+ if len(items) <= c.maxVisibleItems {
+ return 0, len(items)
+ }
+
+ // Try to center the selected item in the viewport
+ // Start by trying to put selected item in the middle
+ targetStart := c.selectedIdx - c.maxVisibleItems/2
+ if targetStart < 0 {
+ targetStart = 0
+ }
+
+ // Find the actual start and end indices that fit within our height budget
+ bestStart := 0
+ bestEnd := 0
+ bestHeight := 0
+
+ // Try different starting positions around our target
+ for start := max(0, targetStart-2); start <= min(len(items)-1, targetStart+2); start++ {
+ currentHeight := 0
+ end := start
+
+ for end < len(items) && currentHeight < c.maxVisibleItems {
+ itemHeight := getItemHeight(end, end == start)
+ if currentHeight+itemHeight > c.maxVisibleItems {
+ break
+ }
+ currentHeight += itemHeight
+ end++
+ }
+
+ // Check if this viewport contains the selected item and is better than current best
+ if start <= c.selectedIdx && c.selectedIdx < end {
+ if currentHeight > bestHeight || (currentHeight == bestHeight && abs(start+end-2*c.selectedIdx) < abs(bestStart+bestEnd-2*c.selectedIdx)) {
+ bestStart = start
+ bestEnd = end
+ bestHeight = currentHeight
+ }
+ }
+ }
+
+ // If no good viewport found that contains selected item, just show from selected item
+ if bestEnd == 0 {
+ bestStart = c.selectedIdx
+ currentHeight := 0
+ for bestEnd = bestStart; bestEnd < len(items) && currentHeight < c.maxVisibleItems; bestEnd++ {
+ itemHeight := getItemHeight(bestEnd, bestEnd == bestStart)
+ if currentHeight+itemHeight > c.maxVisibleItems {
+ break
+ }
+ currentHeight += itemHeight
+ }
+ }
+
+ return bestStart, bestEnd
+}
+
+func abs(x int) int {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
func NewListComponent[T ListItem](
items []T,
maxVisibleItems int,
@@ -176,7 +347,7 @@ func NewListComponent[T ListItem](
// StringItem is a simple implementation of ListItem for string values
type StringItem string
-func (s StringItem) Render(selected bool, width int) string {
+func (s StringItem) Render(selected bool, width int, isFirstInViewport bool) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle()
@@ -198,6 +369,37 @@ func (s StringItem) Render(selected bool, width int) string {
return itemStyle.Render(truncatedStr)
}
+func (s StringItem) Selectable() bool {
+ return true
+}
+
+// HeaderItem is a non-selectable header item for grouping
+type HeaderItem string
+
+func (h HeaderItem) Render(selected bool, width int, isFirstInViewport bool) string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.NewStyle()
+
+ truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
+
+ headerStyle := baseStyle.
+ Foreground(t.Accent()).
+ Bold(true).
+ MarginBottom(0).
+ PaddingLeft(1)
+
+ // Only add top margin if this is not the first item in the viewport
+ if !isFirstInViewport {
+ headerStyle = headerStyle.MarginTop(1)
+ }
+
+ return headerStyle.Render(truncatedStr)
+}
+
+func (h HeaderItem) Selectable() bool {
+ return false
+}
+
// NewStringList creates a new list component with string items
func NewStringList(
items []string,
diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go
index 5c2fbf8bb..09989d8ec 100644
--- a/packages/tui/internal/components/modal/modal.go
+++ b/packages/tui/internal/components/modal/modal.go
@@ -95,7 +95,7 @@ func (m *Modal) Render(contentView string, background string) string {
var finalContent string
if m.title != "" {
titleStyle := baseStyle.
- Foreground(t.Primary()).
+ Foreground(t.Text()).
Bold(true).
Padding(0, 1)
@@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
modalView,
background,
layout.WithOverlayBorder(),
- layout.WithOverlayBorderColor(t.Primary()),
+ layout.WithOverlayBorderColor(t.BorderActive()),
)
}