completions.go

  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}