feat(ui): prioritize filename-based completion ranking

wanghuaiyu@qiniu.com created

Change summary

internal/ui/completions/completions.go      | 412 +++++-----------------
internal/ui/completions/completions_test.go | 163 ++++----
2 files changed, 175 insertions(+), 400 deletions(-)

Detailed changes

internal/ui/completions/completions.go 🔗

@@ -2,10 +2,10 @@ package completions
 
 import (
 	"cmp"
+	"path/filepath"
 	"slices"
 	"strings"
 	"sync"
-	"unicode"
 
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
@@ -15,7 +15,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/ordered"
-	"github.com/sahilm/fuzzy"
 )
 
 const (
@@ -24,32 +23,10 @@ const (
 	minWidth  = 10
 	maxWidth  = 100
 
-	// Scoring weights for fuzzy matching.
-	// Full path fuzzy match contributes most to the score.
-	fullMatchWeight = 1_000
-	// Basename fuzzy match contributes less than full path.
-	baseMatchWeight = 300
-
-	// Bonus points for exact matches (case-insensitive).
-	// Path prefix match (e.g., "src/" matches "src/main.go") gets highest bonus.
-	pathPrefixBonus = 5_000
-	// Path contains match (e.g., "main" matches "src/main.go").
-	pathContainsBonus = 2_000
-	// Additional bonus when path hint is detected (user typed "/" or file extension).
-	pathContainsHintBonus = 2_500
-	// Basename prefix match (e.g., "main" matches "main.go").
-	basePrefixBonus = 1_500
-	// Basename contains match (e.g., "mai" matches "main.go").
-	baseContainsBonus = 500
-	// Smaller bonuses when path hint is detected.
-	basePrefixHintBonus   = 300
-	baseContainsHintBonus = 120
-
-	// Penalties for deeply nested files to favor shallow matches.
-	// Default penalty per directory level (e.g., "a/b/c" has 2 levels).
-	depthPenaltyDefault = 20
-	// Reduced penalty when user explicitly queries a path (typed "/" or extension).
-	depthPenaltyPathHint = 5
+	tierExactName = iota
+	tierPrefixName
+	tierPathSegment
+	tierFallback
 )
 
 // SelectionMsg is sent when a completion is selected.
@@ -88,11 +65,34 @@ type Completions struct {
 	focusedStyle lipgloss.Style
 	matchStyle   lipgloss.Style
 
-	// Custom ranking state (replaces list.FilterableList's built-in filtering).
-	items    []*CompletionItem // All completion items.
-	filtered []*CompletionItem // Filtered and ranked items based on query.
-	paths    []string          // Pre-computed full paths for matching.
-	bases    []string          // Pre-computed basenames for matching.
+	allItems []list.FilterableItem
+	filtered []list.FilterableItem
+}
+
+type namePriorityRule struct {
+	tier  int
+	match func(pathLower, baseLower, stemLower, queryLower string) bool
+}
+
+var namePriorityRules = []namePriorityRule{
+	{
+		tier: tierExactName,
+		match: func(_ string, baseLower, stemLower, queryLower string) bool {
+			return baseLower == queryLower || stemLower == queryLower
+		},
+	},
+	{
+		tier: tierPrefixName,
+		match: func(_ string, baseLower, _ string, queryLower string) bool {
+			return strings.HasPrefix(baseLower, queryLower)
+		},
+	},
+	{
+		tier: tierPathSegment,
+		match: func(pathLower, _ string, _ string, queryLower string) bool {
+			return hasPathSegment(pathLower, queryLower)
+		},
+	},
 }
 
 // New creates a new completions component.
@@ -149,7 +149,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([]*CompletionItem, 0, len(files)+len(resources))
+	items := make([]list.FilterableItem, 0, len(files)+len(resources))
 
 	// Add files first.
 	for _, file := range files {
@@ -177,20 +177,10 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
 
 	c.open = true
 	c.query = ""
-	c.items = items
-	// Pre-compute paths and basenames for efficient fuzzy matching.
-	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)
-	}
-	// Perform initial ranking with empty query (returns all items).
-	c.filtered = c.rank(queryContext{
-		query: c.query,
-	})
-	c.setVisibleItems(c.filtered)
+	c.allItems = items
+	c.filtered = append([]list.FilterableItem(nil), items...)
+	c.list.SetItems(c.filtered...)
+	c.list.SetFilter("")
 	c.list.Focus()
 
 	c.width = maxWidth
@@ -218,15 +208,65 @@ func (c *Completions) Filter(query string) {
 	}
 
 	c.query = query
-	// Apply custom ranking algorithm instead of list's built-in filtering.
-	c.filtered = c.rank(queryContext{
-		query: query,
-	})
-	c.setVisibleItems(c.filtered)
+	c.applyNamePriorityFilter(query)
 
 	c.updateSize()
 }
 
+func (c *Completions) applyNamePriorityFilter(query string) {
+	if query == "" {
+		c.filtered = append([]list.FilterableItem(nil), c.allItems...)
+		c.list.SetItems(c.filtered...)
+		return
+	}
+
+	c.list.SetItems(c.allItems...)
+	c.list.SetFilter(query)
+	raw := c.list.FilteredItems()
+	filtered := make([]list.FilterableItem, 0, len(raw))
+	for _, item := range raw {
+		filterable, ok := item.(list.FilterableItem)
+		if !ok {
+			continue
+		}
+		filtered = append(filtered, filterable)
+	}
+
+	queryLower := strings.ToLower(strings.TrimSpace(query))
+	slices.SortStableFunc(filtered, func(a, b list.FilterableItem) int {
+		return namePriorityTier(a.Filter(), queryLower) - namePriorityTier(b.Filter(), queryLower)
+	})
+	c.filtered = filtered
+	c.list.SetItems(c.filtered...)
+}
+
+func namePriorityTier(path, queryLower string) int {
+	if queryLower == "" {
+		return tierFallback
+	}
+
+	pathLower := strings.ToLower(path)
+	baseLower := strings.ToLower(filepath.Base(strings.ReplaceAll(path, `\`, `/`)))
+	stemLower := strings.TrimSuffix(baseLower, filepath.Ext(baseLower))
+	for _, rule := range namePriorityRules {
+		if rule.match(pathLower, baseLower, stemLower, queryLower) {
+			return rule.tier
+		}
+	}
+	return tierFallback
+}
+
+func hasPathSegment(pathLower, queryLower string) bool {
+	for _, part := range strings.FieldsFunc(pathLower, func(r rune) bool {
+		return r == '/' || r == '\\'
+	}) {
+		if part == queryLower {
+			return true
+		}
+	}
+	return false
+}
+
 func (c *Completions) updateSize() {
 	items := c.filtered
 	start, end := c.list.VisibleItemIndices()
@@ -321,7 +361,10 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
 		return nil
 	}
 
-	item := items[selected]
+	item, ok := items[selected].(*CompletionItem)
+	if !ok {
+		return nil
+	}
 
 	if !keepOpen {
 		c.open = false
@@ -354,266 +397,7 @@ func (c *Completions) Render() string {
 		return ""
 	}
 
-	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.
-//
-// Ranking strategy:
-// 1. Perform fuzzy matching on both full paths and basenames.
-// 2. Apply bonus points for exact prefix/contains matches.
-// 3. Adjust bonuses based on whether query contains path hints (/, \, or file extension).
-// 4. Apply depth penalty to favor shallower files.
-// 5. Sort by score (descending), then alphabetically.
-//
-// Example scoring for query "main.go":
-//   - "main.go" (root): high fuzzy + pathPrefix + basePrefix + low depth penalty
-//   - "src/main.go": high fuzzy + pathContains + basePrefix + moderate depth penalty
-//   - "test/helper/main.go": high fuzzy + pathContains + basePrefix + high depth penalty
-func (c *Completions) rank(ctx queryContext) []*CompletionItem {
-	query := strings.TrimSpace(ctx.query)
-	if query == "" {
-		// Empty query: return all items with no highlights.
-		for _, item := range c.items {
-			item.SetMatch(fuzzy.Match{})
-		}
-		return c.items
-	}
-
-	// Perform fuzzy matching on both full paths and basenames.
-	fullMatches := matchIndex(query, c.paths)
-	baseMatches := matchIndex(query, c.bases)
-	// Collect unique item indices that matched either full path or basename.
-	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)
-	// Detect if query looks like a path (contains / or \ or file extension).
-	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]
-
-		// Check for exact (case-insensitive) prefix/contains matches.
-		pathPrefix := strings.HasPrefix(pathLower, queryLower)
-		pathContains := strings.Contains(pathLower, queryLower)
-		basePrefix := strings.HasPrefix(baseLower, queryLower)
-		baseContains := strings.Contains(baseLower, queryLower)
-
-		// Calculate score by accumulating weighted components.
-		score := 0
-		// Fuzzy match scores (primary signals).
-		if hasFullMatch {
-			score += fullMatch.Score * fullMatchWeight
-		}
-		if hasBaseMatch {
-			score += baseMatch.Score * baseMatchWeight
-		}
-		// Path-level exact match bonuses.
-		if pathPrefix {
-			score += pathPrefixBonus
-		}
-		if pathContains {
-			score += pathContainsBonus
-		}
-		// Apply different bonuses based on whether query contains path hints.
-		if pathHint {
-			// User typed a path-like query (e.g., "src/main.go" or "main.go").
-			// Prioritize full path matches.
-			if pathContains {
-				score += pathContainsHintBonus
-			}
-			if basePrefix {
-				score += basePrefixHintBonus
-			}
-			if baseContains {
-				score += baseContainsHintBonus
-			}
-		} else {
-			// User typed a simple query (e.g., "main").
-			// Prioritize basename matches.
-			if basePrefix {
-				score += basePrefixBonus
-			}
-			if baseContains {
-				score += baseContainsBonus
-			}
-		}
-
-		// Apply penalties to discourage deeply nested files.
-		depthPenalty := depthPenaltyDefault
-		if pathHint {
-			// Reduce penalty when user explicitly queries a path.
-			depthPenalty = depthPenaltyPathHint
-		}
-		score -= strings.Count(path, "/") * depthPenalty
-		// Minor penalty based on path length (favor shorter paths).
-		score -= ansi.StringWidth(path)
-
-		// Choose which match to highlight based on weighted contribution.
-		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 {
-			// Higher score first.
-			return b.score - a.score
-		}
-		// Tie-breaker: sort alphabetically.
-		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
-}
-
-// matchIndex performs fuzzy matching and returns a map of item index to match 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
-}
-
-// stringSource adapts []string to fuzzy.Source interface.
-type stringSource []string
-
-func (s stringSource) Len() int {
-	return len(s)
-}
-
-func (s stringSource) String(i int) string {
-	return s[i]
-}
-
-// pathBase extracts the basename from a file path (handles both / and \ separators).
-// Examples:
-//   - "src/main.go" → "main.go"
-//   - "file.txt" → "file.txt"
-//   - "dir/" → "dir/"
-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:]
-}
-
-// remapMatchToPath remaps a basename match's character indices to full path indices.
-// Example:
-//   - baseMatch for "main" in "main.go" with indices [0,1,2,3]
-//   - fullPath is "src/main.go" (offset 4)
-//   - Result: [4,5,6,7] (highlights "main" in full path)
-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
-}
-
-// hasPathHint detects if the query looks like a file path query.
-// Returns true if:
-//   - Query contains "/" or "\" (explicit path separator)
-//   - Query ends with a file extension pattern (e.g., ".go", ".ts")
-//
-// File extension heuristics:
-//   - Must have a dot not at start/end (e.g., "main.go" ✓, "v0.1" ✗, ".gitignore" ✓)
-//   - Extension must be ≤12 chars (e.g., ".go" ✓, ".verylongextension" ✗)
-//   - Extension must contain at least one letter and only alphanumeric/_/- chars
-//
-// Examples:
-//   - "src/main" → true (contains /)
-//   - "main.go" → true (file extension)
-//   - ".gitignore" → true (file extension)
-//   - "v0.1" → false (no letter in suffix)
-//   - "main" → false (no path hint)
-func hasPathHint(query string) bool {
-	if strings.Contains(query, "/") || strings.Contains(query, "\\") {
-		return true
-	}
-
-	// Check for file extension pattern.
-	lastDot := strings.LastIndex(query, ".")
-	if lastDot < 0 || lastDot == len(query)-1 {
-		// No dot or dot at end (e.g., "main" or "foo.").
-		return false
-	}
-
-	suffix := query[lastDot+1:]
-	if len(suffix) > 12 {
-		// Extension too long (unlikely to be a real extension).
-		return false
-	}
-
-	// Validate that suffix looks like a file extension:
-	// - Contains only alphanumeric, underscore, or hyphen.
-	// - Contains at least one letter (to exclude version numbers like "v0.1").
-	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
+	return c.list.List.Render()
 }
 
 func loadFiles(depth, limit int) []FileCompletionValue {

internal/ui/completions/completions_test.go 🔗

@@ -4,115 +4,106 @@ import (
 	"testing"
 
 	"charm.land/lipgloss/v2"
-	"github.com/sahilm/fuzzy"
 	"github.com/stretchr/testify/require"
 )
 
-// TestRankPrefersStrongBasenameMatch verifies that when no path hint is present,
-// files with exact basename matches rank higher than partial path matches.
-// Query "user" should prefer "user.go" over "internal/user_service.go".
-func TestRankPrefersStrongBasenameMatch(t *testing.T) {
+func TestFilterPrefersExactBasenameStem(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"},
-	}
+	c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle())
+	c.SetItems([]FileCompletionValue{
+		{Path: "internal/ui/chat/search.go"},
+		{Path: "internal/ui/chat/user.go"},
+	}, nil)
 
-	ranked := c.rank(queryContext{query: "user"})
-	require.NotEmpty(t, ranked)
-	require.Equal(t, "user.go", ranked[0].Text())
-}
-
-// TestRankReturnsOriginalOrderForEmptyQuery verifies that empty queries
-// return all items in their original order without reordering.
-func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
-	t.Parallel()
+	c.Filter("user")
 
-	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())
+	filtered := c.filtered
+	require.NotEmpty(t, filtered)
+	first, ok := filtered[0].(*CompletionItem)
+	require.True(t, ok)
+	require.Equal(t, "internal/ui/chat/user.go", first.Text())
+	require.NotEmpty(t, first.match.MatchedIndexes)
 }
 
-// TestRankPrefersPathMatchesWhenPathHintPresent verifies that when query
-// contains a path separator (/), path-level matches are prioritized.
-// Query "internal/u" should rank "internal/user.go" highest.
-func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
+func TestFilterPrefersBasenamePrefix(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"},
-	}
+	c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle())
+	c.SetItems([]FileCompletionValue{
+		{Path: "internal/ui/chat/mcp.go"},
+		{Path: "internal/ui/model/chat.go"},
+	}, nil)
+
+	c.Filter("chat.g")
 
-	ranked := c.rank(queryContext{query: "internal/u"})
-	require.NotEmpty(t, ranked)
-	require.Equal(t, "internal/user.go", ranked[0].Text())
+	filtered := c.filtered
+	require.NotEmpty(t, filtered)
+	first, ok := filtered[0].(*CompletionItem)
+	require.True(t, ok)
+	require.Equal(t, "internal/ui/model/chat.go", first.Text())
+	require.NotEmpty(t, first.match.MatchedIndexes)
 }
 
-// TestRankDotHintPrefersSuffixPathMatch verifies that file extension queries
-// (e.g., ".go") trigger path hint behavior and prioritize extension matches.
-// Query ".go" should rank "user.go" higher than "go-guide.md".
-func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
+func TestNamePriorityTier(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()),
+	tests := []struct {
+		name     string
+		path     string
+		query    string
+		wantTier int
+	}{
+		{
+			name:     "exact stem",
+			path:     "internal/ui/chat/user.go",
+			query:    "user",
+			wantTier: tierExactName,
+		},
+		{
+			name:     "basename prefix",
+			path:     "internal/ui/model/chat.go",
+			query:    "chat.g",
+			wantTier: tierPrefixName,
+		},
+		{
+			name:     "path segment exact",
+			path:     "internal/ui/chat/mcp.go",
+			query:    "chat",
+			wantTier: tierPathSegment,
+		},
+		{
+			name:     "fallback",
+			path:     "internal/ui/chat/search.go",
+			query:    "user",
+			wantTier: tierFallback,
 		},
-		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())
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := namePriorityTier(tt.path, tt.query)
+			require.Equal(t, tt.wantTier, got)
+		})
+	}
 }
 
-// TestRemapMatchToPath verifies that basename match indices are correctly
-// remapped to full path indices. For "user" matched in "user.go" at [0,1,2],
-// when full path is "internal/user.go", indices become [9,10,11].
-func TestRemapMatchToPath(t *testing.T) {
+func TestFilterPrefersPathSegmentExact(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)
-}
+	c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle())
+	c.SetItems([]FileCompletionValue{
+		{Path: "internal/ui/model/xychat.go"},
+		{Path: "internal/ui/chat/mcp.go"},
+	}, nil)
 
-// TestHasPathHint verifies the heuristics for detecting path-like queries.
-// - "internal/u" → true (contains /)
-// - "main.go" → true (file extension)
-// - "v0.1" → false (no letter in suffix)
-// - "main" → false (no path hint)
-func TestHasPathHint(t *testing.T) {
-	t.Parallel()
+	c.Filter("chat")
 
-	require.True(t, hasPathHint("internal/u"))
-	require.True(t, hasPathHint("main.go"))
-	require.False(t, hasPathHint("v0.1"))
-	require.False(t, hasPathHint("main"))
+	filtered := c.filtered
+	require.NotEmpty(t, filtered)
+	first, ok := filtered[0].(*CompletionItem)
+	require.True(t, ok)
+	require.Equal(t, "internal/ui/chat/mcp.go", first.Text())
 }