zsh shell
September 28, 2021

Configuring Zsh From Scratch

Configuring my shell without using any framework

My zsh

I recently decided to reconfigure my Zsh from scratch to try some new plugins. My old config was relatively simple, though It was getting somewhat messy. In addition, I also wanted to try to set up Vi Mode properly and actually play around with the keybinds.

In this post I will go over some of the configuration I made.

The Zsh Dotfiles

As described in Zsh’s docs, Zsh looks for files in $ZDOTDIR if defined. In order to keep my home directory clean, I decided to set this variable system-wide, in /etc/zsh/zshenv.

ZDOTDIR="$HOME/.config/zsh"

Zsh is said to use the following files:

# Sourced on all invocations of the shell
$ZDOTDIR/.zshenv 

# Sourced in interactive shells
$ZDOTDIR/.zshrc

# Sourced in login shells, before .zshrc
$ZDOTDIR/.zprofile

# Sourced in login shells, after .zshrc
$ZDOTDIR/.zlogin

# Sourced when the shell exits
$ZDOTDIR/.zlogout

.zprofile

.zprofile contains my environment variables. First of all I specify my preferred programs and settings:

export EDITOR=/usr/bin/nvim
export BROWSER=/bin/google-chrome-stable
export MONITOR="HDMI-0"
export TERMINAL="/bin/alacritty"

I also append the path for my local binaries and scripts to the PATH variable.

export PATH="$HOME"/.local/bin:"$HOME"/.local/bin/scripts:"$HOME"/.local/share/npm/bin:"$PATH"

The rest of the file is filled with a ton of export commands. They specify settings for other programs. I mostly attempt to clean my home directory from files junk generated by them. More info can be found in here.

export XDG_CONFIG_HOME="$HOME/.config"
export XDG_CACHE_HOME="$HOME/.cache"
export XDG_DATA_HOME="$HOME/.local/share"

# Thousands of exports
# ...

We mustn’t forget to automatically start X11 if the shell is being started in the first tty.

## Launches X11 on session start
if [[ -z $DISPLAY ]] && [[ $(tty) = /dev/tty1 ]]; then
  startx "$XINITRC"
fi

.zlogin

I found out that Zsh has the zcompile builtin. It allows us to compile Zsh files. I know, it already sounds kind of stupid (it is meant to be used with big libraries or frameworks). Buuut… compiling my dotfiles can’t hurt… right? So, I defined the following zcompare function, to check whether files have changed since the last compilation, and should, therefore, be recompiled. The execution is asynchronous, it shouldn’t impact startup either.

Needless to say, I haven’t noticed any performance improvements, my config is just a few hundred lines. I must confess I keep it because I found it funny.

zcompare() {
  if [[ -s ${1} && ( ! -s ${1}.zwc || ${1} -nt ${1}.zwc) ]]; then
    zcompile ${1}
  fi
}

emulate zsh -o extended_glob -c "local files=($ZDOTDIR/*/*.zsh)"
for file in "${files[@]}"; do
  zcompare $file
done

zcompare .zshrc

Compiling a file will generate a .zwc file that will be automatically sourced instead of the original.

.zshrc

.zshrc is the most important file of all, it establishes preferences, loads and configures plugins, and sets the prompt. In my case, it begins by loading my utils module, and setting some base settings (for things such as as history).

# General Setting foldstart
source "$ZDOTDIR/utils/utils.zsh"

# History in cache directory:
HISTSIZE=50000
SAVEHIST=50000
HISTFILE="$XDG_CACHE_HOME"/zsh/history

# Clear default keybinds
clear-keybinds

I also configure and load (or generate) completions.

# Basic auto/tab complete:
autoload -U compinit
zmodload zsh/complist
zstyle ':completion:*' menu select

# Include hidden files.
_comp_options+=(globdots)		

# Autocomplete from the middle of the word
zstyle ':completion:*' matcher-list 'r:|=*' 'l:|=* r:|=*'
compinit
Plugins

At the moment, I am installing my plugins through the AUR, and using paru (my AUR helper) to manage them. Instead of going with a plugin manager, I made a custom function to source the plugins. Why? In my experience, most of them are slow enough to be noticeable. I also have very basic needs so I only need 4 or 5 plugins. In the future I will probably write a function that clones / updates the plugins to avoid relying on my distro’s package manager.

For now, however, this just works.

# Suggestions while typing commands
load-plugin    "zsh-autosuggestions"

# Syntax highlighting for commands
load-plugin    "fast-syntax-highlighting"

# Better history search
load-plugin    "zsh-history-substring-search"   

# Reminder to use aliases 
load-plugin    "zsh-you-should-use" 
Modules

I’ve divided my config in several modules. I know, it’s overkill. Hopefully it will make it more readable and organized, and it will also satisfy my OCD. I’ll get more into it below. For now, all that matters is that I source them simply with:

source "$ZDOTDIR/modules/functions.zsh"
source "$ZDOTDIR/modules/keybinds.zsh"
source "$ZDOTDIR/modules/prompt.zsh"
source "$ZDOTDIR/modules/alias.zsh"
Keybinds

One of the main reasons I wanted to redo my config was to try Zsh’s Vi Mode. My past experiences with it have not been great but recently I found out a post explaining how to fix some of its (many) flaws:

The biggest issue is that Zsh’s Vi mode hangs / swallows some keypresses at times. I figured out (jk, I found in StackOverFlow), that it has to do with key combinations set to begin with ESC.

The solution is pretty straight forward. Just unmap every keybind that starts with ESC and reduce the timeout for key chains to the minimum:

bindkey -v

export KEYTIMEOUT=1   
bindkey -M vicmd '^[' undefined-key

bindkey -M vicmd -r "^[OA"    # up-line-or-history
bindkey -M vicmd -r "^[OB"    # down-line-or-history
bindkey -M vicmd -r "^[OC"    # vi-forward-char
bindkey -M vicmd -r "^[OD"    # vi-backward-char
bindkey -M vicmd -r "^[[200~" # bracketed-paste
bindkey -M vicmd -r "^[[A"    # up-line-or-history
bindkey -M vicmd -r "^[[B"    # down-line-or-history
bindkey -M vicmd -r "^[[C"    # vi-forward-char
bindkey -M vicmd -r "^[[D"    # vi-backward-char

Another tweak I did was removing the : keybind. I found myself accidentally pressing it sometimes. I also set backspace to its normal behaviour, regardless of the Vim mode I’m in. (Otherwise, in normal mode, it would just position the cursor backwards without removing the text).

bindkey -M vicmd -r ":"       

bindkey "^?" backward-delete-char

I also like to be able to move through my completion menu’s using the home row:

bindkey -M menuselect '^J' vi-down-line-or-history
bindkey -M menuselect '^K' vi-up-line-or-history
bindkey -M menuselect '^H' vi-backward-char
bindkey -M menuselect '^L' vi-forward-char

I rather use history-substring-search, instead of the normal history bindings. Thus I removed them and instead set my own:

# Remove defaults
bindkey -rM viins '^X'
bindkey -M viins '^X,' _history-complete-newer \
                 '^X/' _history-complete-older \
                 '^X`' _bash_complete-word 

# Set my own
bindkey -M viins '^[[A' history-substring-search-up    # Arrow up
bindkey -M viins '^[[B' history-substring-search-down  # Arrow down
bindkey -M vicmd '^K'   history-substring-search-up    
bindkey -M viins '^K'   history-substring-search-up    
bindkey -M vicmd 'k'   history-substring-search-up    
bindkey -M vicmd '^J'   history-substring-search-down  
bindkey -M vicmd 'j'   history-substring-search-down  
bindkey -M viins '^J'   history-substring-search-down  

Finally, I set a few more convenient keybinds and disabled those annoying Ctrl+S/ Ctrl+Q maps.

# / to search through history
bindkey -M vicmd '/' fzf-history

# C-Q to edit command in $EDITOR
autoload -U edit-command-line
zle -N edit-command-line
bindkey -M viins "^Q" edit-command-line
bindkey -M vicmd "^Q" edit-command-line

setopt NO_FLOW_CONTROL  # Disable Ctrl+S and Ctrl+Q 
Prompt

I won’t go into full detail about my prompt, if you’re interested you might want to take a look at its source file. It looks fancy. It has colors.

The most remarkable thing I wanted was a Vi Mode indicator

function zle-line-init zle-keymap-select {
    case $KEYMAP in
      vicmd)      VI_INDICATOR="%{$fg[magenta]%}"   ;;
      main|viins) VI_INDICATOR="%{$fg[blue]%}ﴨ"   ;;
    esac
    zle reset-prompt
}

zle -N zle-line-init
zle -N zle-keymap-select

I also wanted to overwrite the default virtualenv indicator, we can do so by setting VIRTUAL_ENV_DISABLE_PROMPT and defining our own:

function virtenv_indicator {
    if [[ -z $VIRTUAL_ENV ]] then
        VIRTUAL_ENV_INDICATOR=''
    else
        VIRTUAL_ENV_INDICATOR=" ${VIRTUAL_ENV##*/} "
    fi
}

autoload -Uz add-zsh-hook
add-zsh-hook precmd virtenv_indicator

# Disable default virtualenv prompt
export VIRTUAL_ENV_DISABLE_PROMPT=1 

And finally I configured a function to get gitstatus information which I won’t get into here. Just know that I decided to use gitstatus over other alternatives because its fast and async, specially noticeable in larger repos.

Options

At the end of the file, I set my preferred options. Zsh offers a wide variety of options that let you tweak how the shell behaves. These are mine:

# automatically list choices on ambiguous completion
setopt AUTO_LIST               
# show completion menu on a successive tab press
setopt AUTO_MENU               
# if completed parameter is a directory, add a trailing slash
setopt AUTO_PARAM_SLASH        
# complete from the cursor rather than from the end of the word
setopt COMPLETE_IN_WORD        
# do not autoselect the first completion entry
setopt NO_MENU_COMPLETE        
setopt HASH_LIST_ALL
setopt ALWAYS_TO_END

# History
# dont store duplicate lines in the history file
setopt HIST_SAVE_NO_DUPS
setopt HIST_IGNORE_ALL_DUPS
# write and import history on every command
setopt SHARE_HISTORY             
setopt HIST_FIND_NO_DUPS 

# Other
# allow comments in command line
setopt INTERACTIVE_COMMENTS    

Results

Overall I’m pretty happy with my current config. Movement and navigation have turned out to be really comfortable, and I’m glad I tried Vi Mode (though it took a while to figure out a fix for its multiple issues). As I mentioned earlier, the main thing that I still want to do is figure out a better way to manage plugins.

Vi Mode Showcase

Have a nice day.