← BACK TO ENGINEERING
Runtime 8 min read

Getting Started with gum: Interactive Shell Scripts Without Writing a TUI

Shell scripts are where most automation starts, and where most of it stays ugly: read -p, a case statement for menus, and echo for everything else. Building a proper terminal UI fixes that, but nobody wants to write a TUI program to ask one question.

gum sits in between. Each gum command is one interactive widget (a menu, a text prompt, a yes/no question, a spinner) that you call from bash like any other program. It prints the answer on stdout, so $(...) captures it, and it reports yes and no through its exit code, so if and && work.

@oakoliver/gum is the TypeScript port of Charm's gum. It's built on our ports of Bubble Tea, Bubbles, Lip Gloss and Glamour, and it runs on Node and Bun.

Seven small bash scripts, from a one-line menu to a commit helper that really commits. Every picture below is the script beside it, running.

A terminal recording: git status shows a staged README, then bash commit.sh asks for the type of change, the scope, the summary and a two-line description, shows the message in a pink rounded box, asks to confirm, commits, logs


I – Install

npm install -g @oakoliver/gum
gum --version

That puts a gum command on your PATH. Everything in this guide is plain bash calling gum, so there's nothing else to set up.

One thing to know before the first script: gum draws its interface on stderr and prints only the answer on stdout. That's why ANSWER=$(gum choose ...) works: the menu still reaches your terminal while the command substitution captures just the choice.


II – A Menu in One Line

gum choose takes options as arguments and prints the one you pick.

#!/usr/bin/env bash
FLAVOR=$(gum choose --header "Pick a flavor" Strawberry Banana Cherry)
echo "You picked $FLAVOR"

Arrow keys move, Enter picks. Options can also come from stdin, one per line, so ls | gum choose works; gum then reads your key presses from the terminal instead of the pipe.

bash pick.sh shows

Add --limit 3 or --no-limit to pick several; each choice is printed on its own line.


III – Asking Questions: input and confirm

gum input reads one line of text. gum confirm asks a yes/no question and answers through its exit code: 0 for yes, 1 for no. That makes it read naturally in an if.

#!/usr/bin/env bash
NAME=$(gum input --placeholder "Your name")

if gum confirm "Say hello to $NAME?"; then
  echo "Hello, $NAME!"
else
  echo "Maybe later."
fi

bash hello.sh: an input prompt where

←/→ toggle the buttons, and y/n answer directly. Pressing Esc in gum input exits with 1 and prints nothing, and Ctrl+C in any of the prompts (input, write, choose, confirm) exits with 130, the usual code for an interrupt, so a script can tell "no" apart from "cancelled". Every command also has its own help: gum input --help.

For a password, use gum input --password. For several lines of text there's gum write, which we'll use in the last step.


IV – Spinners Around Slow Commands

gum spin runs a command and shows a spinner until it finishes. Put the command after --:

#!/usr/bin/env bash
gum spin --spinner dot --title "Installing dependencies..." -- sleep 2
echo "Exit code: $?"

gum spin --spinner moon --title "Running a failing step..." -- bash -c 'sleep 1; exit 3'
echo "Exit code: $?"

bash install.sh: a dot spinner with

Three details make this safe to drop into real scripts:

  • The exit code is the command's. The second step exits 3, and so does gum spin.
  • The command runs directly, not through a shell, so its arguments arrive exactly as you wrote them. When you need a pipe or &&, wrap it in bash -c as above.
  • The command's output is hidden unless you ask for it with --show-output, or --show-error to print it only when the command fails.

V – Styled Output: style and join

gum style draws text with colors, borders, padding and alignment. gum join places blocks next to each other (the default) or on top of each other with --vertical. Because gum keeps the colors when its output is captured, you can build blocks with $(...) and join them:

#!/usr/bin/env bash
TITLE=$(gum style --foreground 212 --border double --border-foreground 212 \
  --align center --width 44 --padding "0 2" "Release 2.0" "Codename: Bubble")

LEFT=$(gum style --border rounded --border-foreground 99 --padding "0 1" --width 20 \
  "Tests" "128 passed")
RIGHT=$(gum style --border rounded --border-foreground 42 --padding "0 1" --width 20 \
  "Build" "dist/ 412 KB")

gum join --vertical --align center "$TITLE" "$(gum join "$LEFT" "  " "$RIGHT")"

bash banner.sh: a pink double-bordered title

(The numbers are sample text; in a real script they'd come from your test run and build.)

Each argument to gum style becomes its own line. Borders are normal, rounded, double, thick, hidden or none, and colors take ANSI numbers (212) or hex ("#FF5F87"). --width includes the border, so the two 20-wide boxes and the 2-space gap come to 42 columns, centered under the 44-wide title.


VI – Markdown and Templates: format

gum format renders Markdown in the terminal, using Glamour under the hood. Pipe a file in, or pass the text as arguments:

# Release notes

Version **2.0** is out. Highlights:

- Faster *startup*
- `--json` output for every command
- Fewer bugs

| Platform | Status |
|----------|--------|
| macOS    | ok     |
| Linux    | ok     |
gum format < notes.md
gum format -t template '{{ Bold "Done:" }} {{ Color "42" "0" " 3 files " }}'

gum format renders notes.md with a pink heading, bold and italic text, inline code, a bullet list and a table; then gum format -t template prints a bold

--type (or -t) switches the input format: markdown is the default, and there are also code (with --language), emoji (:rocket: shortcodes) and template, which takes functions like Bold, Italic, Faint, Underline and Color "fg" "bg" "text".


VII – Tables From CSV

gum table reads CSV from stdin or --file. With --print it just draws the table; without it, you get a selectable table that prints the chosen row.

Flavor,Price,Stock
Strawberry,$1.00,24
Banana,$0.90,8
Cherry,$1.20,0
Watermelon,$1.50,15
gum table --print --file flavors.csv
gum table --file flavors.csv --return-column 1

gum table --print draws the flavors CSV in a rounded table; then the interactive table highlights Strawberry, the selection moves down to Watermelon, Enter is pressed, and

--return-column 1 prints just the first column of the selected row, which is usually what a script wants. Without it you get the whole row as CSV.


VIII – Putting It Together: A Commit Helper

The classic gum demo is a conventional-commit helper, and it uses almost every command above. This version really commits:

#!/usr/bin/env bash
# A conventional-commit helper built from gum commands.
TYPE=$(gum choose --header "Type of change" fix feat docs refactor test chore) || exit 1
SCOPE=$(gum input --placeholder "scope (optional)") || exit 1
[ -n "$SCOPE" ] && SCOPE="($SCOPE)"

SUMMARY=$(gum input --value "$TYPE$SCOPE: " --placeholder "Summary of this change") || exit 1
BODY=$(gum write --placeholder "Details (ctrl+j for a new line)") || exit 1

gum style --border rounded --border-foreground 212 --padding "0 1" "$SUMMARY" "" "$BODY"
gum confirm "Commit?" || exit 1

gum spin --title "Committing..." -- git commit --quiet -m "$SUMMARY" -m "$BODY"
gum log --structured --level info "Committed" sha "$(git rev-parse --short HEAD)"

The recording at the top of this article is this script in a repository with one staged file. A few things worth copying into your own scripts:

  • || exit 1 after every prompt. If the user presses Esc at any of them, the command exits non-zero, and the script stops instead of committing an empty message.
  • --value pre-fills the input, so the summary already starts with feat(readme): and the user only types the rest.
  • gum write takes multi-line text: Enter submits and Ctrl+J inserts a new line.
  • gum log prints a levelled log line. With --structured, the arguments after the message are key/value pairs (sha=57d2de1).

Save it as commit.sh in a repository with staged changes and run bash commit.sh.


IX – What to Read Next

  • The gum README lists every command and flag, including file (a file picker), pager, write and log, which this guide only touched.
  • If one command per widget stops being enough and you want a whole interactive program, the same building blocks are libraries: Bubble Tea for the program, Bubbles for the components and Lip Gloss for the styling.
  • Upstream charmbracelet/gum has more example scripts; most work unchanged, since the port follows gum v0.17.0. The upstream filter command isn't ported yet, and write can't open $EDITOR with Ctrl+E.

X – Requirements

  • Node.js or Bun. The gum binary runs with Node. I ran style, join, format, spin and table --print on Node 18.20, 20.6, 22.16 and 26.8 and on Bun 1.4, and recorded the interactive commands on Node 26.8.
  • A terminal for the interactive commands. They need a TTY to draw on, even when stdin is a pipe.
  • macOS or Linux. Every script here was run on macOS with bash.
"Simplicity is the ultimate sophistication."