| name | tui-component-design |
| description | Best practices for building maintainable, testable TUI components using Bubbletea v2 and the Charm ecosystem. Covers component organization, state management, async operations, visual modes, and common pitfalls. |
| compatibility | claude |
TUI Component Design Patterns
Best practices for building maintainable, testable TUI components using Bubbletea v2 and the Charm ecosystem, based on the hive diff viewer implementation.
Component Organization
Single Responsibility Per File
Each component should be in its own file with clear boundaries:
internal/tui/diff/
├── model.go # Top-level compositor that orchestrates sub-components
├── diffviewer.go # Diff content display with scrolling and selection
├── filetree.go # File navigation tree with expand/collapse
├── lineparse.go # Pure function utilities for parsing diff lines
├── delta.go # External tool integration (syntax highlighting)
└── utils.go # Shared utilities
Key principle: Each file should represent ONE component with its own Model, Update, and View methods.
Component Hierarchy Pattern
For complex UIs, use a compositor pattern:
type Model struct {
fileTree FileTreeModel
diffViewer DiffViewerModel
focused FocusedPanel
helpDialog *components.HelpDialog
showHelp bool
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}
Benefits:
- Each sub-component is independently testable
- Clear ownership of state and behavior
- Easy to reason about message flow
Component Structure
Standard Component Template
type ComponentModel struct {
items []Item
selected int
offset int
width int
height int
iconStyle IconStyle
expanded bool
}
func NewComponent(data []Item, cfg *config.Config) ComponentModel {
return ComponentModel{
items: data,
selected: 0,
iconStyle: determineIconStyle(cfg),
}
}
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
func (m ComponentModel) View() string {
return m.render()
}
func (m ComponentModel) render() string {
}
State Management
Avoid Hidden State
Bad:
var currentSelection int
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
currentSelection++
}
Good:
type Model struct {
currentSelection int
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.currentSelection++
return m, nil
}
Separate UI State from Data
type DiffViewerModel struct {
file *gitdiff.File
content string
lines []string
offset int
cursorLine int
selectionMode bool
selectionStart int
}
Benefits:
- Easy to test rendering at different scroll positions
- Data can be shared/cached without UI state interference
- Clear separation of concerns
Async Operations and Caching
Pattern: Command-Based Async with Caching
For expensive operations like syntax highlighting or external tool calls:
type ComponentModel struct {
cache map[string]*CachedResult
loading bool
}
func (m *ComponentModel) SetData(data *Data) tea.Cmd {
filePath := data.Path
if cached, ok := m.cache[filePath]; ok {
m.content = cached.content
m.lines = cached.lines
return nil
}
m.loading = true
return func() tea.Msg {
content, lines := generateContent(data)
return contentGeneratedMsg{filePath, content, lines}
}
}
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case contentGeneratedMsg:
m.cache[msg.filePath] = &CachedResult{
content: msg.content,
lines: msg.lines,
}
m.content = msg.content
m.lines = msg.lines
m.loading = false
}
return m, nil
}
Key points:
- Never block the UI thread
- Cache expensive computations
- Show loading state while processing
- Custom messages for async results
External Tool Integration
For tools like delta (syntax highlighting):
func NewDiffViewer(file *gitdiff.File) DiffViewerModel {
deltaAvailable := CheckDeltaAvailable() == nil
return DiffViewerModel{
deltaAvailable: deltaAvailable,
}
}
func generateDiffContent(file *gitdiff.File, deltaAvailable bool) (string, []string) {
diff := buildUnifiedDiff(file)
if !deltaAvailable {
return diff, strings.Split(diff, "\n")
}
return applyDelta(diff)
}
func (m *ComponentModel) loadContent(file *gitdiff.File) tea.Cmd {
return func() tea.Msg {
content, lines := generateDiffContent(file, m.deltaAvailable)
return contentReadyMsg{content, lines}
}
}
Visual Modes and Complex Interactions
Mode-Based Keybindings
For vim-style interfaces with normal/visual modes:
type Model struct {
mode Mode
selectionMode bool
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.Code == 'v' && !m.selectionMode {
m.selectionMode = true
m.selectionStart = m.cursorLine
return m, nil
}
if msg.Code == tea.KeyEscape && m.selectionMode {
m.selectionMode = false
return m, nil
}
if m.selectionMode {
return m.handleVisualMode(msg)
}
return m.handleNormalMode(msg)
}
return m, nil
}
Selection State Management
For visual selection (highlighting lines):
type Model struct {
selectionMode bool
selectionStart int
cursorLine int
}
func (m Model) SelectionRange() (start, end int, active bool) {
if !m.selectionMode {
return 0, 0, false
}
start = m.selectionStart
end = m.cursorLine
if start > end {
start, end = end, start
}
return start, end, true
}
func (m Model) View() string {
start, end, active := m.SelectionRange()
for i, line := range m.lines {
if active && i >= start && i <= end {
line = highlightStyle.Render(line)
}
}
}
Scroll Management
Viewport Pattern
For scrollable content with fixed dimensions:
type Model struct {
lines []string
offset int
height int
}
func (m Model) visibleLines() []string {
start := m.offset
end := min(m.offset + m.contentHeight(), len(m.lines))
return m.lines[start:end]
}
func (m Model) contentHeight() int {
return m.height - headerHeight - footerHeight
}
func (m Model) scrollDown() Model {
if m.cursorLine < len(m.lines)-1 {
m.cursorLine++
}
visibleBottom := m.offset + m.contentHeight() - 1
if m.cursorLine > visibleBottom {
m.offset++
}
return m
}
Key principle: Cursor moves first, viewport follows to keep cursor visible.
Editor Integration
Opening External Editors
Pattern for jumping to specific line in editor:
func (m Model) openInEditor(filePath string, lineNum int) tea.Cmd {
return func() tea.Msg {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
arg := fmt.Sprintf("+%d", lineNum)
cmd := exec.Command(editor, arg, filePath)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
return editorFinishedMsg{err: err}
}
}
Critical: For vim/interactive editors, you must connect stdin/stdout/stderr or the editor won't work properly.
Component Communication
Message-Based Coordination
type (
fileSelectedMsg struct {
file *gitdiff.File
}
diffLoadedMsg struct {
content string
}
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case fileSelectedMsg:
return m, m.diffViewer.LoadFile(msg.file)
}
var cmd tea.Cmd
m.fileTree, cmd = m.fileTree.Update(msg)
return m, cmd
}
Focus Management
type FocusedPanel int
const (
FocusFileTree FocusedPanel = iota
FocusDiffViewer
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == tea.KeyTab {
m.focused = (m.focused + 1) % 2
return m, nil
}
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}
Helper Modal Pattern
For overlays like help dialogs:
type Model struct {
helpDialog *components.HelpDialog
showHelp bool
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if m.showHelp {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = false
return m, nil
}
*m.helpDialog, cmd = m.helpDialog.Update(msg)
return m, cmd
}
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = true
return m, nil
}
}
func (m Model) View() string {
view := m.renderNormal()
if m.showHelp {
return m.helpDialog.View(view)
}
return view
}
Common Pitfalls
❌ Modifying State Outside Update
func (m Model) View() string {
m.offset++
return m.render()
}
View must be pure - no side effects!
❌ Blocking Operations in Update
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
content := os.ReadFile("large-file.txt")
m.content = string(content)
return m, nil
}
Use commands for I/O.
❌ Complex Logic in Update
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
}
}
Extract to helper methods:
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
}
}
func (m Model) handleKeyPress(msg tea.KeyPressMsg) (Model, tea.Cmd) {
}
Summary
- One component per file with clear boundaries
- Compositor pattern for complex UIs (parent coordinates, children handle specifics)
- All state in Model - no hidden variables
- Commands for async - never block Update
- Cache expensive operations - external tools, rendering
- Mode-based behavior for complex interactions (vim-style)
- Focus management for multi-panel UIs
- Extract helper methods - keep Update readable
- Pure View - no side effects, deterministic output
- Message-based coordination - components communicate via messages