Gum Terminal UX Integration
Integrate Charmbracelet's gum CLI tool into bash scripts for beautiful terminal output, interactive prompts, and structured logging -- with mandatory TTY detection to ensure scripts remain pipe-safe.
When to Use
- User requests interactive prompts (selection menus, confirmations, text input)
- Script needs visually styled output (headers, banners, success/error messages)
- Structured logging is needed (
gum logwith levels and key-value pairs) - Progress indication for long-running operations (spinners)
- User wants a polished CLI experience for their bash tools
- Existing scripts need UX improvements without changing functionality
Prerequisites
- gum is installed: The script must verify gum is available at runtime. This skill provides the detection boilerplate but does NOT handle gum installation -- the script should fail with a clear message directing the user to install gum.
- Bash 3.2+: Gum integration patterns use bash-specific features (
[[ ]]tests, process substitution). None of these patterns need bash-4-only features (associative arrays,mapfile), so they run on macOS's default/bin/bash3.2.
Gum Subcommand Reference
| Subcommand | Purpose | Needs TTY stdin | Output |
|---|---|---|---|
gum input |
Single-line text input | Yes | stdout |
gum write |
Multi-line text editor | Yes | stdout |
gum filter |
Fuzzy filter from list | Yes | stdout |
gum choose |
Select from options | Yes | stdout |
gum confirm |
Yes/no confirmation | Yes | exit code |
gum file |
File picker | Yes | stdout |
gum pager |
Scrollable text viewer | Yes | none |
gum spin |
Spinner during command | No | passthrough |
gum table |
Interactive row picker; formatted display with --print |
Yes (No with --print) |
stdout |
gum style |
Styled text block | No | stdout |
gum format |
Markdown/emoji rendering | No | stdout |
gum log |
Structured log messages | No | stderr |
gum join |
Join styled blocks | No | stdout |
Key distinction: Commands marked "Needs TTY stdin: Yes" require an interactive terminal on stdin. Commands like gum style, gum format, gum log, and gum spin work without a TTY on stdin, but display-oriented ones (gum style, gum format) should still fall back to plain text when stdout is piped.
gum table is interactive by default: with no --print flag it renders a row picker and blocks waiting for a keypress, which hangs any non-interactive script. Always pass --print when you only want formatted output.
gum log is always safe -- it writes to stderr by default.
Workflow: Add Gum Dependency Check
Every script using gum must include this check near the top, before any gum calls.
Step 1: Add Dependency Check Function
Insert this function in the script's function section:
require_gum() {
if ! command -v gum >/dev/null 2>&1; then
echo >&2 "Error: gum is not installed."
echo >&2 "Install it from https://github.com/charmbracelet/gum"
echo >&2 " macOS: brew install gum"
echo >&2 " Linux: see https://github.com/charmbracelet/gum#installation"
exit 1
fi
}
Step 2: Call Early in Main
main() {
require_gum
# ... rest of script
}
Workflow: TTY Detection (Critical)
This is the most important pattern. Scripts must detect whether their stdin/stdout are terminals and fall back to plain text when piped.
Step 1: Define TTY Detection Helpers
Place these near the top of the script, after constants:
# TTY detection -- true when connected to a terminal
is_interactive_stdin() { [ -t 0 ]; }
is_interactive_stdout() { [ -t 1 ]; }
# Master check: can we use fully interactive gum features?
is_interactive() { is_interactive_stdin && is_interactive_stdout; }
Step 2: Define Dual-Mode Input Wrappers
Create wrapper functions that use gum when interactive, and fall back to plain alternatives when piped:
# --- User Input (requires TTY on stdin AND stdout) ---
prompt_input() {
local prompt_text="$1"
local default_val="${2:-}"
if is_interactive; then
gum input --placeholder "$prompt_text" --value "$default_val"
else
if [ -n "$default_val" ]; then
echo "$default_val"
else
read -r reply
echo "$reply"
fi
fi
}
prompt_choose() {
# Args: option1 option2 option3 ...
if is_interactive; then
gum choose "$@"
else
# When piped, use first option as default
echo "$1"
fi
}
prompt_confirm() {
local message="$1"
if is_interactive; then
gum confirm "$message"
else
# When piped, auto-confirm (caller can override with --no-confirm flag)
return 0
fi
}
prompt_filter() {
# Reads options from stdin
if is_interactive; then
gum filter "$@"
else
head -1
fi
}
Step 3: Define Dual-Mode Display Wrappers
Display functions separate UX output (stderr, for humans) from data output (stdout, for pipes):
# --- Display Output (TTY-aware, always to stderr) ---
show_header() {
local text="$1"
if is_interactive_stdout; then
gum style --foreground 212 --border rounded --border-foreground 212 \
--padding "0 2" --bold "$text" >&2
else
echo >&2 "=== $text ==="
fi
}
show_success() {
local text="$1"
if is_interactive_stdout; then
gum format -t emoji ":white_check_mark: $text" >&2
else
echo >&2 "OK $text"
fi
}
show_error() {
local text="$1"
if is_interactive_stdout; then
gum format -t emoji ":x: $text" >&2
else
echo >&2 "ERROR $text"
fi
}
show_warning() {
local text="$1"
if is_interactive_stdout; then
gum format -t emoji ":warning: $text" >&2
else
echo >&2 "WARN $text"
fi
}
Step 4: Rationale for stderr
All display/UX output goes to stderr (>&2) because:
stderris typically still connected to the terminal even when stdout is piped- Data on stdout remains clean for piping to other tools (
jq,grep,awk) gum logalready writes to stderr by default -- this keeps everything consistent- Unix philosophy: scripts that separate data from decoration compose well
Workflow: Structured Logging with gum log
Replace manual echo >&2 logging with gum log for structured, leveled output.
Step 1: Define Log Functions
# gum log writes to stderr by default -- always pipe-safe
log_debug() { gum log --level debug "$@"; }
log_info() { gum log --level info "$@"; }
log_warn() { gum log --level warn "$@"; }
log_error() { gum log --level error "$@"; }
# Structured key-value logging
log_info_kv() {
local msg="$1"; shift
# Remaining args are key value key value ...
gum log --structured --level info "$msg" "$@"
}
Step 2: Usage Examples
log_info "Starting deployment"
log_warn "Config file not found, using defaults"
log_error "Connection to database failed"
log_debug "Parsed 42 records from input"
# Structured logging with key-value pairs
log_info_kv "Deployment complete" env production version "1.2.3" duration "34s"
Step 3: Log Level Control via Environment
Use the GUM_LOG_LEVEL environment variable (built into gum) to control verbosity:
# Users can control log verbosity:
# GUM_LOG_LEVEL=debug ./my-script.sh # Show all logs
# GUM_LOG_LEVEL=warn ./my-script.sh # Only warnings and errors
# GUM_LOG_LEVEL=error ./my-script.sh # Only errors
Document this in the script's --help output.
Workflow: Spinners for Long Operations
Step 1: Use gum spin with TTY Fallback
run_with_spinner() {
local title="$1"; shift
if is_interactive_stdout; then
gum spin --spinner dot --title "$title" -- "$@"
else
log_info "$title"
"$@"
fi
}
Step 2: Usage
run_with_spinner "Downloading artifacts..." curl -sL "$url" -o "$output"
run_with_spinner "Running tests..." make test
run_with_spinner "Building image..." docker build -t myapp .
Available spinner styles: line, dot, minidot, jump, pulse, points, globe, moon, monkey, meter, hamburger. Default to dot for a clean look.
Workflow: Styled Output Blocks
Step 1: Banners and Headers
show_banner() {
if is_interactive_stdout; then
gum style \
--foreground 212 --border double --border-foreground 57 \
--align center --width 50 --margin "1 0" --padding "1 2" \
"$SCRIPT_NAME" "v$VERSION"
else
echo >&2 "$SCRIPT_NAME v$VERSION"
fi
}
Step 2: Tables
show_table() {
# Arg: path to CSV file with header row
# --print is mandatory: without it `gum table` is an interactive row picker
# that blocks on a keypress. Output goes to stderr like every other show_*.
if is_interactive_stdout; then
gum table --print < "$1" >&2
else
column -t -s',' < "$1" >&2
fi
}
Step 3: Joining Blocks
Always quote gum style output to preserve newlines when passing to gum join:
show_summary() {
if is_interactive_stdout; then
local left right
left="$(gum style --border rounded --padding "0 2" \
"Files: $file_count" "Errors: $error_count")"
right="$(gum style --border rounded --padding "0 2" \
"Duration: ${duration}s" "Status: $status")"
gum join --horizontal "$left" "$right" >&2
else
echo >&2 "Files: $file_count | Errors: $error_count | Duration: ${duration}s | Status: $status"
fi
}
Workflow: Environment Variable Customization
Gum supports theming via environment variables with the pattern GUM_<COMMAND>_<OPTION> -- but not every flag has one. gum <command> --help annotates each env-backed flag with its variable name (e.g. --timeout ($GUM_CONFIRM_TIMEOUT)); a flag with no annotation cannot be set from the environment.
Step 1: Define Script Defaults
# Set gum defaults for consistent branding (users can override)
export GUM_INPUT_PLACEHOLDER="${GUM_INPUT_PLACEHOLDER:-Type here...}"
export GUM_SPIN_SPINNER="${GUM_SPIN_SPINNER:-dot}"
# `gum confirm` button labels have NO environment variable -- there is no
# GUM_CONFIRM_AFFIRMATIVE / GUM_CONFIRM_NEGATIVE. Use a wrapper over the
# --affirmative / --negative flags instead.
confirm() {
gum confirm --affirmative "${BRAND_YES:-Yes}" --negative "${BRAND_NO:-No}" "$@"
}
Step 2: Document in Help
Include a section in --help about customizable environment variables:
usage() {
cat <<EOF
...
Environment Variables:
GUM_LOG_LEVEL Minimum level to show: debug, info, warn, error
(default: unset -- every level is shown)
GUM_SPIN_SPINNER Spinner style: dot, line, minidot, pulse (default: dot)
NO_COLOR Disable all color output (standard convention)
EOF
}
Emoji Guidelines
When integrating gum emoji rendering (gum format -t emoji), follow these rules:
Appropriate uses:
- Final status line:
:white_check_mark: All checks passedor:x: 3 errors found - Section separators in verbose output:
:rocket: Deploying... - Summary headers:
:bar_chart: Results
Avoid:
- Every log line (creates visual noise, distracts from content)
- Data output on stdout (breaks parsability)
- Error messages (plain text is clearer for debugging)
- When
NO_COLORis set (respect the convention)
Implementation pattern:
show_status() {
local emoji="$1" plain="$2" text="$3"
if is_interactive_stdout; then
gum format -t emoji ":${emoji}: ${text}" >&2
else
echo >&2 "${plain} ${text}"
fi
}
# Usage
show_status "white_check_mark" "OK" "Build completed"
show_status "x" "FAIL" "Tests failed"
show_status "warning" "WARN" "Deprecated API in use"
Complete Integration Example
A full runnable script combining every pattern above -- TTY detection, the
dependency check, logging, display wrappers, interaction, spinner, and cleanup
trap -- lives in example.sh alongside this file. Read it when the user wants a
complete skeleton to start from; it is not needed for a one-off change like
adding a single spinner.
It re-states the function definitions shown above so it stands alone as a script.
It is shellcheck-clean under set -euo pipefail; keep it that way when editing.
Error Handling
| Condition | Action |
|---|---|
| gum not installed | Abort with install instructions (see require_gum). Never attempt to install gum automatically. |
| Not a TTY (stdout piped) | Skip gum style, gum format, gum spin display; use plain text to stderr. gum log is always safe. |
| Not a TTY (stdin piped) | Skip gum input, gum choose, gum confirm, gum filter, gum write, gum file; use defaults or read. |
NO_COLOR env set |
Respect the standard: skip color flags, emojis, and styled output. |
| gum command fails | Log the error, fall back to plain text equivalent. Never let a gum failure crash the script. |
| User cancels gum prompt (Ctrl+C) | gum returns non-zero; handle in the trap or check exit code and exit gracefully. |