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.
Table of Contents
- 1. Why CLI UX Matters in Bash
- 2. tput: Using Terminal Capabilities Portably
- 3. ANSI Escape Codes: Color and Formatting
- 4. Spinners and Progress Bars
- 5. Structured Output with column and printf
- 6. Interactive Prompts: read, select and Confirmations
- 7. Log-Level Output with Color and Context
- 8. TTY Detection and Non-Interactive Mode
- 9. CLI UX Techniques Compared
- 10. Summary
- 11. FAQ
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.