Files
clojure-tui/bubbletea-guide.md
2026-01-21 01:21:05 -05:00

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

  1. Overview
  2. The Elm Architecture
  3. Core Concepts
  4. Messages and Commands
  5. Built-in Commands
  6. Input Handling
  7. Rendering
  8. Program Options
  9. 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 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:

// 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

  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 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)
}

  • Bubbles - Pre-built components (spinner, textinput, list, table, etc.)
  • Lipgloss - Styling and layout
  • Harmonica - Smooth animations
  • Glamour - Markdown rendering

Resources