CLI UX best practices: 3 patterns for improving progress displays

Cover for CLI UX best practices: 3 patterns for improving progress displays

Enhance the way your CLIs report progress to users. While there are many developer experience improvements we could make to most command-line apps, I see this one as absolutely essential. If you are creating an app or a shell script for a terminal emulator and you’re pressed for time but can only squeeze in one more improvement, make sure it is how your app displays progress for long-running processes.

No matter how fast computers get, there will always be tasks that will take some noticeable amount of time. Compiling source code might be quick for a small Go application, but can drag for hours for huge applications with C++ codebase. Download times? Those are all about your connection bandwidth. And the list goes on.

In this article, I’ll take a look at three popular user interface patterns for keeping users informed about ongoing processes: the spinner, the X of Y pattern, and the progress bar. We’ll look at the pros and cons of each of them and I’ll share tips for choosing the one that best suits your needs.

Here’s why it matters: control and user diversity

First off, people love feeling in control—it’s a psychological thing. In user interfaces, there’s something satisfying about monitoring actions like code compilation or file downloads. Even if we can’t change the outcome, just seeing the progress gives us a sense of control.

Second, don’t assume command-line apps are only for the stereotypical bearded Linux users. There’s a new generation of developers using your CLIs daily for compiling, building, and deploying apps. And folks who’ve been using laptops and smartphones since they were in diapers expect a lot from software UX.

That’s why it’s crucial for any interface, graphical or command-line, to provide updates on ongoing actions. It’s simply good UX practice. Now, let’s see them in action.

Avoid the silent treatment

Sure, there are cases when a command-line app should be quiet; take a compiler that’s typically run through a build system, like how rustc—the Rust compiler—is done, often executed by cargo. In scenarios like this, it’s fine for a compiler to maintain its relative silence.

But in most other situations, I strongly advise against leaving users staring at a blinking cursor on a dark terminal screen. Instead, make an effort to display meaningful status updates for actions that take time. Additionally, ensure a neat and readable log is left behind once an app has finished its job.

What not to do: steer clear of this behavior in your command-line app

Seriously, take this advice to heart, and treat your users with care by implementing one of the UI patterns suggested here to elevate the user experience of your CLI.

Spinners: for when you’re in the dark

Adding a spinning animation with a status line is a simple way to show that something is happening, especially during a lengthy process.

Simple CLI progress indicator

Here’s an example showing how you could implement a spinner in Go using Charm’s huh? library:

// import ("github.com/charmbracelet/huh/spinner")

_ = spinner.New().
  Title("Saving config file...").
  Action(longOperation).
  Run()

However, the simplicity the spinner offers also has its downsides: a basic spinner doesn’t offer much insight into the progress, and even more critically, it won’t alert the user if the app gets stuck in a loop.

In the command-line realm, a popular solution for the last problem is to program your spinner to update only when a specific action has been completed. For instance, if your app is processing files, the spinner could tick each time it finishes processing another file. This way, the spinner not only shows ongoing activity but also signals if the process has been stuck for too long, thus indicating a potential issue.

So, when is using a spinner your best bet?

  • For one or a few sequential tasks that should wrap up quickly, that is, in just a few seconds.
  • When there’s no extra information to display (more on this in the next section).

The X of Y pattern: ideal when you have data

I’m really keen on making this pattern everyone’s go-to choice. Although there are some cases where it is unfeasible or overly burdensome to gather data, developers can typically access some metrics about the action in progress.

The standard is the X / Y format: display what has been completed (X) and, when feasible, the total that needs to be completed (Y).

Showing (X / Y KB) at the end of the current line provides just enough information to ease our innate anxiety

Implementing this pattern with the uilive Go library is straightforward and only requires a few lines of code.

// import ("github.com/gosuri/uilive")

fmt.Println("Downloading libs...")
writer := uilive.New()
writer.Start()

for lib, count := range libs {
  for i := 0; i <= count; i++ {
    _, _ = fmt.Fprintf(writer, "  %s (%d / %dKB)\n", lib, i, count)
    // time.Sleep(time.Millisecond * 25)
  }
  _, _ = fmt.Fprintf(writer.Bypass(), "✓ %s\n", lib)
}

writer.Stop()

This approach is spot-on for reporting progress. If the numbers are climbing, you’re on track; if they’re static, this means there might be a hiccup somewhere. Further, if you’ve got the Y value, we can help users roughly estimate the remaining wait time. So, why not add a spinner for that extra flair? It might just the finishing touch that makes everything pop!

In short, opt for the X of Y pattern whenever you’re handling step-by-step processes and can measure the progress of each, and make it your standard!

Progress bars: for multiple simultaneous processes

The progress bar really shines when it builds upon the X of Y pattern. And it really takes things up a notch! Users get all the info they had before, plus a visual gauge of progress that helps with ballpark time estimates.

Tracking numbers across multiple lines can get tricky; that’s where visual gauges come in handy

Here’s how multiple simultaneous progress bars could be rendered via the Multi Progress Bar lib:

// import (
//  "github.com/vbauerster/mpb/v8"
//	"github.com/vbauerster/mpb/v8/decor"
// )

fmt.Println("Downloading libs...")
var wg sync.WaitGroup
p := mpb.New(mpb.WithWaitGroup(&wg))
wg.Add(numBars)

for lib, count := range libs {
  name := fmt.Sprintf("  %s", lib)
  bar := p.New(int64(count),
    mpb.BarStyle().Lbound(" ").Filler("█").Tip("█").Padding("░").Rbound(" "),
    mpb.PrependDecorators(
      decor.Name(name, decor.WCSyncSpaceR),
      decor.CurrentNoUnit("%d /", decor.WCSyncSpace),
      decor.TotalNoUnit("%dKB", decor.WCSyncSpace),
    ),
    mpb.AppendDecorators(
      decor.OnComplete(
        decor.EwmaETA(decor.ET_STYLE_GO, 30, decor.WCSyncWidth), "Done",
      ),
    ),
  )

  // simulating some work
  go func() {
    defer wg.Done()
    // ...
  }()
}

p.Wait()

While progress bars are a GUI standard, in the CLI world, they might be overkill. My advice? Think twice before you decide to use a progress bar.

Progress bars are best suited when used for several lengthy, similar processes running in parallel. In these cases, keeping an eye on multiple X of Y numbers can get tricky, and that’s where visual gauges come in handy. If you’re just dealing with a single task or a couple of steps in sequence, it might be better to skip the bars.

Another popular solution to avoid overwhelming users with too many progress bars is to use a single progress bar that reports the overall progress of multiple actions, rather than having one bar for each action.

A single progress bar to show total progress

A single progress bar to show total progress

P. S. Keep the log clean

Before we go into logs just a bit, first off, get in the habit of clearing spinners and progress bars once an action is completed. Most libraries automatically delete them, but just make sure that’s happening.

Second, aim for output that tells a clear, detailed story of the command’s execution: use green colors and checkmark icons to make successful outcomes pop out at a glance.

Check how your app’s standard output stream looks in a text file by redirecting it:

$ app > file.txt

Also, do keep in mind that one command’s output might be another’s input via the pipe operator (|). It’s very likely someone will want to grep through your CLI’s output, so make sure your app doesn’t leave behind any clutter.

And a personal plea: don’t skip updating your status messages from ing’s to ed’s. CLIs often use the gerund form for ongoing actions: download-ing, creat-ing, etc. These make sense during the action, but once it’s done, they’re misleading. The log should switch to past simple or present perfect tense to accurately reflect completed actions. Revisit the video examples in this article—they all adhere to this practice.

I’m fairly confident the CLI world is being neglected because user experience designers aren’t paying enough attention. Startups often pour effort into enhancing web app UX, but CLIs, which are crucial for everyday developer tasks, don’t seem to get the same focus.

Tell us about your command-line app’s goals and challenges, and we’ll offer practical, time-tested advice for your product—all at no cost!