Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Bash & Python Automation By James Joyner IV · · 10 min read

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_one that takes a list of items as arguments, presents them with select and a custom PS3 prompt, 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.

Free download · 368-page PDF

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.