Filenames are the wrong index for Claude Code @ mentions
Claude Code's @ picker matches characters in a path, so it can't find the file where a function lives. This fileSuggestion hook ranks by symbol instead.

Claude Code's @ picker matches characters in a path, so it can't find the file where a function lives. This fileSuggestion hook ranks by symbol instead.
On August 7, Samuel Colvin asked why Claude Code's file selector is so bad. His screenshot shows him typing in @os.rs inside Monty, Pydantic's Python interpreter written in Rust, and getting fifteen suggestions back: run.rs, lib.rs, repl.rs, heap.rs, hash.rs, and ten more just like them.
Not one of them is named os.rs. Monty has two files that are.
I had downtime at the airport that afternoon, and I remembered Boris Cherny posting about a Claude Code setting called fileSuggestion back in April. You point it at a command. When you type @, Claude Code spawns that command once per keystroke, hands it {"query": "..."} on stdin, and shows whatever repo-relative paths it prints on stdout. Nothing in there tells you how to rank the list, which is the part I got excited about, so I started writing one at the gate.
Fuzzy path matching asks whether every character of your query appears somewhere in the path, in order. Walk crates/monty/src/run.rs against os.rs and you'll find the o in "monty", the s in "src", then ., r, s at the end. Valid match. Same story for lib.rs and repl.rs and the other twelve.
So Sam got a correct answer to "which paths contain these characters in order." He asked "which file is named os.rs." Those are not the same question, and the second one is what people actually type.
It gets worse than bad ordering. Type the name of a function and the filename index has nothing to offer at all, because the function name isn't in the filename. In Monty, @resolve_virtual_path returns zero results. Not a bad list. An empty one.
The hook scores every tracked file once and blends three things:
type:symbol search against the repo you're in, so a query naming a function finds the file defining it.Every candidate gets one score, and the tiers sit far enough apart that no stack of weak signals ever outranks a strong one:
| Signal | Weight |
|---|---|
| Exact basename match | 1,000,000 |
| Symbol name matches exactly | 100,000 |
| Symbol name starts with the query | 5,000 |
| Symbol name contains the query | 800 |
| Definition rather than re-export | +200 |
| File touched in the last 25 commits | +50 |
An exact basename match wins outright and skips the symbol search entirely. Network never sits on the hot path: symbol results are cached to disk per four-character prefix, so typing dropg, dropgu, dropgua, dropguard costs one fetch instead of four. Anything shorter than four characters never goes over the wire at all. A cold prefix returns filename results immediately and spawns a detached fetch that fills the cache for the next keystroke.
70fe3f57. The filename lane scores nothing here, so the symbol lane is the only reason there's an answer at all.I re-ran everything on August 20 against Monty at 70fe3f57, 1,139 tracked files, so the numbers below are that run's and not the ones I measured the day I built it.
Here's the part I didn't expect. @os.rs comes out right with the symbol channel switched off:
1 crates/monty-types/src/os.rs
2 crates/monty/src/modules/os.rs
3 crates/monty-fs/src/overlay_state.rs
Both real os.rs files, ranks 1 and 2, from filename scoring alone. Turning symbols on changes that list not at all. Sam's complaint was a path-scoring problem, and nucleo plus "exact basename wins" is the whole fix.
Where the symbol search earns its place is the query filenames can't answer:
@resolve_virtual_path
filenames only: (no results)
with symbols: crates/monty-fs/src/path_security.rs
@dropguard
filenames only: (no results)
with symbols: crates/monty/src/heap_traits.rs
Neither query is a subsequence of the path it should return, so nucleo scores nothing on either one. DropGuard is defined in heap_traits.rs, resolve_virtual_path in path_security.rs, and no amount of path cleverness will ever get you there. That's the case for indexing symbols, and it's a narrower case than "the picker is bad."
Sometimes it does nothing at all, and that's correct. @value returns the same four paths either way, because value.rs and value.ts both win on exact basename, which skips the symbol channel by design. Nothing separates them, so git ls-files order decides. An extension or path-depth preference would settle it. Nothing does today.
Warm p95 is 11.12ms over 200 real subprocess spawns, p50 9.32ms, against the 15ms bar I set for myself. The ranking itself is under a millisecond across Monty's 1,139 files, and the rest is process spawn plus git. Ranking is the one cost that grows with the repo, so on a 19,000-file monorepo it runs to several milliseconds and process spawn stops accounting for almost all of the budget. First-ever invocation pays git ls-files and git log once, at 66ms.
Then there's the honest failure, which repaired itself while this post sat in review. @collect_cycles used to land heap.rs at rank 1, and on August 20 it returned nothing on both sides. Monty had refactored heap.rs into heap/mod.rs, sourcegraph.com's index still carried the old path, and the hook intersects symbol hits against git ls-files so you're never offered a file you don't have. Stale path, dropped, empty list. By the time this published the index had caught up, and the query returns crates/monty/src/heap/mod.rs. The symbol half is only as fresh as the index behind it, so when upstream moves a file you get the filename half until it catches up.
Two more limits worth knowing: macro_rules! macros are absent from the symbol index, so @defer_drop will never resolve. And a trait method with thirty near-identical overrides has no single defining file, so no scoring signal can pick one for you.
It's in the Sourcegraph Community cookbook, Apache-2.0, about 1,200 lines of Rust. Copy this line, and under a minute later you have the binary in place:
curl -sL https://github.com/sourcegraph-community/cookbook/archive/refs/heads/main.zip -o cookbook.zip && unzip -qo cookbook.zip 'cookbook-main/symbol-ranked-file-picker/*' 'cookbook-main/LICENSE' && (cd cookbook-main/symbol-ranked-file-picker && env -u RUSTUP_TOOLCHAIN cargo build --release && cp target/release/file-suggestion ~/.claude/file-suggestion)
The env -u RUSTUP_TOOLCHAIN is there because tools like mise export that variable, and it beats the rust-toolchain.toml sitting in the directory. Leave it in and the build picks stable either way.
Then add one top-level key to ~/.claude/settings.json:
{
"fileSuggestion": { "type": "command", "command": "~/.claude/file-suggestion" }
}
Deleting that block is the complete uninstall.
You don't need a Sourcegraph account for public code. The hook queries sourcegraph.com anonymously by default, so any indexed public repo works the moment you install it. Private code needs your own instance and a token:
export CLAUDE_SG_ENDPOINT="https://sourcegraph.example.com"
export SRC_ENDPOINT="https://sourcegraph.example.com"
export SRC_ACCESS_TOKEN="sgp_..."
Both endpoint variables, not one. An earlier version of the hook attached SRC_ACCESS_TOKEN to every request, which sent one of my own instance tokens to sourcegraph.com during testing and meant rotating it. No build in the cookbook ever behaved that way, so there's nothing for you to rotate. Now the token goes out only when SRC_ENDPOINT matches the endpoint actually being called. Without an indexed repo you get the filename half and nothing breaks.
Thanks to Sam for the tweet that started it, and to Boris for shipping the escape hatch four months before I knew I needed it.
A special thanks to Stephanie Jarmak for her contributions to this blog post.

With Sourcegraph, the code understanding platform for enterprise.
Schedule a demo