charmbracelet/bubbletea
Architecture
Bubble Tea is structured around a single event loop that owns terminal I/O, dispatches messages to the user's Model, runs commands concurrently, and asks a renderer to redraw whenever the model produces a new View. This page sketches the shape of those subsystems and the data flow that connects them.
High-level shape
graph TD
User["User Model<br/>Init / Update / View"] -->|tea.Cmd| EventLoop
EventLoop["Program.eventLoop<br/>(tea.go)"] -->|tea.Msg| User
EventLoop --> CommandRunner["handleCommands<br/>goroutine pool"]
CommandRunner -->|Msg| EventLoop
InputReader["uv.TerminalReader<br/>readLoop"] -->|raw events| Translator["translateInputEvent<br/>(input.go)"]
Translator -->|tea.Msg| EventLoop
EventLoop --> Renderer["cursedRenderer<br/>(cursed_renderer.go)"]
Renderer -->|ANSI bytes| TTY["os.Stdout / TTY"]
Signals["handleSignals<br/>SIGINT / SIGTERM"] -->|InterruptMsg / QuitMsg| EventLoop
Resize["handleResize<br/>SIGWINCH (Unix)"] -->|WindowSizeMsg| EventLoopAll of those concurrent producers send into a single chan Msg (p.msgs). The event loop is the only consumer; it is what makes the model deterministic from the user's point of view.
Core building blocks
| Building block | File | What it does |
|---|---|---|
Program struct |
tea.go |
Owns terminal state, channels, renderer, profile, and the run loop |
Model interface |
tea.go |
User-defined Init() Cmd, Update(Msg), View() View |
Msg / Cmd |
tea.go, commands.go |
Msg = uv.Event; Cmd func() Msg |
View struct |
tea.go |
Declarative description of one frame: content, cursor, alt screen, mouse mode, colors, etc. |
cursedRenderer |
cursed_renderer.go |
Cell-buffered renderer that diffs frames and writes minimal ANSI to advance the screen |
nilRenderer |
nil_renderer.go |
No-op renderer used by WithoutRenderer() |
| Input translator | input.go |
Maps ultraviolet.Event values to typed tea messages |
Lifecycle of a program
tea.NewProgram(model, opts...)allocates aProgram, appliesProgramOptions (options.go), and creates the message and error channels.Program.Run()(intea.go) initializes the TTY, opens raw mode, picks a renderer, queries the color profile, and starts five goroutines:- signal handler (
handleSignals) - resize listener (
handleResize) - command runner (
handleCommands) - input read loop (
readLoopintty.go) - the render ticker
- signal handler (
Model.Init()is called and any returned command is scheduled.- The
eventLoopconsumes fromp.msgs, runsModel.Update, sends the new command into the command channel, and asks the renderer to drawModel.View(). - On
QuitMsg,InterruptMsg, signal, or context cancel,Program.shutdownruns in thesync.OnceshutdownOnce, restores terminal state, and closes the renderer.
Rendering pipeline
graph LR
Update -->|tea.View| Render["Program.render"]
Render --> Cursed["cursedRenderer.render"]
Cursed -->|StyledString.Draw| Cellbuf["uv.ScreenBuffer<br/>(double buffer)"]
Cellbuf --> Diff["TerminalRenderer.flush<br/>(diff & cursor moves)"]
Diff -->|ANSI| Output["os.Stdout"]The renderer never sends the full screen on every frame. Instead it tracks dirty lines, uses cursor movement optimizations (hard tabs, backspace, \n mapping — see setOptimizations in cursed_renderer.go), and optionally wraps the frame in DEC mode 2026 synchronized output to avoid tearing. A 60 Hz time.Ticker flushes pending output and queued commands to os.Stdout.
Concurrency model
- All terminal writes go through
Program.outputBufguarded byProgram.mu. The render ticker and the renderer are the only writers. handleCommandsreads fromcmds chan Cmd. Each command runs in its own goroutine becausetime.Tickand similar long-running commands must not block the loop. The result is sent back as aMsg.BatchMsgis fanned out concurrently inexecBatchMsg;sequenceMsgis run sequentially inexecSequenceMsg. Both paths recover from panics so a misbehaving command can not destroy the terminal state.- Panics in
Model.Updateare recovered by the deferredrecoverFromPanicinRun, which prints a stack trace using\r\nline endings (the terminal may still be in raw mode when this runs).
Terminal state ownership
initTerminal (tty.go) puts the input file into raw mode using golang.org/x/term; restoreTerminalState reverses it. When the user calls tea.Exec (exec.go) the runtime releaseTerminal, runs the child process, then RestoreTerminal. The same dance happens on SuspendMsg (Ctrl+Z handling, tea.go + signals_unix.go). ReleaseTerminal and RestoreTerminal are also part of the public API for programs that need to hand stdin/stdout to another process.
Input translation
The uv.TerminalReader in github.com/charmbracelet/ultraviolet scans escape sequences from stdin and emits typed events. Program.translateInputEvent (input.go) wraps each event in the corresponding Bubble Tea Msg type — for example uv.KeyPressEvent becomes KeyPressMsg. The runtime additionally injects synthetic messages: WindowSizeMsg, ColorProfileMsg, EnvMsg, KeyboardEnhancementsMsg, ModeReportMsg, CapabilityMsg, TerminalVersionMsg, and clipboard responses.
Where to go next
- Program runtime — every step of
Runand the event loop, in detail. - Cursed renderer — the diffing algorithm and ANSI optimizations.
- Input handling — keyboard, mouse, paste, focus, and the Kitty protocol.
- Commands —
Cmd,Batch,Sequence,Tick,Every, and friends. - Declarative views — the v2
Viewsurface area.
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.