Building Bash TUI Menus with dialog and whiptail
Not every ops tool needs a web UI. A dialog-based menu turns a pile of bash scripts into something a tired teammate can run at 3am without memorizing flags.
- #bash
- #python
- #dialog
- #whiptail
- #tui
- #automation
Some of the most-used tools I’ve built were never web apps. They were bash scripts with a blue-and-grey text menu, run over SSH on a bastion host, that let an on-call engineer pick “restart the worker pool” or “rotate the cache” from a list instead of remembering an exact incantation. That’s what dialog and whiptail are for: turning raw scripts into a TUI that’s hard to fumble.
It’s an old technique — Debian’s installer is built on it — but it’s exactly right for internal ops tooling. No dependencies beyond a single package, runs anywhere there’s a terminal, and it makes destructive operations require a deliberate selection instead of a hastily-typed flag.
dialog vs whiptail
They’re near-drop-in compatible. whiptail ships by default on Debian/Ubuntu (it’s part of the installer tooling); dialog is more feature-rich and common on RHEL-family systems. Both speak the same basic widget vocabulary — menus, message boxes, input boxes, checklists, gauges. Write to the common subset and your script runs with either. I’ll use dialog in examples; swap the binary name for whiptail and most of it just works.
# pick whichever is installed
TUI=$(command -v dialog || command -v whiptail)
The key trick: output comes back on stderr
This trips up everyone the first time. dialog draws its UI on the screen (stdout) and returns your selection on stderr. So you redirect stderr to capture the choice, usually swapping the two streams:
choice=$(dialog --clear \
--title "Ops Console" \
--menu "Choose an action:" 15 50 4 \
1 "Show service status" \
2 "Restart worker pool" \
3 "Rotate cache" \
4 "Tail application logs" \
2>&1 >/dev/tty)
That 2>&1 >/dev/tty is the magic: send dialog’s drawing to the real terminal, and route its stderr (your selection) into the $() capture. Forget it and you’ll capture the UI escape codes instead of the answer.
The exit code tells you whether they confirmed or cancelled:
if [[ $? -ne 0 ]]; then
clear; echo "Cancelled."; exit 0
fi
A complete menu loop
Here’s the shape of a real tool — a menu that loops until the user quits, with each action in its own function:
#!/usr/bin/env bash
set -euo pipefail
show_status() {
systemctl status myapp --no-pager > /tmp/out.$$ 2>&1 || true
dialog --title "Status" --textbox /tmp/out.$$ 22 76
rm -f /tmp/out.$$
}
restart_pool() {
dialog --title "Confirm" --yesno \
"Restart the worker pool? This drops in-flight jobs." 8 50 || return
systemctl restart myapp-worker
dialog --msgbox "Worker pool restarted." 6 40
}
while true; do
choice=$(dialog --clear --title "Ops Console" \
--menu "Choose an action:" 15 50 5 \
1 "Show service status" \
2 "Restart worker pool" \
3 "Quit" \
2>&1 >/dev/tty) || break
case "$choice" in
1) show_status ;;
2) restart_pool ;;
3) break ;;
esac
done
clear
The --yesno guard before the restart is the whole point of building a TUI for ops: a destructive action becomes a deliberate two-step confirmation instead of one fat-fingered command. The --textbox widget is perfect for showing command output — it’s scrollable, so long status dumps are readable.
Widgets worth knowing
--inputbox— free text, returned on stderr like menus. For “enter the hostname.”--passwordbox— same, but masks input. Don’t log what you capture.--checklist/--radiolist— multi-select and single-select with on/off defaults. Great for “which hosts to act on.”--gauge— a progress bar fed by piping percentages into it:
( for pct in 0 25 50 75 100; do echo $pct; sleep 0.4; done ) \
| dialog --gauge "Deploying..." 8 50 0
--tailbox— live-follows a growing file, ideal for watching a log during an operation.
Keeping it maintainable
A few habits keep these tools from rotting:
- One function per action. The menu just dispatches. Each action is independently testable by calling the function directly.
set -euo pipefailat the top, but be deliberate — dialog returning non-zero on Cancel is expected, so handle those exit codes with|| returnrather than letting the script abort.- Always
clearon exit. Otherwise you leave the terminal in a half-drawn state. - Confirm before anything destructive, and make the confirmation message state the blast radius (“drops in-flight jobs”), not just “Are you sure?”.
- Detect the binary rather than hardcoding
dialog— the same script then runs on both Debian and RHEL boxes.
When to reach for something else
A dialog menu is perfect for a fixed set of operator actions on a single host. When you need rich layouts, live-updating panels, or real interactivity, that’s where Python TUIs (Textual, urwid) earn their keep. But for “give my teammates a safe, discoverable front-end to these ten scripts,” dialog is faster to build and has zero runtime you have to install or trust.
The goal isn’t a pretty interface. It’s making the safe path the easy path, so a half-asleep engineer picks the right action from a list instead of guessing at flags.
For more practical shell patterns, browse the Bash & Python automation guides, or grab a prompt to scaffold your own console.
TUI scripts still run real commands. Gate destructive actions behind explicit confirmations and test the cancel paths before handing the tool to your team.
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.