1package completions
2
3import (
4 "cmp"
5 "slices"
6 "strings"
7 "sync"
8 "unicode"
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 "github.com/sahilm/fuzzy"
19)
20
21const (
22 minHeight = 1
23 maxHeight = 10
24 minWidth = 10
25 maxWidth = 100
26
27 fullMatchWeight = 1_000
28 baseMatchWeight = 300
29
30 pathPrefixBonus = 5_000
31 pathContainsBonus = 2_000
32 pathContainsHintBonus = 2_500
33 basePrefixBonus = 1_500
34 baseContainsBonus = 500
35 basePrefixHintBonus = 300
36 baseContainsHintBonus = 120
37
38 depthPenaltyDefault = 20
39 depthPenaltyPathHint = 5
40)
41
42// SelectionMsg is sent when a completion is selected.
43type SelectionMsg[T any] struct {
44 Value T
45 KeepOpen bool // If true, insert without closing.
46}
47
48// ClosedMsg is sent when the completions are closed.
49type ClosedMsg struct{}
50
51// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
52type CompletionItemsLoadedMsg struct {
53 Files []FileCompletionValue
54 Resources []ResourceCompletionValue
55}
56
57// Completions represents the completions popup component.
58type Completions struct {
59 // Popup dimensions
60 width int
61 height int
62
63 // State
64 open bool
65 query string
66
67 // Key bindings
68 keyMap KeyMap
69
70 // List component
71 list *list.FilterableList
72
73 // Styling
74 normalStyle lipgloss.Style
75 focusedStyle lipgloss.Style
76 matchStyle lipgloss.Style
77
78 items []*CompletionItem
79 filtered []*CompletionItem
80 paths []string
81 bases []string
82}
83
84// New creates a new completions component.
85func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
86 l := list.NewFilterableList()
87 l.SetGap(0)
88 l.SetReverse(true)
89
90 return &Completions{
91 keyMap: DefaultKeyMap(),
92 list: l,
93 normalStyle: normalStyle,
94 focusedStyle: focusedStyle,
95 matchStyle: matchStyle,
96 }
97}
98
99// IsOpen returns whether the completions popup is open.
100func (c *Completions) IsOpen() bool {
101 return c.open
102}
103
104// Query returns the current filter query.
105func (c *Completions) Query() string {
106 return c.query
107}
108
109// Size returns the visible size of the popup.
110func (c *Completions) Size() (width, height int) {
111 visible := len(c.filtered)
112 return c.width, min(visible, c.height)
113}
114
115// KeyMap returns the key bindings.
116func (c *Completions) KeyMap() KeyMap {
117 return c.keyMap
118}
119
120// Open opens the completions with file items from the filesystem.
121func (c *Completions) Open(depth, limit int) tea.Cmd {
122 return func() tea.Msg {
123 var msg CompletionItemsLoadedMsg
124 var wg sync.WaitGroup
125 wg.Go(func() {
126 msg.Files = loadFiles(depth, limit)
127 })
128 wg.Go(func() {
129 msg.Resources = loadMCPResources()
130 })
131 wg.Wait()
132 return msg
133 }
134}
135
136// SetItems sets the files and MCP resources and rebuilds the merged list.
137func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
138 items := make([]*CompletionItem, 0, len(files)+len(resources))
139
140 // Add files first.
141 for _, file := range files {
142 item := NewCompletionItem(
143 file.Path,
144 file,
145 c.normalStyle,
146 c.focusedStyle,
147 c.matchStyle,
148 )
149 items = append(items, item)
150 }
151
152 // Add MCP resources.
153 for _, resource := range resources {
154 item := NewCompletionItem(
155 resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
156 resource,
157 c.normalStyle,
158 c.focusedStyle,
159 c.matchStyle,
160 )
161 items = append(items, item)
162 }
163
164 c.open = true
165 c.query = ""
166 c.items = items
167 c.paths = make([]string, len(items))
168 c.bases = make([]string, len(items))
169 for i, item := range items {
170 path := item.Filter()
171 c.paths[i] = path
172 c.bases[i] = pathBase(path)
173 }
174 c.filtered = c.rank(queryContext{
175 query: c.query,
176 })
177 c.setVisibleItems(c.filtered)
178 c.list.Focus()
179
180 c.width = maxWidth
181 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
182 c.list.SetSize(c.width, c.height)
183 c.list.SelectFirst()
184 c.list.ScrollToSelected()
185
186 c.updateSize()
187}
188
189// Close closes the completions popup.
190func (c *Completions) Close() {
191 c.open = false
192}
193
194// Filter filters the completions with the given query.
195func (c *Completions) Filter(query string) {
196 if !c.open {
197 return
198 }
199
200 if query == c.query {
201 return
202 }
203
204 c.query = query
205 c.filtered = c.rank(queryContext{
206 query: query,
207 })
208 c.setVisibleItems(c.filtered)
209
210 c.updateSize()
211}
212
213func (c *Completions) updateSize() {
214 items := c.filtered
215 start, end := c.list.VisibleItemIndices()
216 width := 0
217 for i := start; i <= end; i++ {
218 item := c.list.ItemAt(i)
219 if item == nil {
220 continue
221 }
222 s := item.(interface{ Text() string }).Text()
223 width = max(width, ansi.StringWidth(s))
224 }
225 c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
226 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
227 c.list.SetSize(c.width, c.height)
228 c.list.SelectFirst()
229 c.list.ScrollToSelected()
230}
231
232// HasItems returns whether there are visible items.
233func (c *Completions) HasItems() bool {
234 return len(c.filtered) > 0
235}
236
237// Update handles key events for the completions.
238func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
239 if !c.open {
240 return nil, false
241 }
242
243 switch {
244 case key.Matches(msg, c.keyMap.Up):
245 c.selectPrev()
246 return nil, true
247
248 case key.Matches(msg, c.keyMap.Down):
249 c.selectNext()
250 return nil, true
251
252 case key.Matches(msg, c.keyMap.UpInsert):
253 c.selectPrev()
254 return c.selectCurrent(true), true
255
256 case key.Matches(msg, c.keyMap.DownInsert):
257 c.selectNext()
258 return c.selectCurrent(true), true
259
260 case key.Matches(msg, c.keyMap.Select):
261 return c.selectCurrent(false), true
262
263 case key.Matches(msg, c.keyMap.Cancel):
264 c.Close()
265 return ClosedMsg{}, true
266 }
267
268 return nil, false
269}
270
271// selectPrev selects the previous item with circular navigation.
272func (c *Completions) selectPrev() {
273 items := c.filtered
274 if len(items) == 0 {
275 return
276 }
277 if !c.list.SelectPrev() {
278 c.list.WrapToEnd()
279 }
280 c.list.ScrollToSelected()
281}
282
283// selectNext selects the next item with circular navigation.
284func (c *Completions) selectNext() {
285 items := c.filtered
286 if len(items) == 0 {
287 return
288 }
289 if !c.list.SelectNext() {
290 c.list.WrapToStart()
291 }
292 c.list.ScrollToSelected()
293}
294
295// selectCurrent returns a command with the currently selected item.
296func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
297 items := c.filtered
298 if len(items) == 0 {
299 return nil
300 }
301
302 selected := c.list.Selected()
303 if selected < 0 || selected >= len(items) {
304 return nil
305 }
306
307 item := items[selected]
308
309 if !keepOpen {
310 c.open = false
311 }
312
313 switch item := item.Value().(type) {
314 case ResourceCompletionValue:
315 return SelectionMsg[ResourceCompletionValue]{
316 Value: item,
317 KeepOpen: keepOpen,
318 }
319 case FileCompletionValue:
320 return SelectionMsg[FileCompletionValue]{
321 Value: item,
322 KeepOpen: keepOpen,
323 }
324 default:
325 return nil
326 }
327}
328
329// Render renders the completions popup.
330func (c *Completions) Render() string {
331 if !c.open {
332 return ""
333 }
334
335 items := c.filtered
336 if len(items) == 0 {
337 return ""
338 }
339
340 return c.list.Render()
341}
342
343func (c *Completions) setVisibleItems(items []*CompletionItem) {
344 filterables := make([]list.FilterableItem, 0, len(items))
345 for _, item := range items {
346 filterables = append(filterables, item)
347 }
348 c.list.SetItems(filterables...)
349}
350
351type queryContext struct {
352 query string
353}
354
355type rankedItem struct {
356 item *CompletionItem
357 score int
358}
359
360// rank uses path-first fuzzy ordering with basename as a secondary boost.
361func (c *Completions) rank(ctx queryContext) []*CompletionItem {
362 query := strings.TrimSpace(ctx.query)
363 if query == "" {
364 for _, item := range c.items {
365 item.SetMatch(fuzzy.Match{})
366 }
367 return c.items
368 }
369
370 fullMatches := matchIndex(query, c.paths)
371 baseMatches := matchIndex(query, c.bases)
372 allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
373 for idx := range fullMatches {
374 allIndexes[idx] = struct{}{}
375 }
376 for idx := range baseMatches {
377 allIndexes[idx] = struct{}{}
378 }
379
380 queryLower := strings.ToLower(query)
381 pathHint := hasPathHint(query)
382 ranked := make([]rankedItem, 0, len(allIndexes))
383 for idx := range allIndexes {
384 path := c.paths[idx]
385 pathLower := strings.ToLower(path)
386 baseLower := strings.ToLower(c.bases[idx])
387
388 fullMatch, hasFullMatch := fullMatches[idx]
389 baseMatch, hasBaseMatch := baseMatches[idx]
390
391 pathPrefix := strings.HasPrefix(pathLower, queryLower)
392 pathContains := strings.Contains(pathLower, queryLower)
393 basePrefix := strings.HasPrefix(baseLower, queryLower)
394 baseContains := strings.Contains(baseLower, queryLower)
395
396 score := 0
397 if hasFullMatch {
398 score += fullMatch.Score * fullMatchWeight
399 }
400 if hasBaseMatch {
401 score += baseMatch.Score * baseMatchWeight
402 }
403 if pathPrefix {
404 score += pathPrefixBonus
405 }
406 if pathContains {
407 score += pathContainsBonus
408 }
409 if pathHint {
410 if pathContains {
411 score += pathContainsHintBonus
412 }
413 if basePrefix {
414 score += basePrefixHintBonus
415 }
416 if baseContains {
417 score += baseContainsHintBonus
418 }
419 } else {
420 if basePrefix {
421 score += basePrefixBonus
422 }
423 if baseContains {
424 score += baseContainsBonus
425 }
426 }
427
428 depthPenalty := depthPenaltyDefault
429 if pathHint {
430 depthPenalty = depthPenaltyPathHint
431 }
432 score -= strings.Count(path, "/") * depthPenalty
433 score -= ansi.StringWidth(path)
434
435 if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
436 c.items[idx].SetMatch(fullMatch)
437 } else if hasBaseMatch {
438 c.items[idx].SetMatch(remapMatchToPath(baseMatch, path))
439 } else {
440 c.items[idx].SetMatch(fuzzy.Match{})
441 }
442
443 ranked = append(ranked, rankedItem{
444 item: c.items[idx],
445 score: score,
446 })
447 }
448
449 slices.SortStableFunc(ranked, func(a, b rankedItem) int {
450 if a.score != b.score {
451 return b.score - a.score
452 }
453 return strings.Compare(a.item.Text(), b.item.Text())
454 })
455
456 result := make([]*CompletionItem, 0, len(ranked))
457 for _, item := range ranked {
458 result = append(result, item.item)
459 }
460 return result
461}
462
463func matchIndex(query string, values []string) map[int]fuzzy.Match {
464 source := stringSource(values)
465 matches := fuzzy.FindFrom(query, source)
466 result := make(map[int]fuzzy.Match, len(matches))
467 for _, match := range matches {
468 result[match.Index] = match
469 }
470 return result
471}
472
473type stringSource []string
474
475func (s stringSource) Len() int {
476 return len(s)
477}
478
479func (s stringSource) String(i int) string {
480 return s[i]
481}
482
483func pathBase(value string) string {
484 trimmed := strings.TrimRight(value, `/\`)
485 if trimmed == "" {
486 return value
487 }
488 idx := strings.LastIndexAny(trimmed, `/\`)
489 if idx < 0 {
490 return trimmed
491 }
492 return trimmed[idx+1:]
493}
494
495func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
496 base := pathBase(fullPath)
497 if base == "" {
498 return match
499 }
500 offset := len(fullPath) - len(base)
501 remapped := make([]int, 0, len(match.MatchedIndexes))
502 for _, idx := range match.MatchedIndexes {
503 remapped = append(remapped, offset+idx)
504 }
505 match.MatchedIndexes = remapped
506 return match
507}
508
509func hasPathHint(query string) bool {
510 if strings.Contains(query, "/") || strings.Contains(query, "\\") {
511 return true
512 }
513
514 lastDot := strings.LastIndex(query, ".")
515 if lastDot < 0 || lastDot == len(query)-1 {
516 return false
517 }
518
519 suffix := query[lastDot+1:]
520 if len(suffix) > 12 {
521 return false
522 }
523
524 hasLetter := false
525 for _, r := range suffix {
526 if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
527 return false
528 }
529 if unicode.IsLetter(r) {
530 hasLetter = true
531 }
532 }
533
534 return hasLetter
535}
536
537func loadFiles(depth, limit int) []FileCompletionValue {
538 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
539 slices.Sort(files)
540 result := make([]FileCompletionValue, 0, len(files))
541 for _, file := range files {
542 result = append(result, FileCompletionValue{
543 Path: strings.TrimPrefix(file, "./"),
544 })
545 }
546 return result
547}
548
549func loadMCPResources() []ResourceCompletionValue {
550 var resources []ResourceCompletionValue
551 for mcpName, mcpResources := range mcp.Resources() {
552 for _, r := range mcpResources {
553 resources = append(resources, ResourceCompletionValue{
554 MCPName: mcpName,
555 URI: r.URI,
556 Title: r.Name,
557 MIMEType: r.MIMEType,
558 })
559 }
560 }
561 return resources
562}