Skip to content
Select theme

How to add a progress bar to a CLI

Short answer: Use indicatif’s ProgressBar. It draws to stderr — the stream the previous step reserves for progress — and hides itself when stderr is not a terminal, so mycli > out.txt keeps the results file clean.

From a fresh project — cargo new progress-demo, then cargo add indicatif — Cargo.toml needs one line:

Cargo.toml
[dependencies]
indicatif = "0.18.6"

ProgressBar::new(4) is a bar with a known length, inc(1) advances it once per item, and finish_and_clear() erases the line when the work is done. The default template is {wide_bar} {pos}/{len}, which stretches to the terminal’s width; {bar:40} is the same bar at a fixed forty cells, so what follows looks the same on any terminal. finish() on its own leaves the cursor at the end of the bar, and the next println! continues on that line — which is why the ending here is finish_and_clear().

In a terminal the bar animates on one line. python3 opens a pseudo-terminal, as the previous step showed, which is what makes the bar visible here:

$ python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'
██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1/4
████████████████████░░░░░░░░░░░░░░░░░░░░ 2/4
██████████████████████████████░░░░░░░░░░ 3/4
████████████████████████████████████████ 4/4
done

Frames are separated by a carriage return and a clear-line escape, which lets a terminal overwrite one line in place; tr '\r' '\n' puts each frame on its own line so they can be shown at all. The blank line after done is the program’s last newline arriving as \r\n, which tr turns back into two. The frame count depends on timing — indicatif redraws at most twenty times a second — but the last frame stays on screen.

The bar never touches stdout: ProgressDrawTarget::stderr() is the default. Pipe stdout and the bar keeps drawing — the mycli | tee log.txt case from the previous step, where the user watches the terminal while the file fills:

$ python3 -c 'import pty; pty.spawn(["sh","-c","cargo run -q > out.txt"])' | tr '\r' '\n'
██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1/4
████████████████████░░░░░░░░░░░░░░░░░░░░ 2/4
██████████████████████████████░░░░░░░░░░ 3/4
████████████████████████████████████████ 4/4

out.txt holds the result and nothing else. The empty line at the end of the capture is the bar’s line after finish_and_clear() erased it. Point stderr at a file instead:

$ python3 -c 'import pty; pty.spawn(["sh","-c","cargo run -q 2>frames.txt"])' | tr '\r' '\n'
done
$ wc -c < frames.txt
0

The bar is gone from the terminal and the file is empty: indicatif hides the bar rather than writing escape codes into something that is not a terminal. Both streams piped, or TERM=dumb on a real terminal, gets the same restraint:

$ cargo run -q 2>&1 | cat
done
$ TERM=dumb python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'
done

bar.println() writes the message padded to the terminal’s width, and the next draw wraps onto the line below — that wrap is what puts the message above the bar. In the capture the two share a line because the stream holds no newline between them:

$ python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'
████████████████████░░░░░░░░░░░░░░░░░░░░ 1/2
halfway ████████████████████░░░░░░░░░░░░░░░░░░░░ 1/2
████████████████████████████████████████ 2/2
all done

With the bar hidden the message is not printed at all — the piped run shows all done and nothing else:

$ cargo run -q 2>&1 | cat
all done

Messages that must always appear belong on eprintln!; bar.suspend(|| eprintln!(…)) also redraws the bar afterwards.

new_spinner() trades the bar for a spinning character, enable_steady_tick advances it on a timer, and finish_with_message leaves the message where the spinner was:

$ python3 -c 'import pty; pty.spawn(["cargo","run","-q"])' | tr '\r' '\n'
⠁ waiting
⠁ waiting
⠉ waiting
⠙ waiting
⠚ waiting
⠒ waiting
⠂ waiting
⠂ waiting
⠒ waiting
⠲ waiting
ready

Which character the first frame shows depends on when the ticker thread first runs; the count and the last line do not. In a pipe there is nothing at all, not even ready — a hidden draw target has nothing to draw:

$ cargo run -q 2>&1 | cat

The next step in this chapter reads a config file, so settings a CLI keeps retyping can live in one place.