8.2 KiB
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_ons 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/pathinto 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
@pathis plain text; the model reads it via thereadtool 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<std::sync::RwLock<Vec<String>>> }, withMentionIndex::new(),set(&self, paths: Vec<String>), andsnapshot(&self) -> Vec<String>(both plain sync.write()/.read(), notry_/async — a stdRwLockdoesn't block indefinitely here since every hold is a quick vec swap or clone). AppStateRestgets apub mention_index: MentionIndexfield, initialized inAppStateRest::new(), threaded intoToolCtx/ToolCtxBuilderthe same waydir_cacheis (newmention_indexfield on both, wired throughtool_ctx()/tool_ctx_for()/build()).- In
main.rs, right afterAppStateRest::new(...)in the single-process TUI path and the daemon path (not the attach-only client path, which has no localToolCtx), spawnstd::thread::spawnthat:- For each workspace root (index
i, pathw):ignore::Walk::new(w), keep only files, stripwas prefix, format asrelfori == 0or[i]relfori > 0(matchingresolve_path's existing workspace-index convention). - 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).
- Call
mention_index.set(all_paths).
- For each workspace root (index
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 ontoctx.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 }. InputStategainspub autocomplete_kind: AutocompleteKind(defaultCommand) andpub mention_start: usize(byte offset of the triggering@).- New
fn mention_query_at_cursor(&self) -> Option<(usize, String)>: scans backward fromself.cursorfor an@; the scan stops (returnsNone) 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]): callsmention_query_at_cursor(); ifNone, callsclose_autocomplete()and returns. IfSome((start, query)), fuzzy-matchesqueryagainstfilesvianucleo-matcher, keeps the top 10 by score, setsautocomplete_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: afterstate.input.insert(c), keep the existingif buffer.starts_with('/') { open_autocomplete() }check, and add anelse 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@mentionin it).KeyCode::Backspace/KeyCode::Delete: unchanged — both already just callclose_autocomplete()when a dropdown is visible, regardless of kind. No new branching needed sinceclose_autocomplete()already resetsautocomplete_kindisn't touched but becomes irrelevant onceautocomplete_visibleis false.KeyCode::Tab: currently gated onbuffer.starts_with('/'). Extend the condition to also fire whenautocomplete_kind == FileMention && autocomplete_visibleso Tab cycles file-mention candidates too.KeyCode::Enter: unchanged — already callsselect_autocomplete()wheneverautocomplete_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 overautocomplete_candidates/autocomplete_idx); only the title changes based onstate.input.autocomplete_kind:" ⌘ Commands "(unchanged) vs" 📁 Files ".
Testing
Inline #[cfg(test)] mod tests per CLAUDE.md convention:
misc.rs:mention_query_at_cursorreturns the right(start, query)for@at buffer start,@after a space mid-sentence, and correctly returnsNonewhen the@is mid-word (e.g.foo@bar) or when whitespace exists between the@and the cursor.select_autocompleteforFileMentionsplices correctly into a buffer with text before and after the mention span;Commandselection still replaces the whole buffer as before.write.rs: creating a new file appends its path to the sharedmention_index; overwriting an existing file does not add a duplicate entry.- Index construction: not unit-tested directly (it's a
std::thread::spawnwalking the real filesystem at startup) — covered implicitly by exercising the app manually per theverifyskill during implementation.