What FZF_CTRL_T_OPTS can and cannot change¶
Level: 301 · fzf's key bindings are on, and you want a preview pane, a different ranking or one key back
One line: A key binding never passes flags to fzf. __fzf_defaults builds one FZF_DEFAULT_OPTS string — fzf's defaults for that key, then $FZF_DEFAULT_OPTS_FILE, then your $FZF_DEFAULT_OPTS, then the widget's own flags with your FZF_CTRL_T_OPTS spliced in at one fixed point — and runs plain fzf. A later flag wins, so your variable overrides everything written before the splice and nothing written after it: FZF_CTRL_T_OPTS=--scheme=default really does change how Ctrl-T ranks, while FZF_CTRL_R_OPTS=--no-read0 is undone two words later by the --read0 fzf appends. Setting the matching FZF_CTRL_T_COMMAND to the empty string removes the binding altogether, and FZF_CTRL_R_COMMAND is read, warned about, and ignored.
Measured¶
The composer and the three call sites come out of fzf --bash itself. Every probe after them runs in a fresh interactive bash -i that sources fzfopts_probe_bash.inc: load evaluates fzf --bash, and opts cuts a widget's two arguments out of that same text and calls __fzf_defaults with them, so the composed lines below are the widget's own and not a copy. keys reports which keys are bound to an fzf widget. A TAB is printed as \t and a NUL as \0.
Verified output of fzfopts_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.
$ fzf --bash | sed -n '/^__fzf_defaults/,/^}/{p;/^}/q;}'
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
$ fzf --bash | grep -n -F 'FZF_DEFAULT_OPTS=$(__fzf_defaults' | grep -v COMPLETION
54: FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=file,dir,follow,hidden --scheme=path" "${FZF_CTRL_T_OPTS-} -m") \
76: FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=dir,follow,hidden --scheme=path" "${FZF_ALT_C_OPTS-} +m") \
89: FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
112: FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
$ load; opts FZF_CTRL_T_OPTS
--height 40% --min-height 20+ --bind=ctrl-z:ignore --reverse --walker=file,dir,follow,hidden --scheme=path
-m
$ load; opts FZF_ALT_C_OPTS
--height 40% --min-height 20+ --bind=ctrl-z:ignore --reverse --walker=dir,follow,hidden --scheme=path
+m
$ load; opts FZF_CTRL_R_OPTS
--height 40% --min-height 20+ --bind=ctrl-z:ignore
-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line +m --read0
$ export FZF_DEFAULT_OPTS=--info=inline FZF_CTRL_T_OPTS="--preview 'cat {}' +m"; load; opts FZF_CTRL_T_OPTS
--height 40% --min-height 20+ --bind=ctrl-z:ignore --reverse --walker=file,dir,follow,hidden --scheme=path
--info=inline --preview 'cat {}' +m -m
$ cat paths.txt
src/app.js
src/main/java/App.java
doc/app/readme.md
$ load; FZF_DEFAULT_OPTS=$(opts FZF_CTRL_T_OPTS) fzf --filter=app < paths.txt
src/app.js
src/main/java/App.java
doc/app/readme.md
$ export FZF_CTRL_T_OPTS=--scheme=default; load; FZF_DEFAULT_OPTS=$(opts FZF_CTRL_T_OPTS) fzf --filter=app < paths.txt
src/app.js
doc/app/readme.md
src/main/java/App.java
$ printf 'git status\0git push\0make test\0' > history.z
$ load; FZF_DEFAULT_OPTS=$(opts FZF_CTRL_R_OPTS) fzf --filter=git --print0 < history.z
git status\0
git push\0
$ export FZF_CTRL_R_OPTS=--no-read0; load; FZF_DEFAULT_OPTS=$(opts FZF_CTRL_R_OPTS) fzf --filter=git --print0 < history.z
git status\0
git push\0
$ load; FZF_DEFAULT_OPTS=$(opts FZF_CTRL_R_OPTS) fzf --no-read0 --filter=git --print0 < history.z
git status\0
git push\0
make test\0
\0
$ load; keys
"\C-r"
"\C-t"
"\ec"
$ FZF_CTRL_T_COMMAND= load; keys
"\C-r"
"\ec"
$ FZF_CTRL_T_COMMAND="fd --type f" load; keys
"\C-r"
"\C-t"
"\ec"
$ FZF_CTRL_R_COMMAND="atuin search" load 2>&1 >/dev/null; keys
warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R
"\C-r"
"\C-t"
"\ec"
The line is built in four layers, and it arrives as three lines. __fzf_defaults prints fzf's per-key defaults and $1, then copies $FZF_DEFAULT_OPTS_FILE if it can read it, then prints $FZF_DEFAULT_OPTS and $2. fzf splits FZF_DEFAULT_OPTS on any whitespace, newlines included, so three lines are one option list. With nothing of yours set, $FZF_DEFAULT_OPTS_FILE is unset, command cat fails quietly, and the middle line is empty.
Your variable is $2, and it is not last. Ctrl-T's second argument is "${FZF_CTRL_T_OPTS-} -m". Setting both variables put --info=inline in the middle line and --preview 'cat {}' +m after it — and then fzf's own -m. So the +m never takes effect: Ctrl-T is multi-select whatever you write. The same shape holds for Alt-C, whose tail is +m, and for Ctrl-R, whose tail is +m --read0.
Before the splice, you win. Ctrl-T's first argument ends --scheme=path, which ranks a match by the last path segment. Under it, app put src/main/java/App.java second; with FZF_CTRL_T_OPTS=--scheme=default it fell to last. That is the whole trick behind the wiki's FZF_CTRL_T_OPTS="--preview '…'" and FZF_ALT_C_OPTS="--preview 'tree -C {}'" — --preview is not among the flags fzf appends afterwards, so it survives.
After the splice, you lose, and quietly. FZF_CTRL_R_OPTS=--no-read0 produced exactly the unset result: two matches, NUL-terminated, because the trailing --read0 came later and won. Moving --no-read0 to the command line, where nothing follows it, gives what that variable was asking for — one item whose text is the whole file, NULs and all. No warning is printed either way. Ctrl-R's tail is why FZF_CTRL_R_OPTS is the right place for --no-sort --exact and the wrong place for --read0, +m or --query.
FZF_CTRL_T_COMMAND doubles as the on/off switch, and only the empty string is off. fzf --bash guards each bind with [[ ${FZF_CTRL_T_COMMAND-x} != "" ]], so unset binds the key, any command binds the key, and FZF_CTRL_T_COMMAND= leaves Ctrl-T alone. FZF_CTRL_R_COMMAND reaches the same guard but no widget: setting it prints warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R on stderr and binds Ctrl-R to fzf's own history pipeline anyway.
| binding | before your splice, so overridable | after your splice, so not |
|---|---|---|
| Ctrl-T | --height 40% --min-height 20+ --bind=ctrl-z:ignore --reverse --walker=file,dir,follow,hidden --scheme=path |
-m |
| Alt-C | the same, with --walker=dir,follow,hidden |
+m |
| Ctrl-R | --height … --bind=ctrl-z:ignore, then -n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign … --highlight-line |
+m --read0 |
On a Mac¶
Nothing changes, and the key is shared. fzf --bash is the same text on both machines, so the composed lines and the rankings are too. The one thing that does differ is how the keys get bound: bash 5.2 uses bind -x, and /bin/bash 3.2, which has no bind -x in a startup file, uses readline macros instead — so keys asks both bind -s and bind -X and gets one answer on both. Ctrl-R is a pipeline shows those two branches of the integration side by side.
In zsh and fish¶
zsh gets the same composer, byte for byte, and the same splice — what differs is what a key falls back to, because zsh already had something on ^T and \ec:
Verified output of fzfopts_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.
$ diff <(fzf --bash | sed -n "/^__fzf_defaults/,/^}/{p;/^}/q;}") <(fzf --zsh | sed -n "/^__fzf_defaults/,/^}/{p;/^}/q;}") && echo "the composer is identical"
the composer is identical
$ fzf --zsh | grep -n -F 'FZF_DEFAULT_OPTS=$(__fzf_defaults' | grep -v COMPLETION
77: FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=file,dir,follow,hidden --scheme=path" "${FZF_CTRL_T_OPTS-} -m") \
109: FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=dir,follow,hidden --scheme=path" "${FZF_ALT_C_OPTS-} +m") \
140: FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m --read0") \
144: FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m") \
$ load; opts FZF_CTRL_T_OPTS
--height 40% --min-height 20+ --bind=ctrl-z:ignore --reverse --walker=file,dir,follow,hidden --scheme=path
-m
$ export FZF_CTRL_T_OPTS=--scheme=default; load; FZF_DEFAULT_OPTS=$(opts FZF_CTRL_T_OPTS) fzf --filter=app < paths.txt
src/app.js
doc/app/readme.md
src/main/java/App.java
$ load; keys
^T -> "^T" fzf-file-widget
^R -> "^R" fzf-history-widget
\ec -> "^[c" fzf-cd-widget
$ FZF_CTRL_T_COMMAND= FZF_ALT_C_COMMAND= load; keys
^T -> "^T" transpose-chars
^R -> "^R" fzf-history-widget
\ec -> "^[c" capitalize-word
The probes use fzfopts_probe_zsh.inc, the same three helpers reading fzf --zsh. diff found nothing between __fzf_defaults in fzf --bash and in fzf --zsh. The call sites are the same two arguments, with one addition: zsh's Ctrl-R splices --query=${(qqq)LBUFFER} in after your FZF_CTRL_R_OPTS, so a --query of yours is overridden there as well. FZF_CTRL_T_COMMAND= did not leave ^T unbound — it left zsh's own transpose-chars on it, and \ec went back to capitalize-word. That is worth knowing before you conclude the variable did nothing.
fish ships a fish __fzf_defaults, and it composes one line rather than three:
Verified output of fzfopts_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.
$ fzf --fish | sed -n "/^ function __fzf_defaults/,/^ end/p"
function __fzf_defaults
# $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS $argv[2..-1]
end
$ fzf --fish | sed -n "/FZF_DEFAULT_OPTS (__fzf_defaults/,/)\$/p"
set -lx FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --walker=file,dir,follow,hidden --scheme=path" \
"$FZF_CTRL_T_OPTS --multi --print0")
set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--nth=2..,.. --scheme=history --multi --wrap-sign="\t↳ "' \
'--bind=\'shift-delete:execute-silent(eval history delete --exact --case-sensitive -- (string escape -n -- {+} | string replace -r -a "^\d*\\\\\\t|(?<=\\\\\\n)\\\\\\t" ""))+reload(eval $FZF_DEFAULT_COMMAND)\'' \
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c)
set -lx FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --walker=dir,follow,hidden --scheme=path" \
"$FZF_ALT_C_OPTS --no-multi --print0")
$ fzf --fish | source; __fzf_defaults "--reverse --walker=file,dir,follow,hidden --scheme=path" "$FZF_CTRL_T_OPTS --multi --print0"
--height 40% --min-height=20+ --bind=ctrl-z:ignore --reverse --walker=file,dir,follow,hidden --scheme=path --multi --print0
$ cat paths.txt
src/app.js
src/main/java/App.java
doc/app/readme.md
$ fzf --fish | source; set -lx FZF_DEFAULT_OPTS (__fzf_defaults "--reverse --walker=file,dir,follow,hidden --scheme=path" "$FZF_CTRL_T_OPTS --multi --print0"); fzf --filter=app < paths.txt | tr '\0' '\n'
src/app.js
src/main/java/App.java
doc/app/readme.md
$ set -gx FZF_CTRL_T_OPTS --scheme=default; fzf --fish | source; set -lx FZF_DEFAULT_OPTS (__fzf_defaults "--reverse --walker=file,dir,follow,hidden --scheme=path" "$FZF_CTRL_T_OPTS --multi --print0"); fzf --filter=app < paths.txt | tr '\0' '\n'
src/app.js
doc/app/readme.md
src/main/java/App.java
$ fzf --fish | source; for k in ctrl-t ctrl-r alt-c; bind $k; end
bind ctrl-t fzf-file-widget
bind ctrl-r fzf-history-widget
bind alt-c fzf-cd-widget
$ set -gx FZF_CTRL_T_COMMAND ""; fzf --fish | source; for k in ctrl-t ctrl-r alt-c; bind $k; echo "status $status"; end
bind: No binding found for key 'ctrl-t'
status 1
bind ctrl-r fzf-history-widget
status 0
bind alt-c fzf-cd-widget
status 0
string join ' ' puts the four layers on one line, and spells one flag --min-height=20+ where bash and zsh write --min-height 20+. The splice is in the same place and the ranking answer is identical. fish's Ctrl-R is the one widget laid out differently: it passes five groups, your $FZF_CTRL_R_OPTS is the fourth, and --accept-nth=2.. --read0 --print0 --with-shell=… follows it — then --tac is appended after all of that. So --multi and --scheme=history are overridable in fish and +m is not overridable in bash, from the same variable. And an unbound key in fish is simply unbound: bind ctrl-t printed No binding found for key 'ctrl-t' and exited 1.
| the Ctrl-T line | bash | zsh | fish |
|---|---|---|---|
| composed as | three lines, printf + cat + printf |
the same three lines | one line, string join ' ' |
| the minimum-height flag | --min-height 20+ |
--min-height 20+ |
--min-height=20+ |
appended after your _OPTS |
-m |
-m |
--multi --print0 |
| loaded by | eval "$(fzf --bash)" |
source <(fzf --zsh) |
fzf --fish \| source |
FZF_CTRL_T_COMMAND= leaves Ctrl-T |
unbound | transpose-chars |
unbound, bind ctrl-t exits 1 |
FZF_ALT_C_COMMAND= leaves Alt-C |
unbound | capitalize-word |
unbound |
If you are coming from another library¶
- Rust. Fuzzy finding ↗ installs fzf, turns the three key bindings on, and says what each key is for. This page is what to write in the startup file afterwards.
See also¶
- Ctrl-R is a pipeline — the list Ctrl-R searches, and the same integration read for its stages rather than its flags
- Fuzzy means in order — what
--scheme=pathand--scheme=historyactually do to the ranking - fzf is a filter —
FZF_DEFAULT_OPTSand--filter, without a key binding in the way - Which startup file runs — where
export FZF_CTRL_T_OPTS=…has to go for an interactive shell to see it - fzf(1), 0.67.0 ↗ —
FZF_DEFAULT_OPTS,FZF_DEFAULT_OPTS_FILE, and the per-key variables - Configuring shell key bindings ↗ — fzf's own wiki page, which gives the recipes; this page measures why they land where they do