v1.3.11

TUI Design Choices

Last updated


Why the TUI is built on bubbletea as a single package, and when that should be revisited.
Per pardn chiu: "bubbletea isn't designed to be split into separate modules that reference each other — splitting it would make the lifecycle a mess. I don't have the bandwidth to handle it right now." This module is intentionally kept undivided.

The TUI lives in a single package (internal/runtime/tui) and is not split into subpackages. Every file under internal/runtime/tui/ follows this principle.

Why bubbletea (not tview / tcell)


The previous TUI used rivo/tview. It was replaced because:
  • Inline scrollback: bubbletea's tea.Println writes lines that scroll into the terminal's native buffer above the input box. tview owns the entire screen and can't co-exist with shell scrollback.
  • lipgloss styling primitives: borders, padding, foreground/background composition compose cleanly. tview styles are tag-based and harder to reuse across components.
  • bubbles ecosystem: textarea, spinner, cursor are drop-in components that match the rest of the charm-bracelet style.

The cost is that bubbletea is a Go port of The Elm Architecture — its tea.Model interface is monolithic by design.

Why a single package


tea.Model requires Update(tea.Msg) (tea.Model, tea.Cmd) to be a method on the model type. Methods must live in the same package as the type. This forces:
  • All Update logic in the same package as the model
  • Splitting into subpackages requires a wrapper in a third (root) package, plus exporting every model field so the sub-packages can read/write state
  • Currently unexported types like popupType, agentExec, daemonState would have to become exported, creating an "API" that no one outside internal/runtime/tui will ever consume
  • send() and program atomic.Pointer[tea.Program] either move into a sub-package (root sets via setter API) or stay in root and force handlers to import root, which creates a second cycle

A real Go-style TUI would build per-domain widget packages (each owning its state struct, render method, and event handler) with bubbletea acting only as event loop. That refactor is a 600-800 LOC rewrite split into 4 phases. For the current TUI (about 12.0k non-test LOC across internal/runtime/tui) maintained by one developer, the gain doesn't justify the cost.

When to revisit


Switch to per-domain widget packages when any one of:
  • TUI exceeds ~3k LOC and code review keeps stalling on "where does this belong"
  • Multiple developers regularly touch the TUI and step on each other's state
  • Specific widgets need independent unit tests against frozen state — currently impossible without instantiating the whole Model
中文