Bubble Tea v2
Build terminal UIs in Go using the Elm Architecture. Import as tea "charm.land/bubbletea/v2".
$ARGUMENTS context: If a specific component or pattern is requested, focus guidance on that area.
Quick Start
Minimal working program - a counter:
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
type model struct{ count int }
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.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
m.count++
case "down", "j":
m.count--
}
}
return m, nil
}
func (m model) View() tea.View {
return tea.NewView(fmt.Sprintf("Count: %d\n\nup/down to change, q to quit\n", m.count))
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
Core API Reference
The Elm Architecture
Bubble Tea follows the Elm Architecture - a unidirectional data flow pattern:
- Model holds all application state
- Update receives messages, returns updated model + optional command
- View renders the model to a string (called after every Update)
- Commands perform async I/O, returning messages back to Update
The runtime owns the loop. You never mutate state outside Update. You never call View yourself.
tea.Model Interface
type Model interface {
Init() Cmd // initial command (return nil for none)
Update(Msg) (Model, Cmd) // handle messages, return new state + command
View() View // render UI
}
tea.Cmd and tea.Msg
type Msg = any // messages can be any type
type Cmd func() Msg // commands are functions that return a message
A Cmd is a function that performs I/O and returns a Msg. Return nil for no command.
tea.View
// Create a view from a string
v := tea.NewView("Hello, World!")
// View struct fields (set after creation):
v.AltScreen = true // fullscreen mode
v.MouseMode = tea.MouseModeCellMotion // enable mouse
v.ReportFocus = true // get FocusMsg/BlurMsg
v.Cursor = tea.NewCursor(x, y) // show cursor at position
v.WindowTitle = "My App" // set terminal title
v.BackgroundColor = someColor // set terminal bg
v.KeyboardEnhancements.ReportEventTypes = true // key release events
Program
p := tea.NewProgram(model{}, opts...) // create program
m, err := p.Run() // run (blocks until quit)
p.Send(msg) // send message from outside
p.Quit() // quit from outside
p.Kill() // force kill
p.Wait() // block until shutdown
Program Options
tea.WithContext(ctx) // cancellable context
tea.WithInput(reader) // custom input (nil to disable)
tea.WithOutput(writer) // custom output
tea.WithFilter(func(Model, Msg) Msg) // intercept/filter messages
tea.WithFPS(fps) // custom FPS (default 60, max 120)
tea.WithEnvironment([]string) // custom env vars (SSH)
tea.WithWindowSize(w, h) // initial size (testing)
tea.WithColorProfile(profile) // force color profile
tea.WithoutRenderer() // no TUI rendering
tea.WithoutSignalHandler() // handle signals yourself
tea.WithoutCatchPanics() // disable panic recovery
Built-in Messages
| Message | When |
|---|---|
tea.KeyPressMsg |
Key pressed. Use msg.String() to match (e.g. "ctrl+c", "enter", "a") |
tea.KeyReleaseMsg |
Key released (needs keyboard enhancements) |
tea.WindowSizeMsg |
Terminal resized. Fields: Width, Height. Sent on startup + resize |
tea.MouseClickMsg |
Mouse click. Fields: X, Y, Button, Mod |
tea.MouseReleaseMsg |
Mouse button released |
tea.MouseWheelMsg |
Scroll wheel |
tea.MouseMotionMsg |
Mouse moved (needs AllMotion mode) |
tea.FocusMsg |
Terminal gained focus (needs ReportFocus) |
tea.BlurMsg |
Terminal lost focus |
tea.PasteMsg |
Bracketed paste. Field: Content |
tea.ColorProfileMsg |
Terminal color profile on startup |
tea.BackgroundColorMsg |
Response to RequestBackgroundColor. Has IsDark() |
tea.ResumeMsg |
Program resumed after suspend |
tea.KeyboardEnhancementsMsg |
Terminal keyboard capabilities |
Built-in Commands
| Command | What it does |
|---|---|
tea.Quit |
Exit the program |
tea.Suspend |
Suspend (ctrl+z behavior) |
tea.Interrupt |
Interrupt (returns ErrInterrupted) |
tea.ClearScreen |
Clear terminal |
tea.Batch(cmds...) |
Run commands concurrently |
tea.Sequence(cmds...) |
Run commands in order |
tea.Every(dur, fn) |
Tick synced with system clock |
tea.Tick(dur, fn) |
Tick from invocation time |
tea.Println(args...) |
Print above TUI (persists across renders) |
tea.Printf(tmpl, args...) |
Printf above TUI |
tea.SetClipboard(s) |
Set system clipboard (OSC52) |
tea.ReadClipboard |
Read system clipboard |
tea.RequestWindowSize |
Query current window size |
tea.RequestBackgroundColor |
Query terminal background color |
tea.ExecProcess(cmd, callback) |
Run external process (e.g. editor) |
tea.Raw(seq) |
Send raw escape sequence |
Key Handling
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q": // string matching (most common)
return m, tea.Quit
case "up", "k": // arrow keys have string names
case "enter", "space": // special keys
case "ctrl+s": // modifier combos
case "a": // regular characters
}
// Or use the Key struct for type-safe matching:
case tea.KeyPressMsg:
key := msg.Key()
switch key.Code {
case tea.KeyEnter: // typed constant
case tea.KeyTab:
case tea.KeyEsc:
}
// key.Mod for modifiers: tea.ModCtrl, tea.ModAlt, tea.ModShift
// key.Text for printable characters
Mouse Handling
Enable mouse in View, handle in Update:
func (m model) View() tea.View {
v := tea.NewView(m.render())
v.MouseMode = tea.MouseModeCellMotion // or tea.MouseModeAllMotion
return v
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseClickMsg:
x, y := msg.X, msg.Y
if msg.Button == tea.MouseLeft { /* handle click */ }
case tea.MouseWheelMsg:
if msg.Button == tea.MouseWheelUp { /* scroll up */ }
case tea.MouseMsg:
// catches all mouse events
mouse := msg.Mouse()
}
return m, nil
}
Common Patterns
Pattern 1: Async I/O (HTTP, file, etc.)
Define a custom Msg type. Write a Cmd that does the I/O and returns it.
type statusMsg int
type errMsg struct{ error }
func checkServer() tea.Msg {
res, err := http.Get("https://example.com")
if err != nil {
return errMsg{err}
}
defer res.Body.Close()
return statusMsg(res.StatusCode)
}
func (m model) Init() tea.Cmd { return checkServer } // note: pass function, don't call it
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.error
return m, nil
}
return m, nil
}
Pattern 2: Ticking / Timers
tea.Tick and tea.Every send a single message. Re-dispatch to loop.
type tickMsg time.Time
func doTick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m model) Init() tea.Cmd { return doTick() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tickMsg:
m.elapsed++
return m, doTick() // re-dispatch to keep ticking
}
return m, nil
}
tea.Every syncs with the system clock (wall-clock aligned ticks). tea.Tick starts from invocation.
Pattern 3: Composing Child Components
Child components follow the same Model/Update/View pattern. Parent delegates messages.
type parentModel struct {
spinner spinner.Model
input textinput.Model
focus int
}
func (m parentModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, m.input.Focus())
}
func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Always update spinner (it needs ticks regardless of focus)
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
// Only update focused component for key events
switch msg.(type) {
case tea.KeyPressMsg:
if m.focus == 0 {
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
func (m parentModel) View() tea.View {
return tea.NewView(m.spinner.View() + "\n" + m.input.View())
}
Pattern 4: External Messages via Channel
Use p.Send() to push messages from goroutines, or use a channel-based Cmd.
// Channel-based approach (preferred for subscription-like behavior)
func waitForActivity(sub chan resultMsg) tea.Cmd {
return func() tea.Msg {
return <-sub // blocks until message arrives
}
}
func (m model) Init() tea.Cmd {
return waitForActivity(m.sub)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case resultMsg:
m.results = append(m.results, msg)
return m, waitForActivity(m.sub) // re-subscribe
}
return m, nil
}
// p.Send approach (from outside the program)
go func() {
p.Send(myMsg{data: "hello"}) // thread-safe
}()
Pattern 5: Fullscreen with Alt Screen
Set AltScreen in your View. No Program option needed in v2.
func (m model) View() tea.View {
v := tea.NewView(fmt.Sprintf("Fullscreen app! Size: %dx%d", m.width, m.height))
v.AltScreen = true
return v
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
Integration Patterns
Lip Gloss (Styling)
import "charm.land/lipgloss/v2"
var style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Padding(0, 1)
func (m model) View() tea.View {
return tea.NewView(style.Render("styled text"))
}
Bubbles (Components)
Standard components: spinner, textinput, textarea, list, table, viewport, paginator, timer, stopwatch, help, key, filepicker, progress.
Import: "charm.land/bubbles/v2/<component>"
Each bubble follows the same Init/Update/View pattern. See Pattern 3 above.
Huh (Forms)
import "charm.land/huh/v2"
// Huh can run standalone or embed in a bubbletea program
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Name").Value(&name),
),
)
Common Mistakes
Calling a Cmd instead of passing it.
Initreturnstea.Cmd, nottea.Msg. Writereturn checkServernotreturn checkServer(). The runtime calls the function.Forgetting to return a Cmd from tick handlers.
tea.Tickandtea.Everyfire once. You must return a new tick command in your Update handler to keep ticking.Mutating the model outside Update. The Elm Architecture requires all state changes go through Update. Don't share model pointers with goroutines. Use
p.Send()or channel-based Cmds to communicate.Not handling WindowSizeMsg. It's sent on startup and every resize. If you do any layout math, store the dimensions and use them in View.
Using fmt.Println for output. stdout is owned by the TUI. Use
tea.LogToFile()for debug logging. Usetea.Println()/tea.Printf()to print above the TUI.Returning
tea.Batch()with nil commands. This is safe -tea.Batchfilters nil commands - but returning a plainnilis cleaner when you have no commands.AltScreen in v2 is a View property, not a Program option. Set
v.AltScreen = truein your View method. Same for mouse mode - setv.MouseModein View.Blocking in Update. Update must return quickly. Any I/O (HTTP calls, file reads, sleeps) belongs in a Cmd, not directly in Update.
Not handling ctrl+c. The terminal is in raw mode - ctrl+c won't kill your app automatically. Always match
"ctrl+c"in your KeyPressMsg handler and returntea.Quitortea.Interrupt.
Checklist
- Model implements
Init() tea.Cmd,Update(tea.Msg) (tea.Model, tea.Cmd),View() tea.View - View returns
tea.NewView(s), not a raw string - ctrl+c / q handling exists in Update
- WindowSizeMsg handled if doing any layout
- All I/O in Cmds, never in Update
- Tick commands re-dispatched in Update handler
- Child component updates collected with
tea.Batch(cmds...) - No
fmt.Println- usetea.LogToFilefor debugging - AltScreen and MouseMode set in View, not as Program options
Reference
For full message/type/constant listings, see references/api.md.