Customizing shell prompts to be prettier, or to show more information is common, but there are some frequently-overlooked nuances that can further improve your productivity.

Aesthetics or function? Why not both?

Example prompt, showing color automatically picked by the hostname, and showing git status
Example prompt, showing color automatically picked for a different hostname

Insight and decisions

  • Having the command entry always start on the same character makes skimming past command entry easier
  • Displaying environment information that is regularly checked as part of the prompt reduces the need of repetitively typing commands
    • Repetitively typing commands is fatiguing, so you might skip checking something about the environment when it could be useful. Automatically displaying environment information therefore increases information available to you when you would have skipped typing a command.
  • The only way to have dense information in the prompt while also having command entry always start on the same character is by using a multi-line prompt.
  • In general, color is a good way to increase information density of text without taking up extra space.

Automatically choosing a color for the prompt by the computer's hostname

I put color in my prompts both to make them pretty, which increases my satisfaction of using the terminal, and to help increase awareness of what host I'm running a command on, which reduces my likelihood of typing a command on the wrong host.

At a certain point, I had enough different hosts to pick a prompt color for that it became exhausting. Automatically picking a color by the hostname is one less decision I have to worry about.

# Choose a unique color based on the hostname
# Inspired by http://zork.net/~st/jottings/Per-Host_Prompt_Colouring.html
# rgb2short function derived from https://gist.github.com/MicahElliott/719710
ssh_host_color() {  # Allows customizing the color generated by the script
    # Usage:
    # In ~/.ssh/config, put a commented Color entry for your hostname.
    # The color is a hex number representing HSL (HHSSLL)
    # Example:
    # Host mylaptop
    #   User myuser
    #   # Color 2d8ce7
    if command -v perl &>/dev/null; then
    perl -e 'sub walk{foreach my $file(@_){next if $seen{$file}++;open my $FH,"<",$file or next;
    while(<$FH>){if(/^\s*Include\s(.+)$/){walk(glob $1)}else{print}}}}walk(<~/.ssh/config>)'
    else cat ~/.ssh/config; fi |
    awk -v host="$1" 'BEGIN{x=1}END{exit x}
    tolower($1)=="host"{if(m)exit;for(i=2;i<=NF;i++){gsub(/[][().+^$]/,"\\\\&",$i);gsub(/[?]/,".",$i);gsub(/[*]/,".*",$i);if(host~"^"$i"$"&&$i!=".*")m=1}}
    m&&sub(/^[ \t]*#/,"",$0)&&tolower($1)=="color"&&tolower($2)~/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/{print$2;x=0;exit}'
}
HOSTBGCOLOR=$'\e[46m' # fallback to cyan background
HOSTFGCOLOR=$'\e[36m' # fallback to cyan foreground
HOSTBGTEXT=$'\e[40m'  # fallback to black background
HOSTFGTEXT=$'\e[30m'  # fallback to black foreground
HOSTHASH="$( name="$(uname -n | sed 's/\..*//')"; ssh_host_color "$name" 2>/dev/null || printf '%s\n' "$name" | if command -v md5sum &>/dev/null; then md5sum; else md5; fi )"
if command -v python &>/dev/null; then
{ IFS= read -r HOSTCOLORNUMBER; IFS= read -r HOSTTEXTNUMBER; } <<<"$(hosthash=${HOSTHASH:0:6} color_count=$(tput colors 2>/dev/null || echo 8) python -c '
import os
import colorsys
def linear_map(n, min, max, tmin, tmax):
    [n, min, max, tmin, tmax] = [float(i) for i in [n, min, max, tmin, tmax]]
    return (n - min)/(max - min) * (tmax - tmin) + tmin
hosthash = os.environ["hosthash"]
h = linear_map(int(hosthash[0:2], 16), 0, 0xff, 0, 1)
s = linear_map(int(hosthash[2:4], 16), 0, 0xff, 0.5, 1)
l = linear_map(int(hosthash[4:6], 16), 0, 0xff, 0.2, 0.8)
rgb = [0xff * i for i in colorsys.hls_to_rgb(h, l, s)]
cubelevels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
snaps = [(x+y)/2 for x, y in list(zip(cubelevels, [0]+cubelevels))[1:]]
def rgb2short256(r, g, b):
    r, g, b = map(lambda x: len(tuple(s for s in snaps if s<x)), (r, g, b))
    return (r*36 + g*6 + b + 16,
        15 if l < 108. / 0xff or (149. / 0xff < h < 192. / 0xff and l < 166. / 0xff) else 0)
hues = [1, 3, 2, 6, 4, 5, 1]
def hl2short(h, l, color_count):
    h = hues[int(round(linear_map(h, 0, 1, 0, 6)))]
    color = h + 8 if l > .6 and color_count == 16 else h
    return color, (color == 4) * (((color_count != 8) * 8) + 7)
color_count = int(os.environ["color_count"])
colors = rgb2short256(*rgb) if color_count >= 256 else hl2short(h, l, color_count)
for c in colors: print(c)
')"
HOSTBGCOLOR=$(tput setab "$HOSTCOLORNUMBER" 2>/dev/null || printf '\e[48;5;%sm' "$HOSTCOLORNUMBER")
HOSTFGCOLOR=$(tput setaf "$HOSTCOLORNUMBER" 2>/dev/null || printf '\e[38;5;%sm' "$HOSTCOLORNUMBER")
HOSTBGTEXT=$(tput setab "$HOSTTEXTNUMBER" 2>/dev/null || printf '\e[48;5;%sm' "$HOSTTEXTNUMBER")
HOSTFGTEXT=$(tput setaf "$HOSTTEXTNUMBER" 2>/dev/null || printf '\e[38;5;%sm' "$HOSTTEXTNUMBER")
fi

Breakdown:

  • The goal is to set HOSTBGCOLOR, HOSTFGCOLOR, HOSTBGTEXT, and HOSTFGTEXT to ANSI color codes, to be used in the prompt variable.
  • ssh_host_color reads ~/.ssh/config to find a color entry for the given host. This provides a way to override the generated color for a host if desired.
  • The hostname is md5summed and the first 6 characters are used as a Hue, Lightness, Saturation color code
  • The Python program follows these steps to generate a color for background and text:
    • clamps saturation and lightness so that the output always has a strong-enough color
    • converts the HLS color to RGB
    • converts the RGB color to an ANSI color number, considering 256 color support and falling back to 8 color support
    • determines if the text color should be black or white based on the perceived lightness of the background color
    • prints the ANSI color numbers for the background and text

Putting the colors into a prompt:

# zsh prompt:
autoload -Uz colors && colors
PROMPT="%{$HOSTFGCOLOR$HOSTBGCOLOR%}[%{$HOSTFGTEXT$HOSTBGCOLOR%}%n %{$fg[black]$bg[white]%} %m%{$fg[white]$bg[white]%}]%{$reset_color"$'\e[0;100m'"$fg[white]%} %~ %{$reset_color%}"$'\n'"%{$reset_color%B%}%#%{$reset_color%} "
# bash prompt:
PS1="\[$HOSTFGCOLOR$HOSTBGCOLOR\][\[$HOSTFGTEXT$HOSTBGCOLOR\]\u "$'\[\e[0;30m\e[47m\] \h\[\e[0;37m\e[47m\]]\[\e[0m\e[0;100m\e[37m\] \w \[\e[0m\]\n\[\e[0m\e[1m\]\\$\[\e[0m\] '

Unambiguously abbreviating the current working directory for a short terminal title

I want to put the full path to the current working directory in the terminal title so that I can more easily identify other terminal tabs, but the full path is too long.

The solution is to abbreviate it, but I want to show enough information that the abbreviation is unambiguous.

The abbreviation approach:

  1. Replace the home folder with ~
  2. Only show the first letter of directory names for parent directories
  3. Show the full directory name of the current directory

This abbreviation approach conveniently matches what you need to type for zsh directory tab expansion, where /u/l/b/ + [tab] expands to /usr/local/bin/, for example.

For brevity, the short version of the pwd abbreviation function that supports both bash and zsh is below. I have a marginally-faster version written in native zsh, and a marginally-faster version for bash written in awk, which you can find in my dotfiles if you care.

pws() {
    # /usr/local/bin -> /u/l/bin
    # ~/code/hxsl -> ~/c/hxsl
    # Edge case: ~/._.foo/bar -> ~/._.f/b
    pwd|perl -pe 's|^\Q$ENV{HOME}\E|~|;s|(?<=/)([._]*.)[^/]*(?=/)|$1|g;s|^\.$||'
}

To put it in the terminal title:

# bash terminal title:
PROMPT_COMMAND='printf "\e]0;%s\a" "$(pws)"'
# zsh terminal title:
[[ -z "$precmd_functions" ]] && precmd_functions=()
precmd_functions+=('printf "\e]0;%s\a" "$(pws)"')

Showing git status in the prompt

Zsh has some built-in version control status features for the prompt. Git ships with some bash prompt status utility.

I started with the zsh feature and modeled a bash function to mirror zsh's output.

The built-in zsh feature is synchronous, which means that working in large git repositories can cause the prompt to be delayed for a second.

Zsh also supports asynchronous prompt updates. If you asynchronously add git status information to the prompt, then the prompt draws instantly, and git status information just pops in when it's ready.

Bash does not support asynchronous prompt updates.

Synchronous git status

Zsh:

setopt PROMPT_SUBST  # enable variable expansion in prompt

PROMPT="%n@%m:%~"'${vcs_info_msg_0_}'$'\n'"%# "

autoload -Uz vcs_info
zstyle ':vcs_info:*' enable git
zstyle ':vcs_info:*' check-for-changes true
zstyle ':vcs_info:git*' formats $'\n'"(%s) %i%c%u %b %m"
zstyle ':vcs_info:git*' actionformats $'\n'"(%s|%a) %i%c%u %b %m"
zstyle ':vcs_info:git*' stagedstr ' S'
zstyle ':vcs_info:git*' unstagedstr ' U'
zstyle ':vcs_info:git*:*' get-revision true
zstyle ':vcs_info:git*+set-message:*' hooks git-st git-stash

[[ -z "$precmd_functions" ]] && precmd_functions=()
precmd_functions+=(vcs_info)

# Show remote ref name and number of commits ahead-of or behind
+vi-git-st() {
    local ahead behind remote branch on_branch detached_from
    local -a gitstatus

    # If hook_com[revision] is already short then we can skip safely getting the short hash
    [[ "${#hook_com[revision]}" -gt 39 ]] && hook_com[revision]="$(command git rev-parse --verify -q --short=7 HEAD)"

    # On a branch? Need to check because hook_com[branch] might be a tag
    IFS='' read -r branch <"${gitdir}/HEAD"
    [[ "$branch" = "ref: refs/heads/"* ]] && on_branch=true || on_branch=false

    if [[ "$on_branch" = true ]]; then
        # On a remote-tracking branch?
        remote="${$(command git rev-parse --verify --symbolic-full-name @{u} 2>/dev/null)#refs/remotes/}"
        if [[ -n "${remote}" ]]; then
            IFS=$'\t' read -r ahead behind <<<"$(command git rev-list --left-right --count HEAD...@{u})"
            (( ahead )) && gitstatus+=( "+${ahead}" )
            (( behind )) && gitstatus+=( "-${behind}" )
            hook_com[branch]="${hook_com[branch]} [${remote}${gitstatus:+ ${(j:/:)gitstatus}}]"
        fi
    else
        detached_from="${$(command git describe --all --always 2>/dev/null):-${hook_com[revision]}}"
        hook_com[branch]="[detached from ${detached_from}]"
    fi
}

# Show count of stashed changes
+vi-git-stash() {
    local stashes stashes_exit stash_message
    stashes="$(command git rev-list --walk-reflogs --count refs/stash 2>/dev/null)"
    stashes_exit="$?"
    [[ "$stashes_exit" -ne 0 ]] && return
    [[ "$stashes" -eq 0 ]] && return
    stash_message="(${stashes} stashed)"
    hook_com[misc]="${hook_com[misc]}${hook_com[misc]:+ }(${stashes} stashed)"
}

Bash:

PS1=$'\u@\h:\w$(__git_info)\n\$ '

__git_info() {
    local message branch on_branch remote detached_tag detached_from tracking ahead behind gitstatus stashes stashes_exit stash_message staged_changes unstaged_changes

    local repo_info="$(command git rev-parse --verify --git-dir --is-inside-git-dir --is-bare-repository --is-inside-work-tree --show-toplevel --short=7 HEAD 2>/dev/null)"
    local rev_parse_exit_code="$?"
    [[ -z "$repo_info" ]] && return

    local short_sha=""
    if [[ "$rev_parse_exit_code" = "0" ]]; then
        # repository has commits (not empty)
        short_sha="${repo_info##*$'\n'}"
        repo_info="${repo_info%$'\n'*}"
    fi
    local toplevel="${repo_info##*$'\n'}"
    repo_info="${repo_info%$'\n'*}"
    local inside_worktree="${repo_info##*$'\n'}"
    repo_info="${repo_info%$'\n'*}"
    local bare_repo="${repo_info##*$'\n'}"
    repo_info="${repo_info%$'\n'*}"
    local inside_gitdir="${repo_info##*$'\n'}"
    local g="${repo_info%$'\n'*}"

    # On a branch?
    IFS='' read -r branch <"$g/HEAD"  # For bash in cygwin (not zsh), read is MUCH faster than branch="$(<"$g/HEAD")"
    [[ "$branch" = "ref: refs/heads/"* ]] && on_branch=true || on_branch=false
    branch="${branch#ref: refs/heads/}"
    #branch=$(command git symbolic-ref --short -q HEAD)

    if [[ "$inside_worktree" = "true" ]]; then
        unstaged_changes="$(command git diff-files --quiet || echo 'U')"
        if [[ "$rev_parse_exit_code" = "0" ]]; then
            staged_changes="$(command git diff-index --cached --quiet HEAD -- || echo 'S')"
        else
            # empty repository (no commits yet)
            # 4b825dc642cb6eb9a060e54bf8d69288fbee4904 is the git empty tree.
            staged_changes="$(command git diff-index --cached --quiet 4b825dc642cb6eb9a060e54bf8d69288fbee4904 2>/dev/null || echo 'S')"
        fi
    fi
    if [[ "$on_branch" = true ]]; then
        # On a remote-tracking branch?
        remote="$(command git rev-parse --verify --symbolic-full-name @{u} 2>/dev/null)"
        remote="${remote#refs/remotes/}"
        if [[ -n "${remote}" ]]; then
            IFS=$'\t' read -r ahead behind <<<"$(command git rev-list --left-right --count HEAD...@{u})"
            (( $ahead )) && gitstatus+="+${ahead}"
            (( $behind )) && gitstatus="${gitstatus:+$gitstatus/}-${behind}"
            tracking="[${remote}${gitstatus:+ ${gitstatus}}]"
        fi
    else
        detached_tag="$(command git describe --all --always 2>/dev/null)"
        detached_from="${detached_tag:-${short_sha}}"
        tracking="[detached from ${detached_from}]"
    fi
    stashes="$(command git rev-list --walk-reflogs --count refs/stash 2>/dev/null)"
    stashes_exit="$?"
    [[ "$stashes_exit" -eq 0 ]] && stash_message="(${stashes} stashed)"

    message="(git)"
    [[ -n "${short_sha}" ]] && message+=" ${short_sha}"
    [[ -n "${staged_changes}" ]] && message+=" ${staged_changes}"
    [[ -n "${unstaged_changes}" ]] && message+=" ${unstaged_changes}"
    [[ "$on_branch" = true ]] && message+=" ${branch}"
    [[ -n "${tracking}" ]] && message+=" ${tracking}"
    [[ -n "${stash_message}" ]] && message+=" ${stash_message}"
    echo -en "\n${message}"
}

Asynchronous git status

Only supported by zsh. Remove precmd_functions+=(vcs_info) if you previously set it, because it will be replaced by precmd_functions+=(async_vcs_info)

setopt PROMPT_SUBST  # enable variable expansion in prompt

PROMPT="%n@%m:%~"'${vcs_info_msg_0_}'$'\n'"%# "

autoload -Uz vcs_info
zstyle ':vcs_info:*' enable git
zstyle ':vcs_info:*' check-for-changes true
zstyle ':vcs_info:git*' formats $'\n'"(%s) %i%c%u %b %m"
zstyle ':vcs_info:git*' actionformats $'\n'"(%s|%a) %i%c%u %b %m"
zstyle ':vcs_info:git*' stagedstr ' S'
zstyle ':vcs_info:git*' unstagedstr ' U'
zstyle ':vcs_info:git*:*' get-revision true
zstyle ':vcs_info:git*+set-message:*' hooks git-st git-stash

[[ -z "$precmd_functions" ]] && precmd_functions=()
precmd_functions+=(vcs_info)

# Show remote ref name and number of commits ahead-of or behind
+vi-git-st() {
    local ahead behind remote branch on_branch detached_from
    local -a gitstatus

    # If hook_com[revision] is already short then we can skip safely getting the short hash
    [[ "${#hook_com[revision]}" -gt 39 ]] && hook_com[revision]="$(command git rev-parse --verify -q --short=7 HEAD)"

    # On a branch? Need to check because hook_com[branch] might be a tag
    IFS='' read -r branch <"${gitdir}/HEAD"
    [[ "$branch" = "ref: refs/heads/"* ]] && on_branch=true || on_branch=false

    if [[ "$on_branch" = true ]]; then
        # On a remote-tracking branch?
        remote="${$(command git rev-parse --verify --symbolic-full-name @{u} 2>/dev/null)#refs/remotes/}"
        if [[ -n "${remote}" ]]; then
            IFS=$'\t' read -r ahead behind <<<"$(command git rev-list --left-right --count HEAD...@{u})"
            (( ahead )) && gitstatus+=( "+${ahead}" )
            (( behind )) && gitstatus+=( "-${behind}" )
            hook_com[branch]="${hook_com[branch]} [${remote}${gitstatus:+ ${(j:/:)gitstatus}}]"
        fi
    else
        detached_from="${$(command git describe --all --always 2>/dev/null):-${hook_com[revision]}}"
        hook_com[branch]="[detached from ${detached_from}]"
    fi
}

# Show count of stashed changes
+vi-git-stash() {
    local stashes stashes_exit stash_message
    stashes="$(command git rev-list --walk-reflogs --count refs/stash 2>/dev/null)"
    stashes_exit="$?"
    [[ "$stashes_exit" -ne 0 ]] && return
    [[ "$stashes" -eq 0 ]] && return
    stash_message="(${stashes} stashed)"
    hook_com[misc]="${hook_com[misc]}${hook_com[misc]:+ }(${stashes} stashed)"
}

async_vcs_info() {
    setopt LOCAL_OPTIONS NO_MONITOR
    if [[ "$_async_vcs_info_pid" -ne 0 ]]; then
        zle -F "$_async_vcs_info_fd"
        # Clean up the old fd
        exec {_async_vcs_info_fd}<&-
        unset _async_vcs_info_fd
        # Kill the obsolete async child
        kill -s HUP "$_async_vcs_info_pid" &>/dev/null
    fi
    coproc {
        vcs_info
        printf %s "$vcs_info_msg_0_"
    }
    _async_vcs_info_pid=$!  # Get the pid of the vcs_info coproc
    exec {_async_vcs_info_fd}<&p  # Get the vcs_info coproc output fd
    disown %?vcs_info # disown "%${(k)jobstates[(r)*:$_async_vcs_info_pid=*]}"
    zle -F $_async_vcs_info_fd async_vcs_info_handle_complete
}
async_vcs_info_handle_complete() {
    zle -F $1  # Unregister the handler
    local old_vcs_info_msg_0_="$vcs_info_msg_0_"
    vcs_info_msg_0_="$(<&$1)"  # Read the vcs_info data
    exec {1}<&-  # Clean up the old fd
    unset _async_vcs_info_fd
    unset _async_vcs_info_pid
    if [[ "$old_vcs_info_msg_0_" != "$vcs_info_msg_0_" ]]; then
        zle && zle .reset-prompt  # Redraw the prompt
        # use .reset-prompt instead of reset-prompt because of:
        # https://github.com/sorin-ionescu/prezto/issues/1026
    fi
}
[[ -z "$precmd_functions" ]] && precmd_functions=()
precmd_functions+=(async_vcs_info)

clear_vcs_info() {
    vcs_info_msg_0_=''
}
[[ -z "$chpwd_functions" ]] && chpwd_functions=()
chpwd_functions+=(clear_vcs_info)  # Get new vcs_info after cd