← back

Go for Terminal UIs

Why Go is a top choice for terminal apps, the Charm stack, and the MVU loop
Last updated: 2026-07-04

One sentence

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.

Why Go fits the terminal

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.

Goroutines + channels ARE the event loop

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.

One static binary, trivial cross-compile

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.

Fast compile = tight UI iteration

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 stack

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.

LibraryRoleStars
bubbleteaThe framework — MVU / Elm loop (InitCmd, Update(msg)(Model,Cmd), View)~43.5k★
lipglossStyling — declarative, CSS-ish colors/borders/padding/layout on terminal strings~11.5k★
bubblesPrebuilt components — textinput, textarea, list, table, viewport, spinner, progress, filepicker~8.6k★
huhInteractive forms/prompts — select, multiselect, input, confirm; standalone or inside Bubble Tea~7.0k★
wishServe Bubble Tea apps over SSH — the plumbing under soft-serve~5.3k★
glamourMarkdown → styled terminal output (themes, wrap, syntax highlight); the engine behind glow~3.6k★
fangBatteries for Cobra CLIs — styled help and errors~1.9k★
harmonicaPhysics-based spring animation for smooth eased motion; terminal-agnostic~1.6k★
vhsScript terminal recordings to GIF/MP4 from a .tape file~20.3k★

The retained-mode alternative: tcell + tview

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.

LibraryRoleStars
gdamore/tcellLow-level cell/screen library — Go's answer to ncurses: raw terminal, colors, key/mouse, resize~5.2k★
rivo/tviewRetained-mode widget toolkit on tcell — Flex/Grid, Form, Table, TreeView, Modal, Pages, focus~14.0k★
mum4k/termdashDashboard layout — line charts, gauges, sparklines, donuts; reach for it when the app is a metrics wall~3.0k★
Rule of thumb

Dynamic, animated, data-streaming app → Bubble Tea (MVU). Static, form-dense, focus-driven app → tview. Live metrics wall → termdash.

The core pattern: MVU

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 is how async meets the loop

A 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.

Composing sub-models

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...)
}
The discipline

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.

Where it wins — proof in the wild

Star counts and last-push dates pulled live from the GitHub REST API, 2026-07-04.

ToolWhat it isStackStars
lazygitGit TUI — the flagship Go TUI in the wildgocui/tcell~80.0k★
k9sKubernetes cluster TUItview~34.1k★
glowMarkdown reader in the terminalBubble Tea + Glamour~26.2k★
crushCharm's AI coding agent TUIBubble Tea~26.0k★
gumGlue TUI widgets into shell scriptsBubble Tea~24.0k★
soft-serveSelf-hostable Git server, browsable over SSHBubble Tea + Wish~7.1k★
What these prove

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.

The health signal

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.

The one honest boundary

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.