Skip to content

justinhuangcode/termpulse

TermPulse

English | δΈ­ζ–‡

CI Release Crates.io docs.rs License: MIT Rust GitHub Stars Last Commit Issues Platform

A native terminal progress indicator CLI for smart detection, graceful fallback, and zero configuration. πŸ“Š

Why TermPulse?

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.

Features

  • 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 TMUX environment, wraps OSC sequences in DCS passthrough envelope for tmux 3.3+
  • wrap command β€” run any shell command with automatic indeterminate progress; signals done/fail on exit; forwards child exit code
  • pipe command β€” transparent stdin-to-stdout pipe that tracks bytes or lines; shows percentage with --total or 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 ctrlc handler in wrap mode to always clear the progress indicator before exit, even on Ctrl+C
  • NO_COLOR support β€” respects the no-color.org convention; TERMPULSE_FORCE overrides when needed
  • no_std core β€” termpulse-core has 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

Installation

Pre-built binaries (recommended)

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

Via Cargo

cargo install termpulse-cli

From source

git clone https://github.com/justinhuangcode/termpulse.git
cd termpulse
cargo install --path crates/termpulse-cli

Requirements: Rust 1.85+

Quick Start

CLI

# 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 --json

Rust library

use termpulse::Controller;

let mut ctrl = Controller::auto();
ctrl.set(25, "Downloading");
ctrl.set(50, "Downloading");
ctrl.set(75, "Downloading");
ctrl.done("Complete");

Core (no_std)

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\\"

Commands

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)

Global flags

Flag Description
--json Output in JSON format

wrap flags

Flag Default Description
-l, --label Running Label shown during execution
--done-label Done Label shown on success
--fail-label Failed Label shown on failure

pipe flags

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

How It Works

  1. Controller::auto() reads environment variables (TERM_PROGRAM, WT_SESSION, TMUX, etc.)
  2. Detects the terminal and selects the best backend: OSC 9;4, ASCII, or Silent
  3. If inside tmux, wraps OSC sequences in DCS passthrough (\ePtmux;...\e\\)
  4. Throttle engine rate-limits writes to 150ms; deduplicates identical updates
  5. Label sanitizer strips dangerous bytes (ESC, BEL, control chars) before embedding
  6. 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\\)

Architecture

                    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 Support

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)

Environment Variables

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)

Project Structure

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

Design Decisions

  • 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; pipe command 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)

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

Changelog

See CHANGELOG.md for release history.

Acknowledgments

Inspired by osc-progress.

License

MIT

About

A native terminal progress indicator CLI for smart detection, graceful fallback, and zero configuration. πŸ“Š

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors