From 0fcf79baf4aebd2b87cb49a8a287a18e6ad1dcac Mon Sep 17 00:00:00 2001 From: "wanghuaiyu@qiniu.com" Date: Thu, 19 Mar 2026 19:29:39 +0800 Subject: [PATCH] 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 --- internal/ui/completions/completions.go | 255 ++++++++++++++++++-- internal/ui/completions/completions_test.go | 99 ++++++++ 2 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 internal/ui/completions/completions_test.go diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index e20076b267b1129830f848d5dbff66d869592954..71f207f5727a730e1e21619482d6bf1b07e6c8ea 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -5,6 +5,7 @@ import ( "slices" "strings" "sync" + "unicode" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -14,6 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/ordered" + "github.com/sahilm/fuzzy" ) const ( @@ -21,6 +23,20 @@ const ( maxHeight = 10 minWidth = 10 maxWidth = 100 + + fullMatchWeight = 1_000 + baseMatchWeight = 300 + + pathPrefixBonus = 5_000 + pathContainsBonus = 2_000 + pathContainsHintBonus = 2_500 + basePrefixBonus = 1_500 + baseContainsBonus = 500 + basePrefixHintBonus = 300 + baseContainsHintBonus = 120 + + depthPenaltyDefault = 20 + depthPenaltyPathHint = 5 ) // SelectionMsg is sent when a completion is selected. @@ -58,6 +74,11 @@ type Completions struct { normalStyle lipgloss.Style focusedStyle lipgloss.Style matchStyle lipgloss.Style + + items []*CompletionItem + filtered []*CompletionItem + paths []string + bases []string } // New creates a new completions component. @@ -87,7 +108,7 @@ func (c *Completions) Query() string { // Size returns the visible size of the popup. func (c *Completions) Size() (width, height int) { - visible := len(c.list.FilteredItems()) + visible := len(c.filtered) return c.width, min(visible, c.height) } @@ -114,7 +135,7 @@ func (c *Completions) Open(depth, limit int) tea.Cmd { // SetItems sets the files and MCP resources and rebuilds the merged list. func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) { - items := make([]list.FilterableItem, 0, len(files)+len(resources)) + items := make([]*CompletionItem, 0, len(files)+len(resources)) // Add files first. for _, file := range files { @@ -142,8 +163,18 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource c.open = true c.query = "" - c.list.SetItems(items...) - c.list.SetFilter("") + c.items = items + c.paths = make([]string, len(items)) + c.bases = make([]string, len(items)) + for i, item := range items { + path := item.Filter() + c.paths[i] = path + c.bases[i] = pathBase(path) + } + c.filtered = c.rank(queryContext{ + query: c.query, + }) + c.setVisibleItems(c.filtered) c.list.Focus() c.width = maxWidth @@ -171,13 +202,16 @@ func (c *Completions) Filter(query string) { } c.query = query - c.list.SetFilter(query) + c.filtered = c.rank(queryContext{ + query: query, + }) + c.setVisibleItems(c.filtered) c.updateSize() } func (c *Completions) updateSize() { - items := c.list.FilteredItems() + items := c.filtered start, end := c.list.VisibleItemIndices() width := 0 for i := start; i <= end; i++ { @@ -197,7 +231,7 @@ func (c *Completions) updateSize() { // HasItems returns whether there are visible items. func (c *Completions) HasItems() bool { - return len(c.list.FilteredItems()) > 0 + return len(c.filtered) > 0 } // Update handles key events for the completions. @@ -236,7 +270,7 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) { // selectPrev selects the previous item with circular navigation. func (c *Completions) selectPrev() { - items := c.list.FilteredItems() + items := c.filtered if len(items) == 0 { return } @@ -248,7 +282,7 @@ func (c *Completions) selectPrev() { // selectNext selects the next item with circular navigation. func (c *Completions) selectNext() { - items := c.list.FilteredItems() + items := c.filtered if len(items) == 0 { return } @@ -260,7 +294,7 @@ func (c *Completions) selectNext() { // selectCurrent returns a command with the currently selected item. func (c *Completions) selectCurrent(keepOpen bool) tea.Msg { - items := c.list.FilteredItems() + items := c.filtered if len(items) == 0 { return nil } @@ -270,10 +304,7 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg { return nil } - item, ok := items[selected].(*CompletionItem) - if !ok { - return nil - } + item := items[selected] if !keepOpen { c.open = false @@ -301,7 +332,7 @@ func (c *Completions) Render() string { return "" } - items := c.list.FilteredItems() + items := c.filtered if len(items) == 0 { return "" } @@ -309,6 +340,200 @@ func (c *Completions) Render() string { return c.list.Render() } +func (c *Completions) setVisibleItems(items []*CompletionItem) { + filterables := make([]list.FilterableItem, 0, len(items)) + for _, item := range items { + filterables = append(filterables, item) + } + c.list.SetItems(filterables...) +} + +type queryContext struct { + query string +} + +type rankedItem struct { + item *CompletionItem + score int +} + +// rank uses path-first fuzzy ordering with basename as a secondary boost. +func (c *Completions) rank(ctx queryContext) []*CompletionItem { + query := strings.TrimSpace(ctx.query) + if query == "" { + for _, item := range c.items { + item.SetMatch(fuzzy.Match{}) + } + return c.items + } + + fullMatches := matchIndex(query, c.paths) + baseMatches := matchIndex(query, c.bases) + allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches)) + for idx := range fullMatches { + allIndexes[idx] = struct{}{} + } + for idx := range baseMatches { + allIndexes[idx] = struct{}{} + } + + queryLower := strings.ToLower(query) + pathHint := hasPathHint(query) + ranked := make([]rankedItem, 0, len(allIndexes)) + for idx := range allIndexes { + path := c.paths[idx] + pathLower := strings.ToLower(path) + baseLower := strings.ToLower(c.bases[idx]) + + fullMatch, hasFullMatch := fullMatches[idx] + baseMatch, hasBaseMatch := baseMatches[idx] + + pathPrefix := strings.HasPrefix(pathLower, queryLower) + pathContains := strings.Contains(pathLower, queryLower) + basePrefix := strings.HasPrefix(baseLower, queryLower) + baseContains := strings.Contains(baseLower, queryLower) + + score := 0 + if hasFullMatch { + score += fullMatch.Score * fullMatchWeight + } + if hasBaseMatch { + score += baseMatch.Score * baseMatchWeight + } + if pathPrefix { + score += pathPrefixBonus + } + if pathContains { + score += pathContainsBonus + } + if pathHint { + if pathContains { + score += pathContainsHintBonus + } + if basePrefix { + score += basePrefixHintBonus + } + if baseContains { + score += baseContainsHintBonus + } + } else { + if basePrefix { + score += basePrefixBonus + } + if baseContains { + score += baseContainsBonus + } + } + + depthPenalty := depthPenaltyDefault + if pathHint { + depthPenalty = depthPenaltyPathHint + } + score -= strings.Count(path, "/") * depthPenalty + score -= ansi.StringWidth(path) + + if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) { + c.items[idx].SetMatch(fullMatch) + } else if hasBaseMatch { + c.items[idx].SetMatch(remapMatchToPath(baseMatch, path)) + } else { + c.items[idx].SetMatch(fuzzy.Match{}) + } + + ranked = append(ranked, rankedItem{ + item: c.items[idx], + score: score, + }) + } + + slices.SortStableFunc(ranked, func(a, b rankedItem) int { + if a.score != b.score { + return b.score - a.score + } + return strings.Compare(a.item.Text(), b.item.Text()) + }) + + result := make([]*CompletionItem, 0, len(ranked)) + for _, item := range ranked { + result = append(result, item.item) + } + return result +} + +func matchIndex(query string, values []string) map[int]fuzzy.Match { + source := stringSource(values) + matches := fuzzy.FindFrom(query, source) + result := make(map[int]fuzzy.Match, len(matches)) + for _, match := range matches { + result[match.Index] = match + } + return result +} + +type stringSource []string + +func (s stringSource) Len() int { + return len(s) +} + +func (s stringSource) String(i int) string { + return s[i] +} + +func pathBase(value string) string { + trimmed := strings.TrimRight(value, `/\`) + if trimmed == "" { + return value + } + idx := strings.LastIndexAny(trimmed, `/\`) + if idx < 0 { + return trimmed + } + return trimmed[idx+1:] +} + +func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match { + base := pathBase(fullPath) + if base == "" { + return match + } + offset := len(fullPath) - len(base) + remapped := make([]int, 0, len(match.MatchedIndexes)) + for _, idx := range match.MatchedIndexes { + remapped = append(remapped, offset+idx) + } + match.MatchedIndexes = remapped + return match +} + +func hasPathHint(query string) bool { + if strings.Contains(query, "/") || strings.Contains(query, "\\") { + return true + } + + lastDot := strings.LastIndex(query, ".") + if lastDot < 0 || lastDot == len(query)-1 { + return false + } + + suffix := query[lastDot+1:] + if len(suffix) > 12 { + return false + } + + hasLetter := false + for _, r := range suffix { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' { + return false + } + if unicode.IsLetter(r) { + hasLetter = true + } + } + + return hasLetter +} + func loadFiles(depth, limit int) []FileCompletionValue { files, _, _ := fsext.ListDirectory(".", nil, depth, limit) slices.Sort(files) diff --git a/internal/ui/completions/completions_test.go b/internal/ui/completions/completions_test.go new file mode 100644 index 0000000000000000000000000000000000000000..be4b6cb42925aeba0dc67701aa9217d3a867f1e7 --- /dev/null +++ b/internal/ui/completions/completions_test.go @@ -0,0 +1,99 @@ +package completions + +import ( + "testing" + + "charm.land/lipgloss/v2" + "github.com/sahilm/fuzzy" + "github.com/stretchr/testify/require" +) + +func TestRankPrefersStrongBasenameMatch(t *testing.T) { + t.Parallel() + + c := &Completions{ + items: []*CompletionItem{ + NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + NewCompletionItem("internal/user_service.go", FileCompletionValue{Path: "internal/user_service.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + }, + paths: []string{"internal/ui/chat/search.go", "user.go", "internal/user_service.go"}, + bases: []string{"search.go", "user.go", "user_service.go"}, + } + + ranked := c.rank(queryContext{query: "user"}) + require.NotEmpty(t, ranked) + require.Equal(t, "user.go", ranked[0].Text()) +} + +func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) { + t.Parallel() + + c := &Completions{ + items: []*CompletionItem{ + NewCompletionItem("b/user.go", FileCompletionValue{Path: "b/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + NewCompletionItem("a/user.go", FileCompletionValue{Path: "a/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + }, + paths: []string{"b/user.go", "a/user.go"}, + bases: []string{"user.go", "user.go"}, + } + + ranked := c.rank(queryContext{query: ""}) + require.Len(t, ranked, 2) + require.Equal(t, "b/user.go", ranked[0].Text()) + require.Equal(t, "a/user.go", ranked[1].Text()) +} + +func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) { + t.Parallel() + + c := &Completions{ + items: []*CompletionItem{ + NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + NewCompletionItem("internal/user.go", FileCompletionValue{Path: "internal/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + }, + paths: []string{"user.go", "internal/user.go", "internal/ui/chat/search.go"}, + bases: []string{"user.go", "user.go", "search.go"}, + } + + ranked := c.rank(queryContext{query: "internal/u"}) + require.NotEmpty(t, ranked) + require.Equal(t, "internal/user.go", ranked[0].Text()) +} + +func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) { + t.Parallel() + + c := &Completions{ + items: []*CompletionItem{ + NewCompletionItem("docs/go-guide.md", FileCompletionValue{Path: "docs/go-guide.md"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + NewCompletionItem("src/user.go", FileCompletionValue{Path: "src/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()), + }, + paths: []string{"docs/go-guide.md", "src/user.go"}, + bases: []string{"go-guide.md", "user.go"}, + } + + ranked := c.rank(queryContext{query: ".go"}) + require.NotEmpty(t, ranked) + require.Equal(t, "src/user.go", ranked[0].Text()) +} + +func TestRemapMatchToPath(t *testing.T) { + t.Parallel() + + match := remapMatchToPath( + fuzzy.Match{MatchedIndexes: []int{0, 1, 2}}, + "internal/user.go", + ) + require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes) +} + +func TestHasPathHint(t *testing.T) { + t.Parallel() + + require.True(t, hasPathHint("internal/u")) + require.True(t, hasPathHint("main.go")) + require.False(t, hasPathHint("v0.1")) + require.False(t, hasPathHint("main")) +}