Interactive Menus in Pure Bash with select and PS3 (No dialog or whiptail)
Build robust interactive menus in pure Bash using the select built-in and PS3 prompt, with input validation and quit handling, no dialog or whiptail required.
- #automation
- #ai
- #bash
- #cli
- #shell
Every team eventually inherits the same script: a deployment helper or a maintenance tool that someone wired up to dialog or whiptail, and now it refuses to run on the one minimal container image that doesn’t ship ncurses. The fix is almost always smaller than the bug report. Bash has had an interactive menu primitive built in since the Bourne Again days, and most engineers walk past it because it’s underdocumented and the default prompt looks ugly. That primitive is select, and paired with the PS3 variable it gives you a menu loop that runs anywhere Bash runs, with zero external dependencies.
I lean on AI to draft the first version of these menus because the boilerplate is repetitive and easy to get subtly wrong. But menu code is exactly the kind of thing where you have to read every line: a loop that doesn’t handle invalid input or a missing quit path is how you get a script that traps an on-call engineer at 3 a.m. AI drafts, human verifies, and with select there isn’t much to verify.
The minimal select loop
select builds a numbered menu from a list of words, prints it to stderr, reads a number from the user, and assigns the chosen word to your loop variable. Here is the smallest useful version:
#!/usr/bin/env bash
set -euo pipefail
PS3="Choose an action: "
select action in "Deploy" "Rollback" "Status" "Quit"; do
case "$action" in
Deploy) echo "Running deploy..." ;;
Rollback) echo "Rolling back..." ;;
Status) echo "Checking status..." ;;
Quit) echo "Goodbye."; break ;;
*) echo "Invalid choice: $REPLY" >&2 ;;
esac
done
Two variables do the heavy lifting. PS3 is the prompt string select prints when it asks for input (the default is the cryptic #?, which is half the reason people abandon the built-in). REPLY holds the raw text the user typed, which is what you fall back to in the *) case when someone enters garbage. The menu numbers are generated and managed for you, and the list re-prints automatically whenever the user enters an empty line, so you never have to redraw it yourself.
Validating input and handling EOF
The default loop above has one sharp edge: if the user hits Ctrl-D, select returns a non-zero status and the loop exits silently. With set -e that’s actually fine, but in an interactive tool you usually want to handle EOF explicitly so you can clean up or confirm. Check the return status of the select command itself:
while true; do
if ! select env in "staging" "production" "Cancel"; do
: # body runs per selection
done; then
echo "Input closed, aborting." >&2
exit 1
fi
done
That double-loop reads awkwardly, so in practice I prefer a flatter structure that treats an empty REPLY and an invalid action as distinct cases:
PS3="Select environment (or 0 to cancel): "
options=("staging" "production")
select env in "${options[@]}"; do
if [[ "$REPLY" == "0" ]]; then
echo "Cancelled." >&2
exit 0
elif [[ -z "$env" ]]; then
echo "Please enter a number between 1 and ${#options[@]}." >&2
continue
fi
echo "You picked: $env"
break
done
$env is empty whenever the user types a number outside the menu range or any non-numeric text, so the -z "$env" guard is your single, reliable validation check. You don’t parse the input yourself, you let select tell you whether it mapped to a real option. That property is what makes the built-in genuinely safer than rolling your own read loop, where you’d have to reimplement range checking by hand.
Dynamic menus from command output
Static menus are the easy case. The real wins come when the menu is generated from live state, because that’s where a hand-built read loop turns into a mess of array indexing. Feed select an array and it just works:
#!/usr/bin/env bash
set -euo pipefail
# Build a menu from running containers
mapfile -t containers < <(docker ps --format '{{.Names}}')
if [[ ${#containers[@]} -eq 0 ]]; then
echo "No running containers." >&2
exit 0
fi
PS3="Restart which container? "
select name in "${containers[@]}" "Quit"; do
[[ "$name" == "Quit" ]] && break
if [[ -n "$name" ]]; then
echo "Restarting $name..."
docker restart "$name"
break
fi
echo "Invalid selection." >&2
done
Using mapfile (also spelled readarray) to slurp the command output into an array is the key move; it preserves names with spaces and avoids word-splitting bugs. If you want a refresher on the quoting pitfalls that bite people here, the array-safe quoting prompt walks through the failure modes line by line.
Prompt I used to scaffold this: “Write a pure-Bash function
choose_onethat takes a list of items as arguments, presents them withselectand a customPS3prompt, handles an explicit Quit entry plus invalid input by re-prompting, and echoes the chosen item to stdout (menu to stderr) so it can be captured with command substitution. POSIX-portable where possible, no external tools.” The draft was 90% correct; it forgot to send the menu to stderr, which would have polluted$(choose_one ...). That’s the kind of bug you only catch by reading the output, not the code.
A reusable picker function
Pulling the pattern into a function makes it composable. The trick is routing the menu and prompt to stderr so the function’s stdout carries only the answer:
choose_one() {
local prompt="$1"; shift
local item
PS3="$prompt"
select item in "$@"; do
if [[ -n "$item" ]]; then
printf '%s\n' "$item"
return 0
fi
echo "Invalid choice, try again." >&2
done
return 1 # EOF
}
# Capture the answer
region=$(choose_one "Pick a region: " us-east-1 us-west-2 eu-west-1) \
|| { echo "No region chosen." >&2; exit 1; }
echo "Provisioning in $region"
Because select always writes the numbered list and the PS3 prompt to stderr, the user still sees the menu interactively while $(...) captures only the clean result. That separation is something dialog and whiptail give you through a temp file or a redirected file descriptor; with select it’s the default behavior.
When this beats a TUI library
select is the right tool when the interaction is a short, linear sequence of single-choice prompts: pick an environment, pick a target, confirm. It has no dependencies, it degrades gracefully over SSH and inside minimal containers, and the entire control flow is visible in the script, so a reviewer can reason about every path. For a deeper, scaffoldable version of this pattern, see the select interactive menu prompt, and if you need flag-driven entry points alongside the menu, the getopts long-options parser pairs naturally with it.
Reach for whiptail or a real TUI only when you need checkboxes, gauges, scrolling text, or mouse support, in other words when you’re building an installer, not a helper. For everything between a bare read and a full curses app, the built-in covers it.
The broader lesson is the one that holds across all of these little tools: let AI generate the skeleton, then read it as carefully as you’d review a teammate’s pull request. The menu is short enough that there’s no excuse not to. More patterns like this live in the Bash & Python automation collection.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.