Simple Zsh Theme Guide Cheat sheet Open the editor

How to create a custom oh-my-zsh theme

An oh-my-zsh theme is one file of shell variables. This page explains every part of it — where the file goes, how PROMPT is assembled, how colours and git status get in, and which mistakes quietly break alignment — so you can write one by hand or build it visually and understand what comes out.

On this page

Where oh-my-zsh themes live

A theme is a file named <name>.zsh-theme. oh-my-zsh ships its own in ~/.oh-my-zsh/themes/, but that directory is overwritten on every upgrade. Your own themes belong in the custom directory, which is never touched:

# 1. save the theme
mkdir -p ~/.oh-my-zsh/custom/themes
$EDITOR ~/.oh-my-zsh/custom/themes/mytheme.zsh-theme

# 2. point zsh at it
sed -i 's/^ZSH_THEME=.*/ZSH_THEME="mytheme"/' ~/.zshrc

# 3. reload the shell
exec zsh

oh-my-zsh looks in $ZSH_CUSTOM/themes first and falls back to its own directory, so a custom theme named after a bundled one wins. exec zsh replaces the running shell, which is the only reliable way to re-read a theme — sourcing ~/.zshrc again leaves hooks registered twice.

PROMPT, RPROMPT and prompt_subst

zsh draws up to four prompts. A theme almost always sets two of them:

VariableWhat it is
PROMPTThe main prompt, at the left, where you type. PS1 is the same variable under its POSIX name.
RPROMPTDrawn flush with the right edge of the terminal. zsh erases it when you accept a line, so it never ends up in copied text.
PROMPT2Continuation lines — an unclosed quote or a trailing \.
PROMPT4Trace output under set -x.

Both are re-expanded before every prompt, so %~ follows you around the file system without any work on your part. What is not re-expanded by default is command substitution: $(git_prompt_info) inside a prompt string is only run if prompt_subst is on. Every theme that shows git state starts with it:

setopt prompt_subst

PROMPT='%F{green}%n@%m%f %F{blue}%~%f $(git_prompt_info)%(!.#.$) '
RPROMPT='%F{240}%*%f'

Single quotes matter. With double quotes the command substitution runs once, when the theme is read, and your branch name freezes at whatever it was then.

Build one in the editor

Hand-writing the escapes is fine for a one-line prompt and miserable for anything else, which is what the visual oh-my-zsh theme editor is for. It renders the prompt the way zsh does — same escapes, same truncation, same colour lookup — and the file it gives you produces exactly what you saw.

  1. Lay out the prompt

    Drag segments — user, host, directory, git branch, exit status, clock — onto a prompt line. Add a second line for a two-line prompt, or drop segments into RPROMPT to pin them to the right edge.

  2. Style each segment

    Select one or more segments and set foreground, background, bold, underline, and separate colours for when you are root or connected over SSH.

  3. Check it under real shell states

    The scenario strip renders the same prompt with a dirty repository, a failed command, a deep path, no repository, a root shell and an active virtualenv, so an unreadable state shows up before you install the theme.

  4. Copy the generated theme

    The export panel holds a complete .zsh-theme file: prompt_subst, helper functions, ZSH_THEME_GIT_PROMPT_* variables and the PROMPT/RPROMPT assignments. Copy it or download it.

  5. Install and activate it

    Save the file as ~/.oh-my-zsh/custom/themes/<name>.zsh-theme, set ZSH_THEME="<name>" in ~/.zshrc, and run exec zsh. The prompt in your terminal matches the preview character for character.

Colours

Modern zsh has one colour escape, and it takes three kinds of value:

EscapeMeaning
%F{red} … %fOne of the eight names, i.e. palette slots 0–7. What they look like is decided by your terminal's colour scheme, not by zsh.
%F{166} … %fAn xterm-256 index. 0–15 follow the terminal palette; 16–255 are the fixed cube and greyscale ramp, identical everywhere.
%F{#ff8700} … %fA 24-bit hex colour, shown exactly as written wherever truecolor is supported.
%K{…} … %kThe same three forms, for the background.
%B … %bBold. %U %u underline, %S %s reverse video.

Older themes use $fg[red] and $reset_color from the colors function. They still work — oh-my-zsh loads colors for you — but they expand to raw terminal escapes, which zsh cannot measure. That is a real problem: see what breaks a prompt.

Pick 256-colour indexes if you share the theme. Slots 0–15 are redefined by every colour scheme, so a theme built on %F{green} can land on a different green — or, in a light scheme, on something unreadable. The editor previews a theme against ten common schemes for this reason.

Git status

oh-my-zsh's git library gives themes a small set of functions. The two you will use are git_prompt_info, which prints the branch wrapped in your prefix and suffix, and git_prompt_status, which prints one marker per kind of change:

ZSH_THEME_GIT_PROMPT_PREFIX="%F{242}on %F{magenta}"
ZSH_THEME_GIT_PROMPT_SUFFIX="%f"
ZSH_THEME_GIT_PROMPT_DIRTY="%F{yellow} ✗"
ZSH_THEME_GIT_PROMPT_CLEAN="%F{green} ✔"

PROMPT='%F{blue}%~%f $(git_prompt_info) '

The marker variables — ZSH_THEME_GIT_PROMPT_ADDED, …_MODIFIED, …_UNTRACKED, …_RENAMED, …_DELETED, …_UNMERGED, …_STASHED, …_AHEAD, …_BEHIND — are listed with their defaults in the cheat sheet. Every one of them is just a string, so the colour goes inside the string.

parse_git_dirty runs git status on every prompt, which is slow in a large repository. git config --add oh-my-zsh.hide-dirty 1 inside that one repository turns the check off without changing your theme.

Root, SSH and exit codes

zsh has a ternary escape, %(x.true.false), where x is a test letter and the two branches are separated by whatever character follows the condition:

%(!.%F{red}#%f.%F{green}$%f)    # root gets a red #, anyone else a green $
%(?..%F{red}%? )                # exit code, but only when the last command failed
%(1j.%F{yellow}&%j .)           # a job count, only when there are background jobs

There is no test letter for “am I over SSH”, because that is an environment variable rather than shell state. The standard trick is a precmd hook that sets a slot in psvar, which does have a test letter:

autoload -Uz add-zsh-hook
zsh_theme_ssh_psvar() { [[ -n $SSH_TTY$SSH_CONNECTION ]] && psvar[1]=1 || psvar[1]=; }
add-zsh-hook precmd zsh_theme_ssh_psvar

PROMPT='%(1V.%F{red}%M%f.%F{green}%m%f) %~ '   # fully qualified and red over SSH
Do not inline ${SSH_TTY:+…} in a prompt. zsh ends a ${…} at the first unescaped }, so ${SSH_TTY:+%F{red}} swallows the } of the colour escape and prints a stray brace. The psvar form above has no such trap.

Two-line prompts

A second line costs nothing but a newline inside the string, and it buys back the width that long paths and branch names eat:

PROMPT='%F{242}╭─%f %B%F{green}%n@%m%f%b %F{blue}%~%f $(git_prompt_info)
%F{242}╰─%f%(?.%F{green}.%F{red})➜%f '

Two things to know. RPROMPT is drawn on the first line of the prompt, not next to the cursor, so a right prompt on a two-line theme sits up and to the right. And a prompt that ends with a newline makes clear and Ctrl+L behave oddly in some terminals; ending with a space is safer.

What breaks a prompt

Unmeasured escapes push the line

zsh counts the printing width of your prompt to know where the cursor is. Raw terminal escapes have no printing width, so anything zsh cannot recognise must be wrapped in %{…%}, which means “this occupies zero columns”. %F and %B are already understood and must not be wrapped. Get this wrong and the symptom is unmistakable: long command lines wrap in the wrong place, and editing an earlier word smears the prompt.

Glyphs that your font does not have

Powerline separators and Nerd Font icons live in the Private Use Area. Without a patched font they render as a box, and their width is a guess, so alignment goes with them. Use %G (or the editor's width hint) to tell zsh that a sequence counts as one column, and keep an ASCII variant for consoles and SSH sessions.

A virtualenv prefix in front of everything

If your prompt suddenly starts with (env-name) ahead of your own first segment, that is not your theme: it is the virtualenv's activate script doing PS1="(env-name) $PS1". Set VIRTUAL_ENV_DISABLE_PROMPT=1 — themes exported from the editor do it for you — and print $VIRTUAL_ENV in a segment of your own. Conda ignores that variable; use conda config --set changeps1 false.

Colours that vanish inside tmux or over SSH

Truecolor needs the terminal to advertise it; inside tmux it needs set -ga terminal-overrides ",*256col*:Tc". When in doubt, build the theme in 256-colour mode, which every terminal emulator written this century supports.

Frequently asked questions

Where do oh-my-zsh themes live?

In ~/.oh-my-zsh/custom/themes/. Save the generated file there as <name>.zsh-theme, set ZSH_THEME="<name>" in ~/.zshrc, then run exec zsh. The custom directory survives oh-my-zsh upgrades; the bundled themes directory does not.

Do I need a Nerd Font for a custom zsh prompt?

Only if you use powerline separators or glyph segments. Everything else is plain text. The editor warns you when a prompt contains a glyph outside the basic set, and an ASCII-only switch rewrites those segments so the prompt stays readable over SSH or in a console without a patched font.

Why does my prompt show (venv) in front of my theme?

That prefix is not your theme. A Python virtualenv's activate script prepends "(name) " to PS1 unless VIRTUAL_ENV_DISABLE_PROMPT is set, so it lands ahead of your whole prompt. Themes exported here set that variable and read $VIRTUAL_ENV themselves, which puts the environment name back in its own segment. Conda needs conda config --set changeps1 false instead.

Why do the colours look different in my terminal?

Colour indexes 0-15 are defined by the terminal palette, not by zsh, so index 2 is whatever green your scheme picked. Indexes 16-255 are fixed across terminals, and truecolor hex values are exact wherever 24-bit colour is supported. Switch the preview between palettes to see how far a theme drifts.

What is the difference between PROMPT and RPROMPT?

PROMPT (also called PS1) is drawn at the left, where you type. RPROMPT is drawn flush to the right edge of the terminal and is erased by default when you accept a line, which makes it the right place for timestamps, exit codes and other context you do not want copied along with a command.

Can I edit the oh-my-zsh theme I already use?

Yes. Paste any .zsh-theme file into the import dialog. Local variables, $UID and SSH branches, ZSH_THEME_GIT_PROMPT_* settings and the legacy $fg[...] arrays are all understood, and anything the parser cannot classify is kept verbatim so an import never silently changes how your prompt looks.

Does a generated theme work in plain zsh, without oh-my-zsh?

The prompt escapes do, and so do the helper functions the generator writes for you. The git segments call oh-my-zsh helpers such as git_prompt_info, so outside oh-my-zsh you either source its git library or drop the git segments.

Next

Keep the zsh prompt escape codes cheat sheet open while you work, or skip the escapes entirely and assemble the prompt in the editor — it writes the file, checks it against the shell states that usually break a theme, and tells you what each segment costs.