Преглед изворни кода

fix(tui): rework lists and search dialog

adamdotdevin пре 7 месеци
родитељ
комит
533f64fe26

+ 23 - 20
packages/tui/internal/completions/commands.go

@@ -8,7 +8,6 @@ import (
 	"github.com/lithammer/fuzzysearch/fuzzy"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/commands"
-	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 )
@@ -17,7 +16,7 @@ type CommandCompletionProvider struct {
 	app *app.App
 }
 
-func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider {
+func NewCommandCompletionProvider(app *app.App) CompletionProvider {
 	return &CommandCompletionProvider{app: app}
 }
 
@@ -32,24 +31,28 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
 func (c *CommandCompletionProvider) getCommandCompletionItem(
 	cmd commands.Command,
 	space int,
-	t theme.Theme,
-) dialog.CompletionItemI {
-	spacer := strings.Repeat(" ", space)
-	title := "  /" + cmd.PrimaryTrigger() + styles.NewStyle().
-		Foreground(t.TextMuted()).
-		Render(spacer+cmd.Description)
+) CompletionSuggestion {
+	displayFunc := func(s styles.Style) string {
+		t := theme.CurrentTheme()
+		spacer := strings.Repeat(" ", space)
+		display := "  /" + cmd.PrimaryTrigger() + s.
+			Foreground(t.TextMuted()).
+			Render(spacer+cmd.Description)
+		return display
+	}
+
 	value := string(cmd.Name)
-	return dialog.NewCompletionItem(dialog.CompletionItem{
-		Title:      title,
+	return CompletionSuggestion{
+		Display:    displayFunc,
 		Value:      value,
 		ProviderID: c.GetId(),
-	}, dialog.WithBackgroundColor(t.BackgroundElement()))
+		RawData:    cmd,
+	}
 }
 
 func (c *CommandCompletionProvider) GetChildEntries(
 	query string,
-) ([]dialog.CompletionItemI, error) {
-	t := theme.CurrentTheme()
+) ([]CompletionSuggestion, error) {
 	commands := c.app.Commands
 
 	space := 1
@@ -63,20 +66,20 @@ func (c *CommandCompletionProvider) GetChildEntries(
 	sorted := commands.Sorted()
 	if query == "" {
 		// If no query, return all commands
-		items := []dialog.CompletionItemI{}
+		items := []CompletionSuggestion{}
 		for _, cmd := range sorted {
 			if !cmd.HasTrigger() {
 				continue
 			}
 			space := space - lipgloss.Width(cmd.PrimaryTrigger())
-			items = append(items, c.getCommandCompletionItem(cmd, space, t))
+			items = append(items, c.getCommandCompletionItem(cmd, space))
 		}
 		return items, nil
 	}
 
 	// Use fuzzy matching for commands
 	var commandNames []string
-	commandMap := make(map[string]dialog.CompletionItemI)
+	commandMap := make(map[string]CompletionSuggestion)
 
 	for _, cmd := range sorted {
 		if !cmd.HasTrigger() {
@@ -86,7 +89,7 @@ func (c *CommandCompletionProvider) GetChildEntries(
 		// Add all triggers as searchable options
 		for _, trigger := range cmd.Trigger {
 			commandNames = append(commandNames, trigger)
-			commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t)
+			commandMap[trigger] = c.getCommandCompletionItem(cmd, space)
 		}
 	}
 
@@ -97,13 +100,13 @@ func (c *CommandCompletionProvider) GetChildEntries(
 	sort.Sort(matches)
 
 	// Convert matches to completion items, deduplicating by command name
-	items := []dialog.CompletionItemI{}
+	items := []CompletionSuggestion{}
 	seen := make(map[string]bool)
 	for _, match := range matches {
 		if item, ok := commandMap[match.Target]; ok {
 			// Use the command's value (name) as the deduplication key
-			if !seen[item.GetValue()] {
-				seen[item.GetValue()] = true
+			if !seen[item.Value] {
+				seen[item.Value] = true
 				items = append(items, item)
 			}
 		}

+ 33 - 30
packages/tui/internal/completions/files.go

@@ -9,14 +9,13 @@ import (
 
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 )
 
 type filesContextGroup struct {
 	app      *app.App
-	gitFiles []dialog.CompletionItemI
+	gitFiles []CompletionSuggestion
 }
 
 func (cg *filesContextGroup) GetId() string {
@@ -27,12 +26,8 @@ func (cg *filesContextGroup) GetEmptyMessage() string {
 	return "no matching files"
 }
 
-func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
-	t := theme.CurrentTheme()
-	items := make([]dialog.CompletionItemI, 0)
-	base := styles.NewStyle().Background(t.BackgroundElement())
-	green := base.Foreground(t.Success()).Render
-	red := base.Foreground(t.Error()).Render
+func (cg *filesContextGroup) getGitFiles() []CompletionSuggestion {
+	items := make([]CompletionSuggestion, 0)
 
 	status, _ := cg.app.Client.File.Status(context.Background())
 	if status != nil {
@@ -42,21 +37,25 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
 		})
 
 		for _, file := range files {
-			title := file.Path
-			if file.Added > 0 {
-				title += green(" +" + strconv.Itoa(int(file.Added)))
-			}
-			if file.Removed > 0 {
-				title += red(" -" + strconv.Itoa(int(file.Removed)))
+			displayFunc := func(s styles.Style) string {
+				t := theme.CurrentTheme()
+				green := s.Foreground(t.Success()).Render
+				red := s.Foreground(t.Error()).Render
+				display := file.Path
+				if file.Added > 0 {
+					display += green(" +" + strconv.Itoa(int(file.Added)))
+				}
+				if file.Removed > 0 {
+					display += red(" -" + strconv.Itoa(int(file.Removed)))
+				}
+				return display
 			}
-			item := dialog.NewCompletionItem(dialog.CompletionItem{
-				Title:      title,
+			item := CompletionSuggestion{
+				Display:    displayFunc,
 				Value:      file.Path,
 				ProviderID: cg.GetId(),
-				Raw:        file,
-			},
-				dialog.WithBackgroundColor(t.BackgroundElement()),
-			)
+				RawData:    file,
+			}
 			items = append(items, item)
 		}
 	}
@@ -66,8 +65,8 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
 
 func (cg *filesContextGroup) GetChildEntries(
 	query string,
-) ([]dialog.CompletionItemI, error) {
-	items := make([]dialog.CompletionItemI, 0)
+) ([]CompletionSuggestion, error) {
+	items := make([]CompletionSuggestion, 0)
 
 	query = strings.TrimSpace(query)
 	if query == "" {
@@ -89,7 +88,7 @@ func (cg *filesContextGroup) GetChildEntries(
 	for _, file := range *files {
 		exists := false
 		for _, existing := range cg.gitFiles {
-			if existing.GetValue() == file {
+			if existing.Value == file {
 				if query != "" {
 					items = append(items, existing)
 				}
@@ -97,14 +96,18 @@ func (cg *filesContextGroup) GetChildEntries(
 			}
 		}
 		if !exists {
-			item := dialog.NewCompletionItem(dialog.CompletionItem{
-				Title:      file,
+			displayFunc := func(s styles.Style) string {
+				// t := theme.CurrentTheme()
+				// return s.Foreground(t.Text()).Render(file)
+				return s.Render(file)
+			}
+
+			item := CompletionSuggestion{
+				Display:    displayFunc,
 				Value:      file,
 				ProviderID: cg.GetId(),
-				Raw:        file,
-			},
-				dialog.WithBackgroundColor(theme.CurrentTheme().BackgroundElement()),
-			)
+				RawData:    file,
+			}
 			items = append(items, item)
 		}
 	}
@@ -112,7 +115,7 @@ func (cg *filesContextGroup) GetChildEntries(
 	return items, nil
 }
 
-func NewFileContextGroup(app *app.App) dialog.CompletionProvider {
+func NewFileContextGroup(app *app.App) CompletionProvider {
 	cg := &filesContextGroup{
 		app: app,
 	}

+ 8 - 0
packages/tui/internal/completions/provider.go

@@ -0,0 +1,8 @@
+package completions
+
+// CompletionProvider defines the interface for completion data providers
+type CompletionProvider interface {
+	GetId() string
+	GetChildEntries(query string) ([]CompletionSuggestion, error)
+	GetEmptyMessage() string
+}

+ 24 - 0
packages/tui/internal/completions/suggestion.go

@@ -0,0 +1,24 @@
+package completions
+
+import "github.com/sst/opencode/internal/styles"
+
+// CompletionSuggestion represents a data-only completion suggestion
+// with no styling or rendering logic
+type CompletionSuggestion struct {
+	// The text to be displayed in the list. May contain minimal inline
+	// ANSI styling if intrinsic to the data (e.g., git diff colors).
+	Display func(styles.Style) string
+
+	// The value to be used when the item is selected (e.g., inserted into the editor).
+	Value string
+
+	// An optional, longer description to be displayed.
+	Description string
+
+	// The ID of the provider that generated this suggestion.
+	ProviderID string
+
+	// The raw, underlying data object (e.g., opencode.Symbol, commands.Command).
+	// This allows the selection handler to perform rich actions.
+	RawData any
+}

+ 22 - 21
packages/tui/internal/completions/symbols.go

@@ -8,7 +8,6 @@ import (
 
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 )
@@ -58,8 +57,8 @@ const (
 
 func (cg *symbolsContextGroup) GetChildEntries(
 	query string,
-) ([]dialog.CompletionItemI, error) {
-	items := make([]dialog.CompletionItemI, 0)
+) ([]CompletionSuggestion, error) {
+	items := make([]CompletionSuggestion, 0)
 
 	query = strings.TrimSpace(query)
 	if query == "" {
@@ -78,40 +77,42 @@ func (cg *symbolsContextGroup) GetChildEntries(
 		return items, nil
 	}
 
-	t := theme.CurrentTheme()
-	baseStyle := styles.NewStyle().Background(t.BackgroundElement())
-	base := baseStyle.Render
-	muted := baseStyle.Foreground(t.TextMuted()).Render
-
 	for _, sym := range *symbols {
 		parts := strings.Split(sym.Name, ".")
 		lastPart := parts[len(parts)-1]
-		title := base(lastPart)
-
-		uriParts := strings.Split(sym.Location.Uri, "/")
-		lastTwoParts := uriParts[len(uriParts)-2:]
-		joined := strings.Join(lastTwoParts, "/")
-		title += muted(fmt.Sprintf(" %s", joined))
-
 		start := int(sym.Location.Range.Start.Line)
 		end := int(sym.Location.Range.End.Line)
-		title += muted(fmt.Sprintf(":L%d-%d", start, end))
+
+		displayFunc := func(s styles.Style) string {
+			t := theme.CurrentTheme()
+			base := s.Foreground(t.Text()).Render
+			muted := s.Foreground(t.TextMuted()).Render
+			display := base(lastPart)
+
+			uriParts := strings.Split(sym.Location.Uri, "/")
+			lastTwoParts := uriParts[len(uriParts)-2:]
+			joined := strings.Join(lastTwoParts, "/")
+			display += muted(fmt.Sprintf(" %s", joined))
+
+			display += muted(fmt.Sprintf(":L%d-%d", start, end))
+			return display
+		}
 
 		value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
 
-		item := dialog.NewCompletionItem(dialog.CompletionItem{
-			Title:      title,
+		item := CompletionSuggestion{
+			Display:    displayFunc,
 			Value:      value,
 			ProviderID: cg.GetId(),
-			Raw:        sym,
-		})
+			RawData:    sym,
+		}
 		items = append(items, item)
 	}
 
 	return items, nil
 }
 
-func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider {
+func NewSymbolsContextGroup(app *app.App) CompletionProvider {
 	return &symbolsContextGroup{
 		app: app,
 	}

+ 8 - 8
packages/tui/internal/components/chat/editor.go

@@ -142,9 +142,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.spinner = createSpinner()
 		return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
 	case dialog.CompletionSelectedMsg:
-		switch msg.Item.GetProviderID() {
+		switch msg.Item.ProviderID {
 		case "commands":
-			commandName := strings.TrimPrefix(msg.Item.GetValue(), "/")
+			commandName := strings.TrimPrefix(msg.Item.Value, "/")
 			updated, cmd := m.Clear()
 			m = updated.(*editorComponent)
 			cmds = append(cmds, cmd)
@@ -154,7 +154,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			atIndex := m.textarea.LastRuneIndex('@')
 			if atIndex == -1 {
 				// Should not happen, but as a fallback, just insert.
-				m.textarea.InsertString(msg.Item.GetValue() + " ")
+				m.textarea.InsertString(msg.Item.Value + " ")
 				return m, nil
 			}
 
@@ -165,7 +165,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 			// Now, insert the attachment at the position where the '@' was.
 			// The cursor is now at `atIndex` after the replacement.
-			filePath := msg.Item.GetValue()
+			filePath := msg.Item.Value
 			extension := filepath.Ext(filePath)
 			mediaType := ""
 			switch extension {
@@ -192,20 +192,20 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			atIndex := m.textarea.LastRuneIndex('@')
 			if atIndex == -1 {
 				// Should not happen, but as a fallback, just insert.
-				m.textarea.InsertString(msg.Item.GetValue() + " ")
+				m.textarea.InsertString(msg.Item.Value + " ")
 				return m, nil
 			}
 
 			cursorCol := m.textarea.CursorColumn()
 			m.textarea.ReplaceRange(atIndex, cursorCol, "")
 
-			symbol := msg.Item.GetRaw().(opencode.Symbol)
+			symbol := msg.Item.RawData.(opencode.Symbol)
 			parts := strings.Split(symbol.Name, ".")
 			lastPart := parts[len(parts)-1]
 			attachment := &textarea.Attachment{
 				ID:        uuid.NewString(),
 				Display:   "@" + lastPart,
-				URL:       msg.Item.GetValue(),
+				URL:       msg.Item.Value,
 				Filename:  lastPart,
 				MediaType: "text/plain",
 			}
@@ -213,7 +213,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.textarea.InsertString(" ")
 			return m, nil
 		default:
-			slog.Debug("Unknown provider", "provider", msg.Item.GetProviderID())
+			slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
 			return m, nil
 		}
 	}

+ 44 - 102
packages/tui/internal/components/dialog/complete.go

@@ -9,100 +9,17 @@ import (
 	"github.com/charmbracelet/bubbles/v2/textarea"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/lipgloss/v2/compat"
 	"github.com/lithammer/fuzzysearch/fuzzy"
 	"github.com/muesli/reflow/truncate"
+	"github.com/sst/opencode/internal/completions"
 	"github.com/sst/opencode/internal/components/list"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
 )
 
-type CompletionItem struct {
-	Title           string
-	Value           string
-	ProviderID      string
-	Raw             any
-	backgroundColor *compat.AdaptiveColor
-}
-
-type CompletionItemI interface {
-	list.ListItem
-	GetValue() string
-	DisplayValue() string
-	GetProviderID() string
-	GetRaw() any
-}
-
-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.BackgroundPanel()
-	if ci.backgroundColor != nil {
-		backgroundColor = *ci.backgroundColor
-	}
-
-	itemStyle := baseStyle.
-		Background(backgroundColor).
-		Padding(0, 1)
-
-	if selected {
-		itemStyle = itemStyle.Foreground(t.Primary())
-	}
-
-	title := itemStyle.Render(truncatedStr)
-	return title
-}
-
-func (ci *CompletionItem) DisplayValue() string {
-	return ci.Title
-}
-
-func (ci *CompletionItem) GetValue() string {
-	return ci.Value
-}
-
-func (ci *CompletionItem) GetProviderID() string {
-	return ci.ProviderID
-}
-
-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 {
-	return func(ci *CompletionItem) {
-		ci.backgroundColor = &color
-	}
-}
-
-func NewCompletionItem(
-	completionItem CompletionItem,
-	opts ...CompletionItemOption,
-) CompletionItemI {
-	for _, opt := range opts {
-		opt(&completionItem)
-	}
-	return &completionItem
-}
-
-type CompletionProvider interface {
-	GetId() string
-	GetChildEntries(query string) ([]CompletionItemI, error)
-	GetEmptyMessage() string
-}
-
 type CompletionSelectedMsg struct {
-	Item         CompletionItemI
+	Item         completions.CompletionSuggestion
 	SearchString string
 }
 
@@ -121,11 +38,11 @@ type CompletionDialog interface {
 
 type completionDialogComponent struct {
 	query                string
-	providers            []CompletionProvider
+	providers            []completions.CompletionProvider
 	width                int
 	height               int
 	pseudoSearchTextArea textarea.Model
-	list                 list.List[CompletionItemI]
+	list                 list.List[completions.CompletionSuggestion]
 	trigger              string
 }
 
@@ -149,7 +66,7 @@ func (c *completionDialogComponent) Init() tea.Cmd {
 
 func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
 	return func() tea.Msg {
-		allItems := make([]CompletionItemI, 0)
+		allItems := make([]completions.CompletionSuggestion, 0)
 
 		// Collect results from all providers
 		for _, provider := range c.providers {
@@ -169,10 +86,12 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
 
 		// If there's a query, use fuzzy ranking to sort results
 		if query != "" && len(allItems) > 0 {
+			t := theme.CurrentTheme()
+			baseStyle := styles.NewStyle().Background(t.BackgroundElement())
 			// Create a slice of display values for fuzzy matching
 			displayValues := make([]string, len(allItems))
 			for i, item := range allItems {
-				displayValues[i] = item.DisplayValue()
+				displayValues[i] = item.Display(baseStyle)
 			}
 
 			// Get fuzzy matches with ranking
@@ -182,7 +101,7 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
 			sort.Sort(matches)
 
 			// Reorder items based on fuzzy ranking
-			rankedItems := make([]CompletionItemI, 0, len(matches))
+			rankedItems := make([]completions.CompletionSuggestion, 0, len(matches))
 			for _, match := range matches {
 				rankedItems = append(rankedItems, allItems[match.OriginalIndex])
 			}
@@ -196,7 +115,7 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
 func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
-	case []CompletionItemI:
+	case []completions.CompletionSuggestion:
 		c.list.SetItems(msg)
 	case tea.KeyMsg:
 		if c.pseudoSearchTextArea.Focused() {
@@ -214,7 +133,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 
 				u, cmd := c.list.Update(msg)
-				c.list = u.(list.List[CompletionItemI])
+				c.list = u.(list.List[completions.CompletionSuggestion])
 				cmds = append(cmds, cmd)
 			}
 
@@ -248,11 +167,11 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 func (c *completionDialogComponent) View() string {
 	t := theme.CurrentTheme()
-	baseStyle := styles.NewStyle().Foreground(t.Text())
 	c.list.SetMaxWidth(c.width)
 
-	return baseStyle.
-		Padding(0, 0).
+	return styles.NewStyle().
+		Padding(0, 1).
+		Foreground(t.Text()).
 		Background(t.BackgroundElement()).
 		BorderStyle(lipgloss.ThickBorder()).
 		BorderLeft(true).
@@ -271,7 +190,7 @@ func (c *completionDialogComponent) IsEmpty() bool {
 	return c.list.IsEmpty()
 }
 
-func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
+func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
 	value := c.pseudoSearchTextArea.Value()
 	return tea.Batch(
 		util.CmdHandler(CompletionSelectedMsg{
@@ -290,7 +209,7 @@ func (c *completionDialogComponent) close() tea.Cmd {
 
 func NewCompletionDialogComponent(
 	trigger string,
-	providers ...CompletionProvider,
+	providers ...completions.CompletionProvider,
 ) CompletionDialog {
 	ti := textarea.New()
 	ti.SetValue(trigger)
@@ -301,11 +220,34 @@ func NewCompletionDialogComponent(
 		emptyMessage = providers[0].GetEmptyMessage()
 	}
 
+	// Define render function for completion suggestions
+	renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
+		t := theme.CurrentTheme()
+		style := baseStyle
+
+		if selected {
+			style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
+		} else {
+			style = style.Background(t.BackgroundElement()).Foreground(t.Text())
+		}
+
+		// The item.Display string already has any inline colors from the provider
+		truncatedStr := truncate.String(item.Display(style), uint(width-4))
+		return style.Width(width - 4).Render(truncatedStr)
+	}
+
+	// Define selectable function - all completion suggestions are selectable
+	selectableFunc := func(item completions.CompletionSuggestion) bool {
+		return true
+	}
+
 	li := list.NewListComponent(
-		[]CompletionItemI{},
-		7,
-		emptyMessage,
-		false,
+		list.WithItems([]completions.CompletionSuggestion{}),
+		list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
+		list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
+		list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
+		list.WithRenderFunc(renderFunc),
+		list.WithSelectableFunc(selectableFunc),
 	)
 
 	c := &completionDialogComponent{
@@ -318,7 +260,7 @@ func NewCompletionDialogComponent(
 
 	// Load initial items from all providers
 	go func() {
-		allItems := make([]CompletionItemI, 0)
+		allItems := make([]completions.CompletionSuggestion, 0)
 		for _, provider := range providers {
 			items, err := provider.GetChildEntries("")
 			if err != nil {

+ 59 - 23
packages/tui/internal/components/dialog/find.go

@@ -4,9 +4,12 @@ import (
 	"log/slog"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/sst/opencode/internal/completions"
 	"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"
 )
 
@@ -25,11 +28,39 @@ type FindDialog interface {
 	IsEmpty() bool
 }
 
+// findItem is a custom list item for file suggestions
+type findItem struct {
+	suggestion completions.CompletionSuggestion
+}
+
+func (f findItem) Render(
+	selected bool,
+	width int,
+	baseStyle styles.Style,
+) string {
+	t := theme.CurrentTheme()
+
+	itemStyle := baseStyle.
+		Background(t.BackgroundPanel()).
+		Foreground(t.TextMuted())
+
+	if selected {
+		itemStyle = itemStyle.Foreground(t.Primary())
+	}
+
+	return itemStyle.PaddingLeft(1).Render(f.suggestion.Display(itemStyle))
+}
+
+func (f findItem) Selectable() bool {
+	return true
+}
+
 type findDialogComponent struct {
-	completionProvider CompletionProvider
+	completionProvider completions.CompletionProvider
 	width, height      int
 	modal              *modal.Modal
 	searchDialog       *SearchDialog
+	suggestions        []completions.CompletionSuggestion
 }
 
 func (f *findDialogComponent) Init() tea.Cmd {
@@ -38,19 +69,20 @@ func (f *findDialogComponent) Init() tea.Cmd {
 
 func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case []CompletionItemI:
-		// Convert CompletionItemI to list.ListItem
-		items := make([]list.ListItem, len(msg))
-		for i, item := range msg {
-			items[i] = item
+	case []completions.CompletionSuggestion:
+		// Store suggestions and convert to findItem for the search dialog
+		f.suggestions = msg
+		items := make([]list.Item, len(msg))
+		for i, suggestion := range msg {
+			items[i] = findItem{suggestion: suggestion}
 		}
 		f.searchDialog.SetItems(items)
 		return f, nil
 
 	case SearchSelectionMsg:
-		// Handle selection from search dialog
-		if item, ok := msg.Item.(CompletionItemI); ok {
-			return f, f.selectFile(item)
+		// Handle selection from search dialog - now we can directly access the suggestion
+		if item, ok := msg.Item.(findItem); ok {
+			return f, f.selectFile(item.suggestion)
 		}
 		return f, nil
 
@@ -91,11 +123,11 @@ func (f *findDialogComponent) IsEmpty() bool {
 	return f.searchDialog.GetQuery() == ""
 }
 
-func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
+func (f *findDialogComponent) selectFile(item completions.CompletionSuggestion) tea.Cmd {
 	return tea.Sequence(
 		f.Close(),
 		util.CmdHandler(FindSelectedMsg{
-			FilePath: item.GetValue(),
+			FilePath: item.Value,
 		}),
 	)
 }
@@ -110,9 +142,19 @@ func (f *findDialogComponent) Close() tea.Cmd {
 	return util.CmdHandler(modal.CloseModalMsg{})
 }
 
-func NewFindDialog(completionProvider CompletionProvider) FindDialog {
+func NewFindDialog(completionProvider completions.CompletionProvider) FindDialog {
 	searchDialog := NewSearchDialog("Search files...", 10)
 
+	component := &findDialogComponent{
+		completionProvider: completionProvider,
+		searchDialog:       searchDialog,
+		suggestions:        []completions.CompletionSuggestion{},
+		modal: modal.New(
+			modal.WithTitle("Find Files"),
+			modal.WithMaxWidth(80),
+		),
+	}
+
 	// Initialize with empty query to get initial items
 	go func() {
 		items, err := completionProvider.GetChildEntries("")
@@ -120,20 +162,14 @@ func NewFindDialog(completionProvider CompletionProvider) FindDialog {
 			slog.Error("Failed to get completion items", "error", err)
 			return
 		}
-		// Convert CompletionItemI to list.ListItem
-		listItems := make([]list.ListItem, len(items))
+		// Store suggestions and convert to findItem
+		component.suggestions = items
+		listItems := make([]list.Item, len(items))
 		for i, item := range items {
-			listItems[i] = item
+			listItems[i] = findItem{suggestion: item}
 		}
 		searchDialog.SetItems(listItems)
 	}()
 
-	return &findDialogComponent{
-		completionProvider: completionProvider,
-		searchDialog:       searchDialog,
-		modal: modal.New(
-			modal.WithTitle("Find Files"),
-			modal.WithMaxWidth(80),
-		),
-	}
+	return component
 }

+ 54 - 78
packages/tui/internal/components/dialog/models.go

@@ -3,7 +3,6 @@ package dialog
 import (
 	"context"
 	"fmt"
-	"slices"
 	"sort"
 	"time"
 
@@ -46,42 +45,41 @@ type ModelWithProvider struct {
 	Provider opencode.Provider
 }
 
-type ModelItem struct {
-	ModelName    string
-	ProviderName string
+// modelItem is a custom list item for model selections
+type modelItem struct {
+	model ModelWithProvider
 }
 
-func (m *ModelItem) Render(selected bool, width int, isFirstInViewport bool) string {
+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 {
-		displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
-		return styles.NewStyle().
-			Background(t.Primary()).
-			Foreground(t.BackgroundPanel()).
-			Width(width).
-			PaddingLeft(1).
-			Render(displayText)
-	} else {
-		modelStyle := styles.NewStyle().
-			Foreground(t.Text()).
-			Background(t.BackgroundPanel())
-		providerStyle := styles.NewStyle().
-			Foreground(t.TextMuted()).
-			Background(t.BackgroundPanel())
-
-		modelPart := modelStyle.Render(m.ModelName)
-		providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
-
-		combinedText := modelPart + providerPart
-		return styles.NewStyle().
-			Background(t.BackgroundPanel()).
-			PaddingLeft(1).
-			Render(combinedText)
+		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 {
+func (m modelItem) Selectable() bool {
 	return true
 }
 
@@ -110,23 +108,17 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	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,
-							}),
-					)
-				}
-			}
+		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{})
 
@@ -152,13 +144,13 @@ func (m *modelDialog) View() string {
 	return m.searchDialog.View()
 }
 
-func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
+func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
 	maxWidth := minDialogWidth
 
-	for _, item := range modelItems {
+	for _, model := range models {
 		// Calculate the width needed for this item: "ModelName (ProviderName)"
 		// Add 4 for the parentheses, space, and some padding
-		itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
+		itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 4
 		if itemWidth > maxWidth {
 			maxWidth = itemWidth
 		}
@@ -187,14 +179,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{
-			ModelName:    modelWithProvider.Model.Name,
-			ProviderName: modelWithProvider.Provider.Name,
-		}
-	}
-	m.dialogWidth = m.calculateOptimalWidth(modelItems)
+	m.dialogWidth = m.calculateOptimalWidth(m.allModels)
 
 	// Initialize search dialog
 	m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
@@ -266,7 +251,7 @@ func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
 }
 
 // buildDisplayList creates the list items based on search query
-func (m *modelDialog) buildDisplayList(query string) []list.ListItem {
+func (m *modelDialog) buildDisplayList(query string) []list.Item {
 	if query != "" {
 		// Search mode: use fuzzy matching
 		return m.buildSearchResults(query)
@@ -277,7 +262,7 @@ func (m *modelDialog) buildDisplayList(query string) []list.ListItem {
 }
 
 // buildSearchResults creates a flat list of search results using fuzzy matching
-func (m *modelDialog) buildSearchResults(query string) []list.ListItem {
+func (m *modelDialog) buildSearchResults(query string) []list.Item {
 	type modelMatch struct {
 		model ModelWithProvider
 		score int
@@ -300,39 +285,33 @@ func (m *modelDialog) buildSearchResults(query string) []list.ListItem {
 	matches := fuzzy.RankFindFold(query, modelNames)
 	sort.Sort(matches)
 
-	items := []list.ListItem{}
+	items := []list.Item{}
+	seenModels := make(map[string]bool)
+
 	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 {
+		// Create a unique key to avoid duplicates
+		key := fmt.Sprintf("%s:%s", model.Provider.ID, model.Model.ID)
+		if seenModels[key] {
 			continue
 		}
-		items = append(items, &ModelItem{
-			ModelName:    model.Model.Name,
-			ProviderName: model.Provider.Name,
-		})
+		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.ListItem {
-	var items []list.ListItem
+func (m *modelDialog) buildGroupedResults() []list.Item {
+	var items []list.Item
 
 	// 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,
-			})
+			items = append(items, modelItem{model: model})
 		}
 	}
 
@@ -390,10 +369,7 @@ func (m *modelDialog) buildGroupedResults() []list.ListItem {
 
 		// Add models in this provider group
 		for _, model := range models {
-			items = append(items, &ModelItem{
-				ModelName:    model.Model.Name,
-				ProviderName: model.Provider.Name,
-			})
+			items = append(items, modelItem{model: model})
 		}
 	}
 

+ 0 - 496
packages/tui/internal/components/dialog/permission.go

@@ -1,496 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/viewport"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/sst/opencode/internal/styles"
-	"github.com/sst/opencode/internal/theme"
-	"github.com/sst/opencode/internal/util"
-	"strings"
-)
-
-type PermissionAction string
-
-// Permission responses
-const (
-	PermissionAllow           PermissionAction = "allow"
-	PermissionAllowForSession PermissionAction = "allow_session"
-	PermissionDeny            PermissionAction = "deny"
-)
-
-// PermissionResponseMsg represents the user's response to a permission request
-type PermissionResponseMsg struct {
-	// Permission permission.PermissionRequest
-	Action PermissionAction
-}
-
-// PermissionDialogComponent interface for permission dialog component
-type PermissionDialogComponent interface {
-	tea.Model
-	tea.ViewModel
-	// SetPermissions(permission permission.PermissionRequest) tea.Cmd
-}
-
-type permissionsMapping struct {
-	Left         key.Binding
-	Right        key.Binding
-	EnterSpace   key.Binding
-	Allow        key.Binding
-	AllowSession key.Binding
-	Deny         key.Binding
-	Tab          key.Binding
-}
-
-var permissionsKeys = permissionsMapping{
-	Left: key.NewBinding(
-		key.WithKeys("left"),
-		key.WithHelp("←", "switch options"),
-	),
-	Right: key.NewBinding(
-		key.WithKeys("right"),
-		key.WithHelp("→", "switch options"),
-	),
-	EnterSpace: key.NewBinding(
-		key.WithKeys("enter", " "),
-		key.WithHelp("enter/space", "confirm"),
-	),
-	Allow: key.NewBinding(
-		key.WithKeys("a"),
-		key.WithHelp("a", "allow"),
-	),
-	AllowSession: key.NewBinding(
-		key.WithKeys("s"),
-		key.WithHelp("s", "allow for session"),
-	),
-	Deny: key.NewBinding(
-		key.WithKeys("d"),
-		key.WithHelp("d", "deny"),
-	),
-	Tab: key.NewBinding(
-		key.WithKeys("tab"),
-		key.WithHelp("tab", "switch options"),
-	),
-}
-
-// permissionDialogComponent is the implementation of PermissionDialog
-type permissionDialogComponent struct {
-	width  int
-	height int
-	// permission      permission.PermissionRequest
-	windowSize      tea.WindowSizeMsg
-	contentViewPort viewport.Model
-	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
-
-	diffCache     map[string]string
-	markdownCache map[string]string
-}
-
-func (p *permissionDialogComponent) Init() tea.Cmd {
-	return p.contentViewPort.Init()
-}
-
-func (p *permissionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		p.windowSize = msg
-		cmd := p.SetSize()
-		cmds = append(cmds, cmd)
-		p.markdownCache = make(map[string]string)
-		p.diffCache = make(map[string]string)
-		// case tea.KeyMsg:
-		// 	switch {
-		// 	case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
-		// 		p.selectedOption = (p.selectedOption + 1) % 3
-		// 		return p, nil
-		// 	case key.Matches(msg, permissionsKeys.Left):
-		// 		p.selectedOption = (p.selectedOption + 2) % 3
-		// 	case key.Matches(msg, permissionsKeys.EnterSpace):
-		// 		return p, p.selectCurrentOption()
-		// 	case key.Matches(msg, permissionsKeys.Allow):
-		// 		return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
-		// 	case key.Matches(msg, permissionsKeys.AllowSession):
-		// 		return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
-		// 	case key.Matches(msg, permissionsKeys.Deny):
-		// 		return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
-		// 	default:
-		// 		// Pass other keys to viewport
-		// 		viewPort, cmd := p.contentViewPort.Update(msg)
-		// 		p.contentViewPort = viewPort
-		// 		cmds = append(cmds, cmd)
-		// 	}
-	}
-
-	return p, tea.Batch(cmds...)
-}
-
-func (p *permissionDialogComponent) selectCurrentOption() tea.Cmd {
-	var action PermissionAction
-
-	switch p.selectedOption {
-	case 0:
-		action = PermissionAllow
-	case 1:
-		action = PermissionAllowForSession
-	case 2:
-		action = PermissionDeny
-	}
-
-	return util.CmdHandler(PermissionResponseMsg{Action: action}) // , Permission: p.permission})
-}
-
-func (p *permissionDialogComponent) renderButtons() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.NewStyle().Foreground(t.Text())
-
-	allowStyle := baseStyle
-	allowSessionStyle := baseStyle
-	denyStyle := baseStyle
-	spacerStyle := baseStyle.Background(t.Background())
-
-	// Style the selected button
-	switch p.selectedOption {
-	case 0:
-		allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
-		allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
-		denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
-	case 1:
-		allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
-		allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
-		denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
-	case 2:
-		allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
-		allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
-		denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
-	}
-
-	allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
-	allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
-	denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
-
-	content := lipgloss.JoinHorizontal(
-		lipgloss.Left,
-		allowButton,
-		spacerStyle.Render("  "),
-		allowSessionButton,
-		spacerStyle.Render("  "),
-		denyButton,
-		spacerStyle.Render("  "),
-	)
-
-	remainingWidth := p.width - lipgloss.Width(content)
-	if remainingWidth > 0 {
-		content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
-	}
-	return content
-}
-
-func (p *permissionDialogComponent) renderHeader() string {
-	return "NOT IMPLEMENTED"
-	// t := theme.CurrentTheme()
-	// baseStyle := styles.BaseStyle()
-	//
-	// toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
-	// toolValue := baseStyle.
-	// 	Foreground(t.Text()).
-	// 	Width(p.width - lipgloss.Width(toolKey)).
-	// 	Render(fmt.Sprintf(": %s", p.permission.ToolName))
-	//
-	// pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
-	//
-	// // Get the current working directory to display relative path
-	// relativePath := p.permission.Path
-	// if filepath.IsAbs(relativePath) {
-	// 	if cwd, err := filepath.Rel(config.WorkingDirectory(), relativePath); err == nil {
-	// 		relativePath = cwd
-	// 	}
-	// }
-	//
-	// pathValue := baseStyle.
-	// 	Foreground(t.Text()).
-	// 	Width(p.width - lipgloss.Width(pathKey)).
-	// 	Render(fmt.Sprintf(": %s", relativePath))
-	//
-	// headerParts := []string{
-	// 	lipgloss.JoinHorizontal(
-	// 		lipgloss.Left,
-	// 		toolKey,
-	// 		toolValue,
-	// 	),
-	// 	baseStyle.Render(strings.Repeat(" ", p.width)),
-	// 	lipgloss.JoinHorizontal(
-	// 		lipgloss.Left,
-	// 		pathKey,
-	// 		pathValue,
-	// 	),
-	// 	baseStyle.Render(strings.Repeat(" ", p.width)),
-	// }
-	//
-	// // Add tool-specific header information
-	// switch p.permission.ToolName {
-	// case "bash":
-	// 	headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
-	// case "edit":
-	// 	headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
-	// case "write":
-	// 	headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
-	// case "fetch":
-	// 	headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
-	// }
-	//
-	// return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-}
-
-func (p *permissionDialogComponent) renderBashContent() string {
-	// t := theme.CurrentTheme()
-	// baseStyle := styles.BaseStyle()
-	//
-	// if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
-	// 	content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
-	//
-	// 	// Use the cache for markdown rendering
-	// 	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-	// 		r := styles.GetMarkdownRenderer(p.width - 10)
-	// 		s, err := r.Render(content)
-	//    return s
-	// 	})
-	//
-	// 	finalContent := baseStyle.
-	// 		Width(p.contentViewPort.Width).
-	// 		Render(renderedContent)
-	// 	p.contentViewPort.SetContent(finalContent)
-	// 	return p.styleViewport()
-	// }
-	return ""
-}
-
-func (p *permissionDialogComponent) renderEditContent() string {
-	// if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
-	// 	diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-	// 		return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
-	// 	})
-	//
-	// 	p.contentViewPort.SetContent(diff)
-	// 	return p.styleViewport()
-	// }
-	return ""
-}
-
-func (p *permissionDialogComponent) renderPatchContent() string {
-	// if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
-	// 	diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-	// 		return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
-	// 	})
-	//
-	// 	p.contentViewPort.SetContent(diff)
-	// 	return p.styleViewport()
-	// }
-	return ""
-}
-
-func (p *permissionDialogComponent) renderWriteContent() string {
-	// if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
-	// 	// Use the cache for diff rendering
-	// 	diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-	// 		return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
-	// 	})
-	//
-	// 	p.contentViewPort.SetContent(diff)
-	// 	return p.styleViewport()
-	// }
-	return ""
-}
-
-func (p *permissionDialogComponent) renderFetchContent() string {
-	// 	t := theme.CurrentTheme()
-	// 	baseStyle := styles.BaseStyle()
-	//
-	// 	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
-	// 		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
-	//
-	// 		// Use the cache for markdown rendering
-	// 		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-	// 			r := styles.GetMarkdownRenderer(p.width - 10)
-	// 			s, err := r.Render(content)
-	//      return s
-	// 		})
-	//
-	// 		finalContent := baseStyle.
-	// 			Width(p.contentViewPort.Width).
-	// 			Render(renderedContent)
-	// 		p.contentViewPort.SetContent(finalContent)
-	// 		return p.styleViewport()
-	// 	}
-	return ""
-}
-
-func (p *permissionDialogComponent) renderDefaultContent() string {
-	// 	t := theme.CurrentTheme()
-	// 	baseStyle := styles.BaseStyle()
-	//
-	// 	content := p.permission.Description
-	//
-	// 	// Use the cache for markdown rendering
-	// 	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-	// 		r := styles.GetMarkdownRenderer(p.width - 10)
-	// 		s, err := r.Render(content)
-	//    return s
-	// 	})
-	//
-	// 	finalContent := baseStyle.
-	// 		Width(p.contentViewPort.Width).
-	// 		Render(renderedContent)
-	// 	p.contentViewPort.SetContent(finalContent)
-	//
-	// 	if renderedContent == "" {
-	// 		return ""
-	// 	}
-	//
-	return p.styleViewport()
-}
-
-func (p *permissionDialogComponent) styleViewport() string {
-	t := theme.CurrentTheme()
-	contentStyle := styles.NewStyle().Background(t.Background())
-
-	return contentStyle.Render(p.contentViewPort.View())
-}
-
-func (p *permissionDialogComponent) render() string {
-	return "NOT IMPLEMENTED"
-	// t := theme.CurrentTheme()
-	// baseStyle := styles.BaseStyle()
-	//
-	// title := baseStyle.
-	// 	Bold(true).
-	// 	Width(p.width - 4).
-	// 	Foreground(t.Primary()).
-	// 	Render("Permission Required")
-	// // Render header
-	// headerContent := p.renderHeader()
-	// // Render buttons
-	// buttons := p.renderButtons()
-	//
-	// // Calculate content height dynamically based on window size
-	// p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
-	// p.contentViewPort.Width = p.width - 4
-	//
-	// // Render content based on tool type
-	// var contentFinal string
-	// switch p.permission.ToolName {
-	// case "bash":
-	// 	contentFinal = p.renderBashContent()
-	// case "edit":
-	// 	contentFinal = p.renderEditContent()
-	// case "patch":
-	// 	contentFinal = p.renderPatchContent()
-	// case "write":
-	// 	contentFinal = p.renderWriteContent()
-	// case "fetch":
-	// 	contentFinal = p.renderFetchContent()
-	// default:
-	// 	contentFinal = p.renderDefaultContent()
-	// }
-	//
-	// content := lipgloss.JoinVertical(
-	// 	lipgloss.Top,
-	// 	title,
-	// 	baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
-	// 	headerContent,
-	// 	contentFinal,
-	// 	buttons,
-	// 	baseStyle.Render(strings.Repeat(" ", p.width-4)),
-	// )
-	//
-	// return baseStyle.
-	// 	Padding(1, 0, 0, 1).
-	// 	Border(lipgloss.RoundedBorder()).
-	// 	BorderBackground(t.Background()).
-	// 	BorderForeground(t.TextMuted()).
-	// 	Width(p.width).
-	// 	Height(p.height).
-	// 	Render(
-	// 		content,
-	// 	)
-}
-
-func (p *permissionDialogComponent) View() string {
-	return p.render()
-}
-
-func (p *permissionDialogComponent) SetSize() tea.Cmd {
-	// if p.permission.ID == "" {
-	// 	return nil
-	// }
-	// switch p.permission.ToolName {
-	// case "bash":
-	// 	p.width = int(float64(p.windowSize.Width) * 0.4)
-	// 	p.height = int(float64(p.windowSize.Height) * 0.3)
-	// case "edit":
-	// 	p.width = int(float64(p.windowSize.Width) * 0.8)
-	// 	p.height = int(float64(p.windowSize.Height) * 0.8)
-	// case "write":
-	// 	p.width = int(float64(p.windowSize.Width) * 0.8)
-	// 	p.height = int(float64(p.windowSize.Height) * 0.8)
-	// case "fetch":
-	// 	p.width = int(float64(p.windowSize.Width) * 0.4)
-	// 	p.height = int(float64(p.windowSize.Height) * 0.3)
-	// default:
-	// 	p.width = int(float64(p.windowSize.Width) * 0.7)
-	// 	p.height = int(float64(p.windowSize.Height) * 0.5)
-	// }
-	return nil
-}
-
-// func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
-// 	p.permission = permission
-// 	return p.SetSize()
-// }
-
-// Helper to get or set cached diff content
-func (c *permissionDialogComponent) GetOrSetDiff(key string, generator func() (string, error)) string {
-	if cached, ok := c.diffCache[key]; ok {
-		return cached
-	}
-
-	content, err := generator()
-	if err != nil {
-		return fmt.Sprintf("Error formatting diff: %v", err)
-	}
-
-	c.diffCache[key] = content
-
-	return content
-}
-
-// Helper to get or set cached markdown content
-func (c *permissionDialogComponent) GetOrSetMarkdown(key string, generator func() (string, error)) string {
-	if cached, ok := c.markdownCache[key]; ok {
-		return cached
-	}
-
-	content, err := generator()
-	if err != nil {
-		return fmt.Sprintf("Error rendering markdown: %v", err)
-	}
-
-	c.markdownCache[key] = content
-
-	return content
-}
-
-func NewPermissionDialogCmp() PermissionDialogComponent {
-	// Create viewport for content
-	contentViewport := viewport.New() // (0, 0)
-
-	return &permissionDialogComponent{
-		contentViewPort: contentViewport,
-		selectedOption:  0, // Default to "Allow"
-		diffCache:       make(map[string]string),
-		markdownCache:   make(map[string]string),
-	}
-}

+ 20 - 12
packages/tui/internal/components/dialog/search.go

@@ -17,7 +17,7 @@ type SearchQueryChangedMsg struct {
 
 // SearchSelectionMsg is emitted when an item is selected
 type SearchSelectionMsg struct {
-	Item  interface{}
+	Item  any
 	Index int
 }
 
@@ -27,7 +27,7 @@ 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]
+	list      list.List[list.Item]
 	width     int
 	height    int
 	focused   bool
@@ -60,7 +60,7 @@ var searchKeys = searchKeyMap{
 }
 
 // NewSearchDialog creates a new SearchDialog
-func NewSearchDialog(placeholder string, maxVisibleItems int) *SearchDialog {
+func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
 	t := theme.CurrentTheme()
 	bgColor := t.BackgroundElement()
 	textColor := t.Text()
@@ -95,10 +95,18 @@ func NewSearchDialog(placeholder string, maxVisibleItems int) *SearchDialog {
 	ti.Focus()
 
 	emptyList := list.NewListComponent(
-		[]list.ListItem{},
-		maxVisibleItems,
-		" No items",
-		false,
+		list.WithItems([]list.Item{}),
+		list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
+		list.WithFallbackMessage[list.Item](" No items"),
+		list.WithAlphaNumericKeys[list.Item](false),
+		list.WithRenderFunc(
+			func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
+				return item.Render(selected, width, baseStyle)
+			},
+		),
+		list.WithSelectableFunc(func(item list.Item) bool {
+			return item.Selectable()
+		}),
 	)
 
 	return &SearchDialog{
@@ -134,7 +142,7 @@ func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return s, func() tea.Msg { return SearchCancelledMsg{} }
 
 		case key.Matches(msg, searchKeys.Enter):
-			if selectedItem, idx := s.list.GetSelectedItem(); selectedItem != nil {
+			if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
 				return s, func() tea.Msg {
 					return SearchSelectionMsg{Item: selectedItem, Index: idx}
 				}
@@ -143,7 +151,7 @@ func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case key.Matches(msg, searchKeys.Up):
 			var cmd tea.Cmd
 			listModel, cmd := s.list.Update(msg)
-			s.list = listModel.(list.List[list.ListItem])
+			s.list = listModel.(list.List[list.Item])
 			if cmd != nil {
 				cmds = append(cmds, cmd)
 			}
@@ -151,7 +159,7 @@ func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case key.Matches(msg, searchKeys.Down):
 			var cmd tea.Cmd
 			listModel, cmd := s.list.Update(msg)
-			s.list = listModel.(list.List[list.ListItem])
+			s.list = listModel.(list.List[list.Item])
 			if cmd != nil {
 				cmds = append(cmds, cmd)
 			}
@@ -177,7 +185,7 @@ func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (s *SearchDialog) View() string {
 	s.list.SetMaxWidth(s.width)
 	listView := s.list.View()
-	listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleItems(), lipgloss.Top, listView)
+	listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
 	textinput := s.textInput.View()
 	return textinput + "\n\n" + listView
 }
@@ -194,7 +202,7 @@ func (s *SearchDialog) SetHeight(height int) {
 }
 
 // SetItems updates the list items
-func (s *SearchDialog) SetItems(items []list.ListItem) {
+func (s *SearchDialog) SetItems(items []list.Item) {
 	s.list.SetItems(items)
 }
 

+ 18 - 7
packages/tui/internal/components/dialog/session.go

@@ -30,9 +30,13 @@ type sessionItem struct {
 	isDeleteConfirming bool
 }
 
-func (s sessionItem) Render(selected bool, width int, isFirstInViewport bool) string {
+func (s sessionItem) Render(
+	selected bool,
+	width int,
+	isFirstInViewport bool,
+	baseStyle styles.Style,
+) string {
 	t := theme.CurrentTheme()
-	baseStyle := styles.NewStyle()
 
 	var text string
 	if s.isDeleteConfirming {
@@ -228,12 +232,19 @@ func NewSessionDialog(app *app.App) SessionDialog {
 		})
 	}
 
-	// Create a generic list component
 	listComponent := list.NewListComponent(
-		items,
-		10, // maxVisibleSessions
-		"No sessions available",
-		true, // useAlphaNumericKeys
+		list.WithItems(items),
+		list.WithMaxVisibleHeight[sessionItem](10),
+		list.WithFallbackMessage[sessionItem]("No sessions available"),
+		list.WithAlphaNumericKeys[sessionItem](true),
+		list.WithRenderFunc(
+			func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
+				return item.Render(selected, width, false, baseStyle)
+			},
+		),
+		list.WithSelectableFunc(func(item sessionItem) bool {
+			return true
+		}),
 	)
 	listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
 

+ 38 - 22
packages/tui/internal/components/dialog/theme.go

@@ -5,6 +5,7 @@ import (
 	list "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"
 )
@@ -24,7 +25,7 @@ type themeDialog struct {
 	height int
 
 	modal         *modal.Modal
-	list          list.List[list.StringItem]
+	list          list.List[list.Item]
 	originalTheme string
 	themeApplied  bool
 }
@@ -42,16 +43,18 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch msg.String() {
 		case "enter":
 			if item, idx := t.list.GetSelectedItem(); idx >= 0 {
-				selectedTheme := string(item)
-				if err := theme.SetTheme(selectedTheme); err != nil {
-					// status.Error(err.Error())
-					return t, nil
+				if stringItem, ok := item.(list.StringItem); ok {
+					selectedTheme := string(stringItem)
+					if err := theme.SetTheme(selectedTheme); err != nil {
+						// status.Error(err.Error())
+						return t, nil
+					}
+					t.themeApplied = true
+					return t, tea.Sequence(
+						util.CmdHandler(modal.CloseModalMsg{}),
+						util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
+					)
 				}
-				t.themeApplied = true
-				return t, tea.Sequence(
-					util.CmdHandler(modal.CloseModalMsg{}),
-					util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
-				)
 			}
 
 		}
@@ -61,11 +64,13 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	var cmd tea.Cmd
 	listModel, cmd := t.list.Update(msg)
-	t.list = listModel.(list.List[list.StringItem])
+	t.list = listModel.(list.List[list.Item])
 
 	if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
-		theme.SetTheme(string(item))
-		return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(item)})
+		if stringItem, ok := item.(list.StringItem); ok {
+			theme.SetTheme(string(stringItem))
+			return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
+		}
 	}
 	return t, cmd
 }
@@ -94,21 +99,32 @@ func NewThemeDialog() ThemeDialog {
 		}
 	}
 
-	list := list.NewStringList(
-		themes,
-		10, // maxVisibleThemes
-		"No themes available",
-		true,
+	// Convert themes to list items
+	items := make([]list.Item, len(themes))
+	for i, theme := range themes {
+		items[i] = list.StringItem(theme)
+	}
+
+	listComponent := list.NewListComponent(
+		list.WithItems(items),
+		list.WithMaxVisibleHeight[list.Item](10),
+		list.WithFallbackMessage[list.Item]("No themes available"),
+		list.WithAlphaNumericKeys[list.Item](true),
+		list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
+			return item.Render(selected, width, baseStyle)
+		}),
+		list.WithSelectableFunc(func(item list.Item) bool {
+			return item.Selectable()
+		}),
 	)
 
 	// Set the initial selection to the current theme
-	list.SetSelectedIndex(selectedIdx)
+	listComponent.SetSelectedIndex(selectedIdx)
 
 	// Set the max width for the list to match the modal width
-	list.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
-
+	listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
 	return &themeDialog{
-		list:          list,
+		list:          listComponent,
 		modal:         modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
 		originalTheme: currentTheme,
 		themeApplied:  false,

+ 161 - 145
packages/tui/internal/components/list/list.go

@@ -5,17 +5,88 @@ import (
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/muesli/reflow/truncate"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 )
 
-type ListItem interface {
-	Render(selected bool, width int, isFirstInViewport bool) string
+// Item interface that all list items must implement
+type Item interface {
+	Render(selected bool, width int, baseStyle styles.Style) string
 	Selectable() bool
 }
 
-type List[T ListItem] interface {
+// RenderFunc defines how to render an item in the list
+type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
+
+// SelectableFunc defines whether an item is selectable
+type SelectableFunc[T any] func(item T) bool
+
+// Options holds configuration for the list component
+type Options[T any] struct {
+	items               []T
+	maxVisibleHeight    int
+	fallbackMsg         string
+	useAlphaNumericKeys bool
+	renderItem          RenderFunc[T]
+	isSelectable        SelectableFunc[T]
+	baseStyle           styles.Style
+}
+
+// Option is a function that configures the list component
+type Option[T any] func(*Options[T])
+
+// WithItems sets the initial items for the list
+func WithItems[T any](items []T) Option[T] {
+	return func(o *Options[T]) {
+		o.items = items
+	}
+}
+
+// WithMaxVisibleHeight sets the maximum visible height in lines
+func WithMaxVisibleHeight[T any](height int) Option[T] {
+	return func(o *Options[T]) {
+		o.maxVisibleHeight = height
+	}
+}
+
+// WithFallbackMessage sets the message to show when the list is empty
+func WithFallbackMessage[T any](msg string) Option[T] {
+	return func(o *Options[T]) {
+		o.fallbackMsg = msg
+	}
+}
+
+// WithAlphaNumericKeys enables j/k navigation keys
+func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
+	return func(o *Options[T]) {
+		o.useAlphaNumericKeys = enabled
+	}
+}
+
+// WithRenderFunc sets the function to render items
+func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
+	return func(o *Options[T]) {
+		o.renderItem = fn
+	}
+}
+
+// WithSelectableFunc sets the function to determine if items are selectable
+func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
+	return func(o *Options[T]) {
+		o.isSelectable = fn
+	}
+}
+
+// WithStyle sets the base style that gets passed to render functions
+func WithStyle[T any](style styles.Style) Option[T] {
+	return func(o *Options[T]) {
+		o.baseStyle = style
+	}
+}
+
+type List[T any] interface {
 	tea.Model
 	tea.ViewModel
 	SetMaxWidth(maxWidth int)
@@ -25,19 +96,21 @@ type List[T ListItem] interface {
 	SetSelectedIndex(idx int)
 	SetEmptyMessage(msg string)
 	IsEmpty() bool
-	GetMaxVisibleItems() int
-	GetActualHeight() int
+	GetMaxVisibleHeight() int
 }
 
-type listComponent[T ListItem] struct {
+type listComponent[T any] struct {
 	fallbackMsg         string
 	items               []T
 	selectedIdx         int
 	maxWidth            int
-	maxVisibleItems     int
+	maxVisibleHeight    int
 	useAlphaNumericKeys bool
 	width               int
 	height              int
+	renderItem          RenderFunc[T]
+	isSelectable        SelectableFunc[T]
+	baseStyle           styles.Style
 }
 
 type listKeyMap struct {
@@ -94,7 +167,7 @@ func (c *listComponent[T]) moveUp() {
 
 	// Find the previous selectable item
 	for i := c.selectedIdx - 1; i >= 0; i-- {
-		if c.items[i].Selectable() {
+		if c.isSelectable(c.items[i]) {
 			c.selectedIdx = i
 			return
 		}
@@ -117,7 +190,7 @@ func (c *listComponent[T]) moveDown() {
 			break
 		}
 
-		if c.items[c.selectedIdx].Selectable() {
+		if c.isSelectable(c.items[c.selectedIdx]) {
 			return
 		}
 
@@ -129,7 +202,7 @@ func (c *listComponent[T]) moveDown() {
 }
 
 func (c *listComponent[T]) GetSelectedItem() (T, int) {
-	if len(c.items) > 0 && c.items[c.selectedIdx].Selectable() {
+	if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
 		return c.items[c.selectedIdx], c.selectedIdx
 	}
 
@@ -142,7 +215,7 @@ func (c *listComponent[T]) SetItems(items []T) {
 	c.selectedIdx = 0
 
 	// Ensure initial selection is on a selectable item
-	if len(items) > 0 && !items[0].Selectable() {
+	if len(items) > 0 && !c.isSelectable(items[0]) {
 		c.moveDown()
 	}
 }
@@ -169,48 +242,8 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
 	}
 }
 
-func (c *listComponent[T]) GetMaxVisibleItems() int {
-	return c.maxVisibleItems
-}
-
-func (c *listComponent[T]) GetActualHeight() int {
-	items := c.items
-	if len(items) == 0 {
-		return 1 // For empty message
-	}
-
-	maxVisibleItems := min(c.maxVisibleItems, len(items))
-	startIdx := 0
-
-	if len(items) > maxVisibleItems {
-		halfVisible := maxVisibleItems / 2
-		if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
-			startIdx = c.selectedIdx - halfVisible
-		} else if c.selectedIdx >= len(items)-halfVisible {
-			startIdx = len(items) - maxVisibleItems
-		}
-	}
-
-	endIdx := min(startIdx+maxVisibleItems, len(items))
-
-	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]) GetMaxVisibleHeight() int {
+	return c.maxVisibleHeight
 }
 
 func (c *listComponent[T]) View() string {
@@ -224,95 +257,88 @@ func (c *listComponent[T]) View() string {
 		return c.fallbackMsg
 	}
 
-	// Calculate viewport based on actual heights, not item counts
+	// Calculate viewport based on actual heights
 	startIdx, endIdx := c.calculateViewport()
 
 	listItems := make([]string, 0, endIdx-startIdx)
 
 	for i := startIdx; i < endIdx; i++ {
 		item := items[i]
-		isFirstInViewport := (i == startIdx)
-		title := item.Render(i == c.selectedIdx, maxWidth, isFirstInViewport)
+
+		// Special handling for HeaderItem to remove top margin on first item
+		if i == startIdx {
+			// Check if this is a HeaderItem
+			if _, ok := any(item).(Item); ok {
+				if headerItem, isHeader := any(item).(HeaderItem); isHeader {
+					// Render header without top margin when it's first
+					t := theme.CurrentTheme()
+					truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
+					headerStyle := c.baseStyle.
+						Foreground(t.Accent()).
+						Bold(true).
+						MarginBottom(0).
+						PaddingLeft(1)
+					listItems = append(listItems, headerStyle.Render(truncatedStr))
+					continue
+				}
+			}
+		}
+
+		title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
 		listItems = append(listItems, title)
 	}
 
 	return strings.Join(listItems, "\n")
 }
 
-// calculateViewport determines which items to show based on available height
+// calculateViewport determines which items to show based on available space
 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
+	// Calculate heights of all items
+	itemHeights := make([]int, len(items))
+	for i, item := range items {
+		rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
+		itemHeights[i] = lipgloss.Height(rendered)
 	}
 
-	// If we have fewer items than max, show all
-	if len(items) <= c.maxVisibleItems {
-		return 0, len(items)
-	}
+	// Find the range of items that fit within maxVisibleHeight
+	// Start by trying to center the selected item
+	start := 0
+	end := 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
+	// Calculate height from start to selected
+	heightToSelected := 0
+	for i := 0; i <= c.selectedIdx && i < len(items); i++ {
+		heightToSelected += itemHeights[i]
 	}
 
-	// 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
+	// If selected item is beyond visible height, scroll to show it
+	if heightToSelected > c.maxVisibleHeight {
+		// Start from selected and work backwards to find start
+		currentHeight := itemHeights[c.selectedIdx]
+		start = c.selectedIdx
 
-		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
-			}
+		for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
+			currentHeight += itemHeights[i]
+			start = i
 		}
 	}
 
-	// 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
+	// Calculate end based on start
+	currentHeight := 0
+	for i := start; i < len(items); i++ {
+		if currentHeight+itemHeights[i] > c.maxVisibleHeight {
+			end = i
+			break
 		}
+		currentHeight += itemHeights[i]
 	}
 
-	return bestStart, bestEnd
+	return start, end
 }
 
 func abs(x int) int {
@@ -329,27 +355,32 @@ func max(a, b int) int {
 	return b
 }
 
-func NewListComponent[T ListItem](
-	items []T,
-	maxVisibleItems int,
-	fallbackMsg string,
-	useAlphaNumericKeys bool,
-) List[T] {
+func NewListComponent[T any](opts ...Option[T]) List[T] {
+	options := &Options[T]{
+		baseStyle: styles.NewStyle(), // Default empty style
+	}
+
+	for _, opt := range opts {
+		opt(options)
+	}
+
 	return &listComponent[T]{
-		fallbackMsg:         fallbackMsg,
-		items:               items,
-		maxVisibleItems:     maxVisibleItems,
-		useAlphaNumericKeys: useAlphaNumericKeys,
+		fallbackMsg:         options.fallbackMsg,
+		items:               options.items,
+		maxVisibleHeight:    options.maxVisibleHeight,
+		useAlphaNumericKeys: options.useAlphaNumericKeys,
 		selectedIdx:         0,
+		renderItem:          options.renderItem,
+		isSelectable:        options.isSelectable,
+		baseStyle:           options.baseStyle,
 	}
 }
 
-// StringItem is a simple implementation of ListItem for string values
+// StringItem is a simple implementation of Item for string values
 type StringItem string
 
-func (s StringItem) Render(selected bool, width int, isFirstInViewport bool) string {
+func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
 	t := theme.CurrentTheme()
-	baseStyle := styles.NewStyle()
 
 	truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
 
@@ -376,23 +407,18 @@ func (s StringItem) Selectable() bool {
 // HeaderItem is a non-selectable header item for grouping
 type HeaderItem string
 
-func (h HeaderItem) Render(selected bool, width int, isFirstInViewport bool) string {
+func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
 	t := theme.CurrentTheme()
-	baseStyle := styles.NewStyle()
 
 	truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
 
 	headerStyle := baseStyle.
 		Foreground(t.Accent()).
 		Bold(true).
+		MarginTop(1).
 		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)
 }
 
@@ -400,16 +426,6 @@ func (h HeaderItem) Selectable() bool {
 	return false
 }
 
-// NewStringList creates a new list component with string items
-func NewStringList(
-	items []string,
-	maxVisibleItems int,
-	fallbackMsg string,
-	useAlphaNumericKeys bool,
-) List[StringItem] {
-	stringItems := make([]StringItem, len(items))
-	for i, item := range items {
-		stringItems[i] = StringItem(item)
-	}
-	return NewListComponent(stringItems, maxVisibleItems, fallbackMsg, useAlphaNumericKeys)
-}
+// Ensure StringItem and HeaderItem implement Item
+var _ Item = StringItem("")
+var _ Item = HeaderItem("")

+ 63 - 4
packages/tui/internal/components/list/list_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/sst/opencode/internal/styles"
 )
 
 // testItem is a simple test implementation of ListItem
@@ -11,10 +12,19 @@ type testItem struct {
 	value string
 }
 
-func (t testItem) Render(selected bool, width int) string {
+func (t testItem) Render(
+	selected bool,
+	width int,
+	isFirstInViewport bool,
+	baseStyle styles.Style,
+) string {
 	return t.value
 }
 
+func (t testItem) Selectable() bool {
+	return true
+}
+
 // createTestList creates a list with test items for testing
 func createTestList() *listComponent[testItem] {
 	items := []testItem{
@@ -22,7 +32,24 @@ func createTestList() *listComponent[testItem] {
 		{value: "item2"},
 		{value: "item3"},
 	}
-	list := NewListComponent(items, 5, "empty", false)
+	list := NewListComponent(
+		WithItems(items),
+		WithMaxVisibleItems[testItem](5),
+		WithFallbackMessage[testItem]("empty"),
+		WithAlphaNumericKeys[testItem](false),
+		WithRenderFunc(
+			func(item testItem, selected bool, width int, baseStyle styles.Style) string {
+				return item.Render(selected, width, false, baseStyle)
+			},
+		),
+		WithSelectableFunc(func(item testItem) bool {
+			return item.Selectable()
+		}),
+		WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
+			return 1
+		}),
+	)
+
 	return list.(*listComponent[testItem])
 }
 
@@ -55,7 +82,23 @@ func TestJKKeyNavigation(t *testing.T) {
 		{value: "item3"},
 	}
 	// Create list with alpha keys enabled
-	list := NewListComponent(items, 5, "empty", true).(*listComponent[testItem])
+	list := NewListComponent(
+		WithItems(items),
+		WithMaxVisibleItems[testItem](5),
+		WithFallbackMessage[testItem]("empty"),
+		WithAlphaNumericKeys[testItem](true),
+		WithRenderFunc(
+			func(item testItem, selected bool, width int, baseStyle styles.Style) string {
+				return item.Render(selected, width, false, baseStyle)
+			},
+		),
+		WithSelectableFunc(func(item testItem) bool {
+			return item.Selectable()
+		}),
+		WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
+			return 1
+		}),
+	)
 
 	// Test j key (down)
 	jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
@@ -131,7 +174,23 @@ func TestNavigationBoundaries(t *testing.T) {
 }
 
 func TestEmptyList(t *testing.T) {
-	emptyList := NewListComponent([]testItem{}, 5, "empty", false).(*listComponent[testItem])
+	emptyList := NewListComponent(
+		WithItems([]testItem{}),
+		WithMaxVisibleItems[testItem](5),
+		WithFallbackMessage[testItem]("empty"),
+		WithAlphaNumericKeys[testItem](false),
+		WithRenderFunc(
+			func(item testItem, selected bool, width int, baseStyle styles.Style) string {
+				return item.Render(selected, width, false, baseStyle)
+			},
+		),
+		WithSelectableFunc(func(item testItem) bool {
+			return item.Selectable()
+		}),
+		WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
+			return 1
+		}),
+	)
 
 	// Test navigation on empty list (should not crash)
 	downKey := tea.KeyPressMsg{Code: tea.KeyDown}

+ 3 - 3
packages/tui/internal/tui/tui.go

@@ -65,9 +65,9 @@ type appModel struct {
 	editor               chat.EditorComponent
 	messages             chat.MessagesComponent
 	completions          dialog.CompletionDialog
-	commandProvider      dialog.CompletionProvider
-	fileProvider         dialog.CompletionProvider
-	symbolsProvider      dialog.CompletionProvider
+	commandProvider      completions.CompletionProvider
+	fileProvider         completions.CompletionProvider
+	symbolsProvider      completions.CompletionProvider
 	showCompletionDialog bool
 	leaderBinding        *key.Binding
 	// isLeaderSequence     bool