fzr is a small fzy-like path picker with built-in filesystem search.
It is meant for the workflow where you would normally use
find . | sort | fzy
That pipeline is useful, but it treats paths as plain text. fzr scans the
filesystem itself, keeps path metadata separate from display text, and ranks
path-shaped matches directly.
The basic job stays simple. Open the picker, type a few fragments, choose a path, and get the selected relative path on stdout.
fzr -i .
Interactive mode scans the directory tree and opens a picker. The picker UI is drawn on stderr, and the selected path is printed on stdout.
selected/path.txt
Esc cancels the picker. Cancel exits with status 1 and prints no path.
Non-interactive listing is available too.
fzr .
fzr --files .
fzr --dirs .
fzr --sort=mtime .
fzr [options] root
fzr --eval zsh
Commands that scan need an explicit root. With no root, fzr prints help.
Common examples:
fzr .
fzr -f src
fzr -d .
fzr -s mtime .
fzr --ignore-common .
fzr --ignore target --ignore dist .
fzr -i .
fzr -i -f ~/src
fzr -i -c .
fzr -i --style yellow,bold,underline .
fzr -i --style plain .
Options
-i,--interactiveopens the picker.-f,--fileslists files only.-d,--dirslists directories only.-s,--sort=path|mtimechooses path order or modification-time order.-c,--case-sensitivemakes interactive matching case-sensitive.-C,--ignore-commonskips.git,.terraform,node_modules,venv,.venv,__pycache__,.tox, and.cache.-I,--ignore NAMEskips directories with this basename. Can be repeated.--follow-symlinksfollows symlinked directories and files.--style STYLEsets the interactive match highlight style.--eval SHELLprints a shell integration script. Currently supportszsh.-h,--helpprints help.
--files and --dirs cannot be used together.
In non-interactive mode, --sort=mtime prints oldest paths first. In
interactive mode, Ctrl-Space can sort the current visible match set newest
first.
The picker reserves one prompt line and ten result rows. It does not switch to a full-screen alternate buffer.
> query
Keys
- Type printable characters to edit the query.
- Backspace deletes before the cursor.
- Left and right arrows move inside the query.
- Home and End move to the start and end of the query.
- Ctrl-U clears the query.
- Up and down arrows move the selection.
- Ctrl-N and Ctrl-P also move the selection.
- Ctrl-Space sorts current matches by modification time, newest first.
- Enter prints the selected path.
- Esc cancels.
On macOS, Ctrl-Space may be assigned to input source switching. If Ctrl-Space
does nothing in fzr, check the macOS keyboard shortcuts for input sources.
Directories are shown with a trailing /. Matching uses the real candidate
path, not display-only markers.
Matched characters are green, bold, and underlined by default. Use
--style yellow,bold,underline to switch to yellow, or --style plain to
disable match styling. Style tokens are comma-separated and support green,
yellow, bold, underline, and plain.
Matching is case-insensitive by default. Use -c or --case-sensitive for
case-sensitive matching.
Spaces split the query into required fragments.
alpha beta .mkv
That query requires all three fragments to match somewhere in the path. It works like staged filtering without a separate commit step.
Use spaces when the query is made of separate facts: path words, extensions, dates, numbers, or other fragments that must all be present. Each fragment is matched independently, so this is the right form when you want staged filtering without caring much about the order you typed the fragments.
Leave text glued together when you want one fuzzy abbreviation of the path. In that form the whole query is scored as one ordered fuzzy sequence, so it can rank differently from the same text split into fragments.
Ranking prefers
- contiguous substring matches over scattered fuzzy matches
- matches at path boundaries such as
/,-,_, space, and. - stronger token matches without making space-separated token order significant
- separate occurrences for repeated words when possible
- bounded numeric tokens such as episode
10over10inside1080p - bounded dotted version fragments such as
385matching3.8.5, including typed prefixes such as38 - bounded numeric endings in glued fuzzy queries such as
...1080p10
Example paths
media/series/sample-show/s01/Sample Show - 01 (1080p).mkv
media/series/sample-show/s01/Sample Show - 10 (1080p).mkv
media/series/sample-show/s01/Sample Show - 11 (1080p).mkv
Query
sample show 1080p 10
The episode 10 path ranks first. The 10 inside 1080p can still match as a
fallback, but a separate path component or filename token is stronger.
For a glued query with no spaces, fzr keeps the same basic feel as fzy. It
uses an ordered fuzzy alignment and rewards compact matches, consecutive
characters, and path-like boundaries.
fzr differs once the input looks like path search instead of a single
abbreviation.
- Spaces split the query into required fragments instead of becoming one fuzzy
sequence.
alpha beta mkvmeans all three fragments must match the path. - Fragment order is mostly not important.
alpha beta mkvandmkv alpha betaare meant to find the same kind of result. - A fragment that appears as a real substring is stronger than a scattered fuzzy match.
- Path boundaries matter. Matches after
/,-,_, space, and.rank better than matches buried inside a word. - Repeated fragments prefer separate occurrences when possible, so a query like
name 1080p 1080pcan match one1080pin a directory and another in a filename. - Numeric fragments prefer bounded occurrences. This helps
10find an episode or numbered file instead of the10inside1080p. - Numeric fragments can also weakly match one bounded dotted version run, so
385can find3.8.5without matching arbitrary scattered digits. Typed prefixes such as38match too, which keeps interactive narrowing stable. - Glued fuzzy queries with a trailing number also prefer a bounded numeric ending when there is a good one.
The matcher still stays lightweight. Glued fuzzy scoring uses dynamic alignment only over the useful part of a path, and falls back to a cheaper scorer for very large windows. Interactive narrowing also reuses the current result list when you append to the query.
In practice, use spaces for separate facts and glued text for one abbreviation.
project report pdf 2026
prjreppdf2026
The first form behaves like staged filtering. The second form behaves like one ordered fuzzy abbreviation.
Scanning starts immediately and results are added as they are discovered.
Filtering has a short delay on large candidate sets so typing does not turn into
a slow per-character refresh. When you add text to the end of the query and no
new scan results arrived, fzr narrows the existing match list instead of
starting from every known path again.
While scanning is still discovering paths, filtered results are applied from
stable snapshots of the paths known so far. New scan batches can make that
snapshot briefly stale, and fzr schedules another filtering pass instead of
blocking the UI or reranking every batch synchronously. When scanning finishes,
the current query is refreshed against the complete discovered set.
That means a query can get cheaper as it becomes more specific. If a first fragment leaves a small matched list, the next appended fragment filters that list. Backspacing or editing earlier text falls back to a broader search because previously rejected paths may become valid again.
The delay depends on the number of candidates being filtered.
- 1,000 or fewer candidates filter immediately.
- 1,001 to 9,999 candidates wait 100ms after the last edit.
- 10,000 or more candidates wait 250ms after the last edit.
When narrowing an existing result list, the delay is based on the size of that current list rather than the total number of discovered paths.
The status line uses total for discovered paths so far and matched for all
ranked matches for the current query. When it says showing top N, the picker
has a larger matched set but exposes only the active top-ranked subset for
selection and current-result actions.
Multi-token searches can use a soft cutoff. If the top matches look strong, the picker can expose the top 50. If matches look mixed or weaker, it can expose up to 200. Single-token searches and small result sets can show all matches. This keeps weak tail matches out of selection and out of Ctrl-Space recent sorting without discarding the full ranked match list internally.
Ctrl-Space sorts the current active match set newest first. It does not necessarily stat the whole discovered tree, and it does not stat directories. Mtime values are cached during the picker session. The stat call reads metadata, not file contents, so it can be fast after traversal has warmed filesystem caches.
By default, fzr does not ignore anything.
Use --ignore-common for noisy project directories
fzr --ignore-common .
fzr -i --ignore-common .
It skips these directory basenames.
.git.terraformnode_modulesvenv.venv__pycache__.tox.cache
Use --ignore for your own directory basenames
fzr --ignore target --ignore dist .
Ignore matching is by directory basename. A directory named target is skipped
wherever it appears below the root.
By default, symlinks are listed but not followed.
Use --follow-symlinks to follow symlinked files and directories:
fzr --follow-symlinks .
fzr -i --follow-symlinks .
When following symlinked directories, fzr avoids directory cycles. If two
different symlink paths point at the same directory, both paths can appear.
Non-interactive mode prints one relative path per line.
Interactive mode writes UI to stderr and prints only the selected path to stdout. That makes command substitution work cleanly.
path="$(fzr -i .)"
Recommended Ctrl-F widget setup
if command -v fzr >/dev/null 2>&1; then
eval "$(fzr --eval zsh)"
fi
The widget runs fzr -i --ignore-common, then inserts the selected relative
path into LBUFFER using zsh's ${(q)} escaping. Paths with spaces, quotes,
$, brackets, parentheses, and glob characters stay safe and editable.
The generated widget also understands the current path-like word. If your
prompt already contains a directory prefix such as ~/tmp/ or src*/, Ctrl-F
searches from that directory.
Build the binary
make -B build
The binary is written to
build/<goos>-<goarch>/bin/fzr
Install it
make install
As root, install copies to /usr/local/bin. As a normal user, it copies to
$HOME/.local/bin.
Useful project commands
make test
make -B build
make static
make bench
make clean
Benchmarks build their test data in memory from deterministic fake paths. Go
cache, module cache, profiles, and benchmark binaries stay under ./build.
Run the normal benchmark set
make bench
Run a focused matcher benchmark
make bench BENCH='RankEntries/(100000|1000000)' BENCHTIME=1s COUNT=1
Generate CPU and heap profiles under ./build
make bench-profile BENCH=RankEntries/100000 BENCHTIME=2s
Benchmark a real directory tree when needed
FZR_BENCH_ROOT=/path/to/large/tree make bench BENCH=CollectEntriesRealRoot BENCHTIME=1x
The real-tree benchmark is opt-in. Do not commit copied paths or output from that mode.
Benchmark areas
RankEntriesmeasures full-query matcher cost across synthetic corpus sizes, including token-heavy searches, glued fuzzy searches, and broad-match stress searches.RankMatchesNarrowingmeasures the append-at-end fast path after a previous query narrowed the list.EffectiveMatchesWindowmeasures the bounded effective match set used by strong multi-token searches.PickerApplyQueryincludes picker model state and query application cost.RenderPickerVisibleRowskeeps rendering scoped to the prompt and ten result rows.CollectEntries*measures filesystem traversal and scanner allocation cost.
Performance notes
- ASCII paths use a byte-based matcher path. Unicode paths use the rune matcher.
- Query tokens are prepared once per query, not once per candidate.
- Glued fuzzy matching uses dynamic scoring over the useful match window. Oversized windows fall back to a cheaper greedy score.
- Space-separated fragments are required filters. Non-overlapping token chains help repeated words and numeric fragments rank the intended occurrence higher.
- Strong multi-token searches keep full ranked matches internally but expose a smaller effective match set for selection and current-result actions.
- Very broad queries can still be expensive because retaining and sorting a huge match set dominates matcher scoring cost.