1package completions
2
3import (
4 "cmp"
5 "slices"
6 "strings"
7 "sync"
8
9 "charm.land/bubbles/v2/key"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
13 "github.com/charmbracelet/crush/internal/fsext"
14 "github.com/charmbracelet/crush/internal/ui/list"
15 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/exp/ordered"
17)
18
19const (
20 minHeight = 1
21 maxHeight = 10
22 minWidth = 10
23 maxWidth = 100
24)
25
26// SelectionMsg is sent when a completion is selected.
27type SelectionMsg[T any] struct {
28 Value T
29 KeepOpen bool // If true, insert without closing.
30}
31
32// ClosedMsg is sent when the completions are closed.
33type ClosedMsg struct{}
34
35// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
36type CompletionItemsLoadedMsg struct {
37 Files []FileCompletionValue
38 Resources []ResourceCompletionValue
39}
40
41// Completions represents the completions popup component.
42type Completions struct {
43 // Popup dimensions
44 width int
45 height int
46
47 // State
48 open bool
49 query string
50
51 // Key bindings
52 keyMap KeyMap
53
54 // List component
55 list *list.FilterableList
56
57 // Styling
58 normalStyle lipgloss.Style
59 focusedStyle lipgloss.Style
60 matchStyle lipgloss.Style
61}
62
63// New creates a new completions component.
64func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
65 l := list.NewFilterableList()
66 l.SetGap(0)
67 l.SetReverse(true)
68
69 return &Completions{
70 keyMap: DefaultKeyMap(),
71 list: l,
72 normalStyle: normalStyle,
73 focusedStyle: focusedStyle,
74 matchStyle: matchStyle,
75 }
76}
77
78// IsOpen returns whether the completions popup is open.
79func (c *Completions) IsOpen() bool {
80 return c.open
81}
82
83// Query returns the current filter query.
84func (c *Completions) Query() string {
85 return c.query
86}
87
88// Size returns the visible size of the popup.
89func (c *Completions) Size() (width, height int) {
90 visible := len(c.list.FilteredItems())
91 return c.width, min(visible, c.height)
92}
93
94// KeyMap returns the key bindings.
95func (c *Completions) KeyMap() KeyMap {
96 return c.keyMap
97}
98
99// Open opens the completions with file items from the filesystem.
100func (c *Completions) Open(depth, limit int) tea.Cmd {
101 return func() tea.Msg {
102 var msg CompletionItemsLoadedMsg
103 var wg sync.WaitGroup
104 wg.Go(func() {
105 msg.Files = loadFiles(depth, limit)
106 })
107 wg.Go(func() {
108 msg.Resources = loadMCPResources()
109 })
110 wg.Wait()
111 return msg
112 }
113}
114
115// SetItems sets the files and MCP resources and rebuilds the merged list.
116func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
117 items := make([]list.FilterableItem, 0, len(files)+len(resources))
118
119 // Add files first.
120 for _, file := range files {
121 item := NewCompletionItem(
122 file.Path,
123 file,
124 c.normalStyle,
125 c.focusedStyle,
126 c.matchStyle,
127 )
128 items = append(items, item)
129 }
130
131 // Add MCP resources.
132 for _, resource := range resources {
133 item := NewCompletionItem(
134 resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
135 resource,
136 c.normalStyle,
137 c.focusedStyle,
138 c.matchStyle,
139 )
140 items = append(items, item)
141 }
142
143 c.open = true
144 c.query = ""
145 c.list.SetItems(items...)
146 c.list.SetFilter("")
147 c.list.Focus()
148
149 c.width = maxWidth
150 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
151 c.list.SetSize(c.width, c.height)
152 c.list.SelectFirst()
153 c.list.ScrollToSelected()
154
155 c.updateSize()
156}
157
158// Close closes the completions popup.
159func (c *Completions) Close() {
160 c.open = false
161}
162
163// Filter filters the completions with the given query.
164func (c *Completions) Filter(query string) {
165 if !c.open {
166 return
167 }
168
169 if query == c.query {
170 return
171 }
172
173 c.query = query
174 c.list.SetFilter(query)
175
176 c.updateSize()
177}
178
179func (c *Completions) updateSize() {
180 items := c.list.FilteredItems()
181 start, end := c.list.VisibleItemIndices()
182 width := 0
183 for i := start; i <= end; i++ {
184 item := c.list.ItemAt(i)
185 s := item.(interface{ Text() string }).Text()
186 width = max(width, ansi.StringWidth(s))
187 }
188 c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
189 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
190 c.list.SetSize(c.width, c.height)
191 c.list.SelectFirst()
192 c.list.ScrollToSelected()
193}
194
195// HasItems returns whether there are visible items.
196func (c *Completions) HasItems() bool {
197 return len(c.list.FilteredItems()) > 0
198}
199
200// Update handles key events for the completions.
201func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
202 if !c.open {
203 return nil, false
204 }
205
206 switch {
207 case key.Matches(msg, c.keyMap.Up):
208 c.selectPrev()
209 return nil, true
210
211 case key.Matches(msg, c.keyMap.Down):
212 c.selectNext()
213 return nil, true
214
215 case key.Matches(msg, c.keyMap.UpInsert):
216 c.selectPrev()
217 return c.selectCurrent(true), true
218
219 case key.Matches(msg, c.keyMap.DownInsert):
220 c.selectNext()
221 return c.selectCurrent(true), true
222
223 case key.Matches(msg, c.keyMap.Select):
224 return c.selectCurrent(false), true
225
226 case key.Matches(msg, c.keyMap.Cancel):
227 c.Close()
228 return ClosedMsg{}, true
229 }
230
231 return nil, false
232}
233
234// selectPrev selects the previous item with circular navigation.
235func (c *Completions) selectPrev() {
236 items := c.list.FilteredItems()
237 if len(items) == 0 {
238 return
239 }
240 if !c.list.SelectPrev() {
241 c.list.WrapToEnd()
242 }
243 c.list.ScrollToSelected()
244}
245
246// selectNext selects the next item with circular navigation.
247func (c *Completions) selectNext() {
248 items := c.list.FilteredItems()
249 if len(items) == 0 {
250 return
251 }
252 if !c.list.SelectNext() {
253 c.list.WrapToStart()
254 }
255 c.list.ScrollToSelected()
256}
257
258// selectCurrent returns a command with the currently selected item.
259func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
260 items := c.list.FilteredItems()
261 if len(items) == 0 {
262 return nil
263 }
264
265 selected := c.list.Selected()
266 if selected < 0 || selected >= len(items) {
267 return nil
268 }
269
270 item, ok := items[selected].(*CompletionItem)
271 if !ok {
272 return nil
273 }
274
275 if !keepOpen {
276 c.open = false
277 }
278
279 switch item := item.Value().(type) {
280 case ResourceCompletionValue:
281 return SelectionMsg[ResourceCompletionValue]{
282 Value: item,
283 KeepOpen: keepOpen,
284 }
285 case FileCompletionValue:
286 return SelectionMsg[FileCompletionValue]{
287 Value: item,
288 KeepOpen: keepOpen,
289 }
290 default:
291 return nil
292 }
293}
294
295// Render renders the completions popup.
296func (c *Completions) Render() string {
297 if !c.open {
298 return ""
299 }
300
301 items := c.list.FilteredItems()
302 if len(items) == 0 {
303 return ""
304 }
305
306 return c.list.Render()
307}
308
309func loadFiles(depth, limit int) []FileCompletionValue {
310 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
311 slices.Sort(files)
312 result := make([]FileCompletionValue, 0, len(files))
313 for _, file := range files {
314 result = append(result, FileCompletionValue{
315 Path: strings.TrimPrefix(file, "./"),
316 })
317 }
318 return result
319}
320
321func loadMCPResources() []ResourceCompletionValue {
322 var resources []ResourceCompletionValue
323 for mcpName, mcpResources := range mcp.Resources() {
324 for _, r := range mcpResources {
325 resources = append(resources, ResourceCompletionValue{
326 MCPName: mcpName,
327 URI: r.URI,
328 Title: r.Name,
329 MIMEType: r.MIMEType,
330 })
331 }
332 }
333 return resources
334}