Charm Stack TUI Development
Build beautiful, functional terminal user interfaces using the Charm stack: Bubbletea (framework), Bubbles (components), Lipgloss (styling), and Huh (forms).
Your Role: TUI Architect
You build terminal applications using Elm Architecture patterns. You:
✅ Implement Model-Update-View - Core Bubbletea pattern ✅ Compose Bubbles components - Spinners, lists, text inputs ✅ Style with Lipgloss - Colors, borders, layouts ✅ Build forms with Huh - Interactive prompts ✅ Handle messages properly - KeyMsg, WindowMsg, custom messages ✅ Follow project patterns - Module structure from CLY
❌ Do NOT fight the framework - Use Elm Architecture ❌ Do NOT skip Init - Commands need initialization ❌ Do NOT ignore tea.Cmd - Critical for async operations
Core Architecture: The Elm Architecture
The Three Functions
Every Bubbletea program has three parts:
Model - Application state
type model struct { cursor int choices []string selected map[int]struct{} }
Init - Initial command
func (m model) Init() tea.Cmd { return nil // or return a command }
Update - Handle messages, update state
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: // Handle keyboard } return m, nil }
View - Render UI
func (m model) View() string { return "Hello, World!" }
Message Flow
User Input → Msg → Update → Model → View → Screen ↑ ↓ └────────── tea.Cmd ─────────────────┘
Key concepts:
-
Messages are immutable events
-
Update returns new model (don't mutate)
-
Commands run async, generate more messages
-
View is pure function of model state
Bubbletea Patterns
Basic Program
package main
import ( "fmt" "os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct { choices []string cursor int selected map[int]struct{} }
func initialModel() model { return model{ choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, 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 "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", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string { s := "What should we buy?\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
}
func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Printf("Error: %v", err) os.Exit(1) } }
Commands (tea.Cmd)
Commands enable async operations. They return messages.
Simple command:
func checkServer() tea.Msg { // Do work return statusMsg{online: true} }
// In Update: case tea.KeyMsg: if msg.String() == "c" { return m, checkServer // Execute command }
Command that runs async:
func fetchData() tea.Cmd { return func() tea.Msg { resp, err := http.Get("https://api.example.com/data") if err != nil { return errMsg{err} } return dataMsg{resp} } }
Batch commands:
return m, tea.Batch( cmd1, cmd2, cmd3, )
Tick command (for animations):
type tickMsg time.Time
func tick() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) }
// In Init: func (m model) Init() tea.Cmd { return tick() }
// In Update: case tickMsg: m.lastTick = time.Time(msg) return m, tick() // Keep ticking
Window Size Handling
type model struct { width int height int }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil } return m, nil }
func (m model) View() string { return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, "Centered text", ) }
Program Options
p := tea.NewProgram( initialModel(), tea.WithAltScreen(), // Use alternate screen buffer tea.WithMouseCellMotion(), // Enable mouse )
Bubbles Components
Bubbles provides ready-made components. Each is a tea.Model.
Spinner
import "github.com/charmbracelet/bubbles/spinner"
type model struct { spinner spinner.Model }
func initialModel() model { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) return model{spinner: s} }
func (m model) Init() tea.Cmd { return m.spinner.Tick }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd }
func (m model) View() string { return m.spinner.View() + " Loading..." }
Spinner types:
-
spinner.Line
-
spinner.Dot
-
spinner.MiniDot
-
spinner.Jump
-
spinner.Pulse
-
spinner.Points
-
spinner.Globe
-
spinner.Moon
Text Input
import "github.com/charmbracelet/bubbles/textinput"
type model struct { textInput textinput.Model }
func initialModel() model { ti := textinput.New() ti.Placeholder = "Enter your name" ti.Focus() ti.CharLimit = 156 ti.Width = 20
return model{textInput: ti}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
name := m.textInput.Value()
// Use the name
return m, tea.Quit
}
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m model) View() string { return fmt.Sprintf( "What's your name?\n\n%s\n\n%s", m.textInput.View(), "(esc to quit)", ) }
List
import "github.com/charmbracelet/bubbles/list"
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 }
type model struct { list list.Model }
func initialModel() model { items := []list.Item{ item{title: "Raspberry Pi", desc: "A small computer"}, item{title: "Arduino", desc: "Microcontroller"}, item{title: "ESP32", desc: "WiFi & Bluetooth"}, }
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "Hardware"
return model{list: l}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.list.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
if msg.String() == "enter" {
selected := m.list.SelectedItem().(item)
// Use selected
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
Table
import "github.com/charmbracelet/bubbles/table"
func initialModel() model { columns := []table.Column{ {Title: "ID", Width: 4}, {Title: "Name", Width: 10}, {Title: "Status", Width: 10}, }
rows := []table.Row{
{"1", "Alice", "Active"},
{"2", "Bob", "Inactive"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
t.SetStyles(s)
return model{table: t}
}
Viewport (Scrolling)
import "github.com/charmbracelet/bubbles/viewport"
type model struct { viewport viewport.Model content string }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.viewport = viewport.New(msg.Width, msg.Height) m.viewport.SetContent(m.content) }
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m model) View() string { return m.viewport.View() }
Progress
import "github.com/charmbracelet/bubbles/progress"
type model struct { progress progress.Model percent float64 }
func initialModel() model { return model{ progress: progress.New(progress.WithDefaultGradient()), percent: 0.0, } }
func (m model) View() string { return "\n" + m.progress.ViewAs(m.percent) + "\n\n" }
Lipgloss Styling
Basic Styles
import "github.com/charmbracelet/lipgloss"
var ( // Define styles titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("170")). Background(lipgloss.Color("235")). Padding(0, 1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
successStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("42"))
)
// Use styles title := titleStyle.Render("Hello") err := errorStyle.Render("Error!")
Colors
// ANSI 16 colors lipgloss.Color("5") // magenta
// ANSI 256 colors lipgloss.Color("86") // aqua lipgloss.Color("201") // hot pink
// True color (hex) lipgloss.Color("#0000FF") // blue lipgloss.Color("#FF6B6B") // red
// Adaptive (light/dark) lipgloss.AdaptiveColor{ Light: "236", Dark: "248", }
Layout & Spacing
style := lipgloss.NewStyle(). Width(50). Height(10). Padding(1, 2). // top/bottom, left/right Margin(1, 2, 3, 4). // top, right, bottom, left Align(lipgloss.Center)
Borders
style := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(1, 2)
// Border types lipgloss.NormalBorder() lipgloss.RoundedBorder() lipgloss.ThickBorder() lipgloss.DoubleBorder() lipgloss.HiddenBorder()
// Selective borders style.BorderTop(true). BorderLeft(true)
Joining Layouts
// Horizontal row := lipgloss.JoinHorizontal( lipgloss.Top, // Alignment box1, box2, box3, )
// Vertical col := lipgloss.JoinVertical( lipgloss.Left, box1, box2, box3, )
// Positions: Top, Center, Bottom, Left, Right
Positioning
// Place in whitespace centered := lipgloss.Place( width, height, lipgloss.Center, // horizontal lipgloss.Center, // vertical content, )
Advanced Styling
style := lipgloss.NewStyle(). Bold(true). Italic(true). Underline(true). Strikethrough(true). Blink(true). Faint(true). Reverse(true)
Huh Forms
Build interactive forms and prompts.
Basic Form
import "github.com/charmbracelet/huh"
var ( burger string toppings []string name string )
func runForm() error { form := huh.NewForm( huh.NewGroup( huh.NewSelectstring. Title("Choose your burger"). Options( huh.NewOption("Classic", "classic"), huh.NewOption("Chicken", "chicken"), huh.NewOption("Veggie", "veggie"), ). Value(&burger),
huh.NewMultiSelect[string]().
Title("Toppings").
Options(
huh.NewOption("Lettuce", "lettuce"),
huh.NewOption("Tomato", "tomato"),
huh.NewOption("Cheese", "cheese"),
).
Limit(3).
Value(&toppings),
),
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("name required")
}
return nil
}),
),
)
return form.Run()
}
Field Types
Input - Single line text:
huh.NewInput(). Title("Username"). Placeholder("Enter username"). Value(&username)
Text - Multi-line text:
huh.NewText(). Title("Description"). CharLimit(400). Value(&description)
Select - Choose one:
huh.NewSelectstring. Title("Pick one"). Options( huh.NewOption("Option 1", "opt1"), huh.NewOption("Option 2", "opt2"), ). Value(&choice)
MultiSelect - Choose multiple:
huh.NewMultiSelectstring. Title("Pick several"). Options(...). Limit(3). Value(&choices)
Confirm - Yes/No:
huh.NewConfirm(). Title("Are you sure?"). Affirmative("Yes"). Negative("No"). Value(&confirmed)
Accessible Mode
form := huh.NewForm(...) form.WithAccessible(true) // Screen reader friendly
In Bubble Tea
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 done, get values
return m, tea.Quit
}
return m, cmd
}
func (m model) View() string { if m.form.State == huh.StateCompleted { return "Done!\n" } return m.form.View() }
CLY Project Patterns
Module Structure
modules/demo/spinner/ ├── cmd.go # Register() and run() └── spinner.go # Bubbletea model
cmd.go:
package spinner
import ( tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" )
func Register(parent *cobra.Command) { cmd := &cobra.Command{ Use: "spinner", Short: "Spinner demo", RunE: run, } parent.AddCommand(cmd) }
func run(cmd *cobra.Command, args []string) error { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { return err } return nil }
spinner.go:
package spinner
import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" )
type model struct { spinner spinner.Model }
func initialModel() model { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) return model{spinner: s} }
func (m model) Init() tea.Cmd { return m.spinner.Tick }
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 } }
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m model) View() string { return m.spinner.View() + " Loading...\n" }
Common Patterns
Loading State
type model struct { loading bool spinner spinner.Model data []string }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "r" { m.loading = true return m, fetchData }
case dataMsg:
m.loading = false
m.data = msg.data
return m, nil
}
if m.loading {
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 m.spinner.View() + " Loading data..." } return renderData(m.data) }
Error Handling
type model struct { err error }
type errMsg struct{ err error }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case errMsg: m.err = msg.err return m, nil } return m, nil }
func (m model) View() string { if m.err != nil { return errorStyle.Render("Error: " + m.err.Error()) } return normalView() }
Multi-View Navigation
type view int
const ( viewMenu view = iota viewList viewDetail )
type model struct { currentView view }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "1": m.currentView = viewMenu case "2": m.currentView = viewList case "3": m.currentView = viewDetail } } return m, nil }
func (m model) View() string { switch m.currentView { case viewMenu: return renderMenu() case viewList: return renderList() case viewDetail: return renderDetail() } return "" }
Best Practices
Model Immutability
✅ GOOD - Return new state:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.counter++ // Modify copy return m, nil }
❌ BAD - Mutate pointer:
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.counter++ // Mutates original return m, nil }
Quit Handling
Always handle quit signals:
case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q", "esc": return m, tea.Quit }
Alt Screen
Use alt screen for full-screen apps:
p := tea.NewProgram( initialModel(), tea.WithAltScreen(), )
Component Composition
Embed Bubbles components:
type model struct { spinner spinner.Model textInput textinput.Model list list.Model }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
Checklist
-
Model contains all state
-
Init returns initial command
-
Update handles all message types
-
Update returns new model (immutable)
-
View is pure function
-
Quit handling present
-
Window resize handled
-
Commands for async ops
-
Bubbles components updated
-
Lipgloss for all styling
-
Follows CLY module structure
Resources
-
Bubbletea Tutorial
-
Bubbletea Examples
-
Bubbles Components
-
Lipgloss Docs
-
Huh Forms
-
CLY examples: modules/demo/*/