Bubble Tea TUI Framework Skill
Overview
Bubble Tea is a powerful TUI framework for Go based on The Elm Architecture. Every program has a Model (state) and three methods: Init() (initial command), Update() (handle messages), View() (render UI as string).
This skill covers the complete Charm ecosystem:
-
Bubble Tea - Core TUI framework
-
Bubbles - Reusable UI components
-
Lip Gloss - Terminal styling
-
Huh - Interactive forms
Installation
go get github.com/charmbracelet/bubbletea go get github.com/charmbracelet/bubbles # UI components go get github.com/charmbracelet/lipgloss # Styling go get github.com/charmbracelet/huh # Forms
Quick Start
package main
import ( "fmt" "log" tea "github.com/charmbracelet/bubbletea" )
type model struct { cursor int choices []string selected 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 "ctrl+c", "q": 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 := "Choose:\n\n" for i, choice := range m.choices { cursor, checked := " ", " " if m.cursor == i { cursor = ">" } if _, ok := m.selected[i]; ok { checked = "x" } s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) } return s + "\nq to quit.\n" }
func main() { p := tea.NewProgram(model{ choices: []string{"Option A", "Option B", "Option C"}, selected: make(map[int]struct{}), }) if _, err := p.Run(); err != nil { log.Fatal(err) } }
Core Concepts
Messages and Commands
// Custom messages type tickMsg time.Time type dataMsg struct{ data string } type errMsg struct{ error }
// Command returns a message (runs async) func fetchData() tea.Msg { resp, err := http.Get("https://api.example.com") if err != nil { return errMsg{err} } defer resp.Body.Close() // ... process response return dataMsg{data: "result"} }
// Handle in Update func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case dataMsg: m.data = msg.data case errMsg: m.err = msg.error } return m, nil }
// Start command in Init func (m model) Init() tea.Cmd { return fetchData // Runs async, sends message when done }
Batch and Sequence Commands
// Parallel execution return tea.Batch(cmd1, cmd2, cmd3)
// Sequential execution return tea.Sequence(step1, step2, tea.Quit)
Window Size Handling
type model struct { width, height int viewport viewport.Model ready bool }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height-4) m.viewport.SetContent(m.content) m.ready = true } else { m.viewport.Width = msg.Width m.viewport.Height = msg.Height - 4 } } return m, nil }
Common Patterns
Program Options
// Fullscreen p := tea.NewProgram(m, tea.WithAltScreen())
// Mouse support p := tea.NewProgram(m, tea.WithMouseCellMotion())
// Combined p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
Key Bindings with bubbles/key
import "github.com/charmbracelet/bubbles/key"
type keyMap struct { Up key.Binding Down key.Binding Quit key.Binding }
var keys = keyMap{ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), }
// In Update case tea.KeyMsg: switch { case key.Matches(msg, keys.Up): m.cursor-- case key.Matches(msg, keys.Quit): return m, tea.Quit }
Multiple Views
type model struct { state int // 0=menu, 1=detail }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case 0: return m.updateMenu(msg) case 1: return m.updateDetail(msg) } return m, nil }
func (m model) View() string { switch m.state { case 0: return m.menuView() case 1: return m.detailView() } return "" }
Bubbles Components
Component Quick Reference
Component Import Init Key Method
Spinner bubbles/spinner
spinner.New()
.Tick in Init
TextInput bubbles/textinput
textinput.New()
.Focus() , .Value()
TextArea bubbles/textarea
textarea.New()
.Focus() , .Value()
List bubbles/list
list.New(items, delegate, w, h)
.SelectedItem()
Table bubbles/table
table.New(opts...)
.SelectedRow()
Viewport bubbles/viewport
viewport.New(w, h)
.SetContent() , .ScrollUp() , .ScrollDown()
Progress bubbles/progress
progress.New()
.SetPercent()
Help bubbles/help
help.New()
.View(keyMap)
FilePicker bubbles/filepicker
filepicker.New()
.DidSelectFile()
Timer bubbles/timer
timer.New(duration)
.Toggle()
Stopwatch bubbles/stopwatch
stopwatch.New()
.Elapsed()
Focus Management (Multiple Inputs)
type model struct { inputs []textinput.Model focusIndex int }
func (m *model) nextInput() tea.Cmd { m.inputs[m.focusIndex].Blur() m.focusIndex = (m.focusIndex + 1) % len(m.inputs) return m.inputs[m.focusIndex].Focus() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "tab": return m, m.nextInput() } } // Update all inputs cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } return m, tea.Batch(cmds...) }
Component Composition
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd
// Update all components and collect commands
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
Spinner Example
s := spinner.New() s.Spinner = spinner.Dot // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
// In Init(): return m.spinner.Tick // In Update(): handle spinner.TickMsg
List with Custom Items
type item struct{ title, desc string }
func (i item) Title() string { return i.title } func (i item) Description() string { return i.desc } func (i item) FilterValue() string { return i.title } // Required for filtering
items := []list.Item{item{"One", "Description"}} l := list.New(items, list.NewDefaultDelegate(), 30, 10) l.Title = "Select Item"
Table
columns := []table.Column{ {Title: "ID", Width: 10}, {Title: "Name", Width: 20}, } rows := []table.Row{ {"1", "Alice"}, {"2", "Bob"}, } t := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(true), table.WithHeight(7), )
Lip Gloss Styling
Basic Styling
import "github.com/charmbracelet/lipgloss"
var style = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("205")). Background(lipgloss.Color("#7D56F4")). Border(lipgloss.RoundedBorder()). Padding(1, 2)
output := style.Render("Hello, World!")
Colors
// Hex colors lipgloss.Color("#FF00FF")
// ANSI 256 colors lipgloss.Color("205")
// Adaptive colors (auto light/dark background) lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}
// Complete color profiles lipgloss.CompleteColor{ TrueColor: "#FF00FF", ANSI256: "205", ANSI: "5", }
Layout Composition
// Horizontal layout left := lipgloss.NewStyle().Width(20).Render("Left") right := lipgloss.NewStyle().Width(20).Render("Right") row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// Vertical layout header := "Header" body := "Body content" footer := "Footer" page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
// Center content in box centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)
Frame Size for Calculations
// Account for padding/border/margin in calculations h, v := docStyle.GetFrameSize() m.list.SetSize(m.width-h, m.height-v)
// Dynamic content height headerH := lipgloss.Height(m.header()) footerH := lipgloss.Height(m.footer()) m.viewport.Height = m.height - headerH - footerH
Border Styles
lipgloss.NormalBorder() // Standard box lipgloss.RoundedBorder() // Rounded corners lipgloss.ThickBorder() // Thick lines lipgloss.DoubleBorder() // Double lines lipgloss.HiddenBorder() // Invisible (for spacing)
Huh Forms
Quick Start
import "github.com/charmbracelet/huh"
var name string var confirmed bool
form := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("What's your name?"). Value(&name),
huh.NewConfirm().
Title("Ready to proceed?").
Value(&confirmed),
),
)
err := form.Run() if err != nil { if err == huh.ErrUserAborted { fmt.Println("Cancelled") return } log.Fatal(err) }
Field Types (GENERIC TYPES REQUIRED)
// Input - single line text huh.NewInput().Title("Name").Value(&name)
// Text - multi-line huh.NewText().Title("Bio").Lines(5).Value(&bio)
// Select - MUST specify type huh.NewSelectstring. Title("Choose"). Options( huh.NewOption("Option A", "a"), huh.NewOption("Option B", "b"), ). Value(&choice)
// MultiSelect - MUST specify type
huh.NewMultiSelectstring.
Title("Choose many").
Options(huh.NewOptions("A", "B", "C")...).
Limit(2).
Value(&choices)
// Confirm huh.NewConfirm(). Title("Sure?"). Affirmative("Yes"). Negative("No"). Value(&confirmed)
// FilePicker huh.NewFilePicker(). Title("Select file"). AllowedTypes([]string{".go", ".md"}). Value(&filepath)
Validation
huh.NewInput(). Title("Email"). Value(&email). Validate(func(s string) error { if !strings.Contains(s, "@") { return errors.New("invalid email") } return nil })
Dynamic Forms (OptionsFunc/TitleFunc)
var country string var state string
huh.NewSelectstring. Value(&state). TitleFunc(func() string { if country == "Canada" { return "Province" } return "State" }, &country). // Recompute when country changes OptionsFunc(func() []huh.Option[string] { return huh.NewOptions(getStatesFor(country)...) }, &country)
Bubble Tea Integration
type Model struct { form *huh.Form }
func (m Model) Init() tea.Cmd { return m.form.Init() }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f }
if m.form.State == huh.StateCompleted {
// Form completed - access values
name := m.form.GetString("name")
return m, tea.Quit
}
return m, cmd
}
func (m Model) View() string { if m.form.State == huh.StateCompleted { return "Done!" } return m.form.View() }
Themes
form.WithTheme(huh.ThemeCharm()) // Default pink/purple form.WithTheme(huh.ThemeDracula()) // Dark purple/pink form.WithTheme(huh.ThemeCatppuccin()) // Pastel form.WithTheme(huh.ThemeBase16()) // Muted form.WithTheme(huh.ThemeBase()) // Minimal
Spinner for Loading
import "github.com/charmbracelet/huh/spinner"
err := spinner.New(). Title("Processing..."). Action(func() { time.Sleep(2 * time.Second) processData() }). Run()
Common Gotchas
Bubble Tea Core
Blocking in Update/View: Never block. Use commands for I/O:
// BAD time.Sleep(time.Second) // Blocks event loop!
// GOOD return m, func() tea.Msg { time.Sleep(time.Second) return doneMsg{} }
Goroutines modifying model: Race condition! Use commands instead:
// BAD go func() { m.data = fetch() }() // Race!
// GOOD return m, func() tea.Msg { return dataMsg{fetch()} }
Viewport before WindowSizeMsg: Initialize after receiving dimensions:
if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height) m.ready = true }
Using receiver methods incorrectly: Only use func (m *model) for internal helpers. Model interface methods must use value receivers func (m model) .
Startup commands via Init: Don't use tea.EnterAltScreen in Init. Use tea.WithAltScreen() option instead.
Messages not in order: tea.Batch results arrive in ANY order. Use tea.Sequence when order matters.
Bubbles Components
Spinner not animating: Must return m.spinner.Tick from Init() and handle spinner.TickMsg in Update.
TextInput not accepting input: Must call .Focus() before input accepts keystrokes.
Component updates not reflected: Always reassign after Update:
m.spinner, cmd = m.spinner.Update(msg) // Must reassign!
Multiple components losing commands: Use tea.Batch(cmds...) to combine all commands.
List items not filtering: Must implement FilterValue() string on items.
Table not responding: Must set table.WithFocused(true) or call t.Focus() .
Lip Gloss
Style method has no effect: Styles are immutable, must reassign:
// BAD style.Bold(true) // Result discarded!
// GOOD style = style.Bold(true)
Alignment not working: Requires explicit Width:
style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center)
Width calculation wrong: Use lipgloss.Width() not len() for unicode.
Layout arithmetic errors: Use style.GetFrameSize() to account for padding/border/margin.
Huh Forms
Generic types required: Select and MultiSelect MUST have type parameter:
// BAD - won't compile huh.NewSelect()
// GOOD huh.NewSelectstring
Value takes pointer: Always pass pointer: .Value(&myVar) not .Value(myVar) .
OptionsFunc not updating: Must pass binding variable:
.OptionsFunc(fn, &country) // Recomputes when country changes
Don't use Placeholder() in huh forms: Causes rendering bugs. Put examples in Description instead:
// BAD huh.NewInput().Placeholder("example@email.com")
// GOOD huh.NewInput().Description("e.g. example@email.com")
Loop variable closure capture: Capture explicitly in loops:
for _, name := range items { currentName := name // Capture! huh.NewInput().Value(&configs[currentName].Field) }
Don't intercept Enter before form: Let huh handle Enter for navigation.
Debugging
// Log to file (stdout is the TUI) if os.Getenv("DEBUG") != "" { f, _ := tea.LogToFile("debug.log", "app") defer f.Close() } log.Println("Debug message")
Best Practices
-
Keep Update/View fast - The event loop blocks on these
-
Use tea.Cmd for all I/O - HTTP, file, database operations
-
Use tea.Batch for parallel - Multiple independent commands
-
Use tea.Sequence for ordered - Commands that must run in order
-
Store window dimensions - Handle tea.WindowSizeMsg, update components
-
Initialize viewport after WindowSizeMsg - Dimensions aren't available at start
-
Use value receivers - func (m model) Update not func (m *model) Update
-
Define styles as package variables - Reuse instead of creating in loops
-
Use AdaptiveColor - For light/dark terminal support
-
Handle ErrUserAborted - Graceful Ctrl+C handling in huh forms
Additional Resources
-
references/API.md - Complete API reference
-
references/EXAMPLES.md - Extended code examples
-
references/TROUBLESHOOTING.md - Common errors and solutions
Essential Imports
import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" // Static tables "github.com/charmbracelet/lipgloss/tree" // Hierarchical data "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/table" // Interactive tables "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/filepicker" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/timer" "github.com/charmbracelet/bubbles/stopwatch" "github.com/charmbracelet/huh" "github.com/charmbracelet/huh/spinner" "github.com/charmbracelet/wish" // SSH TUI server "github.com/lrstanley/bubblezone" // Mouse zones )
Lip Gloss Tree (Hierarchical Data)
Render tree structures with customizable enumerators:
import "github.com/charmbracelet/lipgloss/tree"
t := tree.Root("Root"). Child("Child 1"). Child( tree.Root("Child 2"). Child("Grandchild 1"). Child("Grandchild 2"), ). Child("Child 3")
// Custom enumerator t.Enumerator(tree.RoundedEnumerator) // ├── └── t.Enumerator(tree.BulletEnumerator) // • bullets t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.
// Custom styling t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))) t.ItemStyle(lipgloss.NewStyle().Bold(true))
fmt.Println(t)
Lip Gloss Table (Static Rendering)
For high-performance static tables (reports, logs). Use bubbles/table for interactive selection.
import "github.com/charmbracelet/lipgloss/table"
t := table.New(). Border(lipgloss.NormalBorder()). BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))). Headers("NAME", "AGE", "CITY"). Row("Alice", "30", "NYC"). Row("Bob", "25", "LA"). Wrap(true). // Enable text wrapping StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { return lipgloss.NewStyle().Bold(true) } return lipgloss.NewStyle() })
fmt.Println(t)
When to use which table:
-
lipgloss/table : Static rendering, reports, logs, non-interactive
-
bubbles/table : Interactive selection, keyboard navigation, focused rows
Custom Component Pattern (Sub-Model)
Create reusable Bubble Tea components by exposing Init , Update , View :
// counter.go - Reusable component package counter
import tea "github.com/charmbracelet/bubbletea"
type Model struct { Count int Min int Max int }
func New(min, max int) Model { return Model{Min: min, Max: max} }
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "+", "=": if m.Count < m.Max { m.Count++ } case "-": if m.Count > m.Min { m.Count-- } } } return m, nil }
func (m Model) View() string { return fmt.Sprintf("Count: %d", m.Count) }
// parent.go - Using the component type parentModel struct { counter counter.Model }
func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.counter, cmd = m.counter.Update(msg) return m, cmd }
ProgramContext Pattern (Production)
Share global state across components without prop drilling:
// context.go type ProgramContext struct { Config *Config Theme *Theme Width int Height int StartTask func(Task) tea.Cmd // Callback for background tasks }
// model.go type Model struct { ctx *ProgramContext sidebar sidebar.Model main main.Model tasks map[string]Task spinner spinner.Model }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.ctx.Width = msg.Width m.ctx.Height = msg.Height // Sync all components m.sidebar.SetHeight(msg.Height) m.main.SetSize(msg.Width - sidebarWidth, msg.Height) } return m, nil }
// Initialize context with task callback func NewModel(cfg *Config) Model { ctx := &ProgramContext{Config: cfg} m := Model{ctx: ctx, tasks: make(map[string]Task)}
ctx.StartTask = func(t Task) tea.Cmd {
m.tasks[t.ID] = t
return m.spinner.Tick
}
return m
}
Testing with teatest
import ( "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" )
func TestModel(t *testing.T) { m := NewModel() tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// Send key presses
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// Wait for specific output
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return strings.Contains(string(bts), "Selected")
}, teatest.WithDuration(time.Second))
// Check final state
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
final := tm.FinalModel(t).(Model)
if final.selected != "expected" {
t.Errorf("expected 'expected', got %q", final.selected)
}
}
SSH Integration with Wish
Serve TUI apps over SSH:
import ( "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/bubbletea" "github.com/charmbracelet/ssh" )
func main() { s, err := wish.NewServer( wish.WithAddress(":2222"), wish.WithHostKeyPath(".ssh/term_info_ed25519"), wish.WithMiddleware( bubbletea.Middleware(teaHandler), ), ) if err != nil { log.Fatal(err) }
log.Println("Starting SSH server on :2222")
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { pty, _, _ := s.Pty() m := NewModel(pty.Window.Width, pty.Window.Height) return m, []tea.ProgramOption{tea.WithAltScreen()} }
Mouse Zones with bubblezone
Define clickable regions without coordinate math:
import zone "github.com/lrstanley/bubblezone"
type model struct { zone *zone.Manager }
func newModel() model { return model{zone: zone.New()} }
func (m model) View() string { // Wrap clickable elements button1 := m.zone.Mark("btn1", "[Button 1]") button2 := m.zone.Mark("btn2", "[Button 2]")
return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.MouseMsg: // Check which zone was clicked if m.zone.Get("btn1").InBounds(msg) { // Button 1 clicked } if m.zone.Get("btn2").InBounds(msg) { // Button 2 clicked } } return m, nil }
// Wrap program with zone.NewGlobal() for simpler API func main() { zone.NewGlobal() p := tea.NewProgram(newModel(), tea.WithMouseCellMotion()) p.Run() }