22 KiB
Bubbletea TUI Framework Guide
A comprehensive guide to Charm's Bubbletea - a Go framework for building terminal user interfaces based on The Elm Architecture.
Table of Contents
- Overview
- The Elm Architecture
- Core Concepts
- Messages and Commands
- Built-in Commands
- Input Handling
- Rendering
- Program Options
- Examples
Overview
Bubbletea is a functional, Elm-inspired framework for building terminal UIs in Go. It enforces a unidirectional data flow pattern that makes applications predictable, testable, and maintainable.
Key characteristics:
- Functional reactive programming model
- Immutable state updates
- Message-driven architecture
- All side effects handled through commands
- Frame-rate limited rendering (60 FPS default)
- Delta rendering for performance
The Elm Architecture
Bubbletea implements the Elm Architecture pattern:
Input → Message → Update → Model → View → Render
↓
Command → Message → (repeat)
The core principle: Your model never directly performs I/O. All side effects flow through Commands, creating predictable, testable code.
The Three Core Methods
Every Bubbletea program implements the tea.Model interface:
type Model interface {
Init() Cmd // Returns initial command
Update(Msg) (Model, Cmd) // Handles messages, returns new state
View() string // Renders current state as string
}
Core Concepts
Model
The model holds all application state. It should be a struct containing everything needed to render the UI:
type model struct {
cursor int // Current cursor position
choices []string // Available options
selected map[int]struct{} // Selected items
quitting bool
}
Init
Called once when the program starts. Returns an optional command to run:
func (m model) Init() tea.Cmd {
// Return nil for no initial command
return nil
// Or return a command to start something
return tea.Batch(
spinner.Tick,
fetchInitialData,
)
}
Update
Receives messages and returns updated model plus optional command:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
}
}
return m, nil
}
View
Returns a string representation of the current state:
func (m model) View() string {
if m.quitting {
return "Goodbye!\n"
}
s := "What do you want for lunch?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress q to quit.\n"
return s
}
Messages and Commands
Messages (Msg)
Messages are events that trigger state updates. They can be:
Built-in messages:
tea.KeyMsg- Keyboard inputtea.MouseMsg- Mouse eventstea.WindowSizeMsg- Terminal resizetea.FocusMsg- Terminal gained focustea.BlurMsg- Terminal lost focustea.QuitMsg- Program should exit
Custom messages:
// Define your own message types
type tickMsg time.Time
type errMsg struct{ error }
type responseMsg struct {
status int
body string
}
Commands (Cmd)
Commands are functions that perform I/O and return messages:
// A Cmd is a function that returns a Msg
type Cmd func() Msg
Creating commands:
// Simple command returning a message
func tick() tea.Msg {
time.Sleep(time.Second)
return tickMsg{}
}
// HTTP request command
func checkServer(url string) tea.Cmd {
return func() tea.Msg {
resp, err := http.Get(url)
if err != nil {
return errMsg{err}
}
defer resp.Body.Close()
return responseMsg{status: resp.StatusCode}
}
}
Built-in Commands
Program Control
tea.Quit // Graceful shutdown (returns QuitMsg)
tea.Suspend // Suspend program (Unix, Ctrl+Z)
Command Composition
// Run multiple commands concurrently
tea.Batch(cmd1, cmd2, cmd3)
// Run commands sequentially (each waits for previous)
tea.Sequence(cmd1, cmd2, cmd3)
Timers
// Fire once after duration
tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
// Fire synchronized to system clock
tea.Every(time.Minute, func(t time.Time) tea.Msg {
return tickMsg(t)
})
Screen Control
tea.ClearScreen // Clear terminal
tea.EnterAltScreen // Full-window mode (no scrollback)
tea.ExitAltScreen // Return to normal mode
tea.ShowCursor // Make cursor visible
tea.HideCursor // Hide cursor
Mouse Control
tea.EnableMouseCellMotion // Clicks, release, drag
tea.EnableMouseAllMotion // All motion including hover
tea.DisableMouse // Disable mouse
External Process Execution
// Run external command (like $EDITOR)
cmd := exec.Command("vim", "file.txt")
tea.ExecProcess(cmd, func(err error) tea.Msg {
return editorFinishedMsg{err}
})
Input Handling
Keyboard Input (KeyMsg)
case tea.KeyMsg:
switch msg.String() {
// Character keys
case "a", "b", "c":
// Special keys
case "enter", "tab", "backspace", "delete":
case "up", "down", "left", "right":
case "home", "end", "pgup", "pgdown":
// Function keys
case "f1", "f2", "f12":
// Control combinations
case "ctrl+c", "ctrl+d", "ctrl+z":
case "alt+enter", "shift+tab":
// Escape
case "esc":
}
// Check key type
switch msg.Type {
case tea.KeyRunes:
// Regular character input
char := string(msg.Runes)
case tea.KeyEnter:
case tea.KeyCtrlC:
}
Mouse Input (MouseMsg)
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonLeft:
if msg.Action == tea.MouseActionPress {
// Handle left click at msg.X, msg.Y
}
case tea.MouseButtonWheelUp:
// Scroll up
case tea.MouseButtonWheelDown:
// Scroll down
}
Window Resize
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
Rendering
How Rendering Works
- After each Update, View() is called
- The returned string is queued for rendering
- A ticker fires at configured FPS (default 60)
- Renderer compares with previous frame (delta detection)
- Only changed lines are written to terminal
Rendering Tips
- Return plain strings from View()
- Use newlines to separate lines
- Use Lipgloss for styling
- Terminal clears and redraws automatically
func (m model) View() string {
// Build your view as a string
var b strings.Builder
b.WriteString("Header\n\n")
for _, item := range m.items {
b.WriteString(item + "\n")
}
b.WriteString("\nFooter")
return b.String()
}
Program Options
Configure program behavior with options:
p := tea.NewProgram(
initialModel,
tea.WithAltScreen(), // Use alternate screen buffer
tea.WithMouseCellMotion(), // Enable mouse (click/drag)
tea.WithMouseAllMotion(), // Enable all mouse events
tea.WithoutSignalHandler(), // Handle Ctrl+C yourself
tea.WithFPS(120), // Custom frame rate
tea.WithReportFocus(), // Get focus/blur events
tea.WithInput(customReader), // Custom input source
tea.WithOutput(customWriter), // Custom output destination
)
// Run the program
finalModel, err := p.Run()
Examples
Example 1: Simple Counter
A minimal example demonstrating the core pattern:
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 "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
m.count++
case "down", "j":
m.count--
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf(
"Count: %d\n\nPress up/down to change, q to quit.\n",
m.count,
)
}
func main() {
p := tea.NewProgram(model{count: 0})
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Example 2: Countdown Timer with Commands
Demonstrates async commands and timers:
package main
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
)
type tickMsg time.Time
type model struct {
seconds int
done bool
}
func (m model) Init() tea.Cmd {
return tick()
}
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case tickMsg:
m.seconds--
if m.seconds <= 0 {
m.done = true
return m, tea.Quit
}
return m, tick()
}
return m, nil
}
func (m model) View() string {
if m.done {
return "Time's up!\n"
}
return fmt.Sprintf("Countdown: %d seconds remaining...\n\nPress q to quit.\n", m.seconds)
}
func main() {
p := tea.NewProgram(model{seconds: 10})
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Example 3: HTTP Request
Async HTTP requests with error handling:
package main
import (
"fmt"
"net/http"
"time"
tea "github.com/charmbracelet/bubbletea"
)
const url = "https://httpstat.us/200"
type statusMsg int
type errMsg struct{ error }
type model struct {
status int
err error
loading bool
}
func checkServer() tea.Msg {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return errMsg{err}
}
defer resp.Body.Close()
return statusMsg(resp.StatusCode)
}
func (m model) Init() tea.Cmd {
return checkServer
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case statusMsg:
m.status = int(msg)
m.loading = false
case errMsg:
m.err = msg.error
m.loading = false
}
return m, nil
}
func (m model) View() string {
if m.loading {
return "Checking server...\n"
}
if m.err != nil {
return fmt.Sprintf("Error: %v\n\nPress q to quit.\n", m.err)
}
return fmt.Sprintf("Server returned: %d\n\nPress q to quit.\n", m.status)
}
func main() {
p := tea.NewProgram(model{loading: true})
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Example 4: Multiple Choice Selection
Interactive list with cursor and selection:
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
func initialModel() model {
return model{
choices: []string{"Pizza", "Sushi", "Tacos", "Burger"},
selected: make(map[int]struct{}),
}
}
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 "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
case "enter", " ":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "What do you want for lunch?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress space to select, q to quit.\n"
return s
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Example 5: Multiple Views / State Machine
Switch between different screens:
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type viewState int
const (
menuView viewState = iota
detailView
)
type model struct {
state viewState
cursor int
items []string
selected string
}
func initialModel() model {
return model{
state: menuView,
items: []string{"Option A", "Option B", "Option C"},
}
}
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 "q", "ctrl+c":
return m, tea.Quit
case "esc":
if m.state == detailView {
m.state = menuView
}
case "up", "k":
if m.state == menuView && m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.state == menuView && m.cursor < len(m.items)-1 {
m.cursor++
}
case "enter":
if m.state == menuView {
m.selected = m.items[m.cursor]
m.state = detailView
}
}
}
return m, nil
}
func (m model) View() string {
switch m.state {
case menuView:
return m.menuView()
case detailView:
return m.detailView()
}
return ""
}
func (m model) menuView() string {
s := "Select an option:\n\n"
for i, item := range m.items {
cursor := " "
if m.cursor == i {
cursor = ">"
}
s += fmt.Sprintf("%s %s\n", cursor, item)
}
s += "\nPress enter to select, q to quit.\n"
return s
}
func (m model) detailView() string {
return fmt.Sprintf(
"You selected: %s\n\nPress esc to go back, q to quit.\n",
m.selected,
)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Example 6: Component Composition
Using sub-components (like spinners):
package main
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/spinner"
)
type model struct {
spinner spinner.Model
loading bool
result string
}
type doneMsg struct{ result string }
func doWork() tea.Msg {
time.Sleep(3 * time.Second)
return doneMsg{result: "Work complete!"}
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
return model{
spinner: s,
loading: true,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
doWork,
)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case doneMsg:
m.loading = false
m.result = msg.result
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m model) View() string {
if m.loading {
return fmt.Sprintf("%s Loading...\n", m.spinner.View())
}
return fmt.Sprintf("%s\n\nPress q to quit.\n", m.result)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Example 7: Text Input Form
Multiple text inputs with focus management:
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/textinput"
)
type model struct {
inputs []textinput.Model
focused int
done bool
}
func initialModel() model {
inputs := make([]textinput.Model, 3)
inputs[0] = textinput.New()
inputs[0].Placeholder = "Name"
inputs[0].Focus()
inputs[0].CharLimit = 50
inputs[1] = textinput.New()
inputs[1].Placeholder = "Email"
inputs[1].CharLimit = 100
inputs[2] = textinput.New()
inputs[2].Placeholder = "Message"
inputs[2].CharLimit = 500
return model{inputs: inputs}
}
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "tab", "shift+tab", "down", "up":
// Cycle focus
if msg.String() == "shift+tab" || msg.String() == "up" {
m.focused--
if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
} else {
m.focused++
if m.focused >= len(m.inputs) {
m.focused = 0
}
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
case "enter":
if m.focused == len(m.inputs)-1 {
m.done = true
return m, tea.Quit
}
m.focused++
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
}
}
// Update all inputs
for i := range m.inputs {
var cmd tea.Cmd
m.inputs[i], cmd = m.inputs[i].Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if m.done {
return fmt.Sprintf(
"Submitted!\nName: %s\nEmail: %s\nMessage: %s\n",
m.inputs[0].Value(),
m.inputs[1].Value(),
m.inputs[2].Value(),
)
}
var b strings.Builder
b.WriteString("Contact Form\n\n")
for i, input := range m.inputs {
b.WriteString(input.View())
if i < len(m.inputs)-1 {
b.WriteString("\n")
}
}
b.WriteString("\n\nTab to move, Enter to submit, Ctrl+C to quit.\n")
return b.String()
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Example 8: Full-Screen with Alt Screen and Mouse
Full-window application with mouse support:
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
width int
height int
mouseX int
mouseY int
clicks 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:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.MouseMsg:
m.mouseX = msg.X
m.mouseY = msg.Y
if msg.Action == tea.MouseActionPress {
m.clicks++
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf(
"Window: %dx%d\nMouse: (%d, %d)\nClicks: %d\n\nClick anywhere! Press q to quit.",
m.width, m.height,
m.mouseX, m.mouseY,
m.clicks,
)
}
func main() {
p := tea.NewProgram(
model{},
tea.WithAltScreen(), // Full-screen mode
tea.WithMouseAllMotion(), // Track all mouse movement
)
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
Key Architectural Patterns
1. Unidirectional Data Flow
All data flows in one direction: Input → Message → Update → Model → View
2. Immutability
Update returns a new model rather than mutating the existing one.
3. Commands for Side Effects
All I/O happens through Commands - never in Update or View.
4. Message Types for Events
Use custom message types to represent domain events.
5. Component Delegation
Sub-components handle their own Update and View:
var cmd tea.Cmd
m.subComponent, cmd = m.subComponent.Update(msg)
return m, cmd
6. State Machine for Views
Use an enum to track which view is active:
switch m.state {
case loading:
return loadingView(m)
case ready:
return mainView(m)
case error:
return errorView(m)
}
Related Libraries
- Bubbles - Pre-built components (spinner, textinput, list, table, etc.)
- Lipgloss - Styling and layout
- Harmonica - Smooth animations
- Glamour - Markdown rendering