Jesse A. Green / notebook

Bubble Tea User Guide

Generated June 2026 from charmbracelet/bubbletea at a23da80

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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 bubbletea subcommands. You build your own program against the tea package, 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 model type, and Init, Update, and View methods 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, not github.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

Update must 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 copy m and 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 a tea.View struct, not a string. 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. Update runs 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 to Update.

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 themselves tea.Cmd values). 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 into Update.


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 like vim or less. 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 progress component 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". (Its Code is still the rune ' ' and its Text is still " " — but match on "space" when using String().)

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, MouseMsg is an interface — always call .Mouse() to get the Mouse struct (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 { /* ... */ }

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 Model in your model, forward messages to component.Update(msg) in your Update, and embed component.View() in your View. 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/GOARCH as 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, and WithOutput to 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 DEBUG environment variable here is your convention, shown for illustration — Bubble Tea does not require it. Bubble Tea itself reads TEA_TRACE and TEA_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:

  1. Import path → charm.land/bubbletea/v2 (and charm.land/lipgloss/v2).
  2. View() string → View() tea.View (return tea.NewView("…")).
  3. tea.KeyMsg (struct) → tea.KeyPressMsg for presses; tea.KeyMsg is now an interface for both press and release.
  4. Terminal features moved into the View — WithAltScreen(), EnterAltScreen, WithMouseCellMotion(), SetWindowTitle, HideCursor, etc. are gone. Set v.AltScreen, v.MouseMode, v.WindowTitle, v.Cursor instead.
  5. Mouse: MouseMsg is now an interface (call .Mouse()); buttons renamed (MouseButtonLeft → MouseLeft); match specific types (MouseClickMsg, …).
  6. Spacebar String() is "space", not " ".
  7. Renames: Sequentially → Sequence; WindowSize() → RequestWindowSize.

Important

The repository ships a complete, table-by-table UPGRADE_GUIDE_V2.md with 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.