1# TUI Architecture Patterns from Crush
2
3Extracted from `internal/ui/` of charmbracelet/crush. Every pattern here is battle-tested in production.
4
5## Table of Contents
6
71. [The Centralized Model Pattern](#the-centralized-model-pattern)
82. [Layout System](#layout-system)
93. [Focus Management](#focus-management)
104. [Key Event Routing](#key-event-routing)
115. [Streaming Rendering](#streaming-rendering)
126. [Responsive Design](#responsive-design)
137. [Mouse Handling](#mouse-handling)
148. [Animation System](#animation-system)
159. [Screen Buffer Rendering](#screen-buffer-rendering)
16
17## The Centralized Model Pattern
18
19**Source:** `internal/ui/model/ui.go`
20
21The UI struct is the sole `tea.Model`. It owns all state directly:
22
23```go
24type UI struct {
25 com *common.Common
26 width, height int
27 layout uiLayout
28 state uiState // uiOnboarding | uiInitialize | uiLanding | uiChat
29 focus uiFocusState // uiFocusNone | uiFocusEditor | uiFocusMain
30
31 textarea textarea.Model // bubbles textarea
32 chat *Chat // custom list wrapper
33 dialog *dialog.Overlay // stacked dialog system
34 completions *completions.Completions
35 attachments *attachments.Attachments
36 status *Status
37 header *header
38 // ... more fields
39}
40```
41
42Sub-components do NOT implement `tea.Model`. They expose imperative methods:
43
44```go
45// Chat has no Update method. The parent calls these directly:
46m.chat.HandleMouseDown(x, y)
47m.chat.ScrollBy(n)
48m.chat.SetMessages(items...)
49m.chat.Animate(msg)
50
51// Completions has a non-standard Update returning bool (consumed?):
52consumed := m.completions.Update(msg)
53
54// Sidebar is just a render method on UI:
55m.drawSidebar(scr, layout.sidebar)
56```
57
58The Update method is one large switch statement routing all messages:
59
60```go
61func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62 var cmds []tea.Cmd
63 switch msg := msg.(type) {
64 case tea.WindowSizeMsg:
65 m.width, m.height = msg.Width, msg.Height
66 m.updateLayoutAndSize()
67 case tea.KeyPressMsg:
68 if cmd := m.handleKeyPressMsg(msg); cmd != nil { cmds = append(cmds, cmd) }
69 case pubsub.Event[message.Message]:
70 // route to chat
71 case pubsub.Event[permission.PermissionRequest]:
72 // open dialog
73 case anim.StepMsg:
74 // route to chat animation
75 case spinner.TickMsg:
76 // route to spinner
77 // ... 30+ message types
78 }
79 return m, tea.Batch(cmds...)
80}
81```
82
83**Why not standard Elm per-component?** When you have 10+ components that share state (focus, session, layout), message forwarding becomes a maze. The centralized model keeps all transitions in one place. Components become simple render+method structs.
84
85## Layout System
86
87**Source:** `internal/ui/model/ui.go`
88
89Layout uses `image.Rectangle` from Go's standard library via ultraviolet's layout helpers:
90
91```go
92type uiLayout struct {
93 area uv.Rectangle // full terminal
94 header uv.Rectangle
95 main uv.Rectangle // chat area
96 pills uv.Rectangle // todo pills
97 editor uv.Rectangle // text input
98 sidebar uv.Rectangle // session details (non-compact)
99 status uv.Rectangle // help/status bar
100 sessionDetails uv.Rectangle // compact mode overlay
101}
102```
103
104Layout is computed in `generateLayout()` based on state. Different states get different layouts:
105
106```go
107func (m *UI) generateLayout(w, h int) uiLayout {
108 area := image.Rect(0, 0, w, h)
109 helpHeight := 1
110 editorHeight := 5
111 sidebarWidth := 30
112
113 // Add margins
114 appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
115 appRect.Min.Y += 1; appRect.Max.Y -= 1
116 appRect.Min.X += 1; appRect.Max.X -= 1
117
118 switch m.state {
119 case uiChat:
120 if m.isCompact {
121 // Compact: header | main | editor | help (no sidebar)
122 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(1))
123 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
124 } else {
125 // Full: main+editor | sidebar, with help bar at bottom
126 mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
127 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
128 }
129 case uiLanding:
130 // header | main | editor | help
131 case uiOnboarding, uiInitialize:
132 // header | main | help
133 }
134}
135```
136
137Key pattern: `layout.SplitVertical` and `layout.SplitHorizontal` take an area and a constraint (`layout.Fixed(n)`) and return two non-overlapping rectangles.
138
139After computing layout, `updateSize()` propagates sizes to child components:
140
141```go
142func (m *UI) updateSize() {
143 m.status.SetWidth(m.layout.status.Dx())
144 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
145 m.textarea.SetWidth(m.layout.editor.Dx())
146 m.textarea.SetHeight(m.layout.editor.Dy() - 2) // account for margins
147}
148```
149
150`updateLayoutAndSize()` is called on every `WindowSizeMsg` and state change.
151
152## Focus Management
153
154**Source:** `internal/ui/model/ui.go`
155
156Focus is a simple enum:
157
158```go
159type uiFocusState uint8
160const (
161 uiFocusNone uiFocusState = iota
162 uiFocusEditor // keys go to textarea
163 uiFocusMain // keys go to chat list
164)
165```
166
167Tab switches focus. Focus determines:
1681. Where key events route
1692. Which cursor shows
1703. Which component gets highlight/border styling
171
172```go
173// In the key handler:
174case key.Matches(msg, m.keyMap.Tab):
175 if m.focus == uiFocusEditor {
176 m.focus = uiFocusMain
177 } else {
178 m.focus = uiFocusEditor
179 }
180```
181
182Cursor position is returned from `Draw()` based on focus:
183
184```go
185func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
186 // ... draw all components ...
187 if m.dialog.HasDialogs() {
188 return m.dialog.Draw(scr, scr.Bounds()) // dialog cursor takes priority
189 }
190 switch m.focus {
191 case uiFocusEditor:
192 if m.textarea.Focused() {
193 cur := m.textarea.Cursor()
194 cur.X += 1 // app margin
195 cur.Y += m.layout.editor.Min.Y + 1 // editor position + attachment row
196 return cur
197 }
198 }
199 return nil
200}
201```
202
203## Key Event Routing
204
205**Source:** `internal/ui/model/keys.go`, `internal/ui/model/ui.go`
206
207Keys are defined as `key.Binding` structs organized in a `KeyMap`:
208
209```go
210type KeyMap struct {
211 Quit key.Binding
212 Help key.Binding
213 Tab key.Binding
214 Commands key.Binding
215 Models key.Binding
216 Editor EditorKeyMap
217 Chat ChatKeyMap
218}
219```
220
221The key handler delegates based on state and focus:
222
223```go
224func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
225 // Dialogs intercept first
226 if m.dialog.HasDialogs() {
227 return m.handleDialogMsg(msg)
228 }
229 // Global keys (quit, help, commands, models)
230 // Then state-specific:
231 switch m.state {
232 case uiChat:
233 switch m.focus {
234 case uiFocusEditor:
235 return m.handleEditorKey(msg)
236 case uiFocusMain:
237 return m.handleChatKey(msg)
238 }
239 }
240}
241```
242
243Keyboard enhancements are detected and key help is updated dynamically:
244
245```go
246case tea.KeyboardEnhancementsMsg:
247 m.keyenh = msg
248 if msg.SupportsKeyDisambiguation() {
249 m.keyMap.Models.SetHelp("ctrl+m", "models")
250 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
251 }
252```
253
254## Streaming Rendering
255
256**Source:** `internal/agent/agent.go`, `internal/ui/model/ui.go`
257
258The agent loop runs in a goroutine. It uses fantasy's streaming callbacks:
259
260```go
261result, err := agent.Stream(ctx, fantasy.AgentStreamCall{
262 OnTextDelta: func(id string, text string) error {
263 currentAssistant.AppendContent(text)
264 return a.messages.Update(ctx, *currentAssistant) // persists and publishes
265 },
266 // ... other callbacks
267})
268```
269
270`messages.Update()` triggers a pubsub event. The app converts pubsub events to `tea.Msg` via a channel that bubbletea reads. The UI receives `pubsub.Event[message.Message]` and updates the chat:
271
272```go
273case pubsub.Event[message.Message]:
274 switch msg.Type {
275 case pubsub.UpdatedEvent:
276 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
277 }
278```
279
280The chat item re-renders its cached content on update. Glamour renders markdown in real-time as chunks arrive.
281
282The chat has a `follow` flag - when true (user hasn't scrolled up), it auto-scrolls to bottom on every new content:
283
284```go
285if m.chat.Follow() {
286 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
287 cmds = append(cmds, cmd)
288 }
289}
290```
291
292## Responsive Design
293
294**Source:** `internal/ui/model/ui.go`
295
296Compact mode triggers automatically at breakpoints:
297
298```go
299const (
300 compactModeWidthBreakpoint = 120
301 compactModeHeightBreakpoint = 30
302)
303
304func (m *UI) updateLayoutAndSize() {
305 if m.state == uiChat {
306 if m.forceCompactMode {
307 m.isCompact = true
308 return
309 }
310 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
311 m.isCompact = true
312 } else {
313 m.isCompact = false
314 }
315 }
316 m.layout = m.generateLayout(m.width, m.height)
317 m.updateSize()
318}
319```
320
321Compact mode removes the sidebar and replaces it with a compact header. Users can also force compact mode via config (`options.tui.compact_mode`).
322
323Text width is capped for readability:
324
325```go
326const maxTextWidth = 120 // in chat/messages.go
327```
328
329## Mouse Handling
330
331**Source:** `internal/ui/model/ui.go`
332
333Mouse events are handled per-type with coordinate translation:
334
335```go
336case tea.MouseClickMsg:
337 // Dialogs first
338 if m.dialog.HasDialogs() {
339 m.dialog.Update(msg)
340 return m, tea.Batch(cmds...)
341 }
342 // Focus click (editor vs chat)
343 if cmd := m.handleClickFocus(msg); cmd != nil { ... }
344 // Translate to chat-local coordinates
345 x := msg.X - m.layout.main.Min.X
346 y := msg.Y - m.layout.main.Min.Y
347 if handled, cmd := m.chat.HandleMouseDown(x, y); handled { ... }
348
349case tea.MouseWheelMsg:
350 switch msg.Button {
351 case tea.MouseWheelUp:
352 m.chat.ScrollByAndAnimate(-MouseScrollThreshold)
353 case tea.MouseWheelDown:
354 m.chat.ScrollByAndAnimate(MouseScrollThreshold)
355 }
356```
357
358The chat tracks drag state for text selection with double/triple click detection:
359
360```go
361case tea.MouseReleaseMsg:
362 if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
363 // Delay to detect double-click vs single-click
364 cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
365 if time.Since(m.lastClickTime) >= doubleClickThreshold {
366 return copyChatHighlightMsg{}
367 }
368 return nil
369 }))
370 }
371```
372
373## Animation System
374
375**Source:** `internal/ui/anim/anim.go`
376
377Animations use a `StepMsg` tick message. The chat routes it to animatable items:
378
379```go
380case anim.StepMsg:
381 if m.state == uiChat {
382 if cmd := m.chat.Animate(msg); cmd != nil {
383 cmds = append(cmds, cmd)
384 }
385 if m.chat.Follow() {
386 m.chat.ScrollToBottomAndAnimate()
387 }
388 }
389```
390
391Items that support animation implement the `Animatable` interface:
392
393```go
394type Animatable interface {
395 StartAnimation() tea.Cmd
396 Animate(msg anim.StepMsg) tea.Cmd
397}
398```
399
400Scrolling is animated - methods like `ScrollByAndAnimate()` and `ScrollToBottomAndAnimate()` smooth the transition.
401
402## Screen Buffer Rendering
403
404**Source:** `internal/ui/model/ui.go`
405
406The View() -> Draw() pipeline:
407
408```go
409func (m *UI) View() tea.View {
410 var v tea.View
411 v.AltScreen = true
412 v.BackgroundColor = m.com.Styles.Background
413 v.MouseMode = tea.MouseModeCellMotion
414
415 canvas := uv.NewScreenBuffer(m.width, m.height)
416 v.Cursor = m.Draw(canvas, canvas.Bounds())
417
418 content := canvas.Render()
419 // Normalize newlines, trim trailing spaces
420 v.Content = content
421 return v
422}
423
424func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
425 screen.Clear(scr)
426 switch m.state {
427 case uiChat:
428 if m.isCompact {
429 m.drawHeader(scr, layout.header)
430 } else {
431 m.drawSidebar(scr, layout.sidebar)
432 }
433 m.chat.Draw(scr, layout.main)
434 uv.NewStyledString(m.renderEditorView(editorWidth)).Draw(scr, layout.editor)
435 }
436 // Status bar
437 m.status.Draw(scr, layout.status)
438 // Completions popup (overlays)
439 if m.completionsOpen { ... }
440 // Dialogs last (always on top)
441 if m.dialog.HasDialogs() {
442 return m.dialog.Draw(scr, scr.Bounds())
443 }
444 // Return cursor based on focus
445}
446```
447
448The screen buffer handles:
449- Z-ordering (components drawn later overlay earlier ones)
450- Cursor from the last-drawn focused component
451- Efficient diffing (bubbletea handles this)
452
453Terminal progress bar is enabled for supported terminals:
454
455```go
456if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
457 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
458}
459```