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	// Scoring weights for fuzzy matching.
 28	// Full path fuzzy match contributes most to the score.
 29	fullMatchWeight = 1_000
 30	// Basename fuzzy match contributes less than full path.
 31	baseMatchWeight = 300
 32
 33	// Bonus points for exact matches (case-insensitive).
 34	// Path prefix match (e.g., "src/" matches "src/main.go") gets highest bonus.
 35	pathPrefixBonus = 5_000
 36	// Path contains match (e.g., "main" matches "src/main.go").
 37	pathContainsBonus = 2_000
 38	// Additional bonus when path hint is detected (user typed "/" or file extension).
 39	pathContainsHintBonus = 2_500
 40	// Basename prefix match (e.g., "main" matches "main.go").
 41	basePrefixBonus = 1_500
 42	// Basename contains match (e.g., "mai" matches "main.go").
 43	baseContainsBonus = 500
 44	// Smaller bonuses when path hint is detected.
 45	basePrefixHintBonus   = 300
 46	baseContainsHintBonus = 120
 47
 48	// Penalties for deeply nested files to favor shallow matches.
 49	// Default penalty per directory level (e.g., "a/b/c" has 2 levels).
 50	depthPenaltyDefault = 20
 51	// Reduced penalty when user explicitly queries a path (typed "/" or extension).
 52	depthPenaltyPathHint = 5
 53)
 54
 55// SelectionMsg is sent when a completion is selected.
 56type SelectionMsg[T any] struct {
 57	Value    T
 58	KeepOpen bool // If true, insert without closing.
 59}
 60
 61// ClosedMsg is sent when the completions are closed.
 62type ClosedMsg struct{}
 63
 64// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
 65type CompletionItemsLoadedMsg struct {
 66	Files     []FileCompletionValue
 67	Resources []ResourceCompletionValue
 68}
 69
 70// Completions represents the completions popup component.
 71type Completions struct {
 72	// Popup dimensions
 73	width  int
 74	height int
 75
 76	// State
 77	open  bool
 78	query string
 79
 80	// Key bindings
 81	keyMap KeyMap
 82
 83	// List component
 84	list *list.FilterableList
 85
 86	// Styling
 87	normalStyle  lipgloss.Style
 88	focusedStyle lipgloss.Style
 89	matchStyle   lipgloss.Style
 90
 91	// Custom ranking state (replaces list.FilterableList's built-in filtering).
 92	items    []*CompletionItem // All completion items.
 93	filtered []*CompletionItem // Filtered and ranked items based on query.
 94	paths    []string          // Pre-computed full paths for matching.
 95	bases    []string          // Pre-computed basenames for matching.
 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// IsOpen returns whether the completions popup is open.
114func (c *Completions) IsOpen() bool {
115	return c.open
116}
117
118// Query returns the current filter query.
119func (c *Completions) Query() string {
120	return c.query
121}
122
123// Size returns the visible size of the popup.
124func (c *Completions) Size() (width, height int) {
125	visible := len(c.filtered)
126	return c.width, min(visible, c.height)
127}
128
129// KeyMap returns the key bindings.
130func (c *Completions) KeyMap() KeyMap {
131	return c.keyMap
132}
133
134// Open opens the completions with file items from the filesystem.
135func (c *Completions) Open(depth, limit int) tea.Cmd {
136	return func() tea.Msg {
137		var msg CompletionItemsLoadedMsg
138		var wg sync.WaitGroup
139		wg.Go(func() {
140			msg.Files = loadFiles(depth, limit)
141		})
142		wg.Go(func() {
143			msg.Resources = loadMCPResources()
144		})
145		wg.Wait()
146		return msg
147	}
148}
149
150// SetItems sets the files and MCP resources and rebuilds the merged list.
151func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
152	items := make([]*CompletionItem, 0, len(files)+len(resources))
153
154	// Add files first.
155	for _, file := range files {
156		item := NewCompletionItem(
157			file.Path,
158			file,
159			c.normalStyle,
160			c.focusedStyle,
161			c.matchStyle,
162		)
163		items = append(items, item)
164	}
165
166	// Add MCP resources.
167	for _, resource := range resources {
168		item := NewCompletionItem(
169			resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
170			resource,
171			c.normalStyle,
172			c.focusedStyle,
173			c.matchStyle,
174		)
175		items = append(items, item)
176	}
177
178	c.open = true
179	c.query = ""
180	c.items = items
181	// Pre-compute paths and basenames for efficient fuzzy matching.
182	c.paths = make([]string, len(items))
183	c.bases = make([]string, len(items))
184	for i, item := range items {
185		path := item.Filter()
186		c.paths[i] = path
187		c.bases[i] = pathBase(path)
188	}
189	// Perform initial ranking with empty query (returns all items).
190	c.filtered = c.rank(queryContext{
191		query: c.query,
192	})
193	c.setVisibleItems(c.filtered)
194	c.list.Focus()
195
196	c.width = maxWidth
197	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
198	c.list.SetSize(c.width, c.height)
199	c.list.SelectFirst()
200	c.list.ScrollToSelected()
201
202	c.updateSize()
203}
204
205// Close closes the completions popup.
206func (c *Completions) Close() {
207	c.open = false
208}
209
210// Filter filters the completions with the given query.
211func (c *Completions) Filter(query string) {
212	if !c.open {
213		return
214	}
215
216	if query == c.query {
217		return
218	}
219
220	c.query = query
221	// Apply custom ranking algorithm instead of list's built-in filtering.
222	c.filtered = c.rank(queryContext{
223		query: query,
224	})
225	c.setVisibleItems(c.filtered)
226
227	c.updateSize()
228}
229
230func (c *Completions) updateSize() {
231	items := c.filtered
232	start, end := c.list.VisibleItemIndices()
233	width := 0
234	for i := start; i <= end; i++ {
235		item := c.list.ItemAt(i)
236		if item == nil {
237			continue
238		}
239		s := item.(interface{ Text() string }).Text()
240		width = max(width, ansi.StringWidth(s))
241	}
242	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
243	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
244	c.list.SetSize(c.width, c.height)
245	c.list.SelectFirst()
246	c.list.ScrollToSelected()
247}
248
249// HasItems returns whether there are visible items.
250func (c *Completions) HasItems() bool {
251	return len(c.filtered) > 0
252}
253
254// Update handles key events for the completions.
255func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
256	if !c.open {
257		return nil, false
258	}
259
260	switch {
261	case key.Matches(msg, c.keyMap.Up):
262		c.selectPrev()
263		return nil, true
264
265	case key.Matches(msg, c.keyMap.Down):
266		c.selectNext()
267		return nil, true
268
269	case key.Matches(msg, c.keyMap.UpInsert):
270		c.selectPrev()
271		return c.selectCurrent(true), true
272
273	case key.Matches(msg, c.keyMap.DownInsert):
274		c.selectNext()
275		return c.selectCurrent(true), true
276
277	case key.Matches(msg, c.keyMap.Select):
278		return c.selectCurrent(false), true
279
280	case key.Matches(msg, c.keyMap.Cancel):
281		c.Close()
282		return ClosedMsg{}, true
283	}
284
285	return nil, false
286}
287
288// selectPrev selects the previous item with circular navigation.
289func (c *Completions) selectPrev() {
290	items := c.filtered
291	if len(items) == 0 {
292		return
293	}
294	if !c.list.SelectPrev() {
295		c.list.WrapToEnd()
296	}
297	c.list.ScrollToSelected()
298}
299
300// selectNext selects the next item with circular navigation.
301func (c *Completions) selectNext() {
302	items := c.filtered
303	if len(items) == 0 {
304		return
305	}
306	if !c.list.SelectNext() {
307		c.list.WrapToStart()
308	}
309	c.list.ScrollToSelected()
310}
311
312// selectCurrent returns a command with the currently selected item.
313func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
314	items := c.filtered
315	if len(items) == 0 {
316		return nil
317	}
318
319	selected := c.list.Selected()
320	if selected < 0 || selected >= len(items) {
321		return nil
322	}
323
324	item := items[selected]
325
326	if !keepOpen {
327		c.open = false
328	}
329
330	switch item := item.Value().(type) {
331	case ResourceCompletionValue:
332		return SelectionMsg[ResourceCompletionValue]{
333			Value:    item,
334			KeepOpen: keepOpen,
335		}
336	case FileCompletionValue:
337		return SelectionMsg[FileCompletionValue]{
338			Value:    item,
339			KeepOpen: keepOpen,
340		}
341	default:
342		return nil
343	}
344}
345
346// Render renders the completions popup.
347func (c *Completions) Render() string {
348	if !c.open {
349		return ""
350	}
351
352	items := c.filtered
353	if len(items) == 0 {
354		return ""
355	}
356
357	return c.list.Render()
358}
359
360func (c *Completions) setVisibleItems(items []*CompletionItem) {
361	filterables := make([]list.FilterableItem, 0, len(items))
362	for _, item := range items {
363		filterables = append(filterables, item)
364	}
365	c.list.SetItems(filterables...)
366}
367
368type queryContext struct {
369	query string
370}
371
372type rankedItem struct {
373	item  *CompletionItem
374	score int
375}
376
377// rank uses path-first fuzzy ordering with basename as a secondary boost.
378//
379// Ranking strategy:
380// 1. Perform fuzzy matching on both full paths and basenames.
381// 2. Apply bonus points for exact prefix/contains matches.
382// 3. Adjust bonuses based on whether query contains path hints (/, \, or file extension).
383// 4. Apply depth penalty to favor shallower files.
384// 5. Sort by score (descending), then alphabetically.
385//
386// Example scoring for query "main.go":
387//   - "main.go" (root): high fuzzy + pathPrefix + basePrefix + low depth penalty
388//   - "src/main.go": high fuzzy + pathContains + basePrefix + moderate depth penalty
389//   - "test/helper/main.go": high fuzzy + pathContains + basePrefix + high depth penalty
390func (c *Completions) rank(ctx queryContext) []*CompletionItem {
391	query := strings.TrimSpace(ctx.query)
392	if query == "" {
393		// Empty query: return all items with no highlights.
394		for _, item := range c.items {
395			item.SetMatch(fuzzy.Match{})
396		}
397		return c.items
398	}
399
400	// Perform fuzzy matching on both full paths and basenames.
401	fullMatches := matchIndex(query, c.paths)
402	baseMatches := matchIndex(query, c.bases)
403	// Collect unique item indices that matched either full path or basename.
404	allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
405	for idx := range fullMatches {
406		allIndexes[idx] = struct{}{}
407	}
408	for idx := range baseMatches {
409		allIndexes[idx] = struct{}{}
410	}
411
412	queryLower := strings.ToLower(query)
413	// Detect if query looks like a path (contains / or \ or file extension).
414	pathHint := hasPathHint(query)
415	ranked := make([]rankedItem, 0, len(allIndexes))
416	for idx := range allIndexes {
417		path := c.paths[idx]
418		pathLower := strings.ToLower(path)
419		baseLower := strings.ToLower(c.bases[idx])
420
421		fullMatch, hasFullMatch := fullMatches[idx]
422		baseMatch, hasBaseMatch := baseMatches[idx]
423
424		// Check for exact (case-insensitive) prefix/contains matches.
425		pathPrefix := strings.HasPrefix(pathLower, queryLower)
426		pathContains := strings.Contains(pathLower, queryLower)
427		basePrefix := strings.HasPrefix(baseLower, queryLower)
428		baseContains := strings.Contains(baseLower, queryLower)
429
430		// Calculate score by accumulating weighted components.
431		score := 0
432		// Fuzzy match scores (primary signals).
433		if hasFullMatch {
434			score += fullMatch.Score * fullMatchWeight
435		}
436		if hasBaseMatch {
437			score += baseMatch.Score * baseMatchWeight
438		}
439		// Path-level exact match bonuses.
440		if pathPrefix {
441			score += pathPrefixBonus
442		}
443		if pathContains {
444			score += pathContainsBonus
445		}
446		// Apply different bonuses based on whether query contains path hints.
447		if pathHint {
448			// User typed a path-like query (e.g., "src/main.go" or "main.go").
449			// Prioritize full path matches.
450			if pathContains {
451				score += pathContainsHintBonus
452			}
453			if basePrefix {
454				score += basePrefixHintBonus
455			}
456			if baseContains {
457				score += baseContainsHintBonus
458			}
459		} else {
460			// User typed a simple query (e.g., "main").
461			// Prioritize basename matches.
462			if basePrefix {
463				score += basePrefixBonus
464			}
465			if baseContains {
466				score += baseContainsBonus
467			}
468		}
469
470		// Apply penalties to discourage deeply nested files.
471		depthPenalty := depthPenaltyDefault
472		if pathHint {
473			// Reduce penalty when user explicitly queries a path.
474			depthPenalty = depthPenaltyPathHint
475		}
476		score -= strings.Count(path, "/") * depthPenalty
477		// Minor penalty based on path length (favor shorter paths).
478		score -= ansi.StringWidth(path)
479
480		// Choose which match to highlight based on weighted contribution.
481		if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
482			c.items[idx].SetMatch(fullMatch)
483		} else if hasBaseMatch {
484			c.items[idx].SetMatch(remapMatchToPath(baseMatch, path))
485		} else {
486			c.items[idx].SetMatch(fuzzy.Match{})
487		}
488
489		ranked = append(ranked, rankedItem{
490			item:  c.items[idx],
491			score: score,
492		})
493	}
494
495	slices.SortStableFunc(ranked, func(a, b rankedItem) int {
496		if a.score != b.score {
497			// Higher score first.
498			return b.score - a.score
499		}
500		// Tie-breaker: sort alphabetically.
501		return strings.Compare(a.item.Text(), b.item.Text())
502	})
503
504	result := make([]*CompletionItem, 0, len(ranked))
505	for _, item := range ranked {
506		result = append(result, item.item)
507	}
508	return result
509}
510
511// matchIndex performs fuzzy matching and returns a map of item index to match result.
512func matchIndex(query string, values []string) map[int]fuzzy.Match {
513	source := stringSource(values)
514	matches := fuzzy.FindFrom(query, source)
515	result := make(map[int]fuzzy.Match, len(matches))
516	for _, match := range matches {
517		result[match.Index] = match
518	}
519	return result
520}
521
522// stringSource adapts []string to fuzzy.Source interface.
523type stringSource []string
524
525func (s stringSource) Len() int {
526	return len(s)
527}
528
529func (s stringSource) String(i int) string {
530	return s[i]
531}
532
533// pathBase extracts the basename from a file path (handles both / and \ separators).
534// Examples:
535//   - "src/main.go" → "main.go"
536//   - "file.txt" → "file.txt"
537//   - "dir/" → "dir/"
538func pathBase(value string) string {
539	trimmed := strings.TrimRight(value, `/\`)
540	if trimmed == "" {
541		return value
542	}
543	idx := strings.LastIndexAny(trimmed, `/\`)
544	if idx < 0 {
545		return trimmed
546	}
547	return trimmed[idx+1:]
548}
549
550// remapMatchToPath remaps a basename match's character indices to full path indices.
551// Example:
552//   - baseMatch for "main" in "main.go" with indices [0,1,2,3]
553//   - fullPath is "src/main.go" (offset 4)
554//   - Result: [4,5,6,7] (highlights "main" in full path)
555func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
556	base := pathBase(fullPath)
557	if base == "" {
558		return match
559	}
560	offset := len(fullPath) - len(base)
561	remapped := make([]int, 0, len(match.MatchedIndexes))
562	for _, idx := range match.MatchedIndexes {
563		remapped = append(remapped, offset+idx)
564	}
565	match.MatchedIndexes = remapped
566	return match
567}
568
569// hasPathHint detects if the query looks like a file path query.
570// Returns true if:
571//   - Query contains "/" or "\" (explicit path separator)
572//   - Query ends with a file extension pattern (e.g., ".go", ".ts")
573//
574// File extension heuristics:
575//   - Must have a dot not at start/end (e.g., "main.go" ✓, "v0.1" ✗, ".gitignore" ✓)
576//   - Extension must be ≤12 chars (e.g., ".go" ✓, ".verylongextension" ✗)
577//   - Extension must contain at least one letter and only alphanumeric/_/- chars
578//
579// Examples:
580//   - "src/main" → true (contains /)
581//   - "main.go" → true (file extension)
582//   - ".gitignore" → true (file extension)
583//   - "v0.1" → false (no letter in suffix)
584//   - "main" → false (no path hint)
585func hasPathHint(query string) bool {
586	if strings.Contains(query, "/") || strings.Contains(query, "\\") {
587		return true
588	}
589
590	// Check for file extension pattern.
591	lastDot := strings.LastIndex(query, ".")
592	if lastDot < 0 || lastDot == len(query)-1 {
593		// No dot or dot at end (e.g., "main" or "foo.").
594		return false
595	}
596
597	suffix := query[lastDot+1:]
598	if len(suffix) > 12 {
599		// Extension too long (unlikely to be a real extension).
600		return false
601	}
602
603	// Validate that suffix looks like a file extension:
604	// - Contains only alphanumeric, underscore, or hyphen.
605	// - Contains at least one letter (to exclude version numbers like "v0.1").
606	hasLetter := false
607	for _, r := range suffix {
608		if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
609			return false
610		}
611		if unicode.IsLetter(r) {
612			hasLetter = true
613		}
614	}
615
616	return hasLetter
617}
618
619func loadFiles(depth, limit int) []FileCompletionValue {
620	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
621	slices.Sort(files)
622	result := make([]FileCompletionValue, 0, len(files))
623	for _, file := range files {
624		result = append(result, FileCompletionValue{
625			Path: strings.TrimPrefix(file, "./"),
626		})
627	}
628	return result
629}
630
631func loadMCPResources() []ResourceCompletionValue {
632	var resources []ResourceCompletionValue
633	for mcpName, mcpResources := range mcp.Resources() {
634		for _, r := range mcpResources {
635			resources = append(resources, ResourceCompletionValue{
636				MCPName:  mcpName,
637				URI:      r.URI,
638				Title:    r.Name,
639				MIMEType: r.MIMEType,
640			})
641		}
642	}
643	return resources
644}