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}