From 56c0c7f7f7cbed1ca33e3e46d95d7b923c2e2f7f Mon Sep 17 00:00:00 2001 From: Adictya Date: Sun, 27 Apr 2025 16:03:53 +0530 Subject: [PATCH 01/13] feat(context-dialog): init --- internal/completions/files-folders.go | 90 +++++++ internal/tui/components/chat/editor.go | 6 + internal/tui/components/dialog/complete.go | 260 ++++++++++++++++++++ internal/tui/components/util/simple-list.go | 229 +++++++++++++++++ internal/tui/page/chat.go | 67 ++++- 5 files changed, 641 insertions(+), 11 deletions(-) create mode 100644 internal/completions/files-folders.go create mode 100644 internal/tui/components/dialog/complete.go create mode 100644 internal/tui/components/util/simple-list.go diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go new file mode 100644 index 00000000..8ce183f4 --- /dev/null +++ b/internal/completions/files-folders.go @@ -0,0 +1,90 @@ +package completions + +import ( + "bytes" + "fmt" + "os/exec" + "path/filepath" + "sort" + + "github.com/opencode-ai/opencode/internal/tui/components/dialog" +) + +type filesAndFoldersContextGroup struct { + prefix string +} + +func (cg *filesAndFoldersContextGroup) GetId() string { + return cg.prefix +} + +func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { + return dialog.NewCompletionItem(dialog.CompletionItem{ + Title: "Files & Folders", + Value: "files", + }) +} + +func getFilesRg() ([]string, error) { + searchRoot := "." + + rgBin, err := exec.LookPath("rg") + if err != nil { + return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err) + } + + args := []string{ + "--files", + "-L", + "--null", + } + + cmd := exec.Command(rgBin, args...) + cmd.Dir = "." + + out, err := cmd.CombinedOutput() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return nil, nil + } + return nil, fmt.Errorf("ripgrep: %w\n%s", err, out) + } + + var matches []string + for _, p := range bytes.Split(out, []byte{0}) { + if len(p) == 0 { + continue + } + abs := filepath.Join(searchRoot, string(p)) + matches = append(matches, abs) + } + + sort.SliceStable(matches, func(i, j int) bool { + return len(matches[i]) < len(matches[j]) + }) + + return matches, nil +} + +func (cg *filesAndFoldersContextGroup) GetChildEntries() ([]dialog.CompletionItemI, error) { + matches, err := getFilesRg() + if err != nil { + return nil, err + } + + items := make([]dialog.CompletionItemI, 0) + + for _, file := range matches { + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: file, + Value: file, + }) + items = append(items, item) + } + + return items, nil +} + +func NewFileAndFolderContextGroup() dialog.CompletionProvider { + return &filesAndFoldersContextGroup{prefix: "file"} +} diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 3548cbb0..7f51928a 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -3,6 +3,7 @@ package chat import ( "os" "os/exec" + "strings" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" @@ -104,6 +105,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 SessionSelectedMsg: if msg.ID != m.session.ID { diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go new file mode 100644 index 00000000..a9793bf6 --- /dev/null +++ b/internal/tui/components/dialog/complete.go @@ -0,0 +1,260 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/opencode-ai/opencode/internal/logging" + utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util" + "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +type CompletionItem struct { + title string + Title string + Value string +} + +func (ci *CompletionItem) DisplayValue() string { + return ci.Title +} + +func (ci *CompletionItem) GetValue() string { + return ci.Value +} + +type CompletionItemI interface { + GetValue() string + DisplayValue() string +} + +func NewCompletionItem(completionItem CompletionItem) CompletionItemI { + return &completionItem +} + +type CompletionProvider interface { + GetId() string + GetEntry() CompletionItemI + GetChildEntries() ([]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 { + completionProvider CompletionProvider + completionItems []CompletionItemI + selectedIdx int + width int + height int + counter int + searchTextArea textarea.Model + listView utilComponents.SimpleList +} + +type completionDialogKeyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Tab key.Binding + Space key.Binding + Backspace key.Binding + Escape key.Binding + J key.Binding + K key.Binding + At key.Binding +} + +var completionDialogKeys = completionDialogKeyMap{ + At: key.NewBinding( + key.WithKeys("@"), + ), + Backspace: key.NewBinding( + key.WithKeys("backspace"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + ), + Space: key.NewBinding( + key.WithKeys(" "), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "previous item"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "next item"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select item"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close"), + ), + J: key.NewBinding( + key.WithKeys("j"), + key.WithHelp("j", "next item"), + ), + K: key.NewBinding( + key.WithKeys("k"), + key.WithHelp("k", "previous item"), + ), +} + +func (c *completionDialogCmp) Init() tea.Cmd { + return nil +} + +func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { + value := c.searchTextArea.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.Reset() + c.searchTextArea.Reset() + c.searchTextArea.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.searchTextArea.Focused() { + var cmd tea.Cmd + c.searchTextArea, cmd = c.searchTextArea.Update(msg) + cmds = append(cmds, cmd) + + var query string + query = c.searchTextArea.Value() + if query != "" { + query = query[1:] + } + + logging.Info("Query", query) + c.listView.Filter(query) + + u, cmd := c.listView.Update(msg) + c.listView = u.(utilComponents.SimpleList) + + cmds = append(cmds, cmd) + + switch { + case key.Matches(msg, completionDialogKeys.Tab): + item, i := c.listView.GetSelectedItem() + if i == -1 { + return c, nil + } + var matchedItem CompletionItemI + + for _, citem := range c.completionItems { + if item.GetValue() == citem.GetValue() { + matchedItem = citem + } + } + + cmd := c.complete(matchedItem) + + return c, cmd + case key.Matches(msg, completionDialogKeys.Escape) || key.Matches(msg, completionDialogKeys.Space) || + (key.Matches(msg, completionDialogKeys.Backspace) && len(c.searchTextArea.Value()) <= 0): + return c, c.close() + } + + return c, tea.Batch(cmds...) + } + switch { + case key.Matches(msg, completionDialogKeys.At): + c.searchTextArea.SetValue("@") + return c, c.searchTextArea.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() + + 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 mapperFunc(i CompletionItemI) utilComponents.SimpleListItem { + return utilComponents.NewListItem( + // i.DisplayValue(), + // "", + i.GetValue(), + ) +} + +func (c *completionDialogCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(completionDialogKeys) +} + +func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog { + ti := textarea.New() + items, err := completionProvider.GetChildEntries() + li := utilComponents.NewSimpleList(utilComponents.MapSlice(items, mapperFunc)) + if err != nil { + logging.Error("Failed to get child entries", err) + } + + return &completionDialogCmp{ + completionProvider: completionProvider, + completionItems: items, + selectedIdx: 0, + counter: 0, + searchTextArea: 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 00000000..196be5cc --- /dev/null +++ b/internal/tui/components/util/simple-list.go @@ -0,0 +1,229 @@ +package utilComponents + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" +) + +type listItem struct { + // title string + // description string + value string +} + +func (li *listItem) GetValue() string { + return li.value +} + +type SimpleListItem interface { + GetValue() string +} + +func NewListItem( + // title string, + // description string, + value string, +) SimpleListItem { + return &listItem{ + // title: title, + // description: description, + value: value, + } +} + +type SimpleList interface { + tea.Model + layout.Bindings + GetSelectedItem() (item SimpleListItem, idx int) + SetItems(items []SimpleListItem) + Filter(query string) + Reset() +} + +type simpleListCmp struct { + items []SimpleListItem + filtereditems []SimpleListItem + query string + selectedIdx int + width int + height int +} + +type simpleListKeyMap struct { + Up key.Binding + Down key.Binding + Enter 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"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select item"), + ), +} + +func (c *simpleListCmp) Init() tea.Cmd { + return nil +} + +func (c *simpleListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, simpleListKeys.Up): + if c.selectedIdx > 0 { + c.selectedIdx-- + } + return c, nil + case key.Matches(msg, simpleListKeys.Down): + if c.selectedIdx < len(c.filtereditems)-1 { + c.selectedIdx++ + } + return c, nil + } + } + + return c, nil +} + +func (c *simpleListCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(simpleListKeys) +} + +func (c *simpleListCmp) GetSelectedItem() (SimpleListItem, int) { + if len(c.filtereditems) > 0 { + return c.filtereditems[c.selectedIdx], c.selectedIdx + } + + return nil, -1 +} + +func (c *simpleListCmp) SetItems(items []SimpleListItem) { + c.selectedIdx = 0 + c.items = items +} + +func (c *simpleListCmp) Filter(query string) { + if query == c.query { + return + } + c.query = query + + filteredItems := make([]SimpleListItem, 0) + + for _, item := range c.items { + if strings.Contains(item.GetValue(), query) { + filteredItems = append(filteredItems, item) + } + } + + c.selectedIdx = 0 + c.filtereditems = filteredItems +} + +func (c *simpleListCmp) Reset() { + c.selectedIdx = 0 + c.filtereditems = c.items +} + +func MapSlice[In any](input []In, mapper func(In) SimpleListItem) []SimpleListItem { + if input == nil { + return nil + } + + output := make([]SimpleListItem, len(input)) + + for i, element := range input { + output[i] = mapper(element) + } + + return output +} + +func (c *simpleListCmp) View() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + items := c.filtereditems + maxWidth := 80 + maxVisibleCommands := 7 + startIdx := 0 + + if len(items) <= 0 { + return "No content available" + } + + if len(items) > maxVisibleCommands { + // Center the selected item when possible + halfVisible := maxVisibleCommands / 2 + if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible { + startIdx = c.selectedIdx - halfVisible + } else if c.selectedIdx >= len(items)-halfVisible { + startIdx = len(items) - maxVisibleCommands + } + } + + endIdx := min(startIdx+maxVisibleCommands, len(items)) + + // return styles.BaseStyle.Padding(0, 0). + // Render(fmt.Sprintf("lenItems %d, s: %d, e: %d", len(c.items), startIdx+maxVisibleCommands, endIdx)) + + listItems := make([]string, 0, maxVisibleCommands) + + for i := startIdx; i < endIdx; i++ { + item := items[i] + itemStyle := baseStyle.Width(maxWidth) + // descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim) + + if i == c.selectedIdx { + itemStyle = itemStyle. + Background(t.Background()). + Foreground(t.Primary()). + Bold(true) + // descStyle = descStyle. + // Background(styles.PrimaryColor). + // Foreground(styles.Background) + } + + title := itemStyle.Render( + item.GetValue(), + ) + listItems = append(listItems, title) + } + + return lipgloss.JoinVertical( + lipgloss.Left, + baseStyle. + Width(maxWidth). + Padding(0, 1). + Render( + lipgloss. + JoinVertical(lipgloss.Left, listItems...), + ), + ) +} + +func NewSimpleList(items []SimpleListItem) SimpleList { + return &simpleListCmp{ + items: items, + filtereditems: items, + selectedIdx: 0, + query: "", + // selectedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true), + // normalStyle: lipgloss.NewStyle(), + } +} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 62a5b9f4..b496d275 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -5,9 +5,12 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/opencode-ai/opencode/internal/app" + "github.com/opencode-ai/opencode/internal/completions" "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/chat" + "github.com/opencode-ai/opencode/internal/tui/components/dialog" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/util" ) @@ -15,19 +18,26 @@ import ( var ChatPage PageID = "chat" type chatPage struct { - app *app.App - editor layout.Container - messages layout.Container - layout layout.SplitPaneLayout - session session.Session + app *app.App + editor layout.Container + messages layout.Container + layout layout.SplitPaneLayout + session session.Session + contextDialog dialog.CompletionDialog + showContextDialog bool } type ChatKeyMap struct { - NewSession key.Binding - Cancel key.Binding + ShowContextDialog key.Binding + NewSession key.Binding + Cancel key.Binding } var keyMap = ChatKeyMap{ + ShowContextDialog: key.NewBinding( + key.WithKeys("@"), + key.WithHelp("@", "context"), + ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "new session"), @@ -41,6 +51,7 @@ var keyMap = ChatKeyMap{ func (p *chatPage) Init() tea.Cmd { cmds := []tea.Cmd{ p.layout.Init(), + p.contextDialog.Init(), } return tea.Batch(cmds...) } @@ -51,6 +62,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: cmd := p.layout.SetSize(msg.Width, msg.Height) cmds = append(cmds, cmd) + case dialog.CompletionDialogCloseMsg: + p.showContextDialog = false case chat.SendMsg: cmd := p.sendMessage(msg.Text) if cmd != nil { @@ -66,6 +79,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.session = msg case tea.KeyMsg: switch { + case key.Matches(msg, keyMap.ShowContextDialog): + p.showContextDialog = true + // Continue sending keys to layout->chat case key.Matches(msg, keyMap.NewSession): p.session = session.Session{} return p, tea.Batch( @@ -81,9 +97,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + if p.showContextDialog { + context, contextCmd := p.contextDialog.Update(msg) + p.contextDialog = context.(dialog.CompletionDialog) + cmds = append(cmds, contextCmd) + } + u, cmd := p.layout.Update(msg) cmds = append(cmds, cmd) p.layout = u.(layout.SplitPaneLayout) + return p, tea.Batch(cmds...) } @@ -128,7 +151,25 @@ func (p *chatPage) GetSize() (int, int) { } func (p *chatPage) View() string { - return p.layout.View() + layoutView := p.layout.View() + + if p.showContextDialog { + _, layoutHeight := p.layout.GetSize() + editorWidth, editorHeight := p.editor.GetSize() + + p.contextDialog.SetWidth(editorWidth) + overlay := p.contextDialog.View() + + layoutView = layout.PlaceOverlay( + 0, + layoutHeight-editorHeight-lipgloss.Height(overlay), + overlay, + layoutView, + false, + ) + } + + return layoutView } func (p *chatPage) BindingKeys() []key.Binding { @@ -138,6 +179,9 @@ func (p *chatPage) BindingKeys() []key.Binding { } func NewChatPage(app *app.App) tea.Model { + cg := completions.NewFileAndFolderContextGroup() + contextDialog := dialog.NewCompletionDialogCmp(cg) + messagesContainer := layout.NewContainer( chat.NewMessagesCmp(app), layout.WithPadding(1, 1, 0, 1), @@ -147,9 +191,10 @@ func NewChatPage(app *app.App) tea.Model { layout.WithBorder(true, false, false, false), ) return &chatPage{ - app: app, - editor: editorContainer, - messages: messagesContainer, + app: app, + editor: editorContainer, + messages: messagesContainer, + contextDialog: contextDialog, layout: layout.NewSplitPane( layout.WithLeftPanel(messagesContainer), layout.WithBottomPanel(editorContainer), From 265afbff901ce6f01d0ea7ccbe800ed42d006636 Mon Sep 17 00:00:00 2001 From: Adictya Date: Sun, 4 May 2025 21:11:24 +0530 Subject: [PATCH 02/13] chore(simple-list): refactor with generics --- internal/completions/files-folders.go | 32 ++++- internal/tui/components/dialog/complete.go | 75 +++++----- internal/tui/components/util/simple-list.go | 152 +++++--------------- 3 files changed, 101 insertions(+), 158 deletions(-) diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go index 8ce183f4..3ab11c19 100644 --- a/internal/completions/files-folders.go +++ b/internal/completions/files-folders.go @@ -25,13 +25,17 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { }) } -func getFilesRg() ([]string, error) { +func getFilesRg(query string) ([]string, error) { searchRoot := "." rgBin, err := exec.LookPath("rg") if err != nil { return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err) } + fzfBin, err := exec.LookPath("fzf") + if err != nil { + return nil, fmt.Errorf("fzf not found in $PATH: %w", err) + } args := []string{ "--files", @@ -42,12 +46,30 @@ func getFilesRg() ([]string, error) { cmd := exec.Command(rgBin, args...) cmd.Dir = "." - out, err := cmd.CombinedOutput() + args2 := []string{ + "--filter", + query, + "--exit-0", + } + + cmd2 := exec.Command(fzfBin, args2...) + cmd2.Dir = "." + + pipReader, err := cmd.StdoutPipe() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return nil, nil + } + return nil, fmt.Errorf("ripgrep: %w", err) + } + cmd2.Stdin = pipReader + + out, err := cmd2.CombinedOutput() if err != nil { if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { return nil, nil } - return nil, fmt.Errorf("ripgrep: %w\n%s", err, out) + return nil, fmt.Errorf("fzf: %w\n%s", err, out) } var matches []string @@ -66,8 +88,8 @@ func getFilesRg() ([]string, error) { return matches, nil } -func (cg *filesAndFoldersContextGroup) GetChildEntries() ([]dialog.CompletionItemI, error) { - matches, err := getFilesRg() +func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { + matches, err := getFilesRg(query) if err != nil { return nil, err } diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index a9793bf6..02846c86 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -19,6 +19,32 @@ type CompletionItem struct { Value string } +type CompletionItemI interface { + utilComponents.SimpleListItem + GetValue() string + DisplayValue() string +} + +func (ci *CompletionItem) Render(selected bool) string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + itemStyle := baseStyle + + 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 } @@ -27,11 +53,6 @@ func (ci *CompletionItem) GetValue() string { return ci.Value } -type CompletionItemI interface { - GetValue() string - DisplayValue() string -} - func NewCompletionItem(completionItem CompletionItem) CompletionItemI { return &completionItem } @@ -39,7 +60,7 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI { type CompletionProvider interface { GetId() string GetEntry() CompletionItemI - GetChildEntries() ([]CompletionItemI, error) + GetChildEntries(query string) ([]CompletionItemI, error) } type CompletionSelectedMsg struct { @@ -61,13 +82,10 @@ type CompletionDialog interface { type completionDialogCmp struct { completionProvider CompletionProvider - completionItems []CompletionItemI - selectedIdx int width int height int - counter int searchTextArea textarea.Model - listView utilComponents.SimpleList + listView utilComponents.SimpleList[CompletionItemI] } type completionDialogKeyMap struct { @@ -143,7 +161,7 @@ func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { } func (c *completionDialogCmp) close() tea.Cmd { - c.listView.Reset() + // c.listView.Reset() c.searchTextArea.Reset() c.searchTextArea.Blur() @@ -166,10 +184,15 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } logging.Info("Query", query) - c.listView.Filter(query) + // c.listView.Filter(query) + items, err := c.completionProvider.GetChildEntries(query) + if err != nil { + logging.Error("Failed to get child entries", err) + } + c.listView.SetItems(items) u, cmd := c.listView.Update(msg) - c.listView = u.(utilComponents.SimpleList) + c.listView = u.(utilComponents.SimpleList[CompletionItemI]) cmds = append(cmds, cmd) @@ -179,15 +202,8 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if i == -1 { return c, nil } - var matchedItem CompletionItemI - for _, citem := range c.completionItems { - if item.GetValue() == citem.GetValue() { - matchedItem = citem - } - } - - cmd := c.complete(matchedItem) + cmd := c.complete(item) return c, cmd case key.Matches(msg, completionDialogKeys.Escape) || key.Matches(msg, completionDialogKeys.Space) || @@ -229,31 +245,22 @@ func (c *completionDialogCmp) SetWidth(width int) { c.width = width } -func mapperFunc(i CompletionItemI) utilComponents.SimpleListItem { - return utilComponents.NewListItem( - // i.DisplayValue(), - // "", - i.GetValue(), - ) -} - func (c *completionDialogCmp) BindingKeys() []key.Binding { return layout.KeyMapToSlice(completionDialogKeys) } func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog { ti := textarea.New() - items, err := completionProvider.GetChildEntries() - li := utilComponents.NewSimpleList(utilComponents.MapSlice(items, mapperFunc)) + + items, err := completionProvider.GetChildEntries("") if err != nil { logging.Error("Failed to get child entries", err) } + li := utilComponents.NewSimpleList(items) + return &completionDialogCmp{ completionProvider: completionProvider, - completionItems: items, - selectedIdx: 0, - counter: 0, searchTextArea: ti, listView: li, } diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index 196be5cc..de526466 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -1,8 +1,6 @@ package utilComponents import ( - "strings" - "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -11,48 +9,22 @@ import ( "github.com/opencode-ai/opencode/internal/tui/theme" ) -type listItem struct { - // title string - // description string - value string -} - -func (li *listItem) GetValue() string { - return li.value -} - type SimpleListItem interface { - GetValue() string -} - -func NewListItem( - // title string, - // description string, - value string, -) SimpleListItem { - return &listItem{ - // title: title, - // description: description, - value: value, - } + Render(selected bool) string } -type SimpleList interface { +type SimpleList[T SimpleListItem] interface { tea.Model layout.Bindings - GetSelectedItem() (item SimpleListItem, idx int) - SetItems(items []SimpleListItem) - Filter(query string) - Reset() + GetSelectedItem() (item T, idx int) + SetItems(items []T) } -type simpleListCmp struct { - items []SimpleListItem - filtereditems []SimpleListItem - query string - selectedIdx int - width int - height int +type simpleListCmp[T SimpleListItem] struct { + items []T + selectedIdx int + width int + height int } type simpleListKeyMap struct { @@ -76,11 +48,11 @@ var simpleListKeys = simpleListKeyMap{ ), } -func (c *simpleListCmp) Init() tea.Cmd { +func (c *simpleListCmp[T]) Init() tea.Cmd { return nil } -func (c *simpleListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { @@ -90,7 +62,7 @@ func (c *simpleListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return c, nil case key.Matches(msg, simpleListKeys.Down): - if c.selectedIdx < len(c.filtereditems)-1 { + if c.selectedIdx < len(c.items)-1 { c.selectedIdx++ } return c, nil @@ -100,114 +72,60 @@ func (c *simpleListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil } -func (c *simpleListCmp) BindingKeys() []key.Binding { +func (c *simpleListCmp[T]) BindingKeys() []key.Binding { return layout.KeyMapToSlice(simpleListKeys) } -func (c *simpleListCmp) GetSelectedItem() (SimpleListItem, int) { - if len(c.filtereditems) > 0 { - return c.filtereditems[c.selectedIdx], c.selectedIdx +func (c *simpleListCmp[T]) GetSelectedItem() (T, int) { + if len(c.items) > 0 { + return c.items[c.selectedIdx], c.selectedIdx } - return nil, -1 + var zero T + return zero, -1 } -func (c *simpleListCmp) SetItems(items []SimpleListItem) { +func (c *simpleListCmp[T]) SetItems(items []T) { c.selectedIdx = 0 c.items = items } -func (c *simpleListCmp) Filter(query string) { - if query == c.query { - return - } - c.query = query - - filteredItems := make([]SimpleListItem, 0) - - for _, item := range c.items { - if strings.Contains(item.GetValue(), query) { - filteredItems = append(filteredItems, item) - } - } - - c.selectedIdx = 0 - c.filtereditems = filteredItems -} - -func (c *simpleListCmp) Reset() { - c.selectedIdx = 0 - c.filtereditems = c.items -} - -func MapSlice[In any](input []In, mapper func(In) SimpleListItem) []SimpleListItem { - if input == nil { - return nil - } - - output := make([]SimpleListItem, len(input)) - - for i, element := range input { - output[i] = mapper(element) - } - - return output -} - -func (c *simpleListCmp) View() string { +func (c *simpleListCmp[T]) View() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() - items := c.filtereditems + items := c.items maxWidth := 80 - maxVisibleCommands := 7 + maxVisibleItems := 7 startIdx := 0 if len(items) <= 0 { return "No content available" } - if len(items) > maxVisibleCommands { - // Center the selected item when possible - halfVisible := maxVisibleCommands / 2 + 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) - maxVisibleCommands + startIdx = len(items) - maxVisibleItems } } - endIdx := min(startIdx+maxVisibleCommands, len(items)) + endIdx := min(startIdx+maxVisibleItems, len(items)) - // return styles.BaseStyle.Padding(0, 0). - // Render(fmt.Sprintf("lenItems %d, s: %d, e: %d", len(c.items), startIdx+maxVisibleCommands, endIdx)) - - listItems := make([]string, 0, maxVisibleCommands) + listItems := make([]string, 0, maxVisibleItems) for i := startIdx; i < endIdx; i++ { item := items[i] - itemStyle := baseStyle.Width(maxWidth) - // descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim) - - if i == c.selectedIdx { - itemStyle = itemStyle. - Background(t.Background()). - Foreground(t.Primary()). - Bold(true) - // descStyle = descStyle. - // Background(styles.PrimaryColor). - // Foreground(styles.Background) - } - - title := itemStyle.Render( - item.GetValue(), - ) + title := item.Render(i == c.selectedIdx) listItems = append(listItems, title) } return lipgloss.JoinVertical( lipgloss.Left, baseStyle. + Background(t.Background()). Width(maxWidth). Padding(0, 1). Render( @@ -217,13 +135,9 @@ func (c *simpleListCmp) View() string { ) } -func NewSimpleList(items []SimpleListItem) SimpleList { - return &simpleListCmp{ - items: items, - filtereditems: items, - selectedIdx: 0, - query: "", - // selectedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true), - // normalStyle: lipgloss.NewStyle(), +func NewSimpleList[T SimpleListItem](items []T) SimpleList[T] { + return &simpleListCmp[T]{ + items: items, + selectedIdx: 0, } } From 0b8a8e6c8b6dc9ced66035fa1ff8bb555c2ce3d5 Mon Sep 17 00:00:00 2001 From: Adictya Date: Mon, 5 May 2025 00:32:21 +0530 Subject: [PATCH 03/13] fix(complete-module): fix fzf issues --- internal/completions/files-folders.go | 77 ++++++++++++++-------- internal/tui/components/dialog/complete.go | 17 +++-- 2 files changed, 62 insertions(+), 32 deletions(-) diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go index 3ab11c19..3f2b4af4 100644 --- a/internal/completions/files-folders.go +++ b/internal/completions/files-folders.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "os/exec" - "path/filepath" "sort" "github.com/opencode-ai/opencode/internal/tui/components/dialog" @@ -32,57 +31,83 @@ func getFilesRg(query string) ([]string, error) { if err != nil { return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err) } + fzfBin, err := exec.LookPath("fzf") if err != nil { return nil, fmt.Errorf("fzf not found in $PATH: %w", err) } - args := []string{ + rgArgs := []string{ "--files", "-L", "--null", } + cmdRg := exec.Command(rgBin, rgArgs...) + cmdRg.Dir = searchRoot - cmd := exec.Command(rgBin, args...) - cmd.Dir = "." + rgPipe, err := cmdRg.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) + } + defer rgPipe.Close() - args2 := []string{ + fzfArgs := []string{ "--filter", query, - "--exit-0", + "--read0", + "--print0", + } + cmdFzf := exec.Command(fzfBin, fzfArgs...) + cmdFzf.Dir = searchRoot + cmdFzf.Stdin = rgPipe + var fzfOut bytes.Buffer + var fzfErr bytes.Buffer + cmdFzf.Stdout = &fzfOut + cmdFzf.Stderr = &fzfErr + + if err := cmdFzf.Start(); err != nil { + return nil, fmt.Errorf("failed to start fzf: %w", err) } - cmd2 := exec.Command(fzfBin, args2...) - cmd2.Dir = "." + errRg := cmdRg.Run() - pipReader, err := cmd.StdoutPipe() - if err != nil { - if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { - return nil, nil - } - return nil, fmt.Errorf("ripgrep: %w", err) + errFzf := cmdFzf.Wait() + + if errRg != nil { + return nil, fmt.Errorf("rg command failed: %w", errRg) } - cmd2.Stdin = pipReader - out, err := cmd2.CombinedOutput() - if err != nil { - if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { - return nil, nil + if errFzf != nil { + if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return []string{}, nil } - return nil, fmt.Errorf("fzf: %w\n%s", err, out) + return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) + } + + outputBytes := fzfOut.Bytes() + if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { + outputBytes = outputBytes[:len(outputBytes)-1] } - var matches []string - for _, p := range bytes.Split(out, []byte{0}) { + if len(outputBytes) == 0 { + return []string{}, nil + } + + split := bytes.Split(outputBytes, []byte{0}) + matches := make([]string, 0, len(split)) + + for _, p := range split { if len(p) == 0 { continue } - abs := filepath.Join(searchRoot, string(p)) - matches = append(matches, abs) + matches = append(matches, string(p)) } sort.SliceStable(matches, func(i, j int) bool { - return len(matches[i]) < len(matches[j]) + if len(matches[i]) != len(matches[j]) { + return len(matches[i]) < len(matches[j]) + } + return matches[i] < matches[j] }) return matches, nil @@ -94,7 +119,7 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C return nil, err } - items := make([]dialog.CompletionItemI, 0) + items := make([]dialog.CompletionItemI, 0, len(matches)) for _, file := range matches { item := dialog.NewCompletionItem(dialog.CompletionItem{ diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index 02846c86..be0a7176 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -81,6 +81,7 @@ type CompletionDialog interface { } type completionDialogCmp struct { + query string completionProvider CompletionProvider width int height int @@ -183,14 +184,17 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { query = query[1:] } - logging.Info("Query", query) - // c.listView.Filter(query) - items, err := c.completionProvider.GetChildEntries(query) - if err != nil { - logging.Error("Failed to get child entries", err) + 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 } - c.listView.SetItems(items) u, cmd := c.listView.Update(msg) c.listView = u.(utilComponents.SimpleList[CompletionItemI]) @@ -260,6 +264,7 @@ func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDia li := utilComponents.NewSimpleList(items) return &completionDialogCmp{ + query: "", completionProvider: completionProvider, searchTextArea: ti, listView: li, From b58ecf8bf948cda8e3d77afe619a8a536c593991 Mon Sep 17 00:00:00 2001 From: Adictya Date: Sat, 10 May 2025 16:59:36 +0530 Subject: [PATCH 04/13] fix(complete-module): add fallbacks when rg or fzf is not available --- go.mod | 1 + go.sum | 2 + internal/completions/files-folders.go | 344 ++++++++++++++++---- internal/tui/components/dialog/complete.go | 3 +- internal/tui/components/util/simple-list.go | 4 +- 5 files changed, 293 insertions(+), 61 deletions(-) diff --git a/go.mod b/go.mod index 52c5e81a..45d3874f 100644 --- a/go.mod +++ b/go.mod @@ -85,6 +85,7 @@ require ( github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index c41acf62..6684df89 100644 --- a/go.sum +++ b/go.sum @@ -152,6 +152,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms= github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go index 3f2b4af4..f14e2062 100644 --- a/internal/completions/files-folders.go +++ b/internal/completions/files-folders.go @@ -3,14 +3,24 @@ package completions import ( "bytes" "fmt" + "io/fs" + "os" "os/exec" + "path/filepath" "sort" + "strings" + "time" + "github.com/bmatcuk/doublestar/v4" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/tui/components/dialog" ) type filesAndFoldersContextGroup struct { - prefix string + rgPath string + fzfPath string + prefix string } func (cg *filesAndFoldersContextGroup) GetId() string { @@ -24,97 +34,302 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { }) } -func getFilesRg(query string) ([]string, error) { - searchRoot := "." +type fileInfo struct { + path string + modTime time.Time +} - rgBin, err := exec.LookPath("rg") +type GlobParams struct { + Pattern string `json:"pattern"` + Path string `json:"path"` +} + +type GlobResponseMetadata struct { + NumberOfFiles int `json:"number_of_files"` + Truncated bool `json:"truncated"` +} + +func globWithDoublestar() ([]string, error) { + searchPath := "." + pattern := "**/*" + fsys := os.DirFS(searchPath) + + relPattern := strings.TrimPrefix(pattern, "/") + + var matches []fileInfo + + err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { + if d.IsDir() { + return nil + } + if skipHidden(path) { + return nil + } + + info, err := d.Info() + if err != nil { + return nil // Skip files we can't access + } + + absPath := path // Restore absolute path + if !strings.HasPrefix(absPath, searchPath) { + absPath = filepath.Join(searchPath, absPath) + } + + matches = append(matches, fileInfo{ + path: absPath, + modTime: info.ModTime(), + }) + + return nil + }) if err != nil { - return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err) + return nil, fmt.Errorf("glob walk error: %w", err) } - fzfBin, err := exec.LookPath("fzf") - if err != nil { - return nil, fmt.Errorf("fzf not found in $PATH: %w", err) + sort.Slice(matches, func(i, j int) bool { + return matches[i].modTime.After(matches[j].modTime) + }) + + results := make([]string, len(matches)) + for i, m := range matches { + results[i] = m.path } + return results, nil +} + +func skipHidden(path string) bool { + // Check for hidden files (starting with a dot) + base := filepath.Base(path) + if base != "." && strings.HasPrefix(base, ".") { + return true + } + + // List of commonly ignored directories in development projects + commonIgnoredDirs := map[string]bool{ + "node_modules": true, + "vendor": true, + "dist": true, + "build": true, + "target": true, + ".git": true, + ".idea": true, + ".vscode": true, + "__pycache__": true, + "bin": true, + "obj": true, + "out": true, + "coverage": true, + "tmp": true, + "temp": true, + "logs": true, + "generated": true, + "bower_components": true, + "jspm_packages": true, + } + + // Check if any path component is in our ignore list + parts := strings.SplitSeq(path, string(os.PathSeparator)) + for part := range parts { + if commonIgnoredDirs[part] { + return true + } + } + + return false +} + +func (cg *filesAndFoldersContextGroup) rgCmd() *exec.Cmd { rgArgs := []string{ "--files", "-L", "--null", } - cmdRg := exec.Command(rgBin, rgArgs...) - cmdRg.Dir = searchRoot - - rgPipe, err := cmdRg.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) - } - defer rgPipe.Close() + cmdRg := exec.Command(cg.rgPath, rgArgs...) + cmdRg.Dir = "." + return cmdRg +} +func (cg *filesAndFoldersContextGroup) fzfCmd(query string) *exec.Cmd { fzfArgs := []string{ "--filter", query, "--read0", "--print0", } - cmdFzf := exec.Command(fzfBin, fzfArgs...) - cmdFzf.Dir = searchRoot - cmdFzf.Stdin = rgPipe - var fzfOut bytes.Buffer - var fzfErr bytes.Buffer - cmdFzf.Stdout = &fzfOut - cmdFzf.Stderr = &fzfErr - - if err := cmdFzf.Start(); err != nil { - return nil, fmt.Errorf("failed to start fzf: %w", err) - } + cmdFzf := exec.Command(cg.fzfPath, fzfArgs...) + cmdFzf.Dir = "." + return cmdFzf +} - errRg := cmdRg.Run() +func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { + if cg.rgPath != "" && cg.fzfPath != "" { - errFzf := cmdFzf.Wait() + cmdRg := cg.rgCmd() + cmdFzf := cg.fzfCmd(query) - if errRg != nil { - return nil, fmt.Errorf("rg command failed: %w", errRg) - } + rgPipe, err := cmdRg.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) + } + defer rgPipe.Close() + + cmdFzf.Stdin = rgPipe + var fzfOut bytes.Buffer + var fzfErr bytes.Buffer + cmdFzf.Stdout = &fzfOut + cmdFzf.Stderr = &fzfErr + + if err := cmdFzf.Start(); err != nil { + return nil, fmt.Errorf("failed to start fzf: %w", err) + } + + errRg := cmdRg.Run() - if errFzf != nil { - if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + errFzf := cmdFzf.Wait() + + if errRg != nil { + return nil, fmt.Errorf("rg command failed: %w", errRg) + } + + if errFzf != nil { + if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return []string{}, nil + } + return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) + } + + outputBytes := fzfOut.Bytes() + if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { + outputBytes = outputBytes[:len(outputBytes)-1] + } + + if len(outputBytes) == 0 { return []string{}, nil } - return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) - } - outputBytes := fzfOut.Bytes() - if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { - outputBytes = outputBytes[:len(outputBytes)-1] - } + split := bytes.Split(outputBytes, []byte{0}) + matches := make([]string, 0, len(split)) - if len(outputBytes) == 0 { - return []string{}, nil - } + for _, p := range split { + if len(p) == 0 { + continue + } + file := filepath.Join(".", string(p)) + if !skipHidden(file) { + matches = append(matches, file) + } + } - split := bytes.Split(outputBytes, []byte{0}) - matches := make([]string, 0, len(split)) + sort.SliceStable(matches, func(i, j int) bool { + if len(matches[i]) != len(matches[j]) { + return len(matches[i]) < len(matches[j]) + } + return matches[i] < matches[j] + }) - for _, p := range split { - if len(p) == 0 { - continue + return matches, nil + } else if cg.rgPath != "" { + // With only rg + logging.Info("only RG") + cmdRg := cg.rgCmd() + var rgOut bytes.Buffer + var rgErr bytes.Buffer + cmdRg.Stdout = &rgOut + cmdRg.Stderr = &rgErr + + if err := cmdRg.Run(); err != nil { + return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String()) } - matches = append(matches, string(p)) - } - sort.SliceStable(matches, func(i, j int) bool { - if len(matches[i]) != len(matches[j]) { - return len(matches[i]) < len(matches[j]) + outputBytes := rgOut.Bytes() + if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { + outputBytes = outputBytes[:len(outputBytes)-1] + } + + split := bytes.Split(outputBytes, []byte{0}) + allFiles := make([]string, 0, len(split)) + + for _, p := range split { + if len(p) == 0 { + continue + } + path := filepath.Join(".", string(p)) + if !skipHidden(path) { + allFiles = append(allFiles, path) + } + } + + matches := fuzzy.Find(query, allFiles) + + return matches, nil + + } else if cg.fzfPath != "" { + // When only fzf is available + files, err := globWithDoublestar() + if err != nil { + return nil, fmt.Errorf("failed to list files: %w", err) + } + + allFiles := make([]string, 0, len(files)) + for _, file := range files { + if !skipHidden(file) { + allFiles = append(allFiles, file) + } + } + + cmdFzf := cg.fzfCmd(query) + var fzfIn bytes.Buffer + for _, file := range allFiles { + fzfIn.WriteString(file) + fzfIn.WriteByte(0) + } + + cmdFzf.Stdin = &fzfIn + var fzfOut bytes.Buffer + var fzfErr bytes.Buffer + cmdFzf.Stdout = &fzfOut + cmdFzf.Stderr = &fzfErr + + if err := cmdFzf.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return []string{}, nil + } + return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String()) } - return matches[i] < matches[j] - }) - return matches, nil + outputBytes := fzfOut.Bytes() + if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { + outputBytes = outputBytes[:len(outputBytes)-1] + } + + split := bytes.Split(outputBytes, []byte{0}) + matches := make([]string, 0, len(split)) + + for _, p := range split { + if len(p) == 0 { + continue + } + matches = append(matches, string(p)) + } + + return matches, nil + } else { + // When neither fzf nor rg is available + allFiles, err := globWithDoublestar() + if err != nil { + return nil, fmt.Errorf("failed to glob files: %w", err) + } + + matches := fuzzy.Find(query, allFiles) + + return matches, nil + } } func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { - matches, err := getFilesRg(query) + matches, err := cg.getFiles(query) if err != nil { return nil, err } @@ -133,5 +348,18 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C } func NewFileAndFolderContextGroup() dialog.CompletionProvider { - return &filesAndFoldersContextGroup{prefix: "file"} + rgBin, err := exec.LookPath("rg") + if err != nil { + logging.Error("ripGrep not found in $PATH", err) + } + fzfBin, err := exec.LookPath("fzf") + if err != nil { + logging.Error("fzf not found in $PATH", err) + } + + return &filesAndFoldersContextGroup{ + rgPath: rgBin, + fzfPath: fzfBin, + prefix: "file", + } } diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index be0a7176..00bc8de3 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -25,7 +25,7 @@ type CompletionItemI interface { DisplayValue() string } -func (ci *CompletionItem) Render(selected bool) string { +func (ci *CompletionItem) Render(selected bool, width int) string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -33,6 +33,7 @@ func (ci *CompletionItem) Render(selected bool) string { if selected { itemStyle = itemStyle. + Width(width). Background(t.Background()). Foreground(t.Primary()). Bold(true) diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index de526466..ca376cc5 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -10,7 +10,7 @@ import ( ) type SimpleListItem interface { - Render(selected bool) string + Render(selected bool, width int) string } type SimpleList[T SimpleListItem] interface { @@ -118,7 +118,7 @@ func (c *simpleListCmp[T]) View() string { for i := startIdx; i < endIdx; i++ { item := items[i] - title := item.Render(i == c.selectedIdx) + title := item.Render(i == c.selectedIdx, maxWidth) listItems = append(listItems, title) } From c70e5b3a95124761bbfd2d34e9938a40668923fd Mon Sep 17 00:00:00 2001 From: Adictya Date: Sat, 10 May 2025 20:21:00 +0530 Subject: [PATCH 05/13] chore(complete-module): code improvements --- internal/completions/files-folders.go | 281 +++++--------------------- internal/fileutil/fileutil.go | 163 +++++++++++++++ internal/llm/tools/glob.go | 169 +++------------- internal/llm/tools/grep.go | 3 +- 4 files changed, 244 insertions(+), 372 deletions(-) create mode 100644 internal/fileutil/fileutil.go diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go index f14e2062..c66eaf1e 100644 --- a/internal/completions/files-folders.go +++ b/internal/completions/files-folders.go @@ -3,24 +3,17 @@ package completions import ( "bytes" "fmt" - "io/fs" - "os" "os/exec" "path/filepath" - "sort" - "strings" - "time" - "github.com/bmatcuk/doublestar/v4" "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/opencode-ai/opencode/internal/fileutil" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/tui/components/dialog" ) type filesAndFoldersContextGroup struct { - rgPath string - fzfPath string - prefix string + prefix string } func (cg *filesAndFoldersContextGroup) GetId() string { @@ -34,141 +27,46 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { }) } -type fileInfo struct { - path string - modTime time.Time -} - -type GlobParams struct { - Pattern string `json:"pattern"` - Path string `json:"path"` -} - -type GlobResponseMetadata struct { - NumberOfFiles int `json:"number_of_files"` - Truncated bool `json:"truncated"` -} - -func globWithDoublestar() ([]string, error) { - searchPath := "." - pattern := "**/*" - fsys := os.DirFS(searchPath) - - relPattern := strings.TrimPrefix(pattern, "/") - - var matches []fileInfo - - err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { - if d.IsDir() { - return nil - } - if skipHidden(path) { - return nil - } - - info, err := d.Info() - if err != nil { - return nil // Skip files we can't access - } - - absPath := path // Restore absolute path - if !strings.HasPrefix(absPath, searchPath) { - absPath = filepath.Join(searchPath, absPath) - } - - matches = append(matches, fileInfo{ - path: absPath, - modTime: info.ModTime(), - }) - - return nil - }) - if err != nil { - return nil, fmt.Errorf("glob walk error: %w", err) +// processNullTerminatedOutput processes output bytes with null terminators +// and returns a slice of filtered file paths +func processNullTerminatedOutput(outputBytes []byte, applyRelativePath bool) []string { + // Trim trailing null terminator if present + if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { + outputBytes = outputBytes[:len(outputBytes)-1] } - sort.Slice(matches, func(i, j int) bool { - return matches[i].modTime.After(matches[j].modTime) - }) - - results := make([]string, len(matches)) - for i, m := range matches { - results[i] = m.path - } - - return results, nil -} - -func skipHidden(path string) bool { - // Check for hidden files (starting with a dot) - base := filepath.Base(path) - if base != "." && strings.HasPrefix(base, ".") { - return true + if len(outputBytes) == 0 { + return []string{} } - // List of commonly ignored directories in development projects - commonIgnoredDirs := map[string]bool{ - "node_modules": true, - "vendor": true, - "dist": true, - "build": true, - "target": true, - ".git": true, - ".idea": true, - ".vscode": true, - "__pycache__": true, - "bin": true, - "obj": true, - "out": true, - "coverage": true, - "tmp": true, - "temp": true, - "logs": true, - "generated": true, - "bower_components": true, - "jspm_packages": true, - } + split := bytes.Split(outputBytes, []byte{0}) + matches := make([]string, 0, len(split)) - // Check if any path component is in our ignore list - parts := strings.SplitSeq(path, string(os.PathSeparator)) - for part := range parts { - if commonIgnoredDirs[part] { - return true + for _, p := range split { + if len(p) == 0 { + continue } - } - return false -} + path := string(p) + if applyRelativePath { + path = filepath.Join(".", path) // Assuming rg gives relative paths + } -func (cg *filesAndFoldersContextGroup) rgCmd() *exec.Cmd { - rgArgs := []string{ - "--files", - "-L", - "--null", + if !fileutil.SkipHidden(path) { + matches = append(matches, path) + } } - cmdRg := exec.Command(cg.rgPath, rgArgs...) - cmdRg.Dir = "." - return cmdRg -} -func (cg *filesAndFoldersContextGroup) fzfCmd(query string) *exec.Cmd { - fzfArgs := []string{ - "--filter", - query, - "--read0", - "--print0", - } - cmdFzf := exec.Command(cg.fzfPath, fzfArgs...) - cmdFzf.Dir = "." - return cmdFzf + return matches } func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { - if cg.rgPath != "" && cg.fzfPath != "" { - - cmdRg := cg.rgCmd() - cmdFzf := cg.fzfCmd(query) + cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case + cmdFzf := fileutil.GetFzfCmd(query) + var matches []string + // Case 1: Both rg and fzf available + if cmdRg != nil && cmdFzf != nil { rgPipe, err := cmdRg.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) @@ -186,54 +84,24 @@ func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) } errRg := cmdRg.Run() - errFzf := cmdFzf.Wait() if errRg != nil { - return nil, fmt.Errorf("rg command failed: %w", errRg) + logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg)) } if errFzf != nil { if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return []string{}, nil + return []string{}, nil // No matches from fzf } return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) } - outputBytes := fzfOut.Bytes() - if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { - outputBytes = outputBytes[:len(outputBytes)-1] - } - - if len(outputBytes) == 0 { - return []string{}, nil - } - - split := bytes.Split(outputBytes, []byte{0}) - matches := make([]string, 0, len(split)) - - for _, p := range split { - if len(p) == 0 { - continue - } - file := filepath.Join(".", string(p)) - if !skipHidden(file) { - matches = append(matches, file) - } - } - - sort.SliceStable(matches, func(i, j int) bool { - if len(matches[i]) != len(matches[j]) { - return len(matches[i]) < len(matches[j]) - } - return matches[i] < matches[j] - }) + matches = processNullTerminatedOutput(fzfOut.Bytes(), true) - return matches, nil - } else if cg.rgPath != "" { - // With only rg - logging.Info("only RG") - cmdRg := cg.rgCmd() + // Case 2: Only rg available + } else if cmdRg != nil { + logging.Debug("Using Ripgrep with fuzzy match fallback for file completions") var rgOut bytes.Buffer var rgErr bytes.Buffer cmdRg.Stdout = &rgOut @@ -243,43 +111,24 @@ func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String()) } - outputBytes := rgOut.Bytes() - if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { - outputBytes = outputBytes[:len(outputBytes)-1] - } - - split := bytes.Split(outputBytes, []byte{0}) - allFiles := make([]string, 0, len(split)) - - for _, p := range split { - if len(p) == 0 { - continue - } - path := filepath.Join(".", string(p)) - if !skipHidden(path) { - allFiles = append(allFiles, path) - } - } + allFiles := processNullTerminatedOutput(rgOut.Bytes(), true) + matches = fuzzy.Find(query, allFiles) - matches := fuzzy.Find(query, allFiles) - - return matches, nil - - } else if cg.fzfPath != "" { - // When only fzf is available - files, err := globWithDoublestar() + // Case 3: Only fzf available + } else if cmdFzf != nil { + logging.Debug("Using FZF with doublestar fallback for file completions") + files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) if err != nil { - return nil, fmt.Errorf("failed to list files: %w", err) + return nil, fmt.Errorf("failed to list files for fzf: %w", err) } allFiles := make([]string, 0, len(files)) for _, file := range files { - if !skipHidden(file) { + if !fileutil.SkipHidden(file) { allFiles = append(allFiles, file) } } - cmdFzf := cg.fzfCmd(query) var fzfIn bytes.Buffer for _, file := range allFiles { fzfIn.WriteString(file) @@ -299,33 +148,27 @@ func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String()) } - outputBytes := fzfOut.Bytes() - if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { - outputBytes = outputBytes[:len(outputBytes)-1] - } + matches = processNullTerminatedOutput(fzfOut.Bytes(), false) - split := bytes.Split(outputBytes, []byte{0}) - matches := make([]string, 0, len(split)) - - for _, p := range split { - if len(p) == 0 { - continue - } - matches = append(matches, string(p)) - } - - return matches, nil + // Case 4: Fallback to doublestar with fuzzy match } else { - // When neither fzf nor rg is available - allFiles, err := globWithDoublestar() + logging.Debug("Using doublestar with fuzzy match for file completions") + allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) if err != nil { return nil, fmt.Errorf("failed to glob files: %w", err) } - matches := fuzzy.Find(query, allFiles) + filteredFiles := make([]string, 0, len(allFiles)) + for _, file := range allFiles { + if !fileutil.SkipHidden(file) { + filteredFiles = append(filteredFiles, file) + } + } - return matches, nil + matches = fuzzy.Find(query, filteredFiles) } + + return matches, nil } func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { @@ -335,7 +178,6 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C } items := make([]dialog.CompletionItemI, 0, len(matches)) - for _, file := range matches { item := dialog.NewCompletionItem(dialog.CompletionItem{ Title: file, @@ -348,18 +190,7 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C } func NewFileAndFolderContextGroup() dialog.CompletionProvider { - rgBin, err := exec.LookPath("rg") - if err != nil { - logging.Error("ripGrep not found in $PATH", err) - } - fzfBin, err := exec.LookPath("fzf") - if err != nil { - logging.Error("fzf not found in $PATH", err) - } - return &filesAndFoldersContextGroup{ - rgPath: rgBin, - fzfPath: fzfBin, - prefix: "file", + prefix: "file", } } diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go new file mode 100644 index 00000000..1883f185 --- /dev/null +++ b/internal/fileutil/fileutil.go @@ -0,0 +1,163 @@ +package fileutil + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/bmatcuk/doublestar/v4" + "github.com/opencode-ai/opencode/internal/logging" +) + +var ( + rgPath string + fzfPath string +) + +func init() { + var err error + rgPath, err = exec.LookPath("rg") + if err != nil { + logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.") + rgPath = "" + } + fzfPath, err = exec.LookPath("fzf") + if err != nil { + logging.Warn("FZF not found in $PATH. Some features might be limited or slower.") + fzfPath = "" + } +} + +func GetRgCmd(globPattern string) *exec.Cmd { + if rgPath == "" { + return nil + } + rgArgs := []string{ + "--files", + "-L", + "--null", + } + if globPattern != "" { + if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") { + globPattern = "/" + globPattern + } + rgArgs = append(rgArgs, "--glob", globPattern) + } + cmd := exec.Command(rgPath, rgArgs...) + cmd.Dir = "." + return cmd +} + +func GetFzfCmd(query string) *exec.Cmd { + if fzfPath == "" { + return nil + } + fzfArgs := []string{ + "--filter", + query, + "--read0", + "--print0", + } + cmd := exec.Command(fzfPath, fzfArgs...) + cmd.Dir = "." + return cmd +} + +type FileInfo struct { + Path string + ModTime time.Time +} + +func SkipHidden(path string) bool { + // Check for hidden files (starting with a dot) + base := filepath.Base(path) + if base != "." && strings.HasPrefix(base, ".") { + return true + } + + commonIgnoredDirs := map[string]bool{ + ".opencode": true, + "node_modules": true, + "vendor": true, + "dist": true, + "build": true, + "target": true, + ".git": true, + ".idea": true, + ".vscode": true, + "__pycache__": true, + "bin": true, + "obj": true, + "out": true, + "coverage": true, + "tmp": true, + "temp": true, + "logs": true, + "generated": true, + "bower_components": true, + "jspm_packages": true, + } + + parts := strings.Split(path, string(os.PathSeparator)) + for _, part := range parts { + if commonIgnoredDirs[part] { + return true + } + } + return false +} + +func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) { + fsys := os.DirFS(searchPath) + relPattern := strings.TrimPrefix(pattern, "/") + var matches []FileInfo + + err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { + if d.IsDir() { + return nil + } + if SkipHidden(path) { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + absPath := path + if !strings.HasPrefix(absPath, searchPath) && searchPath != "." { + absPath = filepath.Join(searchPath, absPath) + } else if !strings.HasPrefix(absPath, "/") && searchPath == "." { + absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly + } + + matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()}) + if limit > 0 && len(matches) >= limit*2 { + return fs.SkipAll + } + return nil + }) + if err != nil { + return nil, false, fmt.Errorf("glob walk error: %w", err) + } + + sort.Slice(matches, func(i, j int) bool { + return matches[i].ModTime.After(matches[j].ModTime) + }) + + truncated := false + if limit > 0 && len(matches) > limit { + matches = matches[:limit] + truncated = true + } + + results := make([]string, len(matches)) + for i, m := range matches { + results[i] = m.Path + } + return results, truncated, nil +} diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go index d62b3a43..c7795989 100644 --- a/internal/llm/tools/glob.go +++ b/internal/llm/tools/glob.go @@ -5,16 +5,13 @@ import ( "context" "encoding/json" "fmt" - "io/fs" - "os" "os/exec" "path/filepath" "sort" "strings" - "time" - "github.com/bmatcuk/doublestar/v4" "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/fileutil" ) const ( @@ -36,7 +33,7 @@ GLOB PATTERN SYNTAX: - '**' matches any sequence of characters, including separators - '?' matches any single non-separator character - '[...]' matches any character in the brackets -- '[!...]' matches any character not in the brackets +- '[!...] matches any character not in the brackets COMMON PATTERN EXAMPLES: - '*.js' - Find all JavaScript files in the current directory @@ -55,11 +52,6 @@ TIPS: - Always check if results are truncated and refine your search pattern if needed` ) -type fileInfo struct { - path string - modTime time.Time -} - type GlobParams struct { Pattern string `json:"pattern"` Path string `json:"path"` @@ -134,47 +126,27 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) } func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { - matches, err := globWithRipgrep(pattern, searchPath, limit) - if err == nil { - return matches, len(matches) >= limit, nil + cmdRg := fileutil.GetRgCmd(pattern) + if cmdRg != nil { + cmdRg.Dir = searchPath // Ensure rg runs in the correct directory + matches, err := runRipgrep(cmdRg, searchPath, limit) + if err == nil { + return matches, len(matches) >= limit && limit > 0, nil + } + // If rg failed, log it but fall through to doublestar + // logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err)) } - return globWithDoublestar(pattern, searchPath, limit) + return fileutil.GlobWithDoublestar(pattern, searchPath, limit) } -func globWithRipgrep( - pattern, searchRoot string, - limit int, -) ([]string, error) { - if searchRoot == "" { - searchRoot = "." - } - - rgBin, err := exec.LookPath("rg") - if err != nil { - return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err) - } - - if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") { - pattern = "/" + pattern - } - - args := []string{ - "--files", - "--null", - "--glob", pattern, - "-L", - } - - cmd := exec.Command(rgBin, args...) - cmd.Dir = searchRoot - +func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) { out, err := cmd.CombinedOutput() if err != nil { if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { - return nil, nil + return nil, nil // No matches found is not an error for rg } - return nil, fmt.Errorf("ripgrep: %w\n%s", err, out) + return nil, fmt.Errorf("ripgrep execution failed: %w. Output: %s", err, string(out)) } var matches []string @@ -182,117 +154,22 @@ func globWithRipgrep( if len(p) == 0 { continue } - abs := filepath.Join(searchRoot, string(p)) - if skipHidden(abs) { + absPath := string(p) + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(searchRoot, absPath) + } + if fileutil.SkipHidden(absPath) { continue } - matches = append(matches, abs) + matches = append(matches, absPath) } sort.SliceStable(matches, func(i, j int) bool { - return len(matches[i]) < len(matches[j]) + return len(matches[i]) < len(matches[j]) // Shorter paths first }) - if len(matches) > limit { + if limit > 0 && len(matches) > limit { matches = matches[:limit] } return matches, nil } - -func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) { - fsys := os.DirFS(searchPath) - - relPattern := strings.TrimPrefix(pattern, "/") - - var matches []fileInfo - - err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { - if d.IsDir() { - return nil - } - if skipHidden(path) { - return nil - } - - info, err := d.Info() - if err != nil { - return nil // Skip files we can't access - } - - absPath := path // Restore absolute path - if !strings.HasPrefix(absPath, searchPath) { - absPath = filepath.Join(searchPath, absPath) - } - - matches = append(matches, fileInfo{ - path: absPath, - modTime: info.ModTime(), - }) - - if len(matches) >= limit*2 { // Collect more than needed for sorting - return fs.SkipAll - } - - return nil - }) - if err != nil { - return nil, false, fmt.Errorf("glob walk error: %w", err) - } - - sort.Slice(matches, func(i, j int) bool { - return matches[i].modTime.After(matches[j].modTime) - }) - - truncated := len(matches) > limit - if truncated { - matches = matches[:limit] - } - - results := make([]string, len(matches)) - for i, m := range matches { - results[i] = m.path - } - - return results, truncated, nil -} - -func skipHidden(path string) bool { - // Check for hidden files (starting with a dot) - base := filepath.Base(path) - if base != "." && strings.HasPrefix(base, ".") { - return true - } - - // List of commonly ignored directories in development projects - commonIgnoredDirs := map[string]bool{ - "node_modules": true, - "vendor": true, - "dist": true, - "build": true, - "target": true, - ".git": true, - ".idea": true, - ".vscode": true, - "__pycache__": true, - "bin": true, - "obj": true, - "out": true, - "coverage": true, - "tmp": true, - "temp": true, - "logs": true, - "generated": true, - "bower_components": true, - "jspm_packages": true, - } - - // Check if any path component is in our ignore list - parts := strings.SplitSeq(path, string(os.PathSeparator)) - for part := range parts { - if commonIgnoredDirs[part] { - return true - } - } - - return false -} diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index 0dd42304..f20d61ef 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -15,6 +15,7 @@ import ( "time" "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/fileutil" ) type GrepParams struct { @@ -288,7 +289,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return nil // Skip directories } - if skipHidden(path) { + if fileutil.SkipHidden(path) { return nil } From 3fd922985f2c6bc45d1a0c44c219c8921bbc4836 Mon Sep 17 00:00:00 2001 From: Adictya Date: Sat, 10 May 2025 20:31:56 +0530 Subject: [PATCH 06/13] chore(complete-module): cleanup --- go.mod | 8 ++++---- internal/completions/files-folders.go | 15 +++++---------- internal/fileutil/fileutil.go | 3 --- internal/tui/components/chat/editor.go | 1 + 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 628bbd86..c2046e09 100644 --- a/go.mod +++ b/go.mod @@ -35,8 +35,6 @@ require ( require ( cloud.google.com/go v0.116.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect cloud.google.com/go/auth v0.13.0 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect @@ -72,14 +70,16 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lithammer/fuzzysearch v1.1.8 + github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go index c66eaf1e..af1b5a87 100644 --- a/internal/completions/files-folders.go +++ b/internal/completions/files-folders.go @@ -27,10 +27,7 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { }) } -// processNullTerminatedOutput processes output bytes with null terminators -// and returns a slice of filtered file paths -func processNullTerminatedOutput(outputBytes []byte, applyRelativePath bool) []string { - // Trim trailing null terminator if present +func processNullTerminatedOutput(outputBytes []byte) []string { if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { outputBytes = outputBytes[:len(outputBytes)-1] } @@ -48,9 +45,7 @@ func processNullTerminatedOutput(outputBytes []byte, applyRelativePath bool) []s } path := string(p) - if applyRelativePath { - path = filepath.Join(".", path) // Assuming rg gives relative paths - } + path = filepath.Join(".", path) if !fileutil.SkipHidden(path) { matches = append(matches, path) @@ -97,7 +92,7 @@ func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) } - matches = processNullTerminatedOutput(fzfOut.Bytes(), true) + matches = processNullTerminatedOutput(fzfOut.Bytes()) // Case 2: Only rg available } else if cmdRg != nil { @@ -111,7 +106,7 @@ func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String()) } - allFiles := processNullTerminatedOutput(rgOut.Bytes(), true) + allFiles := processNullTerminatedOutput(rgOut.Bytes()) matches = fuzzy.Find(query, allFiles) // Case 3: Only fzf available @@ -148,7 +143,7 @@ func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String()) } - matches = processNullTerminatedOutput(fzfOut.Bytes(), false) + matches = processNullTerminatedOutput(fzfOut.Bytes()) // Case 4: Fallback to doublestar with fuzzy match } else { diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 1883f185..6f6887cd 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -43,9 +43,6 @@ func GetRgCmd(globPattern string) *exec.Cmd { "--null", } if globPattern != "" { - if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") { - globPattern = "/" + globPattern - } rgArgs = append(rgArgs, "--glob", globPattern) } cmd := exec.Command(rgPath, rgArgs...) diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 559c85b7..1ff12bcf 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" From 98139ba5dd2884d9a34841beb3f6816ba55c6c29 Mon Sep 17 00:00:00 2001 From: Adictya Date: Sat, 10 May 2025 21:07:45 +0530 Subject: [PATCH 07/13] fix(complete-module): dialog keys cleanup --- internal/fileutil/fileutil.go | 3 + internal/tui/components/dialog/complete.go | 106 ++++++++++----------- internal/tui/page/chat.go | 9 ++ 3 files changed, 63 insertions(+), 55 deletions(-) diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 6f6887cd..1883f185 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -43,6 +43,9 @@ func GetRgCmd(globPattern string) *exec.Cmd { "--null", } if globPattern != "" { + if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") { + globPattern = "/" + globPattern + } rgArgs = append(rgArgs, "--glob", globPattern) } cmd := exec.Command(rgPath, rgArgs...) diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index 00bc8de3..2a86a9a9 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -75,6 +75,10 @@ type CompletionDialogCompleteItemMsg struct { type CompletionDialogCloseMsg struct{} +type CompletionDialogInterruptUpdateMsg struct { + InterrupCmd tea.Cmd +} + type CompletionDialog interface { tea.Model layout.Bindings @@ -82,64 +86,42 @@ type CompletionDialog interface { } type completionDialogCmp struct { - query string - completionProvider CompletionProvider - width int - height int - searchTextArea textarea.Model - listView utilComponents.SimpleList[CompletionItemI] + query string + completionProvider CompletionProvider + width int + height int + pseudoSearchTextArea textarea.Model + listView utilComponents.SimpleList[CompletionItemI] } type completionDialogKeyMap struct { - Up key.Binding - Down key.Binding Enter key.Binding - Tab key.Binding - Space key.Binding + Complete key.Binding + Cancel key.Binding Backspace key.Binding Escape key.Binding J key.Binding K key.Binding - At key.Binding + Start key.Binding } var completionDialogKeys = completionDialogKeyMap{ - At: key.NewBinding( + Start: key.NewBinding( key.WithKeys("@"), ), Backspace: key.NewBinding( key.WithKeys("backspace"), ), - Tab: key.NewBinding( + Complete: key.NewBinding( key.WithKeys("tab"), ), - Space: key.NewBinding( - key.WithKeys(" "), - ), - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "previous item"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "next item"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select item"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), - J: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next item"), - ), - K: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous item"), + Cancel: key.NewBinding( + key.WithKeys(" ", "esc"), ), + // Enter: key.NewBinding( + // key.WithKeys("enter"), + // key.WithHelp("enter", "select item"), + // ), } func (c *completionDialogCmp) Init() tea.Cmd { @@ -147,7 +129,7 @@ func (c *completionDialogCmp) Init() tea.Cmd { } func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { - value := c.searchTextArea.Value() + value := c.pseudoSearchTextArea.Value() if value == "" { return nil @@ -164,8 +146,8 @@ func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { func (c *completionDialogCmp) close() tea.Cmd { // c.listView.Reset() - c.searchTextArea.Reset() - c.searchTextArea.Blur() + c.pseudoSearchTextArea.Reset() + c.pseudoSearchTextArea.Blur() return util.CmdHandler(CompletionDialogCloseMsg{}) } @@ -174,13 +156,13 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: - if c.searchTextArea.Focused() { + if c.pseudoSearchTextArea.Focused() { var cmd tea.Cmd - c.searchTextArea, cmd = c.searchTextArea.Update(msg) + c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg) cmds = append(cmds, cmd) var query string - query = c.searchTextArea.Value() + query = c.pseudoSearchTextArea.Value() if query != "" { query = query[1:] } @@ -202,7 +184,21 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch { - case key.Matches(msg, completionDialogKeys.Tab): + // case key.Matches(msg, completionDialogKeys.Enter): + // logging.Info("InterrupCmd1") + // item, i := c.listView.GetSelectedItem() + // if i == -1 { + // logging.Info("InterrupCmd2", "i", i) + // return c, nil + // } + // + // cmd := c.complete(item) + // + // logging.Info("InterrupCmd") + // return c, util.CmdHandler(CompletionDialogInterruptUpdateMsg{ + // InterrupCmd: cmd, + // }) + case key.Matches(msg, completionDialogKeys.Complete): item, i := c.listView.GetSelectedItem() if i == -1 { return c, nil @@ -211,17 +207,17 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := c.complete(item) return c, cmd - case key.Matches(msg, completionDialogKeys.Escape) || key.Matches(msg, completionDialogKeys.Space) || - (key.Matches(msg, completionDialogKeys.Backspace) && len(c.searchTextArea.Value()) <= 0): + case key.Matches(msg, completionDialogKeys.Cancel) || + (key.Matches(msg, completionDialogKeys.Backspace) && len(c.pseudoSearchTextArea.Value()) <= 0): return c, c.close() } return c, tea.Batch(cmds...) } switch { - case key.Matches(msg, completionDialogKeys.At): - c.searchTextArea.SetValue("@") - return c, c.searchTextArea.Focus() + case key.Matches(msg, completionDialogKeys.Start): + c.pseudoSearchTextArea.SetValue(msg.String()) + return c, c.pseudoSearchTextArea.Focus() } case tea.WindowSizeMsg: c.width = msg.Width @@ -265,9 +261,9 @@ func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDia li := utilComponents.NewSimpleList(items) return &completionDialogCmp{ - query: "", - completionProvider: completionProvider, - searchTextArea: ti, - listView: li, + query: "", + completionProvider: completionProvider, + pseudoSearchTextArea: ti, + listView: li, } } diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 5dc79633..a0831197 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/completions" + "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/chat" @@ -111,6 +112,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if p.showContextDialog { context, contextCmd := p.contextDialog.Update(msg) p.contextDialog = context.(dialog.CompletionDialog) + + // Handles enter key + if interruptMessasge, ok := msg.(dialog.CompletionDialogInterruptUpdateMsg); ok { + logging.Info("Interrupted") + cmds = append(cmds, interruptMessasge.InterrupCmd) + return p, tea.Batch(cmds...) + } + cmds = append(cmds, contextCmd) } From 34212311c8cd688ee9cd3edd72aea2f0f1ad59ea Mon Sep 17 00:00:00 2001 From: Adictya Date: Sat, 10 May 2025 21:14:42 +0530 Subject: [PATCH 08/13] fix(simple-list): add fallback message --- internal/tui/components/dialog/complete.go | 13 +++++++++++-- internal/tui/components/util/simple-list.go | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index 2a86a9a9..65ad948a 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -145,7 +145,7 @@ func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { } func (c *completionDialogCmp) close() tea.Cmd { - // c.listView.Reset() + c.listView.SetItems([]CompletionItemI{}) c.pseudoSearchTextArea.Reset() c.pseudoSearchTextArea.Blur() @@ -216,6 +216,12 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch { case key.Matches(msg, completionDialogKeys.Start): + items, err := c.completionProvider.GetChildEntries("") + if err != nil { + logging.Error("Failed to get child entries", err) + } + + c.listView.SetItems(items) c.pseudoSearchTextArea.SetValue(msg.String()) return c, c.pseudoSearchTextArea.Focus() } @@ -258,7 +264,10 @@ func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDia logging.Error("Failed to get child entries", err) } - li := utilComponents.NewSimpleList(items) + li := utilComponents.NewSimpleList( + items, + "No file matches found", + ) return &completionDialogCmp{ query: "", diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index ca376cc5..f1617338 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -21,6 +21,7 @@ type SimpleList[T SimpleListItem] interface { } type simpleListCmp[T SimpleListItem] struct { + fallbackMsg string items []T selectedIdx int width int @@ -100,7 +101,11 @@ func (c *simpleListCmp[T]) View() string { startIdx := 0 if len(items) <= 0 { - return "No content available" + return baseStyle. + Background(t.Background()). + Padding(0, 1). + Width(maxWidth). + Render(c.fallbackMsg) } if len(items) > maxVisibleItems { @@ -135,8 +140,9 @@ func (c *simpleListCmp[T]) View() string { ) } -func NewSimpleList[T SimpleListItem](items []T) SimpleList[T] { +func NewSimpleList[T SimpleListItem](items []T, fallbackMsg string) SimpleList[T] { return &simpleListCmp[T]{ + fallbackMsg: fallbackMsg, items: items, selectedIdx: 0, } From 36446a84adf949bd403cc87db34fbbbad7834602 Mon Sep 17 00:00:00 2001 From: Adictya Date: Sun, 11 May 2025 14:48:57 +0530 Subject: [PATCH 09/13] fix(commands-dialog): refactor to use simple-list --- internal/tui/components/dialog/commands.go | 190 +++++++------------- internal/tui/components/dialog/complete.go | 19 +- internal/tui/components/util/simple-list.go | 47 ++--- 3 files changed, 102 insertions(+), 154 deletions(-) diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go index c725f020..6cce90d3 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/opencode-ai/opencode/internal/tui/components/util" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/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") - } - // 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 - } - } + maxWidth := 40 - // 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 - } - } + commands := c.listView.GetItems() - 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,17 @@ 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", + ) 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 index 65ad948a..c45793cb 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -29,11 +29,12 @@ func (ci *CompletionItem) Render(selected bool, width int) string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() - itemStyle := baseStyle + itemStyle := baseStyle. + Width(width). + Padding(0, 1) if selected { itemStyle = itemStyle. - Width(width). Background(t.Background()). Foreground(t.Primary()). Bold(true) @@ -237,6 +238,19 @@ func (c *completionDialogCmp) View() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() + maxWidth := 40 + + commands := c.listView.GetItems() + + for _, cmd := range commands { + 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). @@ -266,6 +280,7 @@ func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDia li := utilComponents.NewSimpleList( items, + 7, "No file matches found", ) diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index f1617338..48f0f3d1 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -16,16 +16,20 @@ type SimpleListItem interface { 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 - width int - height int + fallbackMsg string + items []T + selectedIdx int + maxWidth int + maxVisibleItems int + width int + height int } type simpleListKeyMap struct { @@ -91,13 +95,21 @@ func (c *simpleListCmp[T]) SetItems(items []T) { 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 := 80 - maxVisibleItems := 7 + maxWidth := c.maxWidth + maxVisibleItems := min(c.maxVisibleItems, len(items)) startIdx := 0 if len(items) <= 0 { @@ -127,23 +139,14 @@ func (c *simpleListCmp[T]) View() string { listItems = append(listItems, title) } - return lipgloss.JoinVertical( - lipgloss.Left, - baseStyle. - Background(t.Background()). - Width(maxWidth). - Padding(0, 1). - Render( - lipgloss. - JoinVertical(lipgloss.Left, listItems...), - ), - ) + return lipgloss.JoinVertical(lipgloss.Left, listItems...) } -func NewSimpleList[T SimpleListItem](items []T, fallbackMsg string) SimpleList[T] { +func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string) SimpleList[T] { return &simpleListCmp[T]{ - fallbackMsg: fallbackMsg, - items: items, - selectedIdx: 0, + fallbackMsg: fallbackMsg, + items: items, + maxVisibleItems: maxVisibleItems, + selectedIdx: 0, } } From 925bd67e9fd416c2e88a565b67f88756a238c0fd Mon Sep 17 00:00:00 2001 From: Adictya Date: Sun, 11 May 2025 14:57:42 +0530 Subject: [PATCH 10/13] fix(simple-list): add j and k keys --- internal/tui/components/dialog/commands.go | 1 + internal/tui/components/dialog/complete.go | 1 + internal/tui/components/util/simple-list.go | 46 +++++++++++++-------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go index 6cce90d3..25069b8a 100644 --- a/internal/tui/components/dialog/commands.go +++ b/internal/tui/components/dialog/commands.go @@ -172,6 +172,7 @@ func NewCommandDialogCmp() CommandDialog { []Command{}, 10, "No commands available", + true, ) return &commandDialogCmp{ listView: listView, diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index c45793cb..8650453b 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -282,6 +282,7 @@ func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDia items, 7, "No file matches found", + false, ) return &completionDialogCmp{ diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index 48f0f3d1..e1c9478f 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -23,19 +23,22 @@ type SimpleList[T SimpleListItem] interface { } type simpleListCmp[T SimpleListItem] struct { - fallbackMsg string - items []T - selectedIdx int - maxWidth int - maxVisibleItems int - width int - height int + 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 - Enter key.Binding + Up key.Binding + Down key.Binding + UpAlpha key.Binding + DownAlpha key.Binding + Enter key.Binding } var simpleListKeys = simpleListKeyMap{ @@ -47,6 +50,14 @@ var simpleListKeys = simpleListKeyMap{ key.WithKeys("down"), key.WithHelp("↓", "next list item"), ), + UpAlpha: key.NewBinding( + key.WithKeys("k"), + key.WithHelp("↑", "previous list item"), + ), + DownAlpha: key.NewBinding( + key.WithKeys("j"), + key.WithHelp("↓", "next list item"), + ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "select item"), @@ -61,12 +72,12 @@ 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): + 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): + case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)): if c.selectedIdx < len(c.items)-1 { c.selectedIdx++ } @@ -142,11 +153,12 @@ func (c *simpleListCmp[T]) View() string { return lipgloss.JoinVertical(lipgloss.Left, listItems...) } -func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string) SimpleList[T] { +func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] { return &simpleListCmp[T]{ - fallbackMsg: fallbackMsg, - items: items, - maxVisibleItems: maxVisibleItems, - selectedIdx: 0, + fallbackMsg: fallbackMsg, + items: items, + maxVisibleItems: maxVisibleItems, + useAlphaNumericKeys: useAlphaNumericKeys, + selectedIdx: 0, } } From 0affc06c1161fcb5c0415360daa71920c29c3c88 Mon Sep 17 00:00:00 2001 From: Adictya Date: Sun, 11 May 2025 16:19:27 +0530 Subject: [PATCH 11/13] fix(complete-module): cleanup and minor bug fixes --- internal/tui/components/chat/editor.go | 4 +- internal/tui/components/dialog/complete.go | 98 ++++++++-------------- internal/tui/page/chat.go | 14 ++-- 3 files changed, 44 insertions(+), 72 deletions(-) diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 1ff12bcf..fcb32349 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -198,7 +198,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.deleteMode = false return m, nil } - // Handle Enter key if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) { value := m.textarea.Value() if len(value) > 0 && value[len(value)-1] == '\\' { @@ -206,8 +205,9 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.SetValue(value[:len(value)-1] + "\n") return m, nil } else { + logging.Info("Interrupt failed") // Otherwise, send the message - return m, m.send() + // return m, m.send() } } diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index 8650453b..da3256de 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -96,33 +96,17 @@ type completionDialogCmp struct { } type completionDialogKeyMap struct { - Enter key.Binding - Complete key.Binding - Cancel key.Binding - Backspace key.Binding - Escape key.Binding - J key.Binding - K key.Binding - Start key.Binding + Complete key.Binding + Cancel key.Binding } var completionDialogKeys = completionDialogKeyMap{ - Start: key.NewBinding( - key.WithKeys("@"), - ), - Backspace: key.NewBinding( - key.WithKeys("backspace"), - ), Complete: key.NewBinding( - key.WithKeys("tab"), + key.WithKeys("tab", "enter"), ), Cancel: key.NewBinding( - key.WithKeys(" ", "esc"), + key.WithKeys(" ", "esc", "backspace"), ), - // Enter: key.NewBinding( - // key.WithKeys("enter"), - // key.WithHelp("enter", "select item"), - // ), } func (c *completionDialogCmp) Init() tea.Cmd { @@ -158,47 +142,37 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if c.pseudoSearchTextArea.Focused() { - 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) + 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:] } - c.listView.SetItems(items) - c.query = query - } + 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) + } - u, cmd := c.listView.Update(msg) - c.listView = u.(utilComponents.SimpleList[CompletionItemI]) + c.listView.SetItems(items) + c.query = query + } + + u, cmd := c.listView.Update(msg) + c.listView = u.(utilComponents.SimpleList[CompletionItemI]) - cmds = append(cmds, cmd) + cmds = append(cmds, cmd) + } switch { - // case key.Matches(msg, completionDialogKeys.Enter): - // logging.Info("InterrupCmd1") - // item, i := c.listView.GetSelectedItem() - // if i == -1 { - // logging.Info("InterrupCmd2", "i", i) - // return c, nil - // } - // - // cmd := c.complete(item) - // - // logging.Info("InterrupCmd") - // return c, util.CmdHandler(CompletionDialogInterruptUpdateMsg{ - // InterrupCmd: cmd, - // }) case key.Matches(msg, completionDialogKeys.Complete): item, i := c.listView.GetSelectedItem() if i == -1 { @@ -208,15 +182,15 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := c.complete(item) return c, cmd - case key.Matches(msg, completionDialogKeys.Cancel) || - (key.Matches(msg, completionDialogKeys.Backspace) && len(c.pseudoSearchTextArea.Value()) <= 0): - return c, c.close() + 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...) - } - switch { - case key.Matches(msg, completionDialogKeys.Start): + } else { items, err := c.completionProvider.GetChildEntries("") if err != nil { logging.Error("Failed to get child entries", err) @@ -240,9 +214,9 @@ func (c *completionDialogCmp) View() string { maxWidth := 40 - commands := c.listView.GetItems() + completions := c.listView.GetItems() - for _, cmd := range commands { + for _, cmd := range completions { title := cmd.DisplayValue() if len(title) > maxWidth-4 { maxWidth = len(title) + 4 diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index a0831197..40227b8f 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -8,7 +8,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/completions" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/chat" @@ -112,15 +111,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if p.showContextDialog { context, contextCmd := p.contextDialog.Update(msg) p.contextDialog = context.(dialog.CompletionDialog) + cmds = append(cmds, contextCmd) - // Handles enter key - if interruptMessasge, ok := msg.(dialog.CompletionDialogInterruptUpdateMsg); ok { - logging.Info("Interrupted") - cmds = append(cmds, interruptMessasge.InterrupCmd) - return p, tea.Batch(cmds...) + // Doesn't forward event if enter key is pressed + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "enter" { + return p, tea.Batch(cmds...) + } } - - cmds = append(cmds, contextCmd) } u, cmd := p.layout.Update(msg) From 168061ae00a5ac3168c288104550a2f9c3a74800 Mon Sep 17 00:00:00 2001 From: Adictya Date: Sun, 11 May 2025 16:39:58 +0530 Subject: [PATCH 12/13] fix(complete-module): self review --- internal/llm/tools/glob.go | 14 +- internal/tui/components/chat/editor.go | 4 +- .../tui/components/dialog/commands-ref.txt | 249 ++++++++++++++++++ internal/tui/components/dialog/complete.go | 4 - internal/tui/components/util/simple-list.go | 9 +- internal/tui/page/chat.go | 54 ++-- 6 files changed, 287 insertions(+), 47 deletions(-) create mode 100644 internal/tui/components/dialog/commands-ref.txt diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go index c7795989..9894d9ba 100644 --- a/internal/llm/tools/glob.go +++ b/internal/llm/tools/glob.go @@ -12,6 +12,7 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/fileutil" + "github.com/opencode-ai/opencode/internal/logging" ) const ( @@ -33,7 +34,7 @@ GLOB PATTERN SYNTAX: - '**' matches any sequence of characters, including separators - '?' matches any single non-separator character - '[...]' matches any character in the brackets -- '[!...] matches any character not in the brackets +- '[!...]' matches any character not in the brackets COMMON PATTERN EXAMPLES: - '*.js' - Find all JavaScript files in the current directory @@ -128,13 +129,12 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { cmdRg := fileutil.GetRgCmd(pattern) if cmdRg != nil { - cmdRg.Dir = searchPath // Ensure rg runs in the correct directory + cmdRg.Dir = searchPath matches, err := runRipgrep(cmdRg, searchPath, limit) if err == nil { return matches, len(matches) >= limit && limit > 0, nil } - // If rg failed, log it but fall through to doublestar - // logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err)) + logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err)) } return fileutil.GlobWithDoublestar(pattern, searchPath, limit) @@ -144,9 +144,9 @@ func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) { out, err := cmd.CombinedOutput() if err != nil { if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { - return nil, nil // No matches found is not an error for rg + return nil, nil } - return nil, fmt.Errorf("ripgrep execution failed: %w. Output: %s", err, string(out)) + return nil, fmt.Errorf("ripgrep: %w\n%s", err, out) } var matches []string @@ -165,7 +165,7 @@ func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) { } sort.SliceStable(matches, func(i, j int) bool { - return len(matches[i]) < len(matches[j]) // Shorter paths first + return len(matches[i]) < len(matches[j]) }) if limit > 0 && len(matches) > limit { diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index fcb32349..a6c5a44e 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -198,6 +198,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.deleteMode = false return m, nil } + // Hanlde Enter key if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) { value := m.textarea.Value() if len(value) > 0 && value[len(value)-1] == '\\' { @@ -205,9 +206,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.SetValue(value[:len(value)-1] + "\n") return m, nil } else { - logging.Info("Interrupt failed") // Otherwise, send the message - // return m, m.send() + return m, m.send() } } diff --git a/internal/tui/components/dialog/commands-ref.txt b/internal/tui/components/dialog/commands-ref.txt new file mode 100644 index 00000000..c725f020 --- /dev/null +++ b/internal/tui/components/dialog/commands-ref.txt @@ -0,0 +1,249 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +// Command represents a command that can be executed +type Command struct { + ID string + Title string + Description string + Handler func(cmd Command) tea.Cmd +} + +// CommandSelectedMsg is sent when a command is selected +type CommandSelectedMsg struct { + Command Command +} + +// CloseCommandDialogMsg is sent when the command dialog is closed +type CloseCommandDialogMsg struct{} + +// CommandDialog interface for the command selection dialog +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 +} + +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"), + ), + Escape: key.NewBinding( + 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 +} + +func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, 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 { + return c, util.CmdHandler(CommandSelectedMsg{ + Command: c.commands[c.selectedIdx], + }) + } + case key.Matches(msg, commandKeys.Escape): + return c, util.CmdHandler(CloseCommandDialogMsg{}) + } + case tea.WindowSizeMsg: + c.width = msg.Width + c.height = msg.Height + } + return c, nil +} + +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") + } + + // 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 + } + } + + // 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()) + } + + 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) + } + } + + title := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(maxWidth). + Padding(0, 1). + Render("Commands") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + baseStyle.Width(maxWidth).Render(""), + baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)), + baseStyle.Width(maxWidth).Render(""), + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (c *commandDialogCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(commandKeys) +} + +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 + } + } + } +} + +// NewCommandDialogCmp creates a new command selection dialog +func NewCommandDialogCmp() CommandDialog { + return &commandDialogCmp{ + commands: []Command{}, + selectedIdx: 0, + selectedCommandID: "", + } +} diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index da3256de..1ce66e12 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -76,10 +76,6 @@ type CompletionDialogCompleteItemMsg struct { type CompletionDialogCloseMsg struct{} -type CompletionDialogInterruptUpdateMsg struct { - InterrupCmd tea.Cmd -} - type CompletionDialog interface { tea.Model layout.Bindings diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index e1c9478f..7aad2494 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -38,7 +38,6 @@ type simpleListKeyMap struct { Down key.Binding UpAlpha key.Binding DownAlpha key.Binding - Enter key.Binding } var simpleListKeys = simpleListKeyMap{ @@ -52,15 +51,11 @@ var simpleListKeys = simpleListKeyMap{ ), UpAlpha: key.NewBinding( key.WithKeys("k"), - key.WithHelp("↑", "previous list item"), + key.WithHelp("k", "previous list item"), ), DownAlpha: key.NewBinding( key.WithKeys("j"), - key.WithHelp("↓", "next list item"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select item"), + key.WithHelp("j", "next list item"), ), } diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 40227b8f..1804990f 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -19,25 +19,25 @@ import ( var ChatPage PageID = "chat" type chatPage struct { - app *app.App - editor layout.Container - messages layout.Container - layout layout.SplitPaneLayout - session session.Session - contextDialog dialog.CompletionDialog - showContextDialog bool + app *app.App + editor layout.Container + messages layout.Container + layout layout.SplitPaneLayout + session session.Session + completionDialog dialog.CompletionDialog + showCompletionDialog bool } type ChatKeyMap struct { - ShowContextDialog key.Binding - NewSession key.Binding - Cancel key.Binding + ShowCompletionDialog key.Binding + NewSession key.Binding + Cancel key.Binding } var keyMap = ChatKeyMap{ - ShowContextDialog: key.NewBinding( + ShowCompletionDialog: key.NewBinding( key.WithKeys("@"), - key.WithHelp("@", "context"), + key.WithHelp("@", "Complete"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), @@ -52,7 +52,7 @@ var keyMap = ChatKeyMap{ func (p *chatPage) Init() tea.Cmd { cmds := []tea.Cmd{ p.layout.Init(), - p.contextDialog.Init(), + p.completionDialog.Init(), } return tea.Batch(cmds...) } @@ -64,7 +64,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := p.layout.SetSize(msg.Width, msg.Height) cmds = append(cmds, cmd) case dialog.CompletionDialogCloseMsg: - p.showContextDialog = false + p.showCompletionDialog = false case chat.SendMsg: cmd := p.sendMessage(msg.Text, msg.Attachments) if cmd != nil { @@ -90,8 +90,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.session = msg case tea.KeyMsg: switch { - case key.Matches(msg, keyMap.ShowContextDialog): - p.showContextDialog = true + case key.Matches(msg, keyMap.ShowCompletionDialog): + p.showCompletionDialog = true // Continue sending keys to layout->chat case key.Matches(msg, keyMap.NewSession): p.session = session.Session{} @@ -108,9 +108,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - if p.showContextDialog { - context, contextCmd := p.contextDialog.Update(msg) - p.contextDialog = context.(dialog.CompletionDialog) + if p.showCompletionDialog { + context, contextCmd := p.completionDialog.Update(msg) + p.completionDialog = context.(dialog.CompletionDialog) cmds = append(cmds, contextCmd) // Doesn't forward event if enter key is pressed @@ -174,12 +174,12 @@ func (p *chatPage) GetSize() (int, int) { func (p *chatPage) View() string { layoutView := p.layout.View() - if p.showContextDialog { + if p.showCompletionDialog { _, layoutHeight := p.layout.GetSize() editorWidth, editorHeight := p.editor.GetSize() - p.contextDialog.SetWidth(editorWidth) - overlay := p.contextDialog.View() + p.completionDialog.SetWidth(editorWidth) + overlay := p.completionDialog.View() layoutView = layout.PlaceOverlay( 0, @@ -202,7 +202,7 @@ func (p *chatPage) BindingKeys() []key.Binding { func NewChatPage(app *app.App) tea.Model { cg := completions.NewFileAndFolderContextGroup() - contextDialog := dialog.NewCompletionDialogCmp(cg) + completionDialog := dialog.NewCompletionDialogCmp(cg) messagesContainer := layout.NewContainer( chat.NewMessagesCmp(app), @@ -213,10 +213,10 @@ func NewChatPage(app *app.App) tea.Model { layout.WithBorder(true, false, false, false), ) return &chatPage{ - app: app, - editor: editorContainer, - messages: messagesContainer, - contextDialog: contextDialog, + app: app, + editor: editorContainer, + messages: messagesContainer, + completionDialog: completionDialog, layout: layout.NewSplitPane( layout.WithLeftPanel(messagesContainer), layout.WithBottomPanel(editorContainer), From 34e01827ea573722e1e7a083be56c57baef2ee2c Mon Sep 17 00:00:00 2001 From: Adictya Date: Sun, 11 May 2025 16:41:23 +0530 Subject: [PATCH 13/13] fix(complete-module): remove old file --- .../tui/components/dialog/commands-ref.txt | 249 ------------------ 1 file changed, 249 deletions(-) delete mode 100644 internal/tui/components/dialog/commands-ref.txt diff --git a/internal/tui/components/dialog/commands-ref.txt b/internal/tui/components/dialog/commands-ref.txt deleted file mode 100644 index c725f020..00000000 --- a/internal/tui/components/dialog/commands-ref.txt +++ /dev/null @@ -1,249 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// Command represents a command that can be executed -type Command struct { - ID string - Title string - Description string - Handler func(cmd Command) tea.Cmd -} - -// CommandSelectedMsg is sent when a command is selected -type CommandSelectedMsg struct { - Command Command -} - -// CloseCommandDialogMsg is sent when the command dialog is closed -type CloseCommandDialogMsg struct{} - -// CommandDialog interface for the command selection dialog -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 -} - -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"), - ), - Escape: key.NewBinding( - 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 -} - -func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, 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 { - return c, util.CmdHandler(CommandSelectedMsg{ - Command: c.commands[c.selectedIdx], - }) - } - case key.Matches(msg, commandKeys.Escape): - return c, util.CmdHandler(CloseCommandDialogMsg{}) - } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height - } - return c, nil -} - -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") - } - - // 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 - } - } - - // 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()) - } - - 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) - } - } - - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Render("Commands") - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)), - baseStyle.Width(maxWidth).Render(""), - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -func (c *commandDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(commandKeys) -} - -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 - } - } - } -} - -// NewCommandDialogCmp creates a new command selection dialog -func NewCommandDialogCmp() CommandDialog { - return &commandDialogCmp{ - commands: []Command{}, - selectedIdx: 0, - selectedCommandID: "", - } -}