"""@ file completion — shared UI-less logic for tui_v2 / tui_v3. File index (os.scandir, cached per root) + fuzzy match + @token detection + insert text. No UI deps; each front-end renders candidates its own way and calls candidates_for(query, root). Index root is the front-end's choice (session workspace, else CWD). Submit-time: completion-only does NOT read content, but absolutize_mentions() rewrites @relative → @absolute so the agent's file_read (relative to its own cwd) can locate the file. The content-injecting auto-read variant lives in temp/plan_v2_at_mention/autoread_version.py. """ import os import re import threading # ---------------------------------------------------------------- index _IGNORE_DIRS = { ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", ".next", ".idea", ".vscode", "target", ".cache", ".eggs", "model_responses", # GA 会话日志(上千个 .txt),未绑时根=temp 会淹没 @ 候选 } _IGNORE_EXT = {".pyc", ".pyo", ".so", ".o", ".class", ".lock", ".dll", ".exe"} _MAX_FILES = 50_000 # 超大目录宁缺毋卡:到上限就停 def scan_files(root: str, max_files: int = _MAX_FILES) -> list[str]: """Collect relative file paths under root, '/'-normalized. os.scandir over os.walk: one syscall yields is_dir without an extra stat per entry. Dotted dirs are skipped wholesale (.git, .venv...). """ out: list[str] = [] stack = [root] while stack and len(out) < max_files: d = stack.pop() try: with os.scandir(d) as it: for e in it: try: if e.is_dir(follow_symlinks=False): if e.name not in _IGNORE_DIRS and not e.name.startswith("."): stack.append(e.path) elif e.is_file(follow_symlinks=False): if os.path.splitext(e.name)[1].lower() not in _IGNORE_EXT: rel = os.path.relpath(e.path, root).replace("\\", "/") out.append(rel) if len(out) >= max_files: return out except OSError: continue except OSError: continue return out class FileIndexCache: """Per-root background file index. warm() is idempotent-cheap: a rebuild is only started when none is in flight.""" def __init__(self, root: str): self.root = root self._files: list[str] = [] self._lock = threading.Lock() self._building = False self.ready = threading.Event() def warm(self) -> None: with self._lock: if self._building: return self._building = True def _build(): try: files = scan_files(self.root) with self._lock: self._files = files self.ready.set() finally: with self._lock: self._building = False threading.Thread(target=_build, name="ga-at-index", daemon=True).start() def snapshot(self) -> list[str]: with self._lock: return self._files _indexes: dict[str, FileIndexCache] = {} _indexes_lk = threading.Lock() def get_index(root: str) -> FileIndexCache: key = os.path.normcase(os.path.realpath(root or os.getcwd())) with _indexes_lk: idx = _indexes.get(key) if idx is None: idx = _indexes[key] = FileIndexCache(root) return idx # ---------------------------------------------------------------- fuzzy def _subseq_score(q: str, path: str): """Subsequence match score (higher = better), None when q doesn't fully appear in order. Contiguous runs dominate (fzf-style): scattered one-char hits across a long path must not beat a tight cluster. Word-boundary hits and basename substring add on top; ties broken by caller on shorter path.""" if not q: return 0 score, qi, prev_hit = 0, 0, -2 for pi, ch in enumerate(path): if qi < len(q) and ch == q[qi]: score += 1 if pi == prev_hit + 1: score += 2 # contiguous run: the dominant signal if pi == 0 or path[pi - 1] in "/\\_-. ": score += 3 prev_hit = pi qi += 1 if qi < len(q): return None base = path.rsplit("/", 1)[-1] if q in base: score += 8 elif q in path: score += 4 return score def fuzzy_rank(query: str, files: list[str], limit: int = 10) -> list[str]: q = query.lower() if not q: # bare `@`: surface shallow paths first for discoverability return sorted(files, key=lambda f: (f.count("/"), f))[:limit] scored = [] for f in files: s = _subseq_score(q, f.lower()) if s is not None: scored.append((s, f)) scored.sort(key=lambda x: (-x[0], len(x[1]), x[1])) return [f for _, f in scored[:limit]] # ------------------------------------------------------- edit-time token # `(?:^|\s)@` 前置:@ 前必须是行首或空白 → 邮箱/代码里的 a@b 不触发。 # 字符集含路径分隔符与 ~ :,\w 在 unicode 下覆盖中文文件名。 _AT_TOKEN_RE = re.compile(r"(?:^|\s)(@[\w\-./\\~:]*)$", re.UNICODE) def find_at_token(line_before_cursor: str): """Return (query, at_pos) when the cursor sits in an @token being typed on this line, else None. at_pos is the index of '@'.""" m = _AT_TOKEN_RE.search(line_before_cursor) if not m: return None tok = m.group(1) return tok[1:], m.start(1) def format_pick(path: str) -> str: """`@path` insert text; dirs get no trailing space (keep completing next level), files get one (close token). Spaces → quoted.""" trailing = '' if path.endswith(('/', '\\')) else ' ' return f'@"{path}"{trailing}' if ' ' in path else f'@{path}{trailing}' # --- path-like completion: an explicit-path @token (~/ / ./ ../ or C:\) goes # to live directory completion instead of index fuzzy — this is how absolute # paths outside the index root get completed level by level (claude-code parity). def is_path_like(token: str) -> bool: if token in ('~', '.', '..'): return True if token.startswith(('~/', '~\\', './', '.\\', '../', '..\\', '/', '\\')): return True return len(token) >= 3 and token[0].isalpha() and token[1] == ':' and token[2] in '/\\' def path_completions(token: str, root: str, limit: int = 15) -> list[str]: """readdir the real dir of a path-like token, prefix-match, dirs first. `~` expanded, relative → root, absolute as-is; candidates keep the token's spelling, dirs carry a trailing '/'.""" sep = max(token.rfind('/'), token.rfind('\\')) if sep >= 0: dir_part, prefix = token[:sep + 1], token[sep + 1:] elif token in ('~', '.', '..'): dir_part, prefix = token.rstrip('/\\') + '/', '' else: return [] exp = os.path.expanduser(dir_part) real_dir = exp if os.path.isabs(exp) else os.path.join(root, exp) try: with os.scandir(real_dir) as it: entries = list(it) except OSError: return [] pl = prefix.lower() rows = [] for e in entries: nm = e.name if pl and not nm.lower().startswith(pl): continue if nm.startswith('.') and not prefix.startswith('.'): # 隐藏项需显式 . 才出 continue try: is_dir = e.is_dir() except OSError: is_dir = False rows.append((not is_dir, nm.lower(), dir_part + nm + ('/' if is_dir else ''))) rows.sort(key=lambda r: (r[0], r[1])) # 目录优先 + 字母序 return [d for _, _, d in rows[:limit]] def candidates_for(query: str, root: str, limit: int = 15, absolute: bool = False) -> list[str]: """@token candidates: path-like → directory completion, else index fuzzy. Single dispatch point shared by both front-ends. `absolute=True` returns fuzzy hits as absolute paths (front-end shows full path when no workspace is bound, since the relative root isn't obvious to the user).""" if is_path_like(query): return path_completions(query, root, limit) idx = get_index(root) files = idx.snapshot() if not files: idx.warm() # 惰性兜底:该根还没建索引 → 后台建(本次可能空,下次有) res = fuzzy_rank(query, files, limit) if files else [] if absolute: res = [os.path.normpath(os.path.join(root, c)) for c in res] return res # ------------------------------------------------------ submit-time absolutize # A fuzzy candidate inserts a path relative to the @ root (workspace/CWD), but # the agent's file_read resolves relative to its own ./temp cwd — so a bare # `@frontends/x.py` won't be found. At submit we rewrite each @mention naming a # real file to an absolute path; display keeps the short form. Still no content # read — this only completes the path so the agent can locate it. _AT_ABS_RE = re.compile(r'(^|\s)@("([^"]+)"|([\w\-./\\~:#]+))', re.UNICODE) _LINE_SUFFIX_RE = re.compile(r'(#L\d+(?:-\d+)?)$') def absolutize_mentions(text: str, root: str) -> str: """@relative → @absolute (root-resolved, ~ expanded, quoted if it gains a space), `#Lx-y` suffix kept. Only existing paths are rewritten; decorative @words / typos pass through unchanged.""" def repl(m): lead, quoted, bare = m.group(1), m.group(3), m.group(4) raw = quoted if quoted is not None else bare trail = '' if quoted is None: # strip trailing prose punctuation stripped = raw.rstrip(',。,;;))]》>') trail, raw = raw[len(stripped):], stripped sm = _LINE_SUFFIX_RE.search(raw) suffix = sm.group(1) if sm else '' path = raw[:len(raw) - len(suffix)] if suffix else raw if not path: return m.group(0) exp = os.path.expanduser(path) absp = os.path.normpath(exp if os.path.isabs(exp) else os.path.join(root, exp)) if not os.path.exists(absp): # decorative / typo → leave as-is return m.group(0) full = absp + suffix token = f'@"{full}"' if ' ' in full else f'@{full}' return lead + token + trail return _AT_ABS_RE.sub(repl, text)