Open-Source Wikis

/

Bubble Tea

/

Bubble Tea

/

Architecture

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| EventLoop

All 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

  1. tea.NewProgram(model, opts...) allocates a Program, applies ProgramOptions (options.go), and creates the message and error channels.
  2. Program.Run() (in tea.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 (readLoop in tty.go)
    • the render ticker
  3. Model.Init() is called and any returned command is scheduled.
  4. The eventLoop consumes from p.msgs, runs Model.Update, sends the new command into the command channel, and asks the renderer to draw Model.View().
  5. On QuitMsg, InterruptMsg, signal, or context cancel, Program.shutdown runs in the sync.Once shutdownOnce, 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.outputBuf guarded by Program.mu. The render ticker and the renderer are the only writers.
  • handleCommands reads from cmds chan Cmd. Each command runs in its own goroutine because time.Tick and similar long-running commands must not block the loop. The result is sent back as a Msg.
  • BatchMsg is fanned out concurrently in execBatchMsg; sequenceMsg is run sequentially in execSequenceMsg. Both paths recover from panics so a misbehaving command can not destroy the terminal state.
  • Panics in Model.Update are recovered by the deferred recoverFromPanic in Run, which prints a stack trace using \r\n line 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

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Architecture – Bubble Tea wiki | Factory