The controller/view epiphany
I was three weeks in when I noticed that the sidebar of my TUI was running my whole application. The view was the program. To be honest, it took me embarrassingly long to see it.
Context
moitumi’s TUI is built around tmux. The first version had a single
process — call it NavigateView — which:
- Ran the FTXUI event loop (the terminal renderer).
- Held the tmux control-mode connection.
- Owned the graph and the agent orchestrator.
- Wired up the action handlers.
- Rendered a tree of nodes in a sidebar.
It worked. It also had a name that lied. NavigateView was a view
that had quietly absorbed the entire controller. And the moment I
started thinking about content — what happens when you press Enter
on a node and you want to see something richer than a tree — the lie
collapsed.
The thing I was trying to fit into the view
A node’s content can be:
- A markdown document (
tail -fa journal entry). - A live process (the output of a running build, or a
topview of agent CPU). - A graph rendered in ASCII.
- An interactive prompt (a Claude agent the user is talking to).
- Another node tree.
- Nothing — some nodes are pure structure.
I was trying to make a single FTXUI panel handle all of those. Every node type would have its own renderer; the renderer would need to know how to drive the content; for “live process” content, the renderer would have to spawn a child process and pump bytes through itself without blocking the event loop; for an interactive agent, the renderer would need a PTY…
That’s not a renderer anymore. That’s a window manager. And tmux is already a window manager.
The realization, condensed — stop trying to render arbitrary terminal processes inside one FTXUI panel. Let each piece of content be its own tmux pane, running the right process for the content. The “view” is no longer a function of the data — it’s a tmux pane.
Effectively, I’d been writing a worse version of tmux, inside tmux. Cool.
The split
The redesign separates two concerns that had been collapsed:
graph TB
subgraph Before["Before: NavigateView did everything"]
NV[NavigateView]
NV -.owns.-> Graph1[Graph]
NV -.owns.-> TmuxConn1[tmux connection]
NV -.runs.-> Loop1[FTXUI event loop]
NV -.renders.-> Tree1[Tree sidebar]
end
style Before fill:#fee2e2,stroke:#dc2626
graph TB
subgraph After["After: controller + ephemeral views"]
C[Controller<br/>persistent, non-visual]
C -.owns.-> Graph2[Graph]
C -.owns.-> TmuxConn2[tmux control mode]
C -.owns.-> AO[AgentOrchestrator]
C -.listens on.-> Sock[Unix socket]
Nav[Navigator pane<br/>ephemeral]
Content[Content pane<br/>ephemeral]
Agent[Agent pane<br/>ephemeral]
Nav -- "commands" --> Sock
Content -- "commands" --> Sock
Agent -- "commands" --> Sock
C -. "spawn/kill via tmux" .-> Nav
C -. "spawn/kill via tmux" .-> Content
C -. "spawn/kill via tmux" .-> Agent
end
style After fill:#dcfce7,stroke:#16a34a
The pieces:
The controller is the moitumi launcher process. It stays alive
after creating the tmux session. It has no terminal output of its own
— no FTXUI, no tmux pane. It owns:
- The graph and the graph store.
- The audit log.
- The agent orchestrator.
- The tool-bridge server (the IPC endpoint agents use to mutate the graph).
- The tmux control-mode connection.
It listens on a Unix socket for commands from views and watches tmux
notifications (%window-changed, %pane-focus-in) to stay in sync
with what the user is doing.
Every view is a tmux pane running its own process:
- The navigator is a toggleable sidebar — ~25-30 chars wide,
spawned in the current window when the user presses
Ctrl-b t, killed on toggle off. Stateless. When alive, it gets tree data from the controller via socket, renders it, sends commands back. When dead, the controller still holds the selection state. - Content panes run whatever process makes sense for the node —
glowfor markdown,tail -ffor live logs, the agent’s own process for interactive sessions. - Agent panes are full-screen tmux windows running Claude Code.
Window management is tmux-native. The user switches windows with
Ctrl-b n/p, Ctrl-b <index>, Ctrl-b w. They don’t learn moitumi
keybindings — they learn tmux. The controller just listens for
tmux’s own notifications and updates its internal state of “which
node is central.”
The IPC contract
Three communication paths, each with one direction:
Views → Controller: Unix socket (JSON commands)
Controller → Views: tmux control mode (create/kill panes, spawn processes)
Controller → Views: stdin (data piped at spawn time)
The Unix socket carries intent-based commands — focus(node_id),
enter(node_id), spawn_agent(node_id), toggle_sidebar(). No
tmux vocabulary on the wire; the controller translates intent into
tmux operations.
For the controller to push data to a view, it spawns the view
with arguments and/or pipes data to its stdin at spawn time. A new
content pane gets the node it should render passed in via argv;
tree data flows into the navigator over a stdin handle held open
for the pane’s lifetime.
This is brutally simple. No JSON-RPC. No bidirectional streaming. The asymmetry — sockets going one way, tmux control mode going the other — matches the natural direction of each piece of information. Views have things they want to do; the controller has things it wants to render. Needless to say, the simpler shape wins here.
Why this is the right shape
Three reasons it works:
- The controller and view never compete for state. State lives in the controller. Views are pure projections — they ask, they render, they die. A view crashing doesn’t lose state. The controller restarting (rare) just re-spawns the views.
- Tmux scales the layout for free. Want three agents side by
side? Three tmux windows, full-screen each, switch with
Ctrl-b 1/2/3. The controller doesn’t manage layout — tmux does, and the user already knows the keys. - Adding a new view type is mechanical. A new content type
means a new pane process — a shell command, possibly an existing
tool (
glow,tail,top, anything). No changes to the controller beyond “if node type X, spawn process Y.” No “renderer plugin system” needed.
The “graph-driven layout” insight
Once the views were panes, a second pattern fell out. Making a node central creates a tmux window. Its children become panes. Like zooming into an Obsidian graph view, but mapped to tmux:
┌──────────────────────────┐
│ "project-x" is central │
├───────────┬──────────────┤
Enter "project-x" ──→ │ │ │
│ notes/ │ tasks/ │
│ │ │
├───────────┼──────────────┤
│ │ │
│ src/ │ readme.md │
│ │ │
└───────────┴──────────────┘
Each child gets a pane running the right viewer. Leaf nodes get a single pane filling the window. No content-type-based layout engine — the graph structure is the layout.
Agents become a special case of this — spawning an agent creates an agent child node, which (when the parent is central) appears as a pane running Claude Code. Make the agent node central directly → full-screen Claude Code with the agent’s tool bridge attached.
The mental model collapses into one rule — the graph is the layout. No bookkeeping outside that. That being said, I’m sure there are layouts the graph won’t naturally express; I’ll deal with that when I see one.
What I’d missed by not separating earlier
The redesign cost about a week of refactor. Worth it three times over, but the lesson is harder than “controllers and views should be separate” — every CS textbook says that already, and I’d nodded at it for years.
The specific failure was naming the violation. NavigateView had
been called a view, and once it had the word “view” in its name,
nobody (least of all me) questioned that it owned the graph. The
name lied for me. If I’d called it NavigateController from the
start, the second-week refactor would have been “extract the
rendering bits into a view,” not “extract everything except the
rendering bits into a controller.”
Overall — naming is not aesthetic. Names propagate assumptions. The wrong name locks the architecture in place, and you don’t even know it’s locked, because the name says it’s fine. Maybe I am biased after this one, but I’m starting to take “what is this thing actually” more seriously when I sit down to name it.
Source pointers
DEVLOG.md— entries of2026-03-16(“TUI architecture redesign: controller/view separation”),2026-03-16(“Phase 3: Controller/View separation + CI workflow”)PLAN.md— ADR-006 (Rendering), TUI architecture sectiondocs/architecture/tui-architecture.md— controller/view diagram, launcher lifecycle, IPC protocol
Cross-posted from the moitumi dev blog.