| name | charmbracelet-tui |
| description | Use when building or modifying terminal UI with Bubbletea, Bubbles, or Lipgloss v2. Use when creating tea.Model implementations, handling keyboard/mouse input, managing program lifecycle, styling terminal output, using bubbles components (textinput, progress, spinner), or writing tests for TUI code. |
Charmbracelet TUI Development (v2)
Best practices for building terminal UIs with the Charmbracelet stack: Bubbletea (framework), Bubbles (components), Lipgloss (styling). All libraries are v2 with charm.land import paths.
Import Paths
tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/charmbracelet/colorprofile"
The Elm Architecture
Bubbletea follows the Elm Architecture. Every interactive component implements tea.Model:
type Model interface {
Init() tea.Cmd
Update(tea.Msg) (tea.Model, tea.Cmd)
View() tea.View
}
Key principles:
- Update is the only place state changes - View is a pure function of state
- Side effects are tea.Cmd (
func() tea.Msg) - never perform I/O in Update/View
- Messages drive everything - keyboard input, window resize, custom events all arrive as
tea.Msg
View Returns tea.View (Not String)
func (m model) View() tea.View {
return tea.NewView("rendered content")
}
return tea.NewView("")
tea.View has declarative fields that replace v1 commands:
v := tea.NewView(content)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.WindowTitle = "My App"
Note: View.Content is a string - compare with "", never nil.
Keyboard Input
Use tea.KeyPressMsg (not the v1 tea.KeyMsg):
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
case "ctrl+c":
case "space":
case "up":
case "esc":
case "q":
}
Field access for programmatic matching:
msg.Code
msg.Text
msg.Mod
msg.Key()
Common key constants: tea.KeyEnter, tea.KeyEscape, tea.KeyUp, tea.KeyDown, tea.KeyLeft, tea.KeyRight, tea.KeyHome, tea.KeyEnd, tea.KeyTab, tea.KeyBackspace, tea.KeyDelete
Mouse Input
Mouse messages are split by event type:
case tea.MouseClickMsg:
case tea.MouseReleaseMsg:
case tea.MouseWheelMsg:
case tea.MouseMotionMsg:
mouse := msg.Mouse()
x, y := mouse.X, mouse.Y
Program Creation and Lifecycle
p := tea.NewProgram(model,
tea.WithOutput(os.Stderr),
tea.WithColorProfile(profile),
tea.WithoutSignalHandler(),
)
finalModel, err := p.Run()
Always output to stderr when stdout needs to be pipeable (e.g., cd $(wt cd -i)):
tea.WithOutput(os.Stderr)
Color profile detection (pair with WithColorProfile for correct rendering):
profile := colorprofile.Detect(os.Stderr, os.Environ())
p := tea.NewProgram(model,
tea.WithOutput(os.Stderr),
tea.WithColorProfile(profile),
)
Commands and Messages
Commands are side effects that produce messages:
func fetchData() tea.Msg {
result, err := api.Get()
if err != nil {
return errMsg{err}
}
return dataMsg{result}
}
return m, fetchData
tea.Quit
tea.Batch(cmd1, cmd2)
tea.Sequence(cmd1, cmd2)
Channel-Based Messages (Background Updates)
For long-running operations that push updates:
type progressUpdate struct {
current int
message string
}
func waitForUpdate(ch chan progressUpdate) tea.Cmd {
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return tea.Quit()
}
return msg
}
}
case progressUpdate:
m.current = msg.current
return m, waitForUpdate(m.updateCh)
Bubbles Components
TextInput
ti := textinput.New()
ti.Placeholder = "Enter value..."
ti.CharLimit = 156
ti.Prompt = "> "
ti.SetWidth(40)
styles := ti.Styles()
styles.Cursor.Shape = tea.CursorBar
styles.Cursor.Blink = true
styles.Focused.Text = myStyle
styles.Blurred.Text = myStyle
ti.SetStyles(styles)
ti.Focus()
ti.Blur()
ti.Focused()
func (m model) Init() tea.Cmd {
m.input.Focus()
return textinput.Blink
}
m.input, cmd = m.input.Update(msg)
Progress
prog := progress.New(
progress.WithWidth(40),
progress.WithoutPercentage(),
progress.WithColors(primaryColor, accentColor),
)
bar := prog.ViewAs(0.75)
prog, cmd = prog.Update(msg)
Table (lipgloss/v2/table)
t := table.New().
Headers("NAME", "STATUS", "COUNT").
Rows(rows...).
BorderTop(false).
BorderBottom(false).
BorderLeft(false).
BorderRight(false).
BorderHeader(false).
BorderColumn(false).
BorderRow(false).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true).PaddingRight(2)
}
return lipgloss.NewStyle().PaddingRight(2)
})
output := t.String()
Lipgloss Styling
Style Creation
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
Italic(true).
Underline(true).
Padding(0, 1).
MarginTop(1)
rendered := style.Render("text")
Colors
lipgloss.Color("62")
lipgloss.Color("#ff0000")
lipgloss.NoColor{}
lipgloss.Color() returns color.Color (image/color). Use this type for color variables:
import "image/color"
var Primary color.Color = lipgloss.Color("62")
Background Detection
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
Character-Level Styling (Fuzzy Match Highlights)
lipgloss.StyleRunes(text, matchedIndices, highlightStyle, normalStyle)
Borders
lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
BorderLeft(true)
Style Architecture Pattern
Define a central theme with semantic color roles, then build styles as functions (not variables) to support runtime theme switching:
type Theme struct {
Primary color.Color
Accent color.Color
Success color.Color
Error color.Color
Muted color.Color
}
func TitleStyle() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(styles.Primary)
}
func SelectedStyle() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(styles.Accent)
}
Why functions not variables: Package-level var styles capture colors at init time. If the theme changes at runtime (e.g., from config), those variables are stale. Style functions read current color values on each call.
Testing Patterns
Synthetic Key Events
func keyMsg(key string) tea.KeyPressMsg {
switch key {
case "enter":
return tea.KeyPressMsg{Code: tea.KeyEnter}
case "up":
return tea.KeyPressMsg{Code: tea.KeyUp}
case "down":
return tea.KeyPressMsg{Code: tea.KeyDown}
case "left":
return tea.KeyPressMsg{Code: tea.KeyLeft}
case "right":
return tea.KeyPressMsg{Code: tea.KeyRight}
case "esc":
return tea.KeyPressMsg{Code: tea.KeyEscape}
case "ctrl+c":
return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
default:
if len(key) == 1 {
return tea.KeyPressMsg{Code: rune(key[0]), Text: key}
}
return tea.KeyPressMsg{}
}
}
Testing Models Directly
Test by calling Update() with synthetic messages - no need to run a tea.Program:
m := newModel()
m.Init()
updated, cmd := m.Update(keyMsg("enter"))
m = updated.(*myModel)
if !m.done { t.Error("expected done") }
View Assertions
view := m.View()
if view.Content == "" {
t.Error("expected non-empty view")
}
Type-Safe Step Testing (Generic Helper)
For testing subcomponents that return their own type (not tea.Model):
func updateStep[T framework.Step](t *testing.T, s T, msg tea.KeyPressMsg) (T, framework.StepResult) {
t.Helper()
newStep, _, result := s.Update(msg)
return newStep.(T), result
}
Common Mistakes
| Mistake | Fix |
|---|
View() string | View() tea.View + tea.NewView() |
case tea.KeyMsg: | case tea.KeyPressMsg: |
case " ": for space | case "space": |
tea.Sequentially() | tea.Sequence() |
view.Content == nil | view.Content == "" (string in v2) |
tea.WithAltScreen() option | view.AltScreen = true (declarative) |
tea.EnterAltScreen command | view.AltScreen = true (declarative) |
| Printing to stdout | tea.WithOutput(os.Stderr) for piping |
| Missing color profile | colorprofile.Detect() + tea.WithColorProfile() |
| Style variables for themed UI | Style functions that read current theme |
os.Getwd() in commands | Use context-injected working directory |