summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-15 08:07:20 -0500
committeradamdotdevin <[email protected]>2025-07-15 08:07:26 -0500
commit533f64fe265d428aa711e1c14b909fe72376446f (patch)
tree6c9d2e5658e25022594736970006782ef1717438
parentb5c85d38066728e025f5a05abb90e39ed6836b1c (diff)
downloadopencode-533f64fe265d428aa711e1c14b909fe72376446f.tar.gz
opencode-533f64fe265d428aa711e1c14b909fe72376446f.zip
fix(tui): rework lists and search dialog
-rw-r--r--packages/tui/internal/completions/commands.go43
-rw-r--r--packages/tui/internal/completions/files.go63
-rw-r--r--packages/tui/internal/completions/provider.go8
-rw-r--r--packages/tui/internal/completions/suggestion.go24
-rw-r--r--packages/tui/internal/completions/symbols.go43
-rw-r--r--packages/tui/internal/components/chat/editor.go16
-rw-r--r--packages/tui/internal/components/dialog/complete.go146
-rw-r--r--packages/tui/internal/components/dialog/find.go82
-rw-r--r--packages/tui/internal/components/dialog/models.go132
-rw-r--r--packages/tui/internal/components/dialog/permission.go496
-rw-r--r--packages/tui/internal/components/dialog/search.go32
-rw-r--r--packages/tui/internal/components/dialog/session.go25
-rw-r--r--packages/tui/internal/components/dialog/theme.go60
-rw-r--r--packages/tui/internal/components/list/list.go306
-rw-r--r--packages/tui/internal/components/list/list_test.go67
-rw-r--r--packages/tui/internal/tui/tui.go6
16 files changed, 578 insertions, 971 deletions
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go
index 79d2230c8..80c9de4f7 100644
--- a/packages/tui/internal/completions/commands.go
+++ b/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)
}
}
diff --git a/packages/tui/internal/completions/files.go b/packages/tui/internal/completions/files.go
index f69163c77..bece89a89 100644
--- a/packages/tui/internal/completions/files.go
+++ b/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,
}
diff --git a/packages/tui/internal/completions/provider.go b/packages/tui/internal/completions/provider.go
new file mode 100644
index 000000000..dc11522c3
--- /dev/null
+++ b/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
+}
diff --git a/packages/tui/internal/completions/suggestion.go b/packages/tui/internal/completions/suggestion.go
new file mode 100644
index 000000000..fac6b6813
--- /dev/null
+++ b/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
+}
diff --git a/packages/tui/internal/completions/symbols.go b/packages/tui/internal/completions/symbols.go
index fea1b7117..725e2e69b 100644
--- a/packages/tui/internal/completions/symbols.go
+++ b/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,
}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index d5f6facab..67f1f75ea 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/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
}
}
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index 32fb1854c..1b1a25b7e 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/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 {
diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go
index 40909b6f0..b3cdfc982 100644
--- a/packages/tui/internal/components/dialog/find.go
+++ b/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
}
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
index 6dbda865c..34712f51c 100644
--- a/packages/tui/internal/components/dialog/models.go
+++ b/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})
}
}
diff --git a/packages/tui/internal/components/dialog/permission.go b/packages/tui/internal/components/dialog/permission.go
deleted file mode 100644
index 5bc40624b..000000000
--- a/packages/tui/internal/components/dialog/permission.go
+++ /dev/null
@@ -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),
- }
-}
diff --git a/packages/tui/internal/components/dialog/search.go b/packages/tui/internal/components/dialog/search.go
index 2eae4cded..13a305304 100644
--- a/packages/tui/internal/components/dialog/search.go
+++ b/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)
}
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
index 9348647c3..a0eac8eb7 100644
--- a/packages/tui/internal/components/dialog/session.go
+++ b/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)
diff --git a/packages/tui/internal/components/dialog/theme.go b/packages/tui/internal/components/dialog/theme.go
index b6e970617..c71cddc8e 100644
--- a/packages/tui/internal/components/dialog/theme.go
+++ b/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,
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go
index 2405440fe..fd2d7d93f 100644
--- a/packages/tui/internal/components/list/list.go
+++ b/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("")
diff --git a/packages/tui/internal/components/list/list_test.go b/packages/tui/internal/components/list/list_test.go
index 1e1240be2..4d954409a 100644
--- a/packages/tui/internal/components/list/list_test.go
+++ b/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}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index ebd2ff1a0..1e34d4cbc 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/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