An interactive CLI asks questions, shows progress, and responds to keyboard input — instead of requiring every option upfront as a flag. This is the right pattern for setup wizards, administrative tools, deployment scripts, and any CLI where the user needs to make a series of decisions.
Go ships with the basic building blocks in the standard library, and two third-party libraries — golang.org/x/term for raw terminal control and github.com/charmbracelet/bubbletea for full TUI applications — cover everything else.
For flag-based CLIs see Go building CLI with Cobra and Go command-line parsing.
Reading Text Input
bufio.Scanner or bufio.Reader is the right way to read a line of input. fmt.Scanln stops at whitespace, which breaks inputs with spaces; bufio.Reader.ReadString reads the full line:
func prompt(label string) (string, error) {
fmt.Print(label)
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("reading input: %w", err)
}
return strings.TrimSpace(input), nil
}
name, err := prompt("Enter your name: ")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Hello, %s!\n", name)
For multiple prompts in sequence, share one bufio.Reader to avoid buffering issues — each bufio.NewReader call starts a new buffer, which can consume bytes from stdin that the previous reader buffered but didn’t use:
type Prompter struct {
reader *bufio.Reader
}
func NewPrompter() *Prompter {
return &Prompter{reader: bufio.NewReader(os.Stdin)}
}
func (p *Prompter) Ask(label string) (string, error) {
fmt.Print(label)
line, err := p.reader.ReadString('\n')
return strings.TrimSpace(line), err
}
Secure Password Input
Never read passwords with echo enabled — the characters appear on screen. golang.org/x/term provides term.ReadPassword, which disables echo for the duration of the read:
go get golang.org/x/term
import "golang.org/x/term"
func readPassword(prompt string) (string, error) {
fmt.Print(prompt)
// ReadPassword takes the file descriptor, not an io.Reader
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // ReadPassword doesn't print a newline after Enter
if err != nil {
return "", fmt.Errorf("reading password: %w", err)
}
return string(password), nil
}
password, err := readPassword("Password: ")
// The terminal showed nothing while the user typed
term.ReadPassword also works correctly when the terminal is in raw mode and handles Ctrl+C gracefully.
Yes/No Confirmation
Simple confirmations before destructive operations keep users from making accidental mistakes:
func confirm(p *Prompter, prompt string) (bool, error) {
for {
answer, err := p.Ask(prompt + " [y/N] ")
if err != nil {
return false, err
}
switch strings.ToLower(answer) {
case "y", "yes":
return true, nil
case "n", "no", "": // default to no
return false, nil
default:
fmt.Println("Please enter y or n.")
}
}
}
ok, err := confirm(prompter, "Delete all records?")
if err != nil || !ok {
fmt.Println("Aborted.")
return
}
// proceed with deletion
Defaulting to “no” ("" maps to false) is the safer convention for destructive operations — an accidental Enter press doesn’t cause damage.
Menu Selection
A numbered menu is the clearest way to present a bounded set of choices:
func selectOption(p *Prompter, title string, options []string) (int, string, error) {
fmt.Println(title)
for i, opt := range options {
fmt.Printf(" %d) %s\n", i+1, opt)
}
for {
raw, err := p.Ask(fmt.Sprintf("Choice [1-%d]: ", len(options)))
if err != nil {
return 0, "", err
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > len(options) {
fmt.Printf("Please enter a number between 1 and %d.\n", len(options))
continue
}
return n - 1, options[n-1], nil // return zero-indexed
}
}
idx, choice, err := selectOption(prompter, "Select environment:", []string{
"development",
"staging",
"production",
})
if choice == "production" {
ok, _ := confirm(prompter, "You selected production. Are you sure?")
if !ok {
return
}
}
Input Validation
Validation should give immediate, specific feedback rather than letting the user reach an error later:
type Validator func(string) error
func required(s string) error {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("this field is required")
}
return nil
}
func minLen(n int) Validator {
return func(s string) error {
if len(s) < n {
return fmt.Errorf("must be at least %d characters", n)
}
return nil
}
}
func validEmail(s string) error {
if !strings.Contains(s, "@") || !strings.Contains(s, ".") {
return fmt.Errorf("invalid email address")
}
return nil
}
// askWithValidation retries until all validators pass
func (p *Prompter) askWithValidation(label string, validators ...Validator) (string, error) {
for {
value, err := p.Ask(label)
if err != nil {
return "", err
}
var valid = true
for _, v := range validators {
if err := v(value); err != nil {
fmt.Printf(" Error: %v\n", err)
valid = false
break
}
}
if valid {
return value, nil
}
}
}
email, err := prompter.askWithValidation("Email: ", required, validEmail)
password, err := prompter.askWithValidation("Password: ", required, minLen(8))
Progress Indicators
Long-running operations need feedback. A spinner shows “something is happening”; a progress bar shows completion percentage:
// Spinner that runs until a done channel is closed
func spinner(label string, done <-chan struct{}) {
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
i := 0
for {
select {
case <-done:
fmt.Printf("\r%s done\n", label)
return
default:
fmt.Printf("\r%s %s ", frames[i%len(frames)], label)
i++
time.Sleep(80 * time.Millisecond)
}
}
}
// Usage
done := make(chan struct{})
go spinner("Deploying...", done)
err := deploy()
close(done)
if err != nil {
fmt.Fprintln(os.Stderr, "deploy failed:", err)
os.Exit(1)
}
For a progress bar, track completed/total and overwrite the line with \r:
func progressBar(current, total int, width int) string {
pct := float64(current) / float64(total)
filled := int(pct * float64(width))
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
return fmt.Sprintf("[%s] %d/%d (%.0f%%)", bar, current, total, pct*100)
}
for i, file := range files {
process(file)
fmt.Printf("\r%s", progressBar(i+1, len(files), 40))
}
fmt.Println()
Full TUI: Bubble Tea
For richer interactions — scrollable lists, multi-select, forms with keyboard navigation — github.com/charmbracelet/bubbletea provides a full TUI framework based on The Elm Architecture (model → update → view):
go get github.com/charmbracelet/bubbletea
import tea "github.com/charmbracelet/bubbletea"
type model struct {
choices []string
cursor int
selected map[int]struct{}
}
func initialModel() model {
return model{
choices: []string{"API server", "Worker", "Scheduler", "All"},
selected: make(map[int]struct{}),
}
}
// Init runs any initial commands (none needed here)
func (m model) Init() tea.Cmd { return nil }
// Update handles keyboard events and returns the updated model
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
case " ":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
case "enter":
return m, tea.Quit
}
}
return m, nil
}
// View renders the current state as a string
func (m model) View() string {
s := "Which components should be deployed?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = "> "
}
checked := "[ ]"
if _, ok := m.selected[i]; ok {
checked = "[x]"
}
s += fmt.Sprintf("%s%s %s\n", cursor, checked, choice)
}
s += "\nSpace to toggle, Enter to confirm, q to quit.\n"
return s
}
func main() {
m := initialModel()
p := tea.NewProgram(m)
result, err := p.Run()
if err != nil {
log.Fatal(err)
}
final := result.(model)
fmt.Println("Selected:")
for i := range final.selected {
fmt.Println(" -", final.choices[i])
}
}
Bubble Tea handles terminal raw mode, resize events, and rendering automatically. The model is immutable — Update returns a new model rather than mutating the existing one, which makes the state machine easy to reason about.
The Charm ecosystem extends Bubble Tea: lipgloss for styling and layout, bubbles for pre-built components (text inputs, progress bars, spinners, tables), and glamour for Markdown rendering in the terminal.
When to Use Each Approach
| Approach | Best for |
|---|---|
bufio.Reader prompts |
Simple sequential input, scripts |
golang.org/x/term |
Password input, raw terminal control |
| Numbered menus | Short lists of mutually exclusive choices |
| Progress bar / spinner | Long operations with known or unknown duration |
| Bubble Tea | Multi-field forms, selectable lists, navigation |
Summary
- Use a single shared
bufio.Readerfor sequential prompts — multiplebufio.NewReadercalls cause buffering bugs - Use
golang.org/x/term.ReadPasswordfor passwords — never roll your own echo-disabling - Loop with clear error messages until valid input is given — don’t fail with a cryptic error after one bad attempt
- Default confirmations to “no” for destructive operations
- Use Bubble Tea for anything requiring keyboard navigation, multi-select, or multi-screen flows
\r(carriage return without newline) overwrites the current line — the basis for spinners and progress bars
Resources
- golang.org/x/term
- Bubble Tea
- Lipgloss (styling)
- Bubbles (pre-built components)
- Command Line Interface Guidelines (clig.dev)
Comments