Skip to main content
  1. Posts/
  2. The Solo Stack/
  3. Dotfiles/

Enhancing Zsh with Plugins and fzf Previews

Dotfiles - This article is part of a series.
Part 12: This Article

I know, I know — I’ve promised tmux configuration twice now. But when I sat down to work on it, I kept getting distracted by how bare the shell itself felt. No prompt worth looking at, no autocompletion hints, no syntax coloring. Every time I typed a command, the stock zsh experience reminded me there was unfinished business here first.

So before tmux gets its turn, I took a detour to make zsh actually comfortable. Plugins, a proper prompt, and fzf previews that tie together the tools we’ve already installed.

Three Plugins, One File
#

Zsh has a rich plugin ecosystem, but I didn’t want a plugin manager. Tools like Oh My Zsh and zinit are popular, but they add complexity I don’t need. I only want three things:

Three git repos, each cloned into ~/.zsh/. No plugin framework needed.

Managing Plugins with chezmoi Externals
#

Chezmoi has a feature called externals that handles exactly this — pulling in git repos or archives that aren’t part of your dotfiles repo but should end up on the target machine. You define them in .chezmoiexternal.toml:

[".zsh/pure"]
    type = "git-repo"
    url = "https://github.com/sindresorhus/pure.git"
    refreshPeriod = "168h"

[".zsh/zsh-autosuggestions"]
    type = "git-repo"
    url = "https://github.com/zsh-users/zsh-autosuggestions.git"
    refreshPeriod = "168h"

[".zsh/zsh-syntax-highlighting"]
    type = "git-repo"
    url = "https://github.com/zsh-users/zsh-syntax-highlighting.git"
    refreshPeriod = "168h"

Each entry tells chezmoi to clone a git repo into the specified path under $HOME. The refreshPeriod of 168h (one week) means chezmoi won’t re-pull the repo on every apply — only if it’s been more than a week since the last refresh. This keeps chezmoi apply fast while still picking up updates over time.

When you run chezmoi init --apply on a fresh machine, these repos are cloned automatically alongside everything else. No manual git clone steps.

Wiring Up the Plugins in .zshrc
#

With the repos in place, the .zshrc needs to load them. I replaced the old basic prompt and added the plugin sources:

# Pure Prompt laden
fpath+=$HOME/.zsh/pure
autoload -U promptinit; promptinit
prompt pure

Pure needs its directory added to fpath so zsh can find its prompt functions. Then the standard promptinit system loads it. The result: a clean, two-line prompt showing the current directory and git branch, with async git status that doesn’t slow down your shell.

For the other two plugins, it’s just source:

# Auto-Suggestions
source $HOME/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh

# Syntax Highlighting (Muss zwingend die letzte Zeile sein!)
source $HOME/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

One important detail: zsh-syntax-highlighting must be sourced last. The plugin’s documentation is clear about this — it needs to be the final thing that hooks into zsh’s line editing. If you source something after it, highlighting can break in subtle ways. I added a comment to make sure future-me doesn’t accidentally move it.

Configuring fzf Previews
#

With the plugins in place, I turned to fzf. It was already installed and integrated with zsh (the eval "$(fzf --zsh)" line from the previous setup), but its default behavior is fairly bare — you get a list of filenames and that’s it. fzf supports preview windows that show file contents or directory structures right in the fuzzy finder.

File Preview with bat (Ctrl+T)
#

When you press Ctrl+T in the shell, fzf opens a file picker. By default, you just see filenames. With this configuration, you get a syntax-highlighted preview of each file as you navigate:

# fzf: Vorschau für Dateien (Ctrl+T) mit 'bat' (oder 'cat' als Fallback)
export FZF_CTRL_T_OPTS="
  --preview 'batcat --color=always --style=numbers --line-range=:500 {}'
  --bind 'ctrl-/:change-preview-window(down|hidden|)'"

This uses bat — a cat replacement with syntax highlighting and line numbers. The --line-range=:500 prevents it from trying to render massive files. And ctrl-/ toggles the preview window if you need more space for the file list.

Directory Preview with eza (Alt+C)
#

Alt+C is fzf’s directory changer — it lists directories and cds into the one you select. Adding an eza-powered preview gives you a tree view of each directory before you jump in:

# fzf: Vorschau für Ordner (Alt+C) mit 'eza'
export FZF_ALT_C_OPTS="
  --preview 'eza -T --level=2 --icons=always --color=always {}'"

Two levels of depth is enough to get a sense of what’s inside without overwhelming the preview pane. And since eza is already installed and aliased, this keeps the toolchain consistent.

The batcat Detour
#

This is where I hit a small snag that’s worth documenting because it catches everyone on Debian at least once.

I initially wrote the fzf preview to use bat:

--preview 'bat --color=always --style=numbers --line-range=:500 {}'

It didn’t work. No preview, no error — just nothing. The reason: on Debian, the package is called batcat, not bat. There’s a naming conflict with another package, so Debian renamed it. I’d already added alias bat='batcat' to my .zshrc for interactive use, but aliases don’t work in non-interactive contexts like fzf’s preview command. fzf spawns a subshell to run the preview, and that subshell doesn’t load .zshrc aliases.

The fix is straightforward — use batcat directly in the fzf config:

--preview 'batcat --color=always --style=numbers --line-range=:500 {}'

The bat alias still works when I type commands interactively. But anywhere a command runs in a subshell or script context, it needs to be batcat. A good reminder that aliases are a convenience layer, not a system-wide rename.

The Full .zshrc
#

Here’s the complete file after all the changes:

# History
HISTFILE=~/.zsh_history
HISTSIZE=10000
SAVEHIST=10000
setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS

# Completion
autoload -Uz compinit && compinit

# fzf Integration (Vervollständigung & Tastenkürzel)
eval "$(fzf --zsh)"

# fzf: Vorschau für Dateien (Ctrl+T) mit 'bat' (oder 'cat' als Fallback)
export FZF_CTRL_T_OPTS="
  --preview 'batcat --color=always --style=numbers --line-range=:500 {}'
  --bind 'ctrl-/:change-preview-window(down|hidden|)'"

# fzf: Vorschau für Ordner (Alt+C) mit 'eza'
export FZF_ALT_C_OPTS="
  --preview 'eza -T --level=2 --icons=always --color=always {}'"

# Pure Prompt laden
fpath+=$HOME/.zsh/pure
autoload -U promptinit; promptinit
prompt pure

# eza aliases
alias ls='eza --icons=always'
alias la='eza -a --icons=always'
alias lla='eza -la --icons=always --git --header'
alias lls='eza -T --icons=always'

# bat alias for debian
alias bat='batcat'

export PATH="$HOME/.local/bin:$PATH"

# Auto-Suggestions
source $HOME/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh

# Syntax Highlighting (Muss zwingend die letzte Zeile sein!)
source $HOME/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

What We’ve Achieved So Far
#

After chezmoi init --apply, a fresh machine now has:

  • zsh installed and set as default shell
  • The correct version of age, installed from source
  • SSH keys decrypted and in place
  • Git identity configured for the dotfiles repo
  • SSH key linked to git, remote switched to SSH
  • Ansible installed and used to manage software packages
  • tmux installed via Ansible
  • eza installed from an external apt repository via Ansible
  • Onchange script that watches the entire ansible/ directory for changes
  • Sudo password support for non-root environments
  • fzf installed from a GitHub binary release via Ansible
  • eza aliases configured in .zshrc — ls, la, lla, and lls
  • Pure prompt, autosuggestions, and syntax highlighting — managed via chezmoi externals
  • fzf previews with bat (files) and eza (directories)

What’s Next
#

The shell is starting to feel like home — and that’s exactly why this detour was worth it. Sometimes you need to scratch the itch before you can focus on the next thing.

And that next thing is still tmux. Third time’s the charm — it’s installed, it’s waiting, and now the shell around it is actually pleasant enough that I’ll enjoy configuring it.

Marcus Knuth
Author
Marcus Knuth
Dotfiles - This article is part of a series.
Part 12: This Article

Related

Making eza Your Default

In the previous post, I wrapped up the installation story. We now have four strategies for getting software onto a fresh machine. But installing tools is only half the job — they need configuring. And eza is the perfect place to start, because it’s a drop-in replacement for something I use hundreds of times a day: ls.

Getting Started with Dotfile Management

·1225 words·6 mins
Getting Started with Dotfile Management # Up until now, I’ve mostly been working with GUI-driven IDEs on Windows. I wrote about why I’d like to shift to the terminal — the short version is that a portable, keyboard-driven workflow matters when you’re building solo. But alongside that shift, something else has been happening. Since Docker entered my daily work, I’ve been spending more and more time on Linux again — something I hadn’t done seriously since studying computer science. Linux stepped back into my life gradually, and for a long time I just used it without thinking much about it. I never cared about the fundamentals.

Installing GitHub Releases with Ansible

In the previous post, I fixed the onchange script so the Ansible playbook actually triggers when it should. At the end, I mentioned fzf as the next tool to install. Here’s why it needed a different approach. Three Installation Strategies So Far # Over the last few posts, we’ve built up three ways to install software through chezmoi: