most cli tools follow the same pattern. parse flags, do work, print output, exit. this works until the tool needs to present choices, show progress, or let the user navigate through data interactively. at that point the options are either shelling out to fzf, building a web ui, or reaching for a tui framework.
bubbletea is a go library for building terminal user interfaces. it’s built on the elm architecture - a pattern where the entire ui is a function of state, and state changes only happen through messages. no callbacks, no shared mutable state, no goroutines updating the screen from different places. the result is tui code that reads like a state machine and behaves predictably. the examples in this post target bubbletea v1.
the short version
a bubbletea program has three methods: Init, Update, and View. Init returns an initial command. Update receives messages and returns new state. View renders the current state as a string. the framework handles the terminal, input events, and screen rendering. the application only deals with data and how to display it.
the elm architecture in a terminal
the elm architecture separates an application into three concerns. the model holds state. the update function takes a message and the current model, and returns a new model. the view function takes the model and returns what the user sees.
in bubbletea this maps directly to a go interface:
type Model interface {
Init() tea.Cmd
Update(tea.Msg) (tea.Model, tea.Cmd)
View() string
}
tea.Msg is an empty interface - any value can be a message. tea.Cmd is a function that returns a message, used for async operations like http requests or timers. returning nil from Update as the command means no side effect.
the framework runs a loop:
┌──────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐
│ Init() │ ──▶ │ Model │ ──▶ │ View() │ ──▶ │ terminal │ ──▶ │ Update() │
└──────────┘ └─────────┘ └──────────┘ └──────────┘ └──────┬─────┘
once at ▲ renders key press new model
startup │ string resize + tea.Cmd
│ tea.Cmd result │
└────────────────────────────────────────────────────┘
render the view, wait for input or a command result, call update, render again. the application never touches the terminal directly.
a minimal example
a counter that increments and decrements with keyboard input:
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
count int
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "k", "up":
m.count++
case "j", "down":
m.count--
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf("\n count: %d\n\n j/k to change • q to quit\n", m.count)
}
func main() {
p := tea.NewProgram(model{})
if _, err := p.Run(); err != nil {
fmt.Println(err)
}
}
the model is a struct with one field. Update pattern-matches on the message type - tea.KeyMsg for keyboard input - and returns a modified model. View renders the state as a string. the entire ui is 40 lines with no terminal manipulation.
tea.Quit is a command that tells the framework to shut down. without it, the program loops indefinitely.
messages and commands
bubbletea’s message system handles everything from keyboard input to async operations. the framework provides built-in message types:
tea.KeyMsgfor key pressestea.WindowSizeMsgwhen the terminal is resizedtea.MouseMsgfor mouse events (when enabled)
custom messages are plain go types. a command that fetches data returns a message with the result:
type fetchResultMsg struct {
items []string
err error
}
func fetchItems() tea.Msg {
resp, err := http.Get("https://api.example.com/items")
if err != nil {
return fetchResultMsg{err: err}
}
defer resp.Body.Close()
var items []string
json.NewDecoder(resp.Body).Decode(&items)
return fetchResultMsg{items: items}
}
returning fetchItems as a tea.Cmd from Init or Update runs it asynchronously. bubbletea handles the goroutine and delivers the resulting message back to Update:
func (m model) Init() tea.Cmd {
return fetchItems
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case fetchResultMsg:
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.items = msg.items
return m, nil
}
return m, nil
}
the update function never blocks. all io happens in commands, and results arrive as messages. this keeps the ui responsive during network calls or file operations.
composing with bubbles
the bubbletea ecosystem has a companion library called bubbles - a collection of reusable components. text inputs, spinners, lists, tables, paginators, and file pickers. each bubble is its own model with Init, Update, and View methods that compose into the parent model.
a text input with a spinner while loading:
import (
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
)
type model struct {
input textinput.Model
spinner spinner.Model
loading bool
result string
}
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "search..."
ti.Focus()
s := spinner.New()
s.Spinner = spinner.Dot
return model{input: ti, spinner: s}
}
child components receive messages through the parent’s Update. the parent delegates by calling the child’s Update and storing the returned model:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" && !m.loading {
m.loading = true
return m, tea.Batch(m.spinner.Tick, search(m.input.Value()))
}
case searchResultMsg:
m.loading = false
m.result = string(msg)
return m, nil
}
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
} else {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
tea.Batch combines multiple commands into one. the spinner needs its Tick command to animate, and the text input needs key events to update its value. both run through the same message pipeline.
styling with lipgloss
lipgloss is the styling library from the same ecosystem. it provides a css-like api for terminal styling - colors, borders, padding, alignment, and layout:
import "github.com/charmbracelet/lipgloss"
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("170")).
PaddingLeft(2)
itemStyle = lipgloss.NewStyle().
PaddingLeft(4)
selectedStyle = lipgloss.NewStyle().
PaddingLeft(2).
Foreground(lipgloss.Color("170")).
SetString("→ ")
)
styles are applied in the View function:
func (m model) View() string {
s := titleStyle.Render("select a namespace") + "\n\n"
for i, item := range m.items {
if i == m.cursor {
s += selectedStyle.Render(item) + "\n"
} else {
s += itemStyle.Render(item) + "\n"
}
}
return s
}
lipgloss handles ansi escape codes, terminal color detection, and width calculations. the application works with strings and styles, not escape sequences.
what to watch out for
the view function is called on every update. it should be fast and allocation-light. avoid formatting large datasets on every render - precompute what’s visible based on the terminal height and a scroll offset.
commands run in goroutines. they should not reference or mutate the model directly. return data through messages. the model is only safe to modify inside Update.
terminal state is managed by the framework. calling fmt.Println from inside a bubbletea program corrupts the display. use the View method for all output. for logging during development, write to a file instead of stdout.
alternate screen buffer. by default bubbletea uses the inline rendering mode. for full-screen applications, use tea.WithAltScreen() when creating the program. this switches to the alternate screen buffer and restores the original terminal on exit.
references
[1] charm. “bubbletea.”
github.com/charmbracelet/bubbletea
[2] charm. “bubbles.”
github.com/charmbracelet/bubbles
[3] charm. “lipgloss.”
github.com/charmbracelet/lipgloss
[4] evan czaplicki. “the elm architecture.”
guide.elm-lang.org/architecture