Published: 13 August 2026
Tagged: Tools
Every time someone sees my terminal over a screen share, the same question comes up: “what is that prompt?”. So here is the whole thing — every segment, what produces it, and how to rebuild it from scratch.
This is what it looks like inside a repository:
Nine pieces of information, none of which I had to ask for. Where I am, which branch, that the working tree is dirty and has untracked files, that I am one commit ahead in additions and thirty-two in deletions, which Node version this project uses, which Docker context is active, and — critically — which git identity I am committing as.
That last one is not vanity. It is the reason the second line exists at all.
The Stack
Four independent layers. You can adopt any one of them without the others.
| Layer | Tool | What it does |
|---|---|---|
| Terminal | Ghostty | Window, colours, transparency, font rendering |
| Font | JetBrains Mono Nerd Font | The glyphs — without it you get boxes |
| Prompt | Starship | Everything you see before the ❯ |
| Shell tooling | atuin, zoxide, fzf, eza, yazi | History, navigation, listing, file browsing |
Install all of it in one go:
brew install --cask ghostty font-jetbrains-mono-nerd-font
brew install starship atuin zoxide fzf eza yaziLayer 1 — The Terminal
Ghostty is a GPU-accelerated terminal written by Mitchell Hashimoto. It is fast, it is native on macOS, and — the part that matters here — it is configured by a plain text file rather than a settings panel.
The config lives at ~/Library/Application Support/com.mitchellh.ghostty/config on macOS (or ~/.config/ghostty/config if you prefer XDG). Mine, in full:
theme = ayu
window-padding-x = 24
window-padding-y = 0
window-padding-balance = true
font-family = "JetBrainsMono NFM Medium"
font-family-bold = "JetBrainsMono NFM Bold"
font-family-italic = "JetBrainsMono NFM Medium Italic"
font-family-bold-italic = "JetBrainsMono NFM Bold Italic"
font-size = 12
window-decoration = true
background-opacity = 0.85
background-blur-radius = 27
font-thicken = true
shell-integration = detect
shell-integration-features = sudo,no-cursor
cursor-style = bar
cursor-style-blink = true
mouse-hide-while-typing = true
confirm-close-surface = true
macos-icon = retro
macos-icon-frame = aluminum
macos-window-shadow = falseA few of these earn their place more than others:
window-padding-x = 24— the single highest-impact line in the file. Text pinned to the window edge reads as cramped; 24px of breathing room does more for legibility than any colour scheme.background-opacity+background-blur-radius— translucency without the blur is unreadable noise. The two go together or not at all.shell-integration = detect— lets Ghostty know where prompts start and end, which is what makes scroll-to-previous-command work.font-thicken = true— macOS renders thin fonts thinner than you expect. This compensates.
Run ghostty +show-config --default --docs to see every option with inline documentation. It is one of the better-documented configs I have worked with.
Layer 2 — The Font
This is the step people skip, and then nothing works.
The prompt uses glyphs — the powerline arrow , the git branch symbol, the Node hexagon — that do not exist in normal fonts. A **Nerd Font** is a regular font with those glyphs patched in. Without one you get boxes where the icons should be.
brew install --cask font-jetbrains-mono-nerd-fontThen point the terminal at it. Note the name in my config is JetBrainsMono NFM — NFM is the Nerd Font Mono variant, where the icons are squeezed into a single character cell. There is also plain NF, where icons take up two cells. NFM keeps column alignment predictable; NF looks slightly better at large sizes. Pick one and be consistent.
If you are reading this article in a browser without a Nerd Font installed, some glyphs in the code blocks below will render as empty boxes. That is expected — they will look correct in your terminal.
Layer 3 — The Prompt
Starship is a single binary that renders the prompt. It is cross-shell, it is configured in one TOML file, and it detects context automatically — you do not tell it “this is a Node project”, it notices.
Hook it into zsh as the last line of your ~/.zshrc, after any theme framework:
eval "$(starship init zsh)"If you use oh-my-zsh, set ZSH_THEME="" so it does not fight Starship for the prompt.
The format string is the table of contents
Everything in Starship flows from one format key in ~/.config/starship.toml. Read it top to bottom and you have read the prompt:
add_newline = true
command_timeout = 2000
format = """\
[](fg:#3B76F0)\
$directory\
${custom.directory_separator_not_git}\
${custom.directory_separator_git}\
$git_branch[](fg:#FCF392)\
$git_commit$git_status$git_metrics$git_state$fill$cmd_duration$nodejs$all\
${custom.git_config_email}
$character"""Two mechanics to notice before the modules themselves:
Trailing backslashes. Each \ joins the next line without a newline. It lets you write the format one module per line — otherwise this would be one unreadable 300-character string.
The line break is literal. There is exactly one real newline in that string, right before $character. That is what puts the email on its own row and the ❯ below it.
command_timeout = 2000 matters because of the custom modules further down — they shell out, and the default 500ms timeout will make Starship complain on a cold filesystem cache.
The directory block
[directory]
truncate_to_repo = true
format = "[ $path ]($style)"
style = "fg:#ffffff bg:#3B76F0"truncate_to_repo = true is the setting that makes this readable. Inside a repo it shows the repo name, not the eleven directories above it. ~/Development/work/clients/2026/risk-engine/apps/mobile becomes risk.
The background colour is set on the style, not drawn separately — that is how you get a solid block rather than coloured text.
The powerline trick
This is the part of the config I am most pleased with and the part that is least obvious.
A powerline separator is just the glyph `` painted with the previous block’s colour as foreground and the next block’s colour as background. Easy — except the colour that comes next depends on whether you are in a git repo. Inside a repo it is the yellow branch block; outside, it is nothing.
Starship has no if statement. But custom modules have a when condition, and a custom module with an empty command is a pure conditional renderer:
[custom.directory_separator_git]
description = "Separator after the directory when inside a git repository."
command = ""
format = "[](fg:#3B76F0 bg:#FCF392)"
when = "git rev-parse --is-inside-work-tree >/dev/null 2>&1"
[custom.directory_separator_not_git]
description = "Separator after the directory when NOT inside a git repository."
command = ""
format = "[](fg:#3B76F0)"
when = "! git rev-parse --is-inside-work-tree > /dev/null 2>&1"Both sit in the format string next to each other. Exactly one ever renders. Inside a repo the arrow bridges blue into yellow; outside, it fades blue into the background. The seam is invisible either way.
Same trick, opposite end: $git_branch[](fg:#FCF392) closes the yellow block back to the background, and that closing arrow is written inline in the format string rather than as its own module — it only ever renders when $git_branch does.
Git state
[git_branch]
symbol = " "
format = "[ $symbol$branch(:$remote_branch) ]($style)"
style = "fg:#1C3A5E bg:#FCF392"
[git_metrics]
disabled = falsegit_metrics is off by default and worth turning on — it is the +1 -32 in the sample prompt, lines added and deleted since the last commit. It is the difference between knowing you have changes and knowing how big they are.
git_status is on by default and needs no config. Its shorthand is worth memorising:
| Symbol | Meaning |
|---|---|
! |
modified files |
? |
untracked files |
+ |
staged changes |
$ |
stashed changes |
⇡ / ⇣ |
ahead / behind remote |
= |
merge conflict |
So [!?] +1 -32 reads as: modified and untracked files present, one line added, thirty-two removed.
Pushing things to the right
[fill]
symbol = " "
[cmd_duration]
min_time = 2_000
format = "[ ⏲︎ $duration ]($style)"
style = "white"The fill module expands to consume all remaining terminal width. Anything after it in the format string gets pushed to the right edge. That is how the Node version and Docker context end up right-aligned without any manual padding.
cmd_duration with min_time = 2000 only appears when a command took longer than two seconds. Silent when it does not matter, informative when it does — which is the right default for almost every prompt segment.
$all — the catch-all
The $all at the end of the format string is a wildcard: it renders every module not already mentioned explicitly. That is where via v24.4.0 and via 🐳 colima come from — I never configured a Node module or a Docker module. Starship detected a package.json and an active Docker context and said so.
Same for Python virtualenvs, Rust toolchains, Ruby versions, Kubernetes contexts, AWS profiles. It is the highest ratio of information to configuration in the whole file.
If a language shows up that you do not care about, disable it individually:
[ruby]
disabled = trueThe second line
[custom.git_config_email]
description = "Output the current git user's configured email address."
command = "git config user.email"
format = "\n[$symbol( $output)]($style)"
when = "git rev-parse --is-inside-work-tree >/dev/null 2>&1"
style = "text"This is the segment I would keep if I had to throw away the rest.
If you work across a personal account and a work account, you will eventually commit to one with the identity of the other. You will notice weeks later, in a code review, on a branch that has already been merged. The fix is git config user.email per repository — but only if you remember to check, and nobody remembers to check.
So the prompt checks. Every time. On its own line, in muted text, only inside repositories.
Layer 4 — The Shell Around It
The prompt is what people notice. These are what I would actually miss.
atuin — history that is a database
eval "$(atuin init zsh)"
bindkey '^R' atuin-search
bindkey '^[[A' atuin-search # intercept the up arrow tooAtuin replaces ~/.zsh_history with a SQLite database and gives you a full-screen fuzzy search over it, filtered by directory, with exit codes and durations recorded. The second bindkey is the opinionated part: it hijacks the up arrow as well, so the reflex you already have opens the good search instead of the bad one.
zoxide — cd that learns
eval "$(zoxide init zsh)"Tracks which directories you actually visit and ranks them by frequency and recency. z risk jumps to the risk-engine repo from anywhere. After a week of use you stop typing paths.
fzf — fuzzy finding everywhere
source <(fzf --zsh)One line, and Ctrl-T becomes a fuzzy file picker, Alt-C a fuzzy cd. Many other tools use fzf as a backend, so installing it makes things you have not installed yet better.
eza — ls with git awareness
alias ee='eza --icons -l -F --colour=always -a --git --header --time-style=iso'
alias ee2='eza --icons -l -TL 2 --total-size -F --colour=always'--git annotates each file with its git status, which is the flag that makes eza worth the switch. The second alias is a two-level tree with directory sizes — the fastest way to answer “what is taking up space here”.
yazi — a file manager that changes your shell’s directory
function y() {
local tmp="$(mktemp -t "yazi-cwd.XXXXXX")" cwd
yazi "$@" --cwd-file="$tmp"
IFS= read -r -d '' cwd < "$tmp"
[ -n "$cwd" ] && [ "$cwd" != "$PWD" ] && builtin cd -- "$cwd"
rm -f -- "$tmp"
}A child process cannot change its parent’s working directory, so yazi writes its final directory to a temp file and the wrapper cds there afterwards. Browse visually with y, quit, and the shell is already where you were looking.
One more line worth stealing
precmd() { print -Pn "\e]0;%~\a" }Sets the terminal tab title to the current directory before each prompt. Trivial, and the difference between six identifiable tabs and six tabs labelled “zsh”.
What It Costs
Pretty prompts have a reputation for being slow, and this one is not free. Starship will tell you exactly how much:
starship timingsMeasured in a real repository on my machine:
custom.git_config_email - 15ms
custom.directory_separator_git - 8ms
custom.directory_separator_not_git - 7ms
ruby - 5ms
git_metrics - 4ms
directory - 4ms
git_branch - <1msThe three custom modules account for 30ms of the roughly 45ms total — two thirds of the cost, for one email address and one cosmetic arrow. Each of them spawns a process on every single prompt, and the two separator modules both run their check even though only one can ever render.
Modules run in parallel, so the wall-clock hit is smaller than the sum. At 45ms it stays under the threshold where a prompt feels sluggish, and I consider the git identity check worth it. But it is a real trade, and it is worth knowing which lines in your config you are paying for. If you want the aesthetics without the cost, drop the separator modules and accept a hard colour edge — you get 15ms back for a change most people would not notice.
One more piece of honesty: my config carries a leftover $symbol in the format string. symbol is not a module, so Starship silently renders nothing for it. It has been there for a year doing absolutely nothing. Check your own config with starship timings — anything that never appears in the output is not running.
Where to Start
If you adopt one thing from this article, make it the Nerd Font and Starship with a default config — that is fifteen minutes and most of the visual payoff.
If you adopt two, add atuin. Searchable history with directory context changes how you use a shell more than any prompt does.
The rest is decoration. Good decoration, tuned over a couple of years, and I would rebuild all of it on a new machine — but decoration.
One warning before you copy anyone’s dotfiles, mine included: check them for secrets first. API tokens exported in a
.zshrcare the single most common way a private repo becomes a public incident. Keep them in a secret manager and read them at runtime.