English static mirror for SEO/GEO · AI-assisted translation · Read Chinese original

Crush Architecture Deep Dive: Centralized State, Dumb Components, and Cached Rendering

Forum topic · 小凯 · 2026-03-04

Summary

Chapter 2 of an in-depth series analyzing the architecture of Crush, a terminal-based AI coding assistant built with Bubble Tea. The article examines three core architectural decisions. First, the main model: the UI struct acts as an air-traffic-control tower with centralized state management, using a two-layer state machine (application state and focus state) and a large Update method that routes typed messages via Go's type switch, delegating complex work to dedicated methods. Second, the Dumb component pattern: components never handle Bubble Tea messages directly; they expose setter methods, return tea.Cmd for side effects, and return Action values from HandleMsg that only the main model executes—a suggestion-decision split that improves testability and reusability. Third, cached rendering: message items embed small capability structs (cachedMessageItem, focusableMessageItem, highlightableMessageItem) following Go's composition-over-inheritance philosophy; render results are cached by width, cutting per-message cost from 0.1–1 ms to 0.001–0.01 ms (100–1000x speedup) with only a few MB of memory, ensuring stable frame times during scrolling. Code references include internal/ui/model/ui.go and internal/ui/chat/messages.go.

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), and uiChat (the core conversation view). These four states form the main user journey.
  • Focus state: uiFocusEditor (typing in the editor), uiFocusMain (navigating message history), and uiFocusNone. Focus determines where keyboard events are routed and which visuals get highlighted.
  • > 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:

  • Chat: SetMessages(), ScrollToBottom()
  • Status: SetError(), SetWarning()
  • Attachments: Add(), Remove()
  • 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:

  • *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 a highlighter render function, mainly for showing which user code an AI reply references
  • > 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

  • 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
Memory: at ~2 KB per rendered message (including ANSI codes), 100 messages ≈ 200 KB; 1,000 messages ≈ 2 MB—trivial for modern machines.

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.

Tags

#crush#go#bubbletea#architecture#state-management#component-design#rendering-optimization#tui

This page is an English static mirror generated for search and AI citation. It may be a full translation or structured summary of the Chinese original. Canonical interactive discussion lives on the Chinese page: https://zhichai.net/topic/177168683