Files
zesdex/docs/superpowers/specs/2026-07-15-file-mention-design.md
T

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/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<std::sync::RwLock<Vec<String>>> }, with MentionIndex::new(), set(&self, paths: Vec<String>), and snapshot(&self) -> Vec<String> (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.