I spend a lot of time in the terminal. Most of my development workflow runs through neovim and tmux, and over time I started noticing how much of the tooling I actually enjoy using is TUI-based. lazygit, btop, fzf. There is something about that kind of interface that just fits how I think about working.
Eventually I wanted to build some CLI tools of my own, and since I was already comfortable with Go, it felt like the natural place to start. I did some research, which mostly meant watching YouTube videos until something clicked, and ended up on a video where ThePrimeagen was talking about Bubble Tea. I checked it out, played around with it, and it stuck. This post is basically what I wish I had read before I started.
TUIs vs CLIs: What Is the Difference
This is worth getting clear on before anything else because people use these terms interchangeably and they are not the same thing.
A CLI is a request-response interaction. You type a command, the program runs, prints something, and exits. git status, ls, curl. It does its job and gets out of your way.
A TUI is a program that stays running and responds to you in real time. It has state, it reacts to keypresses, it renders and re-renders a UI as you interact with it. Think neovim, lazygit, or htop. These programs take over your terminal and give you an actual interface to work with.
Writing a TUI is a different problem than writing a script. You are managing state across time, responding to events, and re-rendering on every update. It is closer to building a frontend app than writing a command-line utility, just without a browser.
I do plan to write a dedicated post making the case for TUIs more broadly. The short version is that with AI-driven, agentic development workflows becoming more common, the terminal is becoming a more important interface, not less. TUIs fit well into that world because they are fast, composable, and keyboard-native. More on that another time.
The Elm Architecture
Bubble Tea is built around The Elm Architecture. Elm is a functional language for building web UIs, and its core architecture pattern has shown up all over the frontend world. Redux is based on it. React's useReducer hook follows the same shape. If you have used either, this will feel familiar pretty quickly.
The pattern has three parts: Model, Update, and View.
Model
The model holds all of your application state. Everything the program needs to know to render itself lives in one place.
For a simple timer app, the model might look like this:
type model struct {
seconds int
running bool
}Two fields. How many seconds have elapsed, and whether the timer is currently running. If something looks wrong at runtime, you check the model.
Update
When something happens, a keypress, a timer tick, a message from a goroutine, Bubble Tea calls Update with the current model and the event that occurred. You return a new model and optionally a command to run.
1func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2 switch msg := msg.(type) {
3 case tea.KeyPressMsg:
4 switch msg.String() {
5 case "ctrl+c", "q":
6 return m, tea.Quit
7 case " ":
8 m.running = !m.running
9 if m.running {
10 return m, tick()
11 }
12 case "r":
13 m.seconds = 0
14 m.running = false
15 }
16 case tickMsg:
17 if m.running {
18 m.seconds++
19 return m, tick()
20 }
21 }
22 return m, nil
23}Update does not mutate state in place. It takes the old model, computes the new one, and returns it. Same input always produces the same output. That makes the whole thing a lot easier to reason about and test.
View
View takes the model and returns a string. That string is what gets rendered to the terminal.
func (m model) View() string {
status := "paused"
if m.running {
status = "running"
}
mins := m.seconds / 60
secs := m.seconds % 60
return fmt.Sprintf(
"Timer [%s]
%02d:%02d
space to start/stop • r to reset • q to quit
",
status, mins, secs,
)
}The view is just a function of the model. You never reach into the UI to change something directly. State changes, the view reflects it. Same idea as React's render model.
Init
Init runs once at startup and returns an initial command, or nil if there is nothing to kick off right away.
type tickMsg struct{}
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
func (m model) Init() tea.Cmd {
return nil // timer starts paused, so nothing to kick off yet
}Here tick() is a command that fires every second and sends a tickMsg into the update loop. When the user hits space, we start firing ticks. When they pause, we stop scheduling them. This is how Bubble Tea handles async work: you return a tea.Cmd from Update, and the runtime handles executing it and feeding the result back in as a message.
These three methods make up the tea.Model interface. Implement all three and Bubble Tea can run your program.
Getting Started
Install Bubble Tea v2:
go get charm.land/bubbletea/v2Note: The old
github.com/charmbracelet/bubbleteapath still works for v1. For v2, Charmbracelet moved to a vanity domain. More on what changed in v2 below.
Here is the full timer program tied together:
1package main
2
3import (
4 "fmt"
5 "os"
6 "time"
7
8 tea "charm.land/bubbletea/v2"
9)
10
11type tickMsg struct{}
12
13func tick() tea.Cmd {
14 return tea.Tick(time.Second, func(t time.Time) tea.Msg {
15 return tickMsg{}
16 })
17}
18
19type model struct {
20 seconds int
21 running bool
22}
23
24func (m model) Init() tea.Cmd {
25 return nil
26}
27
28func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29 switch msg := msg.(type) {
30 case tea.KeyPressMsg:
31 switch msg.String() {
32 case "ctrl+c", "q":
33 return m, tea.Quit
34 case " ":
35 m.running = !m.running
36 if m.running {
37 return m, tick()
38 }
39 case "r":
40 m.seconds = 0
41 m.running = false
42 }
43 case tickMsg:
44 if m.running {
45 m.seconds++
46 return m, tick()
47 }
48 }
49 return m, nil
50}
51
52func (m model) View() string {
53 status := "paused"
54 if m.running {
55 status = "running"
56 }
57
58 mins := m.seconds / 60
59 secs := m.seconds % 60
60
61 return fmt.Sprintf(
62 "Timer [%s]
63
64%02d:%02d
65
66space to start/stop • r to reset • q to quit
67",
68 status, mins, secs,
69 )
70}
71
72func main() {
73 p := tea.NewProgram(model{})
74 if _, err := p.Run(); err != nil {
75 fmt.Fprintf(os.Stderr, "Error: %v", err)
76 os.Exit(1)
77 }
78}Run it, press space to start the timer, space again to pause, r to reset, q to quit. It is a small example but it shows something the docs tutorial does not: how to handle time-based events and async commands alongside regular keypresses. That pattern, returning a tea.Cmd that fires a message back into the loop, is something you will use constantly in real programs.
What Changed in v2
If you find older Bubble Tea tutorials online, a few things will look different. Here is what actually changed.
Declarative Views
In v1, you configured things like fullscreen mode by passing options when creating the program:
// v1
p := tea.NewProgram(model, tea.WithAltScreen())Toggling behavior at runtime meant sending commands from inside Update:
// v1
return m, tea.EnterAltScreenThe problem is that your program's behavior ends up scattered across startup options, runtime commands, and the model. In v2, all of that moves into View:
// v2
func (m model) View() tea.View {
return tea.View{
AltScreen: true,
Content: m.render(),
}
}You declare what you want, and Bubble Tea handles the rest. It is a cleaner mental model, especially as programs get more complex.
New Renderer
v2 ships with a new renderer built from scratch, based on the ncurses rendering algorithm. It is faster and more efficient, and it reduces bandwidth significantly when running over SSH. If you use Wish (covered below), you will notice the improvement.
Import Paths
# v1
go get github.com/charmbracelet/bubbletea
# v2
go get charm.land/bubbletea/v2
go get charm.land/bubbles/v2
go get charm.land/lipgloss/v2Start a new project? Grab all three at once.
The Charm Ecosystem
Bubble Tea is part of a broader set of libraries from Charmbracelet that are worth knowing about.
Bubbles
Pre-built UI components: spinners, progress bars, text inputs, viewports, file pickers, tables. Rather than implementing a text input yourself and handling all the edge cases around cursor movement, backspace, and paste, you pull in the textinput bubble and drop it into your model.
1import "charm.land/bubbles/v2/spinner"
2
3type model struct {
4 spinner spinner.Model
5 loading bool
6 result string
7}
8
9func initialModel() model {
10 s := spinner.New()
11 s.Spinner = spinner.Dot
12 return model{spinner: s, loading: true}
13}
14
15func (m model) View() string {
16 if m.loading {
17 return fmt.Sprintf("%s Fetching data...
18", m.spinner.View())
19 }
20 return fmt.Sprintf("Done: %s
21", m.result)
22}Bubbles are tea.Model implementations themselves, so they compose naturally. You embed them in your model, forward messages to them in Update, and render them in View.
Lip Gloss
Styling library for terminal output. Define styles and apply them to strings.
import "charm.land/lipgloss/v2"
var (
labelStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#04B575"))
timerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#1a1a2e")).
Padding(0, 1)
)
// inside View():
label := labelStyle.Render("Timer")
display := timerStyle.Render("02:34")Lip Gloss automatically handles color downsampling, so your styles adapt to whatever the terminal supports, 24-bit color, 256 colors, or basic ANSI.
Wish
Wish lets you expose a Bubble Tea app over SSH. Users can connect with ssh yourdomain.com and get the full interactive TUI without installing anything locally. Good for shared tooling or demos.
When to Use It and When Not To
Not every CLI needs a TUI.
Bubble Tea makes sense when the user needs to navigate, make selections, or move through a multi-step flow. It also fits tools that get opened repeatedly as part of a workflow, especially when state and navigation are central to what the tool does.
A plain CLI is probably the better call when the program runs, does something, and exits. Prefer a CLI if the output will be piped to another program, if it runs in CI or any non-interactive context, or when fmt.Println is honestly enough.
It is tempting to reach for Bubble Tea for everything once you get comfortable with it. Most of the time a simpler tool is the right one.
My Honest Take
Bubble Tea is genuinely good. The Elm Architecture gives you a clear place for everything: state in the model, behavior in Update, presentation in View. That structure pays off when programs grow, because there is always an obvious place to make a change.
The v2 update is a real improvement. Declarative views simplify a category of state management that got messy in larger v1 programs, and the new renderer matters in practice.
If you want to see how this looks in a real project, I have a few things built with Bubble Tea and Lip Gloss up on my GitHub. Reading actual code is usually faster than reading tutorials for picking up the patterns that matter. Go check it out.
