diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index b820fe8a00..a3ba813ed8 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -647,6 +647,7 @@ def __init__( self._cache_time: float = 0.0 self._cached_paths: list[str] = [] self._cache_scope: str | None = None + self._cache_query: str | None = None self._top_cache_time: float = 0.0 self._top_cached_paths: list[str] = [] self._fragment_hint: str | None = None @@ -712,6 +713,8 @@ def _get_deep_paths(self) -> list[str]: cache_valid = ( now - self._cache_time <= self._refresh_interval and self._cache_scope == scope ) + if cache_valid and self._is_git is False and scope is None: + cache_valid = self._cache_query == fragment # Invalidate on .git/index mtime change (like Claude Code). if cache_valid and self._is_git: @@ -730,10 +733,16 @@ def _get_deep_paths(self) -> list[str]: paths = list_files_git(self._root, scope) self._git_index_mtime = git_index_mtime(self._root) if paths is None: - paths = list_files_walk(self._root, scope, limit=self._limit) + paths = list_files_walk( + self._root, + scope, + limit=self._limit, + query=fragment if scope is None else None, + ) self._cached_paths = paths self._cache_scope = scope + self._cache_query = fragment if self._is_git is False and scope is None else None self._cache_time = now return self._cached_paths diff --git a/src/kimi_cli/utils/file_filter.py b/src/kimi_cli/utils/file_filter.py index 64ac127724..be32de4f97 100644 --- a/src/kimi_cli/utils/file_filter.py +++ b/src/kimi_cli/utils/file_filter.py @@ -3,6 +3,7 @@ import os import re import subprocess +from collections import deque from pathlib import Path _IGNORED_NAMES: frozenset[str] = frozenset( @@ -293,10 +294,16 @@ def list_files_walk( scope: str | None = None, *, limit: int = 1000, + query: str | None = None, + scan_limit: int | None = None, ) -> list[str]: """List workspace paths via ``os.walk`` (fallback for non-git repos). When *scope* is given, the walk starts from that subdirectory. + When *query* is given, only fuzzy subsequence matches are returned. The + scan remains bounded independently from the result limit so selective + queries can find entries beyond the first result page without turning a + completion request into an unbounded workspace walk. """ resolved_root = root.resolve() walk_root = (root / scope).resolve() if scope else resolved_root @@ -308,6 +315,15 @@ def list_files_walk( except (OSError, ValueError): return [] + if query: + return _list_files_walk_query( + resolved_root, + walk_root, + query=query, + limit=limit, + scan_limit=scan_limit if scan_limit is not None else limit * 10, + ) + paths: list[str] = [] try: for current_root, dirs, files in os.walk(walk_root): @@ -342,6 +358,52 @@ def list_files_walk( return paths +def _list_files_walk_query( + resolved_root: Path, + walk_root: Path, + *, + query: str, + limit: int, + scan_limit: int, +) -> list[str]: + """Return fuzzy matches from a bounded, lazy non-git workspace scan.""" + normalized_query = query.casefold() + pending = deque([walk_root]) + paths: list[str] = [] + scanned = 0 + + def matches(path: str) -> bool: + chars = iter(path.casefold()) + return all(any(char == candidate for candidate in chars) for char in normalized_query) + + while pending and scanned < scan_limit and len(paths) < limit: + directory = pending.popleft() + try: + with os.scandir(directory) as entries: + for entry in entries: + scanned += 1 + if scanned > scan_limit: + break + if is_ignored(entry.name): + continue + try: + relative = Path(entry.path).relative_to(resolved_root).as_posix() + is_dir = entry.is_dir(follow_symlinks=False) + except (OSError, ValueError): + continue + if is_dir: + pending.append(Path(entry.path)) + relative += "/" + if matches(relative): + paths.append(relative) + if len(paths) >= limit: + break + except OSError: + continue + + return paths + + def list_directory_filtered(directory: Path) -> list[dict[str, str | int]]: """List immediate children of *directory*, filtering ignored entries. diff --git a/tests/ui_and_conv/test_file_completer.py b/tests/ui_and_conv/test_file_completer.py index 9df041ee44..d4bfe8fe81 100644 --- a/tests/ui_and_conv/test_file_completer.py +++ b/tests/ui_and_conv/test_file_completer.py @@ -120,6 +120,34 @@ def test_scoped_walk_finds_late_alphabetical_dirs(tmp_path: Path): assert "zzz_target/important.py" in texts +def test_unscoped_query_finds_match_beyond_candidate_limit(tmp_path: Path): + """A selective query must search past the first candidate page. + + Regression test for #1610: non-git workspaces used the 1000-result + candidate limit as a scan limit, so a matching file sorted after those + entries could never be completed. + """ + for index in range(1100): + (tmp_path / f"aaa_{index:04d}.txt").write_text("") + target = tmp_path / "zzz_unique_needle.txt" + target.write_text("find me") + + completer = LocalFileMentionCompleter(tmp_path, limit=1000) + + texts = _completion_texts(completer, "@needle") + + assert target.name in texts + + +def test_unscoped_query_cache_is_keyed_by_fragment(tmp_path: Path): + (tmp_path / "unique_needle.txt").write_text("") + (tmp_path / "unique_other.txt").write_text("") + completer = LocalFileMentionCompleter(tmp_path) + + assert "unique_needle.txt" in _completion_texts(completer, "@needle") + assert "unique_other.txt" in _completion_texts(completer, "@other") + + def test_basename_prefix_is_ranked_first(tmp_path: Path): """Prefer basename prefix matches over cross-segment fuzzy matches. diff --git a/tests/utils/test_file_filter.py b/tests/utils/test_file_filter.py index a5fd2fbc88..fecfef8f9f 100644 --- a/tests/utils/test_file_filter.py +++ b/tests/utils/test_file_filter.py @@ -15,6 +15,26 @@ ) +def test_query_walk_uses_separate_bounded_scan_budget(tmp_path: Path) -> None: + for index in range(10): + (tmp_path / f"aaa_{index:02d}.txt").write_text("") + target = tmp_path / "zzz_needle.txt" + target.write_text("") + + assert target.name not in list_files_walk( + tmp_path, + query="needle", + limit=1000, + scan_limit=10, + ) + assert target.name in list_files_walk( + tmp_path, + query="needle", + limit=1000, + scan_limit=11, + ) + + def _init_git(root: Path) -> None: for cmd in ( ["git", "init"],