I wrote earlier today about trimming my prompt down to four things that matter. This is the longer version: how I actually migrated off a decade of hand-rolled zsh, what broke, and the one performance trap that nearly sent me back.
The thing I was running away from
My old prompt was a precmd function plus a tangle of helpers I'd accreted since roughly 2011. It parsed git status on every keystroke-free moment, shelled out to read the kube context, and sniffed for a Python virtualenv. On a small repo it was fine. On a monorepo with a working tree the size of a small county, every press of return had a perceptible pause while git status walked the tree. I'd trained myself not to notice it, which is the worst kind of bug: the one you've adapted around.
The honest problem was that I'd built a prompt that did expensive work eagerly and synchronously, on a hot path I hit hundreds of times a day. No amount of clever shell was going to fix that architecture. It needed throwing away.
Why starship, and not just better zsh
I had a look at the landscape this autumn. I could have rewritten my zsh functions properly, used git status --porcelain with the right flags, cached aggressively. But I'd be maintaining a bespoke thing forever, and the bits I cared about (git, kube, exit code, sensible path) are exactly the bits everyone cares about. Starship is a single Rust binary, configured by one TOML file, and it's fast enough that the eager-versus-lazy question mostly goes away because the binary is quick and it has sensible timeouts baked in.
The migration was genuinely an afternoon. Install the binary, add one line to .zshrc, delete about two hundred lines of accumulated function, and then spend the rest of the time deciding what not to show.
What I kept
Four modules earn their place.
- git: branch name and a dirty marker. Not the file count, not ahead/behind unless I ask. The marker is the signal; the details are a
git statusaway. - kubernetes: the current context, painted red when the name matches anything production-shaped. This is the one that prevents disasters rather than annoyances.
- the exit code: shown only when non-zero. A clean prompt should mean the last command worked, and that's only true if failure is the exception that appears, not a permanent badge you stop seeing.
- directory: truncated to the last few segments, anchored to the repo root so the useful part is always on screen.
Here's the shape of it:
add_newline = false
format = "$directory$git_branch$git_status$kubernetes$character"
[directory]
truncation_length = 3
truncate_to_repo = true
[git_status]
format = '([$all_status$ahead_behind]($style) )'
[kubernetes]
disabled = false
format = '[$context](bold red) '
[kubernetes.context_aliases]
"gke_.*_prod_.*" = "PROD"
[character]
success_symbol = "[❯](green)"
error_symbol = "[❯](red)"
The performance trap
The one place I nearly came unstuck: by default a few modules will do real work, and on a network filesystem or a giant repo that can reintroduce the exact lag I was escaping. Starship has command timeouts, and I set command_timeout deliberately low. If a module can't answer quickly, I would rather it say nothing than make me wait. A prompt that's occasionally missing the kube context is fine. A prompt that stalls the shell is the bug I started with.
I also turned off a lot of language detection. I don't need a Node badge to tell me I'm in a Node project; the package.json does that, and the badge cost a stat walk I didn't want. The discipline of a heads-up display applies to performance as much as to attention: if a thing isn't changing my next action, it shouldn't be spending my milliseconds either.
There's a subtler version of this trap on a slow filesystem. A few of these modules check for the existence of marker files to decide whether to show themselves, and on a network mount or an sshfs that "does this file exist" question is a round trip, not a memory lookup. Multiply that by a handful of language detectors firing on every prompt and you've quietly rebuilt the lag you came here to escape, except now it's hidden inside a dependency rather than your own shell functions. The fix is the same either way: disable what you don't use, and put a hard ceiling on how long anything is allowed to take.
Measuring it rather than guessing
I didn't want to trust my own sense of "feels snappier", because that sense is exactly what fooled me into tolerating the old prompt for years. Starship has a built-in timing command that breaks down where the milliseconds go:
starship timings
It prints each module and how long it took, and it's brutally clarifying. The first run showed one module I'd left enabled out of habit eating more time than everything else combined. Off it went. Now the whole prompt renders well under the threshold where a human notices a pause, and I have a number to point at rather than a feeling to defend. If you take one thing from this post, let it be that: measure the prompt the same way you'd measure any other hot path, because that's exactly what it is.
The part I didn't expect
What surprised me is how much calmer the terminal feels. The old prompt was busy in a way I'd stopped consciously registering but was apparently still paying for. The quiet version, where a clean line genuinely means everything is fine and colour only appears when it shouldn't, has changed the texture of the work a little. I trust it. When it goes red I look, because it doesn't cry wolf.
It took me the better part of a decade to build a prompt I was proud of and an afternoon to replace it with one I'd actually want. That's the usual ratio, isn't it.