bubbletea

Installation
SKILL.md

Bubble Tea TUI (Go)

Bubble Tea is a Go framework for building terminal user interfaces based on The Elm Architecture: immutable Model, pure Update function that returns a new model and a Cmd, and a pure View function that renders a string. Async work (HTTP, timers, I/O) is dispatched as tea.Cmd values that return tea.Msg values, which are delivered back to Update.

  • github.com/charmbracelet/bubbletea - core runtime (v1 stable; use /v2 for v2)
  • github.com/charmbracelet/bubbles - ready-made components (spinner, textinput, list, table, viewport, progress, paginator, help, key, textarea, stopwatch, timer, filepicker)
  • github.com/charmbracelet/lipgloss - styling (colors, borders, padding, layout)

This skill targets the v1 API on github.com/charmbracelet/bubbletea because that is still the most widely deployed module path. A short migration note at the end covers v2 (github.com/charmbracelet/bubbletea/v2).

1. Minimal program

package main

import (
	"fmt"
	"os"

	tea "github.com/charmbracelet/bubbletea"
)

type model struct {
	cursor   int
	choices  []string
	selected map[int]struct{}
}

func initialModel() model {
	return model{
		choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
		selected: make(map[int]struct{}),
	}
}

// Init runs once when the program starts. Return any startup Cmd (or nil).
func (m model) Init() tea.Cmd {
	return tea.SetWindowTitle("Grocery List")
}

// Update is called for every message. It MUST be pure: take a model, return a
// new model and optionally a Cmd. Never mutate external state here.
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
}

// View renders the current UI as a string. Called after every Update.
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("Alas, there's been an error: %v", err)
		os.Exit(1)
	}
}

The tea.Model interface

type Model interface {
	Init() Cmd
	Update(Msg) (Model, Cmd)
	View() string
}

type Msg interface{}   // any value, type-switched in Update
type Cmd func() Msg    // async work; runs in its own goroutine

Use value receivers, not pointer receivers, for the Model methods. The runtime treats the returned model as the new state. Mutating in-place through a pointer still works, but the idiomatic pattern is to return a modified copy — it keeps Update pure and makes concurrency safe.

2. Commands and messages

A tea.Cmd is a function func() tea.Msg that Bubble Tea runs on a goroutine. Whatever Msg it returns is fed back into Update. This is how you do any async or side-effectful work: HTTP calls, reading files, timers, shelling out, receiving from channels.

type statusMsg int
type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() }

// A Cmd is just a func returning a Msg. Signature: func() tea.Msg
func checkServer() tea.Msg {
	c := &http.Client{Timeout: 10 * time.Second}
	res, err := c.Get("https://charm.sh/")
	if err != nil {
		return errMsg{err}
	}
	defer res.Body.Close()
	return statusMsg(res.StatusCode)
}

func (m model) Init() tea.Cmd { return checkServer }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case statusMsg:
		m.status = int(msg)
		return m, tea.Quit
	case errMsg:
		m.err = msg
		return m, tea.Quit
	case tea.KeyMsg:
		if msg.Type == tea.KeyCtrlC {
			return m, tea.Quit
		}
	}
	return m, nil
}

Commands that take arguments

Return a closure:

func fetch(url string) tea.Cmd {
	return func() tea.Msg {
		resp, err := http.Get(url)
		if err != nil {
			return errMsg{err}
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return bodyMsg(b)
	}
}

// usage: return m, fetch("https://example.com")

Composing commands

  • tea.Batch(cmds...) - run in parallel, messages arrive in any order.
  • tea.Sequence(cmds...) - run one after the other, waiting for each to complete.
  • tea.Tick(d, fn) - fire a single message after a duration.
  • tea.Every(d, fn) - fire a message on a recurring interval (aligned to wall clock).
  • tea.Printf / tea.Println - print above the TUI (inline mode) as a Cmd.
func tickEverySecond() tea.Cmd {
	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
		return tickMsg(t)
	})
}

// Re-arm inside Update to keep ticking:
case tickMsg:
	return m, tickEverySecond()

Sending messages from outside the Elm loop

Pass the *tea.Program into a goroutine and call p.Send(msg):

var p *tea.Program

func main() {
	p = tea.NewProgram(model{})
	go func() {
		for event := range externalChan {
			p.Send(externalMsg(event))
		}
	}()
	if _, err := p.Run(); err != nil { log.Fatal(err) }
}

3. Handling keyboard input

tea.KeyMsg comes in on every keystroke. Three common ways to match:

case tea.KeyMsg:
	// 1. By string representation (most common)
	switch msg.String() {
	case "ctrl+c", "q", "esc":
		return m, tea.Quit
	case "enter", " ":
		// ...
	case "up", "k":
		// ...
	}

	// 2. By KeyType (enum) for special keys
	switch msg.Type {
	case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc:
		return m, tea.Quit
	case tea.KeyTab, tea.KeyShiftTab:
		// ...
	}

	// 3. Raw runes for printable characters
	if msg.Type == tea.KeyRunes {
		for _, r := range msg.Runes { /* ... */ }
	}

Structured keybindings via bubbles/key

Prefer this pattern for real apps - it makes help text automatic:

import "github.com/charmbracelet/bubbles/key"

type keyMap struct {
	Up, Down, Help, 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")),
	Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")),
	Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit")),
}

// In Update:
case tea.KeyMsg:
	switch {
	case key.Matches(msg, keys.Up):   /* ... */
	case key.Matches(msg, keys.Down): /* ... */
	case key.Matches(msg, keys.Quit): return m, tea.Quit
	}

Toggle bindings dynamically with keys.Foo.SetEnabled(false) so the help view hides them contextually.

4. Program options

p := tea.NewProgram(
	model{},
	tea.WithAltScreen(),        // take over the full terminal
	tea.WithMouseCellMotion(),  // enable mouse (cell-level motion only)
	// tea.WithMouseAllMotion() // enable mouse (track every motion event)
	tea.WithContext(ctx),       // propagate cancellation
	tea.WithOutput(os.Stderr),  // render to a custom writer
	tea.WithInput(inputReader), // use a custom input source (SSH, tests)
	tea.WithFPS(60),            // cap renders per second
	tea.WithoutCatchPanics(),   // let panics through (debugging)
	tea.WithoutSignalHandler(), // don't install SIGINT handler
	tea.WithFilter(func(m tea.Model, msg tea.Msg) tea.Msg {
		// intercept/rewrite any message before Update sees it
		return msg
	}),
)

Runtime commands that toggle screen state

These are also messages you can return from Update:

  • tea.EnterAltScreen, tea.ExitAltScreen
  • tea.EnableMouseCellMotion, tea.EnableMouseAllMotion, tea.DisableMouse
  • tea.HideCursor, tea.ShowCursor
  • tea.ClearScreen
  • tea.EnableBracketedPaste, tea.DisableBracketedPaste
  • tea.EnableReportFocus, tea.DisableReportFocus
  • tea.Suspend (with tea.ResumeMsg arriving after)
  • tea.SetWindowTitle("...")
case " ":
	var cmd tea.Cmd
	if m.altscreen {
		cmd = tea.ExitAltScreen
	} else {
		cmd = tea.EnterAltScreen
	}
	m.altscreen = !m.altscreen
	return m, cmd

5. Window sizing and responsive layout

Bubble Tea sends a tea.WindowSizeMsg on startup and whenever the terminal resizes. Store the width/height on your model and propagate them to child components.

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

		headerH := lipgloss.Height(m.headerView())
		footerH := lipgloss.Height(m.footerView())
		vMargin := headerH + footerH

		if !m.ready {
			m.viewport = viewport.New(msg.Width, msg.Height-vMargin)
			m.viewport.SetContent(m.content)
			m.ready = true
		} else {
			m.viewport.Width = msg.Width
			m.viewport.Height = msg.Height - vMargin
		}
	}
	// ...
}

Call tea.WindowSize() as a Cmd to request a size message on demand (useful after returning from a suspended state).

6. Styling with lipgloss

import "github.com/charmbracelet/lipgloss"

var style = lipgloss.NewStyle().
	Bold(true).
	Foreground(lipgloss.Color("#FAFAFA")).
	Background(lipgloss.Color("#7D56F4")).
	PaddingTop(2).
	PaddingLeft(4).
	Width(22)

fmt.Println(style.Render("Hello, kitty"))

Colors

  • ANSI-16: lipgloss.Color("5")
  • ANSI-256: lipgloss.Color("86")
  • True color: lipgloss.Color("#0000FF")
  • Adapt to light/dark terminals: lipgloss.AdaptiveColor{Light: "236", Dark: "248"}
  • Explicit per-profile: lipgloss.CompleteColor{TrueColor: "#0000FF", ANSI256: "21", ANSI: "4"}

Text attributes

Bold, Italic, Faint, Underline, Strikethrough, Reverse, Blink.

Box model (CSS-like shorthands)

s := lipgloss.NewStyle().
	Padding(2).             // all sides
	Margin(1, 4).           // vertical, horizontal
	Padding(1, 4, 2, 1).    // top, right, bottom, left (clockwise)
	Width(40).Height(10).
	Align(lipgloss.Center)  // horizontal alignment

Borders

lipgloss.NewStyle().
	BorderStyle(lipgloss.RoundedBorder()).   // NormalBorder, ThickBorder, DoubleBorder, HiddenBorder, ASCIIBorder
	BorderForeground(lipgloss.Color("228")).
	BorderTop(true).
	BorderLeft(true)

// Custom border:
b := lipgloss.Border{Top: "─", Bottom: "─", Left: "│", Right: "│",
	TopLeft: "╭", TopRight: "╮", BottomLeft: "╰", BottomRight: "╯"}

Composition

lipgloss.JoinHorizontal(lipgloss.Top, left, right)       // side-by-side
lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, s) // center within a box

lipgloss.Width(s)   // measure rendered width (honoring ANSI)
lipgloss.Height(s)  // measure rendered height

Inheritance and unset

base := lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
bold := lipgloss.NewStyle().Inherit(base).Bold(true)
plain := bold.UnsetBold()

7. Bubbles: ready-made components

All Bubbles components follow a mini Elm loop of their own with Init() tea.Cmd, Update(tea.Msg) (Model, tea.Cmd), and View() string. Embed them on your parent model and forward messages in Update.

spinner

import "github.com/charmbracelet/bubbles/spinner"

type model struct{ spinner spinner.Model }

func initial() model {
	s := spinner.New()
	s.Spinner = spinner.Dot // Line, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey, ...
	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 fmt.Sprintf("%s loading...", m.spinner.View())
}

textinput

import "github.com/charmbracelet/bubbles/textinput"

ti := textinput.New()
ti.Placeholder = "Pikachu"
ti.Focus()
ti.CharLimit = 156
ti.Width = 20
// ti.EchoMode = textinput.EchoPassword; ti.EchoCharacter = '•'

func (m model) Init() tea.Cmd { return textinput.Blink }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyEnter:
			// submit: m.textInput.Value()
			return m, tea.Quit
		case tea.KeyCtrlC, tea.KeyEsc:
			return m, tea.Quit
		}
	}
	m.textInput, cmd = m.textInput.Update(msg)
	return m, cmd
}

Multi-input form (tab to cycle)

type model struct {
	focusIndex int
	inputs     []textinput.Model
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "tab", "shift+tab", "up", "down":
			if msg.String() == "up" || msg.String() == "shift+tab" {
				m.focusIndex--
			} else {
				m.focusIndex++
			}
			if m.focusIndex >= len(m.inputs) { m.focusIndex = 0 }
			if m.focusIndex < 0 { m.focusIndex = len(m.inputs) - 1 }

			cmds := make([]tea.Cmd, len(m.inputs))
			for i := range m.inputs {
				if i == m.focusIndex {
					cmds[i] = m.inputs[i].Focus()
				} else {
					m.inputs[i].Blur()
				}
			}
			return m, tea.Batch(cmds...)
		}
	}
	// forward to all; only focused ones actually react
	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...)
}

textarea

import "github.com/charmbracelet/bubbles/textarea"

ta := textarea.New()
ta.Placeholder = "Say something..."
ta.Focus()
ta.CharLimit = 280
// forward msgs like textinput; read with ta.Value()

list

import "github.com/charmbracelet/bubbles/list"

type item string
func (i item) FilterValue() string { return string(i) }

// Minimal custom delegate:
type delegate struct{}
func (delegate) Height() int                               { return 1 }
func (delegate) Spacing() int                              { return 0 }
func (delegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd   { return nil }
func (delegate) Render(w io.Writer, m list.Model, index int, li list.Item) {
	it, ok := li.(item)
	if !ok { return }
	prefix := "  "
	if index == m.Index() { prefix = "> " }
	fmt.Fprint(w, prefix+string(it))
}

l := list.New([]list.Item{item("Alpha"), item("Bravo")}, delegate{}, 30, 14)
l.Title = "Pick one"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)

// In Update, forward and respond to window sizes:
case tea.WindowSizeMsg:
	m.list.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
	if msg.String() == "enter" {
		if it, ok := m.list.SelectedItem().(item); ok {
			m.choice = string(it)
		}
	}
m.list, cmd = m.list.Update(msg)

For a batteries-included default, use list.NewDefaultDelegate() and items that implement both Title() string and Description() string.

viewport (scrollable pager)

import "github.com/charmbracelet/bubbles/viewport"

case tea.WindowSizeMsg:
	if !m.ready {
		m.viewport = viewport.New(msg.Width, msg.Height-headerFooterHeight)
		m.viewport.SetContent(m.content)
		m.ready = true
	} else {
		m.viewport.Width = msg.Width
		m.viewport.Height = msg.Height - headerFooterHeight
	}

// always forward key/mouse for scroll:
m.viewport, cmd = m.viewport.Update(msg)

Enable mouse wheel scrolling with tea.WithMouseCellMotion() in program options. m.viewport.ScrollPercent() gives the current scroll ratio for status bars.

progress

import "github.com/charmbracelet/bubbles/progress"

pb := progress.New(progress.WithDefaultGradient())
// or: progress.WithScaledGradient("#FF7CCB", "#FDFF8C")
// or: progress.WithSolidFill("63")

// Animated set:
case progressMsg: // your own msg carrying a float64 percentage
	cmd := m.progress.SetPercent(float64(msg))
	return m, cmd

case progress.FrameMsg:
	var cmd tea.Cmd
	newModel, cmd := m.progress.Update(msg)
	m.progress = newModel.(progress.Model)
	return m, cmd

// View: m.progress.View()

Set width after window size messages: m.progress.Width = msg.Width - padding.

paginator

import "github.com/charmbracelet/bubbles/paginator"

p := paginator.New()
p.Type = paginator.Dots       // or paginator.Arabic
p.PerPage = 10
p.ActiveDot   = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•")
p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•")
p.SetTotalPages(len(items))

// In View:
start, end := m.paginator.GetSliceBounds(len(m.items))
for _, it := range m.items[start:end] { /* render */ }
sb.WriteString(m.paginator.View())

// Forward in Update:
m.paginator, cmd = m.paginator.Update(msg)

table

import "github.com/charmbracelet/bubbles/table"

cols := []table.Column{
	{Title: "Rank", Width: 4},
	{Title: "City", Width: 20},
}
rows := []table.Row{{"1", "Tokyo"}, {"2", "Delhi"}}

t := table.New(
	table.WithColumns(cols),
	table.WithRows(rows),
	table.WithFocused(true),
	table.WithHeight(7),
)

s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
	BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(false)
s.Selected = s.Selected.Foreground(lipgloss.Color("229")).
	Background(lipgloss.Color("57")).Bold(false)
t.SetStyles(s)

// In Update:
switch msg := msg.(type) {
case tea.KeyMsg:
	switch msg.String() {
	case "enter":
		return m, tea.Batch(tea.Printf("chose %s", m.table.SelectedRow()[1]))
	case "esc":
		if m.table.Focused() { m.table.Blur() } else { m.table.Focus() }
	}
}
m.table, cmd = m.table.Update(msg)

help

import (
	"github.com/charmbracelet/bubbles/help"
	"github.com/charmbracelet/bubbles/key"
)

type keyMap struct{ Up, Down, Help, Quit key.Binding }

func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Help, k.Quit} }
func (k keyMap) FullHelp() [][]key.Binding {
	return [][]key.Binding{{k.Up, k.Down}, {k.Help, k.Quit}}
}

h := help.New()

// View:
helpView := m.help.View(m.keys) // one-liner; m.help.ShowAll = true for full view

// Resize:
case tea.WindowSizeMsg:
	m.help.Width = msg.Width

timer / stopwatch

import "github.com/charmbracelet/bubbles/timer"

m.timer = timer.New(time.Minute)

func (m model) Init() tea.Cmd { return m.timer.Init() }

case timer.TickMsg:
	m.timer, cmd = m.timer.Update(msg)
case timer.StartStopMsg:
	m.timer, cmd = m.timer.Update(msg)
case timer.TimeoutMsg:
	return m, tea.Quit

Stopwatch has the same shape (stopwatch.New(), stopwatch.TickMsg, Start/Stop/Reset).

filepicker

import "github.com/charmbracelet/bubbles/filepicker"

fp := filepicker.New()
fp.AllowedTypes = []string{".go", ".md"}
fp.CurrentDirectory, _ = os.UserHomeDir()

// In Update, forward msgs:
m.fp, cmd = m.fp.Update(msg)
if didSelect, path := m.fp.DidSelectFile(msg); didSelect {
	m.selected = path
}

8. Common patterns

Loading state while an async op runs

type doneMsg struct{ result string }

func loadStuff() tea.Cmd {
	return func() tea.Msg {
		time.Sleep(2 * time.Second)
		return doneMsg{result: "fetched"}
	}
}

type model struct {
	spinner  spinner.Model
	loading  bool
	result   string
}

func (m model) Init() tea.Cmd {
	return tea.Batch(m.spinner.Tick, loadStuff())
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case doneMsg:
		m.loading = false
		m.result = msg.result
		return m, nil
	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 {
	if m.loading {
		return fmt.Sprintf("%s loading...", m.spinner.View())
	}
	return "Result: " + m.result
}

Multi-screen navigation (state machine)

Keep an enum of screens on the model and dispatch in Update and View:

type screen int
const (
	screenMenu screen = iota
	screenForm
	screenResult
)

type model struct {
	screen screen
	menu   list.Model
	form   formModel
	result string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch m.screen {
	case screenMenu:
		return m.updateMenu(msg)
	case screenForm:
		return m.updateForm(msg)
	case screenResult:
		return m.updateResult(msg)
	}
	return m, nil
}

func (m model) View() string {
	switch m.screen {
	case screenMenu:   return m.menu.View()
	case screenForm:   return m.form.View()
	case screenResult: return m.result
	}
	return ""
}

Each sub-update returns m, cmd and can change m.screen to transition.

Modal dialog overlay

Store a modal *modalModel on the parent. When non-nil, render it on top:

func (m model) View() string {
	base := m.mainView()
	if m.modal == nil { return base }
	dialog := lipgloss.NewStyle().
		Border(lipgloss.RoundedBorder()).
		Padding(1, 2).
		Render(m.modal.View())
	return lipgloss.Place(
		m.width, m.height,
		lipgloss.Center, lipgloss.Center,
		dialog,
		lipgloss.WithWhitespaceChars(" "),
	)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	if m.modal != nil {
		// let the modal consume messages; swallow keys from the base
		var cmd tea.Cmd
		*m.modal, cmd = m.modal.Update(msg)
		if m.modal.done { m.modal = nil }
		return m, cmd
	}
	return m.baseUpdate(msg)
}

Composable/embedded models (tab layout)

type sessionState uint
const (
	timerView sessionState = iota
	spinnerView
)

type mainModel struct {
	state   sessionState
	timer   timer.Model
	spinner spinner.Model
}

func (m mainModel) Init() tea.Cmd {
	return tea.Batch(m.timer.Init(), m.spinner.Tick)
}

func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmds []tea.Cmd
	var cmd tea.Cmd
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c", "q":
			return m, tea.Quit
		case "tab":
			if m.state == timerView { m.state = spinnerView } else { m.state = timerView }
		}
		// only forward keys to the focused child:
		if m.state == spinnerView {
			m.spinner, cmd = m.spinner.Update(msg)
		} else {
			m.timer, cmd = m.timer.Update(msg)
		}
		cmds = append(cmds, cmd)
	case spinner.TickMsg:
		m.spinner, cmd = m.spinner.Update(msg)
		cmds = append(cmds, cmd)
	case timer.TickMsg:
		m.timer, cmd = m.timer.Update(msg)
		cmds = append(cmds, cmd)
	}
	return m, tea.Batch(cmds...)
}

func (m mainModel) View() string {
	box := lipgloss.NewStyle().Width(15).Height(5).
		Align(lipgloss.Center, lipgloss.Center).
		BorderStyle(lipgloss.HiddenBorder())
	focused := box.BorderStyle(lipgloss.NormalBorder()).
		BorderForeground(lipgloss.Color("69"))
	if m.state == timerView {
		return lipgloss.JoinHorizontal(lipgloss.Top,
			focused.Render(m.timer.View()), box.Render(m.spinner.View()))
	}
	return lipgloss.JoinHorizontal(lipgloss.Top,
		box.Render(m.timer.View()), focused.Render(m.spinner.View()))
}

Pager (viewport + header + footer)

var (
	titleStyle = func() lipgloss.Style {
		b := lipgloss.RoundedBorder(); b.Right = "├"
		return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
	}()
	infoStyle = func() lipgloss.Style {
		b := lipgloss.RoundedBorder(); b.Left = "┤"
		return titleStyle.BorderStyle(b)
	}()
)

func (m model) headerView() string {
	title := titleStyle.Render("Mr. Pager")
	line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)))
	return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}

func (m model) footerView() string {
	info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
	line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)))
	return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
}

func (m model) View() string {
	if !m.ready { return "\n  Initializing..." }
	return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
}

// Launch with alt-screen + mouse:
p := tea.NewProgram(model{content: string(b)},
	tea.WithAltScreen(), tea.WithMouseCellMotion())

Mouse events

p := tea.NewProgram(model{}, tea.WithMouseAllMotion())

case tea.MouseMsg:
	// msg.X, msg.Y, msg.Action (MouseActionPress/Release/Motion),
	// msg.Button (MouseButtonLeft/Right/Wheel...), msg.Alt/Ctrl/Shift
	return m, tea.Printf("(%d, %d) %s", msg.X, msg.Y, msg.String())

9. Logging and debugging

TUIs own stdout/stderr, so you can't fmt.Println for debugging while the program runs. Instead:

if os.Getenv("DEBUG") != "" {
	f, err := tea.LogToFile("debug.log", "debug")
	if err != nil { log.Fatal(err) }
	defer f.Close()
}
// now log.Printf(...) writes to debug.log

Then tail -f debug.log in another pane. Or print above the TUI (only visible in inline mode, not alt-screen) with tea.Println(...) / tea.Printf(...) returned as a Cmd.

10. Testing TUIs with teatest

import (
	"bytes"
	"testing"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/x/exp/teatest"
)

func TestMyModel(t *testing.T) {
	m := initialModel()
	tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))

	// Type keystrokes
	tm.Type("hello")

	// Send a synthetic message
	tm.Send(tea.KeyMsg{Type: tea.KeyEnter})

	// Wait until the rendered output matches a condition
	teatest.WaitFor(t, tm.Output(),
		func(bts []byte) bool {
			return bytes.Contains(bts, []byte("expected text"))
		},
		teatest.WithDuration(time.Second),
	)

	// Quit and assert final state
	tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
	finalModel := tm.FinalModel(t).(model)
	if finalModel.choice != "hello" {
		t.Fatalf("want choice=hello, got %q", finalModel.choice)
	}

	// Or compare the final rendered output (golden-file friendly)
	out := tm.FinalOutput(t)
	teatest.RequireEqualOutput(t, out)
}

Key helpers:

  • teatest.NewTestModel(t, m, opts...) - harness; WithInitialTermSize(w, h) is usually needed.
  • tm.Send(msg) - inject any tea.Msg.
  • tm.Type("abc") - send keystrokes.
  • tm.Output() - io.Reader of the live render stream.
  • tm.FinalModel(t) / tm.FinalOutput(t) - wait for exit, then inspect.
  • teatest.WaitFor(t, r, pred, opts...) - block until render output satisfies pred.
  • teatest.RequireEqualOutput(t, bytes) - golden-file comparison under -update.

11. Running a program

p := tea.NewProgram(initialModel(), tea.WithAltScreen())
finalModel, err := p.Run()
if err != nil {
	log.Fatalf("alas: %v", err)
}
// finalModel is a tea.Model holding terminal state; type-assert to your concrete type:
if m, ok := finalModel.(model); ok {
	fmt.Println("chose:", m.choice)
}

Other *tea.Program methods worth knowing:

  • p.Send(msg) - inject a message from outside (goroutines).
  • p.Quit() - request shutdown.
  • p.Kill() - force shutdown (returns tea.ErrProgramKilled).
  • p.Wait() - block until the program finishes.
  • p.ReleaseTerminal() / p.RestoreTerminal() - temporarily hand the TTY back (e.g. to shell out to $EDITOR). Or use tea.ExecProcess(cmd, cb) / tea.Exec(...) as a Cmd.

12. Conventions and gotchas

  • Pure Update. Don't do I/O, sleep, or HTTP in Update. Return a Cmd for that.
  • Value receivers. func (m model) Update(...) — returning a modified copy is the idiom.
  • Forward messages. When you embed a Bubbles component, you must call m.sub, cmd = m.sub.Update(msg) in your Update or it won't animate/scroll/blink.
  • Always handle tea.WindowSizeMsg if any child uses Width/Height (viewport, list, table, progress, help).
  • Handle ctrl+c. Always map it to tea.Quit early in the switch.
  • Alt-screen + logs. fmt.Println is swallowed when WithAltScreen() is on; use tea.LogToFile.
  • Batch, not chain. When an Update branch wants to issue multiple commands, collect them and return tea.Batch(cmds...) — don't call Update recursively.
  • Rate-limit updates. If an external source (channel, file watch) can flood p.Send, coalesce on the sender side; the renderer caps at 60 FPS by default.
  • Styles are immutable. s.Bold(true) returns a new style; reassign if you want it to stick.

13. v2 migration notes (github.com/charmbracelet/bubbletea/v2)

If the project imports github.com/charmbracelet/bubbletea/v2 (or charm.land/bubbletea/v2), key differences:

  • View() returns tea.View (via tea.NewView(s)), not a plain string.
  • Key input is split: prefer tea.KeyPressMsg (key-down) in Update. Key fields include Mod (e.g. tea.ModCtrl) and Code (a rune).
  • tea.SetWindowTitle is available as a Cmd (also present in v1).
  • The import for bubbles is charm.land/bubbles/v2/... and for lipgloss charm.land/lipgloss/v2.

Every pattern in this skill has a v2 equivalent; the Model/Update/View loop is unchanged. When in doubt, inspect the project's go.mod to see which major version is in use and match existing style.

Related skills
Installs
3
First Seen
Apr 18, 2026