1package completions
2
3import (
4 "cmp"
5 "path/filepath"
6 "slices"
7 "strings"
8 "sync"
9
10 "charm.land/bubbles/v2/key"
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/ui/list"
16 "github.com/charmbracelet/x/ansi"
17 "github.com/charmbracelet/x/exp/ordered"
18)
19
20const (
21 minHeight = 1
22 maxHeight = 10
23 minWidth = 10
24 maxWidth = 100
25
26 tierExactName = iota
27 tierPrefixName
28 tierPathSegment
29 tierFallback
30)
31
32// SelectionMsg is sent when a completion is selected.
33type SelectionMsg[T any] struct {
34 Value T
35 KeepOpen bool // If true, insert without closing.
36}
37
38// ClosedMsg is sent when the completions are closed.
39type ClosedMsg struct{}
40
41// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
42type CompletionItemsLoadedMsg struct {
43 Files []FileCompletionValue
44 Resources []ResourceCompletionValue
45}
46
47// Completions represents the completions popup component.
48type Completions struct {
49 // Popup dimensions
50 width int
51 height int
52
53 // State
54 open bool
55 query string
56
57 // Key bindings
58 keyMap KeyMap
59
60 // List component
61 list *list.FilterableList
62
63 // Styling
64 normalStyle lipgloss.Style
65 focusedStyle lipgloss.Style
66 matchStyle lipgloss.Style
67
68 allItems []list.FilterableItem
69 filtered []list.FilterableItem
70}
71
72type namePriorityRule struct {
73 tier int
74 match func(pathLower, baseLower, stemLower, queryLower string) bool
75}
76
77var namePriorityRules = []namePriorityRule{
78 {
79 tier: tierExactName,
80 match: func(_ string, baseLower, stemLower, queryLower string) bool {
81 return baseLower == queryLower || stemLower == queryLower
82 },
83 },
84 {
85 tier: tierPrefixName,
86 match: func(_ string, baseLower, _ string, queryLower string) bool {
87 return strings.HasPrefix(baseLower, queryLower)
88 },
89 },
90 {
91 tier: tierPathSegment,
92 match: func(pathLower, _ string, _ string, queryLower string) bool {
93 return hasPathSegment(pathLower, queryLower)
94 },
95 },
96}
97
98// New creates a new completions component.
99func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
100 l := list.NewFilterableList()
101 l.SetGap(0)
102 l.SetReverse(true)
103
104 return &Completions{
105 keyMap: DefaultKeyMap(),
106 list: l,
107 normalStyle: normalStyle,
108 focusedStyle: focusedStyle,
109 matchStyle: matchStyle,
110 }
111}
112
113// SetStyles updates the styles used when rendering completion items.
114// Existing items are not restyled; subsequent SetItems calls pick up the
115// new styles.
116func (c *Completions) SetStyles(normalStyle, focusedStyle, matchStyle lipgloss.Style) {
117 c.normalStyle = normalStyle
118 c.focusedStyle = focusedStyle
119 c.matchStyle = matchStyle
120}
121
122// IsOpen returns whether the completions popup is open.
123func (c *Completions) IsOpen() bool {
124 return c.open
125}
126
127// Query returns the current filter query.
128func (c *Completions) Query() string {
129 return c.query
130}
131
132// Size returns the visible size of the popup.
133func (c *Completions) Size() (width, height int) {
134 visible := len(c.filtered)
135 return c.width, min(visible, c.height)
136}
137
138// KeyMap returns the key bindings.
139func (c *Completions) KeyMap() KeyMap {
140 return c.keyMap
141}
142
143// Open opens the completions with file items from the filesystem.
144func (c *Completions) Open(depth, limit int) tea.Cmd {
145 return func() tea.Msg {
146 var msg CompletionItemsLoadedMsg
147 var wg sync.WaitGroup
148 wg.Go(func() {
149 msg.Files = loadFiles(depth, limit)
150 })
151 wg.Go(func() {
152 msg.Resources = loadMCPResources()
153 })
154 wg.Wait()
155 return msg
156 }
157}
158
159// SetItems sets the files and MCP resources and rebuilds the merged list.
160func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
161 items := make([]list.FilterableItem, 0, len(files)+len(resources))
162
163 // Add files first.
164 for _, file := range files {
165 item := NewCompletionItem(
166 file.Path,
167 file,
168 c.normalStyle,
169 c.focusedStyle,
170 c.matchStyle,
171 )
172 items = append(items, item)
173 }
174
175 // Add MCP resources.
176 for _, resource := range resources {
177 item := NewCompletionItem(
178 resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
179 resource,
180 c.normalStyle,
181 c.focusedStyle,
182 c.matchStyle,
183 )
184 items = append(items, item)
185 }
186
187 c.open = true
188 c.query = ""
189 c.allItems = items
190 c.filtered = append([]list.FilterableItem(nil), items...)
191 c.list.SetItems(c.filtered...)
192 c.list.SetFilter("")
193 c.list.Focus()
194
195 c.width = maxWidth
196 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
197 c.list.SetSize(c.width, c.height)
198 c.list.SelectFirst()
199 c.list.ScrollToSelected()
200
201 c.updateSize()
202}
203
204// Close closes the completions popup.
205func (c *Completions) Close() {
206 c.open = false
207}
208
209// Filter filters the completions with the given query.
210func (c *Completions) Filter(query string) {
211 if !c.open {
212 return
213 }
214
215 if query == c.query {
216 return
217 }
218
219 c.query = query
220 c.applyNamePriorityFilter(query)
221
222 c.updateSize()
223}
224
225func (c *Completions) applyNamePriorityFilter(query string) {
226 if query == "" {
227 c.filtered = append([]list.FilterableItem(nil), c.allItems...)
228 c.list.SetItems(c.filtered...)
229 return
230 }
231
232 c.list.SetItems(c.allItems...)
233 c.list.SetFilter(query)
234 raw := c.list.FilteredItems()
235 filtered := make([]list.FilterableItem, 0, len(raw))
236 for _, item := range raw {
237 filterable, ok := item.(list.FilterableItem)
238 if !ok {
239 continue
240 }
241 filtered = append(filtered, filterable)
242 }
243
244 queryLower := strings.ToLower(strings.TrimSpace(query))
245 slices.SortStableFunc(filtered, func(a, b list.FilterableItem) int {
246 return namePriorityTier(a.Filter(), queryLower) - namePriorityTier(b.Filter(), queryLower)
247 })
248 c.filtered = filtered
249 c.list.SetItems(c.filtered...)
250}
251
252func namePriorityTier(path, queryLower string) int {
253 if queryLower == "" {
254 return tierFallback
255 }
256
257 pathLower := strings.ToLower(path)
258 baseLower := strings.ToLower(filepath.Base(strings.ReplaceAll(path, `\`, `/`)))
259 stemLower := strings.TrimSuffix(baseLower, filepath.Ext(baseLower))
260 for _, rule := range namePriorityRules {
261 if rule.match(pathLower, baseLower, stemLower, queryLower) {
262 return rule.tier
263 }
264 }
265 return tierFallback
266}
267
268func hasPathSegment(pathLower, queryLower string) bool {
269 return slices.Contains(strings.FieldsFunc(pathLower, func(r rune) bool {
270 return r == '/' || r == '\\'
271 }), queryLower)
272}
273
274func (c *Completions) updateSize() {
275 items := c.filtered
276 start, end := c.list.VisibleItemIndices()
277 width := 0
278 for i := start; i <= end; i++ {
279 item := c.list.ItemAt(i)
280 if item == nil {
281 continue
282 }
283 s := item.(interface{ Text() string }).Text()
284 width = max(width, ansi.StringWidth(s))
285 }
286 c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
287 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
288 c.list.SetSize(c.width, c.height)
289 c.list.SelectFirst()
290 c.list.ScrollToSelected()
291}
292
293// HasItems returns whether there are visible items.
294func (c *Completions) HasItems() bool {
295 return len(c.filtered) > 0
296}
297
298// Update handles key events for the completions.
299func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
300 if !c.open {
301 return nil, false
302 }
303
304 switch {
305 case key.Matches(msg, c.keyMap.Up):
306 c.selectPrev()
307 return nil, true
308
309 case key.Matches(msg, c.keyMap.Down):
310 c.selectNext()
311 return nil, true
312
313 case key.Matches(msg, c.keyMap.UpInsert):
314 c.selectPrev()
315 return c.selectCurrent(true), true
316
317 case key.Matches(msg, c.keyMap.DownInsert):
318 c.selectNext()
319 return c.selectCurrent(true), true
320
321 case key.Matches(msg, c.keyMap.Select):
322 return c.selectCurrent(false), true
323
324 case key.Matches(msg, c.keyMap.Cancel):
325 c.Close()
326 return ClosedMsg{}, true
327 }
328
329 return nil, false
330}
331
332// selectPrev selects the previous item with circular navigation.
333func (c *Completions) selectPrev() {
334 items := c.filtered
335 if len(items) == 0 {
336 return
337 }
338 if !c.list.SelectPrev() {
339 c.list.WrapToEnd()
340 }
341 c.list.ScrollToSelected()
342}
343
344// selectNext selects the next item with circular navigation.
345func (c *Completions) selectNext() {
346 items := c.filtered
347 if len(items) == 0 {
348 return
349 }
350 if !c.list.SelectNext() {
351 c.list.WrapToStart()
352 }
353 c.list.ScrollToSelected()
354}
355
356// selectCurrent returns a command with the currently selected item.
357func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
358 items := c.filtered
359 if len(items) == 0 {
360 return nil
361 }
362
363 selected := c.list.Selected()
364 if selected < 0 || selected >= len(items) {
365 return nil
366 }
367
368 item, ok := items[selected].(*CompletionItem)
369 if !ok {
370 return nil
371 }
372
373 if !keepOpen {
374 c.open = false
375 }
376
377 switch item := item.Value().(type) {
378 case ResourceCompletionValue:
379 return SelectionMsg[ResourceCompletionValue]{
380 Value: item,
381 KeepOpen: keepOpen,
382 }
383 case FileCompletionValue:
384 return SelectionMsg[FileCompletionValue]{
385 Value: item,
386 KeepOpen: keepOpen,
387 }
388 default:
389 return nil
390 }
391}
392
393// Render renders the completions popup.
394func (c *Completions) Render() string {
395 if !c.open {
396 return ""
397 }
398
399 items := c.filtered
400 if len(items) == 0 {
401 return ""
402 }
403
404 return c.list.List.Render()
405}
406
407func loadFiles(depth, limit int) []FileCompletionValue {
408 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
409 slices.Sort(files)
410 result := make([]FileCompletionValue, 0, len(files))
411 for _, file := range files {
412 result = append(result, FileCompletionValue{
413 Path: strings.TrimPrefix(file, "./"),
414 })
415 }
416 return result
417}
418
419func loadMCPResources() []ResourceCompletionValue {
420 var resources []ResourceCompletionValue
421 for mcpName, mcpResources := range mcp.Resources() {
422 for _, r := range mcpResources {
423 resources = append(resources, ResourceCompletionValue{
424 MCPName: mcpName,
425 URI: r.URI,
426 Title: r.Name,
427 MIMEType: r.MIMEType,
428 })
429 }
430 }
431 return resources
432}