tui TUI Development Deep Dive zellij-workbench × lazygit ratatui / Rust gocui / Go

Architecture & Performance Deep Dive

A Deep Dive into TUI Development: From zellij-workbench to lazygit

Using this repo (Rust + ratatui) and lazygit (Go, self-built gocui + tcell) as two real codebases, this guide breaks down the event loop, rendering performance, layout system, navigation state, and "essential features" of terminal UI apps — every code reference here has a real source in the respective repo.

01 · Paradigms

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.

ratatui docs
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.

Writing Rust

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.

Clearly message-driven

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.

Panels + popups get complex

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.

02 · Event Loop

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.

src/tui.rs · draw_tui
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:

  1. event::poll(200ms) instead of a busy loop: a blocking event::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.
  2. 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.
  3. 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:

pkg/gocui/gui.go
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.

03 · Performance

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

pkg/gui/context/list_context_trait.go · HandleRender
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 expensive Layout() recompute and only redraws tainted views — 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-string rgbCache, and git config reads via CachedGitConfig — all avoiding recomputation or a forked subprocess on every refresh.
  • Throttling bursts of requests:
    pkg/tasks/tasks.go
    const THROTTLE_TIME = time.Millisecond * 30
    const COMMAND_START_THRESHOLD = time.Millisecond * 10
    when a user rapidly scrolls through the commit list, if the previous git show got 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."
04 · Layout

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.

src/tui.rs · draw_tui
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.

lazycore/pkg/boxlayout/boxlayout.go
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.

Match complexity

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.

Responsiveness is a side effect

Neither codebase has dedicated resize branches — layout was already computed every frame; size is just one more input.

Layout/content decoupled

A Rect array or Dimensions map only answers "how big, where" — never "what to draw inside it."

05 · Navigation

State and Navigation: From an Enum to a Context Stack

src/tui.rs
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

pkg/gui/context.go
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 other MAIN_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.

06 · Async

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

src/tui.rs
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.
07 · Essentials

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::parse layers server: 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. attach fails with a hint to use recreate rather 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_selection finds 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:

src/tui.rs · run_tui (before the fix)
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:

src/tui.rs · run_tui (after 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).

08 · Side by Side

Side-by-Side Comparison

Dimensionzellij-workbench (ratatui)lazygit (gocui/tcell)
ArchitectureImmediate mode: rebuild widget tree every framePersistent View objects + dirty-flag partial rebuilds
Event loopSingle-threaded loop, poll(200ms)Single-threaded processEvent, two channels merged
Render diffingBufferDiff (cell-level)tcell Dirty() (cell-level) + gocui's own view-level fast path
Long-list optimizationNo virtualization (not needed yet)RenderOnlyVisibleLines: formats only the visible window
Layout systemLayout + ConstraintCustom boxlayout: Size/Weight recursive tree
Navigation/modesFlat InputMode enumContextMgr context stack
Asyncthread::spawn + mpscOnWorker + OnUIThread, per-scope locks
Throttling/cachingNone yet (not needed at this scale)Commit-graph/color/git-config caches, 30ms command throttle
Keybinding discoveryFixed bottom hint lineBottom options bar + full ? keybinding menu
Terminal-state recoverycatch_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.

09 · Further

Further Reading

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.