
Bubbletea Code Review
- 228 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Review Go Bubble Tea TUI code for state handling, rendering patterns, keyboard UX, and performance issues before shipping terminal CLI features.
About
Specialized code review skill for Go Bubble Tea terminal UIs, enforcing idiomatic Elm architecture, correct model-update-view flow, efficient rendering, and polished keyboard-driven UX before merge.
- Bubble Tea idioms
- TUI state review
- Keyboard UX checks
- Render performance
- Go CLI patterns
Bubbletea Code Review by the numbers
- 228 all-time installs (skills.sh)
- Ranked #33 of 98 Go skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill bubbletea-code-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 228 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Review Go Bubble Tea TUI code for state handling, rendering patterns, keyboard UX, and performance issues before shipping terminal CLI features.
Files
BubbleTea Code Review
Hard gates (sequence)
Advance only when each pass condition is objectively true (reduces false positives on tea.Cmd and unsubstantiated blocking claims):
| Gate | Pass condition |
|---|---|
| G1 — Anti–false-positive | You skimmed NOT Issues below or read references/elm-architecture.md before recording a finding about tea.Cmd returns, value receivers on Update, or nested child Update. |
| G2 — Evidence for blocking / suspicious I/O | Each Critical/Major finding names file path + line (or a short quoted snippet) showing the blocking call, huh.Form.Run in the wrong place, or other asserted anti-pattern—not a hypothetical. |
| G3 — Verification | Before publishing review output, you applied the [review-verification-protocol](../review-verification-protocol/SKILL.md) to each proposed finding. |
Quick Reference
| Issue Type | Reference |
|---|---|
| Elm architecture, tea.Cmd as data | references/elm-architecture.md |
| Model state, message handling | references/model-update.md |
| View rendering, Lipgloss styling | references/view-styling.md |
| Component composition, Huh forms | references/composition.md |
| Bubbles components (list, table, etc.) | references/bubbles-components.md |
CRITICAL: Avoid False Positives
Read [elm-architecture.md](references/elm-architecture.md) first! The most common review mistake is flagging correct patterns as bugs.
NOT Issues (Do NOT Flag These)
| Pattern | Why It's Correct |
|---|---|
return m, m.loadData() | tea.Cmd is returned immediately; runtime executes async |
Value receiver on Update() | Standard BubbleTea pattern; model returned by value |
Nested m.child, cmd = m.child.Update(msg) | Normal component composition |
Helper functions returning tea.Cmd | Creates command descriptor, no I/O in Update |
tea.Batch(cmd1, cmd2) | Commands execute concurrently by runtime |
ACTUAL Issues (DO Flag These)
| Pattern | Why It's Wrong |
|---|---|
os.ReadFile() in Update | Blocks UI thread |
http.Get() in Update | Network I/O blocks |
time.Sleep() in Update | Freezes UI |
<-channel in Update (blocking) | May block indefinitely |
huh.Form.Run() in Update | Blocking call |
Review Checklist
Architecture
- [ ] No blocking I/O in Update() (file, network, sleep)
- [ ] Helper functions returning
tea.Cmdare NOT flagged as blocking - [ ] Commands used for all async operations
Model & Update
- [ ] Model is immutable (Update returns new model, not mutates)
- [ ] Init returns proper initial command (or nil)
- [ ] Update handles all expected message types
- [ ] WindowSizeMsg handled for responsive layout
- [ ] tea.Batch used for multiple commands
- [ ] tea.Quit used correctly for exit
View & Styling
- [ ] View is a pure function (no side effects)
- [ ] Lipgloss styles defined once, not in View
- [ ] Key bindings use key.Matches with help.KeyMap
Components
- [ ] Sub-component updates propagated correctly
- [ ] Bubbles components initialized with dimensions
- [ ] Huh forms embedded via Update loop (not Run())
Critical Patterns
Model Must Be Immutable
// BAD - mutates model
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.items = append(m.items, newItem) // mutation!
return m, nil
}
// GOOD - returns new model
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
newItems := make([]Item, len(m.items)+1)
copy(newItems, m.items)
newItems[len(m.items)] = newItem
m.items = newItems
return m, nil
}Commands for Async/IO
// BAD - blocking in Update
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
data, _ := os.ReadFile("config.json") // blocks UI!
m.config = parse(data)
return m, nil
}
// GOOD - use commands
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, loadConfigCmd()
}
func loadConfigCmd() tea.Cmd {
return func() tea.Msg {
data, err := os.ReadFile("config.json")
if err != nil {
return errMsg{err}
}
return configLoadedMsg{parse(data)}
}
}Styles Defined Once
// BAD - creates new style each render
func (m Model) View() string {
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
return style.Render("Hello")
}
// GOOD - define styles at package level or in model
var titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
func (m Model) View() string {
return titleStyle.Render("Hello")
}When to Load References
- First time reviewing BubbleTea → elm-architecture.md (prevents false positives)
- Reviewing Update function logic → model-update.md
- Reviewing View function, styling → view-styling.md
- Reviewing component hierarchy → composition.md
- Using Bubbles components → bubbles-components.md
Review Questions
1. Is Update() free of blocking I/O? (NOT: "is the cmd helper blocking?") 2. Is the model immutable in Update? 3. Are Lipgloss styles defined once, not in View? 4. Is WindowSizeMsg handled for resizing? 5. Are key bindings documented with help.KeyMap? 6. Are Bubbles components sized correctly?
Bubbles Component Reference
Complete reference for all charmbracelet/bubbles components.
Component Overview
| Component | Package | Purpose |
|---|---|---|
| list | bubbles/list | Scrollable list with filtering |
| table | bubbles/table | Tabular data display |
| viewport | bubbles/viewport | Scrollable content area |
| textinput | bubbles/textinput | Single-line text input |
| textarea | bubbles/textarea | Multi-line text input |
| spinner | bubbles/spinner | Loading indicator |
| progress | bubbles/progress | Progress bar |
| paginator | bubbles/paginator | Page navigation |
| filepicker | bubbles/filepicker | File/directory selection |
| timer | bubbles/timer | Countdown timer |
| stopwatch | bubbles/stopwatch | Elapsed time counter |
| help | bubbles/help | Key binding help display |
| key | bubbles/key | Key binding definitions |
| cursor | bubbles/cursor | Text cursor management |
---
List
Full-featured list with filtering, pagination, and custom delegates.
Basic Setup
import "github.com/charmbracelet/bubbles/list"
// Items must implement list.Item
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 }
// Create list
items := []list.Item{
item{title: "Raspberry Pi", desc: "A small computer"},
item{title: "Arduino", desc: "A microcontroller"},
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "My List"Common Patterns
// Update list size on window resize
case tea.WindowSizeMsg:
h, v := docStyle.GetFrameSize()
m.list.SetSize(msg.Width-h, msg.Height-v)
// Get selected item
if i, ok := m.list.SelectedItem().(item); ok {
return i.title
}
// Set items dynamically
m.list.SetItems(newItems)
// Custom delegate for styling
delegate := list.NewDefaultDelegate()
delegate.Styles.SelectedTitle = selectedTitleStyle
delegate.Styles.SelectedDesc = selectedDescStyleAnti-Patterns
// ❌ BAD - reaching into internals
selected := m.list.Items()[m.list.Index()]
// ✅ GOOD - use provided methods
selected := m.list.SelectedItem()---
Table
Tabular data with column definitions and row selection.
Basic Setup
import "github.com/charmbracelet/bubbles/table"
columns := []table.Column{
{Title: "Name", Width: 20},
{Title: "Email", Width: 30},
{Title: "Role", Width: 15},
}
rows := []table.Row{
{"Alice", "alice@example.com", "Admin"},
{"Bob", "bob@example.com", "User"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(10),
)
// Apply styles
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder())
s.Selected = s.Selected.Foreground(lipgloss.Color("229"))
t.SetStyles(s)Common Patterns
// Get selected row
selectedRow := m.table.SelectedRow()
// Update rows
m.table.SetRows(newRows)
// Handle selection
case tea.KeyMsg:
switch msg.String() {
case "enter":
row := m.table.SelectedRow()
return m, selectRowCmd(row)
}---
Viewport
Scrollable content area for large text or rendered content.
Basic Setup
import "github.com/charmbracelet/bubbles/viewport"
vp := viewport.New(80, 20)
vp.SetContent(longContent)
// In Update
case tea.WindowSizeMsg:
vp.Width = msg.Width
vp.Height = msg.Height - headerHeight - footerHeightCommon Patterns
// Track scroll position
func (m Model) footerView() string {
return fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)
}
// Programmatic scrolling
m.viewport.GotoTop()
m.viewport.GotoBottom()
m.viewport.LineDown(5)
m.viewport.LineUp(5)
// Update content
m.viewport.SetContent(newContent)Anti-Patterns
// ❌ BAD - setting content in View
func (m Model) View() string {
m.viewport.SetContent(m.renderContent()) // Side effect!
return m.viewport.View()
}
// ✅ GOOD - set content in Update
case contentLoadedMsg:
m.viewport.SetContent(msg.content)
return m, nil---
TextInput
Single-line text input with placeholder and validation.
Basic Setup
import "github.com/charmbracelet/bubbles/textinput"
ti := textinput.New()
ti.Placeholder = "Enter username"
ti.CharLimit = 32
ti.Width = 20
ti.Focus()Common Patterns
// Password input
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '*'
// Validation styling
ti.Validate = func(s string) error {
if len(s) < 3 {
return errors.New("too short")
}
return nil
}
// Get value
value := m.textinput.Value()
// Clear input
m.textinput.Reset()
// Focus management
m.textinput.Focus()
m.textinput.Blur()Multiple Inputs
type Model struct {
inputs []textinput.Model
focused int
}
func (m *Model) nextInput() {
m.inputs[m.focused].Blur()
m.focused = (m.focused + 1) % len(m.inputs)
m.inputs[m.focused].Focus()
}---
TextArea
Multi-line text input with line wrapping.
Basic Setup
import "github.com/charmbracelet/bubbles/textarea"
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.SetWidth(60)
ta.SetHeight(10)
ta.Focus()Common Patterns
// Get/set value
content := m.textarea.Value()
m.textarea.SetValue("Initial content")
// Line count
lines := m.textarea.LineCount()
// Cursor position
row, col := m.textarea.Cursor()
// Resize
case tea.WindowSizeMsg:
m.textarea.SetWidth(msg.Width - 4)
m.textarea.SetHeight(msg.Height - 6)---
Spinner
Loading indicator with multiple styles.
Basic Setup
import "github.com/charmbracelet/bubbles/spinner"
s := spinner.New()
s.Spinner = spinner.Dot // or Line, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey, Meter, Hamburger
// In Init
return s.Tick
// In Update
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmdSpinner Styles
// Available spinners
spinner.Line // |/-\
spinner.Dot // ⣾⣽⣻⢿⡿⣟⣯⣷
spinner.MiniDot // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
spinner.Jump // ⢄⢂⢁⡁⡈⡐⡠
spinner.Pulse // █▓▒░
spinner.Points // ∙∙∙
spinner.Globe // 🌍🌎🌏
spinner.Moon // 🌑🌒🌓🌔🌕🌖🌗🌘
spinner.Monkey // 🙈🙉🙊
spinner.Meter // ▱▰▰▰▰▰▰
spinner.Hamburger // ☰☲☴---
Progress
Progress bar with percentage and custom styling.
Basic Setup
import "github.com/charmbracelet/bubbles/progress"
p := progress.New(progress.WithDefaultGradient())
// or
p := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C"))
// In View
return p.ViewAs(0.5) // 50%
// Animated progress
return p.View() // uses internal percentageCommon Patterns
// Update progress
m.progress.SetPercent(0.75)
// Width adjustment
case tea.WindowSizeMsg:
m.progress.Width = msg.Width - padding
// Animated increment
case progressMsg:
cmd := m.progress.SetPercent(msg.percent)
return m, cmd---
Paginator
Page navigation for paginated content.
Basic Setup
import "github.com/charmbracelet/bubbles/paginator"
p := paginator.New()
p.Type = paginator.Dots // or Arabic (1/10)
p.SetTotalPages(10)
p.PerPage = 5Common Patterns
// Get current page items
start, end := m.paginator.GetSliceBounds(len(items))
pageItems := items[start:end]
// Navigation
if m.paginator.OnLastPage() {
// handle end
}
// In Update - paginator handles arrow keys
m.paginator, cmd = m.paginator.Update(msg)---
FilePicker
File and directory selection.
Basic Setup
import "github.com/charmbracelet/bubbles/filepicker"
fp := filepicker.New()
fp.CurrentDirectory, _ = os.UserHomeDir()
fp.AllowedTypes = []string{".go", ".md", ".txt"}
fp.ShowHidden = falseCommon Patterns
// Check for selection
case tea.KeyMsg:
m.filepicker, cmd = m.filepicker.Update(msg)
if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
m.selectedFile = path
return m, fileSelectedCmd(path)
}
if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect {
m.err = errors.New("file type not allowed")
}---
Timer
Countdown timer with start/stop/reset.
Basic Setup
import "github.com/charmbracelet/bubbles/timer"
t := timer.NewWithInterval(5*time.Minute, time.Second)
// In Init
return t.Init()
// In Update
case timer.TickMsg:
m.timer, cmd = m.timer.Update(msg)
return m, cmd
case timer.TimeoutMsg:
// Timer finished
return m, nilControl
// Toggle
cmd := m.timer.Toggle()
// Stop
cmd := m.timer.Stop()
// Start
cmd := m.timer.Start()---
Stopwatch
Elapsed time counter.
Basic Setup
import "github.com/charmbracelet/bubbles/stopwatch"
sw := stopwatch.NewWithInterval(time.Millisecond * 100)
// In Init
return sw.Init()
// In Update
case stopwatch.TickMsg:
m.stopwatch, cmd = m.stopwatch.Update(msg)
return m, cmd---
Help
Display key bindings to users.
Basic Setup
import "github.com/charmbracelet/bubbles/help"
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Quit key.Binding
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Down, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down},
{k.Quit},
}
}
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"),
),
}
h := help.New()
// In View
return h.View(keys)Expand/Collapse
// Toggle full help
case tea.KeyMsg:
if msg.String() == "?" {
m.help.ShowAll = !m.help.ShowAll
}---
Key
Key binding definitions for consistent input handling.
Basic Setup
import "github.com/charmbracelet/bubbles/key"
var quitKey = key.NewBinding(
key.WithKeys("q", "ctrl+c", "esc"),
key.WithHelp("q", "quit"),
)
// In Update
case tea.KeyMsg:
if key.Matches(msg, quitKey) {
return m, tea.Quit
}Enable/Disable Bindings
// Disable a binding
quitKey.SetEnabled(false)
// Check if enabled
if quitKey.Enabled() {
// ...
}---
Cursor
Text cursor management for custom text inputs.
Basic Setup
import "github.com/charmbracelet/bubbles/cursor"
c := cursor.New()
c.SetMode(cursor.CursorBlink)
// Modes
cursor.CursorBlink
cursor.CursorStatic
cursor.CursorHide---
Integration Patterns
Multiple Components
type Model struct {
list list.Model
spinner spinner.Model
help help.Model
keys keyMap
loading bool
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
// Always update spinner when loading
if m.loading {
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
}
// Update list when not loading
if !m.loading {
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}Component Communication
// Custom message for cross-component communication
type itemSelectedMsg struct {
item Item
}
// Child component emits message
case tea.KeyMsg:
if msg.String() == "enter" {
return m, func() tea.Msg {
return itemSelectedMsg{m.list.SelectedItem().(Item)}
}
}
// Parent handles message
case itemSelectedMsg:
m.selectedItem = msg.item
m.state = viewDetailReview Questions
1. Are components initialized with proper dimensions? 2. Are components updated on WindowSizeMsg? 3. Is focus managed correctly between components? 4. Are component methods used instead of reaching into internals? 5. Are tick messages handled for animated components (spinner, timer)?
Component Composition
Bubbles Integration
1. Using Standard Bubbles
import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/bubbles/spinner"
)
type Model struct {
list list.Model
input textinput.Model
viewport viewport.Model
spinner spinner.Model
}2. Initialize Sub-Components
func NewModel() Model {
// List
items := []list.Item{...}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "My List"
// Text input
ti := textinput.New()
ti.Placeholder = "Type here..."
ti.Focus()
// Spinner
s := spinner.New()
s.Spinner = spinner.Dot
return Model{
list: l,
input: ti,
spinner: s,
}
}3. Update Sub-Components
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
// Always update active sub-components
switch m.state {
case stateList:
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
case stateInput:
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
// Handle window size for all components
if msg, ok := msg.(tea.WindowSizeMsg); ok {
m.list.SetSize(msg.Width, msg.Height-4)
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4
}
return m, tea.Batch(cmds...)
}Custom Components
1. Component Interface Pattern
// Component interface for consistent sub-components
type Component interface {
Init() tea.Cmd
Update(tea.Msg) (Component, tea.Cmd)
View() string
SetSize(width, height int)
}2. Self-Contained Component
// menu/menu.go
package menu
type Model struct {
items []Item
cursor int
width int
height int
}
func New(items []Item) Model {
return Model{items: items}
}
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 "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.items)-1 {
m.cursor++
}
}
}
return m, nil
}
func (m Model) View() string {
var b strings.Builder
for i, item := range m.items {
cursor := " "
if i == m.cursor {
cursor = "> "
}
b.WriteString(cursor + item.Title + "\n")
}
return b.String()
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
}
func (m Model) Selected() Item {
return m.items[m.cursor]
}3. Using Custom Component
import "myapp/menu"
type Model struct {
menu menu.Model
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.menu, cmd = m.menu.Update(msg)
// React to menu selection
if key, ok := msg.(tea.KeyMsg); ok && key.String() == "enter" {
selected := m.menu.Selected()
// handle selection
}
return m, cmd
}State Machine Pattern
1. View States
type viewState int
const (
viewLoading viewState = iota
viewList
viewDetail
viewEdit
)
type Model struct {
state viewState
// sub-components for each state
list list.Model
detail detailModel
edit editModel
}2. State Transitions
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Global key handling
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
// Go back based on current state
switch m.state {
case viewDetail:
m.state = viewList
return m, nil
case viewEdit:
m.state = viewDetail
return m, nil
}
}
}
// Delegate to current state's component
var cmd tea.Cmd
switch m.state {
case viewList:
m.list, cmd = m.list.Update(msg)
// Check for selection
if key, ok := msg.(tea.KeyMsg); ok && key.String() == "enter" {
m.state = viewDetail
m.detail = newDetailModel(m.list.SelectedItem())
}
case viewDetail:
m.detail, cmd = m.detail.Update(msg)
case viewEdit:
m.edit, cmd = m.edit.Update(msg)
}
return m, cmd
}3. View Routing
func (m Model) View() string {
switch m.state {
case viewLoading:
return m.spinner.View() + " Loading..."
case viewList:
return m.list.View()
case viewDetail:
return m.detail.View()
case viewEdit:
return m.edit.View()
default:
return "Unknown state"
}
}Focus Management
1. Track Focus
type focusState int
const (
focusList focusState = iota
focusInput
focusButtons
)
type Model struct {
focus focusState
list list.Model
input textinput.Model
}
func (m *Model) nextFocus() {
m.focus = (m.focus + 1) % 3
m.updateFocus()
}
func (m *Model) updateFocus() {
switch m.focus {
case focusInput:
m.input.Focus()
default:
m.input.Blur()
}
}2. Tab Navigation
case tea.KeyMsg:
switch key.String() {
case "tab":
m.nextFocus()
return m, nil
case "shift+tab":
m.prevFocus()
return m, nil
}
// Only handle keys for focused component
switch m.focus {
case focusList:
m.list, cmd = m.list.Update(msg)
case focusInput:
m.input, cmd = m.input.Update(msg)
}Anti-Patterns
1. Not Propagating Updates
// BAD - sub-component never updates
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// only handles own keys, ignores sub-component
}
return m, nil
}
// GOOD - always update sub-components
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg) // always propagate
return m, cmd
}2. Nested Component Access
// BAD - reaches into component internals
func (m Model) View() string {
return m.list.items[m.list.cursor].Title // breaks encapsulation
}
// GOOD - use component methods
func (m Model) View() string {
return m.list.SelectedItem().(Item).Title
}Huh Forms Integration
Huh is a form library built on BubbleTea.
Basic Form
import "github.com/charmbracelet/huh"
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("name").
Title("What's your name?").
Validate(func(s string) error {
if s == "" {
return errors.New("name required")
}
return nil
}),
huh.NewSelect[string]().
Key("role").
Title("Select role").
Options(
huh.NewOption("Admin", "admin"),
huh.NewOption("User", "user"),
),
huh.NewConfirm().
Key("confirm").
Title("Continue?"),
),
)
// Run standalone (blocking)
err := form.Run()
// Get values
name := form.GetString("name")
role := form.GetString("role")
confirmed := form.GetBool("confirm")Embedding in BubbleTea
type Model struct {
form *huh.Form
done bool
}
func NewModel() Model {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Key("name").Title("Name"),
),
).WithTheme(huh.ThemeDracula())
return Model{form: form}
}
func (m Model) Init() tea.Cmd {
return m.form.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Check completion first
if m.form.State == huh.StateCompleted {
m.done = true
return m, nil
}
// Update form
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
return m, cmd
}
func (m Model) View() string {
if m.done {
return fmt.Sprintf("Hello, %s!", m.form.GetString("name"))
}
return m.form.View()
}Field Types
// Text input
huh.NewInput().Key("name").Title("Name").Placeholder("Enter name")
// Multi-line text
huh.NewText().Key("bio").Title("Bio").Lines(5)
// Single select
huh.NewSelect[string]().Key("color").Title("Color").
Options(
huh.NewOption("Red", "red"),
huh.NewOption("Blue", "blue"),
)
// Multi select
huh.NewMultiSelect[string]().Key("tags").Title("Tags").
Options(
huh.NewOption("Go", "go"),
huh.NewOption("Rust", "rust"),
)
// Confirmation
huh.NewConfirm().Key("agree").Title("Agree?")
// File picker
huh.NewFilePicker().Key("file").Title("Select file")Theming
form := huh.NewForm(...).
WithTheme(huh.ThemeDracula()). // Built-in themes
WithWidth(60).
WithShowHelp(true).
WithShowErrors(true)
// Built-in themes
huh.ThemeBase()
huh.ThemeCharm()
huh.ThemeDracula()
huh.ThemeCatppuccin()
huh.ThemeBase16()Multi-Page Forms
form := huh.NewForm(
// Page 1
huh.NewGroup(
huh.NewInput().Key("name").Title("Name"),
huh.NewInput().Key("email").Title("Email"),
).Title("Personal Info"),
// Page 2
huh.NewGroup(
huh.NewSelect[string]().Key("plan").Title("Plan").
Options(
huh.NewOption("Free", "free"),
huh.NewOption("Pro", "pro"),
),
).Title("Subscription"),
)Anti-Patterns
// ❌ BAD - calling Run() inside BubbleTea (blocks)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.form.Run() // BLOCKS THE UI!
return m, nil
}
// ✅ GOOD - use Update loop
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
form, cmd := m.form.Update(msg)
m.form = form.(*huh.Form)
return m, cmd
}Review Questions
1. Are sub-components properly initialized? 2. Are sub-component updates propagated? 3. Is WindowSizeMsg passed to all components needing resize? 4. Is there a clear state machine for view transitions? 5. Is focus tracked and components blurred/focused correctly? 6. Are Huh forms embedded correctly (not using blocking Run())?
Understanding the Elm Architecture
The Core Principle: Commands Are Data
The most important concept in BubbleTea (and Elm) is that commands describe effects, they don't execute them.
// tea.Cmd is just a function signature
type Cmd func() MsgWhen you return a tea.Cmd from Update(), you're returning a description of work to do. The BubbleTea runtime executes it asynchronously after Update() returns.
Common False Positive: "Synchronous Execution"
This is NOT blocking:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case NavigateMsg:
return m, m.loadData() // ← NOT synchronous execution!
}
return m, nil
}
func (m *Model) loadData() tea.Cmd {
return func() tea.Msg {
// This closure is NOT executed during Update()
// The runtime schedules it for async execution
data, _ := http.Get("https://api.example.com/data")
return DataLoadedMsg{data}
}
}Why this is correct: 1. m.loadData() is called synchronously, but it only creates the command 2. The http.Get inside the closure does NOT run during Update() 3. Update() returns immediately with the command 4. BubbleTea's runtime executes the command in a separate goroutine 5. When complete, the runtime sends DataLoadedMsg back to Update()
The Execution Model
┌─────────────────────────────────────────────────────────────────┐
│ BubbleTea Runtime │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User Input ──┐ │
│ ▼ │
│ ┌──────────┐ returns ┌──────────────┐ │
│ Msg → │ Update │ ───────────────→ │ Model, Cmd │ │
│ └──────────┘ immediately └──────┬───────┘ │
│ │ │
│ ┌───────────────────────────────┘ │
│ ▼ │
│ ┌──────────┐ │
│ │ Runtime │ executes Cmd │
│ │ executes │ in background │
│ │ Cmd │ goroutine │
│ └────┬─────┘ │
│ │ │
│ ▼ sends Msg │
│ ┌──────────┐ │
│ Msg → │ Update │ ← cycle continues │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘NOT Issues (Avoid These False Positives)
1. Helper Functions Returning tea.Cmd
// ✅ CORRECT - this is NOT blocking
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.fetchItems()
}
func (m *Model) fetchItems() tea.Cmd {
return func() tea.Msg {
items, _ := api.GetItems() // Runs LATER, by runtime
return ItemsMsg{items}
}
}Why OK: The helper creates and returns a command descriptor. No I/O happens in Update().
2. Value Receivers on Update
// ✅ CORRECT - standard BubbleTea pattern
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.counter++
return m, nil
}Why OK: BubbleTea returns the model by value. The caller receives the modified copy.
3. Nested Model Updates
// ✅ CORRECT - normal component composition
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.child, cmd = m.child.Update(msg) // Updates child synchronously
return m, cmd
}Why OK: Child's Update() is also non-blocking. Commands bubble up.
4. Batch Commands
// ✅ CORRECT - commands execute concurrently
return m, tea.Batch(
m.loadUser(),
m.loadPosts(),
m.loadSettings(),
)Why OK: All three commands run concurrently by the runtime.
5. Immediate Message Return
// ✅ CORRECT - synchronous state transition
func (m *Model) navigateToMenu() tea.Cmd {
return func() tea.Msg {
return ShowMenuMsg{} // No I/O, just returns a message
}
}Why OK: Even though this returns immediately, it's still async from Update()'s perspective.
ACTUAL Issues to Flag
1. Blocking I/O Directly in Update
// ❌ BAD - blocks the UI
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
data, _ := os.ReadFile("config.json") // BLOCKS!
m.config = parse(data)
return m, nil
}Fix: Move to a command:
return m, loadConfigCmd()2. Sleep in Update
// ❌ BAD - freezes UI for 2 seconds
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
time.Sleep(2 * time.Second)
return m, nil
}Fix: Use tea.Tick:
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return DelayCompleteMsg{}
})3. HTTP Calls in Update
// ❌ BAD - network I/O in Update
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
resp, _ := http.Get("https://api.example.com")
// ...
}Fix: Wrap in a command function.
4. Channel Operations That Block
// ❌ BAD - may block indefinitely
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
data := <-m.dataChan // Could block!
return m, nil
}Fix: Use non-blocking select or move to command.
Quick Reference: Is It Blocking?
| Code Pattern | Blocking? | Why |
|---|---|---|
return m, m.loadData() | No | Returns cmd descriptor |
data := fetchData() (in Update) | Yes | Direct I/O call |
return m, func() tea.Msg { ... } | No | Closure runs later |
time.Sleep(d) (in Update) | Yes | Blocks goroutine |
<-channel (in Update) | Maybe | Blocks if empty |
return m, tea.Tick(d, ...) | No | Runtime handles delay |
Review Guidance
When reviewing BubbleTea code:
1. Look for I/O in Update() - file, network, database calls directly in Update are bugs 2. Ignore cmd helper patterns - return m, m.someHelper() where helper returns tea.Cmd is correct 3. Check what's INSIDE commands - the closure body is where blocking ops belong 4. Value receivers are fine - BubbleTea's design expects this
The rule is simple: Update() must return quickly. Commands do the slow work.
Model & Update
Model Design
1. Model Must Implement tea.Model
type Model struct {
// State
items []Item
cursor int
selected map[int]struct{}
// Dimensions (for responsive layout)
width int
height int
// Sub-components
list list.Model
viewport viewport.Model
// Error state
err error
}
// Verify interface implementation
var _ tea.Model = (*Model)(nil)2. Init Returns Initial Command
// BAD - blocking operation
func (m Model) Init() tea.Cmd {
data := loadData() // blocks!
return nil
}
// GOOD - async via command
func (m Model) Init() tea.Cmd {
return tea.Batch(
loadDataCmd(),
tea.EnterAltScreen,
)
}Update Patterns
1. Switch on Message Type
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case dataLoadedMsg:
m.items = msg.items
return m, nil
case errMsg:
m.err = msg.err
return m, nil
}
return m, nil
}2. Always Handle WindowSizeMsg
// BAD - ignores window size
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// no WindowSizeMsg handling
}
// GOOD
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Update sub-components
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4 // reserve for header/footer
return m, nil3. Key Handling with key.Matches
// BAD - string comparison
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
// GOOD - use key bindings
type keyMap struct {
Quit key.Binding
Up key.Binding
Down key.Binding
}
var keys = keyMap{
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
}
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Up):
m.cursor--
}4. Sub-Component Updates
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Update sub-components
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
// Handle our own messages
switch msg := msg.(type) {
case tea.KeyMsg:
// ...
}
return m, tea.Batch(cmds...)
}Commands
1. Commands Return Messages
// Command that performs I/O
func fetchItemsCmd(url string) tea.Cmd {
return func() tea.Msg {
resp, err := http.Get(url)
if err != nil {
return errMsg{err}
}
defer resp.Body.Close()
var items []Item
json.NewDecoder(resp.Body).Decode(&items)
return itemsFetchedMsg{items}
}
}2. Tick Commands for Animation
type tickMsg time.Time
func tickCmd() tea.Cmd {
return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
case tickMsg:
m.frame++
return m, tickCmd() // schedule next tick3. Batch Multiple Commands
// BAD - returns only last command
func (m Model) Init() tea.Cmd {
loadConfig()
return loadData() // loadConfig result lost!
}
// GOOD - batch them
func (m Model) Init() tea.Cmd {
return tea.Batch(
loadConfigCmd(),
loadDataCmd(),
startSpinnerCmd(),
)
}Anti-Patterns
1. Side Effects in View
// BAD
func (m Model) View() string {
log.Printf("rendering") // side effect!
m.renderCount++ // mutation!
return "..."
}
// GOOD - View is pure
func (m Model) View() string {
return "..."
}2. Blocking in Update
// BAD
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
time.Sleep(2 * time.Second) // freezes UI!
return m, nil
}
// GOOD - use commands for delays
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return delayCompleteMsg{}
})Review Questions
1. Does Init return a command for initial I/O? 2. Does Update handle all relevant message types? 3. Is WindowSizeMsg handled for responsive layout? 4. Are key bindings using key.Matches? 5. Are sub-component updates propagated correctly? 6. Are commands used for all async/I/O operations?
View & Styling
View Function
1. View Must Be Pure
// BAD - side effects
func (m Model) View() string {
m.lastRender = time.Now() // mutation!
log.Println("rendering") // I/O!
return "..."
}
// GOOD - pure function
func (m Model) View() string {
if m.loading {
return m.spinner.View() + " Loading..."
}
return m.renderContent()
}2. Handle Loading/Error States
func (m Model) View() string {
if m.err != nil {
return errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
}
if m.loading {
return m.spinner.View() + " Loading..."
}
return m.renderContent()
}3. Compose Views Cleanly
func (m Model) View() string {
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(m.renderContent())
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}Lipgloss Styling
1. Define Styles at Package Level
// BAD - created every render
func (m Model) View() string {
style := lipgloss.NewStyle().Bold(true)
return style.Render("Hello")
}
// GOOD - defined once
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205"))
itemStyle = lipgloss.NewStyle().
PaddingLeft(2)
)
func (m Model) View() string {
return titleStyle.Render("Hello")
}2. Use Color Palette
// Define a consistent color palette
var (
colorPrimary = lipgloss.Color("205") // magenta
colorSecondary = lipgloss.Color("241") // gray
colorSuccess = lipgloss.Color("78") // green
colorError = lipgloss.Color("196") // red
)
var (
titleStyle = lipgloss.NewStyle().Foreground(colorPrimary)
errorStyle = lipgloss.NewStyle().Foreground(colorError)
)3. Adaptive Colors for Themes
var (
// Adaptive colors work with light and dark terminals
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
)
var titleStyle = lipgloss.NewStyle().
Foreground(highlight).
Background(subtle)4. Responsive Width
func (m Model) View() string {
// Adjust style based on window width
doc := lipgloss.NewStyle().
Width(m.width).
MaxWidth(m.width)
return doc.Render(m.content)
}5. Layout with Place and Join
func (m Model) View() string {
// Horizontal join
row := lipgloss.JoinHorizontal(
lipgloss.Top,
leftPanel.Render(m.menu),
rightPanel.Render(m.content),
)
// Vertical join
return lipgloss.JoinVertical(
lipgloss.Left,
m.header(),
row,
m.footer(),
)
}
// Center content
func (m Model) View() string {
return lipgloss.Place(
m.width, m.height,
lipgloss.Center, lipgloss.Center,
m.content,
)
}6. Borders and Padding
var boxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(1, 2).
Margin(1)
var selectedStyle = lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(lipgloss.Color("205"))Common Patterns
Selected Item Highlighting
func (m Model) renderItems() string {
var b strings.Builder
for i, item := range m.items {
cursor := " "
if i == m.cursor {
cursor = "▸ "
}
style := itemStyle
if i == m.cursor {
style = selectedStyle
}
b.WriteString(style.Render(cursor + item.Title))
b.WriteString("\n")
}
return b.String()
}Help Footer
func (m Model) helpView() string {
return helpStyle.Render("↑/↓: navigate • enter: select • q: quit")
}
// Or use the help bubble
import "github.com/charmbracelet/bubbles/help"
func (m Model) View() string {
return m.content + "\n" + m.help.View(m.keys)
}Status Bar
var statusStyle = lipgloss.NewStyle().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("255")).
Padding(0, 1)
func (m Model) statusBar() string {
status := fmt.Sprintf("Items: %d | Selected: %d", len(m.items), len(m.selected))
return statusStyle.Width(m.width).Render(status)
}Anti-Patterns
1. ANSI Codes Instead of Lipgloss
// BAD - raw ANSI
func (m Model) View() string {
return "\033[1;31mError\033[0m"
}
// GOOD - Lipgloss
var errorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196"))
func (m Model) View() string {
return errorStyle.Render("Error")
}2. Hardcoded Dimensions
// BAD - ignores terminal size
var boxStyle = lipgloss.NewStyle().Width(80)
// GOOD - responsive
func (m Model) renderBox() string {
return boxStyle.Width(m.width - 4).Render(m.content)
}Review Questions
1. Is View a pure function with no side effects? 2. Are styles defined once, not in View? 3. Are colors using AdaptiveColor for light/dark themes? 4. Is layout responsive to WindowSizeMsg? 5. Are lipgloss.Join/Place used for layout composition?