Creating a custom Zsh prompt from scratch

Gaurav Joshi
8 min readSep 3, 2024

--

Zsh on WSL Ubuntu in Windows Terminal

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.

DejaVu Sans Mono font for WSL Ubuntu in Windows Terminal

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" renders text in the color represented by color_code. %F sets the color and %f resets it.
  • "%Btext%b" displays text in bold. %B sets the bold formatting and %b disables it.
  • "%nd" renders n trailing directories of the current directory path. A negative value for n will render leading directories. Just %d will render the entire current directory path.
  • "%(x.true-text.false-text)" is a ternary expression. If x is true, true-text is displayed; otherwise, false-text is used.
  • Expression "nC" evaluates to true if the current directory path has at least n 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 renders GLYPH_PROMPT_TRUNCATION_SYMBOL () in bold and in the color specified by COLOR_PROMPT_GLYPH (white).
  • prompt_end_symbol renders GLYPH_PROMPT_END_SYMBOL () in bold and in the color determined by COLOR_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 by COLOR_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 by COLOR_PROMPT_TEXT (red), followed by prompt_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 the precmd_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!
Custom Zsh left prompt on WSL Ubuntu in Windows Terminal

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 and GLYPH_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:

My system for a custom Zsh right prompt

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, setting git_branch_sync_color to COLOR_GIT_STATUS_CLEAN (green). If they do not match, the color is set to COLOR_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 to GLYPH_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), both git_stash_color and git_status_color are set to COLOR_GIT_STATUS_CLEAN (green). If there are changes, they are set to COLOR_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 and basename extracts the directory name, to be stored in git_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 displays git_repository_name in the color determined by COLOR_GIT_REPOSITORY_TEXT (grey-ish).
  • git_branch_sync_symbol renders GLYPH_GIT_BRANCH_SYNC_SYMBOL («) in bold and in the color specified by git_branch_sync_color (green/red) based on whether the branch is in sync with the remote.
  • git_stash_symbol either displays GLYPH_GIT_STASH_SYMBOL () in bold or shows nothing depending on the presence of stashed changes. The color is determined by git_stash_color (green/red) depending on whether there are any local uncommitted changes.
  • git_status_symbol renders GLYPH_GIT_STATUS_SYMBOL (») in bold and in the color determined by git_status_color (green/red) based on whether there are any local uncommitted changes.
  • git_branch_text displays git_branch_name in the color specified by COLOR_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 the precmd_functions array, ensuring that RPROMPT gets evaluated before each command prompt is displayed.

With that, I have my very own Zsh setup with custom prompts:

Custom Zsh prompts on WSL Ubuntu in Windows Terminal

Notes

  • Here’s my gist with both set_prompt and set_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.

--

--

Gaurav Joshi
Gaurav Joshi

Written by Gaurav Joshi

Master craftsman of innovative cross-browser compatible bugs.