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
Updatefunction - 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
Viewfunction - Cache complex renders when possible
- Use
tea.Batchto 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.
