57. @-File Completion Reads a Cached Candidate Index¶
Date: 2026-10-13
Status: Accepted
Context¶
Issue #198: typing @sess suggested nothing in a repo that has docs/.../acp-session-resume-plan.md. @-completion walked the tree on the UI path on every hint rebuild and Tab press, with a visit budget (200–2000 entries), skipped only .git/.claude, and filtered .gitignore after the walk with a git check-ignore process per rebuild. In this repo the ignored .mimocode/node_modules (≈3,400 files), website/.venv (≈18,800) and website/site sort before cmd/ and docs/, so the budget was spent on entries that were then thrown away. The three passes also disagreed on matching (contains vs prefix).
Research¶
How other harnesses feed the same feature (sources read for this ADR):
| Harness | Candidate source | Ignore handling | Refresh | Matching |
|---|---|---|---|---|
| OpenCode | native fff index, fallback rg --files built once in a forked fiber |
the tool itself; .git excluded |
built once, queried synchronously per key | fuzzy + frecency; 20 results, non-git capped at 100k |
| MiMo-Code | rg --files → cached {files, dirs} |
ripgrep | lazy: refreshed on use | fuzzysort; hidden dirs sort last |
| Codex CLI | ignore::WalkBuilder run once per @ token, incremental |
during descent, git-scoped | one session per token; keystrokes only update the query; cancel flag every 1024 entries | nucleo, score then path; 20 results |
| Gemini CLI | fdir crawl, 20,000-file cap |
directory filter during the crawl | TTL cache, chokidar watcher + 150 ms debounce, result cache narrowed by query prefix | fzf; basename-prefix, then match position, then shorter path |
| aider | HEAD tree + index (git), tracked files only |
gitignore only implicitly | per commit; completer rebuilt per prompt, in a thread | prefix, ≥3 chars; untracked files invisible; no non-git support |
| Claude Code | documented only as fast traversal / bundled ripgrep; respectGitignore; custom fileSuggestion command (5 s, 15 results) |
gitignore, .ignore/.rgignore |
pre-warmed at startup, session cache with background refresh (changelog) | filename match above folder-only match |
Common to all: the list is built once, off the keystroke path, by the ignore engine itself; keystrokes are in-memory queries; candidates and results are capped; refresh is lazy or debounced; basename matches outrank folder matches. Claude Code's changelog doubles as a list of failure modes to avoid: re-scanning every turn in non-git directories, files created mid-session not appearing, freshly initialised repos listing only config files, and directories with more than 100 entries not being searched.
Decision¶
cmd/milk/fileindex.go:
- Candidates from git, not a walk.
git ls-files -z --cached --others --exclude-standard(one process, in the background) returns tracked plus untracked-not-ignored files with nested.gitignore,.git/info/excludeand global excludes honoured by git. Untracked files are included (unlike aider; fixes Claude Code's fresh-repo case). Directories are derived from the paths. Files deleted from the work tree but still tracked are dropped with anLstaton the few results shown. - Non-git fallback: a walk bounded by entry count and a 5 s deadline that prunes ballast directory names (
node_modules,.venv,target,dist,build,vendor, caches, …) while descending — never after. - Fixed memory: at most 50,000 candidates (files + directories), lower-cased once at build time.
- One index per working directory (at most 4 kept), built single-flight and pre-warmed at startup. A snapshot is served for 3 s; a later use refreshes it in the background while the old snapshot keeps answering, and the end of an agent turn marks it stale (files were probably created). A finished build sends
fileIndexReadyMsgso hints shown from a flat listing are redrawn. - Keystrokes are an in-memory filter (≈1.5 ms over 40,000 candidates) with no process spawn and no directory walk. Tab may wait up to 250 ms for the very first build.
- Ranking: base name starts with the fragment, then base name contains it, then the path below the typed directory contains it (2+ characters); within a tier direct children before deeper, shorter path, then alphabetical. A one-character fragment only lists direct children (the existing ≥2-character guard for anything recursive); an empty fragment lists the directory.
- Outside the index: absolute paths and paths leaving the working directory list one directory at a time (
ReadDir), never walked. - Removed:
walkMatches,filterGitIgnoredandexpandPath's second recursive pass.
Consequences¶
@sessfindsdocs/internal/plans/acp-session-resume-plan.mdwhatever the size of ignored directories next to it, and no per-keystroke git process remains.- A repo with more than 50,000 candidates is truncated in
git ls-filesorder (alphabetical by path); late-alphabet directories can be missing there. The cap is deliberately fixed. - Not done (non-goals of the issue / follow-ups): fuzzy subsequence matching, a recency or frecency boost, a file watcher. A boost for files touched in the session can be added to the same ranking later; fuzzy matching should only ever run over this cached list.