# 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 1. [Overview](#overview) 2. [The Elm Architecture](#the-elm-architecture) 3. [Core Concepts](#core-concepts) 4. [Messages and Commands](#messages-and-commands) 5. [Built-in Commands](#built-in-commands) 6. [Input Handling](#input-handling) 7. [Rendering](#rendering) 8. [Program Options](#program-options) 9. [Examples](#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: ```go 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: ```go 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: ```go 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: ```go 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: ```go 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 input - `tea.MouseMsg` - Mouse events - `tea.WindowSizeMsg` - Terminal resize - `tea.FocusMsg` - Terminal gained focus - `tea.BlurMsg` - Terminal lost focus - `tea.QuitMsg` - Program should exit **Custom messages:** ```go // 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: ```go // A Cmd is a function that returns a Msg type Cmd func() Msg ``` **Creating commands:** ```go // 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 ```go tea.Quit // Graceful shutdown (returns QuitMsg) tea.Suspend // Suspend program (Unix, Ctrl+Z) ``` ### Command Composition ```go // Run multiple commands concurrently tea.Batch(cmd1, cmd2, cmd3) // Run commands sequentially (each waits for previous) tea.Sequence(cmd1, cmd2, cmd3) ``` ### Timers ```go // 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 ```go 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 ```go tea.EnableMouseCellMotion // Clicks, release, drag tea.EnableMouseAllMotion // All motion including hover tea.DisableMouse // Disable mouse ``` ### External Process Execution ```go // 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) ```go 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) ```go 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 ```go case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height ``` --- ## Rendering ### How Rendering Works 1. After each Update, View() is called 2. The returned string is queued for rendering 3. A ticker fires at configured FPS (default 60) 4. Renderer compares with previous frame (delta detection) 5. Only changed lines are written to terminal ### Rendering Tips - Return plain strings from View() - Use newlines to separate lines - Use [Lipgloss](https://github.com/charmbracelet/lipgloss) for styling - Terminal clears and redraws automatically ```go 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: ```go 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: ```go 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: ```go 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: ```go 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: ```go 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: ```go 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): ```go 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: ```go 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: ```go 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: ```go 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: ```go switch m.state { case loading: return loadingView(m) case ready: return mainView(m) case error: return errorView(m) } ``` --- ## Related Libraries - **[Bubbles](https://github.com/charmbracelet/bubbles)** - Pre-built components (spinner, textinput, list, table, etc.) - **[Lipgloss](https://github.com/charmbracelet/lipgloss)** - Styling and layout - **[Harmonica](https://github.com/charmbracelet/harmonica)** - Smooth animations - **[Glamour](https://github.com/charmbracelet/glamour)** - Markdown rendering --- ## Resources - [Bubbletea GitHub](https://github.com/charmbracelet/bubbletea) - [Official Examples](https://github.com/charmbracelet/bubbletea/tree/master/examples) - [The Elm Architecture](https://guide.elm-lang.org/architecture/) - [Charm Website](https://charm.sh)