Loading...
Loading...
Bubble Tea is a Go framework for building elegant terminal user interfaces (TUIs). Use when building CLI applications with interactive menus, forms, lists, tables, or any terminal UI. Based on The Elm Architecture (Model-View-Update). Key features: keyboard/mouse input, responsive layouts, async commands, Bubbles components (spinner, list, table, viewport, textinput, progress). Includes Lip Gloss for styling and Huh for forms.
npx skill4agent add quantmind-br/skills bubbletea-docsInit()Update()View()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 # Formspackage 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) }
}// 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
}// Parallel execution
return tea.Batch(cmd1, cmd2, cmd3)
// Sequential execution
return tea.Sequence(step1, step2, tea.Quit)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
}// Fullscreen
p := tea.NewProgram(m, tea.WithAltScreen())
// Mouse support
p := tea.NewProgram(m, tea.WithMouseCellMotion())
// Combined
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())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
}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 ""
}| Component | Import | Init | Key Method |
|---|---|---|---|
| Spinner | | | |
| TextInput | | | |
| TextArea | | | |
| List | | | |
| Table | | | |
| Viewport | | | |
| Progress | | | |
| Help | | | |
| FilePicker | | | |
| Timer | | | |
| Stopwatch | | | |
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...)
}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...)
}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.TickMsgtype 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"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),
)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!")// 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",
}// 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)// 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 - footerHlipgloss.NormalBorder() // Standard box
lipgloss.RoundedBorder() // Rounded corners
lipgloss.ThickBorder() // Thick lines
lipgloss.DoubleBorder() // Double lines
lipgloss.HiddenBorder() // Invisible (for spacing)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)
}// 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.NewSelect[string]().
Title("Choose").
Options(
huh.NewOption("Option A", "a"),
huh.NewOption("Option B", "b"),
).
Value(&choice)
// MultiSelect - MUST specify type
huh.NewMultiSelect[string]().
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)huh.NewInput().
Title("Email").
Value(&email).
Validate(func(s string) error {
if !strings.Contains(s, "@") {
return errors.New("invalid email")
}
return nil
})var country string
var state string
huh.NewSelect[string]().
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)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()
}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()) // Minimalimport "github.com/charmbracelet/huh/spinner"
err := spinner.New().
Title("Processing...").
Action(func() {
time.Sleep(2 * time.Second)
processData()
}).
Run()// BAD
time.Sleep(time.Second) // Blocks event loop!
// GOOD
return m, func() tea.Msg {
time.Sleep(time.Second)
return doneMsg{}
}// BAD
go func() { m.data = fetch() }() // Race!
// GOOD
return m, func() tea.Msg { return dataMsg{fetch()} }if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height)
m.ready = true
}func (m *model)func (m model)tea.EnterAltScreentea.WithAltScreen()tea.Batchtea.Sequencem.spinner.TickInit()spinner.TickMsg.Focus()m.spinner, cmd = m.spinner.Update(msg) // Must reassign!tea.Batch(cmds...)FilterValue() stringtable.WithFocused(true)t.Focus()// BAD
style.Bold(true) // Result discarded!
// GOOD
style = style.Bold(true)style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center)lipgloss.Width()len()style.GetFrameSize()SelectMultiSelect// BAD - won't compile
huh.NewSelect()
// GOOD
huh.NewSelect[string]().Value(&myVar).Value(myVar).OptionsFunc(fn, &country) // Recomputes when country changes// BAD
huh.NewInput().Placeholder("example@email.com")
// GOOD
huh.NewInput().Description("e.g. example@email.com")for _, name := range items {
currentName := name // Capture!
huh.NewInput().Value(&configs[currentName].Field)
}// Log to file (stdout is the TUI)
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "app")
defer f.Close()
}
log.Println("Debug message")func (m model) Updatefunc (m *model) Updateimport (
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
)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)bubbles/tableimport "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)lipgloss/tablebubbles/tableInitUpdateView// 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
}// 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
}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)
}
}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()}
}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()
}