Summary
Replace the bash tokenline.sh with a Rust implementation compiled to a single per-platform binary.
This supersedes #6 (benchmark bash vs Node/TS) and #7 (TypeScript / cross-platform implementation): a benchmark only has meaning against a concrete candidate, and Rust — not TS — is the candidate that fits the constraints below.
This is a learning-motivated rewrite as much as a technical one. No rush — the bash + install.sh path keeps working throughout.
Why — the per-second cold-start constraint
The statusline is spawned as a fresh process on every update (~1×/s): the host pipes a JSON snapshot to stdin, the program prints up to three lines and exits. So the metric that matters is cold start (process boot + execution), not throughput.
Rough cold-start ranges for a spawn-every-second tool:
| Option |
Cold start |
Note |
| Rust / Go (compiled binary) |
~1–5 ms |
No runtime to boot. |
| Bun |
~10–20 ms |
Adds a Bun dependency. |
Node .js |
~30–80 ms |
Pays V8 boot every second. |
.ts via tsx/ts-node |
hundreds of ms |
Transpiles on the fly — non-starter. |
| bash + jq |
~20–80 ms |
bash is fast, but every jq is a subprocess. |
Rust wins because it has no runtime to boot — it jumps straight to main(), no V8/JVM/GC warmup. It also lets us drop jq entirely (parse JSON in-process).
Why not stay in bash — the cross-platform reality
"bash is portable" is mostly a myth: the language is portable, the ecosystem around it is not. The current script already carries the scars:
tokenline.sh:44-45 probe GNU vs BSD at runtime (date -d/stat -c vs date -v/stat -f) because macOS ships BSD coreutils.
tokenline.sh:54 uses mapfile (bash 4+); macOS ships bash 3.2, so users must brew install bash.
- Windows has no native bash at all (WSL / Git Bash / Cygwin, each different).
A compiled binary makes cross-platform simpler, not harder: dates, file mtime and JSON are handled in-language with the same code everywhere. One binary per target (x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-pc-windows-msvc, …), no jq, no coreutils-flag minefield.
Prior art / research
The pattern is clear — tools that prioritize raw speed + a single cross-platform binary chose Rust or Go; those that prioritize a config UI/ecosystem chose JS/TS; the one that stayed in shell had to bolt on a C++ daemon to hit its speed target.
Shell prompts (same conceptual pattern — a fast program run on every render):
| Project |
Language |
Note |
| Starship |
Rust |
Cross-shell, ~5–15 ms render, "drop in and go". |
| Oh My Posh |
Go |
Cross-shell, single binary, low-latency. |
| Powerlevel10k |
Zsh + C++ daemon |
Zsh-only; needed gitstatusd (C++) for speed — evidence that pure shell hits a ceiling. |
Claude Code statuslines (our direct neighbours):
CCometixLine is the closest reference — a Claude Code statusline already in Rust; worth studying for Cargo project structure and per-platform binary distribution.
Trade-offs
We gain:
- Near-zero cold start; drop the
jq dependency.
- Cross-platform as a single codebase, not runtime detection.
- A normal multi-file source layout (
src/parse.rs, src/render.rs, …) with first-class unit tests (cargo test). The single-file constraint exists only for bash copy-paste; the binary stays a single artifact.
- Readable code (the bash syntax pain goes away).
We lose / pay:
- "It's one readable text file you can audit" → an opaque binary.
- A release pipeline: a CI matrix build (Linux/macOS/Windows runners), e.g. via
cargo-dist, producing per-target binaries as release assets.
- macOS Gatekeeper can complain about an unsigned binary — but binaries delivered via npm /
curl | sh / Homebrew usually aren't quarantined, so it's typically a non-issue. Signing + notarization ($99/yr Apple Developer ID) is a later option if needed.
Impact on the npm installer
The TypeScript installer (src/cli.ts) stays useful — arguably more so. Its job is install-time: copy the artifact + safely patch settings.json (backup, merge-only, idempotent, --force). That logic is reused as-is. Changes:
- Claude Code's
statusLine runs any command (it just pipes JSON to stdin), so a binary works directly — and drops the wrapping bash process.
init copies the per-platform binary and writes command: <binary> instead of bash <script> (src/commands/init.ts:31).
doctor no longer checks for jq; it checks the binary exists / is executable.
- The npm package becomes the distribution channel for prebuilt binaries (fetch the right target per platform, like
esbuild).
(Optional future: fold the installer into a tokenline init subcommand of the Rust binary and drop Node — lower priority, higher risk; keep the tested TS installer for now.)
Plan — the benchmark is a go/no-go gate
- Minimal Rust prototype: read the same stdin JSON, render the first line (model / context / cache) with colors.
- Benchmark: current
tokenline.sh vs the Rust prototype (cold start on Linux/WSL2). Record the numbers in this issue. ← go/no-go gate.
- Decide, with the data, here.
- Full implementation: port all three lines + the per-turn timestamp/TTL cache,
cargo test coverage, then the CI matrix + installer changes.
Note
The bash path remains first-class throughout — no user is forced onto the binary until the Rust version is at parity and the installer handles distribution.
Supersedes #6 and #7.
Summary
Replace the bash
tokenline.shwith a Rust implementation compiled to a single per-platform binary.This supersedes #6 (benchmark bash vs Node/TS) and #7 (TypeScript / cross-platform implementation): a benchmark only has meaning against a concrete candidate, and Rust — not TS — is the candidate that fits the constraints below.
This is a learning-motivated rewrite as much as a technical one. No rush — the bash +
install.shpath keeps working throughout.Why — the per-second cold-start constraint
The statusline is spawned as a fresh process on every update (~1×/s): the host pipes a JSON snapshot to stdin, the program prints up to three lines and exits. So the metric that matters is cold start (process boot + execution), not throughput.
Rough cold-start ranges for a spawn-every-second tool:
.js.tsvia tsx/ts-nodejqis a subprocess.Rust wins because it has no runtime to boot — it jumps straight to
main(), no V8/JVM/GC warmup. It also lets us dropjqentirely (parse JSON in-process).Why not stay in bash — the cross-platform reality
"bash is portable" is mostly a myth: the language is portable, the ecosystem around it is not. The current script already carries the scars:
tokenline.sh:44-45probe GNU vs BSD at runtime (date -d/stat -cvsdate -v/stat -f) because macOS ships BSD coreutils.tokenline.sh:54usesmapfile(bash 4+); macOS ships bash 3.2, so users mustbrew install bash.A compiled binary makes cross-platform simpler, not harder: dates, file mtime and JSON are handled in-language with the same code everywhere. One binary per target (
x86_64-unknown-linux-gnu,aarch64-apple-darwin,x86_64-pc-windows-msvc, …), nojq, no coreutils-flag minefield.Prior art / research
The pattern is clear — tools that prioritize raw speed + a single cross-platform binary chose Rust or Go; those that prioritize a config UI/ecosystem chose JS/TS; the one that stayed in shell had to bolt on a C++ daemon to hit its speed target.
Shell prompts (same conceptual pattern — a fast program run on every render):
gitstatusd(C++) for speed — evidence that pure shell hits a ceiling.Claude Code statuslines (our direct neighbours):
CCometixLine is the closest reference — a Claude Code statusline already in Rust; worth studying for Cargo project structure and per-platform binary distribution.
Trade-offs
We gain:
jqdependency.src/parse.rs,src/render.rs, …) with first-class unit tests (cargo test). The single-file constraint exists only for bash copy-paste; the binary stays a single artifact.We lose / pay:
cargo-dist, producing per-target binaries as release assets.curl | sh/ Homebrew usually aren't quarantined, so it's typically a non-issue. Signing + notarization ($99/yr Apple Developer ID) is a later option if needed.Impact on the npm installer
The TypeScript installer (
src/cli.ts) stays useful — arguably more so. Its job is install-time: copy the artifact + safely patchsettings.json(backup, merge-only, idempotent,--force). That logic is reused as-is. Changes:statusLineruns any command (it just pipes JSON to stdin), so a binary works directly — and drops the wrappingbashprocess.initcopies the per-platform binary and writescommand: <binary>instead ofbash <script>(src/commands/init.ts:31).doctorno longer checks forjq; it checks the binary exists / is executable.esbuild).(Optional future: fold the installer into a
tokenline initsubcommand of the Rust binary and drop Node — lower priority, higher risk; keep the tested TS installer for now.)Plan — the benchmark is a go/no-go gate
tokenline.shvs the Rust prototype (cold start on Linux/WSL2). Record the numbers in this issue. ← go/no-go gate.cargo testcoverage, then the CI matrix + installer changes.Note
The bash path remains first-class throughout — no user is forced onto the binary until the Rust version is at parity and the installer handles distribution.
Supersedes #6 and #7.