~/msh
Building delightful CLI tools with Bubble Tea
all writing
·
  • #go
  • #cli
  • #tui
  • #bubble tea
  • #terminal

Building Delightful CLI Tools in Go with Bubble Tea

A practical guide to creating beautiful, interactive terminal applications in Go using the Bubble Tea framework, with step-by-step examples and best practices.

Building Delightful CLI Tools in Go with Bubble Tea

Let’s face it—most command-line tools feel like they were designed in the 1970s. They’re functional but clunky, with interfaces that make you wonder if “user experience” was even a concept when they were created.

But it doesn’t have to be this way. Terminal applications can be beautiful, responsive, and even fun to use. As someone who lives in the terminal, I’ve been on a mission to make CLI tools that people actually enjoy using, and Go’s Bubble Tea framework has been a game-changer.

In this guide, I’ll walk you through creating terminal applications that will make your users do a double-take and ask, “This is running in a terminal?”

Why Bubble Tea?

Before we dive in, you might be wondering: why Bubble Tea specifically? After all, there are plenty of terminal UI libraries out there.

Having built CLI tools with everything from Python’s curses to Node.js’s blessed, I can confidently say Bubble Tea hits a sweet spot:

  • The Elm Architecture: A predictable state management model that makes complex UIs manageable
  • Composable components: Build complex interfaces from simple, reusable parts
  • Go’s performance: Fast startup times and low resource usage
  • Charmbracelet ecosystem: Integrates beautifully with Lipgloss for styling and other companion libraries

Plus, creating delightful terminal UIs in a language as efficient as Go means you don’t have to choose between user experience and performance.

Getting Started: A Simple Todo App

Let’s start with everyone’s favorite example: a todo app. It’s simple enough to understand quickly but complex enough to demonstrate real-world patterns.

First, let’s set up our project:

mkdir tea-todo
cd tea-todo
go mod init github.com/yourusername/tea-todo
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/lipgloss

Now, let’s create a basic Bubble Tea application:

package main

import (
	"fmt"
	"os"

	tea "github.com/charmbracelet/bubbletea"
)

// Model represents the application state
type Model struct {
	todos    []string
	cursor   int
	selected map[int]bool
}

// Initialize the model
func initialModel() Model {
	return Model{
		todos: []string{
			"Buy milk",
			"Feed the cat",
			"Write Bubble Tea tutorial",
			"Build amazing CLI tools",
			"Take over the world",
		},
		selected: make(map[int]bool),
	}
}

// Init is the first function called in the Bubble Tea lifecycle
func (m Model) Init() tea.Cmd {
	// Just return `nil`, which means "no I/O right now, please"
	return nil
}

// Update is called when messages are received
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	// Handle keypresses
	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.todos)-1 {
				m.cursor++
			}
		case "enter", " ":
			_, ok := m.selected[m.cursor]
			if ok {
				delete(m.selected, m.cursor)
			} else {
				m.selected[m.cursor] = true
			}
		}
	}
	
	return m, nil
}

// View renders the current model to the terminal
func (m Model) View() string {
	s := "What needs to be done?\n\n"
	
	for i, todo := range m.todos {
		cursor := " " // No cursor
		if m.cursor == i {
			cursor = ">" // Cursor!
		}
		
		checked := " " // Not selected
		if _, ok := m.selected[i]; ok {
			checked = "x" // Selected!
		}
		
		s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, todo)
	}
	
	s += "\nPress q to quit.\n"
	
	return s
}

func main() {
	p := tea.NewProgram(initialModel())
	if _, err := p.Run(); err != nil {
		fmt.Printf("Alas, there's been an error: %v", err)
		os.Exit(1)
	}
}

This gives us a functional, albeit plain, todo application. Let’s run it:

go run main.go

You should see a list of todos that you can navigate with arrow keys and check/uncheck with Enter or Space.

Adding Style with Lipgloss

Now, let’s make our todo app visually appealing with Lipgloss:

package main

import (
	"fmt"
	"os"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

var (
	appStyle = lipgloss.NewStyle().
		Padding(1, 2)

	titleStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("#FFFDF5")).
		Background(lipgloss.Color("#25A065")).
		Padding(0, 1)

	itemStyle = lipgloss.NewStyle().
		PaddingLeft(4)

	selectedItemStyle = lipgloss.NewStyle().
		PaddingLeft(2).
		Foreground(lipgloss.Color("#25A065"))

	checkedStyle = lipgloss.NewStyle().
		Strikethrough(true).
		Foreground(lipgloss.Color("#6C6C6C"))

	helpStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("#626262")).
		Italic(true)
)

// Model represents the application state
type Model struct {
	todos    []string
	cursor   int
	selected map[int]bool
}

// Initialize the model
func initialModel() Model {
	return Model{
		todos: []string{
			"Buy milk",
			"Feed the cat",
			"Write Bubble Tea tutorial",
			"Build amazing CLI tools",
			"Take over the world",
		},
		selected: make(map[int]bool),
	}
}

// Init is the first function called in the Bubble Tea lifecycle
func (m Model) Init() tea.Cmd {
	return nil
}

// Update is called when messages are received
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.todos)-1 {
				m.cursor++
			}
		case "enter", " ":
			_, ok := m.selected[m.cursor]
			if ok {
				delete(m.selected, m.cursor)
			} else {
				m.selected[m.cursor] = true
			}
		}
	}
	
	return m, nil
}

// View renders the current model to the terminal
func (m Model) View() string {
	s := titleStyle.Render("What needs to be done?") + "\n\n"
	
	for i, todo := range m.todos {
		// Is this item selected?
		checked, ok := m.selected[i]
		
		// Cursor styling
		var renderedItem string
		if m.cursor == i {
			cursor := "→"
			if ok {
				renderedItem = selectedItemStyle.Render(cursor + " [x] " + todo)
			} else {
				renderedItem = selectedItemStyle.Render(cursor + " [ ] " + todo)
			}
		} else {
			cursor := " "
			if ok {
				renderedItem = itemStyle.Render(cursor + " [x] " + checkedStyle.Render(todo))
			} else {
				renderedItem = itemStyle.Render(cursor + " [ ] " + todo)
			}
		}
		
		s += renderedItem + "\n"
	}
	
	helpText := "\nj/k, up/down: navigate • space: toggle • q: quit"
	s += helpStyle.Render(helpText)
	
	return appStyle.Render(s)
}

func main() {
	p := tea.NewProgram(initialModel())
	if _, err := p.Run(); err != nil {
		fmt.Printf("Alas, there's been an error: %v", err)
		os.Exit(1)
	}
}

With these changes, our todo app now has:

  • A styled header
  • Colored selected items
  • Strikethrough for completed tasks
  • Helpful navigation hints
  • Consistent padding and layout

It’s amazing how much a bit of styling can transform the user experience in a terminal application.

Adding Interactivity: New Todos and Deletion

Let’s make our todo app more useful by allowing users to add and delete todos:

package main

import (
	"fmt"
	"os"
	"strings"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

// Styles
var (
	appStyle = lipgloss.NewStyle().Padding(1, 2)

	titleStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("#FFFDF5")).
		Background(lipgloss.Color("#25A065")).
		Padding(0, 1)

	itemStyle = lipgloss.NewStyle().PaddingLeft(4)

	selectedItemStyle = lipgloss.NewStyle().
		PaddingLeft(2).
		Foreground(lipgloss.Color("#25A065"))

	checkedStyle = lipgloss.NewStyle().
		Strikethrough(true).
		Foreground(lipgloss.Color("#6C6C6C"))

	helpStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("#626262")).
		Italic(true)

	inputStyle = lipgloss.NewStyle().
		Border(lipgloss.RoundedBorder()).
		BorderForeground(lipgloss.Color("#25A065")).
		Padding(0, 1)
)

// Input modes
const (
	normalMode = iota
	addMode
)

// Model represents the application state
type Model struct {
	todos    []string
	cursor   int
	selected map[int]bool
	mode     int
	input    string
}

// Initialize the model
func initialModel() Model {
	return Model{
		todos: []string{
			"Buy milk",
			"Feed the cat",
			"Write Bubble Tea tutorial",
			"Build amazing CLI tools",
			"Take over the world",
		},
		selected: make(map[int]bool),
		mode:     normalMode,
	}
}

// Init is the first function called in the Bubble Tea lifecycle
func (m Model) Init() tea.Cmd {
	return nil
}

// Update is called when messages are received
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch m.mode {
		case normalMode:
			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.todos)-1 {
					m.cursor++
				}
			case "enter", " ":
				_, ok := m.selected[m.cursor]
				if ok {
					delete(m.selected, m.cursor)
				} else {
					m.selected[m.cursor] = true
				}
			case "a":
				// Switch to add mode
				m.mode = addMode
				m.input = ""
			case "d":
				// Delete the selected item
				if len(m.todos) > 0 {
					m.todos = append(m.todos[:m.cursor], m.todos[m.cursor+1:]...)
					if m.cursor >= len(m.todos) {
						m.cursor = max(0, len(m.todos)-1)
					}
				}
			}
		case addMode:
			switch msg.String() {
			case "ctrl+c", "esc":
				m.mode = normalMode
			case "enter":
				if m.input != "" {
					m.todos = append(m.todos, m.input)
					m.mode = normalMode
				}
			case "backspace":
				if len(m.input) > 0 {
					m.input = m.input[:len(m.input)-1]
				}
			default:
				// Add character to input if not a special key
				if len(msg.String()) == 1 {
					m.input += msg.String()
				}
			}
		}
	}
	
	return m, nil
}

// View renders the current model to the terminal
func (m Model) View() string {
	switch m.mode {
	case normalMode:
		return m.normalView()
	case addMode:
		return m.addView()
	default:
		return "Unknown mode"
	}
}

func (m Model) normalView() string {
	s := titleStyle.Render("What needs to be done?") + "\n\n"
	
	if len(m.todos) == 0 {
		s += itemStyle.Render("No todos yet. Press 'a' to add one.") + "\n"
	} else {
		for i, todo := range m.todos {
			// Is this item selected?
			checked, ok := m.selected[i]
			
			// Cursor styling
			var renderedItem string
			if m.cursor == i {
				cursor := "→"
				if ok {
					renderedItem = selectedItemStyle.Render(cursor + " [x] " + todo)
				} else {
					renderedItem = selectedItemStyle.Render(cursor + " [ ] " + todo)
				}
			} else {
				cursor := " "
				if ok {
					renderedItem = itemStyle.Render(cursor + " [x] " + checkedStyle.Render(todo))
				} else {
					renderedItem = itemStyle.Render(cursor + " [ ] " + todo)
				}
			}
			
			s += renderedItem + "\n"
		}
	}
	
	helpText := "\nj/k: navigate • space: toggle • a: add • d: delete • q: quit"
	s += helpStyle.Render(helpText)
	
	return appStyle.Render(s)
}

func (m Model) addView() string {
	s := titleStyle.Render("Add a new todo") + "\n\n"
	s += "Enter your todo:\n"
	s += inputStyle.Render(m.input) + "\n\n"
	s += helpStyle.Render("press enter to add, esc to cancel")
	
	return appStyle.Render(s)
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func main() {
	p := tea.NewProgram(initialModel())
	if _, err := p.Run(); err != nil {
		fmt.Printf("Alas, there's been an error: %v", err)
		os.Exit(1)
	}
}

Our todo app is now much more useful with the ability to add and delete todos.

Beyond the Basics: Building Complex Applications

The example above is a good starting point, but real-world CLI applications often require more sophisticated features. Let’s explore how to implement some common patterns.

1. Multiple Views with View Composition

For complex applications, breaking your UI into components makes your code more maintainable:

// Import statements omitted for brevity

type TodoList struct {
	todos    []string
	cursor   int
	selected map[int]bool
}

func (t TodoList) View() string {
	// Todo list rendering logic
}

type StatusBar struct {
	status string
}

func (s StatusBar) View() string {
	// Status bar rendering logic
}

type HelpMenu struct {
	commands map[string]string
}

func (h HelpMenu) View() string {
	// Help menu rendering logic
}

// Main model composes the components
type Model struct {
	todoList  TodoList
	statusBar StatusBar
	helpMenu  HelpMenu
	width     int
	height    int
}

func (m Model) View() string {
	// Combine the views of the components
	return lipgloss.JoinVertical(
		lipgloss.Left,
		m.todoList.View(),
		m.statusBar.View(),
		m.helpMenu.View(),
	)
}

2. Handling Window Resizing

Real applications need to respond to terminal window size changes:

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	// Handle window resize
	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height
		// Adjust your layout based on new dimensions
		
	// Other message handlers...
	}
	
	return m, nil
}

func main() {
	p := tea.NewProgram(initialModel(), tea.WithAltScreen())
	// ...
}

3. Asynchronous Operations

Many CLI tools need to perform I/O operations. Here’s how to handle them without blocking the UI:

// Define a message type for our async result
type fetchTodosMsg struct {
	todos []string
	err   error
}

// Command that performs the async operation
func fetchTodos() tea.Cmd {
	return func() tea.Msg {
		// Simulate network request
		time.Sleep(1 * time.Second)
		
		todos, err := loadTodosFromAPI()
		return fetchTodosMsg{
			todos: todos,
			err:   err,
		}
	}
}

// Initialize the model with our async command
func (m Model) Init() tea.Cmd {
	return fetchTodos()
}

// Handle the async result in Update
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case fetchTodosMsg:
		if msg.err != nil {
			m.error = msg.err.Error()
		} else {
			m.todos = msg.todos
			m.loading = false
		}
		return m, nil
		
	// Other message handlers...
	}
	
	return m, nil
}

Practical Examples: Real-World CLI Applications

Let’s look at some examples of practical CLI tools you could build with Bubble Tea:

1. Interactive Git Client

// A simplified git client that shows status and lets you stage/unstage files

type GitModel struct {
	files       []GitFile
	cursor      int
	status      string
	showHelp    bool
}

type GitFile struct {
	name   string
	status string // Modified, Added, Deleted, etc.
	staged bool
}

// Commands
func gitStatus() tea.Cmd {
	return func() tea.Msg {
		// Run git status and parse output
		// ...
	}
}

func stageFile(file string) tea.Cmd {
	return func() tea.Msg {
		// Run git add on the file
		// ...
	}
}

// And so on for other git operations

2. System Monitor Dashboard

// A top-like system monitor with process management

type Dashboard struct {
	cpuChart    LineChart
	memoryChart LineChart
	diskChart   BarChart
	processes   ProcessList
	activeTab   int
}

// Update CPU stats every second
func updateCPU() tea.Cmd {
	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
		// Get CPU stats
		// ...
	})
}

// Similar commands for other metrics

3. Database Client

// Interactive database client with query editor and results view

type DBClient struct {
	connections []Connection
	activeConn  int
	queryEditor TextArea
	resultSet   Table
	status      string
}

func executeQuery(query string, conn Connection) tea.Cmd {
	return func() tea.Msg {
		// Execute the query and return results
		// ...
	}
}

Best Practices for Building Bubble Tea Applications

After building several production-grade CLI tools with Bubble Tea, I’ve learned some valuable lessons:

1. Think in Terms of State

The Elm Architecture that Bubble Tea uses is all about state management. Every UI change is a result of a state change, so design your model carefully:

  • Include everything needed to render the UI in your model
  • Never modify state outside of the Update function
  • Keep related state grouped together in nested structs

2. Use Lipgloss Effectively

Styling can make or break your TUI application:

  • Define your styles at the top of your file or in a separate package
  • Use constants for colors to maintain a consistent theme
  • Test your UI in different terminal color schemes (dark and light)
  • Remember that many terminals support true color now, but some don’t

3. Handle Edge Cases

Terminal applications have unique challenges:

  • Always handle window resizing gracefully
  • Provide keyboard shortcuts for all actions
  • Include a help screen or statusline with available commands
  • Implement graceful degradation for terminals with limited capabilities

4. Performance Matters

Terminal UIs should feel snappy:

  • Minimize string concatenation in your View function
  • Cache complex renders when possible
  • Use tea.Batch to combine multiple commands
  • Profile your application with Go’s built-in tools

Building a Production-Ready CLI Tool

When you’re ready to take your Bubble Tea application to production, consider these additional steps:

1. Command-Line Argument Parsing

Use a library like cobra to handle command-line arguments:

func main() {
	var rootCmd = &cobra.Command{
		Use:   "tea-todo",
		Short: "A fancy todo app",
		Run: func(cmd *cobra.Command, args []string) {
			p := tea.NewProgram(initialModel())
			if _, err := p.Run(); err != nil {
				fmt.Printf("Error: %v", err)
				os.Exit(1)
			}
		},
	}
	
	// Add subcommands and flags
	rootCmd.AddCommand(exportCmd)
	rootCmd.Flags().BoolP("version", "v", false, "Show version information")
	
	rootCmd.Execute()
}

2. Configuration Management

Store and load user preferences:

type Config struct {
	Theme      string   `json:"theme"`
	TodoFile   string   `json:"todo_file"`
	Categories []string `json:"categories"`
}

func loadConfig() (Config, error) {
	// Load from user's config directory
	home, err := os.UserHomeDir()
	if err != nil {
		return Config{}, err
	}
	
	configPath := filepath.Join(home, ".config", "tea-todo", "config.json")
	data, err := os.ReadFile(configPath)
	if err != nil {
		// Return default config if file doesn't exist
		if os.IsNotExist(err) {
			return defaultConfig(), nil
		}
		return Config{}, err
	}
	
	var config Config
	err = json.Unmarshal(data, &config)
	return config, err
}

3. Error Handling and Logging

Implement robust error handling for a better user experience:

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case errMsg:
		m.err = msg.err
		m.status = "Error: " + msg.err.Error()
		return m, tea.Batch(
			tea.Printf("Error: %v", msg.err), // Log to terminal
			resetErrorAfter(3*time.Second),   // Clear error after delay
		)
	// ...
	}
	
	return m, nil
}

// Command to clear error after delay
func resetErrorAfter(d time.Duration) tea.Cmd {
	return tea.Tick(d, func(time.Time) tea.Msg {
		return clearErrorMsg{}
	})
}

4. Packaging and Distribution

Make your tool easy to install:

// Set during build with -ldflags
var (
	version = "dev"
	commit  = "none"
	date    = "unknown"
)

func main() {
	// Use version info in your app
	if len(os.Args) > 1 && os.Args[1] == "--version" {
		fmt.Printf("tea-todo version %s (%s) built on %s\n", version, commit, date)
		return
	}
	// ...
}

Build for multiple platforms using GitHub Actions or other CI/CD tools.

Conclusion

Building CLI tools with Bubble Tea transforms what would otherwise be boring terminal interfaces into delightful, interactive experiences. The combination of Go’s performance, Bubble Tea’s elegant architecture, and Lipgloss’s styling capabilities gives you everything you need to create professional-grade applications.

The next time you’re considering building a GUI application, ask yourself: could this be a TUI instead? With tools like Bubble Tea, the answer might surprise you.

Remember, the best interface is the one that gets out of the user’s way and lets them focus on the task at hand. Sometimes that’s a beautifully crafted terminal UI.


What kind of CLI tool would you build with Bubble Tea? Let me know in the comments below!

Source code for the examples in this post is available in my GitHub repository.