Crush Architecture Deep Dive: Centralized State, Dumb Components, and Cached Rendering
If Chapter 1's three technologies were the bricks and mortar of the Crush building, this chapter explores the structural blueprint. We go into the engine room to understand the skeleton and muscles that keep the application running smoothly.
2.1 Main Model Design
Crush's main model—the UI struct in internal/ui/model/ui.go—is like an airport control tower. It holds over one hundred fields, roughly grouped into state management, component references, layout calculations, and external system connections. The design embodies centralized state management: all important state lives here, not scattered across components.
Two-Layer State Machine
Crush uses two layers of state:
- Application state (
uiState):uiOnboarding(first-run guide),uiInitialize(loading),uiLanding(waiting for input), anduiChat(the core conversation view). These four states form the main user journey. - Focus state:
uiFocusEditor(typing in the editor),uiFocusMain(navigating message history), anduiFocusNone. Focus determines where keyboard events are routed and which visuals get highlighted. Chat:SetMessages(),ScrollToBottom()Status:SetError(),SetWarning()Attachments:Add(),Remove()*cachedMessageItem— render caching*focusableMessageItem— a minimal focus tracker (one bool field) used to highlight the selected message during arrow-key navigation*highlightableMessageItem— highlights a rectangular region (startLine,startCol,endLine,endCol) plus ahighlighterrender function, mainly for showing which user code an AI reply references- Full render (Markdown parsing, Lipgloss styling, ANSI generation): 0.1–1 ms per message
- Cache hit (string copy + width compare): 0.001–0.01 ms
- Speedup: roughly 100–1000x
> State machine: a mathematical model describing system behavior through a finite set of states and transitions. In UI programming it prevents "impossible" interface states.
Message Routing in Update
The real control happens in the Update method—a hundreds-of-lines message hub using Go's type switch to dispatch by message type: window resizes trigger layout recalculation; session loads switch state and start LSP services; send-message requests call sendMessage; dialog closes pop the top of the dialog overlay.
Importantly, Update doesn't handle all logic directly. For complex operations it delegates to dedicated methods like updateLayoutAndSize(), startLSPs(), and sendMessage(), keeping the routing layer relatively clean despite handling dozens of message types.
The UI struct holds references to all major components—dialog (overlay management), status (status bar), header (compact header), textarea (input), attachments, chat (message list), and completions (@-mention autocomplete). Components never talk to each other directly; they coordinate through the main model.
This makes state changes traceable: any component state change must originate from a main-model method call, which itself was triggered by some message. To understand "why does the UI look like this," just follow the message flow.
The trade-off: centralization concentrates complexity—a huge Update, dozens of component references, tangled call chains. The answer to keeping this maintainable lies in component design patterns.
2.2 The Dumb Component Pattern
In traditional GUI frameworks, components have significant autonomy—buttons decide when they're clicked and how to respond. This works for small apps but degrades into spaghetti code as scale grows.
Crush takes the opposite approach: components are "dumb." As documented in internal/ui/AGENTS.md: *"Components should not handle bubbletea messages directly."* Components only do three things:
1. Receive state changes through exposed methods
2. Return tea.Cmd when side effects are needed
3. Render themselves via a Render method
> Dumb (controlled) component: a component responsible only for presentation, containing no business logic or internal state; it depends entirely on externally provided data and behavior.
The Suggestion-Decision Split
The Dialog interface illustrates the pattern. Its HandleMsg receives a message and returns an Action—a close instruction, a command to run, an error to display. Critically, the Dialog never executes the Action; it only returns it. The main model decides.
Components propose; the main model disposes. This separation makes system behavior highly predictable.
What if a component needs to change its own state? Through exposed methods: the main model calls something like SetContent rather than letting the dialog mutate its own fields inside HandleMsg.
Why accept this apparent extra complexity? Testability and maintainability. When behavior is fully determined by external inputs, unit tests are trivial: call a method, check the return value, verify render output. No message-loop simulation, no hidden inter-component dependencies.
The pattern is used throughout the codebase:
A side benefit: components become highly reusable. Because they don't depend on specific message types or global state, the same component works in entirely different contexts.
The cost is more glue code in the main model—but in large applications, that trade is almost always worth it.
2.3 Cached Rendering Strategy
Scrolling a long conversation can trigger dozens of renders per second. Re-computing layout and styling for every message every time would overwhelm the CPU.
The core idea is simple: if the input hasn't changed, reuse the output. In internal/ui/chat/messages.go, the cachedMessageItem struct stores just three fields: the cached render string, the cached width, and the cached height. getCachedRender validates against width; setCachedRender stores results; clearCache invalidates when content changes.
Capability Composition
Message items combine capabilities by embedding small structs. UserMessageItem embeds three:
> Composition pattern: Go's core reuse mechanism. Instead of class inheritance, small focused structs are embedded into larger ones like building blocks, offering more flexibility than inheritance.
This is Go's classic mixin approach: AssistantMessage may need only caching; UserMessage needs all three; ToolMessage might need caching and highlighting but not focus.
Typical Render flow: check cache → if hit, return; otherwise render, store, return. The cache key is width—in terminal UIs, height is determined by content and width, so the same width plus content yields a deterministic result.
Performance Numbers
The real value isn't single-render speedup but avoiding render spikes. In a 60 FPS loop, each frame has a ~16.67 ms budget. Without caching, heavyweight messages (complex Markdown, code blocks at 2–3 ms each) appearing together cause CPU load spikes and visible stutter. With caching, per-frame load stays stable: only newly visible messages need full rendering; cached ones cost microseconds.
Cache invalidation is handled carefully: when message content updates (streaming AI replies, user edits), clearCache is explicitly called to force recomputation.
Conclusion
Three architectural decisions form Crush's skeleton, and they reinforce each other: centralized state management makes state changes traceable; the Dumb component pattern simplifies interactions and reduces the main model's cognitive load; cached rendering guarantees smooth performance.
The chapter's core insight: UserMessageItem achieves free capability composition by embedding three micro-structs (*cachedMessageItem, *focusableMessageItem, *highlightableMessageItem)—an elegant practice of Go's "composition over inheritance" philosophy.