Skip to content

Commit 56c0c7f

Browse files
committed
feat(context-dialog): init
1 parent 603a3e3 commit 56c0c7f

File tree

5 files changed

+641
-11
lines changed

5 files changed

+641
-11
lines changed

internal/completions/files-folders.go

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package completions
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os/exec"
7+
"path/filepath"
8+
"sort"
9+
10+
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
11+
)
12+
13+
type filesAndFoldersContextGroup struct {
14+
prefix string
15+
}
16+
17+
func (cg *filesAndFoldersContextGroup) GetId() string {
18+
return cg.prefix
19+
}
20+
21+
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
22+
return dialog.NewCompletionItem(dialog.CompletionItem{
23+
Title: "Files & Folders",
24+
Value: "files",
25+
})
26+
}
27+
28+
func getFilesRg() ([]string, error) {
29+
searchRoot := "."
30+
31+
rgBin, err := exec.LookPath("rg")
32+
if err != nil {
33+
return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
34+
}
35+
36+
args := []string{
37+
"--files",
38+
"-L",
39+
"--null",
40+
}
41+
42+
cmd := exec.Command(rgBin, args...)
43+
cmd.Dir = "."
44+
45+
out, err := cmd.CombinedOutput()
46+
if err != nil {
47+
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
48+
return nil, nil
49+
}
50+
return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
51+
}
52+
53+
var matches []string
54+
for _, p := range bytes.Split(out, []byte{0}) {
55+
if len(p) == 0 {
56+
continue
57+
}
58+
abs := filepath.Join(searchRoot, string(p))
59+
matches = append(matches, abs)
60+
}
61+
62+
sort.SliceStable(matches, func(i, j int) bool {
63+
return len(matches[i]) < len(matches[j])
64+
})
65+
66+
return matches, nil
67+
}
68+
69+
func (cg *filesAndFoldersContextGroup) GetChildEntries() ([]dialog.CompletionItemI, error) {
70+
matches, err := getFilesRg()
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
items := make([]dialog.CompletionItemI, 0)
76+
77+
for _, file := range matches {
78+
item := dialog.NewCompletionItem(dialog.CompletionItem{
79+
Title: file,
80+
Value: file,
81+
})
82+
items = append(items, item)
83+
}
84+
85+
return items, nil
86+
}
87+
88+
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
89+
return &filesAndFoldersContextGroup{prefix: "file"}
90+
}

internal/tui/components/chat/editor.go

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package chat
33
import (
44
"os"
55
"os/exec"
6+
"strings"
67

78
"github.com/charmbracelet/bubbles/key"
89
"github.com/charmbracelet/bubbles/textarea"
@@ -104,6 +105,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104105
switch msg := msg.(type) {
105106
case dialog.ThemeChangedMsg:
106107
m.textarea = CreateTextArea(&m.textarea)
108+
case dialog.CompletionSelectedMsg:
109+
existingValue := m.textarea.Value()
110+
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
111+
112+
m.textarea.SetValue(modifiedValue)
107113
return m, nil
108114
case SessionSelectedMsg:
109115
if msg.ID != m.session.ID {
+260
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package dialog
2+
3+
import (
4+
"github.com/charmbracelet/bubbles/key"
5+
"github.com/charmbracelet/bubbles/textarea"
6+
tea "github.com/charmbracelet/bubbletea"
7+
"github.com/charmbracelet/lipgloss"
8+
"github.com/opencode-ai/opencode/internal/logging"
9+
utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
10+
"github.com/opencode-ai/opencode/internal/tui/layout"
11+
"github.com/opencode-ai/opencode/internal/tui/styles"
12+
"github.com/opencode-ai/opencode/internal/tui/theme"
13+
"github.com/opencode-ai/opencode/internal/tui/util"
14+
)
15+
16+
type CompletionItem struct {
17+
title string
18+
Title string
19+
Value string
20+
}
21+
22+
func (ci *CompletionItem) DisplayValue() string {
23+
return ci.Title
24+
}
25+
26+
func (ci *CompletionItem) GetValue() string {
27+
return ci.Value
28+
}
29+
30+
type CompletionItemI interface {
31+
GetValue() string
32+
DisplayValue() string
33+
}
34+
35+
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
36+
return &completionItem
37+
}
38+
39+
type CompletionProvider interface {
40+
GetId() string
41+
GetEntry() CompletionItemI
42+
GetChildEntries() ([]CompletionItemI, error)
43+
}
44+
45+
type CompletionSelectedMsg struct {
46+
SearchString string
47+
CompletionValue string
48+
}
49+
50+
type CompletionDialogCompleteItemMsg struct {
51+
Value string
52+
}
53+
54+
type CompletionDialogCloseMsg struct{}
55+
56+
type CompletionDialog interface {
57+
tea.Model
58+
layout.Bindings
59+
SetWidth(width int)
60+
}
61+
62+
type completionDialogCmp struct {
63+
completionProvider CompletionProvider
64+
completionItems []CompletionItemI
65+
selectedIdx int
66+
width int
67+
height int
68+
counter int
69+
searchTextArea textarea.Model
70+
listView utilComponents.SimpleList
71+
}
72+
73+
type completionDialogKeyMap struct {
74+
Up key.Binding
75+
Down key.Binding
76+
Enter key.Binding
77+
Tab key.Binding
78+
Space key.Binding
79+
Backspace key.Binding
80+
Escape key.Binding
81+
J key.Binding
82+
K key.Binding
83+
At key.Binding
84+
}
85+
86+
var completionDialogKeys = completionDialogKeyMap{
87+
At: key.NewBinding(
88+
key.WithKeys("@"),
89+
),
90+
Backspace: key.NewBinding(
91+
key.WithKeys("backspace"),
92+
),
93+
Tab: key.NewBinding(
94+
key.WithKeys("tab"),
95+
),
96+
Space: key.NewBinding(
97+
key.WithKeys(" "),
98+
),
99+
Up: key.NewBinding(
100+
key.WithKeys("up"),
101+
key.WithHelp("↑", "previous item"),
102+
),
103+
Down: key.NewBinding(
104+
key.WithKeys("down"),
105+
key.WithHelp("↓", "next item"),
106+
),
107+
Enter: key.NewBinding(
108+
key.WithKeys("enter"),
109+
key.WithHelp("enter", "select item"),
110+
),
111+
Escape: key.NewBinding(
112+
key.WithKeys("esc"),
113+
key.WithHelp("esc", "close"),
114+
),
115+
J: key.NewBinding(
116+
key.WithKeys("j"),
117+
key.WithHelp("j", "next item"),
118+
),
119+
K: key.NewBinding(
120+
key.WithKeys("k"),
121+
key.WithHelp("k", "previous item"),
122+
),
123+
}
124+
125+
func (c *completionDialogCmp) Init() tea.Cmd {
126+
return nil
127+
}
128+
129+
func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
130+
value := c.searchTextArea.Value()
131+
132+
if value == "" {
133+
return nil
134+
}
135+
136+
return tea.Batch(
137+
util.CmdHandler(CompletionSelectedMsg{
138+
SearchString: value,
139+
CompletionValue: item.GetValue(),
140+
}),
141+
c.close(),
142+
)
143+
}
144+
145+
func (c *completionDialogCmp) close() tea.Cmd {
146+
c.listView.Reset()
147+
c.searchTextArea.Reset()
148+
c.searchTextArea.Blur()
149+
150+
return util.CmdHandler(CompletionDialogCloseMsg{})
151+
}
152+
153+
func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
154+
var cmds []tea.Cmd
155+
switch msg := msg.(type) {
156+
case tea.KeyMsg:
157+
if c.searchTextArea.Focused() {
158+
var cmd tea.Cmd
159+
c.searchTextArea, cmd = c.searchTextArea.Update(msg)
160+
cmds = append(cmds, cmd)
161+
162+
var query string
163+
query = c.searchTextArea.Value()
164+
if query != "" {
165+
query = query[1:]
166+
}
167+
168+
logging.Info("Query", query)
169+
c.listView.Filter(query)
170+
171+
u, cmd := c.listView.Update(msg)
172+
c.listView = u.(utilComponents.SimpleList)
173+
174+
cmds = append(cmds, cmd)
175+
176+
switch {
177+
case key.Matches(msg, completionDialogKeys.Tab):
178+
item, i := c.listView.GetSelectedItem()
179+
if i == -1 {
180+
return c, nil
181+
}
182+
var matchedItem CompletionItemI
183+
184+
for _, citem := range c.completionItems {
185+
if item.GetValue() == citem.GetValue() {
186+
matchedItem = citem
187+
}
188+
}
189+
190+
cmd := c.complete(matchedItem)
191+
192+
return c, cmd
193+
case key.Matches(msg, completionDialogKeys.Escape) || key.Matches(msg, completionDialogKeys.Space) ||
194+
(key.Matches(msg, completionDialogKeys.Backspace) && len(c.searchTextArea.Value()) <= 0):
195+
return c, c.close()
196+
}
197+
198+
return c, tea.Batch(cmds...)
199+
}
200+
switch {
201+
case key.Matches(msg, completionDialogKeys.At):
202+
c.searchTextArea.SetValue("@")
203+
return c, c.searchTextArea.Focus()
204+
}
205+
case tea.WindowSizeMsg:
206+
c.width = msg.Width
207+
c.height = msg.Height
208+
}
209+
210+
return c, tea.Batch(cmds...)
211+
}
212+
213+
func (c *completionDialogCmp) View() string {
214+
t := theme.CurrentTheme()
215+
baseStyle := styles.BaseStyle()
216+
217+
return baseStyle.Padding(0, 0).
218+
Border(lipgloss.NormalBorder()).
219+
BorderBottom(false).
220+
BorderRight(false).
221+
BorderLeft(false).
222+
BorderBackground(t.Background()).
223+
BorderForeground(t.TextMuted()).
224+
Width(c.width).
225+
Render(c.listView.View())
226+
}
227+
228+
func (c *completionDialogCmp) SetWidth(width int) {
229+
c.width = width
230+
}
231+
232+
func mapperFunc(i CompletionItemI) utilComponents.SimpleListItem {
233+
return utilComponents.NewListItem(
234+
// i.DisplayValue(),
235+
// "",
236+
i.GetValue(),
237+
)
238+
}
239+
240+
func (c *completionDialogCmp) BindingKeys() []key.Binding {
241+
return layout.KeyMapToSlice(completionDialogKeys)
242+
}
243+
244+
func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
245+
ti := textarea.New()
246+
items, err := completionProvider.GetChildEntries()
247+
li := utilComponents.NewSimpleList(utilComponents.MapSlice(items, mapperFunc))
248+
if err != nil {
249+
logging.Error("Failed to get child entries", err)
250+
}
251+
252+
return &completionDialogCmp{
253+
completionProvider: completionProvider,
254+
completionItems: items,
255+
selectedIdx: 0,
256+
counter: 0,
257+
searchTextArea: ti,
258+
listView: li,
259+
}
260+
}

0 commit comments

Comments
 (0)