Snapshot-driven rendering: the TUI never touches actor state
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, not through a clever cache. If the renderer wants to know anything about the world, it asks for a snapshot over the bus 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 (the engine runs on Claude, with your own key). All of that state lives in an actor tree: workspace → tabs → lanes → pane groups → panes, each actor owning its slice exclusively, connected by an embedded NATS bus. The UI is Bubble Tea. The two worlds share no memory.
The mechanism
When the TUI needs to render, it makes a NATS request:
request: rysh.ws.{session}.snapshot
reply: WorkspaceSnapshot (JSON)
The WorkspaceActor doesn't answer from a cache of everyone else's state — it doesn't have everyone else's state. It cascades: asks each TabActor for a tab snapshot; tabs ask their pane groups; groups ask 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
Status string
Output []string // merged shell+AI stream
ChatOutput []string
// raw mode (vim, htop, ...):
RawMode bool
VTScreen []string
VTCursorRow, VTCursorCol int
// stack metadata for rendering the deck:
StackTotal, StackPosition int
StackedTitles []string
// ...
}
And then the punchline, which is almost boring:
func (m Model) View() string {
return render(m.snapshot) // pure function; nothing else exists
}
View() consumes the snapshot and produces a string. Layout math (column flex, stacked-pane title bars, split-down rows) runs over snapshot fields like Flex, SplitDown, and the stack metadata — a FlatPanes() helper on the tab snapshot feeds the geometry pipeline exactly what it needs. When a pane is in raw mode, View() paints the VTScreen grid and places the cursor from VTCursorRow/Col instead of rendering scrollback. Same function, same input type.
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 renderer holds a value, not a reference. There is nothing shared to race on. We didn't fix this class of bug; we made it unwritable. (It's the same discipline as our no-mutexes-in-actors rule — 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 — nothing about the protocol privileges it:
rysh attachafter a detach? Request a snapshot, render it. Reattachment isn't a special code path; it's frame one of the same loop.- A desktop or mobile surface rendering the session? Same request, same struct, different paint.
- Tests? Feed a snapshot into
render(), assert on strings. No actors needed at all.
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. A workspace with many panes, each carrying bounded scrollback plus a VT screen grid, serializes into a non-trivial payload — built and shipped on every refresh. Two things keep this tolerable: output buffers are size-bounded, and the request is local (the NATS server is embedded, in-process; this is a memory copy wearing a network costume, not a network round trip).
No diffing — yet. We ship the whole snapshot every time. There is no delta protocol, no "pane 3 changed, others unchanged." Bubble Tea re-renders from the value and terminal output is itself diffed by the framework, so the screen isn't thrashing — but the serialization work is duplicated across refreshes. This is the first thing on this subsystem's future-work list, 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. A pane actor grinding through a message backlog delays the whole frame's data. In practice pane mailboxes stay shallow — bulk PTY output rides the data plane directly to output topics, precisely so mailboxes don't back up — but the coupling is real and we watch it.
Request/reply per frame is a choice, not a law. The inverted design — actors push state changes, UI accumulates — is the classic alternative, and it's where a delta protocol would naturally live. We chose pull-with-cascade for consistency guarantees (each fragment atomic within its actor) and for the it's-just-a-request property that makes headless and remote clients trivial. If profiling ever says otherwise, push-based invalidation with pull-based snapshots is the likely hybrid.
What we'd do differently
Start the delta protocol earlier — retrofit is harder than greenfield. And version the snapshot schema from day one: it's the one struct that every surface (TUI, tests, future clients) depends on, which makes it the de-facto public API of the whole backend. We treat it that way now; we should have from the first commit.
The rule stands, though. Five words of architecture — the UI renders snapshots, period — bought us race-freedom, trivial reattach, and a backend that doesn't know or care who's watching. 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