Image of the glider from the Game of Life by John Conway
Skip to content

My ZSH Prompt Long Overdue

About a month ago, while teaching a class in Philadelphia, a student in my class had an interesting BASH prompt. In fact, not only had he customized a prompt for BASH, but one for CSH and KSH as well, as apparently, he spends adequate time in those shells during work. Well, this got me rethinking about my own ZSH prompt, and how I wanted to customize it to fit my needs. However, as things go with teaching on the road, I got a bit lazy, and didn't do much about it. Then, last week, while teaching a Linux security course, I had another student with an interesting take on his prompts. That must have been the straw that broke the camel's back, because I was determined by Friday to have my customized ZSH prompt.

First, the question begs why- why spend so much time configuring a silly prompt? Surely you can get the information you need from commands in the terminal. While this is true, I spend so much time in my terminal, that I want it to be functional, and well as attractive. After all, I probably spend three quarters of my working day behind the terminal. So, I might as well make it as informative as I can. With that, let's see what I've done to my prompt. First, a simple screenshot showing the cleanliness of the default promt:

Basic ZSH prompt

Here's what I wanted behind my prompt:

  • A matching theme with my .screenrc, Mutt theme and 88_madcows Irssi theme (yes, they all tie in together).
  • The continuation prompt should carry on the theme of the main prompt.
  • My username and host that I'm connected to
  • The history number of the last command executed.
  • A timestamp showing when I executed the last command.
  • The prompt should never be wider than the terminal we're attached to (we may need to truncate the path).
  • Display statuses of the following, only when needed:
  • Battery status of my laptop, if less than 100%.
  • The current GIT branch, if any.
  • The exit code of the last command, if any other than zero.
  • The screen number I'm attached to, if behind screen.
  • The number of jobs executing in the background, if any.

That's it. Not much really. Most of it is fairly straight forward. Some of those points will require some hacking, and a bit of logic, but for the most part, this shouldn't be too bad. So, with that, let's start dissecting my code line-by-line.

At first glance, you'll notice that I'm using two functions to pull this off. The first function is a special ZSH function called 'precmd()'. This function is always executed before a new prompt is drawn. Because we'll be creating custom variables, and using them in our prompt, we'll need to take advantage of this function. If we don't then we'll have to source our .zshrc every time we want our dynamic prompt updated. The second function is what actually draws the prompt. We'll look at that a bit later.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    # let's get the current get branch that we are under
    # ripped from /etc/bash_completion.d/git from the git devs
    git_ps1 () {
        if which git > /dev/null; then
            local g="$(git rev-parse --git-dir 2>/dev/null)"
            if [ -n "$g" ]; then
                local r
                local b
                if [ -d "$g/rebase-apply" ]; then
                    if test -f "$g/rebase-apply/rebasing"; then
                        r="|REBASE"
                    elif test -f "$g/rebase-apply/applying"; then
                        r="|AM"
                    else
                        r="|AM/REBASE"
                    fi
                    b="$(git symbolic-ref HEAD 2>/dev/null)"
                elif [ -f "$g/rebase-merge/interactive" ]; then
                    r="|REBASE-i"
                    b="$(cat "$g/rebase-merge/head-name")"
                elif [ -d "$g/rebase-merge" ]; then
                    r="|REBASE-m"
                    b="$(cat "$g/rebase-merge/head-name")"
                elif [ -f "$g/MERGE_HEAD" ]; then
                    r="|MERGING"
                    b="$(git symbolic-ref HEAD 2>/dev/null)"
                else
                    if [ -f "$g/BISECT_LOG" ]; then
                        r="|BISECTING"
                    fi
                    if ! b="$(git symbolic-ref HEAD 2>/dev/null)"; then
                       if ! b="$(git describe --exact-match HEAD 2>/dev/null)"; then
                          b="$(cut -c1-7 "$g/HEAD")..."
                       fi
                    fi
                fi
                if [ -n "$1" ]; then
                     printf "$1" "${b##refs/heads/}$r"
                else
                     printf "%s" "${b##refs/heads/}$r"
                fi
            fi
        else
            printf ""
        fi
    }

    GITBRANCH=" $(git_ps1)"

Before I even begin, I want to start off with determining my GIT branch that I'm currently working on, if any. I use GIT extensively at work, and I've even setup my own GIT repository at home, to track my own config files and custom scripts that I want to hold on to. As such, if I'm ever on a GIT branch, I want that displayed in my prompt. As noticed in the comment before the code, this was pulled directly from the BASH completion package from the GIT developers? Why the extensive code? Well, when doing a rebase on the branch, I want to make sure I'm keeping track of my current location, as HEAD won't always be reliable.

1
2
3
4
5
6
7
8
9
10
11
    # The following 9 lines of code comes directly from Phil!'s ZSH prompt
    # http://aperiodic.net/phil/prompt/
    local TERMWIDTH
    (( TERMWIDTH = ${COLUMNS} - 1 ))

    local PROMPTSIZE=${#${(%):--- %D{%R.%S %a %b %d %Y}\! ---}}
    local PWDSIZE=${#${(%):-%~}}

    if [[ "$PROMPTSIZE + $PWDSIZE" -gt $TERMWIDTH ]]; then
    (( PR_PWDLEN = $TERMWIDTH - $PROMPTSIZE ))
    fi

Next, it's time to determine the terminal width. As mentioned above, I don't want my prompt to be longer than my terminal and wrap, so as such, I need some logic to figure this out. After several hours of hacking at this, I couldn't get it right. So, after a bit of searching, I found Phil Gold's ZSH prompt, and decided to copy the code verbatim, and modify it to fit my needs. Phil is using a right hand prompt, and I'm not. As such, there was some code I could cut out, to get exactly what I needed. He does a good job explaining the logic, so I'll let you read it all there.

1
2
3
4
5
6
    # set a simple variable to show when in screen
    if [[ -n "${WINDOW}" ]]; then
        SCREEN=" S:${WINDOW}"
    else
        SCREEN=""
    fi

Screen is my best friend. I use it to the point, where I only need one terminal open for all my separation needs. However, as much as I use it, it's not open all the time. As such, when it's open, I want to show it in my prompt. When it is not open, I don't want it cluttering the prompt up. As such, if a value exists in the $WINDOW variable (this is set when screen is executed, then I set the $SCREEN variable. Otherwise, I don't bother.

1
2
3
4
5
6
    # check if jobs are executing
    if [[ $(jobs | wc -l) -gt 0 ]]; then
        JOBS=" J:%j"
    else
        JOBS=""
    fi

I seem to background jobs from time-to-time. Usually when testing an application, or executing an application from the terminal. Although not critical, it's useful knowing if jobs are running in the background or not. Again, I don't want this cluttering up my terminal if jobs aren't running.

1
2
3
4
5
    # I want to know my battery percentage when less than 100%.
    if which ibam &> /dev/null; then
        BATTSTATE="$(ibam --percentbattery)"
        BATTPRCNT="${BATTSTATE[(f)1][(w)-2]}"
        BATTTIME="${BATTSTATE[(f)2][(w)-1]}"

Most of my time computing is spent on my laptop. Although there are several utilities that show my current battery percentage, I figured why not put it into my prompt? One thing that converted me to ZSH was it's powerful scripting capabilities. Here, I'm creating three variables for ultimately getting the data I want. In the BATTPRCNT, I find the data by grabbing the first line in the list (BATTSTATE[(f)1]) and the second-to-last word ([(w)-2]). For the remaining time, I get this information from the second line, and last word in the list.

1
2
3
4
5
6
7
8
9
10
11
12
        PR_BATTERY=" B:${BATTPRCNT}%% (${BATTTIME})"
        if [[ "${BATTPRCNT}" -lt 15 ]]; then
            PR_BATTERY="$PR_BRIGHT_RED$PR_BATTERY"
        elif [[ "${BATTPRCNT}" -lt 50 ]]; then
            PR_BATTERY="$PR_BRIGHT_YELLOW$PR_BATTERY"
        elif [[ "${BATTPRCNT}" -lt 100 ]]; then
            PR_BATTERY="$PR_RESET$PR_BATTERY"
        fi
    else
        PR_BATTERY=""
    fi
}

Now that I have the battery data that I want, I format it to fit my needs. If the battery percentage is less than 100% and greater than 49%, I want the color to be the same as the rest of the prompt- normal. If it's less than 50% but greater than 14%, I want the color to be yellow. If it's less than 15%, then the color should be red. You'll notice that there are variables for colorizing the value. These are defined the next function below. Lastly, if the battery is 100%, then don't show the information at all. In order to retrieve this information, I use the "ibam" utility. It's installed on my laptop, so that's all that matters to me. This concludes the precmd() function. Now, to drawing the prompt itself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
setprompt () {
    # Need this, so the prompt will work
    setopt prompt_subst

    # let's load colors into our environment, then set them
    autoload colors zsh/terminfo

    if [[ "$terminfo[colors]" -gt 8 ]]; then
        colors
    fi

    for COLOR in RED GREEN YELLOW WHITE BLACK; do
        eval PR_$COLOR='%{$fg[${(L)COLOR}]%}'
        eval PR_BRIGHT_$COLOR='%{$fg_bold[${(L)COLOR}]%}'
    done

    PR_RESET="%{$reset_color%}"

Now, we move onto drawing the prompt. This function is defined by myself, and is not a ZSH builtin. First, and foremost, I need to set the prompt_subst ZSH option. This is needed to substitute the variables in my prompt with what was defined earlier

Then, we need to load colors into the prompt. ZSH has an awesome autoload utility for loading modules into the namespace. Here, I'm loading colors and terminfo, necessary for setting the appropriate colors. Then, we discover if we can even use colors. One thing I wanted about my prompt, was the ability to fail gracefully, if colors aren't supported. Here, I make that possible. Then it's on to defining the colors themselves. A simple 'for' loop makes this possible, and we cycle through only the colors needed by the prompt. I'm setting both bold an normal colors, so I can take advantage of them for a little styling of the prompt. I'm also creating a RESET variable for resetting the color back to non-colored, normal text.

Thanks to lists, again, I can grab foreground and background colors as needed. Also, because I created a 'COLOR' variable, I need to lowercase the variable when making the assignments, as the '$fg[]' list is expecting the color in lowercase. Why not just do it all in lowercase? I'm a fan of uppercase variables in my scripts, and I keep this behavior here. So, '$fg[${(L)COLOR}] will lowercase the value of '$COLOR'.

1
2
3
4
5
6
7
8
9
10
11
    # Finally, let's set the prompt
    PROMPT='\
${PR_BRIGHT_BLACK}<${PR_RESET}${PR_RED}<${PR_BRIGHT_RED}<${PR_RESET} \
%D{%R.%S %a %b %d %Y}${PR_RED}!${PR_RESET}%$PR_PWDLEN<...<%~%<< \

${PR_BRIGHT_BLACK}<${PR_RESET}${PR_RED}<${PR_BRIGHT_RED}<\
${PR_RESET} %n@%m${PR_RED}!${PR_RESET}H:%h${SCREEN}${JOBS}%(?.. E:%?)\
${PR_BATTERY}${GITBRANCH}\

${PR_BRIGHT_BLACK}>${PR_RESET}${PR_GREEN}>${PR_BRIGHT_GREEN}>\
${PR_BRIGHT_WHITE} '

The 'PROMPT' variable is the same as the 'PS1' variable in ZSH, so I use it here. This is the prompt string itself, doing all the formatting and placement of variables. Notice that whenever I define a bright color, before I can use a non-bright color, I have to reset it first. I'm sure there's a better way to do this, but it works fine for me. Also, the 'PROMPT' variable must be enclosed in single quotes, or some of the variables will not be evaluated.

1
2
3
4
5
6
    # Of course we need a matching continuation prompt
    PROMPT2='\
${PR_BRIGHT_BLACK}>${PR_RESET}${PR_GREEN}>${PR_BRIGHT_GREEN}>\
${PR_RESET} %_ ${PR_BRIGHT_BLACK}>${PR_RESET}${PR_GREEN}>\
${PR_BRIGHT_GREEN}>${PR_BRIGHT_WHITE} '

}

Of course, it would be silly to not define a matching continuation prompt. The 'PROMPT2' variable in ZSH is the same as the 'PS2' variable, so again, I prefer to use that variable name. Below is a screenshot showing every aspect of the prompt, except for the GIT branch. As you can see, I'm in a directory structure that normally would be longer than the width of the terminal, I'm behind screen in window #0, running 2 backgrounded jobs, there was an exit code of '42' from the previous executable, my battery has a 94% charge, the previous executable was run at 23:05 on November 22, 2008 and I'm about to execute history item number 1573.

Basic ZSH prompt
1
setprompt

Lastly, we call the function to draw the prompt, and we're off! Since creating this prompt, I've thought about other useful data that I could use for the prompt. Maybe I'll get around to that, but right now, I'm very happy with the final status. The post is informative, but not intrusive. It contains both function and form. It's bright to grab your attention, but not overbearing to distract you for your work. It conserves real estate, yet is loaded with information. Yeah. I won't be changing shells, or prompts, anytime soon. 🙂

The entire source can be downloaded here.

Cheers!

{ 7 } Comments