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.
eventLoopis the only placemodel.Updateis 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
Cmdisfunc() Msg. The loop hands each returned command tohandleCommands, which runs it in a fresh goroutine and feeds its result back viap.Send. This is why slow work never blocks rendering. - Rendering is decoupled.
renderjust stores the latestView; 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) showInit() (Model, Cmd). That is a documentation inconsistency — the authoritativeModelinterface intea.godefinesInit() Cmd. The README and both tutorials useInit() Cmd. ImplementInit() Cmd.
2.3 The Program runtime
tea.NewProgram(model, opts...) builds a *Program. Program.Run() is the blocking entry point that:
- Resolves input (stdin or an opened TTY) and enters raw mode.
- Detects terminal size and color profile; constructs the renderer (or
nilRendererif disabled). - Sends bootstrap messages:
ColorProfileMsg, the initialWindowSizeMsg, andEnvMsg. - Runs
model.Init()and dispatches the resulting command. - Starts the signal handler, resize listener, command processor, and renderer ticker.
- Enters
eventLoopuntil 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/andtutorials/directories are separate Go modules (examples,tutorial) with their owngo.modand areplacedirective 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:
- Translates raw input events (
translateInputEvent) into typedtea.Msgvalues (key, mouse, paste, focus, color, capability, etc.). - Filters the message through the optional
WithFilterfunction (which can rewrite or drop it). - Handles framework-internal messages before the model sees them — including
QuitMsg,InterruptMsg,SuspendMsg,BatchMsg,sequenceMsg,WindowSizeMsg, clipboard/color requests,ClearScreen,Println, andColorProfileMsg. - Calls
model.Update(msg), capturing the returned model and command. - Dispatches the command to the command channel.
- 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
Tickcould 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 capmaxFPS = 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
modifyOtherKeysand 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
Viewagainst 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, andEDITORappear 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 onlyTEA_TRACEandTEA_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
xtermto 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) andRaw(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.Quitcommand (fromUpdate) orp.Quit()(from outside). p.Kill()forces immediate teardown (returnsErrProgramKilled, skips the final render).WithContext(ctx)lets orchestration cancel the program; cancellation surfaces asErrProgramKilled.- Use
p.Wait()to block until shutdown completes when coordinating multiple goroutines.
Caution
Known issue #1689: calling
Kill()immediately afterRun()can race. Prefer letting the program start, thenQuit()/Kill(); or coordinate via a channel/WithContext.
9.4 Inspecting the runtime
- Trace log: set
TEA_TRACE=/path/to/trace.logto capture an internal output trace. - Panic log: set
TEA_DEBUG=1to writebubbletea-panic-*.logon panic. - App log: use
tea.LogToFile("debug.log", "debug")andtail -fit (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+leftwith 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.modreplacedirective breaksgo 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/Viewshape 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
Viewfield reconciled incursed_renderer.go, plus (b) a message type translated ininput.go. Follow the existing diff-and-emit pattern. - Custom renderer: implement the unexported
rendererinterface (render,flush,resize,setColorProfile,insertAbove,onMouse, …) if you need a bespoke backend;nilRendereris 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
contributepage (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.