Two Architectural Paradigms: Immediate Mode vs. the Elm Architecture
Before writing a TUI, settle one question first: what's the relationship between UI state and rendering logic? The industry has largely converged on two answers.
Immediate Mode — ratatui
The official docs put it plainly: the UI is recreated every frame, with no persistent widget objects.
loop {
terminal.draw(|f| {
if state.condition {
f.render_widget(SomeWidget::new(), layout);
} else {
f.render_widget(AnotherWidget::new(), layout);
}
})?;
}
zellij-workbench's draw_tui (src/tui.rs) is exactly this: every call
rebuilds List, Paragraph, and Line widgets from scratch, holding no
reference to last frame's widgets. The upside is directness — UI logic is a straight projection of state;
the cost is that you own the render and event loops yourself — the library won't decide "when to redraw" for you.
The Elm Architecture (Model-Update-View) — bubbletea
Go's bubbletea (43k+ stars) is the flagship of this school.
Three functions: Init() Cmd returns the startup command, Update(Msg) (Model, Cmd) takes a
message and returns new state plus an optional side-effect command, View() string is a pure function
that only reads the Model. Data flows one way:
event → Msg → Update → new Model → View → terminal, with side effects isolated inside Cmd.
Persistent View + dirty flags — gocui / lazygit
A third, more pragmatic shape: View (pkg/gocui/view.go) is a persistent object
holding its own line buffer, cursor, and scroll position — closer to a traditional retained-mode control. But it's
driven by a tainted dirty flag plus explicit SetContent calls — effectively "stateful
immediate mode": the view object persists, but content is still replaced wholesale rather than patched incrementally.
Embrace ratatui's immediate mode directly — don't hand-roll another widget-tree-plus-diff layer on top; the library's own Buffer diffing already solves the performance problem.
With heavy network requests, subscriptions, or multiple independent async sources, the Elm Architecture saves you from a lot of "where did this state change" bugs — you can hand-roll a simplified version even without bubbletea.
Multi-level navigation as complex as lazygit's eventually needs a persistent view object plus explicit dirty flags; purely functional "rebuild everything every frame" gets unwieldy once state volume grows.
The Event Loop: A TUI's Heartbeat
Whichever paradigm you pick, the event loop skeleton looks the same: read/wait for an event → update state → re-render as needed.
loop {
apply_completed_refresh(&refresh_rx, ..., &search, view, server_filter.as_deref());
if last_auto_refresh.elapsed() >= AUTO_REFRESH_INTERVAL && !auto_refresh_in_flight {
spawn_auto_refresh(refresh_tx.clone());
auto_refresh_in_flight = true;
}
terminal.draw(|frame| { /* render the whole UI tree */ })?;
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
match mode { /* handle the key */ }
}
}
}
Three key design points:
event::poll(200ms)instead of a busy loop: a blockingevent::read()can't check "is it time to auto-refresh" while waiting for a keypress; an uncapped busy-poll would pin a CPU core at 100%. 200ms is imperceptible to a human keypress, yet infrequent enough to not waste CPU.- Render happens before waiting for events: guarantees any state change (even one a background thread just pushed) gets drawn on the very next iteration, with no extra "is it dirty" check needed.
- The event loop is single-threaded; side effects go elsewhere: anything that might block (scanning sessions, running git commands) can never go directly in this loop, or a 3-second scan freezes the whole UI for 3 seconds.
lazygit: two channels feeding one single-threaded loop
The pollEvent() goroutine only pushes terminal events into g.gEvents; any background goroutine
that wants to update the UI must call Gui.Update(f), which is really pushing a closure into the
g.userEvents channel:
func (g *Gui) Update(f func(*Gui) error) {
task := g.NewTask()
select {
case g.userEvents <- userEvent{f: f, task: task}:
default:
panic("gocui: userEvents channel full; refusing to block or reorder")
}
}
Note the deliberately non-blocking select/default: a blocking send from the UI thread to
itself could deadlock; "fall back to synchronous execution when full" looks safe but actually breaks the ordering
guarantee callers rely on — better to panic and surface the bug. The main loop does one select across
both channels, then non-blockingly drains everything currently queued before deciding whether to render — explicit
event coalescing: process a batch of events that arrived close together, then render once.
General takeaway: regardless of language or framework, "single-threaded event loop + all cross-thread communication via channel/queue" is close to an iron law for TUIs. You can enforce this in the type system (the Elm Architecture's Cmd/Msg), or fall back to a runtime panic like gocui does, but "UI state can only be mutated by the thread that owns it" is a line not to cross.
Rendering Performance: Immediate Mode Doesn't Mean Brute-Force Redraws
The most common misunderstanding is "redrawing every widget every frame = sending every byte to the terminal every frame." In reality these are two separate things, with a diffing layer in between.
ratatui's Buffer diffing: after rendering into an in-memory Buffer, it isn't converted
wholesale into escape sequences — instead a BufferDiff iterator only emits the (x, y, cell)
entries that actually changed this frame, to the backend.
gocui/tcell follows the same idea, one layer lower: CellBuffer.Dirty(x, y) compares this
frame against last frame — cells that haven't changed never make it into the byte stream sent to the terminal.
Takeaway 1: you almost never need to hand-write "dirty rectangle" logic — that performance is free.
Takeaway 2: the real trap is at the "logical rebuild" layer, not the "physical transmission" layer — formatting 10,000 strings for a 10,000-row list that isn't even on screen has already paid its CPU cost; diffing only saves the last step of "sending it to the terminal."
How lazygit handles the "logical rebuild" layer concretely
if self.renderOnlyVisibleLines {
// Rendering only the visible area can save a lot of cell memory for
// those views that support it.
startIdx, length := self.GetViewTrait().ViewPortYBounds()
content := self.renderLines(startIdx, startIdx+length)
self.GetViewTrait().SetViewPortContentAndClearEverythingElse(totalLength, content)
}
- List virtualization: format only the rows inside the viewport's visible range, not the entire commit history or file list.
- A "content-only" fast path that skips the whole layout recompute: when a batch of events is entirely tagged
contentOnly,flushContentOnly()skips the expensiveLayout()recompute and only redrawstaintedviews — a status-bar spinner ticking every 100ms has no reason to trigger a full window-layout repass. - Explicit caching of expensive computations: the commit graph's
pipeSetCache, colored-stringrgbCache, andgit configreads viaCachedGitConfig— all avoiding recomputation or a forked subprocess on every refresh. - Throttling bursts of requests:
pkg/tasks/tasks.gowhen a user rapidly scrolls through the commit list, if the previous
const THROTTLE_TIME = time.Millisecond * 30 const COMMAND_START_THRESHOLD = time.Millisecond * 10git showgot cancelled quickly and the system already looks under load, the next command sleeps 30ms before starting — explicitly to avoid "rapid paging" turning into "a stampede of forked subprocesses."
Checklist
- Long lists should always be viewport-windowed — diffing only saves transmission cost, not the cost of building strings.
- Anything "same input, recomputed repeatedly in a short window" is worth memoizing in a
HashMap. - High-frequency but visually lightweight updates (progress bars, spinners) need a fast path that skips the full layout recompute.
- Operations a user can trigger rapidly that are backed by a heavyweight command should "cancel the previous one and throttle the next."
Layout: Constraint-Driven, Not Pixel Math
Good TUI layout converges on the same idea: declarative constraints/weights, not hand-computed x/y coordinates — so a terminal resize only requires re-solving constraints.
let shell = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // top status bar: fixed 3 rows
Constraint::Min(8), // main body: at least 8 rows, gets the rest
Constraint::Length(1), // bottom keybinding hint: fixed 1 row
])
.split(frame.area());
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
.split(shell[1]);
A typical nested split: slice the terminal vertically into header/body/footer, then slice the middle section into a 52%/48% two-column split. No code changes needed on resize.
type Box struct {
Direction Direction // ROW or COLUMN
Children []*Box
Window string // leaf node: which window this represents
Size int // static size (mutually exclusive with Weight)
Weight int // dynamic weight, proportional share of remaining space
}
ArrangeWindows's allocation algorithm is essentially flexbox: satisfy every Size child
first, then divide remaining space proportionally by Weight — the same model as CSS's flex-grow.
Every frame rebuilds the entire Box tree fresh from the current screen size, focused window, and screen
mode, then solves it — layout is recomputed from scratch every time, never patched, because the tree
is small enough that the cost is negligible.
The constraint system's complexity should match the layout's — a static two-column layout is well served by Constraint alone; "normal/half/full screen, portrait/landscape" justifies a dedicated recursive Box tree.
Neither codebase has dedicated resize branches — layout was already computed every frame; size is just one more input.
A Rect array or Dimensions map only answers "how big, where" — never "what to draw inside it."
State and Navigation: From an Enum to a Context Stack
enum InputMode {
Normal,
Search,
}
An enum plus match is entirely sufficient for "browse a list + search box" flat interaction. But once
you need "a panel pops a menu, the menu pops a confirmation dialog, Esc unwinds one level at a time, restoring the
underlying panel's exact prior state" — a flat enum rapidly explodes into a giant match over the
Cartesian product of every mode.
lazygit's explicit context stack
type ContextMgr struct {
ContextStack []types.Context
sync.RWMutex
...
}
Every panel/popup implements types.Context and declares its own kind; push rules branch on kind:
- Pushing a
SIDE_CONTEXT(side panel) → clears the whole stack, leaving only itself (mutually exclusive, no nesting). - Pushing a
MAIN_CONTEXT(main content) → removes otherMAIN_CONTEXTs, but keeps side panels and other layers. - Anything else (menus/confirmations) → if the top is a temporary popup, it gets replaced rather than nested (the code comment candidly admits this is a known trade-off: temporary popups reuse the same view, so escaping back through them isn't currently possible).
This stack is also the sole source of truth for keyboard event routing: activating a context calls
SetCurrentView, and gocui's decision of "who handles this key" only ever looks at
g.currentView — the answer is always "look at the stack top."
When to graduate
- Your "modes" start exhibiting genuine nesting rather than simple mutually-exclusive switching.
- Esc's semantics become "return to the previous level" rather than "go back to some fixed default state."
- Different modes need independent keybinding tables, rather than enumerating a Cartesian product inside one giant
match.
Don't reach for it too early: if you genuinely only have "browse + search" flat interaction, an enum is far easier to maintain than a full context-stack abstraction. A context stack is for apps that genuinely need nested navigation — not a TUI starter-kit default.
Async and Responsiveness: Never Block the Event Loop
What do you do about slow operations (network, subprocesses, disk)? Both codebases give a strikingly consistent answer: spawn a thread/goroutine to do the work, send the result back over a channel, and have the event loop non-blockingly check "is there a new result yet."
let (refresh_tx, refresh_rx) = mpsc::channel();
fn spawn_auto_refresh(refresh_tx: Sender<RefreshResult>) {
thread::spawn(move || {
let result = refresh_index_report()
.and_then(|summary| { /* re-read from SQLite */ })
.map_err(|err| format!("{err:#}"));
let _ = refresh_tx.send(result);
});
}
The top of every main-loop iteration non-blockingly calls try_recv() to pull in any finished background
result and merge it into UI state, while restore_selection finds the user's currently selected row back
by ID rather than index — never bouncing the cursor to the top just because data refreshed. Scanning sessions and
running git commands run entirely on another thread; the event loop keeps polling for keypresses on its normal
200ms cadence. This is essentially a hand-rolled, simplified "Cmd dispatch + Msg collection" (the Elm Architecture
idea from section 1).
lazygit's two layers of async
Gui.OnWorker(f) spawns a goroutine to run f, automatically wrapped in panic recovery;
RefreshHelper.Refresh lets files/branches/commits/stash each refresh concurrently, and once finished
must route back through OnUIThread (i.e. Gui.Update) — a worker goroutine is never allowed
to touch a view directly. Each category of data has its own lock (RefreshingFilesMutex, etc.) to prevent overlapping refreshes.
Finer-grained still is ViewBufferManager in pkg/tasks/tasks.go: scrolling commits triggers
a git show per selection. Rather than waiting for the whole command to finish, 4 cooperating goroutines
consume and display it incrementally — one scans stdout into a channel, one shows a "loading…" placeholder if
nothing arrives within 200ms, one consumer writes arriving lines and triggers a partial redraw, and one watches for
cancellation to gracefully terminate the previous still-running process.
Checklist
- Anything that might block longer than one frame's worth of time must never go directly on the event loop's thread.
- Background results must be handed back via a channel/queue before mutating state — don't reach for a
Mutex<State>mutated across threads and hope the render thread "just sees it." - High-frequency refreshes should either deduplicate (don't restart if already running) or throttle.
- Long-output commands should stream rather than "wait then process all at once" — it meaningfully improves perceived latency.
The Essentials Checklist: What a "Good" TUI Needs
- Keybindings must be discoverable — not something users memorize from docs.
zellij-workbench's bottom status bar permanently shows every available key; lazygit goes further with a bottom options bar plus a full searchable?keybinding menu sharing the same binding data. - Search/filter should support structured queries.
SearchQuery::parselayersserver:status:tag:git:prefixes on top of plain text, expressing a compound condition in one line. - Async operations need visible status feedback, even just a line of text (
scan_status: idle / refreshing… / ok, N workspaces / failed). - Default actions should be conservative; destructive ones need explicit escalation.
attachfails with a hint to userecreaterather than silently creating an empty session; lazygit pops confirmations before force-pushing or discarding changes. - Background refresh must not disrupt the user's current state.
restore_selectionfinds the selected row back by ID rather than resetting selection on every refresh. - Resize should require no special handling — as long as layout is "re-solved from current size every frame" (see section 4).
Terminal state must be recoverable on an abnormal exit
A real issue discovered in this very repo while writing this guide:
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let result = draw_tui(&mut terminal, workspaces); // if this panics...
disable_raw_mode()?; // ...these two lines never run
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
If draw_tui panics internally — even from a mundane off-by-one bug — the user's terminal is left stuck in raw mode plus the alternate screen: Enter does nothing, miserable experience. The fix:
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
draw_tui(&mut terminal, workspaces)
}));
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
match result {
Ok(result) => result,
Err(payload) => std::panic::resume_unwind(payload),
}
lazygit has the equivalent consideration: onWorkerAux wraps every background goroutine in panic
recovery, and an unrecovered panic calls Screen.Fini() (tcell's cleanup for leaving the alternate
screen) before re-panicking. The principle is identical: any path that might panic after the terminal has
switched into a special mode must explicitly restore it — a lesson that generalizes to nearly every
language/framework touching raw terminal mode directly (Node's blessed/ink and Python's curses have equivalent
finally/atexit patterns).
Side-by-Side Comparison
| Dimension | zellij-workbench (ratatui) | lazygit (gocui/tcell) |
|---|---|---|
| Architecture | Immediate mode: rebuild widget tree every frame | Persistent View objects + dirty-flag partial rebuilds |
| Event loop | Single-threaded loop, poll(200ms) | Single-threaded processEvent, two channels merged |
| Render diffing | BufferDiff (cell-level) | tcell Dirty() (cell-level) + gocui's own view-level fast path |
| Long-list optimization | No virtualization (not needed yet) | RenderOnlyVisibleLines: formats only the visible window |
| Layout system | Layout + Constraint | Custom boxlayout: Size/Weight recursive tree |
| Navigation/modes | Flat InputMode enum | ContextMgr context stack |
| Async | thread::spawn + mpsc | OnWorker + OnUIThread, per-scope locks |
| Throttling/caching | None yet (not needed at this scale) | Commit-graph/color/git-config caches, 30ms command throttle |
| Keybinding discovery | Fixed bottom hint line | Bottom options bar + full ? keybinding menu |
| Terminal-state recovery | catch_unwind (this guide's fix) | panic recover + Screen.Fini() |
Not "lazygit is better everywhere" — each project's complexity investment matches its own problem scale. lazygit
has to handle real repos of any size and complex multi-panel navigation, so it invested real effort in
virtualization/caching/throttling/context stacks; zellij-workbench's current scale is entirely well
served by immediate mode plus flat state. Keep architectural complexity at "just enough," and only add
virtualization, caching, or a context stack once you hit an actual signal that something is visibly slow or mode
management has become unmanageable.
Further Reading
- Ratatui: Rendering under the hood — the official docs' detailed explanation of Buffer diffing
- Ratatui ARCHITECTURE.md — the multi-crate split since 0.30
- The Elm Architecture (bubbletea) — Model-Update-View realized in Go
- lazygit's source — especially
pkg/gocui/gui.go,pkg/gui/context.go,pkg/tasks/tasks.go,lazycore/pkg/boxlayout - This repo's own
src/tui.rs— nearly every ratatui example here is lifted directly from that file
Suggested order: get the main flow working with immediate mode plus a flat state enum, wire up the event loop and async refresh skeleton per section 2, then gradually "add ingredients" following lazygit's approach in sections 3, 4, and 5 as you actually run into performance or navigation complexity — don't front-load a context stack, virtualized lists, and throttling from day one.