Pragmatic CLI UX in Bash: Colors, Progress, Tables and Prompts
AI generated
Bash · CLI UX · tput · ANSI · Shell Scripting
Pragmatic CLI UX in Bash
Colors, Progress, Tables and Prompts

Shell scripts that inform the operator instead of confusing them are more maintainable, safer and more pleasant to use. With tput, ANSI codes, spinners, column and printf tables, professional CLI UX emerges without external dependencies, directly in Bash, portable and geared toward robustness.

15 min read tput · ANSI · Spinner · column · printf · select Bash 4.x · 5.x · Linux · macOS

1. Why CLI UX Matters in Bash

The term CLI UX in Bash sounds like a contradiction at first: shell scripts are considered pure tools for developers and system administrators who can handle raw text. In practice, however, deployment scripts, backup routines and maintenance tools run in situations where fast readability decides between success and failure. When a deploy script aborts after 40 minutes with a single line of error text that gets lost in a wall of otherwise identical-looking lines, that costs time, sometimes significant time.

Professional CLI UX in Bash does not mean bringing a full-fledged UI to the shell. It is about making status and errors visually unambiguous, keeping the operator informed about current progress, keeping tables readable and phrasing decision prompts clearly. These are pragmatic improvements with manageable effort that noticeably improve the operation of shell scripts. All the techniques described here work without external dependencies, using tput, ANSI codes, the Bash builtins read and select, and the standard tools printf and column.

The most important ground rule for CLI UX in Bash: status messages belong on stderr (>&2), result data on stdout. This allows further processing in pipes without progress output polluting the result. Spinners and progress bars must be cleared before the final output. Colors should only be emitted when the terminal supports them, never in pipe contexts or when output is redirected to a file.

2. tput: Using Terminal Capabilities Portably

tput is the portable interface to the terminfo database and the recommended method for CLI UX in Bash whenever compatibility across different terminal emulators matters. Instead of using hardcoded ANSI codes, tput queries at runtime which sequences the current terminal supports. That matters especially for scripts that run on a variety of systems, from modern Linux servers with xterm-256color, through plain SSH sessions with TERM=dumb, to CI environments with no terminal at all.

The most important tput commands for CLI UX in Bash: tput bold enables bold text, tput sgr0 resets all attributes, tput setaf N sets the foreground color (0 to 7 for standard colors, 0 to 255 for 256 colors), tput setab N sets the background color. For positioning: tput cup ROW COL moves the cursor, tput el clears to the end of the line, tput civis and tput cnorm hide and show the cursor. These commands are the building blocks for spinners that overwrite a single line without multiplying the output.

A critical aspect of robust CLI UX in Bash: tput writes to stdout. If you capture tput output in variables and then emit it via >&2, you have to make sure the redirect to stderr actually reaches the terminal. In CI environments with TERM=dumb or without a terminal, tput returns empty strings, so the script output stays plain text without errors. That makes tput the preferred choice over direct ANSI sequences for scripts that need to stay maintainable long term.


#!/usr/bin/env bash
# lib/colors.sh - Portable terminal color library using tput
set -euo pipefail

# Initialize colors only if terminal supports them
setup_colors() {
  if [[ -t 2 ]] && [[ -n "${TERM:-}" ]] && tput colors &>/dev/null 2>&1; then
    readonly RED="$(tput setaf 1)"
    readonly GREEN="$(tput setaf 2)"
    readonly YELLOW="$(tput setaf 3)"
    readonly BLUE="$(tput setaf 4)"
    readonly CYAN="$(tput setaf 6)"
    readonly BOLD="$(tput bold)"
    readonly DIM="$(tput dim 2>/dev/null || true)"
    readonly RESET="$(tput sgr0)"
    readonly CURSOR_HIDE="$(tput civis 2>/dev/null || true)"
    readonly CURSOR_SHOW="$(tput cnorm 2>/dev/null || true)"
    readonly ERASE_LINE="$(tput el)"
  else
    # Fallback for non-interactive or dumb terminals
    readonly RED="" GREEN="" YELLOW="" BLUE="" CYAN=""
    readonly BOLD="" DIM="" RESET=""
    readonly CURSOR_HIDE="" CURSOR_SHOW="" ERASE_LINE=""
  fi
}

# Usage helper functions
info()    { printf '%s[INFO]%s  %s\n'  "${CYAN}"   "${RESET}" "$*" >&2; }
ok()      { printf '%s[OK]%s    %s\n'  "${GREEN}"  "${RESET}" "$*" >&2; }
warn()    { printf '%s[WARN]%s  %s\n'  "${YELLOW}" "${RESET}" "$*" >&2; }
error()   { printf '%s[ERROR]%s %s\n'  "${RED}"    "${RESET}" "$*" >&2; }
bold_msg(){ printf '%s%s%s\n'          "${BOLD}"   "$*" "${RESET}" >&2; }

setup_colors
info  "Deployment started"
ok    "Database migrated"
warn  "Legacy configuration found"
error "Connection failed"

3. ANSI Escape Codes: Color and Formatting

Where tput is unavailable or direct control is needed, ANSI escape codes offer precise options for CLI UX in Bash. The basic syntax is \e[Nm or \033[Nm for colors and attributes. The most important codes: \e[0m for reset, \e[1m for bold, \e[2m for dim, \e[31m through \e[37m for standard colors, and \e[38;5;Nm for the 256-color mode. For 24-bit color (true color) the syntax is \e[38;2;R;G;Bm, supported by most modern terminals.

Important for reliable CLI UX in Bash: ANSI codes must always be closed with a reset, otherwise they color all subsequent terminal output as well. Bash's $'...' quoting allows escape sequences directly in string literals: $'\e[32m' instead of the harder to read $(printf '\033[32m'). For cursor positioning, \e[ROW;COLf or \e[ROW;COLH is the direct ANSI alternative to tput cup. With \r (carriage return without newline) the cursor returns to the start of the line without starting a new one, which is the foundation for overwritable status lines.

4. Spinners and Progress Bars

A spinner in Bash is one of the most visible improvements to CLI UX during long-running operations. It signals to the operator that the script is actively working and has not frozen. The implementation uses the carriage return \r to overwrite the current line, and an array sequence of Unicode characters as the frame sequence. The spinner runs as a background process while the actual command executes in the foreground, with a trap that stops the spinner on completion.

Progress bars for known totals follow the same pattern: the progress function computes the percentage and bar length from the current index and total count, writes the line back to the start with \r, and can thus be updated live. Critical for stable CLI UX in Bash: the cursor must be hidden while the progress bar is active and made visible again once it finishes, otherwise the output looks unfinished. A trap on EXIT ensures the cursor reappears even if the script aborts with an error.


#!/usr/bin/env bash
set -euo pipefail

# Spinner running as background process
start_spinner() {
  local message="${1:-Please wait...}"
  local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
  local i=0
  printf '%s' "${CURSOR_HIDE:-}" >&2
  while true; do
    printf '\r%s %s ' "${frames[$((i % ${#frames[@]}))]}"] "$message" >&2
    sleep 0.1
    (( i++ ))
  done
}

stop_spinner() {
  local pid="${1:-}"
  [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
  printf '\r%s\r' "${ERASE_LINE:-}" >&2
  printf '%s' "${CURSOR_SHOW:-}" >&2
}

# Usage: wrap a long-running command
spinner_pid=""
trap 'stop_spinner "$spinner_pid"' EXIT

start_spinner "Deployment running..." &
spinner_pid=$!

sleep 3  # Replace with actual long-running operation

stop_spinner "$spinner_pid"
spinner_pid=""
printf '%s[OK]%s Deployment complete\n' "${GREEN:-}" "${RESET:-}" >&2

# ---- Progress bar for known totals ----
progress_bar() {
  local current=$1 total=$2 label="${3:-Progress}"
  local width=40
  local pct=$(( current * 100 / total ))
  local filled=$(( current * width / total ))
  local bar
  bar="$(printf '%*s' "$filled" '' | tr ' ' '█')$(printf '%*s' "$((width - filled))" '' | tr ' ' '░')"
  printf '\r  %s [%s] %3d%%' "$label" "$bar" "$pct" >&2
  [[ $current -eq $total ]] && printf '\n' >&2
}

files=( /etc/*.conf )
total=${#files[@]}
for i in "${!files[@]}"; do
  progress_bar "$(( i + 1 ))" "$total" "Checking configuration files"
  sleep 0.05  # Simulate work
done

5. Structured Output with column and printf

Tabular output is a central element of professional CLI UX in Bash. The column tool from the util-linux package can turn tab-separated input into aligned columns. With column -t, column widths are calculated automatically; column -s $'\t' specifies the separator. This method suits dynamic data whose column widths are not known in advance, such as file listings, process listings or configuration overviews.

For predictable table layouts, printf with format specifiers is the more precise choice. The format spec %-20s %10s %8s defines left-aligned fields with a fixed width. This is the preferred technique for CLI UX in Bash when tables have a fixed header and rows are generated programmatically, for example in deployment reports, server status overviews or cost breakdowns. Colored headers via ANSI codes or tput make the difference between a plain text dump and a readable status overview.

Another useful tool for CLI UX in Bash is combining printf with \r for single-line, overwritable status lines. Instead of writing every action to a new line, a single status line is updated, similar to how Ansible or Docker Compose shape their output. Once finished, the last status line is replaced with a compact summary so the terminal stays readable after a long run.


#!/usr/bin/env bash
set -euo pipefail

# ---- column-based table with auto-width ----
print_service_table() {
  local -a rows=()
  rows+=("SERVICE\tSTATUS\tPORT\tUPTIME")
  rows+=("nginx\trunning\t80,443\t14d 3h")
  rows+=("mysql\trunning\t3306\t14d 3h")
  rows+=("redis\tstopped\t6379\tn/a")
  rows+=("php-fpm\trunning\t9000\t2d 11h")

  printf '%s\n' "${rows[@]}" | column -t -s $'\t'
}

# ---- printf-based fixed-width table with colors ----
print_deploy_report() {
  local sep="${BOLD:-}$(printf '%0.s─' {1..60})${RESET:-}"
  printf '%s\n' "$sep" >&2
  printf "${BOLD:-}%-28s %12s %10s %8s${RESET:-}\n" \
    "STEP" "STATUS" "DURATION" "CODE" >&2
  printf '%s\n' "$sep" >&2

  local -a steps=(
    "Composer install|${GREEN:-}OK${RESET:-}|42s|0"
    "DB migration|${GREEN:-}OK${RESET:-}|8s|0"
    "Static content|${YELLOW:-}WARN${RESET:-}|120s|0"
    "Cache flush|${GREEN:-}OK${RESET:-}|2s|0"
    "Smoke test|${RED:-}FAIL${RESET:-}|5s|1"
  )

  for step in "${steps[@]}"; do
    IFS='|' read -r name status dur code <<< "$step"
    printf "%-28s %12b %10s %8s\n" "$name" "$status" "$dur" "$code" >&2
  done
  printf '%s\n' "$sep" >&2
}

setup_colors() {
  if [[ -t 2 ]]; then
    RED="$(tput setaf 1)"; GREEN="$(tput setaf 2)"
    YELLOW="$(tput setaf 3)"; BOLD="$(tput bold)"; RESET="$(tput sgr0)"
  else
    RED="" GREEN="" YELLOW="" BOLD="" RESET=""
  fi
}

setup_colors
print_service_table
print_deploy_report

6. Interactive Prompts: read, select and Confirmations

Interactive prompts belong in CLI UX in Bash wherever a script needs a decision from the operator before proceeding with a potentially irreversible action. The Bash builtin read with the option -r -p "Prompt: " variable outputs a prompt message and reads the user's input into a variable. With -t N a timeout can be set after which a default value automatically applies. With -s the input is hidden, useful for passwords and tokens.

The builtin select builds a numbered menu from an array of options and waits for a selection. It is the cleanest method for CLI UX in Bash when the user needs to choose from a fixed list, for example between deployment environments (dev, staging, prod) or between rollback options. A common extension: color-coding the options and validating input with a loop that repeats until a valid choice is made.

7. Log-Level Output with Color and Context

A structured log-level system is one of the most effective improvements for CLI UX in Bash. Instead of undifferentiated echo calls, you implement a logging library with the levels DEBUG, INFO, WARN, ERROR and FATAL that get filtered depending on the configured log level. Each level has a fixed color: DEBUG gray or dimmed, INFO cyan, WARN yellow, ERROR red, FATAL bold red. ISO 8601 timestamps and the calling file name via ${BASH_SOURCE[1]} round out the context.

The combination of color and structured fields lets you scan a scrolling terminal for errors and warnings without reading every line. For CLI UX in Bash in production scripts the rule is: logging functions always write to stderr, never to stdout. This allows the script to be used in pipes (./deploy.sh | jq .) without log lines polluting the JSON output. With LOG_LEVEL=${LOG_LEVEL:-INFO} the level can be controlled externally: DEBUG stays quiet in production and becomes verbose while debugging.


#!/usr/bin/env bash
# lib/logging.sh - Structured log-level output with colors
set -euo pipefail

declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3 [FATAL]=4)
CURRENT_LOG_LEVEL="${LOG_LEVEL:-INFO}"

_log() {
  local level="$1"; shift
  local level_num="${LOG_LEVELS[$level]:-1}"
  local current_num="${LOG_LEVELS[$CURRENT_LOG_LEVEL]:-1}"
  [[ $level_num -lt $current_num ]] && return 0

  local ts
  ts="$(date '+%Y-%m-%dT%H:%M:%S')"
  local caller="${BASH_SOURCE[2]##*/}:${BASH_LINENO[1]}"

  local color=""
  case "$level" in
    DEBUG) color="${DIM:-}"    ;;
    INFO)  color="${CYAN:-}"   ;;
    WARN)  color="${YELLOW:-}" ;;
    ERROR) color="${RED:-}"    ;;
    FATAL) color="${BOLD:-}${RED:-}" ;;
  esac

  printf '%s%s [%-5s] [%s] %s%s\n' \
    "$color" "$ts" "$level" "$caller" "$*" "${RESET:-}" >&2
}

debug() { _log DEBUG "$@"; }
info()  { _log INFO  "$@"; }
warn()  { _log WARN  "$@"; }
error() { _log ERROR "$@"; }
fatal() { _log FATAL "$@"; exit 1; }

# Example usage
info  "Deployment process started for environment: ${DEPLOY_ENV:-unknown}"
debug "Composer version: $(composer --version 2>/dev/null | head -1 || echo n/a)"
warn  "Directory /tmp/deploy already exists, will be overwritten"
error "Database connection failed after 3 attempts"
fatal "Critical error: rollback not possible, manual intervention required"

8. TTY Detection and Non-Interactive Mode

Robust CLI UX in Bash must distinguish between interactive and non-interactive mode. In CI pipelines, cron jobs and pipe contexts, a script must neither wait for user input nor emit color codes that show up as control characters in log files. Detection happens via the Bash conditions [[ -t 0 ]] (stdin is a terminal), [[ -t 1 ]] (stdout) and [[ -t 2 ]] (stderr). With these tests the script automatically switches between interactive mode with colors and prompts, and non-interactive mode with plain text logging.

The variable $TERM tells you the terminal type. TERM=dumb indicates the terminal does not understand escape sequences. The variable $CI, automatically set by GitHub Actions, GitLab CI and other systems, can be used as an additional indicator. For CLI UX in Bash the rule is: always test defensively, never assume a terminal is present. A script that looks great in the terminal but ends up littering log files with escape sequences in CI has worse UX than one with no formatting at all.

9. CLI UX Techniques Compared

The various techniques for CLI UX in Bash have different strengths and use cases. The choice between tput and direct ANSI codes, between column and printf tables, or between interactive prompts and command-line flags depends on the requirements of the specific script.

Technique Use case Portability Recommendation
tput Colors, cursor control, attributes High (terminfo) Preferred for all terminal attributes
Direct ANSI codes 24-bit colors, precise control Medium (xterm/VTE) For modern terminals, with a TTY check
column -t Dynamic tables, variable widths High (util-linux) For file listings and dynamic data
printf tables Fixed reports, colored headers Very high (Bash builtin) For structured deployment reports
Spinner/Progress Long-running operations Terminal required With TTY check and trap cleanup

For most scripts, a library file (lib/ui.sh) that centralizes all UX functions is recommended. The library checks the terminal's capabilities at initialization, sets color variables and exports logging and progress functions. All scripts in a project load the same library with source "$(dirname "${BASH_SOURCE[0]}")/lib/ui.sh". That eliminates code duplication and ensures CLI UX in Bash stays consistent across every script.

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that actually keep the operator informed?

We build Bash scripts with professional CLI UX: structured logging, progress indicators, tables and interactive prompts that stay readable in a terminal and run smoothly in CI pipelines.

UI libraries

Portable color, logging and progress libraries for existing Bash projects

Deploy reports

Structured tabular output for deployment logs and status reports

CI compatibility

TTY detection and plain text fallback for clean output in GitHub Actions and GitLab CI

10. Summary

Professional CLI UX in Bash is not a luxury but an operational necessity for scripts used in production. The techniques described here, tput for portable terminal control, ANSI codes for precise formatting, spinners and progress bars for long-running operations, column and printf for structured tables, and read/select for interactive prompts, can be moved into a shared library and used consistently across every script.

The most important ground rule remains: every UX component must be combined with TTY detection so that scripts in CI pipelines, cron jobs and pipe contexts automatically switch to plain text output. A spinner or a colored progress bar that shows up as unreadable control-character gibberish in a CI log file is worse than no formatting at all. With the approach described here, a centralized library, defensive terminal checks and a clear separation of stdout and stderr, you get CLI UX in Bash that works in every environment.

CLI UX in Bash: The Essentials at a Glance

Colors & tput

Use tput for portable terminal attributes. Set color variables at initialization and fall back to empty strings if no terminal is detected.

Spinner & Progress

Background process with \r overwriting. Hide the cursor while active, use a trap on EXIT for clean cleanup.

Tables

column -t for dynamic widths, printf with format specifiers for fixed reports. Colored headers with BOLD/RESET.

TTY detection

[[ -t 2 ]] before every UX initialization. Automatic plain text fallback for CI, cron and pipe contexts.

11. FAQ: CLI UX in Bash

1tput vs. direct ANSI codes?
tput queries terminfo, so it is more portable and safer. Direct ANSI codes are hardcoded but allow 24-bit colors. For production scripts, prefer tput.
2Preventing color codes in CI logs?
[[ -t 2 ]] before color initialization. If false, set all color variables to empty. Also check $CI and TERM=dumb.
3Safe spinner in Bash?
Background process, store the PID, trap on EXIT for kill plus tput cnorm. Clear the line after completion with \r plus tput el.
4column or printf for tables?
column -t for dynamic widths. printf for fixed reports. printf is a Bash builtin with no external dependency.
5Why messages on stderr?
Pipes stay clean. Only payload data on stdout, logging and progress on stderr, which enables ./deploy.sh | jq . without pollution.
6Reading passwords securely?
read -r -s -p 'Password: ' password, -s suppresses the echo. Print '\n' after input. Do not write it to logs.
7Confirmation with a timeout?
read -r -t 30 -p 'Continue? [y/N]: ' answer. Non-zero exit on timeout. [[ "${answer:-N}" =~ ^[Yy]$ ]] for evaluation.
8What is $'...' quoting?
Allows \n, \t, \e directly inside a string. RED=$'\e[31m' instead of $(printf '\033[31m'). No subshell fork, more readable.
9Cursor state on errors?
trap 'tput cnorm 2>/dev/null; tput sgr0 2>/dev/null' EXIT at the start of the script, runs on normal exit, set -e and signals.
10Making a UI library reusable?
Move it into lib/ui.sh, source "$(dirname "${BASH_SOURCE[0]}")/lib/ui.sh" in every script. Maintain it once, consistent everywhere.