Home › Blog › Snapshot-driven rendering: the TUI never touches actor state
Blog

Snapshot-driven rendering: the TUI never touches actor state

Jul 19, 20266 min readBy the Rysh team

rysh's Bubble Tea View() is a pure function of a snapshot fetched over request/reply. The UI and the actors share no memory.

There is one rule in rysh's UI code that has no exceptions: the TUI never reads actor state. Not through a pointer, not through a "just this once" accessor. If the renderer wants to know anything about the world, it asks the bus — a snapshot for the layout, the panes' own output topics for the text — and renders what comes back.

rysh is an agentic terminal multiplexer — tabs, lanes, stacked panes, where any pane can be a shell or a live agent conversation (by default running on Claude, with your own Anthropic key). All of that state lives in an actor tree: workspace → tabs → lanes → pane groups → panes, each actor owning its slice, connected by a NATS bus that ships inside the binary. The UI is Bubble Tea. The two worlds share no memory.

The mechanism

When something changes, the TUI makes a NATS request:

request:  {session}.ws.snapshot
reply:    WorkspaceSnapshot (JSON envelope)

The WorkspaceActor doesn't have everyone else's state — only a memo of its last answer, at most 100 ms old. Past that, it cascades: asks each TabActor for a tab snapshot; tabs ask their lanes, lanes their pane groups, groups their panes. Each actor builds its fragment inside its own mailbox, which means the fragment is internally consistent by construction — a pane can't be mid-resize while it's describing itself, because the resize message and the snapshot request are neighbors in one sequential queue, not concurrent threads.

The fragments compose upward into one WorkspaceSnapshot — plain transport-agnostic structs in a domain package, no actor types, no channels, no PTY handles:

type PaneSnapshot struct {
    ID, Title, GivenName string
    Mode                 string   // shell | prompt | rysh | chat | …
    Status               string
    Output               string   // merged shell+AI stream
    ChatOutput           string
    // raw mode (vim, htop, ...):
    RawMode                  bool
    VTScreen                 []string
    VTCursorRow, VTCursorCol int
    // filled in by FlatPanes() for the deck:
    StackTotal, StackPosition int
    StackCollapsed            bool
    // ...
}

And then the punchline, which is almost boring:

func (m Model) View() string {
    return lipgloss.JoinVertical(lipgloss.Left,
        m.renderHeader(), m.renderBody(), m.renderFooter()) // every pane fact comes from m.snapshot
}

View() reads the world only from m.snapshot — streamed pane text is merged into it first — plus the TUI's own UI state, and produces a string. Layout math (column flex, stacked-pane title bars, split-down rows) runs over snapshot fields like Flex, RowFlex, and the stack metadata — FlatLanes()/FlatPanes() helpers on the tab snapshot feed the geometry pipeline exactly what it needs. When a pane is in raw mode, View() paints its VTScreen grid — the pane's own terminal emulator has already drawn the cursor into it — instead of scrollback. One exception: a full-screen program alone on screen gets the terminal handed to it byte for byte.

What the rule buys

No data races, by construction. The usual TUI failure mode — renderer reads a buffer while an I/O goroutine appends to it — cannot be expressed. The pane's buffers live in another process; the renderer only ever holds a decoded copy. There is nothing shared to race on. We didn't fix this class of bug; we made it unwritable. (It's the same discipline we aim for in the actors — one owner per state, and the boundary crossed only by values.)

Every client is the same client. This is the payoff that kept growing. The TUI is just a snapshot consumer — every client sends the same request; the TUI just sets the flags it needs:

An honest boundary. Because the snapshot is serialized JSON over a bus, there is no temptation gradient. You physically cannot "just peek" at a PaneActor's buffer from the render loop — the type system and the process architecture both say no. Boundaries that rely on discipline erode; boundaries that rely on serialization don't.

The costs, measured in honesty

Snapshots aren't free. The layout snapshot still walks every actor and carries every pane's metadata, rebuilt on each structural change and on a periodic reconcile; scrollback and VT grids now travel per pane, beside it. Two things keep this tolerable: output buffers are size-bounded, and the request never leaves the machine (the TUI reaches the session's NATS server over loopback).

No layout diffing — yet. The layout tree is re-sent whole on every refresh; the TUI only drops a reply whose bytes are identical to the last one. Pane text already moves as deltas, but the tree itself has no "pane 3 changed, others unchanged." Bubble Tea skips repainting lines that didn't change, so the screen isn't thrashing — but the cascade work is repeated across refreshes, and I'd rather say that than pretend it's already clever.

Depth is on the latency path. The cascade means snapshot latency is bounded by the slowest pane's mailbox, up to a one-second timeout after which that pane is drawn as unreachable. A pane actor grinding through a message backlog delays the whole frame's data. Bulk PTY output never travels down the hierarchy — it goes straight to the pane's output topics, and the only mailbox it re-enters is the pane's own — but the coupling is real and we watch it.

Pull for the tree, push for the rest. The inverted design — actors push state changes, UI accumulates — is the classic alternative, and it's where deltas naturally live. Actors push a cheap "layout changed" signal and the TUI pulls the tree; pane text is pushed and accumulated. We kept pull-with-cascade for the layout for its consistency guarantee (each fragment atomic within its actor) and for the it's-just-a-request property that headless and remote clients build on.

What we'd do differently

Split layout from content earlier — retrofit is harder than greenfield. And version the snapshot schema: it's the one struct every surface (TUI, web UI, desktop app, tests) depends on, which makes it the de-facto public API of the whole backend. Today it carries no version number; new fields are added as optional and every reader tolerates their absence.

The rule stands, though. Six words of architecture — the UI never touches actor state — bought us race-freedom, trivial reattach, and a backend that serves every viewer the same request. Most rules this simple are lies. This one has held.


rysh is in early access and we're recruiting design partners — teams who want agents in their real workflow and will tell us what breaks. Direct line to the founders: rysh.ai/design-partner.

More in this series: The actor hierarchy · No mutexes: what sequential mailboxes buy you

Try Rysh

Every pane is a shell and an AI agent — install takes one command.

Get started free →