Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/kimi_cli/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Completion suggestions for one search can be reused for a different search in projects where git listing fails

The completion cache is tagged as having no specific search text (self._cache_query = ... else None at src/kimi_cli/ui/shell/prompt.py:745) even when the file results were actually narrowed to a single search text, so a later different search reuses the earlier search's suggestions.

Impact: In an affected project, typing one @ query and then a different one within a couple of seconds can show stale or empty completion results.

Mechanism: query-specific fallback results cached with a null query key

When _is_git is truthy but list_files_git returns None (git is present but git ls-files fails/times out), paths stays None and the code falls through to the query-aware walk with query=fragment (src/kimi_cli/ui/shell/prompt.py:735-741). The returned paths are therefore filtered to fragment. However, self._cache_query is written as fragment if self._is_git is False and scope is None else None (src/kimi_cli/ui/shell/prompt.py:745) — since _is_git is True, it is set to None. On the next call with a different fragment, the invalidation guard if cache_valid and self._is_git is False and scope is None: cache_valid = self._cache_query == fragment (src/kimi_cli/ui/shell/prompt.py:716-717) does not fire (because _is_git is True), and if the .git/index mtime is unchanged the cache is treated as valid, returning the previous query's filtered paths. The downstream FuzzyCompleter then re-filters that stale subset against the new fragment, typically yielding wrong or empty suggestions until the 2s refresh interval expires. The cache key should track whether the query walk was actually used (i.e. whenever the fallback walk ran with a non-None query), not whether _is_git is False.

Prompt for agents
In _get_deep_paths in src/kimi_cli/ui/shell/prompt.py, the fallback walk is invoked with query=fragment whenever scope is None and the git listing did not produce results (this includes the case where _is_git is True but list_files_git returned None). However both the cache-write (self._cache_query = fragment if self._is_git is False and scope is None else None) and the cache-validity guard (if cache_valid and self._is_git is False and scope is None: cache_valid = self._cache_query == fragment) only account for the _is_git is False case. As a result, when git is detected but ls-files fails, query-specific fallback results get cached under a None query key and are incorrectly reused for a different subsequent query within the refresh interval. Fix by keying the cache on whether the query walk actually ran (e.g. track a boolean of whether paths came from list_files_walk with a non-None query, and store/compare _cache_query accordingly), rather than conditioning on _is_git is False. Ensure the validity guard uses the same condition as the write side.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

self._cache_time = now
return self._cached_paths

Expand Down
62 changes: 62 additions & 0 deletions src/kimi_cli/utils/file_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import re
import subprocess
from collections import deque
from pathlib import Path

_IGNORED_NAMES: frozenset[str] = frozenset(
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions tests/ui_and_conv/test_file_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 20 additions & 0 deletions tests/utils/test_file_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading