Sfoglia il codice sorgente

feat(tui): even better model selector

adamdotdevin 7 mesi fa
parent
commit
f1e7e7c138

+ 2 - 2
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)

+ 3 - 3
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)
 		}

+ 10 - 3
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)
 	}

+ 210 - 36
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)
 }

+ 221 - 0
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()
+}

+ 5 - 1
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

+ 222 - 20
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,

+ 2 - 2
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()),
 	)
 }