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/v2for 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.ExitAltScreentea.EnableMouseCellMotion,tea.EnableMouseAllMotion,tea.DisableMousetea.HideCursor,tea.ShowCursortea.ClearScreentea.EnableBracketedPaste,tea.DisableBracketedPastetea.EnableReportFocus,tea.DisableReportFocustea.Suspend(withtea.ResumeMsgarriving 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 anytea.Msg.tm.Type("abc")- send keystrokes.tm.Output()-io.Readerof 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 satisfiespred.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 (returnstea.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 usetea.ExecProcess(cmd, cb)/tea.Exec(...)as aCmd.
12. Conventions and gotchas
- Pure Update. Don't do I/O, sleep, or HTTP in
Update. Return aCmdfor 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 yourUpdateor it won't animate/scroll/blink. - Always handle
tea.WindowSizeMsgif any child usesWidth/Height(viewport, list, table, progress, help). - Handle
ctrl+c. Always map it totea.Quitearly in the switch. - Alt-screen + logs.
fmt.Printlnis swallowed whenWithAltScreen()is on; usetea.LogToFile. - Batch, not chain. When an Update branch wants to issue multiple commands, collect them and return
tea.Batch(cmds...)— don't callUpdaterecursively. - 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()returnstea.View(viatea.NewView(s)), not a plainstring.- Key input is split: prefer
tea.KeyPressMsg(key-down) inUpdate.Keyfields includeMod(e.g.tea.ModCtrl) andCode(a rune). tea.SetWindowTitleis available as aCmd(also present in v1).- The import for bubbles is
charm.land/bubbles/v2/...and for lipglosscharm.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.