charmbracelet-tui

star 6

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.

raphi011 By raphi011 schedule Updated 2/24/2026

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"           // framework
"charm.land/bubbles/v2/textinput"       // text input component
"charm.land/bubbles/v2/progress"        // progress bar component
"charm.land/bubbles/v2/spinner"         // spinner component
"charm.land/lipgloss/v2"               // styling
"charm.land/lipgloss/v2/table"         // table component
"github.com/charmbracelet/colorprofile" // terminal color detection

The Elm Architecture

Bubbletea follows the Elm Architecture. Every interactive component implements tea.Model:

type Model interface {
    Init() tea.Cmd                          // initial command (e.g., start spinner)
    Update(tea.Msg) (tea.Model, tea.Cmd)    // handle messages, return new state + side effects
    View() tea.View                         // render current state (MUST be pure)
}

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)

// Always wrap output in tea.NewView()
func (m model) View() tea.View {
    return tea.NewView("rendered content")
}

// Empty view (e.g., when done)
return tea.NewView("")

tea.View has declarative fields that replace v1 commands:

v := tea.NewView(content)
v.AltScreen = true                          // replaces tea.EnterAltScreen
v.MouseMode = tea.MouseModeCellMotion       // replaces tea.EnableMouseCellMotion
v.ReportFocus = true                        // replaces tea.EnableReportFocus
v.WindowTitle = "My App"                    // replaces tea.SetWindowTitle

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":    // enter key
    case "ctrl+c":   // ctrl combinations
    case "space":    // space bar (NOT " ")
    case "up":       // arrow keys
    case "esc":      // escape
    case "q":        // character keys
    }

Field access for programmatic matching:

msg.Code    // rune: tea.KeyEnter, tea.KeyUp, 'a', ' ', etc.
msg.Text    // string: typed text (e.g., "a")
msg.Mod     // modifier: tea.ModCtrl, tea.ModAlt, tea.ModShift
msg.Key()   // full key info struct

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:     // button pressed
case tea.MouseReleaseMsg:   // button released
case tea.MouseWheelMsg:     // scroll wheel
case tea.MouseMotionMsg:    // movement

// Access mouse data
mouse := msg.Mouse()
x, y := mouse.X, mouse.Y

Program Creation and Lifecycle

p := tea.NewProgram(model,
    tea.WithOutput(os.Stderr),          // ALWAYS for piping support
    tea.WithColorProfile(profile),      // explicit color profile
    tea.WithoutSignalHandler(),         // for background/embedded programs
)
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:

// A command is func() tea.Msg
func fetchData() tea.Msg {
    result, err := api.Get()
    if err != nil {
        return errMsg{err}
    }
    return dataMsg{result}
}

// Return from Update
return m, fetchData   // runs async, sends result back as message

// Built-in commands
tea.Quit              // quit the program
tea.Batch(cmd1, cmd2) // run commands in parallel
tea.Sequence(cmd1, cmd2) // run commands sequentially (v2: renamed from Sequentially)

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()  // channel closed -> quit
        }
        return msg
    }
}

// In Update: re-subscribe after handling
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)

// Cursor styling
styles := ti.Styles()
styles.Cursor.Shape = tea.CursorBar     // also: tea.CursorBlock, tea.CursorUnderline
styles.Cursor.Blink = true
styles.Focused.Text = myStyle           // style when focused
styles.Blurred.Text = myStyle           // style when blurred
ti.SetStyles(styles)

// Focus management
ti.Focus()                              // activate input
ti.Blur()                               // deactivate
ti.Focused()                            // check state

// Init must return Blink for cursor animation
func (m model) Init() tea.Cmd {
    m.input.Focus()
    return textinput.Blink
}

// Forward messages in Update
m.input, cmd = m.input.Update(msg)

Progress

prog := progress.New(
    progress.WithWidth(40),
    progress.WithoutPercentage(),
    progress.WithColors(primaryColor, accentColor),  // variadic color.Color
)

bar := prog.ViewAs(0.75)  // render at 75%

// Forward Update for animations
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")).   // ANSI color
    Bold(true).
    Italic(true).
    Underline(true).
    Padding(0, 1).
    MarginTop(1)

rendered := style.Render("text")

Colors

lipgloss.Color("62")        // ANSI 256
lipgloss.Color("#ff0000")   // hex
lipgloss.NoColor{}          // terminal default (no color override)

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)

// Highlight specific character positions with one style, rest with another
lipgloss.StyleRunes(text, matchedIndices, highlightStyle, normalStyle)

Borders

lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).       // also: NormalBorder, ThickBorder, DoubleBorder
    BorderForeground(primaryColor).
    BorderLeft(true)                        // enable specific sides

Style Architecture Pattern

Define a central theme with semantic color roles, then build styles as functions (not variables) to support runtime theme switching:

// Theme struct with semantic colors (styles/theme.go)
type Theme struct {
    Primary color.Color   // borders, titles
    Accent  color.Color   // selected/active items
    Success color.Color   // checkmarks
    Error   color.Color   // error messages
    Muted   color.Color   // disabled text
}

// Styles as functions to pick up theme changes (framework/styles.go)
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()

// Send key events directly
updated, cmd := m.Update(keyMsg("enter"))
m = updated.(*myModel)

// Assert state
if !m.done { t.Error("expected done") }

View Assertions

view := m.View()
if view.Content == "" {     // Content is string in v2, NOT nil-comparable
    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
Install via CLI
npx skills add https://github.com/raphi011/wt --skill charmbracelet-tui
Repository Details
star Stars 6
call_split Forks 2
navigation Branch main
article Path SKILL.md
More from Creator