Barebones Zsh
No Oh My Zsh, no prezto, no plugin manager. Just Zsh, a handful of small files, and three external tools. Everything in the config is something I put there and can explain.
What’s in it
- Config in
~/.config/zshinstead of scattered across the home directory - Vi mode with a beam/block cursor, text objects and surround
hjklin the completion menu- A directory stack you can actually use
- History that survives a crashed terminal
- Starship for the prompt, zoxide for jumping around, fzf for history and files
Most of it ships with Zsh and only needs switching on.
Layout
~/.zshenv has to stay in the home directory. It does one job: point Zsh at the real config.
export ZDOTDIR="${ZDOTDIR:-$HOME/.config/zsh}"
.zshrc is then just a loader for small files, one per concern: env, path, completion, keybindings, options, tools. The order matters in one place, which is the first thing that bit me.
Vi mode
bindkey -v
# Edit the current command in $VISUAL with v from normal mode
autoload -Uz edit-command-line
zle -N edit-command-line
bindkey -M vicmd v edit-command-line
# hjkl in the completion menu
zmodload zsh/complist
bindkey -M menuselect 'h' vi-backward-char
bindkey -M menuselect 'k' vi-up-line-or-history
bindkey -M menuselect 'l' vi-forward-char
bindkey -M menuselect 'j' vi-down-line-or-history
bindkey -v has to run before every other bindkey in the config. Zsh picks the main keymap at startup, and in a fresh terminal that is emacs. Any binding made before the switch lands in the emacs keymap and disappears the moment vi mode turns on. That includes the bindings fzf sets up, so the keybindings file is sourced before anything that touches keys.
A beam cursor in insert mode and a block in normal mode makes the current mode obvious:
zle-keymap-select() {
if [[ $KEYMAP == vicmd ]]; then
echo -ne '\e[2 q' # block
else
echo -ne '\e[6 q' # beam
fi
}
zle-line-init() { echo -ne '\e[6 q' }
zle -N zle-keymap-select
zle -N zle-line-init
Text objects and surround
Out of the box, Zsh’s vi mode has word objects and nothing else. No ci", no da(. The widgets ship with Zsh, they just aren’t bound:
autoload -Uz select-bracketed select-quoted
zle -N select-quoted
zle -N select-bracketed
for km in viopp visual; do
for c in {a,i}${(s..)^:-\'\"\`\|,./:;=+@}; do
bindkey -M $km $c select-quoted
done
for c in {a,i}${(s..)^:-'()[]{}<>bB'}; do
bindkey -M $km $c select-bracketed
done
done
Surround is the same story, a bundled function waiting for keys:
autoload -Uz surround
zle -N delete-surround surround
zle -N add-surround surround
zle -N change-surround surround
bindkey -M vicmd cs change-surround
bindkey -M vicmd ds delete-surround
bindkey -M vicmd ys add-surround
bindkey -M visual S add-surround
Now cs"' swaps double quotes for single and ysiw) wraps a word in parentheses, exactly like vim-surround.
Gotcha: KEYTIMEOUT=1 makes surround untypeable
The standard advice for making ESC feel instant in vi mode is:
export KEYTIMEOUT=1
The unit is hundredths of a second, so that is 10ms. It is also how long Zsh waits for the second key of a multi-key binding. After c, you have 10ms to press s before Zsh decides you meant plain c. Nobody types that fast, so cs, ds and ys are bound and never fire.
I went with:
export KEYTIMEOUT=20
200ms is enough to type the pair, and the price is the same 200ms on ESC. If you never use surround, keep it at 1.
Directory stack
zoxide answers “take me to that project”. The directory stack answers “take me back to where I was two cds ago”. They don’t overlap as much as I expected.
setopt AUTO_PUSHD
setopt PUSHD_IGNORE_DUPS
setopt PUSHD_SILENT
alias d='dirs -v'
for index ({1..9}) alias "$index"="cd +${index}"; unset index
d lists the stack with numbers, and typing the number jumps there:
~/dev/site > d
0 ~/dev/site
1 /etc
2 ~/.config/zsh
~/dev/site > 2
~/.config/zsh >
I had the three setopt lines for a long time without the aliases, which means Zsh was faithfully recording a stack I never looked at.
I run zoxide as a cd replacement (zoxide init --cmd cd zsh), so I checked whether cd +2 survives the wrapper. It does: zoxide passes +N and -N straight to the builtin.
History
HISTFILE="$ZDOTDIR/.zsh_history"
HISTSIZE=50000
SAVEHIST=50000
setopt INC_APPEND_HISTORY
setopt HIST_SAVE_NO_DUPS
setopt HIST_IGNORE_SPACE
By default Zsh writes history when the shell exits. With a dozen long-lived tmux panes, that means a crashed terminal takes its history with it, and a new pane knows nothing about what the others ran. INC_APPEND_HISTORY writes each command as it is entered. SHARE_HISTORY goes one step further and imports into shells that are already open; pick one, not both.
Up and down search by prefix, so typing git and pressing up only walks through git commands:
autoload -U up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search
zle -N down-line-or-beginning-search
bindkey "^[[A" up-line-or-beginning-search
bindkey "^[[B" down-line-or-beginning-search
Completion
autoload -Uz compinit; compinit
_comp_options+=(globdots) # complete dotfiles too
That is all the built-in system needs. It already knows git, ssh, docker and a few hundred others.
Gotcha: brew’s zsh-completions does nothing by default
brew install zsh-completions
This drops the completion files in /opt/homebrew/share/zsh-completions, a directory that is not on fpath. Homebrew prints a caveat about it once, at install time, and then it is easy to carry the package around for months while it does nothing. Check with:
print -l $fpath | grep zsh-completions
No output means it isn’t loaded. Add it before compinit runs:
fpath=(/opt/homebrew/share/zsh-completions(N) $fpath)
autoload -Uz compinit; compinit
The (N) makes the entry vanish quietly on machines where the directory doesn’t exist.
Gotcha: XDG variables on macOS
A lot of dotfiles set all three XDG variables in .zshenv. On Linux that’s reasonable. On macOS I ran into both sides of it.
Unset, things break silently. My completion config had:
zstyle ':completion::complete:*' cache-path "$XDG_CACHE_HOME/.zcompcache"
With XDG_CACHE_HOME empty that expands to /.zcompcache, at the root of the filesystem, where nothing can be written. The completion cache had simply never worked.
Set, other things break. Plenty of CLI tools keep their state in ~/Library/Application Support on macOS and switch to the XDG path the moment the variable exists. zoxide is one of them: export XDG_DATA_HOME and it starts over with an empty database, because the old one is still sitting in Application Support.
So I only set the one that fixes a problem and can’t lose anything:
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
The external tools
brew install starship zoxide fzf zsh-syntax-highlighting
These go last in the config, in this order:
eval "$(starship init zsh)"
source "$HOMEBREW_PREFIX/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"
eval "$(zoxide init --cmd cd zsh)"
Syntax highlighting is the only plugin, and it is one source line, so there is nothing to manage. It wraps the widgets that exist when it loads, which is why it comes after the keybindings and surround.
Conclusion
A framework gives you a few hundred aliases and options you never chose. This is the opposite: a config small enough to read in one sitting, where most of the features were already in Zsh. The cost is that you hit the sharp edges yourself, like KEYTIMEOUT against surround, XDG variables against macOS defaults, and a completions package that was never on fpath. Each one took a few minutes to find and one line to fix.
Further reading
Much of this is adapted from two articles that are worth reading in full:
- Back to the Basics: Zsh without Oh My Zsh by Bozhidar Batsov
- Configuring Zsh Without Dependencies from The Valuable Dev