Files
EvoScientist-Multi/EvoScientist/cli/file_mentions.py
T
Xi Zhang fab5f85eee v0.0.4 (#93)
* feat(tui): enhance conversation history rendering and implement two-level thread hierarchy in picker

* feat(tui): improve conversation history display and enhance thread selection UI

* feat(file_mentions): implement @file mention parsing and completion for CLI and TUI

* feat(uv-tool): add compatibility checks and installation helpers for uv tool environments

* feat(dependencies): update package versions in uv.lock for compatibility and improvements

* feat(badges): update PyPI version to v0.0.4 in SVG assets and README files

* feat(tests): format code in TestUvToolCompat for improved readability
2026-03-24 18:13:42 +00:00

344 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""@file mention parsing and injection for CLI and TUI input.
Usage::
text, injected = resolve_file_mentions(user_input, workspace_dir)
# text — original input unchanged
# injected — full prompt with file contents appended (or original if no mentions)
"""
from __future__ import annotations
import re
from difflib import SequenceMatcher
from pathlib import Path
# ---------------------------------------------------------------------------
# Patterns
# ---------------------------------------------------------------------------
_PATH_CHARS = r"A-Za-z0-9._~/\\:-"
FILE_MENTION_PATTERN = re.compile(r"@(?P<path>(?:\\.|[" + _PATH_CHARS + r"])+)")
"""Matches ``@path/to/file`` in user input.
Escaped spaces (``@my\\\\ folder/file``) are supported. Bare ``@`` with no
path characters is not matched (uses ``+`` not ``*``).
"""
_EMAIL_PREFIX = re.compile(r"[a-zA-Z0-9._%+-]$")
"""If the character immediately before ``@`` matches this, it's an email address."""
# Files larger than this are referenced by path only (not embedded inline).
_MAX_EMBED_BYTES = 256 * 1024 # 256 KB
# Fuzzy search thresholds (ported from DeepAgents FuzzyFileController)
_MIN_FUZZY_SCORE = 15
_MIN_FUZZY_RATIO = 0.4
# Max files to index per workspace
_MAX_WORKSPACE_FILES = 1000
# ---------------------------------------------------------------------------
# Module-level file cache
# ---------------------------------------------------------------------------
_file_cache: dict[str, list[str]] = {}
"""workspace_dir -> sorted list of relative POSIX paths"""
def _get_workspace_files(root: Path) -> list[str]:
"""Glob workspace files up to 4 levels deep, skipping hidden entries."""
files: list[str] = []
for pattern in ["*", "*/*", "*/*/*", "*/*/*/*"]:
for p in root.glob(pattern):
if not p.is_file():
continue
rel = p.relative_to(root)
# Skip any part that starts with '.'
if any(part.startswith(".") for part in rel.parts):
continue
files.append(rel.as_posix())
if len(files) >= _MAX_WORKSPACE_FILES:
return files
return files
def _get_cached_files(workspace_dir: str) -> list[str]:
"""Return cached file list for *workspace_dir*, scanning if necessary."""
if workspace_dir not in _file_cache:
_file_cache[workspace_dir] = _get_workspace_files(Path(workspace_dir))
return _file_cache[workspace_dir]
def invalidate_file_cache(workspace_dir: str | None = None) -> None:
"""Invalidate the workspace file cache.
Call when the workspace changes (e.g. ``/new``, ``/resume``).
Args:
workspace_dir: If given, invalidate only that workspace entry.
If ``None``, clear the entire cache.
"""
if workspace_dir:
_file_cache.pop(workspace_dir, None)
else:
_file_cache.clear()
# ---------------------------------------------------------------------------
# Fuzzy scoring (ported from DeepAgents FuzzyFileController)
# ---------------------------------------------------------------------------
def _fuzzy_score(query: str, candidate: str) -> float:
"""Score how well *query* matches *candidate* path.
Four-level priority (higher = better match):
1. Filename starts with query (150 base + length bonus)
2. Filename contains query as substring (100–120)
3. Full path contains query as substring (40–80)
4. SequenceMatcher ratio on filename (15–30)
Returns 0 when below ``_MIN_FUZZY_SCORE``.
"""
q = query.lower()
c = candidate.lower()
filename = c.split("/")[-1]
# Level 1: filename starts with query
if filename.startswith(q):
return 150 + len(q)
# Level 2: filename contains query
if q in filename:
bonus = 20 if filename.startswith(q[:1]) else 0
return 100 + bonus
# Level 3: full path contains query
if q in c:
depth_bonus = max(0, 40 - candidate.count("/") * 5)
return 40 + depth_bonus
# Level 4: SequenceMatcher on filename
ratio = SequenceMatcher(None, q, filename).ratio()
if ratio >= _MIN_FUZZY_RATIO:
return 15 + ratio * 15
return 0
def _fuzzy_search(
query: str,
candidates: list[str],
limit: int = 10,
) -> list[str]:
"""Return up to *limit* candidates from *candidates* ranked by fuzzy score.
When *query* is empty, returns the first *limit* candidates sorted by
depth then name (shallowest, alphabetical first).
"""
if not query:
# Tree order: group by top-level component, dir entry before its children,
# root-level files sorted among top-level dirs alphabetically.
def _tree_key(p: str) -> tuple:
top = p.split("/")[0] # first path component (no slash)
is_file_entry = 0 if p.endswith("/") else 1 # dir entry sorts first
return (top.lower(), is_file_entry, p.lower())
return sorted(candidates, key=_tree_key)[:limit]
scored = [
(score, c)
for c in candidates
if (score := _fuzzy_score(query, c)) >= _MIN_FUZZY_SCORE
]
return [c for _, c in sorted(scored, key=lambda x: -x[0])[:limit]]
# ---------------------------------------------------------------------------
# Core helpers
# ---------------------------------------------------------------------------
def _read_file(path: Path) -> str:
"""Return a Markdown snippet for embedding the file inline.
Files larger than ``_MAX_EMBED_BYTES`` get a path-only reference with a
hint to use the ``read_file`` tool instead.
"""
size = path.stat().st_size
if size > _MAX_EMBED_BYTES:
size_kb = size // 1024
return (
f"\n### {path.name}\n"
f"Path: `{path}`\n"
f"Size: {size_kb} KB (too large to embed inline — "
"use the read_file tool to view it)"
)
content = path.read_text(encoding="utf-8", errors="replace")
return f"\n### {path.name}\nPath: `{path}`\n```\n{content}\n```"
def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]:
"""Extract resolved ``@file`` paths from *text*.
Args:
text: Raw user input that may contain ``@path`` mentions.
cwd: Base directory for resolving relative paths. Defaults to the
process working directory.
Returns:
List of resolved, existing ``Path`` objects (directories excluded).
Unresolvable or missing paths are skipped with a printed warning.
"""
if cwd is None:
cwd = Path.cwd()
files: list[Path] = []
for match in FILE_MENTION_PATTERN.finditer(text):
# Skip email addresses — character immediately before @ is alphanumeric
before = text[: match.start()]
if before and _EMAIL_PREFIX.search(before):
continue
raw = match.group("path")
clean = raw.replace("\\ ", " ")
try:
p = Path(clean).expanduser()
if not p.is_absolute():
p = cwd / p
resolved = p.resolve()
if resolved.exists() and resolved.is_file():
files.append(resolved)
else:
print(f"[warning] @file not found: {raw}")
except (OSError, RuntimeError) as exc:
print(f"[warning] invalid @file path {raw!r}: {exc}")
return files
def resolve_file_mentions(
text: str,
workspace_dir: str | None = None,
) -> tuple[str, str]:
"""Parse ``@file`` mentions and return *(original_text, final_prompt)*.
*final_prompt* equals *original_text* when no valid mentions are found,
otherwise it appends a ``## Referenced Files`` section with the file
contents embedded as fenced code blocks.
Args:
text: Raw user input.
workspace_dir: Workspace root used for resolving relative paths.
Returns:
``(original_text, final_prompt)`` — the first element is always the
unchanged input; the second is the prompt to send to the agent.
"""
cwd = Path(workspace_dir) if workspace_dir else None
files = parse_file_mentions(text, cwd=cwd)
if not files:
return text, text
parts = [text, "\n\n## Referenced Files\n"]
for path in files:
try:
parts.append(_read_file(path))
except (OSError, UnicodeDecodeError) as exc:
parts.append(f"\n### {path.name}\n[Error reading file: {exc}]")
return text, "\n".join(parts)
# ---------------------------------------------------------------------------
# Autocomplete helpers (used by CLI completer and TUI)
# ---------------------------------------------------------------------------
def _type_hint(rel_path: str) -> str:
"""Return a short type label for *rel_path* (extension or ``'file'``)."""
suffix = rel_path.rsplit(".", 1)[-1] if "." in rel_path.split("/")[-1] else ""
return suffix or "file"
def complete_file_mention(
text: str,
workspace_dir: str | None = None,
) -> list[tuple[str, str]]:
"""Return candidate file paths for the ``@`` prefix at the end of *text*.
Scans the workspace (up to 4 levels deep) and returns fuzzy-matched
file/dir names relative to *workspace_dir* (or cwd). Returns ``[]``
when *text* does not end with an ``@``-started token.
Args:
text: Current input text (up to cursor position).
workspace_dir: Root directory to scan for completions.
Returns:
List of ``(completion_string, type_hint)`` tuples, e.g.
``[("@results/v2.json", "json"), ("@README.md", "md")]``.
Directories have a trailing ``/`` and type hint ``"dir"``.
"""
# Find the last @token
match = re.search(r"@([^\s]*)$", text)
if not match:
return []
partial = match.group(1).replace("\\ ", " ")
base_str = workspace_dir or str(Path.cwd())
base = Path(base_str)
# If partial contains a path separator, check for subdirectory listing
if partial.endswith("/"):
# List directory contents
sub = (base / partial.rstrip("/")).resolve()
if not sub.is_dir():
return []
candidates_raw: list[str] = []
try:
for entry in sorted(sub.iterdir()):
if entry.name.startswith("."):
continue
rel = entry.relative_to(base)
suffix = "/" if entry.is_dir() else ""
candidates_raw.append(rel.as_posix() + suffix)
except OSError:
return []
return [
(f"@{r}", "dir" if r.endswith("/") else _type_hint(r))
for r in candidates_raw[:10]
]
# Fuzzy search over cached workspace files
all_files = _get_cached_files(base_str)
# Also add top-level directories (for dir completion)
dir_candidates: list[str] = []
try:
for entry in sorted(base.iterdir()):
if entry.is_dir() and not entry.name.startswith("."):
dir_candidates.append(entry.name + "/")
except OSError:
pass
combined = all_files + dir_candidates
# Determine query: if partial has a slash, search within that subtree
if "/" in partial:
# Filter candidates to those starting with the directory prefix
dir_prefix = partial.rsplit("/", 1)[0] + "/"
file_query = partial.rsplit("/", 1)[1]
subtree = [c for c in combined if c.startswith(dir_prefix)]
results = _fuzzy_search(file_query, subtree)
else:
results = _fuzzy_search(partial, combined)
return [(f"@{r}", "dir" if r.endswith("/") else _type_hint(r)) for r in results]