diff --git a/docs/superpowers/specs/2026-07-15-file-mention-design.md b/docs/superpowers/specs/2026-07-15-file-mention-design.md new file mode 100644 index 0000000..56facb6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-file-mention-design.md @@ -0,0 +1,126 @@ +# 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.