Dropping Starship for plx
When I open-sourced plx yesterday, it was still a Starship plugin. Three subcommands (path, git, tmux-title) that rendered powerline segments as raw ANSI, hooked into Starship via [custom] modules. Starship handled the rest: exit status, command duration, background jobs, the prompt character, and the shell integration that wires it all together.
That’s a lot of machinery to render a $ .
Why Remove Starship
Starship was doing two things for me. First, shell integration: registering precmd and preexec hooks, capturing $? and elapsed time, setting PROMPT. Second, rendering the segments I wasn’t handling myself: the error indicator, duration, job count, and prompt character.
The shell integration is about 15 lines of zsh. The remaining segments are trivial compared to git status parsing. And Starship’s overhead (its own binary startup, config parsing, spawning subshells for each custom module) was the dominant cost in prompt rendering. I was paying for a framework to host four format strings.
What Changed
plx now renders the entire prompt in a single invocation. One binary call, no subshells, no config file. The shell just passes four values:
PROMPT="$(plx prompt 20 $exit_status $duration_ms $job_count) "
The prompt subcommand chains every segment internally:
username → hostname → nix-shell → path → git → status → duration → jobs → character → reset
Each segment follows the same pattern: take the current background color, render content, return the next background color. The git segment transitions from path’s dark grey to its own green or pink. The status badge (when exit code is non-zero) pops out as a red powerline segment. Duration and job count render as inline yellow text on the current background. No arrows, no transitions, just information. The prompt character is white on success, red on failure. A final reset segment draws the closing arrow and clears all attributes.
The git segment was the interesting refactor. It previously ended every path with a reset escape, because it was the last thing in the chain. Now it just transitions to bg(236) and leaves the reset to whatever comes after it. The standalone plx git subcommand still works. It appends its own reset.
Shell Integration
plx init zsh outputs the hooks:
_plx_preexec() { _plx_cmd_start=$EPOCHREALTIME }
_plx_precmd() {
local exit_status=$?
local duration_ms=0
if [[ -n "$_plx_cmd_start" ]]; then
duration_ms=$(( ($EPOCHREALTIME - _plx_cmd_start) * 1000 ))
duration_ms=${duration_ms%.*}
unset _plx_cmd_start
fi
local job_count=${(%):-%j}
PROMPT="$(plx prompt 20 $exit_status $duration_ms $job_count) "
}
autoload -Uz add-zsh-hook
add-zsh-hook precmd _plx_precmd
add-zsh-hook preexec _plx_preexec
preexec records the time before a command runs. precmd calculates the duration, grabs the exit status and job count, and calls plx. The 20 is the max directory name length for path truncation.
One thing I got wrong on the first pass: raw ANSI escapes in PROMPT break zsh’s line length calculation. The cursor jumps around because zsh counts escape sequences as visible characters. The fix is wrapping every \x1b[...m sequence in %{...%}, which tells zsh “this is non-printing, don’t count it.” plx does this wrapping automatically when rendering prompt output.
Performance
This is the part I was most curious about. plx replaces both Starship and powerline-go, so the relevant comparison is against powerline-go with equivalent modules:
| Prompt | Mean | Min | Relative |
|---|---|---|---|
| plx | 9.1ms | 5.9ms | 1.0x |
| powerline-go | 37.4ms | 28.1ms | 4.1x slower |
Measured with hyperfine --warmup 10 --runs 100 on the same repo. Both rendering the full segment chain: username, hostname, nix-shell, path, git, exit status, duration, jobs, prompt character.
The 4x gap comes from three things. Go’s runtime startup is heavier than Rust’s. powerline-go shells out to git for status; plx uses libgit2 directly. And powerline-go resets ANSI attributes between every segment transition, while plx only sets what changes.
For context, the Starship migration post benchmarked Starship + plx custom modules at 36ms. That included Starship’s own startup, config parsing, and spawning bash subshells for each custom module. Removing Starship from the loop cut prompt rendering by 75%.
Visual Comparison
The clean repo case (no errors, no long commands, no background jobs) is pixel-identical to powerline-go. Same colors, same arrows, same segment order.
The error case is intentionally different. powerline-go renders exit status as a pink badge with a text label (ERROR, SIGINT). plx uses a red badge with the numeric code (1, 130). Duration and jobs are inline text instead of full powerline segments. The prompt character changes foreground color on error instead of background. These felt like reasonable simplifications. Less visual noise for information I glance at rather than study.
Dotfiles Changes
The Starship config, the starship.toml with all the custom module definitions, is now dead code. The shell init went from eval "$(starship init zsh)" to eval "$(plx init zsh)". The dotfiles flake pins plx from GitHub and nix-darwin puts it on PATH.
inputs.plx.url = "github:mmichie/plx";
Starship is still installed. Other tools reference it, and removing it is a separate cleanup. But it no longer touches my prompt.
The Arc
This has been a longer journey than I expected. It started with powerline-go, a Go binary someone else wrote. I migrated to Starship with bash helper scripts, then rewrote those in Rust for speed. Split the Rust code into its own repo. Moved everything to Nix for cross-platform builds. And now removed the last external dependency from prompt rendering entirely.
The whole thing is about 600 lines of Rust across a handful of modules. It renders a terminal prompt. Sometimes the right amount of code for a problem is exactly as much as it takes and no more.
The source is at github.com/mmichie/plx.