summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components
diff options
context:
space:
mode:
authorAdictya <[email protected]>2025-05-15 08:42:45 +0530
committerAdam <[email protected]>2025-05-15 08:29:54 -0500
commit15bf40bc102ed5426fa2148b9e8f39acef1174a0 (patch)
tree4b005eba92703864a4bc2884b3cdc1fb5ab21a3c /internal/tui/components
parenta33e3e25b6646b6128975836d7f182e89f5eb891 (diff)
downloadopencode-15bf40bc102ed5426fa2148b9e8f39acef1174a0.tar.gz
opencode-15bf40bc102ed5426fa2148b9e8f39acef1174a0.zip
feat(complete-module): add completions logic, dialog and providers
Diffstat (limited to 'internal/tui/components')
-rw-r--r--internal/tui/components/chat/editor.go6
-rw-r--r--internal/tui/components/dialog/commands.go191
-rw-r--r--internal/tui/components/dialog/complete.go265
-rw-r--r--internal/tui/components/util/simple-list.go159
4 files changed, 491 insertions, 130 deletions
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index dbe55e7f9..0b2c9abb8 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"slices"
+ "strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
@@ -148,6 +149,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
+ case dialog.CompletionSelectedMsg:
+ existingValue := m.textarea.Value()
+ modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
+
+ m.textarea.SetValue(modifiedValue)
return m, nil
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
index 17f430504..b989154c6 100644
--- a/internal/tui/components/dialog/commands.go
+++ b/internal/tui/components/dialog/commands.go
@@ -4,6 +4,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
@@ -18,6 +19,33 @@ type Command struct {
Handler func(cmd Command) tea.Cmd
}
+func (ci Command) Render(selected bool, width int) string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
+ itemStyle := baseStyle.Width(width).
+ Foreground(t.Text()).
+ Background(t.Background())
+
+ if selected {
+ itemStyle = itemStyle.
+ Background(t.Primary()).
+ Foreground(t.Background()).
+ Bold(true)
+ descStyle = descStyle.
+ Background(t.Primary()).
+ Foreground(t.Background())
+ }
+
+ title := itemStyle.Padding(0, 1).Render(ci.Title)
+ if ci.Description != "" {
+ description := descStyle.Padding(0, 1).Render(ci.Description)
+ return lipgloss.JoinVertical(lipgloss.Left, title, description)
+ }
+ return title
+}
+
// CommandSelectedMsg is sent when a command is selected
type CommandSelectedMsg struct {
Command Command
@@ -31,35 +59,20 @@ type CommandDialog interface {
tea.Model
layout.Bindings
SetCommands(commands []Command)
- SetSelectedCommand(commandID string)
}
type commandDialogCmp struct {
- commands []Command
- selectedIdx int
- width int
- height int
- selectedCommandID string
+ listView utilComponents.SimpleList[Command]
+ width int
+ height int
}
type commandKeyMap struct {
- Up key.Binding
- Down key.Binding
Enter key.Binding
Escape key.Binding
- J key.Binding
- K key.Binding
}
var commandKeys = commandKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("↑", "previous command"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("↓", "next command"),
- ),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select command"),
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
- J: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next command"),
- ),
- K: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous command"),
- ),
}
func (c *commandDialogCmp) Init() tea.Cmd {
- return nil
+ return c.listView.Init()
}
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
- case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
- if c.selectedIdx > 0 {
- c.selectedIdx--
- }
- return c, nil
- case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
- if c.selectedIdx < len(c.commands)-1 {
- c.selectedIdx++
- }
- return c, nil
case key.Matches(msg, commandKeys.Enter):
- if len(c.commands) > 0 {
+ selectedItem, idx := c.listView.GetSelectedItem()
+ if idx != -1 {
return c, util.CmdHandler(CommandSelectedMsg{
- Command: c.commands[c.selectedIdx],
+ Command: selectedItem,
})
}
case key.Matches(msg, commandKeys.Escape):
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.width = msg.Width
c.height = msg.Height
}
- return c, nil
+
+ u, cmd := c.listView.Update(msg)
+ c.listView = u.(utilComponents.SimpleList[Command])
+ cmds = append(cmds, cmd)
+
+ return c, tea.Batch(cmds...)
}
func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
- if len(c.commands) == 0 {
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(40).
- Render("No commands available")
- }
+ maxWidth := 40
- // Calculate max width needed for command titles
- maxWidth := 40 // Minimum width
- for _, cmd := range c.commands {
- if len(cmd.Title) > maxWidth-4 { // Account for padding
- maxWidth = len(cmd.Title) + 4
- }
- if len(cmd.Description) > maxWidth-4 {
- maxWidth = len(cmd.Description) + 4
- }
- }
+ commands := c.listView.GetItems()
- // Limit height to avoid taking up too much screen space
- maxVisibleCommands := min(10, len(c.commands))
-
- // Build the command list
- commandItems := make([]string, 0, maxVisibleCommands)
- startIdx := 0
-
- // If we have more commands than can be displayed, adjust the start index
- if len(c.commands) > maxVisibleCommands {
- // Center the selected item when possible
- halfVisible := maxVisibleCommands / 2
- if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
- startIdx = c.selectedIdx - halfVisible
- } else if c.selectedIdx >= len(c.commands)-halfVisible {
- startIdx = len(c.commands) - maxVisibleCommands
- }
- }
-
- endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
-
- for i := startIdx; i < endIdx; i++ {
- cmd := c.commands[i]
- itemStyle := baseStyle.Width(maxWidth)
- descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
-
- if i == c.selectedIdx {
- itemStyle = itemStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- descStyle = descStyle.
- Background(t.Primary()).
- Foreground(t.Background())
+ for _, cmd := range commands {
+ if len(cmd.Title) > maxWidth-4 {
+ maxWidth = len(cmd.Title) + 4
}
-
- title := itemStyle.Padding(0, 1).Render(cmd.Title)
- description := ""
if cmd.Description != "" {
- description = descStyle.Padding(0, 1).Render(cmd.Description)
- commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
- } else {
- commandItems = append(commandItems, title)
+ if len(cmd.Description) > maxWidth-4 {
+ maxWidth = len(cmd.Description) + 4
+ }
}
}
+ c.listView.SetMaxWidth(maxWidth)
+
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
- baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
+ baseStyle.Width(maxWidth).Render(c.listView.View()),
baseStyle.Width(maxWidth).Render(""),
)
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
}
func (c *commandDialogCmp) SetCommands(commands []Command) {
- c.commands = commands
-
- // If we have a selected command ID, find its index
- if c.selectedCommandID != "" {
- for i, cmd := range commands {
- if cmd.ID == c.selectedCommandID {
- c.selectedIdx = i
- return
- }
- }
- }
-
- // Default to first command if selected not found
- c.selectedIdx = 0
-}
-
-func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
- c.selectedCommandID = commandID
-
- // Update the selected index if commands are already loaded
- if len(c.commands) > 0 {
- for i, cmd := range c.commands {
- if cmd.ID == commandID {
- c.selectedIdx = i
- return
- }
- }
- }
+ c.listView.SetItems(commands)
}
// NewCommandDialogCmp creates a new command selection dialog
func NewCommandDialogCmp() CommandDialog {
+ listView := utilComponents.NewSimpleList[Command](
+ []Command{},
+ 10,
+ "No commands available",
+ true,
+ )
return &commandDialogCmp{
- commands: []Command{},
- selectedIdx: 0,
- selectedCommandID: "",
+ listView: listView,
}
}
diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go
new file mode 100644
index 000000000..88e2c3c37
--- /dev/null
+++ b/internal/tui/components/dialog/complete.go
@@ -0,0 +1,265 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textarea"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/logging"
+ "github.com/sst/opencode/internal/status"
+ utilComponents "github.com/sst/opencode/internal/tui/components/util"
+ "github.com/sst/opencode/internal/tui/layout"
+ "github.com/sst/opencode/internal/tui/styles"
+ "github.com/sst/opencode/internal/tui/theme"
+ "github.com/sst/opencode/internal/tui/util"
+)
+
+type CompletionItem struct {
+ title string
+ Title string
+ Value string
+}
+
+type CompletionItemI interface {
+ utilComponents.SimpleListItem
+ GetValue() string
+ DisplayValue() string
+}
+
+func (ci *CompletionItem) Render(selected bool, width int) string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ itemStyle := baseStyle.
+ Width(width).
+ Padding(0, 1)
+
+ if selected {
+ itemStyle = itemStyle.
+ Background(t.Background()).
+ Foreground(t.Primary()).
+ Bold(true)
+ }
+
+ title := itemStyle.Render(
+ ci.GetValue(),
+ )
+
+ return title
+}
+
+func (ci *CompletionItem) DisplayValue() string {
+ return ci.Title
+}
+
+func (ci *CompletionItem) GetValue() string {
+ return ci.Value
+}
+
+func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
+ return &completionItem
+}
+
+type CompletionProvider interface {
+ GetId() string
+ GetEntry() CompletionItemI
+ GetChildEntries(query string) ([]CompletionItemI, error)
+}
+
+type CompletionSelectedMsg struct {
+ SearchString string
+ CompletionValue string
+}
+
+type CompletionDialogCompleteItemMsg struct {
+ Value string
+}
+
+type CompletionDialogCloseMsg struct{}
+
+type CompletionDialog interface {
+ tea.Model
+ layout.Bindings
+ SetWidth(width int)
+}
+
+type completionDialogCmp struct {
+ query string
+ completionProvider CompletionProvider
+ width int
+ height int
+ pseudoSearchTextArea textarea.Model
+ listView utilComponents.SimpleList[CompletionItemI]
+}
+
+type completionDialogKeyMap struct {
+ Complete key.Binding
+ Cancel key.Binding
+}
+
+var completionDialogKeys = completionDialogKeyMap{
+ Complete: key.NewBinding(
+ key.WithKeys("tab", "enter"),
+ ),
+ Cancel: key.NewBinding(
+ key.WithKeys(" ", "esc", "backspace"),
+ ),
+}
+
+func (c *completionDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
+ value := c.pseudoSearchTextArea.Value()
+
+ if value == "" {
+ return nil
+ }
+
+ return tea.Batch(
+ util.CmdHandler(CompletionSelectedMsg{
+ SearchString: value,
+ CompletionValue: item.GetValue(),
+ }),
+ c.close(),
+ )
+}
+
+func (c *completionDialogCmp) close() tea.Cmd {
+ c.listView.SetItems([]CompletionItemI{})
+ c.pseudoSearchTextArea.Reset()
+ c.pseudoSearchTextArea.Blur()
+
+ return util.CmdHandler(CompletionDialogCloseMsg{})
+}
+
+func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ if c.pseudoSearchTextArea.Focused() {
+
+ if !key.Matches(msg, completionDialogKeys.Complete) {
+
+ var cmd tea.Cmd
+ c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
+ cmds = append(cmds, cmd)
+
+ var query string
+ query = c.pseudoSearchTextArea.Value()
+ if query != "" {
+ query = query[1:]
+ }
+
+ if query != c.query {
+ logging.Info("Query", query)
+ items, err := c.completionProvider.GetChildEntries(query)
+ if err != nil {
+ logging.Error("Failed to get child entries", err)
+ }
+
+ c.listView.SetItems(items)
+ c.query = query
+ }
+
+ u, cmd := c.listView.Update(msg)
+ c.listView = u.(utilComponents.SimpleList[CompletionItemI])
+
+ cmds = append(cmds, cmd)
+ }
+
+ switch {
+ case key.Matches(msg, completionDialogKeys.Complete):
+ item, i := c.listView.GetSelectedItem()
+ if i == -1 {
+ return c, nil
+ }
+
+ cmd := c.complete(item)
+
+ return c, cmd
+ case key.Matches(msg, completionDialogKeys.Cancel):
+ // Only close on backspace when there are no characters left
+ if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
+ return c, c.close()
+ }
+ }
+
+ return c, tea.Batch(cmds...)
+ } else {
+ items, err := c.completionProvider.GetChildEntries("")
+ if err != nil {
+ status.Error(err.Error())
+ }
+
+ c.listView.SetItems(items)
+ c.pseudoSearchTextArea.SetValue(msg.String())
+ return c, c.pseudoSearchTextArea.Focus()
+ }
+ case tea.WindowSizeMsg:
+ c.width = msg.Width
+ c.height = msg.Height
+ }
+
+ return c, tea.Batch(cmds...)
+}
+
+func (c *completionDialogCmp) View() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ maxWidth := 40
+
+ completions := c.listView.GetItems()
+
+ for _, cmd := range completions {
+ title := cmd.DisplayValue()
+ if len(title) > maxWidth-4 {
+ maxWidth = len(title) + 4
+ }
+ }
+
+ c.listView.SetMaxWidth(maxWidth)
+
+ return baseStyle.Padding(0, 0).
+ Border(lipgloss.NormalBorder()).
+ BorderBottom(false).
+ BorderRight(false).
+ BorderLeft(false).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted()).
+ Width(c.width).
+ Render(c.listView.View())
+}
+
+func (c *completionDialogCmp) SetWidth(width int) {
+ c.width = width
+}
+
+func (c *completionDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(completionDialogKeys)
+}
+
+func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
+ ti := textarea.New()
+
+ items, err := completionProvider.GetChildEntries("")
+ if err != nil {
+ status.Error(err.Error())
+ }
+
+ li := utilComponents.NewSimpleList(
+ items,
+ 7,
+ "No file matches found",
+ false,
+ )
+
+ return &completionDialogCmp{
+ query: "",
+ completionProvider: completionProvider,
+ pseudoSearchTextArea: ti,
+ listView: li,
+ }
+}
diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go
new file mode 100644
index 000000000..7b8c4b1cb
--- /dev/null
+++ b/internal/tui/components/util/simple-list.go
@@ -0,0 +1,159 @@
+package utilComponents
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/tui/layout"
+ "github.com/sst/opencode/internal/tui/styles"
+ "github.com/sst/opencode/internal/tui/theme"
+)
+
+type SimpleListItem interface {
+ Render(selected bool, width int) string
+}
+
+type SimpleList[T SimpleListItem] interface {
+ tea.Model
+ layout.Bindings
+ SetMaxWidth(maxWidth int)
+ GetSelectedItem() (item T, idx int)
+ SetItems(items []T)
+ GetItems() []T
+}
+
+type simpleListCmp[T SimpleListItem] struct {
+ fallbackMsg string
+ items []T
+ selectedIdx int
+ maxWidth int
+ maxVisibleItems int
+ useAlphaNumericKeys bool
+ width int
+ height int
+}
+
+type simpleListKeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ UpAlpha key.Binding
+ DownAlpha key.Binding
+}
+
+var simpleListKeys = simpleListKeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "previous list item"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "next list item"),
+ ),
+ UpAlpha: key.NewBinding(
+ key.WithKeys("k"),
+ key.WithHelp("k", "previous list item"),
+ ),
+ DownAlpha: key.NewBinding(
+ key.WithKeys("j"),
+ key.WithHelp("j", "next list item"),
+ ),
+}
+
+func (c *simpleListCmp[T]) Init() tea.Cmd {
+ return nil
+}
+
+func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
+ if c.selectedIdx > 0 {
+ c.selectedIdx--
+ }
+ return c, nil
+ case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
+ if c.selectedIdx < len(c.items)-1 {
+ c.selectedIdx++
+ }
+ return c, nil
+ }
+ }
+
+ return c, nil
+}
+
+func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(simpleListKeys)
+}
+
+func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
+ if len(c.items) > 0 {
+ return c.items[c.selectedIdx], c.selectedIdx
+ }
+
+ var zero T
+ return zero, -1
+}
+
+func (c *simpleListCmp[T]) SetItems(items []T) {
+ c.selectedIdx = 0
+ c.items = items
+}
+
+func (c *simpleListCmp[T]) GetItems() []T {
+ return c.items
+}
+
+func (c *simpleListCmp[T]) SetMaxWidth(width int) {
+ c.maxWidth = width
+}
+
+func (c *simpleListCmp[T]) View() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ items := c.items
+ maxWidth := c.maxWidth
+ maxVisibleItems := min(c.maxVisibleItems, len(items))
+ startIdx := 0
+
+ if len(items) <= 0 {
+ return baseStyle.
+ Background(t.Background()).
+ Padding(0, 1).
+ Width(maxWidth).
+ Render(c.fallbackMsg)
+ }
+
+ 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))
+
+ listItems := make([]string, 0, maxVisibleItems)
+
+ for i := startIdx; i < endIdx; i++ {
+ item := items[i]
+ title := item.Render(i == c.selectedIdx, maxWidth)
+ listItems = append(listItems, title)
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Left, listItems...)
+}
+
+func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
+ return &simpleListCmp[T]{
+ fallbackMsg: fallbackMsg,
+ items: items,
+ maxVisibleItems: maxVisibleItems,
+ useAlphaNumericKeys: useAlphaNumericKeys,
+ selectedIdx: 0,
+ }
+}