English | δΈζ
A native terminal progress indicator CLI for smart detection, graceful fallback, and zero configuration. π
Build tools, CI pipelines, and download scripts need to show progress. But terminal progress is harder than it looks:
- No standard API β OSC 9;4 is the closest thing, but every terminal supports it differently
- Silent failures β emit the wrong escape sequence and you get garbage in the output
- No fallback β if the terminal doesn't support OSC, your progress just vanishes
Existing solutions don't solve this:
| termpulse | osc-progress | Raw escape codes | |
|---|---|---|---|
| Language | Rust (with CLI) | TypeScript (library only) | Any |
| Terminal detection | 10+ terminals, auto-detect | 3 terminals, manual | None |
| Graceful fallback | OSC > ASCII > Silent | None | None |
| tmux / screen | DCS passthrough | No | Manual wrapping |
NO_COLOR respected |
Yes | No | Manual |
| Ctrl+C cleanup | Yes | No | Manual |
| Throttle / dedup | Built-in 150ms | Manual | Manual |
| ETA estimation | EMA algorithm | No | Manual |
| Label injection safety | Sanitized | No | Manual |
| Sequence parser | Full round-trip | No | Manual |
no_std core |
Zero deps, WASM-ready | No | N/A |
termpulse detects the terminal, picks the best output method, and falls back gracefully. You call termpulse set 50 and it just works.
- Zero-config detection β auto-detects Ghostty, WezTerm, iTerm2, Kitty, Windows Terminal, VS Code, ConEmu, Contour, foot, Rio, and more
- Three-tier fallback β OSC 9;4 native progress > ASCII progress bar on stderr > silent mode; never crashes, never corrupts output
- tmux DCS passthrough β detects
TMUXenvironment, wraps OSC sequences in DCS passthrough envelope for tmux 3.3+ wrapcommand β run any shell command with automatic indeterminate progress; signals done/fail on exit; forwards child exit codepipecommand β transparent stdin-to-stdout pipe that tracks bytes or lines; shows percentage with--totalor indeterminate counter without- Throttle and dedup engine β rate-limits backend writes to 150ms intervals; deduplicates identical state; passes state and label changes immediately
- ETA estimation β exponential moving average (EMA) algorithm with configurable alpha; human-readable display capped at 24 hours
- Label sanitization β strips ESC, BEL, C1 ST, and control characters from labels at zero cost; prevents OSC escape injection
- Signal handling β installs
ctrlchandler inwrapmode to always clear the progress indicator before exit, even on Ctrl+C NO_COLORsupport β respects the no-color.org convention;TERMPULSE_FORCEoverrides when neededno_stdcore βtermpulse-corehas zero dependencies,#![no_std],forbid(unsafe_code); works in embedded, WASM, and FFI contexts- Dependency injection β all I/O goes through traits (
Backend,EnvLookup,Write); 111 tests with full mock coverage
Download from GitHub Releases:
| Platform | Archive |
|---|---|
| Linux x86_64 | termpulse-x86_64-unknown-linux-gnu.tar.gz |
| Linux aarch64 | termpulse-aarch64-unknown-linux-gnu.tar.gz |
| macOS x86_64 | termpulse-x86_64-apple-darwin.tar.gz |
| macOS Apple Silicon | termpulse-aarch64-apple-darwin.tar.gz |
| Windows x86_64 | termpulse-x86_64-pc-windows-msvc.zip |
cargo install termpulse-cligit clone https://github.com/justinhuangcode/termpulse.git
cd termpulse
cargo install --path crates/termpulse-cliRequirements: Rust 1.85+
# Set progress to 50%
termpulse set 50 -l "Building"
# Indeterminate spinner
termpulse start -l "Compiling"
# Wrap a command β shows progress, forwards exit code
termpulse wrap -- cargo build --release
# Pipe with progress tracking
curl -sL https://example.com/file.tar.gz \
| termpulse pipe --total 104857600 -l "Downloading" \
> file.tar.gz
# Signal completion
termpulse done -l "Build complete"
termpulse fail -l "Build failed"
# Detect terminal capabilities
termpulse detect --jsonuse termpulse::Controller;
let mut ctrl = Controller::auto();
ctrl.set(25, "Downloading");
ctrl.set(50, "Downloading");
ctrl.set(75, "Downloading");
ctrl.done("Complete");use termpulse_core::{OscSequence, ProgressState, Terminator};
let seq = OscSequence::normal_with_label(50, "Building");
let mut buf = [0u8; 256];
let n = seq.write_to(&mut buf).unwrap();
// buf[..n] = b"\x1b]9;4;1;50;Building\x1b\\"| Command | Description |
|---|---|
set <percent> [-l label] |
Set progress percentage (0-100) |
start [-l label] |
Start indeterminate progress |
done [-l label] |
Signal success (100% then clear) |
fail [-l label] |
Signal failure (error state then clear) |
wrap -- <command...> |
Wrap a shell command with progress |
pipe [--total N] [--lines] |
Pipe stdin to stdout with progress |
clear |
Clear/remove the progress indicator |
detect |
Show terminal capabilities |
completions <shell> |
Generate shell completions (bash, zsh, fish, powershell, elvish) |
| Flag | Description |
|---|---|
--json |
Output in JSON format |
| Flag | Default | Description |
|---|---|---|
-l, --label |
Running |
Label shown during execution |
--done-label |
Done |
Label shown on success |
--fail-label |
Failed |
Label shown on failure |
| Flag | Default | Description |
|---|---|---|
-t, --total |
β | Total expected bytes (enables percentage) |
--lines |
false |
Count lines instead of bytes |
--buffer-size |
8192 |
Read buffer size in bytes |
-l, --label |
Piping |
Label shown during piping |
Controller::auto()reads environment variables (TERM_PROGRAM,WT_SESSION,TMUX, etc.)- Detects the terminal and selects the best backend: OSC 9;4, ASCII, or Silent
- If inside tmux, wraps OSC sequences in DCS passthrough (
\ePtmux;...\e\\) - Throttle engine rate-limits writes to 150ms; deduplicates identical updates
- Label sanitizer strips dangerous bytes (ESC, BEL, control chars) before embedding
- On done/fail, emits final state and clears the indicator
termpulse set 50 -l "Building"
|
v
detect terminal (env vars)
|
v
select backend (OSC / ASCII / Silent)
|
v
throttle + dedup (150ms, skip identical)
|
v
sanitize label (strip ESC/BEL/control)
|
v
emit to stderr (\x1b]9;4;1;50;Building\x1b\\)
Cargo Workspace
+------------------+ +------------------+ +------------------+
| termpulse-core | | termpulse | | termpulse-cli |
| (no_std, 0 dep) |--->| (library, 1 dep) |--->| (binary, 5 dep) |
+------------------+ +------------------+ +------------------+
| OscSequence | | Controller | | set / start |
| ProgressState | | detect() | | done / fail |
| find_sequences() | | Backend trait | | wrap / pipe |
| sanitize_label() | | OscBackend | | clear / detect |
| strip_sequences()| | TmuxBackend | | |
| | | AsciiBackend | | |
| | | SilentBackend | | |
| | | Throttle | | |
| | | Estimator | | |
+------------------+ +------------------+ +------------------+
Core narrow, outer wide β the inner crate has maximum constraints (no_std, zero dependencies, forbid(unsafe_code)) while outer crates progressively add capabilities:
| Crate | no_std |
Dependencies | Purpose |
|---|---|---|---|
termpulse-core |
Yes | 0 | OSC 9;4 build, parse, sanitize, strip |
termpulse |
No | 1 (termpulse-core) | Detection, backends, throttle, ETA |
termpulse-cli |
No | 5 (anyhow, clap, ctrlc, serde, serde_json) | CLI binary |
| Terminal | Detection method | Support |
|---|---|---|
| Ghostty | TERM_PROGRAM=ghostty |
OSC 9;4 native |
| WezTerm | TERM_PROGRAM=wezterm |
OSC 9;4 native |
| iTerm2 | TERM_PROGRAM=iTerm.app |
OSC 9;4 native |
| Kitty | TERM_PROGRAM=kitty |
OSC 9;4 native |
| Windows Terminal | WT_SESSION env var |
OSC 9;4 native |
| VS Code Terminal | TERM_PROGRAM=vscode |
OSC 9;4 native |
| ConEmu | ConEmuPID env var |
OSC 9;4 native |
| Contour | TERM_PROGRAM=contour |
OSC 9;4 native |
| foot | TERM=foot* |
OSC 9;4 native |
| Rio | TERM_PROGRAM=rio |
OSC 9;4 native |
| tmux | TMUX env var |
DCS passthrough |
| Other TTY | Is a TTY | ASCII fallback [====> ] 50% |
| Non-TTY (pipe, file) | Not a TTY | Silent (no output) |
| Variable | Effect |
|---|---|
TERMPULSE_FORCE=1 |
Force OSC mode regardless of detection |
TERMPULSE_DISABLE=1 |
Disable OSC, use ASCII fallback or silent |
NO_COLOR |
Avoid escape sequences, use ASCII fallback (no-color.org) |
termpulse/
βββ Cargo.toml # Workspace root (shared deps, lints, metadata)
βββ rust-toolchain.toml # Pins stable + rustfmt + clippy
βββ .github/workflows/ci.yml # CI: test, clippy, fmt, doc (Linux/macOS/Windows)
βββ crates/
β βββ termpulse-core/ # no_std, zero deps
β β βββ src/
β β βββ osc.rs # OscSequence, ProgressState, Terminator
β β βββ parse.rs # find_sequences() β zero-alloc parser
β β βββ sanitize.rs # sanitize_label() β injection prevention
β β βββ strip.rs # strip_sequences() β remove OSC from text
β βββ termpulse/ # Main library
β β βββ src/
β β βββ controller.rs # High-level Controller API
β β βββ detect.rs # Terminal + multiplexer detection
β β βββ throttle.rs # 150ms rate limiter + dedup
β β βββ estimate.rs # ETA estimation (EMA algorithm)
β β βββ backend/ # OSC, tmux, ASCII, silent backends
β βββ termpulse-cli/ # CLI binary
β βββ src/cmd/ # set, start, done, fail, wrap, pipe, clear, detect
β βββ tests/cli_integration.rs # 20 integration tests (assert_cmd)
βββ CHANGELOG.md
βββ CONTRIBUTING.md # Contribution guidelines
βββ AGENTS.md # Developer guidelines
βββ LICENSE # MIT
βββ README.md
- OSC 9;4 over custom protocols β OSC 9;4 is the widest-supported terminal progress protocol, originated by ConEmu and adopted by Ghostty, WezTerm, iTerm2, Kitty, Windows Terminal, and others
- stderr, not stdout β progress output goes to stderr so it never corrupts piped data;
pipecommand passes stdin to stdout untouched - Best-effort writes β write errors to the terminal are silently ignored; progress is informational, not critical
- 150ms throttle β balances visual smoothness with terminal performance; state and label changes bypass the timer
- Conservative multiplexer support β tmux passthrough is enabled (well-supported since tmux 3.3+); GNU screen passthrough is disabled (too unreliable across versions)
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
See CHANGELOG.md for release history.
Inspired by osc-progress.