Bubble Tea is a Go framework for building rich terminal user interfaces (TUIs). It is based on The Elm Architecture — a simple, functional, and stateful pattern where your entire application is described by a single model, an update function that reacts to events, and a view that renders the current state. Bubble Tea takes care of input, rendering, and the redraw loop so you can focus on your application's logic.
This guide covers Bubble Tea v2 (module path charm.land/bubbletea/v2). v2 is a significant evolution of the original github.com/charmbracelet/bubbletea; if you are migrating an existing app, jump to Upgrading from v1 and the dedicated UPGRADE_GUIDE_V2.md shipped in the repository.
Note
Bubble Tea is a library you import, not a command-line tool you install and run. There are no
bubbleteasubcommands. You build your own program against theteapackage, and your binary is what users run.
1. Quick Start
Here is a complete, runnable Bubble Tea program — a counter you increment with the spacebar.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
// model holds your entire application state.
type model struct {
count int
}
// Init runs once at startup and returns an optional initial command.
func (m model) Init() tea.Cmd {
return nil
}
// Update reacts to messages (events) and returns the next model + an optional command.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "space":
m.count++
}
}
return m, nil
}
// View renders the UI from the current model.
func (m model) View() tea.View {
return tea.NewView(fmt.Sprintf("Count: %d\n\nPress space to increment, q to quit.\n", m.count))
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}
Run it:
go run .
Expected output (interactive — updates in place as you press space):
Count: 3
Press space to increment, q to quit.
Tip
Every Bubble Tea program is just these four pieces: a
modeltype, andInit,Update, andViewmethods on it. Once you understand this loop, you understand Bubble Tea.
2. Installation & Requirements
Requirements
| Requirement | Detail |
|---|---|
| Go | 1.25 or newer (the module declares go 1.25.0) |
| Terminal | Any modern terminal emulator. Advanced features (true color, keyboard enhancements, synchronized output) are detected and gracefully degraded when unsupported. |
| OS | Linux, macOS, BSD, Windows, and more. Windows uses the Console VT API; most Unix-like systems are supported via termios. |
Add it to your project
go get charm.land/bubbletea/v2
Then import it (the conventional alias is tea):
import tea "charm.land/bubbletea/v2"
Important
The v2 import path is the vanity domain
charm.land/bubbletea/v2, notgithub.com/charmbracelet/bubbletea. Using the old path pulls in v1, which has a different API.
Run go mod tidy to download Bubble Tea and its dependencies.
Pair it with the ecosystem
Bubble Tea is the runtime. For ready-made widgets and styling you'll almost always pair it with its sibling libraries:
| Library | Purpose | Import |
|---|---|---|
| Bubbles | Common components: text input, list, table, viewport, spinner, paginator, help, file picker, text area, progress, stopwatch, timer | charm.land/bubbles/v2/... |
| Lip Gloss | Style, color, and layout (borders, padding, alignment, joins) | charm.land/lipgloss/v2 |
| Glamour | Render Markdown in the terminal | charm.land/glamour/v2 |
| Harmonica | Spring-physics animation | github.com/charmbracelet/harmonica |
Note
Bubbles and Lip Gloss are separate modules with their own v2 import paths. This guide documents Bubble Tea itself; component APIs (like
textinput.Model) belong to Bubbles.
3. Core Concepts: The Elm Architecture
Bubble Tea applications have exactly three moving parts, all defined by the tea.Model interface:
type Model interface {
Init() Cmd // startup: return an optional initial command
Update(Msg) (Model, Cmd) // event handling: react to a message
View() View // rendering: describe the UI
}
┌─────────────────────────────────────────┐
│ │
start ──►│ Init() ──► initial Cmd │
│ │
│ ┌──────────────┐ │
event ──►│ ──► │ Update() │ ──► (Model, Cmd) ──┐ │
(Msg) │ └──────────────┘ │ │
│ ▲ │ │
│ │ Cmd runs ──► new Msg ───┘ │
│ │ │
│ ┌──────────────┐ │
│ │ View() │ ──► rendered to screen│
│ └──────────────┘ │
└─────────────────────────────────────────┘
Model — your state
The model is any Go type (usually a struct) that holds everything your app needs to know. It is the single source of truth.
type model struct {
choices []string
cursor int
selected map[int]struct{}
}
Init — the startup hook
Init runs once before the first render and returns an initial tea.Cmd (or nil for "do nothing yet"). Use it to kick off work like an HTTP request, a timer, or a blinking cursor.
func (m model) Init() tea.Cmd {
return nil // no initial I/O
}
Update — react to messages
Whenever anything happens — a keypress, a window resize, a command finishing — Bubble Tea calls Update with a tea.Msg. You inspect the message (usually with a type switch), return an updated model, and optionally return a command to do more work.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
case "enter", "space":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
case "ctrl+c", "q":
return m, tea.Quit
}
}
return m, nil
}
Important
Updatemust return the updated model. Bubble Tea uses the returned model for the next render and the next update. If you mutate a copy and forget to return it, your changes are lost. (When your model is a value type, you modify the copymand return it; when it's a pointer, you can return the same pointer.)
View — describe the UI
View looks at the current model and returns a tea.View describing what to show. You never call it yourself and you never manage redraws — Bubble Tea calls View after every update and diffs the output for you.
func (m model) View() tea.View {
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 tea.NewView(s)
}
Tip
In v2,
View()returns atea.Viewstruct, not astring.tea.NewView("...")is the shortcut for "just this text." When you need terminal features like full-screen mode or mouse tracking, you set fields on the View — see Section 5.
4. Commands and Messages
Messages (tea.Msg) are events. Commands (tea.Cmd) are how you cause events — especially the results of I/O. This separation is what keeps Bubble Tea apps responsive: slow work runs off the main loop and reports back as a message.
A command is simply a function that does some work and returns a message:
type Cmd func() Msg
Writing a command
Here a command performs an HTTP request and reports the result back as a message:
const url = "https://charm.sh/"
type statusMsg int
type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() }
// checkServer is a tea.Cmd: a func() tea.Msg.
func checkServer() tea.Msg {
c := &http.Client{Timeout: 10 * time.Second}
res, err := c.Get(url)
if err != nil {
return errMsg{err}
}
defer res.Body.Close()
return statusMsg(res.StatusCode)
}
func (m model) Init() tea.Cmd {
return checkServer // run it at startup
}
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
}
return m, nil
}
Caution
Never do blocking I/O directly inside
Update.Updateruns on the main loop; blocking it freezes your whole UI. Put slow work inside a command instead — Bubble Tea runs commands concurrently and feeds the resulting message back toUpdate.
Built-in commands
These functions return commands (or special messages) you return from Init/Update:
| Command | Returns | What it does |
|---|---|---|
tea.Quit |
special | Tells the program to exit gracefully. |
tea.Interrupt |
special | Exits with ErrInterrupted (like Ctrl+C semantics). |
tea.Suspend |
special | Suspends the program (like Ctrl+Z); you get a ResumeMsg on return. |
tea.Batch(cmds...) |
Cmd |
Run several commands concurrently, no ordering guarantees. |
tea.Sequence(cmds...) |
Cmd |
Run several commands one at a time, in order. |
tea.Tick(d, fn) |
Cmd |
Fire once after duration d, independent of the system clock. |
tea.Every(d, fn) |
Cmd |
Fire once, synced to the system clock (e.g. on the next whole second). |
tea.Exec(c, fn) / tea.ExecProcess(c, fn) |
Cmd |
Pause the program and run an external process (editor, shell). |
tea.Println(args...) / tea.Printf(fmt, args...) |
Cmd |
Print a line above the program that persists across renders. |
tea.ClearScreen |
special | Clear the screen before the next render (inline mode). |
tea.SetClipboard(s) / tea.ReadClipboard |
Cmd / special |
Write/read the system clipboard via OSC52. |
tea.SetPrimaryClipboard(s) / tea.ReadPrimaryClipboard |
Cmd / special |
X11/Wayland primary selection. |
tea.RequestWindowSize |
special | Ask for the current terminal size (delivered as WindowSizeMsg). |
tea.RequestBackgroundColor / tea.RequestForegroundColor / tea.RequestCursorColor |
special | Query terminal colors. |
tea.RequestCursorPosition |
special | Query the cursor position (CursorPositionMsg). |
tea.RequestCapability(name) |
Cmd |
Query a terminfo capability (e.g. "RGB", "Tc"). |
tea.RequestTerminalVersion |
special | Query the terminal name/version (XTVERSION). |
tea.Raw(seq) |
Cmd |
Send a raw escape sequence to the terminal (advanced). |
Note
Some of these (
tea.Quit,tea.ClearScreen,tea.RequestWindowSize,tea.ReadClipboard, …) are functions you pass by name (they are themselvestea.Cmdvalues). Others (tea.Batch,tea.Tick,tea.SetClipboard, …) you call to produce a command. Match the usage in the examples above.
Looping a timer
Tick and Every fire only once. To keep ticking, return the command again from Update:
type tickMsg time.Time
func tickEvery() tea.Cmd {
return tea.Every(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m model) Init() tea.Cmd { return tickEvery() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tickMsg:
// ... do per-second work ...
return m, tickEvery() // schedule the next tick
}
return m, nil
}
Built-in messages you'll receive
Bubble Tea translates terminal events into typed messages and delivers them to Update. Match the ones you care about:
| Message | When it arrives |
|---|---|
tea.KeyPressMsg |
A key was pressed. The most common message you'll handle. |
tea.KeyReleaseMsg |
A key was released (only with keyboard enhancements). |
tea.KeyMsg |
Interface covering both press and release — type-switch inside. |
tea.MouseClickMsg / tea.MouseReleaseMsg / tea.MouseWheelMsg / tea.MouseMotionMsg |
Mouse events (when a mouse mode is enabled). |
tea.MouseMsg |
Interface covering all mouse events — call .Mouse() for coordinates. |
tea.WindowSizeMsg |
Sent on startup and on every terminal resize. Has Width, Height. |
tea.FocusMsg / tea.BlurMsg |
Terminal gained/lost focus (when ReportFocus is on). |
tea.PasteMsg / tea.PasteStartMsg / tea.PasteEndMsg |
Bracketed-paste content and boundaries. |
tea.ColorProfileMsg |
The detected color profile (sent at startup). |
tea.BackgroundColorMsg / tea.ForegroundColorMsg / tea.CursorColorMsg |
Terminal color query results. Has IsDark(). |
tea.EnvMsg |
The program's environment (useful over SSH). |
tea.KeyboardEnhancementsMsg |
Which keyboard enhancements the terminal supports. |
tea.ResumeMsg |
The program resumed after a suspend. |
tea.WindowSizeMsg is auto-delivered — you don't need to request it for normal use. |
Tip
A custom message is just any type. Define your own (
type myMsg struct{...}) and send it from a command. That's the idiomatic way to get the result of background work intoUpdate.
5. The View: Controlling the Terminal
In v2, terminal features are declarative: you set fields on the tea.View returned by View(), every render. There are no startup flags or toggle commands — you simply describe the state you want, and Bubble Tea makes the terminal match it.
func (m model) View() tea.View {
v := tea.NewView("Hello, full screen!")
v.AltScreen = true // take over the whole window
v.MouseMode = tea.MouseModeCellMotion // track mouse clicks/drags
v.WindowTitle = "My App" // set the terminal title
return v
}
View fields reference
| Field | Type | What it controls |
|---|---|---|
Content |
string |
The rendered text. Set with tea.NewView("…") or v.SetContent("…"). |
AltScreen |
bool |
Full-window "alternate screen" mode. Auto-restored on exit. |
MouseMode |
MouseMode |
MouseModeNone, MouseModeCellMotion, or MouseModeAllMotion. |
ReportFocus |
bool |
Deliver FocusMsg/BlurMsg when the terminal gains/loses focus. |
DisableBracketedPasteMode |
bool |
Turn off bracketed paste for this view. |
WindowTitle |
string |
The terminal window title. |
Cursor |
*Cursor |
Show/position/style the cursor. nil hides it. |
ForegroundColor |
color.Color |
Override the terminal foreground color (nil = default). |
BackgroundColor |
color.Color |
Override the terminal background color (nil = default). |
ProgressBar |
*ProgressBar |
Native terminal progress bar (where supported). |
KeyboardEnhancements |
KeyboardEnhancements |
Request key repeat/release and other enhanced reporting. |
OnMouse |
func(MouseMsg) Cmd |
Per-view mouse handler based on rendered content. |
Full-screen vs. inline
- Inline (default): your UI renders in the normal terminal flow, scrolling with history. Great for prompts, spinners, and progress.
- Alt screen (
v.AltScreen = true): your UI takes over the entire window likevimorless. On exit, the previous terminal contents are restored.
// Toggle full screen with a key:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "f" {
m.fullscreen = !m.fullscreen
}
}
return m, nil
}
func (m model) View() tea.View {
v := tea.NewView("Press f to toggle full screen.")
v.AltScreen = m.fullscreen
return v
}
Showing and styling the cursor
v := tea.NewView("Type here: ")
v.Cursor = tea.NewCursor(11, 0) // x, y; visible block cursor that blinks
v.Cursor.Shape = tea.CursorBar // CursorBlock, CursorUnderline, or CursorBar
v.Cursor.Blink = false
Set v.Cursor = nil to hide the cursor entirely.
Native terminal progress bar
v := tea.NewView("Downloading…")
v.ProgressBar = tea.NewProgressBar(tea.ProgressBarDefault, 42) // 0–100
States: ProgressBarNone, ProgressBarDefault, ProgressBarError, ProgressBarIndeterminate, ProgressBarWarning. Support depends on the terminal (e.g. Windows Terminal).
Note
The View progress bar is the terminal's own progress indicator (taskbar/tab). For an in-content progress bar widget, use the
progresscomponent from Bubbles.
6. Handling Keyboard Input
Match tea.KeyPressMsg and compare against msg.String() — the readable, human-friendly key name.
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
// submit
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
m.cursor--
case "shift+tab":
// previous field
}
Key naming you should know
| Key | String() returns |
|---|---|
| Spacebar | "space" (not " ") |
| Enter / Return | "enter" |
| Escape | "esc" |
| Arrows | "up", "down", "left", "right" |
| With modifiers | "ctrl+c", "alt+enter", "shift+tab", "ctrl+shift+a" |
Important
The spacebar's
String()is"space". (ItsCodeis still the rune' 'and itsTextis still" "— but match on"space"when usingString().)
Field-level matching
For maximum precision, inspect the Key directly instead of its string:
case tea.KeyPressMsg:
key := msg.Key()
switch key.Code {
case tea.KeyEnter:
// enter
case tea.KeyEsc:
// escape
default:
if key.Text != "" {
// a printable character was typed: key.Text
}
}
The Key struct fields:
| Field | Meaning |
|---|---|
Code |
The key as a rune or special constant (KeyEnter, KeyF1, 'a', …). |
Text |
The printable character(s), if any (empty for special/modified keys). |
Mod |
Modifier bitmask. Test with key.Mod.Contains(tea.ModCtrl), etc. |
ShiftedCode |
The shifted key (e.g. 'A' for shift+a). Kitty protocol / Windows only. |
BaseCode |
The key on a US PC-101 layout (handy for international keyboards). |
IsRepeat |
Whether the key is auto-repeating. Kitty protocol / Windows only. |
Modifier constants: tea.ModShift, tea.ModAlt, tea.ModCtrl, tea.ModMeta, tea.ModSuper, tea.ModHyper, plus lock states tea.ModCapsLock, tea.ModNumLock, tea.ModScrollLock.
Keyboard enhancements (key repeat & release)
Some terminals (via the Kitty keyboard protocol) can report key releases and richer key data. Request it via the View:
func (m model) View() tea.View {
v := tea.NewView("Press keys…")
v.KeyboardEnhancements.ReportEventTypes = true // enable release/repeat events
return v
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyboardEnhancementsMsg:
if msg.SupportsEventTypes() {
// great — we'll get KeyReleaseMsg too
}
case tea.KeyReleaseMsg:
// a key was released
}
return m, nil
}
7. Handling Mouse Input
Enable a mouse mode on the View, then match the specific mouse message types.
func (m model) View() tea.View {
v := tea.NewView("Click anywhere.")
v.MouseMode = tea.MouseModeCellMotion // clicks, releases, wheel, drags
return v
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseClickMsg:
mouse := msg.Mouse()
if mouse.Button == tea.MouseLeft {
// left click at mouse.X, mouse.Y
}
case tea.MouseWheelMsg:
if msg.Mouse().Button == tea.MouseWheelUp {
// scrolled up
}
case tea.MouseMotionMsg:
// movement (drag in CellMotion, any move in AllMotion)
}
return m, nil
}
Mouse modes
| Mode | Captures |
|---|---|
tea.MouseModeNone |
Nothing (default). |
tea.MouseModeCellMotion |
Clicks, releases, wheel, and drag motion (motion while a button is held). Best supported. |
tea.MouseModeAllMotion |
All of the above plus motion with no buttons pressed. |
Mouse buttons
tea.MouseLeft, tea.MouseMiddle, tea.MouseRight, tea.MouseWheelUp, tea.MouseWheelDown, tea.MouseWheelLeft, tea.MouseWheelRight, tea.MouseBackward, tea.MouseForward, tea.MouseNone.
Note
Coordinates are zero-based with
(0,0)at the top-left. In v2,MouseMsgis an interface — always call.Mouse()to get theMousestruct (X,Y,Button,Mod).
8. Common Tasks (Recipes)
Run an external program (editor, pager, shell)
Pause Bubble Tea, hand the terminal to another process, and resume when it exits.
type editorFinishedMsg struct{ err error }
func openEditor() tea.Cmd {
editor := cmp.Or(os.Getenv("EDITOR"), "vim")
c := exec.Command(editor)
return tea.ExecProcess(c, func(err error) tea.Msg {
return editorFinishedMsg{err}
})
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "e" {
return m, openEditor()
}
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
return m, tea.Quit
}
}
return m, nil
}
Send messages into a running program from outside
A *Program can receive messages from other goroutines via p.Send. This is how you feed in events from servers, channels, or background workers.
p := tea.NewProgram(initialModel())
go func() {
for activity := range someChannel {
p.Send(activityMsg(activity)) // delivered to Update
}
}()
if _, err := p.Run(); err != nil { /* ... */ }
Print persistent lines above the UI
Use tea.Println/tea.Printf (as commands) to emit log-like lines that scroll into history above your live UI. (No effect while the alt screen is active.)
return m, tea.Printf("processed item %d", n)
Add a spinner (Bubbles)
import "charm.land/bubbles/v2/spinner"
type model struct{ spinner spinner.Model }
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
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) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "q" {
return m, tea.Quit
}
default:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m model) View() tea.View {
return tea.NewView(fmt.Sprintf("%s Loading…\n", m.spinner.View()))
}
Add a text input (Bubbles)
import "charm.land/bubbles/v2/textinput"
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "Pikachu"
ti.Focus()
ti.SetWidth(20)
return model{textInput: ti}
}
func (m model) Init() tea.Cmd { return textinput.Blink }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "enter", "ctrl+c", "esc":
return m, tea.Quit
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
Tip
The pattern for every Bubbles component is the same: hold its
Modelin your model, forward messages tocomponent.Update(msg)in yourUpdate, and embedcomponent.View()in yourView. This composition is the heart of building larger Bubble Tea apps.
Composing multiple components
Forward messages to each child and assemble their views. Use Lip Gloss to lay them out:
import "charm.land/lipgloss/v2"
func (m model) View() tea.View {
str := lipgloss.JoinVertical(lipgloss.Top,
m.headerView(),
m.textInput.View(),
m.footerView(),
)
return tea.NewView(str)
}
9. Workflows
Build & ship your TUI
Because your Bubble Tea app is an ordinary Go program, you build and distribute it like any Go binary:
go build -o myapp .
./myapp
- [ ] Write your
model,Init,Update,View - [ ]
go mod tidy - [ ]
go build - [ ] Test in your target terminals (and over SSH if relevant)
- [ ] Distribute the single binary (cross-compile with
GOOS/GOARCHas needed)
Run a TUI over SSH
When serving a TUI to remote users, pass the client's environment so color and terminal detection are correct (the server's os.Getenv would be wrong):
// sess is a github.com/charmbracelet/ssh Session
pty, _, _ := sess.Pty()
environ := append(sess.Environ(), "TERM="+pty.Term)
p := tea.NewProgram(model, tea.WithEnvironment(environ),
tea.WithInput(sess), tea.WithOutput(sess))
Tip
For full SSH-app scaffolding (auth, sessions, a TUI bastion), see Charm's Wish library. Bubble Tea provides
WithEnvironment,WithInput, andWithOutputto plug into it.
Headless / daemon mode
Disable the renderer to use the Elm Architecture for a non-TUI program (e.g. when output is not a TTY):
p := tea.NewProgram(model, tea.WithoutRenderer())
Output and logs then go straight to stdout without redraw logic — handy for "TUI when interactive, plain logs when piped."
10. Debugging
You can't log to stdout
Your TUI owns stdout, so fmt.Println will corrupt the display. Log to a file instead:
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
defer f.Close()
}
Then watch it live in another terminal:
tail -f debug.log
Note
The
DEBUGenvironment variable here is your convention, shown for illustration — Bubble Tea does not require it. Bubble Tea itself readsTEA_TRACEandTEA_DEBUG; see the Admin Guide for those.
Debugging with Delve
Because Bubble Tea takes over stdin/stdout, run Delve headless and connect to it:
# Terminal 1: start the debugger
dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .
API server listening at: 127.0.0.1:43000
# Terminal 2: connect
dlv connect 127.0.0.1:43000
Always pass --listen (otherwise the port changes per run) and --api-version=2.
11. Troubleshooting
Error: bubbletea: InitialModel cannot be nil
Cause: You passed nil (or an unset model) to tea.NewProgram.
Solution: Pass an initialized model value: tea.NewProgram(initialModel()).
Prevention: Construct your model with a helper like initialModel() and pass it directly.
Error: bubbletea: error opening TTY: …
Cause: Bubble Tea needs a controlling terminal for input but stdin isn't a TTY (e.g. input is piped) and no TTY could be opened.
Solution: Run the program in a real terminal, or supply input explicitly with tea.WithInput(...), or disable input with tea.WithInput(nil) if your program doesn't need it.
Prevention: Don't pipe into an interactive TUI; for non-interactive use, run with tea.WithoutRenderer().
Garbage characters printed when the program quits too early
Cause: The program exits before the terminal finishes processing queued sequences (related to known issue #1590).
Solution: Let Run() return normally (use tea.Quit) rather than calling os.Exit from inside a goroutine; allow the graceful shutdown to restore the terminal.
Prevention: Quit via the tea.Quit command from Update, not by killing the process mid-render.
My program shows garbage after a panic
Cause: A panic left the terminal in raw mode.
Solution: Bubble Tea catches panics by default and restores the terminal, printing the stack trace. If you disabled this with tea.WithoutCatchPanics(), re-enable it, or run reset in your shell to recover.
Prevention: Keep panic catching enabled (the default).
Alt screen on macOS Terminal.app adds blank lines instead of a true alternate screen
Cause: Terminal.app's limited alt-screen support (known issue #1455). Solution: Use a more capable terminal (iTerm2, Ghostty, WezTerm, kitty, Alacritty) for full alt-screen behavior. Prevention: Test alt-screen UIs across the terminals your users actually use.
Window resize isn't detected on Windows
Cause: Windows has no SIGWINCH signal, so resize handling differs from Unix (regression tracked in #1601).
Solution: Update to the latest Bubble Tea v2; rely on the WindowSizeMsg you receive and avoid assuming continuous resize events on Windows.
Prevention: Handle WindowSizeMsg whenever it arrives and recompute layout from it; don't hard-code dimensions.
Wide characters / emoji break table or border alignment
Cause: Grapheme width calculation differs across terminals; some emoji (e.g. flags) render at unexpected widths (issues #1646, #1614). Solution: Use the latest version (it negotiates Unicode "grapheme width" mode where supported) and prefer Lip Gloss/Bubbles components that account for display width. Prevention: Test layouts with multi-byte and emoji content; avoid manual column math on raw strings.
Mouse events aren't firing
Cause: No mouse mode is enabled.
Solution: Set v.MouseMode = tea.MouseModeCellMotion (or AllMotion) in View().
Prevention: Remember mouse tracking is opt-in and lives on the View in v2 — there is no WithMouse… option anymore.
Key releases never arrive
Cause: Key-release reporting requires keyboard enhancements, which not all terminals support.
Solution: Request it (v.KeyboardEnhancements.ReportEventTypes = true) and check the KeyboardEnhancementsMsg to confirm support before relying on KeyReleaseMsg.
Prevention: Treat releases as a progressive enhancement, not a guarantee.
12. Frequently Asked Questions
Q: Is Bubble Tea a CLI tool I install and run?
No. It's a Go library. You import charm.land/bubbletea/v2, build your own program, and run that.
Q: Why does View() return a tea.View instead of a string?
That's the v2 design. The View struct carries both your content and the terminal features you want (full screen, mouse, cursor, title, colors), so everything about the UI is declared in one place each render.
Q: How do I enter full-screen mode?
Set v.AltScreen = true on the View you return. There's no WithAltScreen() option in v2.
Q: How do I run something slow without freezing the UI?
Wrap it in a tea.Cmd (a func() tea.Msg) and return it from Update. Bubble Tea runs commands concurrently and delivers the result back as a message.
Q: What's the difference between Batch and Sequence?
Batch runs commands concurrently with no ordering. Sequence runs them one at a time, in order.
Q: How do I get the terminal size?
Handle tea.WindowSizeMsg — it's delivered automatically on startup and on every resize. You can also ask explicitly with tea.RequestWindowSize.
Q: How do I update the model from a goroutine?
Don't mutate the model directly from another goroutine. Instead call p.Send(msg) and update state inside Update. This preserves the single-threaded, predictable update loop.
Q: Where do widgets like lists and text inputs come from?
From Bubbles (charm.land/bubbles/v2), a separate component library. Styling comes from Lip Gloss (charm.land/lipgloss/v2).
Q: My spacebar case " " stopped matching after upgrading. Why?
In v2, KeyPressMsg.String() returns "space" for the spacebar. Change case " ": to case "space":.
Q: How do I quit from outside the program?
Call p.Quit() (graceful) or p.Kill() (immediate, skips the final render and returns ErrProgramKilled).
Q: Does Bubble Tea work over SSH?
Yes. Pass the client's environment with tea.WithEnvironment(...) and wire input/output to the SSH session (often via Charm's Wish).
Q: Can I use Bubble Tea without a TUI?
Yes — tea.WithoutRenderer() runs the architecture without drawing, useful for daemon-like programs or a non-interactive fallback when output isn't a TTY.
Q: I'm on v1. How much work is the upgrade?
Mostly mechanical: change the import path, make View() return tea.View, switch KeyMsg→KeyPressMsg, move feature flags into the View, and rename a few constants. See below.
13. Upgrading from v1
If you're coming from github.com/charmbracelet/bubbletea, the headline changes are:
- Import path →
charm.land/bubbletea/v2(andcharm.land/lipgloss/v2). View() string→View() tea.View(returntea.NewView("…")).tea.KeyMsg(struct) →tea.KeyPressMsgfor presses;tea.KeyMsgis now an interface for both press and release.- Terminal features moved into the View —
WithAltScreen(),EnterAltScreen,WithMouseCellMotion(),SetWindowTitle,HideCursor, etc. are gone. Setv.AltScreen,v.MouseMode,v.WindowTitle,v.Cursorinstead. - Mouse:
MouseMsgis now an interface (call.Mouse()); buttons renamed (MouseButtonLeft→MouseLeft); match specific types (MouseClickMsg, …). - Spacebar
String()is"space", not" ". - Renames:
Sequentially→Sequence;WindowSize()→RequestWindowSize.
Important
The repository ships a complete, table-by-table
UPGRADE_GUIDE_V2.mdwith before/after code and a flat old→new lookup designed for search-and-replace (and for pointing an LLM at). Use it as your migration checklist.
For the full architectural rationale, configuration, and internals of v2, see the Admin Guide.