# Fuzzy @file-mention Autocomplete — Design **Status:** Approved, pending implementation plan **Date:** 2026-07-15 **Scope:** `src/app/state/misc.rs`, `src/app/state/rest.rs`, `src/controller/input.rs`, `src/view/mod.rs`, `src/tool/mod.rs`, `src/tool/fs/write.rs`, `src/main.rs` ## Context The chat input already has a dropdown autocomplete (`InputState` in `misc.rs`), but it only covers slash commands: it requires the whole buffer to start with `/` and filters a fixed `COMMANDS` list by prefix. There's no way to reference a project file from the chat input without typing its exact path from memory. The existing `dir_cache` (used by the `dir_cache_update` tool) looks like it could serve this but doesn't: it's a single, non-recursive directory snapshot, overwritten on each LLM-driven `dir_cache_update` call — not a standing, recursive, whole-workspace file index. `search.rs`'s `Grep`/`Glob` tools already do the recursive, `.gitignore`-respecting walk this feature needs, via `ignore::Walk`. Also relevant: there is no persistent async runtime driving the TUI loop. `main.rs` constructs a `tokio::runtime::Runtime` but never `.enter()`s or `block_on`s it in the main loop — `run_loop` is fully synchronous. The one existing async-flavored pattern (`dir_cache_update.rs`) spins up a throwaway one-shot runtime purely to satisfy `tokio::sync::RwLock`'s API, then discards it. This feature does not need that ceremony: a plain `std::sync::RwLock` is enough, since every reader/writer here is synchronous (`handle_key`, `Tool::run`, and the index-build thread all being plain sync code). ## Goals - Typing `@` at a word boundary (start of buffer or after whitespace) in the chat input, followed by non-whitespace characters, opens a dropdown of fuzzy-matched project file paths, live-updating as the query changes. - Selecting a candidate splices `@relative/path ` into the buffer at the mention's position (not a whole-buffer replace) and the user keeps typing. - Candidates come from a background-built, whole-workspace file index — not the LLM-facing `dir_cache`. ## Non-goals - No auto-reading of the selected file's content into the conversation — the inserted `@path` is plain text; the model reads it via the `read` tool if it wants to, same as any other path reference. - No live re-filter on Backspace/Delete while a mention dropdown is open — mirrors the slash-command dropdown's existing behavior (closes on Backspace/Delete rather than refiltering). Not fixing that for commands here; file mentions just inherit it for consistency. - No periodic re-walk of the index after startup — only single-file incremental updates on file creation (see below). A deleted or renamed file may show a stale entry until restart; acceptable since selecting it just inserts text, it doesn't touch the filesystem. - No fuzzy matching over directories, only files. ## Dependency Add `nucleo-matcher = "0.3"` (the fuzzy-matching engine from the Helix editor project; small, actively maintained, no heavy transitive deps). ## Index storage & construction - New type in `misc.rs`: `MentionIndex { entries: Arc>> }`, with `MentionIndex::new()`, `set(&self, paths: Vec)`, and `snapshot(&self) -> Vec` (both plain sync `.write()`/`.read()`, no `try_`/async — a std `RwLock` doesn't block indefinitely here since every hold is a quick vec swap or clone). - `AppStateRest` gets a `pub mention_index: MentionIndex` field, initialized in `AppStateRest::new()`, threaded into `ToolCtx`/`ToolCtxBuilder` the same way `dir_cache` is (new `mention_index` field on both, wired through `tool_ctx()`/`tool_ctx_for()`/`build()`). - In `main.rs`, right after `AppStateRest::new(...)` in the single-process TUI path and the daemon path (not the attach-only client path, which has no local `ToolCtx`), spawn `std::thread::spawn` that: 1. For each workspace root (index `i`, path `w`): `ignore::Walk::new(w)`, keep only files, strip `w` as prefix, format as `rel` for `i == 0` or `[i]rel` for `i > 0` (matching `resolve_path`'s existing workspace-index convention). 2. Stop collecting once the total across all workspaces hits 50,000 entries (repos larger than that are rare here; this is a soft cap to bound memory/scan time, not a hard requirement). 3. Call `mention_index.set(all_paths)`. - `write.rs`: after a successful write, if the target path did **not** exist before the write (i.e. this created a new file, not an overwrite), compute its relative/workspace-prefixed form and push it onto `ctx.mention_index`'s vec directly (read-modify-write under the same lock) rather than re-walking. ## `InputState` changes (`misc.rs`) - New `pub enum AutocompleteKind { Command, FileMention }`. - `InputState` gains `pub autocomplete_kind: AutocompleteKind` (default `Command`) and `pub mention_start: usize` (byte offset of the triggering `@`). - New `fn mention_query_at_cursor(&self) -> Option<(usize, String)>`: scans backward from `self.cursor` for an `@`; the scan stops (returns `None`) if it hits whitespace before finding `@`. The `@` only counts as a trigger if it's at buffer start or immediately preceded by whitespace. Returns `(byte offset of '@', query text between '@' and cursor)`. - New `fn open_mention_autocomplete(&mut self, files: &[String])`: calls `mention_query_at_cursor()`; if `None`, calls `close_autocomplete()` and returns. If `Some((start, query))`, fuzzy-matches `query` against `files` via `nucleo-matcher`, keeps the top 10 by score, sets `autocomplete_candidates`, `autocomplete_kind = FileMention`, `mention_start = start`, `autocomplete_visible = !candidates.is_empty()`. - `select_autocomplete()` becomes kind-aware: - `Command` (today's behavior, unchanged): `buffer = candidate.clone()`, `cursor = buffer.len()`. - `FileMention`: `buffer.replace_range(mention_start..cursor, &format!("@{candidate} "))`, `cursor = mention_start + candidate.len() + 2` (the `@` plus the candidate plus the trailing space). - Both paths end with `close_autocomplete()`, same as today. ## `input.rs` wiring - `KeyCode::Char(c)` handler: after `state.input.insert(c)`, keep the existing `if buffer.starts_with('/') { open_autocomplete() }` check, and add an `else if let Some(_) = state.input.mention_query_at_cursor() { state.input.open_mention_autocomplete(&state.mention_index.snapshot()) }` branch. These are mutually exclusive in practice (a buffer starting with `/` is a slash command, not a sentence with an `@mention` in it). - `KeyCode::Backspace` / `KeyCode::Delete`: unchanged — both already just call `close_autocomplete()` when a dropdown is visible, regardless of kind. No new branching needed since `close_autocomplete()` already resets `autocomplete_kind` isn't touched but becomes irrelevant once `autocomplete_visible` is false. - `KeyCode::Tab`: currently gated on `buffer.starts_with('/')`. Extend the condition to also fire when `autocomplete_kind == FileMention && autocomplete_visible` so Tab cycles file-mention candidates too. - `KeyCode::Enter`: unchanged — already calls `select_autocomplete()` whenever `autocomplete_visible`, which is now kind-aware internally. ## Rendering (`view/mod.rs`) - `render_input_bar`'s dropdown block reuses the exact same list-rendering code (already generic over `autocomplete_candidates`/`autocomplete_idx`); only the title changes based on `state.input.autocomplete_kind`: `" ⌘ Commands "` (unchanged) vs `" 📁 Files "`. ## Testing Inline `#[cfg(test)] mod tests` per CLAUDE.md convention: - `misc.rs`: `mention_query_at_cursor` returns the right `(start, query)` for `@` at buffer start, `@` after a space mid-sentence, and correctly returns `None` when the `@` is mid-word (e.g. `foo@bar`) or when whitespace exists between the `@` and the cursor. `select_autocomplete` for `FileMention` splices correctly into a buffer with text before and after the mention span; `Command` selection still replaces the whole buffer as before. - `write.rs`: creating a new file appends its path to the shared `mention_index`; overwriting an existing file does not add a duplicate entry. - Index construction: not unit-tested directly (it's a `std::thread::spawn` walking the real filesystem at startup) — covered implicitly by exercising the app manually per the `verify` skill during implementation.