component-composition.md

  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).