Mastering ZSH: Part 3 - Understanding Your Prompt: How Powerlevel10k Actually Works

Demystify your fancy ZSH prompt. Learn how Powerlevel10k displays git status, command duration, and error codes instantly. Build a mini-P10k from scratch to understand the magic.

📚 Series: Mastering Zsh

  1. Mastering ZSH: Part 1 - Hooks and Automation
  2. Mastering ZSH: Part 2 - Line Editor and Custom Widgets
  3. Mastering ZSH: Part 3 - Understanding Your Prompt: How Powerlevel10k Actually Works (current)
  4. Mastering ZSH: Part 4 - Completion System Demystified

You press Enter. Your prompt instantly updates with:

  • Git branch and status (✓ clean, ✗ dirty)
  • Command duration (if it took >3 seconds)
  • Python virtualenv indicator
  • Exit code (red X if the command failed)

It’s beautiful. It’s fast. But how does it work?

Powerlevel10k isn’t magic. It’s clever use of ZSH hooks, escape sequences, and async rendering. Let’s build a mini version from scratch to understand what’s happening behind that fancy prompt.

What is a Prompt, Actually?

Your prompt is just a string variable that ZSH prints before accepting input.

The simplest possible prompt:

1
PROMPT="$ "

That’s it. Every time ZSH is ready for input, it prints the value of $PROMPT.

But modern prompts are dynamic: they change based on context. To understand how, we need three concepts:

  1. Escape sequences: Special codes that ZSH interprets
  2. Prompt expansion: Variables and functions evaluated before display
  3. Hooks: Functions that run before the prompt displays

Let’s build these up one by one.

Escape Sequences: Making Prompts Colorful

ZSH replaces special % codes with dynamic values:

1
PROMPT="%~ %# "
  • %~: Current directory (with ~ for $HOME)
  • %#: # if root, % otherwise

Result:

~/code/blog %

Color Escape Sequences

1
PROMPT="%F{blue}%~%f %# "
  • %F{blue}: Start blue foreground color
  • %f: Reset to default foreground

Other useful escapes:

CodeMeaning
%B / %bBold on/off
%U / %uUnderline on/off
%K{color} / %kBackground color on/off
%nUsername
%mHostname
%D{%H:%M}Time (strftime format)
%?Exit code of last command

Example: Basic Colored Prompt

1
PROMPT="%F{cyan}%n@%m%f %F{blue}%~%f %# "

Result:

username@hostname ~/code/blog %

But this is still static. The directory updates automatically because %~ is evaluated each time, but what about git status?

Dynamic Content with PROMPT_SUBST

To run code or expand variables in your prompt, enable substitution:

1
setopt PROMPT_SUBST

Now you can use command substitution and variable expansion:

1
PROMPT='%F{blue}%~%f $(git branch --show-current 2>/dev/null) %# '

Important: Use single quotes so the command substitution runs each time, not just once when you set the variable.

Result:

~/code/blog main %

The git command runs every time the prompt displays. That’s potentially slow, which we’ll fix later.

Using Hooks for Complex Prompts

Remember Part 1 where we covered ZSH hooks? The precmd hook runs right before the prompt displays, which is perfect for building dynamic prompts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
precmd() {
  # Build prompt components
  local git_branch=$(git branch --show-current 2>/dev/null)
  local git_prompt=""

  if [[ -n $git_branch ]]; then
    git_prompt=" %F{yellow}$git_branch%f"
  fi

  PROMPT="%F{blue}%~%f${git_prompt} %# "
}

This pattern is exactly what Powerlevel10k does, but at scale.

Why use precmd instead of command substitution?

  1. You can cache expensive operations
  2. You can set multiple prompt variables (PROMPT, RPROMPT, etc.)
  3. You can share data between prompt components
  4. Cleaner separation of logic

Building a Git-Aware Prompt

Let’s add more git context: not just the branch, but also status indicators.

Using vcs_info (ZSH’s Built-in Git Integration)

ZSH has a built-in module for version control information:

1
2
3
4
5
6
7
8
autoload -Uz vcs_info
precmd_vcs_info() { vcs_info }
precmd_functions+=( precmd_vcs_info )

setopt PROMPT_SUBST
PROMPT='%F{blue}%~%f ${vcs_info_msg_0_} %# '

zstyle ':vcs_info:git:*' formats '%F{yellow}%b%f'

Result:

~/code/blog main %

Adding Git Status Indicators

Let’s add dirty/clean indicators:

1
2
3
4
5
zstyle ':vcs_info:git:*' formats '%F{yellow}%b%f %c%u'
zstyle ':vcs_info:git:*' actionformats '%F{yellow}%b%f|%F{red}%a%f %c%u'
zstyle ':vcs_info:git:*' stagedstr '%F{green}●%f'
zstyle ':vcs_info:git:*' unstagedstr '%F{red}●%f'
zstyle ':vcs_info:git:*' check-for-changes true

Now your prompt shows:

  • Yellow branch name
  • Green dot if staged changes
  • Red dot if unstaged changes
  • Red action indicator during rebase/merge

Problem: check-for-changes is slow on large repositories. This is why P10k uses a different approach.

The Fast Way: Custom Git Status

Instead of relying on vcs_info checking for changes, run git status yourself with optimizations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
precmd() {
  # Get git info
  local branch=$(git branch --show-current 2>/dev/null)

  if [[ -z $branch ]]; then
    PROMPT="%F{blue}%~%f %# "
    return
  fi

  # Check for uncommitted changes (fast)
  local git_status=$(git status --porcelain 2>/dev/null)
  local status_indicator=""

  if [[ -n $git_status ]]; then
    status_indicator=" %F{red}✗%f"
  else
    status_indicator=" %F{green}✓%f"
  fi

  PROMPT="%F{blue}%~%f %F{yellow}$branch%f$status_indicator %# "
}

Result:

~/code/blog main ✓ %     # Clean repo
~/code/blog main ✗ %     # Dirty repo

This is faster because git status --porcelain exits early once it finds any change.

Command Duration Display

Powerlevel10k shows command duration if execution took more than a threshold (default 3s). Here’s how:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
preexec() {
  __cmd_start=$EPOCHSECONDS
}

precmd() {
  # Command duration
  local duration_display=""
  if [[ -n $__cmd_start ]]; then
    local duration=$((EPOCHSECONDS - __cmd_start))
    if (( duration >= 3 )); then
      duration_display=" %F{cyan}⏱ ${duration}s%f"
    fi
  fi
  unset __cmd_start

  # Git info (abbreviated for space)
  local branch=$(git branch --show-current 2>/dev/null)
  local git_prompt=""
  [[ -n $branch ]] && git_prompt=" %F{yellow}$branch%f"

  PROMPT="%F{blue}%~%f${git_prompt}${duration_display}
%# "
}

Result after running sleep 5:

~/code/blog main ⏱ 5s
%

How it works:

  1. preexec captures timestamp before command runs
  2. precmd calculates duration after command completes
  3. Only displays if duration exceeds threshold

Exit Code Indicator

Show when the last command failed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
precmd() {
  local exit_code=$?
  local status_icon="%F{green}✓%f"

  if (( exit_code != 0 )); then
    status_icon="%F{red}✗%f ($exit_code)"
  fi

  PROMPT="${status_icon} %F{blue}%~%f %# "
}

Result after false:

✗ (1) ~/code/blog %

Right-Side Prompt (RPROMPT)

Powerlevel10k often puts time, virtualenv, or other context on the right side:

1
2
3
4
5
6
7
precmd() {
  # Left side
  PROMPT="%F{blue}%~%f %# "

  # Right side
  RPROMPT="%F{240}%D{%H:%M:%S}%f"
}

Result:

~/code/blog %                           14:32:15

The right prompt is useful for information you want visible but not in the way.

Putting It All Together: Mini-P10k

Here’s a complete, practical prompt that feels 80% like Powerlevel10k in about 50 lines:

 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
49
50
51
52
53
54
55
# Enable prompt substitution
setopt PROMPT_SUBST

# Command timing
preexec() {
  __cmd_start=$EPOCHSECONDS
}

precmd() {
  local exit_code=$?

  # Exit status indicator
  local status="%F{green}✓%f"
  if (( exit_code != 0 )); then
    status="%F{red}✗%f"
  fi

  # Command duration
  local duration_display=""
  if [[ -n $__cmd_start ]]; then
    local duration=$((EPOCHSECONDS - __cmd_start))
    if (( duration >= 3 )); then
      duration_display=" %F{cyan}${duration}s%f"
    fi
  fi
  unset __cmd_start

  # Git branch and status
  local git_prompt=""
  local branch=$(git branch --show-current 2>/dev/null)

  if [[ -n $branch ]]; then
    local git_status=$(git status --porcelain 2>/dev/null)
    local git_indicator="%F{green}✓%f"

    if [[ -n $git_status ]]; then
      git_indicator="%F{red}✗%f"
    fi

    git_prompt=" %F{yellow}$branch%f $git_indicator"
  fi

  # Python virtualenv
  local venv_prompt=""
  if [[ -n $VIRTUAL_ENV ]]; then
    venv_prompt=" %F{blue}($(basename $VIRTUAL_ENV))%f"
  fi

  # Build left prompt
  PROMPT="${status} %F{cyan}%~%f${git_prompt}${venv_prompt}${duration_display}
%# "

  # Right prompt: time
  RPROMPT="%F{240}%D{%H:%M:%S}%f"
}

Result:

✓ ~/code/blog main ✓ (venv)              14:35:22
%

After a long command:

✓ ~/code/blog main ✓ 12s                 14:35:34
%

After a failed command:

✗ ~/code/blog main ✗                     14:35:40
%

Performance: Why P10k is Fast

Your mini-prompt works great for most repos, but P10k is noticeably faster on huge repositories (Linux kernel, Chromium). Why?

1. Instant Prompt

P10k shows a prompt immediately with cached data from the previous invocation, then updates asynchronously.

Your prompt blocks until git status completes. P10k shows:

~/linux main ✓ %    # Shown instantly (cached from last time)

Then updates a second later if status changed:

~/linux main ✗ %    # Updated asynchronously

2. Smart Caching

P10k caches git status and only re-checks when files change. It uses git’s internal index timestamp to detect changes without running git status.

3. Async Workers

P10k spawns background workers using zsh/zpty (pseudo-terminal) to run expensive operations without blocking.

Basic async pattern:

 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
# Simplified version of async rendering
autoload -Uz add-zsh-hook

typeset -g __git_status_cache=""

async_git_status() {
  # This runs in background
  git status --porcelain 2>/dev/null
}

async_git_callback() {
  # This runs when background job completes
  __git_status_cache=$1
  zle reset-prompt  # Redraw prompt with new data
}

precmd() {
  # Show cached status immediately
  local git_indicator=""
  if [[ -n $__git_status_cache ]]; then
    git_indicator=" %F{red}✗%f"
  else
    git_indicator=" %F{green}✓%f"
  fi

  PROMPT="%F{blue}%~%f$git_indicator %# "

  # Start async update for next time
  # (Real implementation uses zsh-async or zsh/zpty)
}

P10k’s full async implementation is complex, but the principle is:

  1. Show cached data immediately
  2. Update in background
  3. Redraw when ready

When Do You Need P10k’s Complexity?

Use your mini-prompt if:

  • Your repos are reasonably sized (<100k files)
  • You want to understand and customize behavior
  • You prefer simple, readable code

Use Powerlevel10k if:

  • You work on massive repositories
  • You want every millisecond of speed
  • You want tons of built-in integrations (AWS, kubectl, etc.)

Most developers will never notice the difference.

Common Prompt Patterns

Conditional Display

Only show git status when in a repo:

1
2
3
4
5
local git_prompt=""
if git rev-parse --git-dir >/dev/null 2>&1; then
  # In a git repo
  git_prompt=" $(git branch --show-current)"
fi

Truncating Long Paths

1
2
3
4
5
# Show only last 2 path components
PROMPT='%2~ %# '

# ~/code/blog/content/posts becomes:
# content/posts %

Multiline Prompts

1
2
PROMPT='%F{blue}%~%f
%# '

The literal newline creates a two-line prompt.

Transient Prompts

Show fancy prompt while typing, but simplify after command runs. This keeps your scrollback clean.

P10k calls this “transient prompt.” You can approximate it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
zle-line-init() {
  # Fancy prompt while editing
  PROMPT='%F{blue}%~%f %F{yellow}$(git branch --show-current 2>/dev/null)%f %# '
}

zle-line-finish() {
  # Simple prompt after command runs
  PROMPT='%# '
}

zle -N zle-line-init
zle -N zle-line-finish

Debugging Your Prompt

If your prompt looks wrong:

1
2
3
4
5
# See prompt with escapes visible
print -P $PROMPT

# Disable prompt expansion temporarily
unsetopt PROMPT_SUBST

If your prompt is slow:

1
2
3
4
5
6
7
8
9
# Time the precmd hook
precmd() {
  local start=$EPOCHREALTIME

  # ... your prompt code ...

  local elapsed=$(( EPOCHREALTIME - start ))
  echo "Prompt took ${elapsed}s"
}

What We Learned

Powerlevel10k’s “magic” is:

  1. Escape sequences: %F{color}, %~, %# for dynamic content
  2. PROMPT_SUBST: Expands variables/commands each display
  3. precmd hook: Builds prompt right before display
  4. Smart caching: Don’t recompute unchanged values
  5. Async rendering: Show cached data, update in background

You don’t need to abandon Powerlevel10k (it’s excellent). But now you understand:

  • Why your prompt updates instantly
  • How git status appears without lag
  • What those configuration options actually control
  • How to build your own custom prompt from scratch

Next Steps

You now understand how your prompt works. In Part 4 (coming soon), we’ll tackle the completion system: making your custom tools feel as polished as your prompt with intelligent tab completion.

Want to explore further?

Your Turn

Try building your own minimal prompt using the patterns above. Start simple:

  1. Add your current directory in blue
  2. Add git branch in yellow (when in a repo)
  3. Add ✓/✗ indicator for clean/dirty status
  4. Add command duration for slow commands

Then customize it and make it yours. That’s the real power of understanding how your prompt works.

📚 Series: Mastering Zsh

  1. Mastering ZSH: Part 1 - Hooks and Automation
  2. Mastering ZSH: Part 2 - Line Editor and Custom Widgets
  3. Mastering ZSH: Part 3 - Understanding Your Prompt: How Powerlevel10k Actually Works (current)
  4. Mastering ZSH: Part 4 - Completion System Demystified