Creating a custom Zsh prompt from scratch
I recently decided to try WSL Ubuntu on my Windows 11 machine and was impressed by how easy it was to set up and how seamlessly it integrated with Windows. Naturally, I was tempted to make it the primary environment for all my projects and the most important step in making it feel like home was creating a custom Zsh prompt.
Over the years, I’ve come to prefer Zsh for its intuitive tab completion and extensive prompt customization. It also boasts a vibrant community of developers who build useful plugins to tailor it to your taste, including the most popular: Oh My Zsh.
But what’s the fun in having a Zsh setup that looks similar to the developer next to me? If I’m customizing Zsh, I want it to be unique and precisely crafted to support my development workflows and habits. Besides, when will I get another chance to spend hours exploring Zsh prompt customization just to use it once for a personalized setup?
So, explore I did.
Zsh allows me to customize both the left and right prompts. I decided to use both for different purposes and incorporate various glyphs to make the prompts stand out from regular text. I started off by selecting a monospace font for WSL Ubuntu in Windows Terminal: DejaVu Sans Mono.
Left prompt
For the left prompt, I chose to display the current working directory. This can be done by setting the prompt to show the output of the pwd
command. However, in deeply nested directories, this can cause the prompt to take up most of the line. To address this, I could use pwd
combined with the basename
command to only show the current directory. However, I prefer to maintain an overview of my location within the directory structure. Ideally, I want a truncated path that displays a few directories from the root and a few leading up to the current directory.
I achieved this with the following code in my Zsh profile:
Let’s take a look at the code snippet in chunks. First, I defined a couple of attributes for the prompt:
COLOR_PROMPT_TEXT='009'
COLOR_PROMPT_GLYPH='255'
NUM_DIRS_LEFT_OF_TRUNCATION=1
NUM_DIRS_RIGHT_OF_TRUNCATION=2
GLYPH_PROMPT_TRUNCATION_SYMBOL='⋯'
GLYPH_PROMPT_END_SYMBOL='❯'
- I set the prompt text to be rendered in red (
COLOR_PROMPT_TEXT='009'
) and any glyphs in the prompt to be rendered in white (COLOR_PROMPT_GLYPH='255'
). - I defined the glyphs to be used from the DejaVu Sans Mono font: an ellipsis (
GLYPH_PROMPT_TRUNCATION_SYMBOL='⋯'
) to represent the truncated directory path and a symbol (GLYPH_PROMPT_END_SYMBOL='❯'
) to mark the end of the prompt. - Most importantly, I want the prompt to display a truncated path, showing one directory from the root (
NUM_DIRS_LEFT_OF_TRUNCATION=1
) and two directories leading up to the current directory (NUM_DIRS_RIGHT_OF_TRUNCATION=2
).
It’s convenient to have these attributes defined as variables at the top so I can tweak the colors, glyphs and truncation configuration without changing the subsequent code.
Next, the set_prompt
function evaluates the left prompt and applies the above attributes. Let’s delve into this function:
[[ $NUM_DIRS_LEFT_OF_TRUNCATION -le 0 ]] && NUM_DIRS_LEFT_OF_TRUNCATION=1
[[ $NUM_DIRS_RIGHT_OF_TRUNCATION -le 0 ]] && NUM_DIRS_RIGHT_OF_TRUNCATION=2
- The function begins by validating the truncation configuration values. Mistakenly setting these values to negative or leaving them undefined can cause issues with the prompt. In either case, defaults need to be applied.
Before we move on, let’s review some key concepts related to visual effects and conditional substrings in Zsh prompts:
"%F{color_code}text%f"
renderstext
in the color represented bycolor_code
.%F
sets the color and%f
resets it."%Btext%b"
displaystext
in bold.%B
sets the bold formatting and%b
disables it."%nd"
rendersn
trailing directories of the current directory path. A negative value forn
will render leading directories. Just%d
will render the entire current directory path."%(x.true-text.false-text)"
is a ternary expression. Ifx
is true,true-text
is displayed; otherwise,false-text
is used.- Expression
"nC"
evaluates to true if the current directory path has at leastn
directories relative to the root directory. This determines whether the path will be truncated.
With these concepts in mind, I defined how the truncation symbol (⋯
), the current directory path, and the end-of-prompt marker (❯
) should be rendered:
local prompt_truncation_symbol="%F{${COLOR_PROMPT_GLYPH}}%B${GLYPH_PROMPT_TRUNCATION_SYMBOL}%b%f"
local prompt_end_symbol="%F{${COLOR_PROMPT_GLYPH}}%B${GLYPH_PROMPT_END_SYMBOL}%b%f"
local total_dirs=$(($NUM_DIRS_LEFT_OF_TRUNCATION+$NUM_DIRS_RIGHT_OF_TRUNCATION+1))
local dir_path_full="%F{${COLOR_PROMPT_TEXT}}%d%f"
local dir_path_truncated="%F{${COLOR_PROMPT_TEXT}}%-${NUM_DIRS_LEFT_OF_TRUNCATION}d/%f${prompt_truncation_symbol}%F{${COLOR_PROMPT_TEXT}}/%${NUM_DIRS_RIGHT_OF_TRUNCATION}d%f"
prompt_truncation_symbol
rendersGLYPH_PROMPT_TRUNCATION_SYMBOL
(⋯
) in bold and in the color specified byCOLOR_PROMPT_GLYPH
(white).prompt_end_symbol
rendersGLYPH_PROMPT_END_SYMBOL
(❯
) in bold and in the color determined byCOLOR_PROMPT_GLYPH
(white).total_dirs
calculates the maximum number of directories to show in the current directory path, including the truncation symbol.dir_path_full
renders the entire current directory path in the color determined byCOLOR_PROMPT_TEXT
(red).dir_path_truncated
renders a truncated path. It shows one leading directory (NUM_DIRS_LEFT_OF_TRUNCATION
) of the current directory path in the color specified byCOLOR_PROMPT_TEXT
(red), followed byprompt_truncation_symbol
wrapped in path separators (/
), and then two trailing directories (NUM_DIRS_RIGHT_OF_TRUNCATION
) of the current directory path.
Now, it’s time to put the pieces together:
PROMPT="%(${total_dirs}C.${dir_path_truncated}.${dir_path_full}) ${prompt_end_symbol} "
- This is where it’s determined whether to render the complete current directory path or the truncated version using the conditional substring
"%(x.true-text.false-text)"
. - If the current directory path contains four (
total_dirs
) or more directories,dir_path_truncated
is rendered; otherwise,dir_path_full
is used. - Assigning the evaluated string to
PROMPT
overrides the default Zsh left prompt.
Almost there…
precmd_functions+=(set_prompt)
- This adds the
set_prompt
function to theprecmd_functions
array, ensuring that the function is called before each command prompt is displayed. This way,PROMPT
gets evaluated and I get my customized Zsh left prompt!
Having spent time figuring out the intricate Zsh prompt expansion, I realized why many developers favor Oh My Zsh.
Right prompt
For the right prompt, my goal was to display essential Git information whenever inside a Git repository. I wanted to know the top-level directory, the current branch, its sync status with the remote, an indicator for any local uncommitted changes, and an indicator for stashed changes. I prefer to have this information available at a glance — without needing to run multiple commands or sift through verbose outputs. These were my requirements, and it was time to build a system tailored to them.
Here's the code in my Zsh profile that gets it done:
To start off with, I defined a set of attributes for the prompt like earlier:
COLOR_GIT_REPOSITORY_TEXT='245'
COLOR_GIT_BRANCH_TEXT='255'
COLOR_GIT_STATUS_CLEAN='010'
COLOR_GIT_STATUS_DIRTY='009'
GLYPH_GIT_BRANCH_SYNC_SYMBOL='«'
GLYPH_GIT_STASH_SYMBOL='∘'
GLYPH_GIT_STATUS_SYMBOL='»'
- I chose a grey-ish color (
COLOR_GIT_REPOSITORY_TEXT='245'
) for the top-level directory to blend subtly into the background, while the current Git branch name is rendered in white (COLOR_GIT_BRANCH_TEXT='255'
) for clear visibility. - I selected several glyphs:
GLYPH_GIT_BRANCH_SYNC_SYMBOL='«'
to indicate branch sync status with the remote,GLYPH_GIT_STASH_SYMBOL='∘'
for stashed changes andGLYPH_GIT_STATUS_SYMBOL='»'
for local uncommitted changes. - Lastly, I selected green (
COLOR_GIT_STATUS_CLEAN='010'
) to represent a clean Git status (no changes) and red (COLOR_GIT_STATUS_DIRTY='009'
) to highlight a dirty status (uncommitted changes).
Here’s a general idea of what I was aiming for:
Next, let’s look at the set_rprompt
function:
local git_branch_name=$(git symbolic-ref --short HEAD 2> /dev/null)
if [[ -z $git_branch_name ]]; then
RPROMPT=""
return
fi
- The function begins by checking if the current directory is a Git repository. It does this by trying to get the current Git branch name using
git symbolic-ref --short HEAD
. If the command fails (i.e., no branch name is found), the prompt is cleared, and the function exits silently. Errors are redirected to/dev/null
. - As with the left prompt, setting a string to
RPROMPT
overrides the default Zsh right prompt.
With confirmation that a Git repository exists, various Git status details can now be fetched and displayed:
local git_remote_commit=$(git rev-parse "origin/$git_branch_name" 2> /dev/null)
local git_local_commit=$(git rev-parse "$git_branch_name" 2> /dev/null)
local git_branch_sync_color=$COLOR_GIT_STATUS_DIRTY
if [[ $git_remote_commit == $git_local_commit ]]; then
git_branch_sync_color=$COLOR_GIT_STATUS_CLEAN
fi
- This part collects the commit hashes of the latest commit on both the current Git branch and its remote using
git rev-parse
. If they match, it indicates that the branch is in sync with the remote, settinggit_branch_sync_color
toCOLOR_GIT_STATUS_CLEAN
(green). If they do not match, the color is set toCOLOR_GIT_STATUS_DIRTY
(red). - Errors are redirected to
/dev/null
to handle any untracked branches gracefully.
local git_stash=$(git stash list)
local git_stash_symbol=$GLYPH_GIT_STASH_SYMBOL
if [[ -z $git_stash ]]; then
git_stash_symbol=""
fi
- This section checks for any stashed changes using
git stash list
. If there are stashed changes,git_stash_symbol
is set toGLYPH_GIT_STASH_SYMBOL
(Git stash indicator glyph); otherwise, it is left empty.
local git_status=$(git status --porcelain)
local git_stash_color=$COLOR_GIT_STATUS_DIRTY
local git_status_color=$COLOR_GIT_STATUS_DIRTY
if [[ -z $git_status ]]; then
git_stash_color=$COLOR_GIT_STATUS_CLEAN
git_status_color=$COLOR_GIT_STATUS_CLEAN
fi
- Then comes the check for local uncommitted changes using
git status --porcelain
, which provides a simplified output. If the output is empty (indicating no changes), bothgit_stash_color
andgit_status_color
are set toCOLOR_GIT_STATUS_CLEAN
(green). If there are changes, they are set toCOLOR_GIT_STATUS_DIRTY
(red).
local git_repository_path=$(git rev-parse --show-toplevel)
local git_repository_name=$(basename "$git_repository_path")
- Finally,
git rev-parse --show-toplevel
provides the path to the top-level directory of the Git repository andbasename
extracts the directory name, to be stored ingit_repository_name
.
It’s time to determine how the individual pieces of the prompt should be rendered:
local git_repository_text="%F{${COLOR_GIT_REPOSITORY_TEXT}}${git_repository_name}%f"
local git_branch_sync_symbol="%F{${git_branch_sync_color}}%B${GLYPH_GIT_BRANCH_SYNC_SYMBOL}%b%f"
local git_stash_symbol="%F{${git_stash_color}}%B${git_stash_symbol}%b%f"
local git_status_symbol="%F{${git_status_color}}%B${GLYPH_GIT_STATUS_SYMBOL}%b%f"
local git_branch_text="%F{${COLOR_GIT_BRANCH_TEXT}}${git_branch_name}%f"
git_repository_text
displaysgit_repository_name
in the color determined byCOLOR_GIT_REPOSITORY_TEXT
(grey-ish).git_branch_sync_symbol
rendersGLYPH_GIT_BRANCH_SYNC_SYMBOL
(«
) in bold and in the color specified bygit_branch_sync_color
(green/red) based on whether the branch is in sync with the remote.git_stash_symbol
either displaysGLYPH_GIT_STASH_SYMBOL
(∘
) in bold or shows nothing depending on the presence of stashed changes. The color is determined bygit_stash_color
(green/red) depending on whether there are any local uncommitted changes.git_status_symbol
rendersGLYPH_GIT_STATUS_SYMBOL
(»
) in bold and in the color determined bygit_status_color
(green/red) based on whether there are any local uncommitted changes.git_branch_text
displaysgit_branch_name
in the color specified byCOLOR_GIT_BRANCH_TEXT
(white).
It’s time to put the pieces together:
RPROMPT="${git_repository_text} ${git_branch_sync_symbol}${git_stash_symbol}${git_status_symbol} ${git_branch_text}"
And…
precmd_functions+=(set_rprompt)
- Adds the
set_rprompt
function to theprecmd_functions
array, ensuring thatRPROMPT
gets evaluated before each command prompt is displayed.
With that, I have my very own Zsh setup with custom prompts:
Notes
- Here’s my gist with both
set_prompt
andset_rprompt
functions put together in Zsh profile. - For the left prompt, it’s also possible to truncate the current directory path with
%<string<
and%>string>
conditional substrings.