The Terminal Renaissance: Building Production TUIs with Bubble Tea, Ratatui, and Textual

Terminal user interfaces are having a moment. Not the ncurses dialogs of the 1990s or the static bash menus of early Linux distros — a genuine renaissance driven by three frameworks that bring modern software architecture to the terminal. Whether you’re building a deployment dashboard, an interactive CLI wizard, or a system monitor, the tooling available today makes text-based UIs viable for production tooling in ways they simply weren’t five years ago.

Three frameworks dominate this space: Bubble Tea (Go), Ratatui (Rust), and Textual (Python). Each takes a fundamentally different approach to the same problem: how do you render interactive, stateful UIs in a medium designed for sequential line output?

Bubble Tea: The Elm Architecture in Go

Bubble Tea, part of the Charm ecosystem, brings The Elm Architecture to Go. If you’ve worked with Elm or Redux, the pattern will feel familiar: a single immutable model, an update function that produces a new model from messages, and a view function that renders the model as a string. The framework handles the event loop, terminal initialization, and rendering pipeline.

With over 44,000 GitHub stars and active development (v2.0.8 shipped in July 2026), Bubble Tea has the largest ecosystem of the three. The broader Charm stack includes Lip Gloss for styling (think CSS for the terminal), Bubbles for pre-built components (lists, text inputs, spinners, progress bars), and Glamour for markdown rendering. Notable tools built on Bubble Tea include Glow (markdown viewer), Soft Serve (self-hosted Git server), and Crush, Charm’s terminal-based AI coding agent.

package main

import (
    "fmt"
    "os"
    "tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

func initialModel() model {
    return model{
        choices:  []string{"Deploy to staging", "Deploy to production", "Rollback"},
        selected: make(map[int]struct{}),
    }
}

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "up", "k":
            if m.cursor > 0 {
                m.cursor--
            }
        case "down", "j":
            if m.cursor < len(m.choices)-1 {
                m.cursor++
            }
        case "enter", " ":
            _, ok := m.selected[m.cursor]
            if ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "Select deployment action:\n\n"
    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }
        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }
        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }
    return s + "\nPress q to quit.\n"
}

func main() {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        fmt.Println("Error running program:", err)
        os.Exit(1)
    }
}

The Update-View-Model cycle makes state changes predictable and testable. Async operations — fetching data from an API, running a build — are handled through tea.Cmd objects that return messages back to the update loop. You never mutate state directly from a goroutine; instead, the command produces a message, and the Update function handles it safely.

Ratatui: Immediate Mode in Rust

Ratatui takes a fundamentally different approach. Instead of an opinionated architecture, it provides an immediate-mode rendering library: you describe the entire UI every frame, and Ratatui draws it. There’s no built-in event loop, no prescribed state management pattern. You bring your own runtime (typically tokio or standard threads) and wire up input handling yourself.

With 22,000+ stars and used by over 2,100 crates, Ratatui has found a niche in performance-critical system tools. Netflix uses it for bpftop, their eBPF monitoring tool. AWS ships the Amazon Q developer CLI on top of it. The v0.30 release in December 2025 was described as their biggest ever, adding no_std support for embedded targets, a modular workspace, and the ratatui::run() convenience API.

use crossterm::event::{self, Event, KeyCode};
use ratatui::{prelude::*, widgets::*};
use std::time::Duration;

fn main() -> Result<(), Box> {
    let mut terminal = ratatui::init();
    let mut selected: usize = 0;
    let actions = vec!["Deploy to staging", "Deploy to production", "Rollback"];

    loop {
        terminal.draw(|frame| {
            let area = frame.area();
            let items: Vec = actions.iter()
                .enumerate()
                .map(|(i, &action)| {
                    let style = if i == selected {
                        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
                    } else {
                        Style::default()
                    };
                    ListItem::new(action).style(style)
                })
                .collect();
            let list = List::new(items)
                .block(Block::default().borders(Borders::ALL)
                    .title("Deployment Actions"));
            frame.render_widget(list, area);
        })?;

        if event::poll(Duration::from_millis(250))? {
            if let Event::Key(key) = event::read()? {
                match key.code {
                    KeyCode::Char('q') => break,
                    KeyCode::Down | KeyCode::Char('j') => {
                        if selected < actions.len() - 1 { selected += 1; }
                    }
                    KeyCode::Up | KeyCode::Char('k') => {
                        if selected > 0 { selected -= 1; }
                    }
                    KeyCode::Enter => {
                        println!("Selected: {}", actions[selected]);
                        break;
                    }
                    _ => {}
                }
            }
        }
    }
    ratatui::restore();
    Ok(())
}

The trade-off is clear: Ratatui gives you full control over rendering, input, and state, but you’re responsible for wiring everything together. The benefit is performance and flexibility. For monitoring tools that refresh at high frequencies or TUIs that need to run on resource-constrained systems, the immediate-mode approach with zero allocations per frame is hard to beat.

Textual: Web Development Patterns in Python

Textual takes the third approach: bring web development patterns to the terminal. Built by the Textualize team with 37,000+ stars, it uses CSS-like styling, a widget tree (complete with DOM-like hierarchy), and message passing between widgets. If you’ve built a React or Vue application, Textual’s component model will feel natural.

What sets Textual apart is its CSS engine. You define styles in separate CSS files or inline, apply them via class names, and the framework handles layout, colors, borders, and even reactive updates. It also ships with a web serve mode — the same app can run in a browser via WebAssembly, making it the only framework here that gives you a terminal and web UI from a single codebase.

from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Static
from textual.containers import Container

CSS = """
Screen {
    align: center middle;
}

#actions {
    layout: vertical;
    width: 40;
    height: auto;
    padding: 1 2;
    border: thick $accent;
    background: $surface;
}

Button {
    margin: 1 0;
}

.status {
    margin-top: 1;
    text-align: center;
    color: $text-muted;
}
"""

class DeployApp(App):
    CSS = CSS
    BINDINGS = [("q", "quit", "Quit")]

    def compose(self) -> ComposeResult:
        yield Header()
        with Container(id="actions"):
            yield Button("Deploy to staging", id="staging")
            yield Button("Deploy to production", id="production")
            yield Button("Rollback", id="rollback")
            yield Static("", id="status")
        yield Footer()

    @on(Button.Pressed)
    def handle_button(self, event: Button.Pressed) -> None:
        status = self.query_one("#status", Static)
        action = event.button.label
        status.update(f"Triggered: {action}")


if __name__ == "__main__":
    app = DeployApp()
    app.run()

Choosing Between Them

The right choice depends on your constraints. If you’re already in Go and want the fastest path to a polished TUI with a large ecosystem of pre-built components, Bubble Tea is the clear winner. The Elm Architecture keeps state predictable, and the Charm stack means you rarely need to build styling or input handling from scratch.

If you’re in Rust or building performance-critical system tooling, Ratatui is the better fit. The immediate-mode model gives you frame-by-frame control, and the no_std support means you can even run TUIs on embedded devices. The cost is more boilerplate — you’re managing the event loop, input polling, and state transitions yourself.

If you want to prototype quickly in Python or need browser and terminal from the same codebase, Textual is unmatched. The CSS styling system and widget model lower the barrier to entry significantly. The trade-off is Python’s runtime overhead, which matters for high-frequency refresh scenarios but is irrelevant for most interactive tools.

Why TUIs Are Winning Developers Back

The terminal renaissance isn’t nostalgia. Three forces are driving it. First, remote development workflows mean more engineers live in SSH sessions where GUIs aren’t available. A well-built TUI works identically over SSH, in a Docker container, or on a local machine. Second, the frameworks have matured to the point where building a TUI is as fast as building a web UI for internal tools — sometimes faster, since there’s no build step, no browser compatibility, and no CSS reset.

Third, the developer experience gap has closed. Bubble Tea’s Lip Gloss gives you colors, borders, and layout that rival any web framework. Ratatui’s widget library covers everything from charts to tables to sparklines. Textual’s CSS means you can style a terminal app with the same mental model you use for web. The terminal is no longer a constraint — it’s a choice, and the tooling finally makes it a good one.

Leave a Reply

Your email address will not be published. Required fields are marked *