tui-patterns.md

  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```