Building a custom Zsh prompt from scratch
... and understanding how your favorite Zsh prompt theme works!
This week, I got a new gorgeous space black MacBook Pro 💻 powered by an M3 Pro CPU and GPU. I was excited to set up my development environment, but this time, I decided not to copy over my decade-old ZSH setup with Oh my ZSH! and Starship. It was an excellent chance to start afresh and learn more about the inner workings of ZSH.
I wanted to build a custom Zsh prompt that was lightning-fast, minimalistic, and focused on performance. I wanted to understand how Zsh works under the hood and how I could leverage its features to build a fast and minimal prompt.
Let's go! 🚀
How Zsh prompts work
Traditionally, shells define the prompt using the PS1
environment variable. Zsh supports this for compatibility but also provides more advanced ways to define prompts using the PROMPT
and RPROMPT
variables.
The PROMPT
variable defines the prompt's left side, while the RPROMPT
variable defines the right side. These variables can contain a mix of text and escape sequences that Zsh expands when the prompt is displayed.
Most Zsh prompts use a combination of escape sequences and custom functions that execute when the prompt is displayed. These functions can display dynamic information like the current Git branch, the current Python virtual environment, or the current NodeJS version.
Building a custom Zsh prompt
Through years of using Zsh, I've come to appreciate minimalistic prompts that are fast and discreet. I wanted to build a prompt that displayed only the essential information, like the current working directory, git branch and status. Something like this:
~/projects/my-project (branch) [status]
$
Zsh uses escape sequences to display dynamic information in the prompt. For example, the %n
escape sequence expands to the current username, the %~
escape sequence displays the current working directory, and the %#
escape sequence expands to a #
if the current user has superuser privileges.
To quickly iterate and test the prompt, you can set the `PROMPT
` variable in the current shell session. For example, the default Zsh prompt in macOS is "%n@%m %~%# "
, which displays the username, host name, working directory, and a `#` if the user has superuser privileges. It would be equivalent to setting the PROMPT
variable to:
PROMPT='%n@%m %~%# '
Using this as a starting point, I decided to get rid of the username and host name (as I'm the only user on my machine) to end with something like this:
PROMPT='%~ %# '
Going multi-line
I'm a big fan of multi-line prompts, as they allow me to display long paths and more information without cluttering the prompt.
My first try was to sneak in a newline character (\n
) in the PROMPT
variable, but that didn't work. Zsh doesn't support escape sequences in the PROMPT
variable, so I used the precmd
hook to print the CWD and display the prompt on the following line.
The precmd
hook executes before each prompt is displayed and can be used to run custom commands or functions. I used the print
with -P
inside it to interpret prompt expansion sequences, just like in the PROMPT
variable.
precmd() {
print -P "%~"
}
PROMPT='%# '
It worked like a charm; I could display the current working directory on a new line.
Adding Git information
Zsh has built-in support for displaying Git information in the prompt. You can use the vcs_info
module to display the current Git branch and status in the prompt.
To enable the vcs_info
module, you must load it into your Zsh session using the autoload
command.
autoload -Uz vcs_info
Once loaded, you must invoke the vcs_info
function in the precmd
hook to update the Git information before displaying each prompt. The function then populates the vcs_info_msg_0_
variable with the Git information, which you can display in the prompt.
precmd() {
vcs_info
print -P "%~ ${vcs_info_msg_0_}"
}
Except that it didn't work. Zsh does not enable the expansion of variables in prompts by default, so I had to enable it using the setopt prompt_subst
command.
setopt prompt_subst
Voilà! I now had a custom Zsh prompt that displayed the current working directory and Git information.
Customizing the VCS info
You can customize the Git information displayed in the prompt by setting the vcs_info_msg_0_
variable to a custom format string. The format string can contain escape sequences that Zsh expands when the prompt is displayed.
I wanted to have the current branch and staged/unstaged status. So, using zstyle, I changed the format string to:
zstyle ':vcs_info:*' enable git
zstyle ':vcs_info:git:*' formats '%b%f %m%u%c %a'
This format string displays the current branch (%b
), the staged (%c
) and unstaged (%u
) changes, any ongoing action like rebase (%a
) and miscellaneous info (%m
).
I wanted to display symbols instead of text for the Git status, so I set the stagedstr
and unstagedstr
variables to +
and !
.
zstyle ':vcs_info:*' check-for-changes true
zstyle ':vcs_info:*' stagedstr ' +'
zstyle ':vcs_info:*' unstagedstr ' !'
Colours and formatting
I got the prompt working, but it was bland. I wanted to add some colours and formatting to make it pop ✨.
Zsh supports escape sequences for colours and formatting, which you can use to style the prompt. The %F{color}
escape sequence sets the foreground colour, the %K{color}
escape sequence sets the background colour, and the %B
escape sequence sets bold text.
I wanted to display the current working directory in bold, the git branch in blue, and the git status in green and red for staged and unstaged changes. I also wanted to display Powerline symbols for the prompt.
So I headed over to Nerd Fonts, downloaded the patched “Fira Code” font, and set it as the font in iTerm.
I then used escape sequences to set the colours and formatting in the PROMPT
and vcs_info
styles.
Here's the final prompt. The Powerline symbol for git branch \uE0A0
is unfortunately not supported in any other font and hence appears as an unrecognized square.
zstyle ':vcs_info:git*' formats " %F{blue}%b%f %m%u%c %a "
zstyle ':vcs_info:*' enable git
zstyle ':vcs_info:*' check-for-changes true
zstyle ':vcs_info:*' stagedstr ' %F{green}✚%f'
zstyle ':vcs_info:*' unstagedstr ' %F{red}●%f'
precmd() {
vcs_info
print -P '%B%~%b ${vcs_info_msg_0_}'
}
PROMPT='%B%(!.#.$)%b '
And here's how it looks:
I use the Snazzy colour scheme in iTerm, which makes the prompt colours look even better. My Zsh configuration has some more customizations, like history search, syntax highlighting, and autosuggestions, but I'll save that for another post.
And that's it! I now have a custom Zsh prompt that displays the current working directory, Git branch and status in a minimalistic and colourful way.