GitHub - yamafaktory/whetuu: An opinionated, zero-config status line and history picker for fish, bash and zsh, written in Zig

GitHub

21 min read Original article ↗

🌟 whetū

CI version license

An opinionated, zero-config status line and history picker for fish, bash and zsh, written in Zig.

Two things, not one. whetuu draws the status line above your cursor, and it puts your history on the up arrow.

Both live in one binary, so the picker knows where you are. The up arrow opens on what you ran in this directory, and Ctrl+G switches to all of it. Failed commands are never stored, so you do not scroll past your own typos.

Your own shell history stays exactly where it is. whetuu never reads or rewrites it, and keeps its own store alongside. See History for what the picker does and where it keeps things.

whetū is Māori for "star". A star is the character you type after, using the Nerd Font glyph nf-md-star_face. The binary is installed as whetuu.

Pronounced feh-TOO (/fɛˈtuː/). In Māori wh is an f sound, not a w. The macron in ū makes that vowel long, and a long vowel takes the stress, so it falls on the second syllable. The ASCII name doubles the u to write that same long vowel.

There is nothing to configure. One compiled binary renders one curated status line, the same for everyone. The slowest module runs as a task via std.Io while the rest run alongside it, so a render costs about what its slowest probe costs. See Performance.

Needs a Nerd Font. whetuu draws the git branch, the language logos and the star with Nerd Font glyphs. Most terminal setups already run one, so try whetuu first. If the star and the branch glyph come out as empty boxes, switch your terminal font and they will be there.

A terminal session. The status line tracks the branch, git status and toolchain version. The history picker then filters and runs a command

Website · Install · Performance · Security

Modules

Left to right, each shown only when relevant:

Module Shows
user_host user@host in bold green, only over SSH ($SSH_CONNECTION/$SSH_TTY) or when root, and then in bold red as a warning
directory Current directory, with $HOME collapsed to ~. Keeps the anchor plus as many trailing directories as fit the width (~/…/projects/client)
git branch Branch glyph and current branch (or (detached)), in magenta
git state Any operation underway, in yellow: (rebasing 2/7), (merging), (cherry-picking), (reverting), (bisecting). Read straight from .git, with no extra subprocess
git status [=conflicts $stashes +staged !modified ?untracked ⇡ahead ⇣behind]
language Logo and toolchain version in the brand color, for 39 languages and tools. Detected from a project manifest (Cargo.toml, mix.exs, …), a source file extension (*.odin, *.rkt, …), or an infra marker (flake.nix, Dockerfile, *.tf for Terraform and OpenTofu)
cmd_duration Timer glyph and <time> when the last command ran for 2 s or more
update Cloud glyph and the tag of a newer release, when one has been published. Read from a file, never the network. Absent the rest of the time
character A star, purple by default, or in the language brand color. Turns red after a failed command

Performance

A status line runs before every command, so you pay its cost constantly. Numbers from hyperfine --warmup 40 --runs 400 on a 13th gen i9-13900H, ReleaseFast build, with the toolchain version cache warm, pinned to the performance cores on an otherwise idle machine:

Directory Render For comparison
No repo, no toolchain 1.0 ms ± 0.2
Zig repo, 49 files 2.8 ms ± 0.4 zig version alone: 3.5 ms
Monorepo, 10 000 files 11.0 ms ± 1.8 git status alone: 10.9 ms

Two things do most of the work. The probes overlap, so a render costs about what the slowest one costs rather than the sum of all of them. In the monorepo the whole status line takes about as long as git status on its own.

Outside a repository neither of them runs. whetuu walks up from the current directory looking for the repository, which is a handful of stat calls, and only starts git once it has found one. That walk was already happening to read the operation state and the stash count. Doing it first is what makes a directory with no repository and no project cost a millisecond.

Toolchain versions are also cached, keyed on the binary path, mtime and size. The first render in a project pays for the probe. Later ones read a small file instead. Upgrading a toolchain changes its mtime, which drops the stale entry. What that saves depends on the toolchain. A slow --version call is well worth skipping. A fast one is already hidden behind the git probe running alongside it, which is why the Zig repo above lands within noise of zig version itself.

Reproduce it with:

hyperfine --warmup 40 --runs 400 \
  'whetuu render --shell fish --status 0 --duration-ms 0 --width 100'

Pin the run on a laptop that mixes performance and efficiency cores, with taskset -c 0-11 on Linux or its equivalent. Left to the scheduler, the same measurement spreads across a factor of two and tells you nothing.

A slow repository cannot hang your shell. Both subprocesses are bounded. The git call gets 250 ms and the toolchain probe gets 200 ms. They run at the same time, so the worst case is the larger of the two, not the sum. Given a git that hangs for 30 s, the status line still returns in 257 ms. It simply drops the git segment.

And a status line stays out of git's way. The call passes --no-optional-locks, so git reads the index without refreshing it. It never takes index.lock, which a git commit in another terminal would then fail to take, and it never writes the index back once per command forever.

In a large repository, almost all of that time is git status, and most of that is the scan for untracked files. Speeding it up is git's job, not whetuu's. Turning on git's untracked cache cut git status from 10.6 ms to 6.7 ms on the 10 000 file repository above:

git config core.untrackedCache true

Security

whetuu reads your repository and prints a line. Here is what that involves.

  • The network, in full. Every request whetuu can make is a GET over TLS, to one of three GitHub hosts, of a URL anyone can open in a browser:

    Host What for Sent by
    api.github.com the newest release tag upgrade, upgrade --check
    github.com the release tarball and its SHA256SUMS upgrade
    raw.githubusercontent.com CHANGELOG.md at that tag, to print what changed upgrade, upgrade --check

    whetuu upgrade makes all three, and checks the download against SHA256SUMS before writing anything. whetuu upgrade --check makes the first and the last, and writes the tag to a file, which is how the status line knows to show one. The status line starts that check for itself at most once a day, in a detached process whose output goes to /dev/null, and never asks anything itself. That is the whole of it: no telemetry, no account, no server, and nothing about you or your machine is sent — the requests carry a user agent of whetuu/<version> and nothing else.

  • Every path is one the spec already names. The binary goes in ~/.local/bin, the history store under $XDG_DATA_HOME and the version cache under $XDG_CACHE_HOME. whetuu creates no directory of its own in $HOME. Run whetuu paths to see both data locations, and whether each file exists yet. Uninstall lists what to remove.

  • The installer edits one file, once. It appends an init line to the config of the shell in $SHELL, guarded so a second run changes nothing. Not the config of a shell you do not use. A PATH line joins it only when ~/.local/bin is not already on your PATH. Set WHETUU_NO_MODIFY=1 and it prints them instead.

  • No config file. whetuu has none, so there is no config parser and no format for anything to smuggle through. Running, it writes three files. One is the history store. The others live under ~/.cache/whetuu, or under $XDG_CACHE_HOME when that is set: versions holds toolchain version strings, and release holds the newest release tag and the time it was looked up. Delete either whenever you like.

  • Three subprocesses, and nothing else. git --no-optional-locks status --porcelain=2 --branch -z, and the version command of the detected toolchain (zig version, node --version, …), both bounded and neither run outside a repository or a project. The third is whetuu itself, as whetuu upgrade --check, at most once a day: detached, in a process group of its own, with all three streams on /dev/null so it can never write over your status line. Nothing else is executed.

  • The history store is 0600, set again on every append. Command lines routinely contain paths and secrets. The store lives at ~/.local/share/whetuu/history, or under $XDG_DATA_HOME when that is set. It usually is not set, on macOS or Linux.

  • A leading space keeps a command out of the store. Shells have used this convention for decades:

     curl -H "Authorization: Bearer $TOKEN" https://api.example.com

    This works in fish, zsh and bash. bash needs help, because its history output has already lost the indentation by the time whetuu sees the command. So the bash integration adds ignorespace to your HISTCONTROL and keeps any value you already had. The command then stays out of bash's history too.

  • Anything else is stored in plaintext. Paste a token into a curl without that leading space and the whole line is written to the store, as long as the command succeeds. File permissions are the only protection. Nothing is redacted. Keep secrets in environment variables or a credentials file, as you would with your shell's own history.

    Only commands that exited 0 are stored. Treat that as noise reduction for the picker, not a safeguard. It filters out your typos, not your working curl.

  • A command that is not text is not stored either. Paste an image into your terminal and the shell keeps whatever survived of it on the command line. Run that as part of a line ending in a command that works and the shell reports the whole thing as having exited 0. whetuu drops it rather than fill the picker with a row nothing can run.

One thing to know. The language module picks which toolchain to probe from the files in the current directory. So entering an untrusted repository can make whetuu run something like node --version. It runs the binary your PATH resolves, never one from the repository. If you keep . in your PATH that distinction goes away, and it goes away for every other tool you run too.

Install

Two ways. Neither is more supported than the other.

Download the binary

Prebuilt binaries are on the releases page, with a SHA256SUMS file to verify them:

Platform Target
Linux x86-64 x86_64-linux-musl, static, no runtime dependencies
Linux ARM64 aarch64-linux-musl, static, no runtime dependencies
macOS Apple Silicon aarch64-macos
macOS Intel x86_64-macos
sha256sum -c SHA256SUMS --ignore-missing
tar -xzf whetuu-<version>-<target>.tar.gz
mv whetuu ~/.local/bin/

Then add one line to your shell config, which Shell setup covers. That is the whole thing. The installer below does exactly this and nothing more.

The macOS binaries are unsigned. Download one in a browser and Gatekeeper quarantines it, so the first run fails with "cannot be opened because the developer cannot be verified". Clear the flag once with xattr -d com.apple.quarantine "$(command -v whetuu)". Downloading with curl or wget avoids the attribute entirely.

Run the installer

curl --proto '=https' --tlsv1.2 -fsSL https://yamafaktory.github.io/whetuu/install.sh | sh

It detects your platform, checks the download against the published SHA256SUMS, puts the binary in ~/.local/bin, and adds the init line to the config of the shell in $SHELL. A PATH line joins it only when ~/.local/bin is not already on your PATH, which on most systems it is. Running it twice changes nothing.

Read it first if you would rather not pipe to a shell, or take the download route above instead. The script saves you a uname and a checksum check. It is not a way to verify anything you could not verify yourself, and if this repository were compromised the script would be too.

WHETUU_NO_MODIFY=1 prints the lines instead of writing them. WHETUU_INSTALL_DIR puts the binary somewhere else, and then the shell config is left alone.

Upgrading

The status line tells you when to run it: a cloud glyph and the new version appear once a release is out, and go away once you are on it. To read what is waiting before installing it:

That names the release and prints its changelog entries, and installs nothing.

It replaces the running binary with the newest release, wherever that binary lives, and prints every changelog entry between the two versions. The download is checked against the published SHA256SUMS before anything is written, and the new binary is renamed onto the old one, so an upgrade that fails halfway leaves the one you have running.

whetuu does all of it itself. Your machine needs no curl, no tar and no shell for it. Running shells pick up the new binary on their next command. Open a new one to reload the init script.

Two cases it leaves alone. A binary you cannot write to, which is what a package manager install looks like, so upgrade that the way you installed it. And a build from source, which reports dev and has no release to compare itself with. The installer above upgrades an install too, and running it twice changes nothing.

Uninstall

rm ~/.local/bin/whetuu
rm -rf ~/.local/share/whetuu ~/.cache/whetuu

Then delete the # whetuu block from your shell config. The first line removes the program. The second removes the history store and both caches, which live under the XDG directories rather than next to the binary. Run whetuu paths before you delete anything and it prints all three locations, in case $XDG_DATA_HOME or $XDG_CACHE_HOME moves them on your machine.

From source

Needs Zig 0.17 (dev), see minimum_zig_version in build.zig.zon for the exact nightly:

git clone https://github.com/yamafaktory/whetuu.git
cd whetuu
zig build --release=fast
mv zig-out/bin/whetuu ~/.local/bin/

Other build steps:

zig build test         # run the unit tests
zig build check        # type check only
zig build fmt          # format all source files
zig build run          # build and run without installing

Maintainers: see RELEASING.md for cutting a release.

Shell setup

The installer already did this. This section is for a download or source install, for WHETUU_NO_MODIFY=1, or for a shell whose config it could not find.

Add the matching line to your shell config, then restart the shell. Add ~/.local/bin to your PATH first if it is not there already:

fish~/.config/fish/config.fish

whetuu init fish | source

bash~/.bashrc (needs bash 5 or newer for command timing)

eval "$(whetuu init bash)"

zsh~/.zshrc

eval "$(whetuu init zsh)"

whetuu init <shell> prints the integration script. The shell hook then calls whetuu render … before every command, passing the last exit status, the command duration, and the terminal width.

Run whetuu init <shell> by hand and it prints the line above instead, with the file it belongs in. Several hundred lines of shell answer nothing when you are looking at a terminal. Pipe or substitute it, as the lines above do, and you get the script. whetuu init fish | less reads it.

Usage

Day to day there is nothing to run. The shell hook drives everything, and the history picker is on the up arrow. The full command surface:

Command Does
whetuu Print the command list
whetuu --version Print the version
whetuu init <fish|bash|zsh> Print the shell integration script, meant to be sourced or evaled. Prints the setup line instead when run straight into a terminal
whetuu render Render one status line. Called by the shell hook, not by you
whetuu history Open the interactive history picker
whetuu history add -- <command> Record a finished command. Called by the shell hook
whetuu paths Print where the history store and version cache live, and whether each file exists yet
whetuu upgrade Replace the running binary with the newest release, and print what changed
whetuu upgrade --check Say what release is waiting and what changed in it, and install nothing. Writes the tag down, which is what the status line reads. whetuu runs this for itself once a day

render and history add take flags that only the init scripts pass, namely exit status, duration and width. That is why they are left out here.

whetuu paths marks a file that is not there yet rather than hiding it. A fresh install has none of them until the first command is recorded, the first toolchain version is cached, and the first release check runs. With neither $HOME nor the matching XDG variable set it says so, because then whetuu has nowhere to write.

History

whetuu keeps its own command history. It is one file, shared by all three shells, at ~/.local/share/whetuu/history. It moves under $XDG_DATA_HOME when that variable is set. macOS uses the same path rather than ~/Library, so the store stays put when you share a dotfiles setup across machines.

Nothing is ever deleted from it. The picker reads the most recent few megabytes rather than the whole file, so it opens just as fast on a store built over years as on a fresh one. Everything you have run stays on disk either way, and on a store that large the commands past the window are ones you last ran years ago. How often you run a command is counted over that same window, so what breaks a tie between two equally good matches is how often you run it lately.

Your shell's own history file is untouched. whetuu never reads, writes or truncates ~/.bash_history, ~/.zsh_history or fish's database. The two stores run side by side. Delete the whetuu store and your shell history is exactly as it was.

Two things it does take. The up arrow, which all three integrations bind to the picker. And on bash only, ignorespace is added to your HISTCONTROL, keeping whatever value you already had, so a space prefixed command stays out of both stores. Ctrl+R and everything else your shell gives you keep working.

On bash the status line is drawn from a PROMPT_COMMAND hook. That is a list the shell shares with everything else that wants to run each prompt, so whetuu joins the front of it rather than replacing it. A terminal's own shell integration and tools like direnv keep working.

A command is recorded once it finishes, and only when it exited with status 0. Typos and failed runs never enter the store. Prefix a command with a space to keep it out of the store entirely. Every command is stored together with the directory it ran in.

The command that just broke is not lost. When a command does not exit 0, it appears at the top of the picker, in red. Pick it to fix and run it again. Cancel and it is still there the next time you open the picker. It lives in memory until you run another command, and never reaches the store.

All three integrations bind the up arrow to the picker. Anything already typed on the command line carries over into the search field. The picker opens on the current directory's history, which is the set of commands you actually run in this project. It falls back to all history when the directory has none yet. A bar at the top names both scopes and highlights the active one, like ~/dev/whetuu | all.

  • type to filter — the letters have to appear in order, not next to each other, so gcm finds git commit -m. Every word of the search must match, ignoring case
  • Backspace / Delete — drop the last character of the search. The cursor never leaves the end of it, so the two keys do the same thing
  • Ctrl+W / Alt+Backspace — drop the last word of the search. A word here is what the search itself splits on, so one press takes one search word
  • ↑ / ↓ — move the selection, where ↑ goes further back in time
  • Home / End — jump to the newest command or to the oldest one on the list
  • Ctrl+G — switch between this directory's history and all history
  • Enter — run the selected command. A search that matches nothing runs as typed
  • Tab — put the selected command on your shell's command line without running it, so you can edit it there before you press Enter
  • Esc / Ctrl-C — cancel, leaving whatever you had typed on the command line

The picker behaves the same in all three shells.

Tab is for the command that is almost right. It closes the picker and hands the command to your shell, which means you edit it with the editor you already know. Arrow keys, word jumps, your own key bindings and fish's syntax highlighting all work, because at that point the line is an ordinary command line. whetuu is out of the way. Press Enter to run it, or Ctrl-C to drop it.

The list grows upward from the bottom. The most recent command sits just above the search line and older ones climb from there. Type and the closest match takes that bottom row instead, with weaker ones climbing away from it. Each row is prefixed with how long ago it ran, like 5m, 2h or 3d. The selected row is highlighted across the full width in the star purple of the status line.

Matches are ranked by how well they fit what you typed. Letters found in a run, at the start of a word, or at the front of the command are worth more than the same letters found scattered through the middle. So gcm puts git commit -m above git checkout main, and both above a command that merely happens to contain a g, a c and an m.

When two commands fit equally well, the one you run more often and more recently comes first. That is only ever a tiebreak. A command that fits what you typed better is always shown above one that fits it worse, however long ago you last ran it.

An empty search is not ranked at all. Open the picker and the list is in the order you ran things, newest first, so the up arrow still lands on the command you just ran. Nothing you run often can push it off that row.

Searching stays fast on a store of any size. Commands are scored several at a time using the vector instructions your processor already has, and a search is narrowed by which characters a command contains before any of it is scored. Against a full window of about 50 000 commands a keystroke costs 1 to 3 ms on the machine the Performance numbers come from, which is roughly a fifth of what the same code costs scoring one command at a time.

Commands are syntax highlighted. The program name, flags, paths, variables, quoted strings and operators each get their own color, so a long row reads at a glance. The colors come from your terminal theme rather than from whetuu, so the picker matches the palette you already run. The selected row switches to lighter tints of the same colors, which stay readable on the purple.

Paths are recognized by how they are written, like /tmp/out, ./build or ~/dev. A bare src stays plain. whetuu never touches the filesystem to render a row, so it cannot know that one is a directory.

A command wider than the terminal loses its middle to a rather than its end. Both the program name and the tail stay on screen. That is what keeps a run of commands sharing one long prefix apart, like several cd <long path> && git … entries that differ only in the part a plain cut would drop.

Rows are drawn on one line. Runs of spaces, tabs and newlines each collapse to a single space, so a command written across several lines stays readable in the list. This changes the row only. Enter and Tab both give you back the command exactly as it was recorded.

The picker draws on /dev/tty, so nothing but the chosen command reaches stdout. Duplicates are collapsed per directory, so the same command run in two projects keeps its own recency in each.

License

MIT