Why Go is a top choice for terminal apps, the Charm stack, and the MVU loop
Last updated: 2026-07-04
The hard part of a TUI is concurrency, and in Go that is free — goroutines and channels are the event loop — while go build ships the whole thing as a single cross-compiled binary that runs everywhere.
The hard part of any interactive TUI is concurrency. The keyboard, a ticking clock, a network request, a file watcher, and a resize event all fire asynchronously, and every one must funnel into a single render without corrupting shared state. In most languages that means callback soup, an async runtime, or a hand-rolled select loop.
In Go this is the language's home turf. A goroutine does the blocking work; it sends a value on a channel; a single select loop receives it and updates state. That is the event loop — Bubble Tea's runtime is literally a goroutine reading messages off a channel and calling your Update. You get the hard part for free, and you get it with the race detector (go test -race) to prove you got it right.
You don't build the concurrency machinery a TUI needs — you inherit it. Blocking work parks a goroutine for ~2KB of stack; a channel carries the result back; one Update serializes every state change. No async runtime to bolt on.
go build produces a single statically-linked executable — no runtime, no interpreter, no shared-lib hunt. Cross-compiling is one env var:
GOOS=linux GOARCH=amd64 go build -o mytool GOOS=darwin GOARCH=arm64 go build -o mytool # Apple Silicon GOOS=windows GOARCH=amd64 go build -o mytool.exe
For a CLI tool you want people to install, this is what matters. curl the binary, drop it in $PATH, done — no pip, no node_modules, no glibc version mismatch. It is why most modern "download and run" terminal tools are a Go (or Rust) binary.
A TUI is a visual artifact: you tweak a border, a color, a layout constant, and you want to see it now. Go's sub-second incremental compiles make the terminal your live canvas. Pair with vhs to record the result as a GIF for a README without a screen recorder.
The center of gravity is Charm (charmbracelet). In Feb 2026 they shipped v2 of the three flagship libs — Bubble Tea, Lip Gloss, Bubbles — the first breaking changes in the project's six-year history, with a new ncurses-derived "Cursed" renderer and terminal synchronized-output mode 2026 to kill flicker and tearing.
| Library | Role | Stars |
|---|---|---|
| bubbletea | The framework — MVU / Elm loop (Init→Cmd, Update(msg)→(Model,Cmd), View) | ~43.5k★ |
| lipgloss | Styling — declarative, CSS-ish colors/borders/padding/layout on terminal strings | ~11.5k★ |
| bubbles | Prebuilt components — textinput, textarea, list, table, viewport, spinner, progress, filepicker | ~8.6k★ |
| huh | Interactive forms/prompts — select, multiselect, input, confirm; standalone or inside Bubble Tea | ~7.0k★ |
| wish | Serve Bubble Tea apps over SSH — the plumbing under soft-serve | ~5.3k★ |
| glamour | Markdown → styled terminal output (themes, wrap, syntax highlight); the engine behind glow | ~3.6k★ |
| fang | Batteries for Cobra CLIs — styled help and errors | ~1.9k★ |
| harmonica | Physics-based spring animation for smooth eased motion; terminal-agnostic | ~1.6k★ |
| vhs | Script terminal recordings to GIF/MP4 from a .tape file | ~20.3k★ |
Bubble Tea is immediate-mode — you re-describe the whole screen every frame. For widget- and form-heavy retained UIs (a settings panel with focus traversal, dozens of stateful boxes) the older tcell + tview stack is often a better fit.
| Library | Role | Stars |
|---|---|---|
| gdamore/tcell | Low-level cell/screen library — Go's answer to ncurses: raw terminal, colors, key/mouse, resize | ~5.2k★ |
| rivo/tview | Retained-mode widget toolkit on tcell — Flex/Grid, Form, Table, TreeView, Modal, Pages, focus | ~14.0k★ |
| mum4k/termdash | Dashboard layout — line charts, gauges, sparklines, donuts; reach for it when the app is a metrics wall | ~3.0k★ |
Dynamic, animated, data-streaming app → Bubble Tea (MVU). Static, form-dense, focus-driven app → tview. Live metrics wall → termdash.
The model is MVU (Model-View-Update), borrowed from Elm. Three methods on your state struct:
type Model struct { /* your app state */ }
func (m Model) Init() tea.Cmd // kick off initial async work
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) // handle one event, return new state
func (m Model) View() tea.View // pure render of current state
tea.Cmd — the first async job (or nil).tea.View struct: declarative, you set fields, the runtime handles cursor and mode.tea.Cmd is how async meets the loopA Cmd is just func() tea.Msg — it runs in its own goroutine, does the blocking thing (HTTP, disk, sleep), and returns a message the runtime feeds back into Update. That is the whole async story:
func fetchUser(id int) tea.Cmd {
return func() tea.Msg {
u, err := api.Get(id) // blocks — fine, it's a goroutine
if err != nil {
return errMsg{err}
}
return userMsg{u} // becomes the next Update's msg
}
}
// in Update: case userMsg: m.user = msg.u
No callbacks, no promises. Goroutine in, message out, single-threaded state. This is the pattern the whole ecosystem is built on.
A big TUI is a tree of small MVU models. The parent embeds children, forwards messages down, and collects commands up with tea.Batch — which fans multiple commands out to goroutines concurrently while the loop stays single-threaded:
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "tab" { m.active = m.active.next() }
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg); cmds = append(cmds, cmd)
m.input, cmd = m.input.Update(msg); cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
All state transitions live in one function, so you read the entire behavior of the app by reading Update. Recurring work is tea.Tick (each tick is a message you re-issue); fan-out is tea.Batch (three goroutines, three independent return messages). The loop stays single-threaded; the work is concurrent — the goroutines-are-the-event-loop thesis in one line.
Star counts and last-push dates pulled live from the GitHub REST API, 2026-07-04.
| Tool | What it is | Stack | Stars |
|---|---|---|---|
| lazygit | Git TUI — the flagship Go TUI in the wild | gocui/tcell | ~80.0k★ |
| k9s | Kubernetes cluster TUI | tview | ~34.1k★ |
| glow | Markdown reader in the terminal | Bubble Tea + Glamour | ~26.2k★ |
| crush | Charm's AI coding agent TUI | Bubble Tea | ~26.0k★ |
| gum | Glue TUI widgets into shell scripts | Bubble Tea | ~24.0k★ |
| soft-serve | Self-hostable Git server, browsable over SSH | Bubble Tea + Wish | ~7.1k★ |
k9s: tview scales to a complex, high-frequency, streaming operational tool. lazygit: a Go TUI can become the way a huge audience uses git. gum: MVU components are reusable enough to expose as shell primitives. soft-serve + wish: you can serve a full TUI over SSH — the terminal as a network app surface.
All pushed within the last ~10 weeks; none archived. These are working tools a large slice of developers touch every day, not one-off demos.
Graphical / GPU-accelerated UI is not the terminal's job, and therefore not Go's job here. A cell grid of styled text can't be a pixel canvas: no real image compositing (sixel/kitty-graphics are hacks, not a UI model), no GPU shaders, no sub-cell layout, no rich typography. If the product needs those, you've left the terminal — a different toolchain conversation entirely. See the golang hub #ui section for where Go's desktop/GPU GUI story has real gaps. Inside the terminal, though, Go isn't a compromise — it and Rust are the two stacks people reach for first.