48 KiB
Charm.sh Ecosystem - Personal Cheat Sheet
Personal reference for building TUI applications with the Charm stack.
📦 Core Libraries Overview
Core Packages
bubbletea: Event-driven TUI framework (MVU pattern)lipgloss: Styling and layout enginebubbles: Pre-built components (viewport, textinput, etc.)huh: Advanced form builderglamour: Markdown renderer
🫧 BubbleTea (MVU Pattern)
Model-View-Update Lifecycle
// Model holds all state
type Model struct {
content string
ready bool
}
// Update handles events and returns new state
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// Handle resize
return m, nil
case tea.KeyMsg:
// Handle keyboard input
return m, nil
}
return m, nil
}
// View renders current state
func (m Model) View() string {
return "content"
}
// Init returns initial command
func (m Model) Init() tea.Cmd {
return nil
}
Commands and Messages
// Commands return future messages
func loadDataCmd() tea.Msg {
return DataLoadedMsg{data: "loaded"}
}
// Async operations
return m, tea.Cmd(func() tea.Msg {
time.Sleep(time.Second)
return TimerMsg{}
})
Critical Patterns
// Model interface implementation
type Model struct {
styles *styles.Styles // ALWAYS use shared styles
}
func (m Model) Init() tea.Cmd {
// ALWAYS reset state completely
m.content = ""
m.ready = false
return m.loadContent
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// NEVER store dimensions in model - use styles.SetSize()
// Model gets dimensions via m.styles.GetSize()
case tea.KeyMsg:
switch msg.String() {
case "enter": return m, navigateCmd
}
}
}
🎨 Lipgloss (Styling & Layout)
Purpose: CSS-like styling for terminal interfaces Key Insight: Height() vs MaxHeight() behavior difference!
Critical Height Control
// ❌ WRONG: Height() sets MINIMUM height (can expand!)
style := lipgloss.NewStyle().Height(1).Border(lipgloss.NormalBorder())
// ✅ CORRECT: MaxHeight() + Inline() for EXACT height
style := lipgloss.NewStyle().MaxHeight(1).Inline(true)
// ✅ PRODUCTION: Background approach for consistent 1-line footers
footer := lipgloss.NewStyle().
Width(width).
Background(borderColor).
Foreground(textColor).
Padding(0, 1, 0, 1). // Only horizontal padding
Render(text)
// FOOTER APPROACH - PRODUCTION READY (✅ PROVEN SOLUTION)
// ❌ WRONG: Border approach (inconsistent height)
style.BorderTop(true).Height(1)
// ✅ CORRECT: Background approach (always 1 line)
style.Background(color).Foreground(textColor).Padding(0,1,0,1)
Layout Patterns
// LAYOUT COMPOSITION
lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
lipgloss.JoinHorizontal(lipgloss.Top, left, right)
lipgloss.Place(width, height, lipgloss.Center, lipgloss.Top, content)
// Horizontal layout
left := lipgloss.NewStyle().Width(leftWidth).Render(leftContent)
right := lipgloss.NewStyle().Width(rightWidth).Render(rightContent)
combined := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// Vertical layout with consistent spacing
sections := []string{header, content, footer}
combined := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Centering content
centered := lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
// Responsive design
verticalStyle := lipgloss.NewStyle().Width(width).Padding(0, 2, 0, 2)
if width < 80 {
// Vertical layout for narrow screens
}
Responsive Patterns
// Breakpoint-based layout
width, height := m.styles.GetSize() // ALWAYS from styles
if width < 80 {
return lipgloss.JoinVertical(lipgloss.Left, panels...)
} else {
return lipgloss.JoinHorizontal(lipgloss.Top, panels...)
}
// Dynamic width allocation
leftWidth := width / 3
rightWidth := width - leftWidth - 4
📺 Bubbles (Interactive Components)
Purpose: Pre-built interactive components Key Components: viewport, textinput, list, table
Viewport - Critical for Scrolling
import "github.com/charmbracelet/bubbles/viewport"
// Setup
viewport := viewport.New(width, height)
viewport.Style = lipgloss.NewStyle() // Clean style prevents conflicts
// Modern scroll methods (use these!)
viewport.ScrollUp(1) // Replaces LineUp()
viewport.ScrollDown(1) // Replaces LineDown()
viewport.ScrollLeft(2) // Horizontal, 2 steps for forms
viewport.ScrollRight(2)
// Deprecated (avoid)
vp.LineUp(lines) // ❌ Deprecated
vp.LineDown(lines) // ❌ Deprecated
// Status tracking
viewport.ScrollPercent() // 0.0 to 1.0
viewport.AtBottom() // bool
viewport.AtTop() // bool
// State checking
isScrollable := !(vp.AtTop() && vp.AtBottom())
progress := vp.ScrollPercent()
// Content management
viewport.SetContent(content)
viewport.View() // Renders visible portion
// Update in message handling
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
TextInput
import "github.com/charmbracelet/bubbles/textinput"
ti := textinput.New()
ti.Placeholder = "Enter text..."
ti.Focus()
ti.EchoMode = textinput.EchoPassword // For masked input
ti.CharLimit = 100
📝 Huh (Forms)
Purpose: Advanced form builder for complex user input
import "github.com/charmbracelet/huh"
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("api_key").
Title("API Key").
Password(). // Masked input
Validate(func(s string) error {
if len(s) < 10 {
return errors.New("API key too short")
}
return nil
}),
huh.NewSelect[string]().
Key("provider").
Title("Provider").
Options(
huh.NewOption("OpenAI", "openai"),
huh.NewOption("Anthropic", "anthropic"),
),
),
)
// Integration with bubbletea
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.form, cmd = m.form.Update(msg)
if m.form.State == huh.StateCompleted {
// Form submitted - access values
apiKey := m.form.GetString("api_key")
provider := m.form.GetString("provider")
}
return m, cmd
}
✨ Glamour (Markdown Rendering)
Purpose: Beautiful markdown rendering in terminal CRITICAL: Create renderer ONCE in styles.New(), reuse everywhere
// ✅ CORRECT: Single renderer instance (prevents freezing)
// styles.go
type Styles struct {
renderer *glamour.TermRenderer
}
func New() *Styles {
renderer, _ := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(80),
)
return &Styles{renderer: renderer}
}
func (s *Styles) GetRenderer() *glamour.TermRenderer {
return s.renderer
}
// Usage in models
rendered, err := m.styles.GetRenderer().Render(markdown)
// ❌ WRONG: Creating new renderer each time (can freeze!)
renderer, _ := glamour.NewTermRenderer(...)
Safe Rendering with Fallback
// Safe rendering with fallback
rendered, err := renderer.Render(content)
if err != nil {
// Fallback to plain text
rendered = fmt.Sprintf("# Content\n\n%s\n\n*Render error: %v*", content, err)
}
🏗️ Production Architecture Patterns
1. Centralized Styles & Dimensions
CRITICAL: Never store width/height in models - use styles singleton
// ✅ CORRECT: Centralized in styles
type Styles struct {
width int
height int
renderer *glamour.TermRenderer
// ... all styles
}
func (s *Styles) SetSize(width, height int) {
s.width = width
s.height = height
s.updateStyles() // Recalculate responsive styles
}
func (s *Styles) GetSize() (int, int) {
return s.width, s.height
}
// Models use styles for dimensions
func (m *Model) updateViewport() {
width, height := m.styles.GetSize()
if width <= 0 || height <= 0 {
return // Graceful handling
}
// ... viewport setup
}
2. TUI-Safe Logging System
Problem: fmt.Printf breaks TUI rendering Solution: File-based logger
// logger.Log() writes to log.json
logger.Log("[Component] ACTION: details %v", value)
logger.Errorf("[Component] ERROR: %v", err)
// Development monitoring (separate terminal)
tail -f log.json
// ❌ WRONG: Console output in TUI
fmt.Printf("Debug: %v\n", value) // Breaks rendering
3. Unified Header/Footer Management
// app.go - Central layout control
func (a *App) View() string {
header := a.renderHeader()
footer := a.renderFooter()
content := a.currentModel.View()
contentHeight := max(height - headerHeight - footerHeight, 0)
contentArea := a.styles.Content.Height(contentHeight).Render(content)
return lipgloss.JoinVertical(lipgloss.Left, header, contentArea, footer)
}
func (a *App) renderHeader() string {
switch a.navigator.Current() {
case WelcomeScreen:
return a.styles.RenderASCIILogo()
default:
return a.styles.Header.Render(title)
}
}
func (a *App) renderFooter() string {
actions := []string{}
// Dynamic actions based on screen state
if canContinue {
actions = append(actions, "Enter: Continue")
}
if hasScrollableContent {
actions = append(actions, "↑↓: Scroll")
}
return lipgloss.NewStyle().
Width(width).
Background(borderColor).
Foreground(textColor).
Padding(0, 1, 0, 1).
Render(strings.Join(actions, " • "))
}
// locale.go - Helper functions
func BuildCommonActions() []string {
return []string{NavBack, NavExit}
}
func BuildEULAActions(atEnd bool) []string {
if !atEnd {
return []string{EULANavScrollInstructions}
}
return []string{EULANavAcceptReject}
}
// Usage
actions := locale.BuildCommonActions()
actions = append(actions, specificActions...)
4. Type-Safe Navigation with Composite ScreenIDs
Critical Pattern: Use typed screen IDs with argument support
type ScreenID string
const (
WelcomeScreen ScreenID = "welcome"
EULAScreen ScreenID = "eula"
MainMenuScreen ScreenID = "main_menu"
LLMProviderFormScreen ScreenID = "llm_provider_form"
)
// ScreenID methods for composite support
func (s ScreenID) GetScreen() string {
parts := strings.Split(string(s), "§")
return parts[0]
}
func (s ScreenID) GetArgs() []string {
parts := strings.Split(string(s), "§")
if len(parts) <= 1 {
return []string{}
}
return parts[1:]
}
func CreateScreenID(screen string, args ...string) ScreenID {
if len(args) == 0 {
return ScreenID(screen)
}
parts := append([]string{screen}, args...)
return ScreenID(strings.Join(parts, "§"))
}
type NavigationMsg struct {
Target ScreenID // Can be simple or composite!
GoBack bool
}
// Usage - Simple screen
return m, func() tea.Msg {
return NavigationMsg{Target: EULAScreen}
}
// Usage - Composite screen with arguments
return m, func() tea.Msg {
return NavigationMsg{Target: CreateScreenID("llm_provider_form", "openai")}
}
5. Model State Management
Pattern: Complete reset on Init() for predictable behavior
func (m *Model) Init() tea.Cmd {
logger.Log("[Model] INIT")
// ALWAYS reset ALL state
m.content = ""
m.ready = false
m.scrolled = false
m.scrolledToEnd = false
m.error = nil
return m.loadContent
}
// Force re-render after async operations
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case ContentLoadedMsg:
m.content = msg.Content
m.ready = true
// Force view update
return m, func() tea.Msg { return nil }
}
return m, nil
}
🐛 Key Debugging Techniques
1. TUI-Safe Debug Output
// ❌ NEVER: Breaks TUI rendering
fmt.Println("debug")
log.Println("debug")
// ✅ ALWAYS: File-based logging
logger.Log("[Component] Event: %v", msg)
logger.Log("[Model] UPDATE: key=%s", msg.String())
logger.Log("[Model] VIEWPORT: %dx%d ready=%v", width, height, m.ready)
// Monitor in separate terminal
tail -f log.json
2. Dimension Handling
func (m Model) View() string {
width, height := m.styles.GetSize()
if width <= 0 || height <= 0 {
return "Loading..." // Graceful fallback
}
// ... normal rendering
}
// Log dimension changes
logger.Log("[Model] RESIZE: %dx%d", width, height)
3. Content Loading Debug
func (m *Model) loadContent() tea.Msg {
logger.Log("[Model] LOAD: start")
content, err := source.GetContent()
if err != nil {
logger.Errorf("[Model] LOAD: error: %v", err)
return ErrorMsg{err}
}
logger.Log("[Model] LOAD: success (%d chars)", len(content))
return ContentLoadedMsg{content}
}
🎯 Advanced Navigation with Composite ScreenIDs
Composite ScreenID Pattern
Problem: Need to pass parameters to screens (e.g., which provider to configure)
Solution: Composite ScreenIDs with § separator
// Format: "screen§arg1§arg2§..."
type ScreenID string
// Methods for parsing composite IDs
func (s ScreenID) GetScreen() string {
parts := strings.Split(string(s), "§")
return parts[0]
}
func (s ScreenID) GetArgs() []string {
parts := strings.Split(string(s), "§")
if len(parts) <= 1 {
return []string{}
}
return parts[1:]
}
// Helper for creating composite IDs
func CreateScreenID(screen string, args ...string) ScreenID {
if len(args) == 0 {
return ScreenID(screen)
}
parts := append([]string{screen}, args...)
return ScreenID(strings.Join(parts, "§"))
}
Usage Examples
// Simple screen (no arguments)
welcome := WelcomeScreen // "welcome"
// Composite screen (with arguments)
providerForm := CreateScreenID("llm_provider_form", "openai") // "llm_provider_form§openai"
// Navigation with arguments
return m, func() tea.Msg {
return NavigationMsg{
Target: CreateScreenID("llm_provider_form", "anthropic"),
Data: FormData{ProviderID: "anthropic"},
}
}
// In createModelForScreen - extract arguments
func (a *App) createModelForScreen(screenID ScreenID, data any) tea.Model {
baseScreen := screenID.GetScreen()
args := screenID.GetArgs()
switch ScreenID(baseScreen) {
case LLMProviderFormScreen:
providerID := "openai" // default
if len(args) > 0 {
providerID = args[0]
}
return NewLLMProviderFormModel(providerID, ...)
}
}
State Persistence
// Stack automatically preserves composite IDs
navigator.Push(CreateScreenID("llm_provider_form", "gemini"))
// State contains: ["welcome", "main_menu", "llm_providers", "llm_provider_form§gemini"]
// On restore: user returns to Gemini provider form, not default OpenAI
🎯 Advanced Form Scrolling with Viewport
Auto-Scrolling Forms Pattern
Problem: Forms with many fields don't fit on smaller terminals, focused fields go off-screen Solution: Viewport component with automatic scroll-to-focus behavior
import "github.com/charmbracelet/bubbles/viewport"
type FormModel struct {
fields []FormField
focusedIndex int
viewport viewport.Model
formContent string
fieldHeights []int // Heights of each field for scroll calculation
}
// Initialize viewport
func New() *FormModel {
return &FormModel{
viewport: viewport.New(0, 0),
}
}
// Update viewport dimensions on resize
func (m *FormModel) updateViewport() {
contentWidth, contentHeight := m.getContentSize()
m.viewport.Width = contentWidth - 4 // padding
m.viewport.Height = contentHeight - 2 // header/footer space
m.viewport.SetContent(m.formContent)
}
// Render form content and track field positions
func (m *FormModel) updateFormContent() {
var sections []string
m.fieldHeights = []int{}
for i, field := range m.fields {
fieldHeight := 4 // title + description + input + spacing
m.fieldHeights = append(m.fieldHeights, fieldHeight)
sections = append(sections, field.Title)
sections = append(sections, field.Description)
sections = append(sections, field.Input.View())
sections = append(sections, "") // spacing
}
m.formContent = strings.Join(sections, "\n")
m.viewport.SetContent(m.formContent)
}
// Auto-scroll to focused field
func (m *FormModel) ensureFocusVisible() {
if m.focusedIndex >= len(m.fieldHeights) {
return
}
// Calculate Y position of focused field
focusY := 0
for i := 0; i < m.focusedIndex; i++ {
focusY += m.fieldHeights[i]
}
visibleRows := m.viewport.Height
offset := m.viewport.YOffset
// Scroll up if field is above visible area
if focusY < offset {
m.viewport.YOffset = focusY
}
// Scroll down if field is below visible area
if focusY+m.fieldHeights[m.focusedIndex] >= offset+visibleRows {
m.viewport.YOffset = focusY + m.fieldHeights[m.focusedIndex] - visibleRows + 1
}
}
// Navigation with auto-scroll
func (m *FormModel) focusNext() {
m.fields[m.focusedIndex].Input.Blur()
m.focusedIndex = (m.focusedIndex + 1) % len(m.fields)
m.fields[m.focusedIndex].Input.Focus()
m.updateFormContent()
m.ensureFocusVisible() // Key addition!
}
// Render scrollable form
func (m *FormModel) View() string {
return m.viewport.View() // Viewport handles clipping and scrolling
}
Key Benefits of Viewport Forms
- Automatic Clipping: Viewport handles content that exceeds available space
- Smooth Scrolling: Fields slide into view without jarring jumps
- Focus Preservation: Focused field always remains visible
- No Extra Hotkeys: Uses standard navigation (Tab, arrows)
- Terminal Friendly: Works on any terminal size
Critical Implementation Details
- Field Height Tracking: Must calculate actual rendered height of each field
- Scroll Timing: Call
ensureFocusVisible()after every focus change - Content Updates: Re-render form content when input values change
- Viewport Sizing: Account for padding, headers, footers in size calculation
This pattern is essential for professional TUI applications with complex forms.
⚠️ Common Pitfalls & Solutions
1. Glamour Renderer Freezing
Problem: Creating new renderer instances can freeze Solution: Single shared renderer in styles.New()
// ❌ WRONG: New renderer each time
func (m *Model) renderMarkdown(content string) string {
renderer, _ := glamour.NewTermRenderer(...) // Can freeze!
return renderer.Render(content)
}
// ✅ CORRECT: Shared renderer instance
func (m *Model) renderMarkdown(content string) string {
return m.styles.GetRenderer().Render(content)
}
2. Footer Height Inconsistency
Problem: Border-based footers vary in height Solution: Background approach with padding
// ❌ WRONG: Border approach (height varies)
footer := lipgloss.NewStyle().
Height(1).
Border(lipgloss.Border{Top: true}).
Render(text)
// ✅ CORRECT: Background approach (exactly 1 line)
footer := lipgloss.NewStyle().
Background(borderColor).
Foreground(textColor).
Padding(0, 1, 0, 1).
Render(text)
3. Dimension Synchronization
Problem: Models store their own width/height, get out of sync Solution: Centralize dimensions in styles singleton
// ❌ WRONG: Models managing their own dimensions
type Model struct {
width, height int
}
// ✅ CORRECT: Centralized dimension management
type Model struct {
styles *styles.Styles // Access via styles.GetSize()
}
4. TUI Rendering Corruption
Problem: Console output breaks rendering Solution: File-based logger, never fmt.Printf
// ❌ NEVER: Use tea.ClearScreen during navigation
return a, tea.Batch(cmd, tea.ClearScreen)
// ✅ CORRECT: Let model Init() handle clean state
return a, a.currentModel.Init()
5. Navigation State Issues
Problem: Models retain state between visits Solution: Complete state reset in Init()
// ❌ WRONG: String-based navigation (typo-prone)
return NavigationMsg{Target: "main_menu"}
// ❌ WRONG: Manual string concatenation for arguments
return NavigationMsg{Target: ScreenID("llm_provider_form/openai")}
// ✅ CORRECT: Type-safe constants
return NavigationMsg{Target: MainMenuScreen}
// ✅ CORRECT: Composite ScreenID with helper
return NavigationMsg{Target: CreateScreenID("llm_provider_form", "openai")}
🚀 Performance & Best Practices
Proven Patterns
// ✅ DO: Shared renderer
rendered, _ := m.styles.GetRenderer().Render(content)
// ✅ DO: Centralized dimensions
width, height := m.styles.GetSize()
// ✅ DO: File logging
logger.Log("[Component] ACTION: %v", data)
// ✅ DO: Complete state reset
func (m *Model) Init() tea.Cmd {
m.resetAllState()
return m.loadContent
}
// ✅ DO: Graceful dimension handling
if width <= 0 || height <= 0 {
return "Loading..."
}
Anti-Patterns to Avoid
// ❌ DON'T: New renderer instances
renderer, _ := glamour.NewTermRenderer(...)
// ❌ DON'T: Model dimensions
type Model struct {
width int // Store in styles instead
height int // Store in styles instead
}
// ❌ DON'T: Console output
fmt.Printf("Debug: %v\n", value)
// ❌ DON'T: Partial state reset
func (m *Model) Init() tea.Cmd {
// Only resetting some fields - incomplete!
m.content = ""
// Missing: m.ready, m.scrolled, etc.
}
Key Best Practices Summary
- Single glamour renderer: Prevents freezing, faster rendering
- Centralized dimensions: Eliminates sync issues, simplifies models
- Background footer: Consistent height, modern appearance
- Type-safe navigation: Compile-time error prevention
- File-based logging: Debug without breaking TUI
- Complete state reset: Predictable model behavior
- Graceful fallbacks: Handle edge cases elegantly
- Resource estimation: Real-time calculation of token/memory usage
- Environment integration: Proper EnvVar handling with cleanup
- Value formatting: Consistent human-readable displays (formatBytes, formatNumber)
This cheat sheet contains battle-tested solutions for TUI development in the Charm ecosystem, proven in production use.
🎯 Advanced Form Field Patterns
Boolean Fields with Tab Completion
Innovation: Auto-completion for boolean values with suggestions
import "github.com/charmbracelet/bubbles/textinput"
func createBooleanField() textinput.Model {
input := textinput.New()
input.Prompt = ""
input.ShowSuggestions = true
input.SetSuggestions([]string{"true", "false"}) // Enable tab completion
// Show default value in placeholder
input.Placeholder = "true (default)" // Or "false (default)"
return input
}
// Tab completion handler in Update()
func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
// Complete boolean suggestion
if m.focusedField.Input.ShowSuggestions {
suggestion := m.focusedField.Input.CurrentSuggestion()
if suggestion != "" {
m.focusedField.Input.SetValue(suggestion)
m.focusedField.Input.CursorEnd()
return m, nil
}
}
}
}
return m, nil
}
Integer Fields with Range Validation
Innovation: Real-time validation with human-readable formatting
type IntegerFieldConfig struct {
Key string
Title string
Description string
Min int
Max int
Default int
}
func (m *FormModel) addIntegerField(config IntegerFieldConfig) {
input := textinput.New()
input.Prompt = ""
input.PlaceholderStyle = m.styles.FormPlaceholder
// Human-readable placeholder with default
input.Placeholder = fmt.Sprintf("%s (%s default)",
formatNumber(config.Default), formatBytes(config.Default))
// Add validation range to description
fullDescription := fmt.Sprintf("%s (Range: %s - %s)",
config.Description, formatBytes(config.Min), formatBytes(config.Max))
field := FormField{
Key: config.Key,
Title: config.Title,
Description: fullDescription,
Input: input,
Min: config.Min,
Max: config.Max,
}
m.fields = append(m.fields, field)
}
// Real-time validation
func (m *FormModel) validateIntegerField(field *FormField) {
value := field.Input.Value()
if value == "" {
field.Input.Placeholder = fmt.Sprintf("%s (default)", formatNumber(field.Default))
return
}
if intVal, err := strconv.Atoi(value); err != nil {
field.Input.Placeholder = "Enter a valid number or leave empty for default"
} else {
if intVal < field.Min || intVal > field.Max {
field.Input.Placeholder = fmt.Sprintf("Range: %s - %s",
formatBytes(field.Min), formatBytes(field.Max))
} else {
field.Input.Placeholder = "" // Clear error
}
}
}
Value Formatting Utilities
Critical: Consistent formatting across all forms
// Universal byte formatting for configuration values
func formatBytes(bytes int) string {
if bytes >= 1048576 {
return fmt.Sprintf("%.1fMB", float64(bytes)/1048576)
} else if bytes >= 1024 {
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
}
return fmt.Sprintf("%d bytes", bytes)
}
// Universal number formatting for display
func formatNumber(num int) string {
if num >= 1000000 {
return fmt.Sprintf("%.1fM", float64(num)/1000000)
} else if num >= 1000 {
return fmt.Sprintf("%.1fK", float64(num)/1000)
}
return strconv.Itoa(num)
}
// Usage in forms and info panels
sections = append(sections, fmt.Sprintf("• Memory Limit: %s", formatBytes(memoryLimit)))
sections = append(sections, fmt.Sprintf("• Estimated tokens: ~%s", formatNumber(tokenCount)))
Environment Variable Integration Pattern
Innovation: Direct EnvVar integration with presence detection
// EnvVar wrapper (from loader package)
type EnvVar struct {
Key string
Value string // Current value in environment
Default string // Default value from config
}
func (e EnvVar) IsPresent() bool {
return e.Value != "" // Check if actually set in environment
}
// Form field creation from EnvVar
func (m *FormModel) addFieldFromEnvVar(envVarName, fieldKey, title, description string) {
envVar, _ := m.controller.GetVar(envVarName)
// Track initially set fields for cleanup logic
m.initiallySetFields[fieldKey] = envVar.IsPresent()
input := textinput.New()
input.Prompt = ""
// Show default in placeholder if not set
if !envVar.IsPresent() {
input.Placeholder = fmt.Sprintf("%s (default)", envVar.Default)
} else {
input.SetValue(envVar.Value) // Set current value
}
field := FormField{
Key: fieldKey,
Title: title,
Description: description,
Input: input,
EnvVarName: envVarName,
}
m.fields = append(m.fields, field)
}
Smart Field Cleanup Pattern
Innovation: Environment variable cleanup for empty values
func (m *FormModel) saveConfiguration() error {
// First pass: Remove cleared fields from environment
for _, field := range m.fields {
value := strings.TrimSpace(field.Input.Value())
// If field was initially set but now empty, remove it
if value == "" && m.initiallySetFields[field.Key] {
if err := m.controller.SetVar(field.EnvVarName, ""); err != nil {
return fmt.Errorf("failed to clear %s: %w", field.EnvVarName, err)
}
logger.Log("[FormModel] SAVE: cleared %s", field.EnvVarName)
}
}
// Second pass: Save only non-empty values
for _, field := range m.fields {
value := strings.TrimSpace(field.Input.Value())
if value == "" {
continue // Skip empty - use defaults
}
// Validate before saving
if err := m.validateFieldValue(field, value); err != nil {
return fmt.Errorf("validation failed for %s: %w", field.Key, err)
}
// Save validated value
if err := m.controller.SetVar(field.EnvVarName, value); err != nil {
return fmt.Errorf("failed to set %s: %w", field.EnvVarName, err)
}
logger.Log("[FormModel] SAVE: set %s=%s", field.EnvVarName, value)
}
return nil
}
Resource Estimation Pattern
Innovation: Real-time calculation of resource usage
func (m *ConfigFormModel) calculateResourceEstimate() string {
// Get current form values or defaults
maxMemory := m.getIntValueOrDefault("max_memory")
maxConnections := m.getIntValueOrDefault("max_connections")
cacheSize := m.getIntValueOrDefault("cache_size")
// Algorithm-specific calculations
var estimatedMemory int
switch m.configType {
case "database":
estimatedMemory = maxMemory + (maxConnections * 1024) + cacheSize
case "worker":
estimatedMemory = maxMemory * maxConnections
default:
estimatedMemory = maxMemory
}
// Convert to human-readable format
return fmt.Sprintf("~%s RAM", formatBytes(estimatedMemory))
}
// Helper to get form value or default
func (m *FormModel) getIntValueOrDefault(fieldKey string) int {
// First check current form input
for _, field := range m.fields {
if field.Key == fieldKey {
if value := strings.TrimSpace(field.Input.Value()); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
}
}
// Fall back to environment default
envVar, _ := m.controller.GetVar(m.getEnvVarName(fieldKey))
if defaultVal, err := strconv.Atoi(envVar.Default); err == nil {
return defaultVal
}
return 0
}
// Display in form content
func (m *FormModel) updateFormContent() {
// ... form fields ...
// Resource estimation section
sections = append(sections, "")
sections = append(sections, m.styles.Subtitle.Render("Resource Estimation"))
sections = append(sections, m.styles.Paragraph.Render("Estimated usage: "+m.calculateResourceEstimate()))
m.formContent = strings.Join(sections, "\n")
m.viewport.SetContent(m.formContent)
}
Current Configuration Preview Pattern
Innovation: Live display of current settings in info panel
func (m *TypeSelectionModel) renderConfigurationPreview() string {
selectedType := m.types[m.selectedIndex]
var sections []string
// Helper to get current environment values
getValue := func(suffix string) string {
envVar, _ := m.controller.GetVar(m.getEnvVarName(selectedType.ID, suffix))
if envVar.Value != "" {
return envVar.Value
}
return envVar.Default + " (default)"
}
getIntValue := func(suffix string) int {
envVar, _ := m.controller.GetVar(m.getEnvVarName(selectedType.ID, suffix))
if envVar.Value != "" {
if val, err := strconv.Atoi(envVar.Value); err == nil {
return val
}
}
if val, err := strconv.Atoi(envVar.Default); err == nil {
return val
}
return 0
}
// Display current configuration
sections = append(sections, m.styles.Subtitle.Render("Current Configuration"))
sections = append(sections, "")
maxMemory := getIntValue("MAX_MEMORY")
timeout := getIntValue("TIMEOUT")
enabled := getValue("ENABLED")
sections = append(sections, fmt.Sprintf("• Max Memory: %s", formatBytes(maxMemory)))
sections = append(sections, fmt.Sprintf("• Timeout: %d seconds", timeout))
sections = append(sections, fmt.Sprintf("• Enabled: %s", enabled))
// Type-specific configuration
if selectedType.ID == "advanced" {
retries := getIntValue("MAX_RETRIES")
sections = append(sections, fmt.Sprintf("• Max Retries: %d", retries))
}
return strings.Join(sections, "\n")
}
Type-Based Dynamic Forms
Innovation: Conditional field generation based on selection
func (m *FormModel) buildDynamicForm() {
m.fields = []FormField{} // Reset
// Common fields for all types
m.addFieldFromEnvVar("ENABLED", "enabled", "Enable Service", "Enable or disable this service")
m.addFieldFromEnvVar("MAX_MEMORY", "max_memory", "Memory Limit", "Maximum memory usage in bytes")
// Type-specific fields
switch m.configType {
case "database":
m.addFieldFromEnvVar("MAX_CONNECTIONS", "max_connections", "Max Connections", "Maximum database connections")
m.addFieldFromEnvVar("CACHE_SIZE", "cache_size", "Cache Size", "Database cache size in bytes")
case "worker":
m.addFieldFromEnvVar("WORKER_COUNT", "worker_count", "Worker Count", "Number of worker processes")
m.addFieldFromEnvVar("QUEUE_SIZE", "queue_size", "Queue Size", "Maximum queue size")
case "api":
m.addFieldFromEnvVar("RATE_LIMIT", "rate_limit", "Rate Limit", "API requests per minute")
m.addFieldFromEnvVar("TIMEOUT", "timeout", "Request Timeout", "Request timeout in seconds")
}
// Set focus on first field
if len(m.fields) > 0 {
m.fields[0].Input.Focus()
}
}
// Environment variable naming helper
func (m *FormModel) getEnvVarName(configType, suffix string) string {
prefix := strings.ToUpper(configType) + "_"
return prefix + suffix
}
These advanced patterns enable:
- Smart Validation: Real-time feedback with user-friendly error messages
- Resource Awareness: Live estimation of memory, CPU, or token usage
- Environment Integration: Proper handling of defaults, presence detection, and cleanup
- Type Safety: Compile-time validation and runtime error handling
- User Experience: Auto-completion, formatting, and intuitive navigation
🎯 Production Form Architecture Patterns
Form Model Structure (Latest Pattern)
Based on successful llm_provider_form.go and summarizer_form.go implementations
type FormModel struct {
controller *controllers.StateController
styles *styles.Styles
window *window.Window
// Core form state
fields []FormField
focusedIndex int
showValues bool
hasChanges bool
args []string // Arguments from composite ScreenID
// Enhanced state tracking (from summarizer implementation)
initialized bool
configType string
typeName string
initiallySetFields map[string]bool // Track fields for cleanup
// Viewport as permanent property for forms
viewport viewport.Model
formContent string
fieldHeights []int
}
// Constructor pattern - args from composite ScreenID
func NewFormModel(
controller *controllers.StateController, styles *styles.Styles,
window *window.Window, args []string,
) *FormModel {
// Extract primary argument (e.g., provider ID)
primaryArg := "default"
if len(args) > 0 && args[0] != "" {
primaryArg = args[0]
}
return &FormModel{
controller: controller,
styles: styles,
window: window,
args: args,
viewport: viewport.New(window.GetContentSize()), // Permanent viewport
}
}
Key Form Implementation Patterns
1. Proper Navigation Hotkeys
// Modern form navigation (Production Pattern - from summarizer_form.go)
switch msg.String() {
case "down": // ↓: Next field
m.focusNext()
m.ensureFocusVisible()
case "up": // ↑: Previous field
m.focusPrev()
m.ensureFocusVisible()
case "tab": // Tab: Complete suggestion (boolean auto-complete)
m.completeSuggestion()
case "ctrl+h": // Ctrl+H: Toggle show/hide masked values
m.toggleShowValues()
case "ctrl+s": // Ctrl+S: Save configuration only
return m.saveConfiguration()
case "ctrl+r": // Ctrl+R: Reset form to defaults
m.resetForm()
return m, nil
case "enter": // Enter: Save and return (GoBack navigation)
return m.saveAndReturn()
}
// Enhanced field navigation with auto-scroll
func (m *FormModel) focusNext() {
if len(m.fields) == 0 {
return
}
m.fields[m.focusedIndex].Input.Blur()
m.focusedIndex = (m.focusedIndex + 1) % len(m.fields)
m.fields[m.focusedIndex].Input.Focus()
m.updateFormContent()
}
2. Suggestions and Auto-completion
// Boolean field with suggestions (from summarizer_form.go)
func (m *FormModel) addBooleanField(key, title, description string, envVar loader.EnvVar) {
input := textinput.New()
input.Prompt = ""
input.PlaceholderStyle = m.styles.FormPlaceholder
input.ShowSuggestions = true
input.SetSuggestions([]string{"true", "false"})
// Show default in placeholder
if envVar.Default == "true" {
input.Placeholder = "true (default)"
} else {
input.Placeholder = "false (default)"
}
// Set value only if actually present in environment
if envVar.Value != "" && envVar.IsPresent() {
input.SetValue(envVar.Value)
}
}
// Tab completion handler
func (m *FormModel) completeSuggestion() {
if m.focusedIndex < len(m.fields) {
suggestion := m.fields[m.focusedIndex].Input.CurrentSuggestion()
if suggestion != "" {
m.fields[m.focusedIndex].Input.SetValue(suggestion)
m.fields[m.focusedIndex].Input.CursorEnd()
m.fields[m.focusedIndex].Value = suggestion
m.hasChanges = true
m.updateFormContent()
}
}
}
3. Dynamic Input Width Calculation
// Adaptive input sizing
func (m *FormModel) getInputWidth() int {
viewportWidth, _ := m.getViewportSize()
inputWidth := viewportWidth - 6 // Account for padding
if m.isVerticalLayout() {
inputWidth = viewportWidth - 4 // Less padding in vertical
}
return inputWidth
}
func (m *FormModel) getViewportSize() (int, int) {
contentWidth, contentHeight := m.window.GetContentSize()
if contentWidth <= 0 || contentHeight <= 0 {
return 0, 0
}
if m.isVerticalLayout() {
return contentWidth - PaddingWidth/2, contentHeight - PaddingHeight
} else {
leftWidth := MinMenuWidth
extraWidth := contentWidth - leftWidth - MinInfoWidth - PaddingWidth
if extraWidth > 0 {
leftWidth = min(leftWidth+extraWidth/2, MaxMenuWidth)
}
return leftWidth, contentHeight - PaddingHeight
}
}
4. Viewport as Permanent Form Property
// ✅ CORRECT: Viewport as permanent property for forms
type FormModel struct {
viewport viewport.Model // Permanent - preserves scroll position
}
// Update viewport dimensions on resize
func (m *FormModel) updateViewport() {
formContentHeight := lipgloss.Height(m.formContent) + 2
viewportWidth, viewportHeight := m.getViewportSize()
m.viewport.Width = viewportWidth
m.viewport.Height = min(viewportHeight, formContentHeight)
m.viewport.SetContent(m.formContent)
}
// ❌ WRONG: Creating viewport in View() - loses scroll state
func (m *FormModel) View() string {
vp := viewport.New(width, height) // State lost on re-render!
return vp.View()
}
Layout Architecture (Two-Column Pattern)
1. Layout Constants (Production Values)
const (
MinMenuWidth = 38 // Minimum left panel width
MaxMenuWidth = 66 // Maximum left panel width (prevents too wide)
MinInfoWidth = 34 // Minimum right panel width
PaddingWidth = 8 // Total horizontal padding
PaddingHeight = 2 // Vertical padding
)
2. Adaptive Layout Logic
func (m *Model) isVerticalLayout() bool {
contentWidth := m.window.GetContentWidth()
return contentWidth < (MinMenuWidth + MinInfoWidth + PaddingWidth)
}
// Horizontal layout with dynamic width allocation
func (m *Model) renderHorizontalLayout(leftPanel, rightPanel string, width, height int) string {
leftWidth, rightWidth := MinMenuWidth, MinInfoWidth
extraWidth := width - leftWidth - rightWidth - PaddingWidth
// Distribute extra space, but cap left panel at MaxMenuWidth
if extraWidth > 0 {
leftWidth = min(leftWidth+extraWidth/2, MaxMenuWidth)
rightWidth = width - leftWidth - PaddingWidth/2
}
leftStyled := lipgloss.NewStyle().Width(leftWidth).Padding(0, 2, 0, 2).Render(leftPanel)
rightStyled := lipgloss.NewStyle().Width(rightWidth).PaddingLeft(2).Render(rightPanel)
// Use viewport for final layout rendering
viewport := viewport.New(width, height-PaddingHeight)
viewport.SetContent(lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled))
return viewport.View()
}
3. Content Hiding When Space Insufficient
// Vertical layout with conditional content hiding
func (m *Model) renderVerticalLayout(leftPanel, rightPanel string, width, height int) string {
verticalStyle := lipgloss.NewStyle().Width(width).Padding(0, 4, 0, 2)
leftStyled := verticalStyle.Render(leftPanel)
rightStyled := verticalStyle.Render(rightPanel)
// Hide right panel if both don't fit
if lipgloss.Height(leftStyled)+lipgloss.Height(rightStyled)+2 < height {
return lipgloss.JoinVertical(lipgloss.Left,
leftStyled,
verticalStyle.Height(1).Render(""),
rightStyled,
)
}
// Show only essential left panel
return leftStyled
}
Composite ScreenID Navigation (Production Pattern)
1. Proper ScreenID Creation for Navigation
// Navigation from menu with argument preservation
func (m *MenuModel) handleSelection() (tea.Model, tea.Cmd) {
selectedItem := m.getSelectedItem()
// Create composite ScreenID with current selection for stack preservation
return m, func() tea.Msg {
return NavigationMsg{
Target: CreateScreenID(string(targetScreen), selectedItem.ID),
}
}
}
// Form navigation back - use GoBack to avoid stack loops
func (m *FormModel) saveAndReturn() (tea.Model, tea.Cmd) {
model, cmd := m.saveConfiguration()
if cmd != nil {
return model, cmd
}
// ✅ CORRECT: Use GoBack to return to previous screen
return m, func() tea.Msg {
return NavigationMsg{GoBack: true}
}
}
// ❌ WRONG: Direct navigation creates stack loops
return m, func() tea.Msg {
return NavigationMsg{Target: LLMProvidersScreen} // Creates loop!
}
2. Constructor with Args Pattern
// Model constructor receives args from composite ScreenID
func NewModel(
controller *controllers.StateController, styles *styles.Styles,
window *window.Window, args []string,
) *Model {
// Initialize with selection from args
selectedIndex := 0
if len(args) > 1 && args[1] != "" {
// Find matching item and set selectedIndex
for i, item := range items {
if item.ID == args[1] {
selectedIndex = i
break
}
}
}
return &Model{
controller: controller,
selectedIndex: selectedIndex,
args: args,
}
}
// No separate SetSelected* methods needed
func (m *Model) Init() tea.Cmd {
logger.Log("[Model] INIT: args=%s", strings.Join(m.args, " § "))
// Selection already set in constructor from args
m.loadData()
return nil
}
Viewport Usage Patterns
1. Forms: Permanent Viewport Property
// ✅ For forms with user interaction and scroll state
type FormModel struct {
viewport viewport.Model // Permanent - preserves scroll position
}
func (m *FormModel) ensureFocusVisible() {
// Auto-scroll to focused field
focusY := m.calculateFieldPosition(m.focusedIndex)
if focusY < m.viewport.YOffset {
m.viewport.YOffset = focusY
}
// ... scroll logic
}
2. Layout: Temporary Viewport Creation
// ✅ For final layout rendering only
func (m *Model) renderHorizontalLayout(left, right string, width, height int) string {
content := lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled)
// Create viewport just for layout rendering
vp := viewport.New(width, height-PaddingHeight)
vp.SetContent(content)
return vp.View()
}
Screen Architecture (App.go Integration)
1. Content Area Only Pattern
// Screen models ONLY handle content area
func (m *Model) View() string {
// ✅ CORRECT: Only content, no header/footer
leftPanel := m.renderForm()
rightPanel := m.renderHelp()
if m.isVerticalLayout() {
return m.renderVerticalLayout(leftPanel, rightPanel, width, height)
}
return m.renderHorizontalLayout(leftPanel, rightPanel, width, height)
}
// ❌ WRONG: Handling header/footer in screen
func (m *Model) View() string {
header := m.renderHeader() // App.go handles this!
footer := m.renderFooter() // App.go handles this!
// ...
}
2. App.go Layout Management
// App.go manages complete layout structure
func (a *App) View() string {
header := a.renderHeader() // Screen-specific header
footer := a.renderFooter() // Dynamic footer with actions
content := a.currentModel.View() // Content from model
// Calculate content area size
contentWidth, contentHeight := a.window.GetContentSize()
contentArea := a.styles.Content.
Width(contentWidth).
Height(contentHeight).
Render(content)
return lipgloss.JoinVertical(lipgloss.Left, header, contentArea, footer)
}
Field Configuration Best Practices
1. Clean Input Setup
// Modern input field setup
func (m *FormModel) addInputField(config *Config, fieldType string) {
input := textinput.New()
input.Prompt = "" // Clean appearance
input.PlaceholderStyle = m.styles.FormPlaceholder
// Dynamic width - set during rendering
// input.Width NOT set here - calculated in updateFormContent()
if fieldType == "password" {
input.EchoMode = textinput.EchoPassword
}
if fieldType == "boolean" {
input.ShowSuggestions = true
input.SetSuggestions([]string{"true", "false"})
}
// Set value from config
if config != nil {
input.SetValue(config.GetValue(fieldType))
}
}
2. Dynamic Width Application
// Apply width during content update
func (m *FormModel) updateFormContent() {
inputWidth := m.getInputWidth()
for i, field := range m.fields {
// Apply dynamic width to input
field.Input.Width = inputWidth - 3 // Account for borders
field.Input.SetValue(field.Input.Value()) // Trigger width update
// Render with consistent styling
inputStyle := m.styles.FormInput.Width(inputWidth)
if i == m.focusedIndex {
inputStyle = inputStyle.BorderForeground(styles.Primary)
}
renderedInput := inputStyle.Render(field.Input.View())
sections = append(sections, renderedInput)
}
}
State Management Best Practices
1. Configuration vs Status Separation
// ✅ SIMPLIFIED: Single status field
type ProviderInfo struct {
ID string
Name string
Description string
Configured bool // Single status - provider has required fields
}
// Load status logic
func (m *Model) loadProviders() {
configs := m.controller.GetLLMProviders()
provider := ProviderInfo{
ID: "openai",
Name: locale.LLMProviderOpenAI,
Configured: configs["openai"].Configured, // From controller
}
}
// ❌ COMPLEX: Multiple status fields (removed)
type ProviderInfo struct {
Configured bool
Enabled bool // Removed - controller handles this
}
2. GoBack Navigation Pattern
// ✅ CORRECT: GoBack prevents navigation loops
func (m *FormModel) saveAndReturn() (tea.Model, tea.Cmd) {
if err := m.saveConfiguration(); err != nil {
return m, nil // Stay on form if save fails
}
// Return to previous screen (from navigation stack)
return m, func() tea.Msg {
return NavigationMsg{GoBack: true}
}
}
// Navigation stack automatically maintained:
// ["main_menu§llm_providers", "llm_providers§openai", "llm_provider_form§openai"]
// GoBack removes current and returns to: "llm_providers§openai"
This production architecture ensures:
- Clean separation: Forms handle content, app.go handles layout
- Persistent state: Viewport scroll positions maintained
- Adaptive design: Content hides gracefully when space insufficient
- Type-safe navigation: Arguments preserved in composite ScreenIDs
- No navigation loops: GoBack pattern prevents stack corruption