Open-Source Wikis

/

Bubble Tea

/

Systems

/

Program runtime

charmbracelet/bubbletea

Program runtime

Active contributors: aymanbagabas, meowgorithm, muesli

Purpose

The runtime is the heart of Bubble Tea. It owns terminal state, drives the Elm-style event loop, runs commands concurrently, recovers from panics, and shuts everything down cleanly on quit, signal, or context cancellation. Almost every line of this code lives in one file: tea.go.

Directory layout

tea.go              ← Program, Run, eventLoop, signals, resize, commands, panic recovery
options.go          ← ProgramOptions
commands.go         ← Cmd helpers (Quit/Tick/Every/Batch/Sequence)
tty.go              ← initInputReader, initInput, restoreInput, OpenTTY, checkResize
signals_unix.go     ← suspendProcess (SIGTSTP) on Unix
signals_windows.go  ← suspendProcess no-op on Windows
termios_*.go        ← per-OS termios constants

Key abstractions

Type / func File Description
Program (struct) tea.go Runtime instance. Holds channels, renderer, profile, TTYs, contexts, options.
NewProgram tea.go Constructor; applies all ProgramOptions.
Program.Run tea.go Blocking entry point. Initializes the TTY and runs the event loop.
Program.eventLoop tea.go Selects on ctx.Done, errs, and msgs.
Program.handleSignals tea.go Goroutine that listens for SIGINT/SIGTERM and pushes InterruptMsg/QuitMsg.
Program.handleResize tea.go Goroutine that fires WindowSizeMsg on terminal resize.
Program.handleCommands tea.go Goroutine pool that executes Cmds and forwards their Msgs.
Program.execBatchMsg / execSequenceMsg tea.go Concurrent vs. sequential dispatch of multiple commands.
Program.recoverFromPanic / recoverFromGoPanic tea.go Panic interceptors that restore the TTY before printing a stack trace.
Program.shutdown tea.go sync.Once-guarded teardown sequence.
Program.ReleaseTerminal / RestoreTerminal tea.go, tty.go Public API for handing the TTY to a child process.
channelHandlers tea.go Internal helper that waits for all spawned goroutines on shutdown.

How it works

sequenceDiagram
    participant U as User code
    participant P as Program
    participant L as eventLoop
    participant R as Renderer
    participant I as Input reader
    U->>P: NewProgram(model, opts...)
    U->>P: Run()
    P->>P: initTerminal (raw mode)
    P->>P: pick renderer + detect color profile
    P->>I: initInputReader
    P->>P: handleSignals / handleResize / handleCommands
    P->>L: model = initialModel; loop
    L-->>R: render(model.View())
    I-->>L: KeyPressMsg, MouseClickMsg, …
    L->>U: model.Update(msg)
    U-->>L: model', cmd
    L->>P: cmds <- cmd
    P->>L: msg = cmd()
    L-->>R: render(model'.View())
    Note over L,R: Loop continues until QuitMsg / ctx.Done()
    P->>P: shutdown → restoreTerminalState
    P-->>U: returnModel, returnErr

Startup details (in Run)

  1. Validate that the model is non-nil; otherwise return early.
  2. Initialize handlers (a fresh channelHandlers per run) and the cmds chan Cmd.
  3. Defer close(p.finished) so Wait returns and cancel() so the internal context unblocks.
  4. Open input. If disableInput is set, p.input = nil. Otherwise default to stdin or open the controlling TTY (OpenTTY in tty.go) when stdin is not itself a terminal.
  5. Install signal handler if disableSignalHandler is false.
  6. Defer panic recovery if disableCatchPanics is false.
  7. Set up TTYs and raw mode (initTerminalinitInput).
  8. Detect initial window size with term.GetSize, build a WindowSizeMsg.
  9. Choose a renderer: nilRenderer if disableRenderer, otherwise newCursedRenderer. Apply optimizations (hardTabs, backspace, mapNl).
  10. Detect the color profile (colorprofile.Detect) and send a ColorProfileMsg.
  11. Send the initial WindowSizeMsg and an EnvMsg snapshot of the environment.
  12. Init the input reader and render the initial frame from model.View().
  13. Optionally query synchronized output (DEC mode 2026) and unicode core (mode 2027) when shouldQuerySynchronizedOutput returns true.
  14. Run the event loop until shutdown.

shouldQuerySynchronizedOutput makes a careful set of decisions: it skips SSH sessions unless the terminal is known-good (Windows Terminal, ghostty, wezterm, alacritty, kitty, rio).

The event loop

eventLoop selects on three channels:

Channel Action
<-p.ctx.Done() Return without an error (graceful cancel).
<-p.errs Return with the error.
<-p.msgs Translate, filter, switch on type, forward to model.Update, schedule the returned Cmd, render the new view.

Special internal messages handled before delegating to model.Update:

Internal Msg Effect
QuitMsg Return from the loop.
InterruptMsg Return with ErrInterrupted.
SuspendMsg Call suspend() (TTY release → SIGTSTP → restore + ResumeMsg).
CapabilityMsg Upgrade the color profile to TrueColor if the terminal advertised RGB.
ModeReportMsg Enable synchronized output / unicode core in the renderer when the terminal confirms.
MouseMsg Forward to renderer.onMouse for any view-level handling, then to the model.
Clipboard / color / cursor / capability / version requests Translated to ANSI escape writes via Program.execute.
BatchMsg / sequenceMsg Dispatched to execBatchMsg / execSequenceMsg goroutines.
WindowSizeMsg Forwarded to renderer.resize.
printLineMessage Forwarded to renderer.insertAbove.
clearScreenMsg Forwarded to renderer.clearScreen.
ColorProfileMsg Forwarded to renderer.setColorProfile.

The filter set by WithFilter runs on every message and can replace it or drop it (return nil).

Concurrency invariants

  • p.msgs is the only path messages take to Update. If something can't be a Msg, it isn't dispatched.
  • Goroutines registered with p.handlers.add(ch) close their channel on exit; handlers.shutdown waits for all of them.
  • Program.Send(msg) is safe to call from any goroutine; it selects on <-ctx.Done() to avoid blocking after teardown.
  • Panic recovery never tries to send on p.errs if a previous error is already buffered; the channel is buffered at length 1.

Suspend / resume

SuspendMsg triggers Program.suspend() (tty.go):

  1. releaseTerminal(true) — flushes output, restores tty, cancels the input reader.
  2. suspendProcess() — Unix sends SIGTSTP to its own group via signals_unix.go. On Windows the function is a stub.
  3. After SIGCONT, RestoreTerminal() puts the TTY back into raw mode, restarts the renderer, and reinitializes input.
  4. A ResumeMsg is sent so the model can refresh anything time-sensitive.

Exec

tea.Exec (see Exec) uses the same releaseTerminal/RestoreTerminal dance so a child process can take over the TTY.

Integration points

  • Renderer: the runtime calls renderer.start, render, flush, close, resize, setColorProfile, clearScreen, insertAbove, setSyncdUpdates, setWidthMethod, and onMouse.
  • Input: tty.go owns cancelReader, inputScanner, and readLoopDone. The runtime defers to readLoop for streaming events into p.msgs.
  • Commands: cmds chan Cmd couples eventLoop (writer) to handleCommands (reader). Commands run on their own goroutines.
  • Signals: handleSignals is the only path SIGINT/SIGTERM take into the model.

Entry points for modification

  • Adding a new internal message type that the runtime reacts to: extend the type switch in eventLoop and add the producer in the relevant subsystem (clipboard, screen, etc.).
  • Hooking a new lifecycle event (e.g. before render / after update): the cleanest insertion point is around the model, cmd = model.Update(msg) call in eventLoop. Resist the urge to add ad-hoc callbacks — prefer messages.
  • Tuning shutdown ordering: Program.shutdown is sync.Once-guarded; changes there must preserve "renderer stopped before TTY restored".

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

Program runtime – Bubble Tea wiki | Factory