Skip to content

Fuzzy finding: three key bindings, and the setup that is not the install

Level: 101 → 201 · working knowledge

One line: fzf replaces type the exact path with type four letters and pick from a list — but installing it gives you a command, not the key bindings, and the key bindings are the entire reason to install it.

The shape of it

Press Ctrl-T in the middle of typing a command. A filtered list of files opens, you type a fragment of the name — not a prefix, any fragment, in any order — and the path you pick is pasted into the command line where your cursor was.

rustc --edition 2024 ▊          <- press Ctrl-T here, type "optvsres", pick
rustc --edition 2024 17_Option_and_Result/option_vs_result/examples/option_vs_result.rs▊

Nothing is remembered, nothing is configured per project, and the list is the current directory tree. That is the whole tool: it reads lines on stdin, lets you narrow them interactively, and writes the chosen one to stdout. Everything else is a wrapper around that.

Install

brew install fzf          # macOS

On Debian or Ubuntu it is apt install fzf; on Arch, pacman -S fzf. It is a single static binary with no runtime dependencies, which is also the answer for a machine where you cannot install anything: drop the release binary into ~/bin and it works.

The step that is not the install

brew install fzf puts fzf on your PATH and stops. The key bindings are a separate opt-in, and this is the point at which most people conclude fzf is "just a menu you have to pipe things into".

Add one line to your shell's startup file:

# ~/.config/fish/config.fish
fzf --fish | source
# ~/.bashrc
eval "$(fzf --bash)"
# ~/.zshrc
source <(fzf --zsh)

Then open a new shell. Older fzf releases have no --fish/--bash/--zsh flag and ship the same code as files instead — source (brew --prefix)/opt/fzf/shell/key-bindings.fish and its .bash / .zsh siblings, which are still installed alongside the newer flag.

The three bindings

Three keys, and each one answers a different question. They work while you are typing a command — you do not run fzf, you interrupt yourself with it.

Key Answers What happens when you pick
Ctrl-T "where is that file?" the path is pasted where your cursor was, and you carry on typing
Ctrl-R "what was that command I ran?" the whole command line is replaced with the one you picked
Alt-C "where is that directory?" you cd there immediately — no pasting, it just moves you

Ctrl-T, step by step

You want to compile an example but cannot remember the folder.

  1. Type rustc --edition 2024 and stop, cursor still at the end.
  2. Press Ctrl-T. The bottom half of the terminal fills with a list of every file under the current directory, and a > prompt appears.
  3. Type optvs. The list narrows as you type. These are not the first letters of the filename — fzf matches those characters in order, anywhere in the path, so optvs finds 17_Option_and_Result/option_vs_result/examples/option_vs_result.rs.
  4. Move with the arrow keys if more than one line survived, and press Enter.
  5. The list disappears and your command line now reads rustc --edition 2024 17_Option_and_Result/option_vs_result/examples/option_vs_result.rs, cursor after it. Nothing has run yet — you finish the command and press Enter yourself.

Esc at any point closes the list and leaves your command line untouched.

Ctrl-R, step by step

This one replaces a habit rather than a command, and it is the one that pays for the install fastest.

  1. Press Ctrl-R on an empty line.
  2. Your shell history opens, most recent first.
  3. Type carg nex. Both fragments have to appear, in that order, but nothing has to be adjacent — so this finds cargo nextest run --workspace even though the command does not start with either fragment.
  4. Enter puts that command on your command line.

What this replaces depends on your shell, which is worth knowing before you decide it is a revelation. In bash and zsh the built-in Ctrl-R walks backwards through history matching a substring, one candidate at a time, and you press Ctrl-R again to step further back — fzf is a large win there. fish already has a history pager on that key (history-pager, a list you can filter), so what fzf adds is fuzzy matching instead of substring matching, and its ranking. You can check which one you have with bind ctrl-r: it names fzf-history-widget once the integration is loaded.

Alt-C, step by step

  1. Press Alt-C (hold Option on a Mac, press C).
  2. A list of directories opens — directories only, no files.
  3. Type a fragment, press Enter, and you are in that directory. There is no command to finish; the cd has already happened.

If nothing at all happens when you press it, that is the next section and it is not your fault.

Point it at fd

fzf builds its own file list when you have not told it otherwise, and that walker skips exactly two directories: .git and node_modules. It has never read .gitignore and does not know what a target/ is.

Set three variables and it uses fd instead, which reads every .gitignore on the way down.

# ~/.config/fish/config.fish
set -gx FZF_DEFAULT_COMMAND "fd --type f --hidden --exclude .git"
set -gx FZF_CTRL_T_COMMAND $FZF_DEFAULT_COMMAND
set -gx FZF_ALT_C_COMMAND "fd --type d --hidden --exclude .git"

The bash and zsh forms are the same three names with export. --hidden puts dotfiles back in — fd skips them by default, which is the opposite of fzf's walker — and --exclude .git then removes the one hidden directory you never want to pick a file from.

Measured on this repository, August 2026:

Ctrl-T list is built by Entries offered
fzf's own walker — files and directories, hidden included, .git and node_modules skipped ~18,800
fd --type f --hidden --exclude .git 444

Two generated directories are about 97% of that gap: a Python .venv/ and the built site/. Both are named in .gitignore, so fd never enters them and fzf's walker has no reason not to.

The totals are rounded because they drift while you watch.venv/ grows whenever a tool syncs a dependency group, and site/ is rewritten by every docs build, this page now among its 700-odd files. The proportion is the stable fact, and it is the one that matters.

This is not a speed argument; fzf narrows 18,800 lines instantly. It is that a list of 444 real files can be cut to one by typing three characters, and a list of 18,800 cannot — every fragment you type still leaves forty files you have never opened.

How to tell which one you are on, without checking any config: fzf's walker lists directories as well as files, with a trailing /. fd --type f lists only files. If your Ctrl-T shows examples/ on a line of its own, the variables above have not reached that shell yet.

The variables are read when you press the key, not when the shell starts, so it does not matter whether these lines come before or after the fzf --fish | source line — but they do have to be in a shell that has started since you wrote them.

Ctrl-T from your home directory is not the tool

Pointing fzf at fd fixes the list inside a project. Run the same keystroke from ~ and you get a different problem, because .gitignore only helps where there are git repositories, and your home directory is mostly not that.

Measured on this machine, from ~:

652,712 files

Nearly all of it is generated, and --hidden is why you can see most of it:

Directory Files
Library/ 257,764+
.cache/ 212,482
.npm/ 38,767
.cargo/ 32,520
.local/ 19,898
.vscode/ 12,064
.rustup/ 7,499

Those seven are about 98% of the list, and not one of them holds a file you would ever pick by name. Library/ is the largest and it is not even hidden — no dot, so --hidden is not what let it in; it is simply a directory with a quarter of a million files in it.

The + on that first row is not laziness. The total depends on what the process asking is allowed to read, because macOS gates ~/Library/Mail, ~/Library/Messages, ~/Library/Safari and others behind privacy permissions. The same fd command run from a sandboxed process reported 589,679 — sixty-three thousand fewer — and returned zero files for each of those four directories rather than an error. If you are ever comparing two file counts on macOS and they differ by tens of thousands, this is the first thing to suspect, and it is silent in both directions.

fd reads a global ignore file for exactly this, in .gitignore syntax, at ~/.config/fd/ignore. No flag turns it on:

mkdir -p ~/.config/fd
echo '# Machine-generated caches — never a file you pick from a list.
Library/
.cache/
.npm/
.cargo/
.rustup/
.local/
.vscode/
node_modules/
.Trash/' > ~/.config/fd/ignore
From ~ Files offered
before 652,712
after 9,430

Both figures are from the same shell on the same machine, minutes apart — a sixty-nine-fold cut, and what survives is Desktop, Documents, Downloads and your actual project folders. It applies to every fd invocation on the machine, Ctrl-T included, and costs you nothing you were going to search for — rg -uu and fd -u still reach past it when you genuinely mean to.

Even at 8,684 this is a project tool used from inside a project. The honest rule is that Ctrl-T's list should be the tree you are working in; if you find yourself pressing it from ~, the thing you actually wanted was to change directory first.

The macOS trap: Alt-C types a letter instead

Ctrl-T and Ctrl-R work the moment the integration is sourced. Alt-C usually does not, and it fails in a way that looks like nothing to do with fzf: pressing it inserts an accented character into your command line.

Option-C  →  ç        # US layout
Option-C  →  ć        # Polish layout

That is macOS working as designed. The Option key is a compose key, and each layout maps it to its own accented letters, so the terminal never sends the Meta-C that fzf is listening for. Which letter you get is a property of your keyboard layout, not of the terminal.

Change the right Option key and leave the left one alone. Both terminals below configure the two keys separately, so Right Option → Esc+ with Left Option → Normal gives you Meta on one thumb and ć ó ż ł ę ś on the other. Only give up the left one if you never type those.

  • iTerm2 — Settings (⌘,) → Profiles → your profile → Keys tab → GeneralRight Option (⌥) keyEsc+
  • Terminal.app — Settings → Profiles → Keyboard → Use Option as Meta key
  • Ghostty, WezTerm, Alacritty — Meta is on by default; nothing to change

Two things that go wrong on the way there

iTerm2 has two different panels called Keys, and the one in the toolbar is the wrong one. Toolbar → Keys → Remap Modifiers changes which physical key acts as which modifier, machine-wide; setting Right Option to something there does not give you Meta and can cost you the Option key entirely. The setting you want is nested one level down, inside a profile. That is also why it is per-profile in the preferences file: a second profile keeps its own answer, and changing the one you are not using looks exactly like the change not working.

Once you are on the right panel there are seven dropdowns in a column, and the menu tells you whether you are on the right row. The two Option rows offer Normal / Meta / Esc+. The control, command and fn rows offer Normal / Hyper / Meta / Super — no Esc+ at all. If the menu you just opened has no Esc+ in it, you are on a modifier that cannot do this job; close it without choosing, because Meta or Super on a control row will break the Ctrl-R and Ctrl-T that currently work.

Check it without clicking

The setting lands in iTerm2's preferences file, so you can read back what actually took effect — one pair per profile, in the order the profile list shows them:

/usr/libexec/PlistBuddy -c "Print" ~/Library/Preferences/com.googlecode.iterm2.plist | grep -i "Option Key Sends"
Option Key Sends = 0              <- left,  Normal: still types accents
Right Option Key Sends = 2        <- right, Esc+:   this is the one fzf needs
Option Key Sends = 0              <- a second profile, untouched
Right Option Key Sends = 0

0 is Normal, 1 is Meta, 2 is Esc+. Read it rather than trusting the click — this is a setting whose failure mode is silence, and a wrong profile looks identical to a wrong panel.

Working Alt-C opens a list of directories only, which is a useful confirmation on its own: FZF_ALT_C_COMMAND runs fd --type d, so if what appears has files in it, the variable has not reached that shell.

A preview pane

bat is a cat that syntax-highlights and knows about git. Wired into Ctrl-T it shows the file under the cursor while you narrow the list:

set -gx FZF_CTRL_T_OPTS "--preview 'bat -n --color=always {}'"

{} is the currently highlighted line, substituted by fzf before running the command. Any command works there — head -50 {} needs nothing extra installed at all.

Skip this if you would rather not add a dependency. The preview is a genuine convenience when you are picking among six similarly-named files and a waste of a pane the rest of the time.

Where it does not help

fzf narrows a list you already have. It has no index, learns nothing between runs, and cannot answer which files mention this function — that is rg, and the two compose rather than compete:

rg -l "fn main" | fzf     # narrow the files that matched, interactively

It is also not a project switcher. A fuzzy list of every directory under ~ is not more useful than a fuzzy list of every file under ~; both are too long to narrow by typing. The tools that solve that keep a ranked list of where you have actually been.

See also

  • What the Rust rewrites boughtfd and rg measured against the tools they replace, and the honest breakdown of where the speed comes from
  • Choosing an editor — the same question one layer up: what a window costs you before it shows you a type

Po polsku

Wyszukiwanie rozmyte (fuzzy finding) to zamiana „wpisz dokładną ścieżkę” na „wpisz cztery litery i wybierz z listy”: fzf niczego nie indeksuje i nic nie pamięta między uruchomieniami — czyta linie ze standardowego wejścia i pozwala je interaktywnie zawężać. Pułapka, o której jest ta strona, nie dotyczy jednak samego narzędzia. brew install fzf (czy apt install fzf) daje polecenie, a nie skróty klawiszowe, a instaluje się je właśnie dla skrótów. Dopiero jedna linia w pliku startowym powłoki (shell) — fzf --fish | source, eval "$(fzf --bash)" — sprawia, że Ctrl-T, Ctrl-R i Alt-C zaczynają istnieć. Bez niej człowiek dochodzi do wniosku, że fzf to „menu, któremu trzeba coś podać potokiem”, i odinstalowuje.

Najciekawszy dla polskiego czytelnika jest tu akapit o macOS, bo angielska rada jest w naszym przypadku odwrócona. W układzie „Polish Pro” wszystkie dziewięć polskich liter siedzi pod klawiszem Option (ą = ⌥a, ć = ⌥c, ę = ⌥e, ł = ⌥l, ń = ⌥n, ó = ⌥o, ś = ⌥s, ź = ⌥x, ż = ⌥z), więc terminal nigdy nie wysyła Meta, na które czeka fzf, i Alt-C po prostu wpisuje ć. Strona radzi poświęcić prawy Option i zostawić lewy — to trafne przy układzie amerykańskim, ale pięć najczęstszych polskich znaków (ą ś ć ż ź) leży na lewej połowie klawiatury, więc naturalnie wystukuje się je lewą ręką przy prawym Optionie. Oddając prawy, tracisz dokładnie te litery. Wyjścia są dwa: oddać zamiast niego lewy Option (wtedy niewygodne robią się ó, ł, ń, leżące po prawej), albo nie oddawać żadnego i podpiąć widget katalogów pod wolny skrót z Ctrl — w fish bind \cg fzf-cd-widget (najpierw sprawdź samym bind, czy Ctrl-G jest u ciebie wolny). Ctrl-T i Ctrl-R tego problemu nie mają i działają od razu po włączeniu integracji.

W samej liście fzf polskie znaki zachowują się lepiej, niż można się spodziewać, i warto to wiedzieć, zanim zacznie się ich unikać w nazwach plików. fzf domyślnie normalizuje litery alfabetu łacińskiego, więc wpisane bez ogonków gorska znajduje wycieczka_górska.md, a zolw znajduje żółw. Działa to tylko w jedną stronę — wpisanie ż zawęża wynik do prawdziwego ż i odsiewa z — a wyłącza je flaga --literal. Praktyczny wniosek: przy zawężaniu listy w ogóle nie sięgaj po Option, pisz bez ogonków, a ogonek wstaw celowo dopiero wtedy, gdy chcesz odsiać nazwy, które go nie mają.

Ostatnia część strony to higiena samej listy i nie ma w niej nic polskiego, ale jedna liczba jest warta zapamiętania: własny mechanizm chodzenia po katalogach, którego fzf używa domyślnie, nie czyta .gitignore. Przestawienie FZF_DEFAULT_COMMAND na fd zmienia listę w tym repozytorium z około 18 800 pozycji na 444. To nie jest argument o szybkości — 18 800 linii fzf zawęża natychmiast — tylko o tym, że listę 444 prawdziwych plików da się trzema znakami przyciąć do jednego, a listy 18 800 nie da się.

Szukaj po polsku: wyszukiwanie rozmyte · skróty klawiszowe w terminalu · układ klawiatury Polish Pro · fzf key bindings not working · fzf shell integration · iterm2 right option esc+