1# Component Composition Patterns from Crush
2
3How Crush composes bubbletea + lipgloss + bubbles + ultraviolet into a production multi-pane TUI.
4
5## Table of Contents
6
71. [Interface Hierarchy](#interface-hierarchy)
82. [Chat Item System](#chat-item-system)
93. [Dialog Stack](#dialog-stack)
104. [List Component](#list-component)
115. [Shared Context Pattern](#shared-context-pattern)
126. [Tool Renderer Factory](#tool-renderer-factory)
137. [Completions Popup](#completions-popup)
148. [Notification System](#notification-system)
15
16## Interface Hierarchy
17
18**Source:** `internal/ui/list/item.go`, `internal/ui/chat/messages.go`, `internal/ui/chat/tools.go`
19
20Crush builds a layered interface system through composition, not inheritance:
21
22```
23list.Item (base)
24 Render(width int) string
25
26list.RawRenderable
27 RawRender(width int) string
28
29list.Focusable
30 SetFocused(focused bool)
31
32list.Highlightable
33 SetHighlight(startLine, startCol, endLine, endCol int)
34 Highlight() (startLine, startCol, endLine, endCol int)
35
36list.MouseClickable
37 HandleMouseClick(btn MouseButton, x, y int) bool
38```
39
40Chat extends these:
41
42```
43chat.MessageItem = list.Item + list.RawRenderable + Identifiable
44 ID() string
45
46chat.ToolMessageItem extends MessageItem with:
47 ToolCall() message.ToolCall
48 ToolResult() *message.ToolResult
49 MessageID() string
50 SetToolResult(result *message.ToolResult)
51 ToolStatus() string
52```
53
54Opt-in capabilities (components implement only what they need):
55
56```
57chat.Animatable
58 StartAnimation() tea.Cmd
59 Animate(msg anim.StepMsg) tea.Cmd
60
61chat.Expandable
62 ToggleExpanded() bool
63
64chat.Compactable
65 SetCompact(compact bool)
66
67chat.KeyEventHandler
68 HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd)
69
70chat.NestedToolContainer
71 SetNestedTools(tools []ToolMessageItem)
72```
73
74This design lets the Chat and List check capabilities at runtime:
75
76```go
77// In UI.Update when handling animation:
78for _, item := range items {
79 if animatable, ok := item.(chat.Animatable); ok {
80 if cmd := animatable.StartAnimation(); cmd != nil {
81 cmds = append(cmds, cmd)
82 }
83 }
84}
85
86// In rendering, check if item supports compact mode:
87if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
88 simplifiable.SetCompact(true)
89}
90```
91
92## Chat Item System
93
94**Source:** `internal/ui/chat/`
95
96The Chat wraps a `list.List` and provides message-specific behavior. Items are created by `ExtractMessageItems()` which parses a `message.Message` into one or more `MessageItem`s.
97
98### Message Types
99
100| File | Handles | Key Feature |
101|------|---------|-------------|
102| `chat/user.go` | User messages | Shows input text + attachments |
103| `chat/assistant.go` | Assistant text, thinking, errors | Streaming markdown, reasoning blocks, info footer |
104| `chat/bash.go` | Bash, JobOutput, JobKill | Command display, output truncation, job tracking |
105| `chat/file.go` | View, Write, Edit, MultiEdit, Download | Diff rendering, file path display |
106| `chat/search.go` | Glob, Grep, LS, Sourcegraph | Result lists with truncation |
107| `chat/fetch.go` | Fetch, WebFetch, WebSearch | URL display, content preview |
108| `chat/agent.go` | Agent, AgenticFetch | Nested tool containers |
109| `chat/mcp.go` | MCP tools (mcp_ prefix) | Generic tool rendering with server name |
110| `chat/generic.go` | Fallback | Any unrecognized tool |
111| `chat/diagnostics.go` | LSP diagnostics | Error/warning lists |
112| `chat/todos.go` | Todo lists | Checkbox rendering |
113
114### Cached Rendering
115
116Items cache their rendered output and invalidate when data changes. This is critical because the list only renders visible items (lazy rendering), but those items may be re-rendered on every frame during streaming.
117
118```go
119// Pattern from chat/messages.go - embedded cache struct
120type cachedMessageItem struct {
121 cachedRender string
122 cachedWidth int
123 dirty bool
124}
125
126func (c *cachedMessageItem) invalidate() {
127 c.dirty = true
128}
129
130func (c *cachedMessageItem) render(width int, renderFn func(int) string) string {
131 if !c.dirty && c.cachedWidth == width {
132 return c.cachedRender
133 }
134 c.cachedRender = renderFn(width)
135 c.cachedWidth = width
136 c.dirty = false
137 return c.cachedRender
138}
139```
140
141### Tool Renderer Factory
142
143**Source:** `internal/ui/chat/tools.go`
144
145`NewToolMessageItem` is a central factory routing tool names to specific types:
146
147```go
148func NewToolMessageItem(sty *styles.Styles, msg *message.Message, tc message.ToolCall, result *message.ToolResult) ToolMessageItem {
149 switch tc.Name {
150 case "bash":
151 return newBashItem(sty, msg, tc, result)
152 case "edit", "multiedit":
153 return newEditItem(sty, msg, tc, result)
154 case "view":
155 return newViewItem(sty, msg, tc, result)
156 // ... etc
157 default:
158 if strings.HasPrefix(tc.Name, "mcp_") {
159 return newMCPItem(sty, msg, tc, result)
160 }
161 return newGenericItem(sty, msg, tc, result)
162 }
163}
164```
165
166Each tool renderer implements `RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string` which produces the styled output.
167
168## Dialog Stack
169
170**Source:** `internal/ui/dialog/dialog.go`
171
172Dialogs are an overlay system managed by `dialog.Overlay`:
173
174```go
175type Dialog interface {
176 ID() string
177 HandleMsg(msg tea.Msg) Action // returns typed action
178 Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
179}
180
181type Overlay struct {
182 dialogs []Dialog // stack
183}
184
185func (d *Overlay) OpenDialog(dialog Dialog) // push
186func (d *Overlay) CloseFrontDialog() // pop
187func (d *Overlay) ContainsDialog(id string) bool // check
188func (d *Overlay) HasDialogs() bool // any open?
189```
190
191Dialog implementations in `internal/ui/dialog/`:
192
193| Dialog | ID | Purpose |
194|--------|----|---------|
195| `Models` | "models" | Model picker with provider groups |
196| `Sessions` | "sessions" | Session browser with search |
197| `Commands` | "commands" | Slash command picker |
198| `Permissions` | "permissions" | Tool approval with diff view |
199| `APIKeyInput` | "api_key_input" | API key entry |
200| `OAuthCopilot` | "oauth_copilot" | GitHub Copilot OAuth flow |
201| `FilePicker` | "filepicker" | File browser |
202| `Reasoning` | "reasoning" | Extended thinking view |
203| `Quit` | "quit" | Quit confirmation |
204| `Arguments` | "arguments" | Skill argument input |
205
206### Dialog Actions
207
208Dialogs return typed `Action` values that the parent UI handles:
209
210```go
211// In UI.handleDialogMsg:
212action := m.dialog.Update(msg)
213switch a := action.(type) {
214case dialog.ModelSelectedAction:
215 // user picked a model
216case dialog.PermissionAction:
217 // user allowed/denied tool
218case dialog.SessionSelectedAction:
219 // user switched session
220}
221```
222
223This decouples dialog logic from the parent. Dialogs don't need to know about the UI - they just return what happened.
224
225### Dialog Rendering
226
227Dialogs use `RenderContext` for consistent layout:
228
229```go
230rc := dialog.NewRenderContext(styles, width)
231rc.Title = "Select Model"
232rc.Parts = []string{modelList, helpText}
233rc.Help = helpView
234rendered := rc.Render()
235```
236
237`RenderContext` handles title gradient, content alignment, help bar positioning, and onboarding-mode layout (bottom-left instead of centered).
238
239## List Component
240
241**Source:** `internal/ui/list/list.go` (650 lines)
242
243A custom lazy-rendered scrollable list. Not the standard bubbles list - this is crush-specific.
244
245Key design decisions:
246- **Lazy rendering**: only visible items are rendered
247- **No internal cache**: items cache their own rendering (see cached render pattern above)
248- **Scroll by lines, not items**: viewport tracks `offsetIdx` (first visible item) + `offsetLine` (lines scrolled within that item)
249- **Render callbacks**: the parent can modify items before rendering via `RegisterRenderCallback()`
250
251```go
252type List struct {
253 width, height int
254 items []Item
255 gap int
256 reverse bool
257 focused bool
258 selectedIdx int
259 offsetIdx int
260 offsetLine int
261 renderCallbacks []func(idx, selectedIdx int, item Item) Item
262}
263```
264
265The `Render()` method walks visible items, renders each to a string, joins with gap, and truncates to viewport height. This is the string that Chat then draws onto the ultraviolet screen buffer.
266
267The list supports both forward and reverse rendering (`reverse` flag) for chat-style bottom-up layouts.
268
269## Shared Context Pattern
270
271**Source:** `internal/ui/common/common.go`
272
273```go
274type Common struct {
275 App *app.App
276 Styles *styles.Styles
277}
278
279func (c *Common) Config() *config.Config { return c.App.Config() }
280func (c *Common) Store() *config.ConfigStore { ... }
281```
282
283Every component that needs styles or app access receives `*common.Common` at creation:
284
285```go
286ch := NewChat(com) // chat
287status := NewStatus(com, ui) // status bar
288header := newHeader(com) // header
289```
290
291This avoids passing individual dependencies and makes it easy to add new shared state.
292
293## Completions Popup
294
295**Source:** `internal/ui/completions/completions.go`
296
297The `@` completions popup shows files and MCP resources. It's positioned relative to the cursor:
298
299```go
300// In UI.Draw():
301if m.completionsOpen && m.completions.HasItems() {
302 w, h := m.completions.Size()
303 x := m.completionsPositionStart.X
304 y := m.completionsPositionStart.Y - h // above cursor
305
306 // Keep within screen bounds
307 if x+w > screenW { x = screenW - w }
308 x = max(0, x)
309 y = max(0, y+1)
310
311 completionsView := uv.NewStyledString(m.completions.Render())
312 completionsView.Draw(scr, image.Rectangle{
313 Min: image.Pt(x, y),
314 Max: image.Pt(x+w, y+h),
315 })
316}
317```
318
319The completions component has its own key handling that returns whether it consumed the event:
320
321```go
322// Non-standard Update signature
323func (c *Completions) Update(msg tea.Msg) bool {
324 // returns true if it handled the key (consumed)
325}
326```
327
328Items are loaded asynchronously via `completions.CompletionItemsLoadedMsg`.
329
330## Notification System
331
332**Source:** `internal/ui/notification/`
333
334Desktop notifications are sent when:
335- A tool needs permission approval
336- The agent finishes its turn
337- But ONLY when the terminal window is unfocused AND the terminal supports focus reporting
338
339```go
340func (m *UI) shouldSendNotification() bool {
341 if cfg.Options.DisableNotifications { return false }
342 return m.caps.ReportFocusEvents && !m.notifyWindowFocused
343}
344```
345
346Focus tracking uses bubbletea's `tea.FocusMsg` / `tea.BlurMsg`:
347
348```go
349case tea.FocusMsg:
350 m.notifyWindowFocused = true
351case tea.BlurMsg:
352 m.notifyWindowFocused = false
353```
354
355The notification backend is swapped based on terminal capability:
356
357```go
358case tea.ModeReportMsg:
359 if m.caps.ReportFocusEvents {
360 m.notifyBackend = notification.NewNativeBackend(notification.Icon)
361 }
362```
363
364If the terminal doesn't support focus reporting, notifications stay disabled (NoopBackend).