Jesse A. Green / notebook

Bubble Tea Admin & Architecture 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.

This guide is for developers, architects, and maintainers who embed Bubble Tea in a product, operate Bubble Tea–based programs, or need to understand how the framework works internally. It documents Bubble Tea v2 (charm.land/bubbletea/v2) at commit a23da80.

For task-oriented usage, see the companion User Guide. This document goes deeper: the runtime architecture, the event loop, the renderer, configuration, security posture, terminal-compatibility behavior, and how to extend or contribute to the project.


1. Executive Summary

Bubble Tea is a Go framework for terminal user interfaces, built on The Elm Architecture (Model–Update–View). It is a library, not an application: consumers import the tea package and build their own binaries. There is no server, no daemon, no configuration file, and no network surface — Bubble Tea runs in-process inside the host program and talks only to the controlling terminal (stdin/stdout) and OS signals.

Attribute Value
Kind Importable Go library / application framework
Module charm.land/bubbletea/v2
Language Go 1.25.0
License MIT
Architecture The Elm Architecture (MVU), single-goroutine update loop, concurrent commands
Core package One package (tea), 43 source files (~152 KB) at the repo root
Rendering Cell-based "cursed renderer" backed by charmbracelet/ultraviolet
Concurrency model One central event loop; I/O runs in commands on separate goroutines
External I/O Controlling terminal (TTY) + OS signals only — no sockets, no files (except opt-in logging)
Distribution go get; ships as part of the consumer's binary

What's notable in v2: a declarative View struct replaces v1's imperative options/commands for terminal features; a new high-performance cell renderer (ultraviolet); first-class keyboard/mouse with the Kitty protocol; native clipboard (OSC52); and a vanity module path (charm.land/...).


2. System Architecture

2.1 The Elm Architecture

Bubble Tea structures every program around three pure-ish functions on a single state value:

         ┌──────────────────────────────────────────────────────────┐
         │                       Program.Run()                       │
         │                                                           │
 stdin ─►│  TerminalReader (ultraviolet) ─► uv.Event ─► translate ─► │─► Msg
 (TTY)   │                                                           │     │
         │                          ┌────────────────┐              │     ▼
         │                          │   eventLoop()  │◄─────────────┼── p.msgs
         │   model.Update(msg) ◄────┤  (1 goroutine) │              │
         │        │                 └────────────────┘              │
         │        ├─► returns (Model, Cmd)                          │
         │        │                                                 │
         │        ▼                                                 │
         │   handleCommands(): cmd() runs in its OWN goroutine ─────┼─► p.Send(newMsg)
         │        │                                                 │
         │        ▼                                                 │
         │   render(model.View()) ─► renderer.render(View)          │
         │                              │                           │
         │                          ticker @ FPS ─► renderer.flush()│─► stdout (TTY)
         └──────────────────────────────────────────────────────────┘

Key properties:

  • One update goroutine. eventLoop is the only place model.Update is called, so model mutation is serialized and race-free by construction — as long as you don't mutate the model from other goroutines.
  • Commands are the I/O boundary. A Cmd is func() Msg. The loop hands each returned command to handleCommands, which runs it in a fresh goroutine and feeds its result back via p.Send. This is why slow work never blocks rendering.
  • Rendering is decoupled. render just stores the latest View; a separate ticker goroutine flushes it to the terminal at the configured FPS (default 60, capped at 120).

2.2 The Model contract

type Model interface {
	Init() Cmd               // startup command (nil = none)
	Update(Msg) (Model, Cmd) // event → next state + optional command
	View() View              // state → declarative UI description
}

Msg is a type alias for ultraviolet.Event (type Msg = uv.Event), i.e. any value can be a message. Cmd is func() Msg.

Note

A few doc-comment examples inside the source (e.g. in color.go, keyboard.go) show Init() (Model, Cmd). That is a documentation inconsistency — the authoritative Model interface in tea.go defines Init() Cmd. The README and both tutorials use Init() Cmd. Implement Init() Cmd.

2.3 The Program runtime

tea.NewProgram(model, opts...) builds a *Program. Program.Run() is the blocking entry point that:

  1. Resolves input (stdin or an opened TTY) and enters raw mode.
  2. Detects terminal size and color profile; constructs the renderer (or nilRenderer if disabled).
  3. Sends bootstrap messages: ColorProfileMsg, the initial WindowSizeMsg, and EnvMsg.
  4. Runs model.Init() and dispatches the resulting command.
  5. Starts the signal handler, resize listener, command processor, and renderer ticker.
  6. Enters eventLoop until quit/kill/interrupt, then restores the terminal.

Program lifecycle methods:

Method Purpose
Run() (Model, error) Start and block until termination; returns the final model.
Send(msg Msg) Inject a message from any goroutine (no-op after exit).
Quit() Graceful shutdown from outside (sends QuitMsg).
Kill() Immediate shutdown, skips final render; Run returns ErrProgramKilled.
Wait() Block until the program has fully shut down.
Println(...) / Printf(...) Emit persistent lines above the UI.
ReleaseTerminal() / RestoreTerminal() Temporarily yield and reclaim the terminal (used by Exec).

2.4 Component composition

Bubble Tea has no widget system of its own; composition is a convention. A parent model embeds child component models, forwards messages to each child's Update, and stitches their View() strings together (typically with Lip Gloss). This recursion — "models all the way down" — is how large apps (and the entire Bubbles library) are built. See User Guide §8.


3. Source Layout

The library is a single Go package at the repository root. The rest of the tree is learning material and demos.

bubbletea/
├── tea.go              Program, Model, View, Cmd, NewProgram, Run, eventLoop
├── options.go          ProgramOption constructors (With…)
├── commands.go         Batch, Sequence, Tick, Every, RequestWindowSize
├── exec.go             Exec / ExecProcess (run external processes)
├── key.go              Key, KeyPressMsg/KeyReleaseMsg, key constants
├── keyboard.go         KeyboardEnhancements + KeyboardEnhancementsMsg
├── mouse.go            Mouse, MouseClickMsg/Release/Wheel/Motion, buttons
├── screen.go           WindowSizeMsg, ClearScreen, ModeReportMsg
├── cursor.go           Position, Cursor shapes, CursorPositionMsg
├── color.go / profile.go   Color query msgs, ColorProfileMsg
├── clipboard.go        OSC52 clipboard commands/messages
├── paste.go            Bracketed-paste messages
├── focus.go            FocusMsg / BlurMsg
├── environ.go          EnvMsg (environment over SSH)
├── raw.go              Raw escape-sequence command
├── termcap.go / xterm.go   Capability + terminal-version queries
├── logging.go          LogToFile / LogToFileWith
├── cursed_renderer.go  The cell-based renderer (ultraviolet-backed)
├── nil_renderer.go     No-op renderer (WithoutRenderer)
├── renderer.go         renderer interface, Println/Printf, FPS constants
├── tty*.go / termios*.go / signals_*.go   Per-OS terminal & signal plumbing
├── mod.go              KeyMod modifier constants
├── tutorials/          basics (MVU) + commands (I/O) — the canonical learning path
└── examples/           63 runnable demos (recipe gallery; not part of the API)

Note

The examples/ and tutorials/ directories are separate Go modules (examples, tutorial) with their own go.mod and a replace directive pointing at the parent. They are not imported by consumers and are excluded from the published library. They are, however, the best executable reference for real-world patterns.


4. The Event Loop in Detail

eventLoop (in tea.go) is the heart of the runtime. For each message it:

  1. Translates raw input events (translateInputEvent) into typed tea.Msg values (key, mouse, paste, focus, color, capability, etc.).
  2. Filters the message through the optional WithFilter function (which can rewrite or drop it).
  3. Handles framework-internal messages before the model sees them — including QuitMsg, InterruptMsg, SuspendMsg, BatchMsg, sequenceMsg, WindowSizeMsg, clipboard/color requests, ClearScreen, Println, and ColorProfileMsg.
  4. Calls model.Update(msg), capturing the returned model and command.
  5. Dispatches the command to the command channel.
  6. Renders the new View.

4.1 Concurrency & ordering

Mechanism Behavior
Batch(cmds…) Commands run concurrently in separate goroutines; results arrive in any order.
Sequence(cmds…) Commands run serially, in order; each completes before the next starts. Nested Batch/Sequence are handled recursively.
Plain returned Cmd Runs in its own goroutine via handleCommands; its Msg is Send-ed back to the loop.
p.Send(msg) Thread-safe injection from any goroutine; blocks until the loop accepts it (or no-ops after shutdown).

Important

Command goroutines are intentionally not awaited at shutdown — a long Tick could otherwise stall exit. They are allowed to leak until their function returns. Design commands to be cancellation-tolerant (e.g. honor a context you close).

4.2 Panic handling

By default Bubble Tea recovers from panics in the main loop and in command goroutines, restores the terminal, prints the stack trace (with \r\n line endings so it's readable in raw mode), and returns ErrProgramPanic wrapped in ErrProgramKilled. WithoutCatchPanics() disables this — which leaves the terminal in raw mode on panic, so use it only for debugging.

If TEA_DEBUG is truthy, the panic and stack are also written to bubbletea-panic-<unixtime>.log.


5. The Renderer

5.1 Cursed renderer (default)

cursed_renderer.go implements the production renderer. It is cell-based: it maintains a screen buffer (ultraviolet.ScreenBuffer), draws the View's styled content into it, and diffs against the last frame to emit the minimal set of escape sequences. Highlights:

  • Frame-rate–driven flush. A ticker flushes at the configured FPS (default defaultFPS = 60, hard cap maxFPS = 120). Frames with no changes are skipped (viewEquals).
  • Inline vs. alt screen. Inline mode renders relative to the cursor and grows/shrinks with content height; alt-screen mode uses a fixed full-window buffer and forces full redraws on resize.
  • Synchronized output (mode 2026). On terminals known to support it, updates are wrapped in synchronized-output sequences to render atomically (no tearing). Apple Terminal and unreliable SSH sessions are excluded (shouldQuerySynchronizedOutput).
  • Unicode core (mode 2027). When available, grapheme-width mode is enabled for correct wide-character/emoji widths.
  • Cursor-movement optimizations. Hard tabs and backspace are used to shorten cursor moves where termios indicates support; scroll optimization is disabled on Windows due to terminal bugs.
  • Keyboard protocol negotiation. Emits modifyOtherKeys and Kitty keyboard sequences; resets them when switching between main and alt screens (which have separate keyboard registries).
  • Native progress bar, window title, FG/BG/cursor colors, bracketed paste, focus reporting, mouse modes are all reconciled by diffing the new View against the last one and emitting only the deltas.

5.2 Nil renderer

WithoutRenderer() installs nilRenderer, a no-op that discards all drawing. Output and logs then go straight to the writer with no redraw logic — for daemon-style programs or a non-interactive fallback when output isn't a TTY.


6. Configuration Reference

Bubble Tea has no config files. All configuration is via ProgramOption values passed to NewProgram, plus two environment variables read by the library.

6.1 Program options

Option Effect Default
WithContext(ctx) Cancel the program from outside; cancellation yields ErrProgramKilled. context.Background()
WithOutput(w io.Writer) Where to render. os.Stdout
WithInput(r io.Reader) Where to read input. Pass nil to disable input entirely. os.Stdin (opens TTY if stdin isn't one)
WithEnvironment(env []string) Environment used for terminal/color detection (essential over SSH). os.Environ()
WithColorProfile(p) Force a color profile instead of auto-detecting. auto-detected
WithWindowSize(w, h) Set initial size (great for testing/non-interactive runs). queried from the TTY
WithFPS(n) Max render frame rate. <1 → 60; >120 → 120. 60
WithFilter(func(Model, Msg) Msg) Intercept/rewrite/drop messages before the model sees them. none
WithoutSignalHandler() Don't install the SIGINT/SIGTERM handler. handler installed
WithoutSignals() Ignore OS signals entirely (mainly for tests). signals honored
WithoutCatchPanics() Disable panic recovery (leaves terminal raw on panic). panics caught
WithoutRenderer() Disable rendering (use the architecture headless). renderer on

6.2 Environment variables (read by the library)

Variable Read at Effect
TEA_TRACE NewProgram If set to a path, Bubble Tea opens it and writes an internal debug trace log there.
TEA_DEBUG panic recovery If truthy, panic details + stack are written to bubbletea-panic-<unixtime>.log.

Caution

Negative knowledge: TEA_LOGFILE, HELP_DEBUG, BUBBLETEA_LOG, and EDITOR appear in the codebase, but they are read by the example programs, not by the Bubble Tea library. Do not treat them as framework configuration. The library reads only TEA_TRACE and TEA_DEBUG.

6.3 Removed in v2 (negative knowledge)

These v1 APIs no longer exist. They are listed so you don't reach for them or document them as available:

  • Options: WithAltScreen, WithMouseCellMotion, WithMouseAllMotion, WithReportFocus, WithoutBracketedPaste, WithInputTTY, WithANSICompressor.
  • Commands: EnterAltScreen/ExitAltScreen, EnableMouseCellMotion/EnableMouseAllMotion/DisableMouse, HideCursor/ShowCursor, EnableBracketedPaste/DisableBracketedPaste, EnableReportFocus/DisableReportFocus, SetWindowTitle.
  • Methods: p.Start(), p.StartReturningModel(), p.EnterAltScreen()/ExitAltScreen(), p.Enable/DisableMouse…(), p.SetWindowTitle().
  • Renames: Sequentially → Sequence; WindowSize() (Cmd) → RequestWindowSize (Msg).

All terminal-feature toggles moved onto the View struct (AltScreen, MouseMode, WindowTitle, Cursor, ReportFocus, DisableBracketedPasteMode, KeyboardEnhancements).


7. Terminal Compatibility & Platform Behavior

Bubble Tea negotiates capabilities at runtime and degrades gracefully.

Concern Behavior
Raw mode Entered via x/term; restored on exit, suspend, and Exec.
Windows Uses the Console VT API (ENABLE_VIRTUAL_TERMINAL_INPUT/PROCESSING). No SIGWINCH, so continuous resize events aren't available (resize handled differently from Unix).
Unix/BSD Resize via SIGWINCH; cursor-movement optimizations from termios flags.
Color Auto-detected (colorprofile.Detect); downsampled to the terminal's capability. Query/upgrade via RequestCapability("RGB"/"Tc").
Synchronized output (2026) Enabled on capable terminals; excluded on Apple Terminal.app and unreliable SSH sessions.
Apple Terminal.app Limited: alt screen and capability queries are quirky; treated conservatively in shouldQuerySynchronizedOutput.
Kitty keyboard protocol Used for enhanced key reporting (release/repeat, shifted/base codes); ignored by terminals that don't support it.
Suspend (Ctrl+Z) Supported on Unix (SIGTSTP); not supported on Windows (suspendSupported = false).

Tip

Recommend modern terminals (Ghostty, kitty, WezTerm, Alacritty, iTerm2, Windows Terminal) to users who need alt screen, true color, synchronized output, and key-release events. Test the same UI in Terminal.app and a basic xterm to confirm graceful degradation.


8. Security Analysis

Bubble Tea has a deliberately small attack surface — it is an in-process library with no network, no persistence, and no privileged operations. Still, integrators should be aware of:

Area Assessment
Network None. The library opens no sockets. (Your commands may; that's your code, e.g. the HTTP example in the commands tutorial.)
Filesystem Minimal and opt-in: LogToFile/LogToFileWith open a log file with mode 0o600; panic logs (TEA_DEBUG) and trace logs (TEA_TRACE) write to disk. Nothing else is read or written.
External processes Exec/ExecProcess run arbitrary commands you specify, with the terminal handed over. Validate any user-influenced command/path before passing it to exec.Command (the examples disable the gosec lint with //nolint:gosec precisely because the command is developer-controlled).
Clipboard OSC52 read/write (SetClipboard, ReadClipboard, primary selection). Reading the clipboard exposes whatever the user has copied; treat that content as untrusted input. OSC52 isn't supported in all terminals.
Raw escape sequences Raw(...) writes sequences directly to the terminal. Never build these from untrusted input — a hostile sequence could manipulate the user's terminal.
Environment over SSH WithEnvironment lets you pass a remote client's env. Treat TERM and friends as untrusted; the library already handles unknown values defensively.
Raw mode & signal handling The library installs SIGINT/SIGTERM handling and restores terminal state on exit/panic. If you disable panic catching (WithoutCatchPanics) and panic, the terminal is left in raw mode — a usability/DoS-of-session risk, not a privilege issue.
Secrets The framework stores none. Anything sensitive in your model stays in your process memory.

Warning

The highest-risk integration points are Exec/ExecProcess (command injection if you interpolate untrusted input) and Raw (terminal-sequence injection). Both require you to supply the dangerous value, so the mitigation is input validation in your application code.


9. Operations

9.1 Building and shipping

A Bubble Tea program is a normal Go binary. Cross-compile with GOOS/GOARCH; ship the single artifact.

GOOS=linux  GOARCH=amd64 go build -o myapp-linux  .
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin .
GOOS=windows GOARCH=amd64 go build -o myapp.exe   .

The repo itself releases via GoReleaser (.goreleaser.yml includes the shared Charm config) and runs lint/test through a Taskfile:

task lint   # golangci-lint run
task test   # go test -race -count 4 -cpu 1,4 ./...

9.2 Running over SSH

Serve TUIs remotely by wiring input/output to the SSH session and passing the client environment:

pty, _, _ := sess.Pty()
environ := append(sess.Environ(), "TERM="+pty.Term)
p := tea.NewProgram(model,
	tea.WithEnvironment(environ),
	tea.WithInput(sess),
	tea.WithOutput(sess),
)

Pair with Wish for the SSH server, auth, and session plumbing.

9.3 Graceful shutdown & external control

  • Exit cleanly with the tea.Quit command (from Update) or p.Quit() (from outside).
  • p.Kill() forces immediate teardown (returns ErrProgramKilled, skips the final render).
  • WithContext(ctx) lets orchestration cancel the program; cancellation surfaces as ErrProgramKilled.
  • Use p.Wait() to block until shutdown completes when coordinating multiple goroutines.

Caution

Known issue #1689: calling Kill() immediately after Run() can race. Prefer letting the program start, then Quit()/Kill(); or coordinate via a channel/WithContext.

9.4 Inspecting the runtime

  • Trace log: set TEA_TRACE=/path/to/trace.log to capture an internal output trace.
  • Panic log: set TEA_DEBUG=1 to write bubbletea-panic-*.log on panic.
  • App log: use tea.LogToFile("debug.log", "debug") and tail -f it (you can't log to stdout — the TUI owns it).

10. Error & Message Reference

10.1 Sentinel errors from Run()

Error Meaning
ErrProgramPanic The program recovered from a panic (wrapped inside ErrProgramKilled).
ErrProgramKilled The program was killed (Kill(), context cancel, or internal teardown).
ErrInterrupted The program received SIGINT or an InterruptMsg.
bubbletea: InitialModel cannot be nil NewProgram was given a nil model.

Other wrapped errors surface from terminal setup/teardown (opening the TTY, entering raw mode, restoring console state, getting terminal size) and from renderer flush/close paths — all prefixed bubbletea: or describing the failing terminal operation.

10.2 Framework messages (selected)

Message Notes
QuitMsg / SuspendMsg / ResumeMsg / InterruptMsg Lifecycle control (send via Quit/Suspend/Interrupt).
WindowSizeMsg{Width, Height} Auto-sent at startup and on resize.
KeyPressMsg / KeyReleaseMsg (+ KeyMsg interface) Keyboard events.
MouseClickMsg / MouseReleaseMsg / MouseWheelMsg / MouseMotionMsg (+ MouseMsg interface) Mouse events.
PasteMsg / PasteStartMsg / PasteEndMsg Bracketed paste.
FocusMsg / BlurMsg Focus reporting.
ColorProfileMsg, ForegroundColorMsg, BackgroundColorMsg, CursorColorMsg, CursorPositionMsg Color/cursor introspection (IsDark() on color messages).
EnvMsg Program environment (SSH-aware).
KeyboardEnhancementsMsg Terminal keyboard-enhancement support.
ModeReportMsg, CapabilityMsg, TerminalVersionMsg, ClipboardMsg, RawMsg Terminal capability/mode introspection and raw I/O.

11. Code Quality & Conventions

Aspect Observation
Linting Strict golangci-lint config (.golangci.yml): gosec, errcheck-style wrapcheck, revive, prealloc, nestif, exhaustive, gofumpt, goimports, and more. Generated/false-positive exclusions are scoped narrowly.
Testing Race-enabled, multi-CPU test runs (go test -race -count 4 -cpu 1,4). Golden-file tests (x/exp/golden) assert exact terminal output for view/screen behavior (testdata/*.golden). Several tests explicitly reproduce fixed bug numbers (e.g. mouse-vs-flush race #1690).
Error wrapping Consistent %w wrapping with a bubbletea: prefix on terminal-facing errors.
Build tags Clean per-OS separation (tty_unix.go/tty_windows.go, termios_*.go, signals_*.go) keeps platform code isolated.
Concurrency discipline The renderer guards shared state with a mutex; the program serializes model updates through a single loop. Command goroutines are deliberately fire-and-forget.
Docs Extensive package and symbol doc comments with runnable examples (a minor inconsistency around Init's example signature, noted in §2.2).

12. Potential Improvements & Known Issues

From the issue tracker and source review at a23da80:

  • Kill()-after-Run() data race (#1689, open). Tighten the startup/teardown handshake so an immediate kill is safe.
  • Textarea infinite loop on alt+left with empty input (#1652, open). A component-level edge case (Bubbles) exposed through Bubble Tea input.
  • Emoji/flag width breaks tables (#1646, open) & hard-tab cursor optimization misaligns columns (#1614, closed). Display-width correctness across terminals remains the thorniest area; the Unicode-core (2027) negotiation mitigates but doesn't fully solve it.
  • Renderer mangles output on some terminals (#1564, open) / sneaky rendering (#1671, closed). Long tail of terminal-specific rendering quirks; the cell renderer + synchronized output reduces but doesn't eliminate them.
  • Garbage chars on too-early quit (#1590, open). Ensure all queued sequences flush before exit when the program quits very quickly.
  • examples/go.mod replace directive breaks go run …/examples/...@latest (#1681, open). Packaging nit for the examples module.
  • Proposed tooling (#1655 devtools inspector, #1654 testing framework). Community interest in a Bubble Tea inspector and an official testing harness — promising directions for operability.

Note

None of these are security defects; they are rendering-correctness and lifecycle edge cases inherent to driving heterogeneous terminals. Track the open issues for your target terminals before relying on bleeding-edge features (key release, synchronized output, native progress bars).


13. Extending & Contributing

  • Build a component: implement the same Init/Update/View shape as a sub-model and expose it for embedding — the Bubbles library is the reference implementation pattern.
  • Add terminal features: they generally surface as (a) a new View field reconciled in cursed_renderer.go, plus (b) a message type translated in input.go. Follow the existing diff-and-emit pattern.
  • Custom renderer: implement the unexported renderer interface (render, flush, resize, setColorProfile, insertAbove, onMouse, …) if you need a bespoke backend; nilRenderer is the minimal template.
  • Local development: use the Taskfile (task lint, task test). Tests are race-enabled with golden files — regenerate goldens deliberately, never blindly.
  • Contribution entry point: the repo's contribute page (linked from the README). Match the strict lint config and add golden/race tests for rendering changes.

The Admin-level takeaway: Bubble Tea is small, composable, and terminal-faithful. Its complexity lives almost entirely in the renderer and per-terminal negotiation; the application-facing API is a tight, well-tested MVU core that you extend by composition, not configuration.