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:

1mkdir tea-todo
2cd tea-todo
3go mod init github.com/yourusername/tea-todo
4go get github.com/charmbracelet/bubbletea
5go get github.com/charmbracelet/lipgloss

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

 1package main
 2
 3import (
 4	"fmt"
 5	"os"
 6
 7	tea "github.com/charmbracelet/bubbletea"
 8)
 9
10// Model represents the application state
11type Model struct {
12	todos    []string
13	cursor   int
14	selected map[int]bool
15}
16
17// Initialize the model
18func initialModel() Model {
19	return Model{
20		todos: []string{
21			"Buy milk",
22			"Feed the cat",
23			"Write Bubble Tea tutorial",
24			"Build amazing CLI tools",
25			"Take over the world",
26		},
27		selected: make(map[int]bool),
28	}
29}
30
31// Init is the first function called in the Bubble Tea lifecycle
32func (m Model) Init() tea.Cmd {
33	// Just return `nil`, which means "no I/O right now, please"
34	return nil
35}
36
37// Update is called when messages are received
38func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
39	switch msg := msg.(type) {
40	// Handle keypresses
41	case tea.KeyMsg:
42		switch msg.String() {
43		case "ctrl+c", "q":
44			return m, tea.Quit
45		case "up", "k":
46			if m.cursor > 0 {
47				m.cursor--
48			}
49		case "down", "j":
50			if m.cursor < len(m.todos)-1 {
51				m.cursor++
52			}
53		case "enter", " ":
54			_, ok := m.selected[m.cursor]
55			if ok {
56				delete(m.selected, m.cursor)
57			} else {
58				m.selected[m.cursor] = true
59			}
60		}
61	}
62	
63	return m, nil
64}
65
66// View renders the current model to the terminal
67func (m Model) View() string {
68	s := "What needs to be done?\n\n"
69	
70	for i, todo := range m.todos {
71		cursor := " " // No cursor
72		if m.cursor == i {
73			cursor = ">" // Cursor!
74		}
75		
76		checked := " " // Not selected
77		if _, ok := m.selected[i]; ok {
78			checked = "x" // Selected!
79		}
80		
81		s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, todo)
82	}
83	
84	s += "\nPress q to quit.\n"
85	
86	return s
87}
88
89func main() {
90	p := tea.NewProgram(initialModel())
91	if _, err := p.Run(); err != nil {
92		fmt.Printf("Alas, there's been an error: %v", err)
93		os.Exit(1)
94	}
95}

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

1go 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:

  1package main
  2
  3import (
  4	"fmt"
  5	"os"
  6
  7	tea "github.com/charmbracelet/bubbletea"
  8	"github.com/charmbracelet/lipgloss"
  9)
 10
 11var (
 12	appStyle = lipgloss.NewStyle().
 13		Padding(1, 2)
 14
 15	titleStyle = lipgloss.NewStyle().
 16		Foreground(lipgloss.Color("#FFFDF5")).
 17		Background(lipgloss.Color("#25A065")).
 18		Padding(0, 1)
 19
 20	itemStyle = lipgloss.NewStyle().
 21		PaddingLeft(4)
 22
 23	selectedItemStyle = lipgloss.NewStyle().
 24		PaddingLeft(2).
 25		Foreground(lipgloss.Color("#25A065"))
 26
 27	checkedStyle = lipgloss.NewStyle().
 28		Strikethrough(true).
 29		Foreground(lipgloss.Color("#6C6C6C"))
 30
 31	helpStyle = lipgloss.NewStyle().
 32		Foreground(lipgloss.Color("#626262")).
 33		Italic(true)
 34)
 35
 36// Model represents the application state
 37type Model struct {
 38	todos    []string
 39	cursor   int
 40	selected map[int]bool
 41}
 42
 43// Initialize the model
 44func initialModel() Model {
 45	return Model{
 46		todos: []string{
 47			"Buy milk",
 48			"Feed the cat",
 49			"Write Bubble Tea tutorial",
 50			"Build amazing CLI tools",
 51			"Take over the world",
 52		},
 53		selected: make(map[int]bool),
 54	}
 55}
 56
 57// Init is the first function called in the Bubble Tea lifecycle
 58func (m Model) Init() tea.Cmd {
 59	return nil
 60}
 61
 62// Update is called when messages are received
 63func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 64	switch msg := msg.(type) {
 65	case tea.KeyMsg:
 66		switch msg.String() {
 67		case "ctrl+c", "q":
 68			return m, tea.Quit
 69		case "up", "k":
 70			if m.cursor > 0 {
 71				m.cursor--
 72			}
 73		case "down", "j":
 74			if m.cursor < len(m.todos)-1 {
 75				m.cursor++
 76			}
 77		case "enter", " ":
 78			_, ok := m.selected[m.cursor]
 79			if ok {
 80				delete(m.selected, m.cursor)
 81			} else {
 82				m.selected[m.cursor] = true
 83			}
 84		}
 85	}
 86	
 87	return m, nil
 88}
 89
 90// View renders the current model to the terminal
 91func (m Model) View() string {
 92	s := titleStyle.Render("What needs to be done?") + "\n\n"
 93	
 94	for i, todo := range m.todos {
 95		// Is this item selected?
 96		checked, ok := m.selected[i]
 97		
 98		// Cursor styling
 99		var renderedItem string
100		if m.cursor == i {
101			cursor := "→"
102			if ok {
103				renderedItem = selectedItemStyle.Render(cursor + " [x] " + todo)
104			} else {
105				renderedItem = selectedItemStyle.Render(cursor + " [ ] " + todo)
106			}
107		} else {
108			cursor := " "
109			if ok {
110				renderedItem = itemStyle.Render(cursor + " [x] " + checkedStyle.Render(todo))
111			} else {
112				renderedItem = itemStyle.Render(cursor + " [ ] " + todo)
113			}
114		}
115		
116		s += renderedItem + "\n"
117	}
118	
119	helpText := "\nj/k, up/down: navigate • space: toggle • q: quit"
120	s += helpStyle.Render(helpText)
121	
122	return appStyle.Render(s)
123}
124
125func main() {
126	p := tea.NewProgram(initialModel())
127	if _, err := p.Run(); err != nil {
128		fmt.Printf("Alas, there's been an error: %v", err)
129		os.Exit(1)
130	}
131}

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:

  1package main
  2
  3import (
  4	"fmt"
  5	"os"
  6	"strings"
  7
  8	tea "github.com/charmbracelet/bubbletea"
  9	"github.com/charmbracelet/lipgloss"
 10)
 11
 12// Styles
 13var (
 14	appStyle = lipgloss.NewStyle().Padding(1, 2)
 15
 16	titleStyle = lipgloss.NewStyle().
 17		Foreground(lipgloss.Color("#FFFDF5")).
 18		Background(lipgloss.Color("#25A065")).
 19		Padding(0, 1)
 20
 21	itemStyle = lipgloss.NewStyle().PaddingLeft(4)
 22
 23	selectedItemStyle = lipgloss.NewStyle().
 24		PaddingLeft(2).
 25		Foreground(lipgloss.Color("#25A065"))
 26
 27	checkedStyle = lipgloss.NewStyle().
 28		Strikethrough(true).
 29		Foreground(lipgloss.Color("#6C6C6C"))
 30
 31	helpStyle = lipgloss.NewStyle().
 32		Foreground(lipgloss.Color("#626262")).
 33		Italic(true)
 34
 35	inputStyle = lipgloss.NewStyle().
 36		Border(lipgloss.RoundedBorder()).
 37		BorderForeground(lipgloss.Color("#25A065")).
 38		Padding(0, 1)
 39)
 40
 41// Input modes
 42const (
 43	normalMode = iota
 44	addMode
 45)
 46
 47// Model represents the application state
 48type Model struct {
 49	todos    []string
 50	cursor   int
 51	selected map[int]bool
 52	mode     int
 53	input    string
 54}
 55
 56// Initialize the model
 57func initialModel() Model {
 58	return Model{
 59		todos: []string{
 60			"Buy milk",
 61			"Feed the cat",
 62			"Write Bubble Tea tutorial",
 63			"Build amazing CLI tools",
 64			"Take over the world",
 65		},
 66		selected: make(map[int]bool),
 67		mode:     normalMode,
 68	}
 69}
 70
 71// Init is the first function called in the Bubble Tea lifecycle
 72func (m Model) Init() tea.Cmd {
 73	return nil
 74}
 75
 76// Update is called when messages are received
 77func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 78	switch msg := msg.(type) {
 79	case tea.KeyMsg:
 80		switch m.mode {
 81		case normalMode:
 82			switch msg.String() {
 83			case "ctrl+c", "q":
 84				return m, tea.Quit
 85			case "up", "k":
 86				if m.cursor > 0 {
 87					m.cursor--
 88				}
 89			case "down", "j":
 90				if m.cursor < len(m.todos)-1 {
 91					m.cursor++
 92				}
 93			case "enter", " ":
 94				_, ok := m.selected[m.cursor]
 95				if ok {
 96					delete(m.selected, m.cursor)
 97				} else {
 98					m.selected[m.cursor] = true
 99				}
100			case "a":
101				// Switch to add mode
102				m.mode = addMode
103				m.input = ""
104			case "d":
105				// Delete the selected item
106				if len(m.todos) > 0 {
107					m.todos = append(m.todos[:m.cursor], m.todos[m.cursor+1:]...)
108					if m.cursor >= len(m.todos) {
109						m.cursor = max(0, len(m.todos)-1)
110					}
111				}
112			}
113		case addMode:
114			switch msg.String() {
115			case "ctrl+c", "esc":
116				m.mode = normalMode
117			case "enter":
118				if m.input != "" {
119					m.todos = append(m.todos, m.input)
120					m.mode = normalMode
121				}
122			case "backspace":
123				if len(m.input) > 0 {
124					m.input = m.input[:len(m.input)-1]
125				}
126			default:
127				// Add character to input if not a special key
128				if len(msg.String()) == 1 {
129					m.input += msg.String()
130				}
131			}
132		}
133	}
134	
135	return m, nil
136}
137
138// View renders the current model to the terminal
139func (m Model) View() string {
140	switch m.mode {
141	case normalMode:
142		return m.normalView()
143	case addMode:
144		return m.addView()
145	default:
146		return "Unknown mode"
147	}
148}
149
150func (m Model) normalView() string {
151	s := titleStyle.Render("What needs to be done?") + "\n\n"
152	
153	if len(m.todos) == 0 {
154		s += itemStyle.Render("No todos yet. Press 'a' to add one.") + "\n"
155	} else {
156		for i, todo := range m.todos {
157			// Is this item selected?
158			checked, ok := m.selected[i]
159			
160			// Cursor styling
161			var renderedItem string
162			if m.cursor == i {
163				cursor := "→"
164				if ok {
165					renderedItem = selectedItemStyle.Render(cursor + " [x] " + todo)
166				} else {
167					renderedItem = selectedItemStyle.Render(cursor + " [ ] " + todo)
168				}
169			} else {
170				cursor := " "
171				if ok {
172					renderedItem = itemStyle.Render(cursor + " [x] " + checkedStyle.Render(todo))
173				} else {
174					renderedItem = itemStyle.Render(cursor + " [ ] " + todo)
175				}
176			}
177			
178			s += renderedItem + "\n"
179		}
180	}
181	
182	helpText := "\nj/k: navigate • space: toggle • a: add • d: delete • q: quit"
183	s += helpStyle.Render(helpText)
184	
185	return appStyle.Render(s)
186}
187
188func (m Model) addView() string {
189	s := titleStyle.Render("Add a new todo") + "\n\n"
190	s += "Enter your todo:\n"
191	s += inputStyle.Render(m.input) + "\n\n"
192	s += helpStyle.Render("press enter to add, esc to cancel")
193	
194	return appStyle.Render(s)
195}
196
197func max(a, b int) int {
198	if a > b {
199		return a
200	}
201	return b
202}
203
204func main() {
205	p := tea.NewProgram(initialModel())
206	if _, err := p.Run(); err != nil {
207		fmt.Printf("Alas, there's been an error: %v", err)
208		os.Exit(1)
209	}
210}

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:

 1// Import statements omitted for brevity
 2
 3type TodoList struct {
 4	todos    []string
 5	cursor   int
 6	selected map[int]bool
 7}
 8
 9func (t TodoList) View() string {
10	// Todo list rendering logic
11}
12
13type StatusBar struct {
14	status string
15}
16
17func (s StatusBar) View() string {
18	// Status bar rendering logic
19}
20
21type HelpMenu struct {
22	commands map[string]string
23}
24
25func (h HelpMenu) View() string {
26	// Help menu rendering logic
27}
28
29// Main model composes the components
30type Model struct {
31	todoList  TodoList
32	statusBar StatusBar
33	helpMenu  HelpMenu
34	width     int
35	height    int
36}
37
38func (m Model) View() string {
39	// Combine the views of the components
40	return lipgloss.JoinVertical(
41		lipgloss.Left,
42		m.todoList.View(),
43		m.statusBar.View(),
44		m.helpMenu.View(),
45	)
46}

2. Handling Window Resizing

Real applications need to respond to terminal window size changes:

 1func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 2	switch msg := msg.(type) {
 3	// Handle window resize
 4	case tea.WindowSizeMsg:
 5		m.width = msg.Width
 6		m.height = msg.Height
 7		// Adjust your layout based on new dimensions
 8		
 9	// Other message handlers...
10	}
11	
12	return m, nil
13}
14
15func main() {
16	p := tea.NewProgram(initialModel(), tea.WithAltScreen())
17	// ...
18}

3. Asynchronous Operations

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

 1// Define a message type for our async result
 2type fetchTodosMsg struct {
 3	todos []string
 4	err   error
 5}
 6
 7// Command that performs the async operation
 8func fetchTodos() tea.Cmd {
 9	return func() tea.Msg {
10		// Simulate network request
11		time.Sleep(1 * time.Second)
12		
13		todos, err := loadTodosFromAPI()
14		return fetchTodosMsg{
15			todos: todos,
16			err:   err,
17		}
18	}
19}
20
21// Initialize the model with our async command
22func (m Model) Init() tea.Cmd {
23	return fetchTodos()
24}
25
26// Handle the async result in Update
27func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
28	switch msg := msg.(type) {
29	case fetchTodosMsg:
30		if msg.err != nil {
31			m.error = msg.err.Error()
32		} else {
33			m.todos = msg.todos
34			m.loading = false
35		}
36		return m, nil
37		
38	// Other message handlers...
39	}
40	
41	return m, nil
42}

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

 1// A simplified git client that shows status and lets you stage/unstage files
 2
 3type GitModel struct {
 4	files       []GitFile
 5	cursor      int
 6	status      string
 7	showHelp    bool
 8}
 9
10type GitFile struct {
11	name   string
12	status string // Modified, Added, Deleted, etc.
13	staged bool
14}
15
16// Commands
17func gitStatus() tea.Cmd {
18	return func() tea.Msg {
19		// Run git status and parse output
20		// ...
21	}
22}
23
24func stageFile(file string) tea.Cmd {
25	return func() tea.Msg {
26		// Run git add on the file
27		// ...
28	}
29}
30
31// And so on for other git operations

2. System Monitor Dashboard

 1// A top-like system monitor with process management
 2
 3type Dashboard struct {
 4	cpuChart    LineChart
 5	memoryChart LineChart
 6	diskChart   BarChart
 7	processes   ProcessList
 8	activeTab   int
 9}
10
11// Update CPU stats every second
12func updateCPU() tea.Cmd {
13	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
14		// Get CPU stats
15		// ...
16	})
17}
18
19// Similar commands for other metrics

3. Database Client

 1// Interactive database client with query editor and results view
 2
 3type DBClient struct {
 4	connections []Connection
 5	activeConn  int
 6	queryEditor TextArea
 7	resultSet   Table
 8	status      string
 9}
10
11func executeQuery(query string, conn Connection) tea.Cmd {
12	return func() tea.Msg {
13		// Execute the query and return results
14		// ...
15	}
16}

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:

 1func main() {
 2	var rootCmd = &cobra.Command{
 3		Use:   "tea-todo",
 4		Short: "A fancy todo app",
 5		Run: func(cmd *cobra.Command, args []string) {
 6			p := tea.NewProgram(initialModel())
 7			if _, err := p.Run(); err != nil {
 8				fmt.Printf("Error: %v", err)
 9				os.Exit(1)
10			}
11		},
12	}
13	
14	// Add subcommands and flags
15	rootCmd.AddCommand(exportCmd)
16	rootCmd.Flags().BoolP("version", "v", false, "Show version information")
17	
18	rootCmd.Execute()
19}

2. Configuration Management

Store and load user preferences:

 1type Config struct {
 2	Theme      string   `json:"theme"`
 3	TodoFile   string   `json:"todo_file"`
 4	Categories []string `json:"categories"`
 5}
 6
 7func loadConfig() (Config, error) {
 8	// Load from user's config directory
 9	home, err := os.UserHomeDir()
10	if err != nil {
11		return Config{}, err
12	}
13	
14	configPath := filepath.Join(home, ".config", "tea-todo", "config.json")
15	data, err := os.ReadFile(configPath)
16	if err != nil {
17		// Return default config if file doesn't exist
18		if os.IsNotExist(err) {
19			return defaultConfig(), nil
20		}
21		return Config{}, err
22	}
23	
24	var config Config
25	err = json.Unmarshal(data, &config)
26	return config, err
27}

3. Error Handling and Logging

Implement robust error handling for a better user experience:

 1func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 2	switch msg := msg.(type) {
 3	case errMsg:
 4		m.err = msg.err
 5		m.status = "Error: " + msg.err.Error()
 6		return m, tea.Batch(
 7			tea.Printf("Error: %v", msg.err), // Log to terminal
 8			resetErrorAfter(3*time.Second),   // Clear error after delay
 9		)
10	// ...
11	}
12	
13	return m, nil
14}
15
16// Command to clear error after delay
17func resetErrorAfter(d time.Duration) tea.Cmd {
18	return tea.Tick(d, func(time.Time) tea.Msg {
19		return clearErrorMsg{}
20	})
21}

4. Packaging and Distribution

Make your tool easy to install:

 1// Set during build with -ldflags
 2var (
 3	version = "dev"
 4	commit  = "none"
 5	date    = "unknown"
 6)
 7
 8func main() {
 9	// Use version info in your app
10	if len(os.Args) > 1 && os.Args[1] == "--version" {
11		fmt.Printf("tea-todo version %s (%s) built on %s\n", version, commit, date)
12		return
13	}
14	// ...
15}

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.