Skip to content

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