on main help sign in
feat(ui): improve file completion ranking algorithm

Implement custom ranking algorithm for file completions to provide better matching results based on user input patterns. Changes: - Add intelligent path hint detection to distinguish between filename and filepath queries - Implement weighted scoring system that balances fuzzy matching with prefix/contains bonuses - Support both full path and basename matching with appropriate weights - Add depth penalty adjustment based on query type - Select match highlights based on weighted contribution - Add comprehensive test coverage for ranking logic

commit0fcf79baf4aebd2b87cb49a8a287a18e6ad1dcac authorwanghuaiyu@qiniu.com <wanghuaiyu@qiniu.com> dateThu 19 Mar 2026, 19:29 (7 months ago) parent697fad9
2 files changed+339−15
internal/ui/completions/completions.go+240−15 internal/ui/completions/completions_test.go+99
internal/ui/completions/completions.go+240−15
@@ -5,6 +5,7 @@ import (
55 "slices"
66 "strings"
77 "sync"
8+ "unicode"
89
910 "charm.land/bubbles/v2/key"
1011 tea "charm.land/bubbletea/v2"
@@ -14,6 +15,7 @@ import (
1415 "github.com/charmbracelet/crush/internal/ui/list"
1516 "github.com/charmbracelet/x/ansi"
1617 "github.com/charmbracelet/x/exp/ordered"
18+ "github.com/sahilm/fuzzy"
1719)
1820
1921const (
@@ -21,6 +23,20 @@ const (
2123 maxHeight = 10
2224 minWidth = 10
2325 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
2440)
2541
2642// SelectionMsg is sent when a completion is selected.
@@ -58,6 +74,11 @@ type Completions struct {
5874 normalStyle lipgloss.Style
5975 focusedStyle lipgloss.Style
6076 matchStyle lipgloss.Style
77+
78+ items []*CompletionItem
79+ filtered []*CompletionItem
80+ paths []string
81+ bases []string
6182}
6283
6384// New creates a new completions component.
@@ -87,7 +108,7 @@ func (c *Completions) Query() string {
87108
88109// Size returns the visible size of the popup.
89110func (c *Completions) Size() (width, height int) {
90− visible := len(c.list.FilteredItems())
111+ visible := len(c.filtered)
91112 return c.width, min(visible, c.height)
92113}
93114
@@ -114,7 +135,7 @@ func (c *Completions) Open(depth, limit int) tea.Cmd {
114135
115136// SetItems sets the files and MCP resources and rebuilds the merged list.
116137func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
117− items := make([]list.FilterableItem, 0, len(files)+len(resources))
138+ items := make([]*CompletionItem, 0, len(files)+len(resources))
118139
119140 // Add files first.
120141 for _, file := range files {
@@ -142,8 +163,18 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
142163
143164 c.open = true
144165 c.query = ""
145− c.list.SetItems(items...)
146− c.list.SetFilter("")
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)
147178 c.list.Focus()
148179
149180 c.width = maxWidth
@@ -171,13 +202,16 @@ func (c *Completions) Filter(query string) {
171202 }
172203
173204 c.query = query
174− c.list.SetFilter(query)
205+ c.filtered = c.rank(queryContext{
206+ query: query,
207+ })
208+ c.setVisibleItems(c.filtered)
175209
176210 c.updateSize()
177211}
178212
179213func (c *Completions) updateSize() {
180− items := c.list.FilteredItems()
214+ items := c.filtered
181215 start, end := c.list.VisibleItemIndices()
182216 width := 0
183217 for i := start; i <= end; i++ {
@@ -197,7 +231,7 @@ func (c *Completions) updateSize() {
197231
198232// HasItems returns whether there are visible items.
199233func (c *Completions) HasItems() bool {
200− return len(c.list.FilteredItems()) > 0
234+ return len(c.filtered) > 0
201235}
202236
203237// Update handles key events for the completions.
@@ -236,7 +270,7 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
236270
237271// selectPrev selects the previous item with circular navigation.
238272func (c *Completions) selectPrev() {
239− items := c.list.FilteredItems()
273+ items := c.filtered
240274 if len(items) == 0 {
241275 return
242276 }
@@ -248,7 +282,7 @@ func (c *Completions) selectPrev() {
248282
249283// selectNext selects the next item with circular navigation.
250284func (c *Completions) selectNext() {
251− items := c.list.FilteredItems()
285+ items := c.filtered
252286 if len(items) == 0 {
253287 return
254288 }
@@ -260,7 +294,7 @@ func (c *Completions) selectNext() {
260294
261295// selectCurrent returns a command with the currently selected item.
262296func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
263− items := c.list.FilteredItems()
297+ items := c.filtered
264298 if len(items) == 0 {
265299 return nil
266300 }
@@ -270,10 +304,7 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
270304 return nil
271305 }
272306
273− item, ok := items[selected].(*CompletionItem)
274− if !ok {
275− return nil
276− }
307+ item := items[selected]
277308
278309 if !keepOpen {
279310 c.open = false
@@ -301,7 +332,7 @@ func (c *Completions) Render() string {
301332 return ""
302333 }
303334
304− items := c.list.FilteredItems()
335+ items := c.filtered
305336 if len(items) == 0 {
306337 return ""
307338 }
@@ -309,6 +340,200 @@ func (c *Completions) Render() string {
309340 return c.list.Render()
310341}
311342
343+func (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+
351+type queryContext struct {
352+ query string
353+}
354+
355+type rankedItem struct {
356+ item *CompletionItem
357+ score int
358+}
359+
360+// rank uses path-first fuzzy ordering with basename as a secondary boost.
361+func (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+
463+func 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+
473+type stringSource []string
474+
475+func (s stringSource) Len() int {
476+ return len(s)
477+}
478+
479+func (s stringSource) String(i int) string {
480+ return s[i]
481+}
482+
483+func 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+
495+func 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+
509+func 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+
312537func loadFiles(depth, limit int) []FileCompletionValue {
313538 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
314539 slices.Sort(files)
internal/ui/completions/completions_test.gonew file+99
@@ -0,0 +1,99 @@
1+package completions
2+
3+import (
4+ "testing"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/sahilm/fuzzy"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRankPrefersStrongBasenameMatch(t *testing.T) {
12+ t.Parallel()
13+
14+ c := &Completions{
15+ items: []*CompletionItem{
16+ NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
17+ NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
18+ NewCompletionItem("internal/user_service.go", FileCompletionValue{Path: "internal/user_service.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
19+ },
20+ paths: []string{"internal/ui/chat/search.go", "user.go", "internal/user_service.go"},
21+ bases: []string{"search.go", "user.go", "user_service.go"},
22+ }
23+
24+ ranked := c.rank(queryContext{query: "user"})
25+ require.NotEmpty(t, ranked)
26+ require.Equal(t, "user.go", ranked[0].Text())
27+}
28+
29+func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
30+ t.Parallel()
31+
32+ c := &Completions{
33+ items: []*CompletionItem{
34+ NewCompletionItem("b/user.go", FileCompletionValue{Path: "b/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
35+ NewCompletionItem("a/user.go", FileCompletionValue{Path: "a/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
36+ },
37+ paths: []string{"b/user.go", "a/user.go"},
38+ bases: []string{"user.go", "user.go"},
39+ }
40+
41+ ranked := c.rank(queryContext{query: ""})
42+ require.Len(t, ranked, 2)
43+ require.Equal(t, "b/user.go", ranked[0].Text())
44+ require.Equal(t, "a/user.go", ranked[1].Text())
45+}
46+
47+func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
48+ t.Parallel()
49+
50+ c := &Completions{
51+ items: []*CompletionItem{
52+ NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
53+ NewCompletionItem("internal/user.go", FileCompletionValue{Path: "internal/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
54+ NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
55+ },
56+ paths: []string{"user.go", "internal/user.go", "internal/ui/chat/search.go"},
57+ bases: []string{"user.go", "user.go", "search.go"},
58+ }
59+
60+ ranked := c.rank(queryContext{query: "internal/u"})
61+ require.NotEmpty(t, ranked)
62+ require.Equal(t, "internal/user.go", ranked[0].Text())
63+}
64+
65+func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
66+ t.Parallel()
67+
68+ c := &Completions{
69+ items: []*CompletionItem{
70+ NewCompletionItem("docs/go-guide.md", FileCompletionValue{Path: "docs/go-guide.md"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
71+ NewCompletionItem("src/user.go", FileCompletionValue{Path: "src/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
72+ },
73+ paths: []string{"docs/go-guide.md", "src/user.go"},
74+ bases: []string{"go-guide.md", "user.go"},
75+ }
76+
77+ ranked := c.rank(queryContext{query: ".go"})
78+ require.NotEmpty(t, ranked)
79+ require.Equal(t, "src/user.go", ranked[0].Text())
80+}
81+
82+func TestRemapMatchToPath(t *testing.T) {
83+ t.Parallel()
84+
85+ match := remapMatchToPath(
86+ fuzzy.Match{MatchedIndexes: []int{0, 1, 2}},
87+ "internal/user.go",
88+ )
89+ require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
90+}
91+
92+func TestHasPathHint(t *testing.T) {
93+ t.Parallel()
94+
95+ require.True(t, hasPathHint("internal/u"))
96+ require.True(t, hasPathHint("main.go"))
97+ require.False(t, hasPathHint("v0.1"))
98+ require.False(t, hasPathHint("main"))
99+}