The Stack That Made GitTop Possible: bubbletea, lipgloss, and the Charm Ecosystem
Source: lobsters
hjr265 built GitTop using a fully agentic coding workflow, and the tool it produced is a real-time terminal viewer for git repository activity modeled on htop’s live process monitoring design. The agentic workflow gets most of the attention in the write-up, as it should. But there is a technical foundation underneath it that deserves its own examination: the Charm toolkit.
Charm is a small company that has built what may be the most coherent ecosystem for terminal application development in any language. It is not a single library but a composition of several distinct pieces operating at different layers: bubbletea for application architecture, lipgloss for visual styling, bubbles for pre-built components, and supporting tools including glamour for Markdown rendering, gum for scriptable terminal UI, and VHS for recording terminal sessions as reproducible code. Understanding what each layer provides explains why a project like GitTop is substantially easier to build today than it would have been five years ago, even setting agentic workflows aside entirely.
The Architecture Layer: bubbletea
bubbletea’s contribution is borrowing the Elm architecture for terminal application development. The pattern divides an application into three functions: a model that holds all state, an update function that handles incoming messages and returns a new model, and a view function that renders the current model to a string. Side effects, timers, subprocess executions, network calls, are expressed as commands that the runtime executes and feeds back into the update loop as messages.
For a tool like GitTop, this architecture maps directly onto the monitoring problem:
type Model struct {
commits []Commit
contributors []Contributor
cursor int
width, height int
}
func (m Model) Init() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
data, _ := fetchGitData()
return Model{
commits: data.commits, contributors: data.contributors,
cursor: m.cursor, width: m.width, height: m.height,
}, nil
case tea.WindowSizeMsg:
return Model{
commits: m.commits, contributors: m.contributors,
cursor: m.cursor, width: msg.Width, height: msg.Height,
}, nil
case tea.KeyMsg:
switch msg.String() {
case "j", "down":
if m.cursor < len(m.commits)-1 {
m.cursor++
}
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
Before Charm, building this same refresh loop in Go required either tcell, which provides a low-level screen abstraction requiring manual cursor positioning and direct ANSI sequence management, or wrapping a C ncurses binding. Both options put the application architecture decisions entirely on the developer: how to structure the polling loop, how to manage state between refreshes, how to handle terminal resize, how to organize the rendering pass. bubbletea answers all of those questions ahead of time, and the answers are structurally enforced. The forced explicitness of model/update/view makes it difficult to produce tangled state management even when adding features iteratively.
The Styling Layer: lipgloss
lipgloss brings CSS-like styling to terminal output. Rather than constructing ANSI escape sequences manually or through a printf helper library, you define styles declaratively:
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
Padding(0, 1)
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#7D56F4"))
lipgloss handles color degradation across terminal capabilities automatically. An 8-color terminal, a 256-color terminal, and a true-color terminal each get the best representation they support, without the developer writing detection and fallback logic. It also handles width-aware rendering correctly, accounting for multi-byte Unicode and wide characters when truncating and padding strings. Hand-written ANSI code that does not account for character width is a persistent source of display bugs, particularly when rendering CJK text or emoji in column-aligned layouts.
For GitTop, the practical effect is that visual quality, table alignment, color scheme, border rendering, does not require specialized terminal knowledge to achieve. The developer describes what they want in terms similar to CSS, and lipgloss translates that into correct ANSI output.
The Component Layer: bubbles
The bubbles package provides pre-built, composable components: a scrollable list, a viewport, a progress bar, a spinner, a text input, a paginator, a table, and a file picker. Each component is itself a bubbletea model with its own update and view logic, designed to be embedded in a parent model.
For GitTop, the natural components are a list for commit history and a table for contributor stats. Both are in bubbles with sensible defaults and keyboard handling already implemented. Implementing a correctly-wrapping, correctly-scrolling list that handles terminal resize and keyboard navigation from scratch is several hundred lines of careful code. With bubbles it is an import and a configuration call:
commitList := list.New(items, list.NewDefaultDelegate(), width, height)
commitList.Title = "Recent Commits"
commitList.SetShowStatusBar(false)
commitList.SetFilteringEnabled(false)
The component implementations in bubbles represent substantial work. Edge case handling for different terminal emulators, ANSI sequence correctness under resize, proper Unicode measurement for alignment. That work was done once by the Charm team and is available to any project that imports the library. For a personal tool like GitTop, that amortized effort is the difference between a weekend project that looks rough at 90 columns but not 80, and one that handles both correctly.
The Comparison: Building Without Charm
The older approach to terminal TUIs in Go used either tcell directly or tview, a capable widget library built on tcell. tview uses a widget tree with callbacks rather than a unidirectional data flow. Mixing mutable widget state with application state requires discipline, and the mismatch becomes visible as an application grows.
Before that, Go terminal applications sometimes used CGo bindings to C ncurses, goncurses being one example. ncurses is robust and portable but its API reflects 1988 origins: explicit screen coordinate management, global terminal state, and a developer-managed event loop. Producing a correctly-behaving application requires understanding terminal behavior that most developers do not have occasion to learn.
In Rust, ratatui (the maintained fork of tui-rs) provides widget-based immediate-mode rendering, and it is powerful and actively developed. But ratatui leaves the application architecture, state management approach, and event loop structure to the developer. An equivalent GitTop in Rust with ratatui would require those architectural decisions to be made explicitly before a single widget is rendered. Python’s Textual framework is the closest architectural comparison to bubbletea, using reactive properties and a CSS layout model, but its CSS/reactive model introduces a steeper learning curve than bubbletea’s three-function interface.
Why This Combination Is Agent-Friendly
Charm’s stack is also unusually well-suited to agentic code generation, for reasons that parallel its advantages for human developers.
bubbletea’s architecture has clear, explicit seams between concerns. An agent generating bubbletea code cannot easily produce structurally incoherent output because the framework enforces the right separations. With tcell, the implementation surface is larger and the number of ways to produce wrong code is correspondingly higher. The agent has to manage event polling, state, rendering, and cursor positioning without structural guidance, which increases the variance across generation cycles.
lipgloss’s declarative API draws on a vocabulary, colors, borders, padding, alignment, that is well-represented in the agent’s training data through its similarity to CSS and web styling patterns. An agent generating lipgloss styling has dense prior examples to draw on. An agent producing raw ANSI sequences has sparser and less consistent training signal.
bubbles components reduce the total code surface an agent needs to generate. Importing a pre-built scrollable list and configuring it replaces implementing one from scratch. Less generated code means fewer points of failure and faster convergence to a working result. This is part of why the fully agentic workflow hjr265 describes produced a working tool as quickly as it did: the agent was composing well-documented components, not reasoning from first principles about terminal layout.
The Enabling Platform Effect
GitTop is a demonstration of what is possible when a well-designed ecosystem reduces the implementation barrier for a class of tools that has always been valuable but rarely built. The economics of terminal tooling for personal use are different now than five years ago, not primarily because of agentic coding, but because Charm built a toolkit that brings the implementation cost of a polished terminal application within reach of a single developer’s afternoon.
The agentic workflow amplifies this effect. An agent directed to build a git activity monitor using Charm’s toolkit produces well-structured, visually reasonable code faster than it could navigating a lower-level API with more decisions left open. The platform choice and the development workflow compound each other: the platform reduces implementation ambiguity, and the reduced ambiguity benefits an agent generating code at least as much as it benefits a human writing it.
For developers with a list of terminal tools they have meant to build, the gap between idea and working prototype is narrow enough, agentic or not, that the traditional excuse of insufficient time is harder to defend. GitTop is one data point in that argument. The Charm ecosystem is the platform that made it.