diff options
| author | Adictya <[email protected]> | 2025-05-15 08:42:45 +0530 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-05-15 08:29:54 -0500 |
| commit | 15bf40bc102ed5426fa2148b9e8f39acef1174a0 (patch) | |
| tree | 4b005eba92703864a4bc2884b3cdc1fb5ab21a3c /internal/tui/components | |
| parent | a33e3e25b6646b6128975836d7f182e89f5eb891 (diff) | |
| download | opencode-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.go | 6 | ||||
| -rw-r--r-- | internal/tui/components/dialog/commands.go | 191 | ||||
| -rw-r--r-- | internal/tui/components/dialog/complete.go | 265 | ||||
| -rw-r--r-- | internal/tui/components/util/simple-list.go | 159 |
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, + } +} |
