
Bubbletea
- 400 installs
- 20 repo stars
- Updated June 11, 2026
- ggprompts/tfe
bubbletea is a Go agent skill that builds terminal user interfaces with the Charm Bubble Tea framework, Lipgloss styling, and Bubbles components using Elm architecture patterns, dual-pane layouts, and production-tested l
About
bubbletea is a GGPrompts agent skill for developers shipping terminal user interfaces in Go with the Charm ecosystem. The skill covers Bubble Tea's Elm architecture modelUpdate/update/view cycle, Lipgloss styling, and Bubbles components for lists, dialogs, tables, and preview panes. Production templates include dual-pane layouts, accordion modes, mouse and keyboard handling, and an effects library with metaballs and wave animations. Documentation emphasizes Four Golden Rules that prevent common TUI bugs: account for border height in layout math, truncate text explicitly to avoid wrap chaos, align mouse hit detection with layout orientation, and prefer proportional weights over fixed pixel sizes. Dependencies include github.com/charmbracelet/bubbletea, lipgloss, bubbles, and gopkg.in/yaml.v3 for config-driven apps. Developers reach for bubbletea when scaffolding new CLI tools, fixing rendering overflow, or adding interactive panels to existing Go binaries. Install via npx skills add ggprompts/tfe --skill bubbletea for Claude Code projects.
- bubbletea
- Development
Bubbletea by the numbers
- 400 all-time installs (skills.sh)
- Ranked #1,042 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 26, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ggprompts/tfe --skill bubbleteaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 400 |
|---|---|
| repo stars | ★ 20 |
| Last updated | June 11, 2026 |
| Repository | ggprompts/tfe ↗ |
How do you build Go terminal UIs with Bubble Tea?
For development and infrastructure management.
Who is it for?
Go developers building interactive CLI tools who need Bubble Tea, Lipgloss, and battle-tested dual-pane TUI layout patterns.
Skip if: Web React frontends, native mobile UI, or teams not using Go and the Charm terminal stack.
When should I use this skill?
The user creates a Go TUI, hits Bubble Tea rendering or layout bugs, or asks for dual-pane terminal UI with mouse and keyboard handling.
What you get
Go TUI application with Bubble Tea model/update/view structure, Lipgloss layouts, and reusable Bubbles components
- Go TUI application scaffold
- Lipgloss-styled layouts
- reusable Bubble Tea components
By the numbers
- Documents Four Golden Rules for preventing common Bubble Tea layout bugs
- References 4 Charm ecosystem packages: bubbletea, lipgloss, bubbles, and yaml.v3
Files
Bubbletea TUI Development
Production-ready skill for building beautiful terminal user interfaces with Go, Bubbletea, and Lipgloss.
When to Use This Skill
Use this skill when:
- Creating new TUI applications with Go
- Adding Bubbletea components to existing apps
- Fixing layout/rendering issues (borders, alignment, overflow)
- Implementing mouse/keyboard interactions
- Building dual-pane or multi-panel layouts
- Adding visual effects (metaballs, waves, rainbow text)
- Troubleshooting TUI rendering problems
Core Principles
CRITICAL: Before implementing ANY layout, consult references/golden-rules.md for the 4 Golden Rules. These rules prevent the most common and frustrating TUI layout bugs.
The 4 Golden Rules (Summary)
1. Always Account for Borders - Subtract 2 from height calculations BEFORE rendering panels 2. Never Auto-Wrap in Bordered Panels - Always truncate text explicitly 3. Match Mouse Detection to Layout - Use X coords for horizontal, Y coords for vertical 4. Use Weights, Not Pixels - Proportional layouts scale perfectly
Full details and examples in references/golden-rules.md.
Creating New Projects
This project includes a production-ready template system. When this skill is bundled with a new project (via new_project.sh), use the existing template structure as the starting point.
Project Structure
All new projects follow this architecture:
your-app/
├── main.go # Entry point (minimal, ~21 lines)
├── types.go # Type definitions, structs, enums
├── model.go # Model initialization & layout calculation
├── update.go # Message dispatcher
├── update_keyboard.go # Keyboard handling
├── update_mouse.go # Mouse handling
├── view.go # View rendering & layouts
├── styles.go # Lipgloss style definitions
├── config.go # Configuration management
└── .claude/skills/bubbletea/ # This skill (bundled)Architecture Guidelines
- Keep
main.gominimal (entry point only, ~21 lines) - All types in
types.go(structs, enums, constants) - Separate keyboard and mouse handling into dedicated files
- One file, one responsibility
- Maximum file size: 800 lines (ideally <500)
- Configuration via YAML with hot-reload support
Available Components
See references/components.md for the complete catalog of reusable components:
- Panel System: Single, dual-pane, multi-panel, tabbed layouts
- Lists: Simple list, filtered list, tree view
- Input: Text input, multiline, forms, autocomplete
- Dialogs: Confirm, input, progress, modal
- Menus: Context menu, command palette, menu bar
- Status: Status bar, title bar, breadcrumbs
- Preview: Text, markdown, syntax highlighting, images, hex
- Tables: Simple and interactive tables
Effects Library
Beautiful physics-based animations available in the template:
- 🔮 Metaballs - Lava lamp-style floating blobs
- 🌊 Wave Effects - Sine wave distortions
- 🌈 Rainbow Cycling - Animated color gradients
- 🎭 Layer Compositor - ANSI-aware multi-layer rendering
See references/effects.md for usage examples and integration patterns.
Layout Implementation Pattern
When implementing layouts, follow this sequence:
1. Calculate Available Space
func (m model) calculateLayout() (int, int) {
contentWidth := m.width
contentHeight := m.height
// Subtract UI elements
if m.config.UI.ShowTitle {
contentHeight -= 3 // title bar (3 lines)
}
if m.config.UI.ShowStatus {
contentHeight -= 1 // status bar
}
// CRITICAL: Account for panel borders
contentHeight -= 2 // top + bottom borders
return contentWidth, contentHeight
}2. Use Weight-Based Panel Sizing
// Calculate weights based on focus/accordion mode
leftWeight, rightWeight := 1, 1
if m.accordionMode && m.focusedPanel == "left" {
leftWeight = 2 // Focused panel gets 2x weight
}
// Calculate actual widths from weights
totalWeight := leftWeight + rightWeight
leftWidth := (availableWidth * leftWeight) / totalWeight
rightWidth := availableWidth - leftWidth3. Truncate Text to Prevent Wrapping
// Calculate max text width to prevent wrapping
maxTextWidth := panelWidth - 4 // -2 borders, -2 padding
// Truncate ALL text before rendering
title = truncateString(title, maxTextWidth)
subtitle = truncateString(subtitle, maxTextWidth)
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-1] + "…"
}Mouse Interaction Pattern
Always check layout mode before processing mouse events:
func (m model) handleLeftClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.shouldUseVerticalStack() {
// Vertical stack mode: use Y coordinates
topHeight, _ := m.calculateVerticalStackLayout()
relY := msg.Y - contentStartY
if relY < topHeight {
m.focusedPanel = "left" // Top panel
} else {
m.focusedPanel = "right" // Bottom panel
}
} else {
// Side-by-side mode: use X coordinates
leftWidth, _ := m.calculateDualPaneLayout()
if msg.X < leftWidth {
m.focusedPanel = "left"
} else {
m.focusedPanel = "right"
}
}
return m, nil
}Common Pitfalls to Avoid
See references/troubleshooting.md for detailed solutions to common issues:
❌ DON'T: Set explicit Height() on bordered panels
// BAD: Can cause misalignment
panelStyle := lipgloss.NewStyle().
Border(border).
Height(height) // Don't do this!✅ DO: Fill content to exact height
// GOOD: Fill content lines to exact height
for len(lines) < innerHeight {
lines = append(lines, "")
}
panelStyle := lipgloss.NewStyle().Border(border)Testing and Debugging
When panels don't align or render incorrectly:
1. Check height accounting - Verify contentHeight calculation subtracts all UI elements + borders 2. Check text wrapping - Ensure all strings are truncated to maxTextWidth 3. Check mouse detection - Verify X/Y coordinate usage matches layout orientation 4. Check border consistency - Use same border style for all panels
See references/troubleshooting.md for the complete debugging decision tree.
Configuration System
All projects support YAML configuration with hot-reload:
theme: "dark"
keybindings: "default"
layout:
type: "dual_pane"
split_ratio: 0.5
accordion_mode: true
ui:
show_title: true
show_status: true
mouse_enabled: true
show_icons: trueConfiguration files are loaded from: 1. ~/.config/your-app/config.yaml (user config) 2. ./config.yaml (local override)
Dependencies
Required:
github.com/charmbracelet/bubbletea
github.com/charmbracelet/lipgloss
github.com/charmbracelet/bubbles
gopkg.in/yaml.v3Optional (uncomment in go.mod as needed):
github.com/charmbracelet/glamour # Markdown rendering
github.com/charmbracelet/huh # Forms
github.com/alecthomas/chroma/v2 # Syntax highlighting
github.com/evertras/bubble-table # Interactive tables
github.com/koki-develop/go-fzf # Fuzzy finderReference Documentation
All reference files are loaded progressively as needed:
- golden-rules.md - Critical layout patterns and anti-patterns
- components.md - Complete catalog of reusable components
- troubleshooting.md - Common issues and debugging decision tree
- emoji-width-fix.md - Battle-tested solution for emoji alignment across terminals (xterm, WezTerm, Termux, Windows Terminal)
External Resources
Best Practices Summary
1. Always consult golden-rules.md before implementing layouts 2. Always use weight-based sizing for flexible layouts 3. Always truncate text explicitly (never rely on auto-wrap) 4. Always match mouse detection to layout orientation 5. Always account for borders in height calculations 6. Never set explicit Height() on bordered Lipgloss styles 7. Never assume layout orientation in mouse handlers
Follow these patterns and you'll avoid 90% of TUI layout bugs.
Bubbletea Components Catalog
Reusable components for building TUI applications. All components follow the Elm architecture pattern (Init, Update, View).
Panel System
Pre-built panel layouts for different UI arrangements.
Single Panel
Full-screen single view with optional title and status bars.
Use for:
- Simple focused interfaces
- Full-screen text editors
- Single-purpose tools
Implementation:
func (m model) renderSinglePanel() string {
contentWidth, contentHeight := m.calculateLayout()
// Create panel with full available space
panel := m.styles.Panel.
Width(contentWidth).
Render(content)
return panel
}Dual Pane
Side-by-side panels with configurable split ratio and accordion mode.
Use for:
- File browsers with preview
- Split editors
- Source/destination views
Features:
- Dynamic split ratio (50/50, 66/33, 75/25)
- Accordion mode (focused panel expands)
- Responsive (stacks vertically on narrow terminals)
- Weight-based sizing for smooth resizing
Implementation:
func (m model) renderDualPane() string {
contentWidth, contentHeight := m.calculateLayout()
// Calculate weights based on focus/accordion
leftWeight, rightWeight := 1, 1
if m.accordionMode && m.focusedPanel == "left" {
leftWeight = 2
}
// Calculate actual widths from weights
totalWeight := leftWeight + rightWeight
leftWidth := (contentWidth * leftWeight) / totalWeight
rightWidth := contentWidth - leftWidth
// Render panels
leftPanel := m.renderPanel("left", leftWidth, contentHeight)
rightPanel := m.renderPanel("right", rightWidth, contentHeight)
return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
}Keyboard shortcuts:
Tab- Switch focus between panelsa- Toggle accordion mode- Arrow keys - Focus panel in direction
Mouse support:
- Click panel to focus
- Works in both horizontal and vertical stack modes
Multi-Panel
3+ panels with configurable sizes and arrangements.
Use for:
- IDEs (file tree, editor, terminal, output)
- Dashboard views
- Complex workflows
Common layouts:
- Three-column (25/50/25)
- Three-row
- Grid (2x2, 3x3)
- Sidebar + main + inspector
Implementation:
// Three-column example
mainWeight, leftWeight, rightWeight := 2, 1, 1 // 50/25/25
totalWeight := mainWeight + leftWeight + rightWeight
leftWidth := (contentWidth * leftWeight) / totalWeight
mainWidth := (contentWidth * mainWeight) / totalWeight
rightWidth := contentWidth - leftWidth - mainWidthTabbed
Multiple views with tab switching.
Use for:
- Multiple documents
- Settings pages
- Different data views
Features:
- Tab bar with active indicator
- Keyboard shortcuts (
1-9,Ctrl+Tab) - Mouse click to switch tabs
- Close tab support
Lists
Simple List
Basic scrollable list of items.
Use for:
- File listings
- Menu options
- Search results
Features:
- Keyboard navigation (Up/Down, Home/End, PgUp/PgDn)
- Mouse scrolling and selection
- Visual selection indicator
- Viewport scrolling (only visible items rendered)
Integration:
import "github.com/charmbracelet/bubbles/list"
type model struct {
list list.Model
}
func (m model) Init() tea.Cmd {
items := []list.Item{
item{title: "Item 1", desc: "Description 1"},
item{title: "Item 2", desc: "Description 2"},
}
m.list = list.New(items, list.NewDefaultDelegate(), 0, 0)
return nil
}Filtered List
List with fuzzy search/filter.
Use for:
- Quick file finder
- Command palette
- Searchable settings
Features:
- Real-time filtering as you type
- Fuzzy matching
- Highlighted matches
Dependencies:
github.com/koki-develop/go-fzfTree View
Hierarchical list with expand/collapse.
Use for:
- Directory trees
- Nested data structures
- Outline views
Features:
- Expand/collapse nodes
- Indentation levels
- Parent/child relationships
- Recursive rendering
Input Components
Text Input
Single-line text field.
Use for:
- Forms
- Search boxes
- Prompts
Integration:
import "github.com/charmbracelet/bubbles/textinput"
type model struct {
input textinput.Model
}
func (m model) Init() tea.Cmd {
m.input = textinput.New()
m.input.Placeholder = "Enter text..."
m.input.Focus()
return textinput.Blink
}Multiline Input
Text area for longer content.
Use for:
- Commit messages
- Notes
- Configuration editing
Integration:
import "github.com/charmbracelet/bubbles/textarea"
type model struct {
textarea textarea.Model
}Forms
Structured input with multiple fields.
Use for:
- Settings dialogs
- User registration
- Multi-field input
Integration:
import "github.com/charmbracelet/huh"
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Name").
Value(&name),
huh.NewInput().
Title("Email").
Value(&email),
),
)Autocomplete
Text input with suggestions.
Use for:
- Command entry
- File paths
- Tag selection
Features:
- Real-time suggestions
- Keyboard navigation of suggestions
- Tab completion
Dialogs
Confirm Dialog
Yes/No confirmation.
Use for:
- Delete confirmations
- Save prompts
- Destructive actions
Example:
┌─────────────────────────────┐
│ Delete this file? │
│ │
│ [Yes] [No] │
└─────────────────────────────┘Input Dialog
Prompt for single value.
Use for:
- Quick input
- Rename operations
- New file creation
Progress Dialog
Show long-running operations.
Use for:
- File uploads
- Build processes
- Data processing
Integration:
import "github.com/charmbracelet/bubbles/progress"
type model struct {
progress progress.Model
}Modal
Full overlay dialog.
Use for:
- Settings
- Help screens
- Complex forms
Menus
Context Menu
Right-click or keyboard-triggered menu.
Use for:
- File operations
- Quick actions
- Tool integration
Example:
┌─────────────┐
│ Open │
│ Copy │
│ Delete │
│ Properties │
└─────────────┘Command Palette
Fuzzy searchable command list.
Use for:
- Command discovery
- Keyboard-first workflows
- Power user features
Keyboard:
Ctrl+PorCtrl+Shift+Pto open- Type to filter
- Enter to execute
Menu Bar
Top-level menu system.
Use for:
- Traditional application menus
- Organized commands
- Discoverability
Example:
File Edit View HelpStatus Components
Status Bar
Bottom bar showing state and help.
Use for:
- Current mode/state
- Keyboard hints
- File info
Example:
┌────────────────────────────────────┐
│ │
│ Content area │
│ │
├────────────────────────────────────┤
│ Normal | file.txt | Line 10/100 │
└────────────────────────────────────┘Pattern:
func (m model) renderStatusBar() string {
left := fmt.Sprintf("%s | %s", m.mode, m.filename)
right := fmt.Sprintf("Line %d/%d", m.cursor, m.lineCount)
width := m.width
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
return left + strings.Repeat(" ", gap) + right
}Title Bar
Top bar with app title and context.
Use for:
- Application name
- Current path/document
- Action buttons
Breadcrumbs
Path navigation component.
Use for:
- Directory navigation
- Nested views
- History trail
Example:
Home > Projects > TUITemplate > componentsPreview Components
Text Preview
Rendered text with syntax highlighting.
Use for:
- File preview
- Code display
- Log viewing
Integration:
import "github.com/alecthomas/chroma/v2/quick"
func renderCode(code, language string) string {
var buf bytes.Buffer
quick.Highlight(&buf, code, language, "terminal256", "monokai")
return buf.String()
}Markdown Preview
Rendered markdown.
Integration:
import "github.com/charmbracelet/glamour"
func renderMarkdown(md string) (string, error) {
renderer, _ := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(80),
)
return renderer.Render(md)
}Image Preview
ASCII/Unicode art from images.
Use for:
- Image thumbnails
- Visual file preview
- Logos/artwork
External tools:
catimg- Convert images to 256-color ASCIIviu- View images in terminal with full color
Hex Preview
Binary file viewer.
Use for:
- Binary file inspection
- Debugging
- Data analysis
Example:
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0200 3e00 0100 0000 6009 4000 0000 0000 ..>.....`.@.....Tables
Simple Table
Static data display.
Use for:
- Data display
- Reports
- Comparison views
Interactive Table
Navigable table with selection.
Use for:
- Database browsers
- CSV viewers
- Process lists
Integration:
import "github.com/evertras/bubble-table/table"
type model struct {
table table.Model
}
func (m model) Init() tea.Cmd {
m.table = table.New([]table.Column{
table.NewColumn("id", "ID", 10),
table.NewColumn("name", "Name", 20),
})
return nil
}Features:
- Sort by column
- Row selection
- Keyboard navigation
- Column resize
Component Integration Patterns
Composing Components
type model struct {
// Multiple components in one view
list list.Model
preview string
input textinput.Model
focused string // which component has focus
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Route to focused component
switch m.focused {
case "list":
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
case "input":
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
return m, nil
}Lazy Loading Components
Only initialize components when needed:
type model struct {
preview *PreviewComponent // nil until needed
previewPath string
}
func (m *model) showPreview(path string) {
if m.preview == nil {
m.preview = NewPreviewComponent()
}
m.preview.Load(path)
}Component Communication
Use Bubbletea commands to communicate between components:
type fileSelectedMsg struct {
path string
}
// In list component Update
case tea.KeyMsg:
if key.Matches(msg, m.keymap.Enter) {
selectedFile := m.list.SelectedItem()
return m, func() tea.Msg {
return fileSelectedMsg{path: selectedFile.Path()}
}
}
// In main model Update
case fileSelectedMsg:
m.preview.Load(msg.path)
return m, nilBest Practices
1. Keep components focused - Each component should have one responsibility 2. Use bubbles package - Don't reinvent standard components 3. Lazy initialization - Create components when needed, not upfront 4. Proper sizing - Always pass explicit width/height to components 5. Clean interfaces - Components should expose minimal, clear APIs
External Dependencies
Core Charm libraries:
github.com/charmbracelet/bubbletea # Framework
github.com/charmbracelet/lipgloss # Styling
github.com/charmbracelet/bubbles # Standard componentsExtended functionality:
github.com/charmbracelet/glamour # Markdown rendering
github.com/charmbracelet/huh # Forms
github.com/alecthomas/chroma/v2 # Syntax highlighting
github.com/evertras/bubble-table # Interactive tables
github.com/koki-develop/go-fzf # Fuzzy finderSee go.mod in template for complete list of optional dependencies.
Emoji Width Alignment Fix for Terminal UIs
Date: 2025-10-27 Source: TFE project debugging session Issue: Emoji alignment breaks in WezTerm/Termux but works in Windows Terminal
---
The Problem
Some emojis with variation selectors (U+FE0F) render inconsistently across terminals:
| Emoji | Windows Terminal | WezTerm/Termux | Result |
|---|---|---|---|
| ⬆️ (U+2B06 + U+FE0F) | 2 cells | 1 cell | Misalignment |
| ⚙️ (U+2699 + U+FE0F) | 2 cells | 1 cell | Misalignment |
| 🗜️ (U+1F5DC + U+FE0F) | 2 cells | 1 cell | Misalignment |
| 📦 (U+1F4E6) | 2 cells | 2 cells | ✅ Aligned |
Symptom: File names with narrow emojis shift left by 1 space, breaking column alignment.
---
Root Causes
0. XTerm Terminals Require unicode11
For xterm-based terminals: Must configure go-runewidth properly:
import "github.com/mattn/go-runewidth"
// Required initialization for xterm terminals
// Without this, xterm won't handle emoji widths correctly1. go-runewidth Bug #76 (Open since Feb 2024)
Issue: Variation Selectors incorrectly report width = 1 instead of 0
// WRONG: go-runewidth bug
runewidth.StringWidth("⬆️") // Returns 2 (base=1 + VS=1)
// Should return 1 (base=1 + VS=0)This causes padding calculations to fail:
- Code thinks "⬆️" is already 2 cells wide
- No padding added
- Terminal renders as 1 cell
- Result: 1 space misalignment
2. Terminal Rendering Differences
Different terminals handle emoji + variation selector differently:
- Windows Terminal: Honors VS-16 → renders as 2 cells (colorful, wide) - slightly different handling
- WezTerm/Termux: Ignores VS-16 for width → renders as 1 cell - need identical fixes
- xterm: Requires unicode11 configuration (see above)
- Kitty: Actively adjusts width based on VS
No standard exists - Unicode only defines width at codepoint level, not grapheme level.
---
The Fix
Strategy: Strip variation selectors before width calculation AND before display in affected terminals.
Implementation
// In your width calculation function (strips ANSI codes first)
func visualWidth(s string) int {
// Strip ANSI codes first
stripped := stripANSI(s)
// Strip variation selectors to work around go-runewidth bug #76
// VS incorrectly reports width=1 instead of width=0
stripped = strings.ReplaceAll(stripped, "\uFE0F", "") // VS-16 (emoji presentation)
stripped = strings.ReplaceAll(stripped, "\uFE0E", "") // VS-15 (text presentation)
// Now use StringWidth on the whole stripped string
return runewidth.StringWidth(stripped)
}
// In your icon padding function
func (m model) padIconToWidth(icon string) string {
// Strip variation selectors for terminals that render emoji+VS as 1 cell
if m.terminalType == terminalWezTerm || m.terminalType == terminalTermux {
icon = strings.ReplaceAll(icon, "\uFE0F", "")
icon = strings.ReplaceAll(icon, "\uFE0E", "")
}
return padToVisualWidth(icon, 2)
}Terminal Type Detection
// Detect terminal type early in initialization
func detectTerminalType() terminalType {
// Check for Termux (Android) - BEFORE xterm check
// Termux sets TERM=xterm-256color, so check PREFIX first
if strings.Contains(os.Getenv("PREFIX"), "com.termux") {
return terminalTermux
}
// Check for WezTerm
if os.Getenv("TERM_PROGRAM") == "WezTerm" {
return terminalWezTerm
}
// Check for Windows Terminal
if os.Getenv("WT_SESSION") != "" {
return terminalWindowsTerminal
}
// Check for Kitty
if strings.Contains(os.Getenv("TERM"), "kitty") {
return terminalKitty
}
// Fallback
return terminalGeneric
}---
Results
Before fix:
⬆️ parent_dir <-- shifted left by 1 space
📦 package.tar <-- correct alignment
⚙️ config.ini <-- shifted left by 1 spaceAfter fix:
⬆ parent_dir <-- aligned (VS stripped, emoji less colorful)
📦 package.tar <-- aligned
⚙ config.ini <-- aligned (VS stripped, emoji less colorful)Trade-off: Emojis may appear slightly different (less colorful, more text-like) in WezTerm/Termux, but alignment is perfect.
---
Alternative Approaches (Not Recommended)
❌ Emoji Replacement Map
// Replace narrow emojis with always-wide alternatives
replacements := map[string]string{
"⬆️": "⏫", // Up arrow → double up
"⚙️": "🔧", // Gear → wrench
}Issue: Loses semantic meaning, doesn't solve the root problem.
❌ Manual Space Addition
// Add extra space after problematic emojis
icon := "⚙️ "Issue: Doesn't work reliably - Lipgloss may re-measure width.
❌ Zero-Width Joiners (ZWJ)
Issue: Makes problems worse, poor terminal support.
---
Key Takeaways
1. Always use `StringWidth()`, never `RuneWidth()` for display width
RuneWidth()breaks multi-rune emoji like flags, skin tones, emoji+VS
2. Strip ANSI codes before width calculation
stripped := stripANSI(text)
width := runewidth.StringWidth(stripped)3. Terminal-specific compensation is necessary
- No universal solution exists
- Different terminals render emoji differently
- Detect terminal type and adjust accordingly
4. Accept the trade-off
- Emoji appearance vs. alignment consistency
- Most users prefer proper alignment
5. This is a known ecosystem problem
- lazygit: Issue #3514 (still open)
- k9s: Provides
noIconsconfig option - Lipgloss: PR #563 (still open, trying to improve)
- go-runewidth: Issue #76 (VS width bug, unfixed)
---
Related Issues
- go-runewidth #76 - Variation Selector width bug (OPEN)
- go-runewidth #59 - "First non-zero width" heuristic limitation
- Lipgloss #55 - Emoji width causing incorrect borders
- Lipgloss #563 - PR to improve Unicode width (OPEN, not merged)
- WezTerm #4223 - Terminal rendering differences discussion
---
When to Use This Fix
Apply this fix when:
- ✅ Your TUI uses emoji icons for files/folders
- ✅ You support multiple terminal emulators
- ✅ Users report alignment issues in specific terminals
- ✅ You're using
github.com/mattn/go-runewidthfor width calculations
---
Testing Checklist
When implementing this fix, test in:
- [ ] Windows Terminal (should maintain perfect alignment)
- [ ] WezTerm (should fix alignment, emoji may look different)
- [ ] Termux (Android) (should fix alignment)
- [ ] Kitty (should maintain good alignment)
- [ ] iTerm2 (macOS) (should maintain good alignment)
- [ ] Generic xterm (baseline compatibility)
Test all view modes:
- [ ] List/table views
- [ ] Tree views
- [ ] Split pane layouts
- [ ] Full-screen views
---
Code Location Reference
From TFE project (reference implementation):
- file_operations.go:936-968 -
visualWidth()function - file_operations.go:969-983 -
visualWidthCompensated()function - file_operations.go:1237-1246 -
padIconToWidth()function - model.go:187-197 - Terminal type detection
Full debugging session: TFE/docs/EMOJI_DEBUG_SESSION_2.md
---
Quick Reference Code Snippet
// Complete minimal implementation
func visualWidth(s string) int {
// Strip ANSI escape codes
stripped := stripANSI(s)
// Work around go-runewidth bug #76
stripped = strings.ReplaceAll(stripped, "\uFE0F", "")
stripped = strings.ReplaceAll(stripped, "\uFE0E", "")
return runewidth.StringWidth(stripped)
}
func stripANSI(s string) string {
stripped := ""
inAnsi := false
for _, ch := range s {
if ch == '\033' {
inAnsi = true
continue
}
if inAnsi {
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
inAnsi = false
}
continue
}
stripped += string(ch)
}
return stripped
}---
Status: ✅ Tested and working in TFE project (2025-10-27) Affected Terminals: WezTerm, Termux (Android) Fix Complexity: Low (2 function changes) Success Rate: 100% (alignment fixed, acceptable emoji appearance change)
The 4 Golden Rules for TUI Layout
These rules prevent the most common and frustrating TUI layout bugs. They were discovered through trial-and-error on real projects and will save you hours of debugging.
Rule #1: Always Account for Borders
Subtract 2 from height calculations BEFORE rendering panels.
The Problem
Lipgloss borders add height to your content. If you calculate content height without accounting for borders, panels will overflow and cover other UI elements.
The Math
WRONG:
contentHeight = totalHeight - 3 (title) - 1 (status) = totalHeight - 4
Panel renders with borders = contentHeight + 2 (borders)
Actual height used = totalHeight - 4 + 2 = totalHeight - 2 (TOO TALL!)
CORRECT:
contentHeight = totalHeight - 3 (title) - 1 (status) - 2 (borders) = totalHeight - 6
Panel renders with borders = contentHeight + 2
Actual height used = totalHeight - 6 + 2 = totalHeight - 4 ✓Visual Layout
┌─────────────────────────────────┐ ← Title Bar (3 lines)
│ App Title │
│ Subtitle/Info │
├─────────────────────────────────┤ ─┐
│ ┌─────────────┬───────────────┐ │ │
│ │ │ │ │ │
│ │ Left │ Right │ │ │ Content Height
│ │ Panel │ Panel │ │ │ (minus borders)
│ │ │ │ │ │
│ └─────────────┴───────────────┘ │ │
├─────────────────────────────────┤ ─┘
│ Status Bar: Help text here │ ← Status Bar (1 line)
└─────────────────────────────────┘
Panel borders (┌─┐ └─┘) = 2 lines total (top + bottom)Correct Implementation
func (m model) calculateLayout() (int, int) {
contentWidth := m.width
contentHeight := m.height
if m.config.UI.ShowTitle {
contentHeight -= 3 // title bar (3 lines)
}
if m.config.UI.ShowStatus {
contentHeight -= 1 // status bar
}
// CRITICAL: Account for panel borders
contentHeight -= 2 // top + bottom borders
return contentWidth, contentHeight
}Height Calculation Example
Total Terminal Height: 25
- Title Bar: -3
- Status Bar: -1
- Panel Borders: -2
─────────────────────────
Content Height: 19 ✓Rule #2: Never Auto-Wrap in Bordered Panels
Always truncate text explicitly to prevent wrapping.
The Problem
When text wraps inside a bordered panel, it can cause:
- Panels to become different heights (misalignment)
- Content to overflow panel boundaries
- Inconsistent rendering across different terminal widths
Why This Happens
Lipgloss auto-wraps text that exceeds the panel width. In bordered panels, this creates extra lines you didn't account for in your height calculations.
The Solution
Calculate the maximum text width and truncate ALL strings before rendering:
// Calculate max text width to prevent wrapping
maxTextWidth := panelWidth - 4 // -2 for borders, -2 for padding
// Truncate ALL text before rendering
title = truncateString(title, maxTextWidth)
subtitle = truncateString(subtitle, maxTextWidth)
// Truncate content lines too
for i := 0; i < availableContentLines && i < len(content); i++ {
line := truncateString(content[i], maxTextWidth)
lines = append(lines, line)
}
// Helper function
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-1] + "…"
}Real-World Example
Without truncation, this subtitle wraps:
┌─────────────┐
│Weight: 2 | │
│Size: 80x25 │ ← Wrapped to 2 lines!
└─────────────┘With truncation:
┌─────────────┐
│Weight: 2 | …│ ← Truncated, stays 1 line
└─────────────┘Rule #3: Match Mouse Detection to Layout
Use X coordinates for horizontal layouts, Y coordinates for vertical layouts.
The Problem
If your layout orientation changes (side-by-side vs stacked), but your mouse detection logic doesn't, clicks won't work correctly.
The Solution
Check layout mode before processing mouse events:
func (m model) handleLeftClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
// ... boundary checks ...
if m.shouldUseVerticalStack() {
// Vertical stack mode: use Y coordinates
topHeight, _ := m.calculateVerticalStackLayout()
relY := msg.Y - contentStartY
if relY < topHeight {
m.focusedPanel = "left" // Top panel
} else if relY > topHeight {
m.focusedPanel = "right" // Bottom panel
}
} else {
// Side-by-side mode: use X coordinates
leftWidth, _ := m.calculateDualPaneLayout()
if msg.X < leftWidth {
m.focusedPanel = "left"
} else if msg.X > leftWidth {
m.focusedPanel = "right"
}
}
return m, nil
}Visual Guide
Horizontal Layout (use X coordinates):
┌────────┬────────┐
│ Left │ Right │
│ │ │
└────────┴────────┘
↑ msg.X determines which panelVertical Layout (use Y coordinates):
┌────────────────┐
│ Top │ ↑
├────────────────┤ msg.Y determines
│ Bottom │ which panel
└────────────────┘Rule #4: Use Weights, Not Pixels
Proportional layouts scale perfectly across all terminal sizes.
The Problem
Fixed pixel widths break when:
- Terminal is resized
- Different monitors have different dimensions
- Users have portrait vs landscape terminals
The Solution: Weight-Based Layout (LazyGit Pattern)
Instead of calculating pixel widths, assign weights to panels:
// Calculate weights based on focus
leftWeight, rightWeight := 1, 1
if m.accordionMode && m.focusedPanel == "left" {
leftWeight = 2 // Focused panel gets 2x weight
}
// Calculate actual widths from weights
totalWeight := leftWeight + rightWeight
leftWidth := (availableWidth * leftWeight) / totalWeight
rightWidth := availableWidth - leftWidthWeight Examples
Equal weights (1:1) = 50/50 split:
Total width: 80
leftWeight: 1, rightWeight: 1
totalWeight: 2
leftWidth = (80 * 1) / 2 = 40
rightWidth = 80 - 40 = 40
┌──────────────────────┬──────────────────────┐
│ │ │
│ 50% │ 50% │
│ │ │
└──────────────────────┴──────────────────────┘Focused weight (2:1) = 66/33 split:
Total width: 80
leftWeight: 2, rightWeight: 1
totalWeight: 3
leftWidth = (80 * 2) / 3 = 53
rightWidth = 80 - 53 = 27
┌────────────────────────────────┬─────────────┐
│ │ │
│ 66% │ 33% │
│ │ │
└────────────────────────────────┴─────────────┘Why This Works
1. Proportional - Always maintains exact ratios 2. Simple - No complex formulas, just division 3. Immediate - No animations needed, instant resize 4. Flexible - Change weight = instant layout change 5. Scalable - Works at any terminal size
Common Weight Patterns
// Equal split
leftWeight, rightWeight := 1, 1 // 50/50
// Accordion mode (focused panel larger)
if focusedPanel == "left" {
leftWeight, rightWeight = 2, 1 // 66/33
} else {
leftWeight, rightWeight = 1, 2 // 33/66
}
// Three panels
mainWeight, leftWeight, rightWeight := 2, 1, 1 // 50/25/25
// Preview mode (main content larger)
contentWeight, previewWeight := 3, 1 // 75/25Common Pitfalls
❌ DON'T: Set explicit Height() on bordered styles
// BAD: Can cause misalignment
panelStyle := lipgloss.NewStyle().
Border(border).
Height(height) // Don't do this!Why it's bad: The height includes borders, making calculations confusing and error-prone.
✅ DO: Fill content to exact height, let borders add naturally
// GOOD: Fill content lines to exact height
for len(lines) < innerHeight {
lines = append(lines, "")
}
panelStyle := lipgloss.NewStyle().Border(border)
// No Height() - let content determine it❌ DON'T: Assume layout orientation in mouse handlers
// BAD: Always using X coordinate
if msg.X < leftWidth {
// This breaks in vertical stack!
}✅ DO: Check layout mode first
// GOOD: Different logic per orientation
if m.shouldUseVerticalStack() {
// Use Y coordinates
} else {
// Use X coordinates
}Debugging Checklist
When panels don't align or render incorrectly, check in this order:
1. Height accounting (Rule #1)
- Did you subtract 2 for borders?
- Formula:
totalHeight - titleLines - statusLines - 2
2. Text wrapping (Rule #2)
- Is text wrapping to multiple lines?
maxWidth = panelWidth - 4- Truncate ALL strings explicitly
3. Mouse detection (Rule #3)
- Vertical stack? → Use
msg.Y - Horizontal? → Use
msg.X - Match detection to layout mode
4. Weight calculations (Rule #4)
- Using weights instead of pixels?
- Formula:
(totalWidth * weight) / totalWeights
Decision Tree
Panel Layout Problem?
│
├─ Panels covering title/status bar?
│ └─> Check height accounting (Rule #1)
│ - Did you subtract 2 for borders?
│ - Formula: totalHeight - titleLines - statusLines - 2
│
├─ Panels misaligned (different heights)?
│ └─> Check text wrapping (Rule #2)
│ - Is text wrapping to multiple lines?
│ - maxWidth = panelWidth - 4
│ - Truncate ALL strings explicitly
│
├─ Mouse clicks not working?
│ └─> Check mouse detection (Rule #3)
│ - Vertical stack? → Use msg.Y
│ - Horizontal? → Use msg.X
│ - Match detection to layout mode
│
└─ Accordion/resize janky?
└─> Check weight calculations (Rule #4)
- Using weights instead of pixels?
- Formula: (totalWidth * weight) / totalWeightsSummary
Follow these 4 rules and you'll avoid 90% of TUI layout bugs:
1. ✅ Always account for borders - Subtract 2 before rendering 2. ✅ Never auto-wrap - Truncate explicitly 3. ✅ Match mouse to layout - X for horizontal, Y for vertical 4. ✅ Use weights - Proportional scaling
These patterns are battle-tested and will save you hours of debugging frustration.
TUI Troubleshooting Guide
Common issues and their solutions when building Bubbletea applications.
Layout Issues
Panels Covering Header/Status Bar
Symptom: Panels overflow and cover the title bar or status bar, especially on portrait/vertical monitors.
Root Cause: Height calculation doesn't account for panel borders.
Solution: Always subtract 2 for borders in height calculations. See Golden Rules #1.
// WRONG
contentHeight := totalHeight - titleLines - statusLines
// CORRECT
contentHeight := totalHeight - titleLines - statusLines - 2 // -2 for bordersQuick Fix:
func (m model) calculateLayout() (int, int) {
contentHeight := m.height
if m.config.UI.ShowTitle {
contentHeight -= 3 // title bar
}
if m.config.UI.ShowStatus {
contentHeight -= 1 // status bar
}
contentHeight -= 2 // CRITICAL: borders
return m.width, contentHeight
}Panels Misaligned (Different Heights)
Symptom: One panel appears one or more rows higher/lower than adjacent panels.
Root Cause: Text wrapping. Long strings wrap to multiple lines in narrower panels, making them taller.
Solution: Never rely on auto-wrapping. Truncate all text explicitly. See Golden Rules #2.
maxTextWidth := panelWidth - 4 // -2 borders, -2 padding
// Truncate everything
title = truncateString(title, maxTextWidth)
subtitle = truncateString(subtitle, maxTextWidth)
for i := range contentLines {
contentLines[i] = truncateString(contentLines[i], maxTextWidth)
}Helper function:
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen < 1 {
return ""
}
return s[:maxLen-1] + "…"
}Borders Not Rendering
Symptom: Panel borders missing or showing weird characters.
Possible Causes:
1. Terminal doesn't support Unicode box drawing
// Use ASCII fallback
border := lipgloss.NormalBorder() // Uses +-| instead of ┌─┐2. Terminal encoding issue
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-83. Wrong border style
// Make sure you're using a valid border
import "github.com/charmbracelet/lipgloss"
border := lipgloss.RoundedBorder() // ╭─╮
// or
border := lipgloss.NormalBorder() // ┌─┐
// or
border := lipgloss.DoubleBorder() // ╔═╗Content Overflows Panel
Symptom: Text or content extends beyond panel boundaries.
Solutions:
1. For text content:
// Truncate to fit
maxWidth := panelWidth - 4
content = truncateString(content, maxWidth)2. For multi-line content:
// Limit both width and height
maxWidth := panelWidth - 4
maxHeight := panelHeight - 2
lines := strings.Split(content, "\n")
for i := 0; i < maxHeight && i < len(lines); i++ {
displayLines = append(displayLines,
truncateString(lines[i], maxWidth))
}3. For wrapped content:
// Use lipgloss MaxWidth
content := lipgloss.NewStyle().
MaxWidth(panelWidth - 4).
Render(text)Mouse Issues
Mouse Clicks Not Working
Symptom: Clicking panels doesn't change focus or trigger actions.
Possible Causes:
1. Mouse not enabled in program
// In main()
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(),
tea.WithMouseCellMotion(), // Enable mouse
)2. Not handling MouseMsg
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
return m.handleMouse(msg)
}
}3. Wrong coordinate system See Mouse Detection Not Matching Layout.
Mouse Detection Not Matching Layout
Symptom: Clicks work in horizontal layout but break when terminal is resized to vertical stack (or vice versa).
Root Cause: Using X coordinates when layout is vertical, or Y coordinates when horizontal.
Solution: Check layout mode before processing mouse events. See Golden Rules #3.
func (m model) handleLeftClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.shouldUseVerticalStack() {
// Vertical: use Y coordinates
if msg.Y < topPanelHeight {
m.focusedPanel = "top"
} else {
m.focusedPanel = "bottom"
}
} else {
// Horizontal: use X coordinates
if msg.X < leftPanelWidth {
m.focusedPanel = "left"
} else {
m.focusedPanel = "right"
}
}
return m, nil
}Mouse Scrolling Not Working
Symptom: Mouse wheel doesn't scroll content.
Solution:
case tea.MouseMsg:
switch msg.Type {
case tea.MouseWheelUp:
m.scroll -= 3
if m.scroll < 0 {
m.scroll = 0
}
case tea.MouseWheelDown:
m.scroll += 3
maxScroll := len(m.content) - m.visibleLines
if m.scroll > maxScroll {
m.scroll = maxScroll
}
}Rendering Issues
Flickering/Jittering
Symptom: Screen flickers or elements jump around during updates.
Causes & Solutions:
1. Updating too frequently
// Don't update on every tick
case tickMsg:
if m.needsUpdate {
m.needsUpdate = false
return m, nil
}
return m, tick() // Skip render2. Inconsistent dimensions
// Cache dimensions, don't recalculate every frame
type model struct {
width, height int
cachedLayout string
layoutDirty bool
}
func (m model) View() string {
if m.layoutDirty {
m.cachedLayout = m.renderLayout()
m.layoutDirty = false
}
return m.cachedLayout
}3. Using alt screen incorrectly
// Always use alt screen for full-screen TUIs
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(), // Essential!
)Colors Not Showing
Symptom: Colors appear as plain text or wrong colors.
Possible Causes:
1. Terminal doesn't support colors
# Check color support
echo $COLORTERM # Should show "truecolor" or "24bit"
tput colors # Should show 256 or more2. Not using lipgloss properly
// Use lipgloss for color
import "github.com/charmbracelet/lipgloss"
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Background(lipgloss.Color("#000000"))3. Environment variables
export TERM=xterm-256color
export COLORTERM=truecolorEmojis/Unicode Wrong Width
Symptom: Emojis cause text misalignment, borders broken, columns don't line up.
Root Cause: Different terminals calculate emoji width differently (1 vs 2 cells).
Solutions:
1. Detect and adjust
import "github.com/mattn/go-runewidth"
// Get actual display width
width := runewidth.StringWidth(text)2. Avoid emojis in structural elements
// DON'T use emojis in borders, tables, or aligned content
// DO use emojis in content that doesn't need precise alignment3. Use icons from fixed-width sets
// Use Nerd Fonts or similar fixed-width icon fonts instead
// (vs 📁 emoji)4. Terminal-specific settings For WezTerm, see project's docs/EMOJI_WIDTH_FIX.md.
Keyboard Issues
Keyboard Shortcuts Not Working
Symptom: Key presses don't trigger expected actions.
Debugging Steps:
1. Log the key events
case tea.KeyMsg:
log.Printf("Key: %s, Type: %s", msg.String(), msg.Type)2. Check key matching
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Quit key.Binding
}
var keys = keyMap{
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
// In Update
case tea.KeyMsg:
if key.Matches(msg, keys.Quit) {
return m, tea.Quit
}3. Check focus state
// Make sure the right component has focus
case tea.KeyMsg:
switch m.focused {
case "input":
// Route to input
case "list":
// Route to list
}Special Keys Not Detected
Symptom: Function keys, Ctrl combinations, or other special keys don't work.
Solution: Use tea.KeyType constants:
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyTab:
m.nextPanel()
case tea.KeyF1:
m.showHelp()
case tea.KeyEnter:
m.confirm()
}Common keys:
tea.KeyTabtea.KeyEntertea.KeyEsctea.KeyCtrlCtea.KeyUp/Down/Left/Righttea.KeyF1throughtea.KeyF12
Performance Issues
Slow Rendering
Symptom: Noticeable lag when updating the display.
Solutions:
1. Only render visible content
// Don't render 1000 lines when only 20 are visible
visibleStart := m.scroll
visibleEnd := min(m.scroll + m.height, len(m.lines))
for i := visibleStart; i < visibleEnd; i++ {
rendered = append(rendered, m.lines[i])
}2. Cache expensive computations
type model struct {
content []string
renderedCache string
contentDirty bool
}
func (m *model) View() string {
if m.contentDirty {
m.renderedCache = m.renderContent()
m.contentDirty = false
}
return m.renderedCache
}3. Avoid string concatenation in loops
// SLOW
var s string
for _, line := range lines {
s += line + "\n" // Creates new string each iteration
}
// FAST
var b strings.Builder
for _, line := range lines {
b.WriteString(line)
b.WriteString("\n")
}
s := b.String()4. Lazy load data
// Don't load all files upfront
type model struct {
fileList []string
fileContent map[string]string // Load on demand
}
func (m *model) getFileContent(path string) string {
if content, ok := m.fileContent[path]; ok {
return content
}
content := loadFile(path)
m.fileContent[path] = content
return content
}High Memory Usage
Symptom: Application uses excessive memory.
Solutions:
1. Limit cache size
const maxCacheEntries = 100
func (m *model) addToCache(key, value string) {
if len(m.cache) >= maxCacheEntries {
// Evict oldest entry
for k := range m.cache {
delete(m.cache, k)
break
}
}
m.cache[key] = value
}2. Stream large files
// Don't load entire file into memory
func readLines(path string, start, count int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var lines []string
lineNum := 0
for scanner.Scan() {
if lineNum >= start && lineNum < start+count {
lines = append(lines, scanner.Text())
}
lineNum++
if lineNum >= start+count {
break
}
}
return lines, scanner.Err()
}Configuration Issues
Config File Not Loading
Symptom: Application doesn't respect config file settings.
Common Locations:
configPaths := []string{
"./config.yaml", // Current directory
"~/.config/yourapp/config.yaml", // XDG config
"/etc/yourapp/config.yaml", // System-wide
}
for _, path := range configPaths {
if fileExists(expandPath(path)) {
return loadConfig(path)
}
}Debug config loading:
func loadConfig(path string) (*Config, error) {
log.Printf("Attempting to load config from: %s", path)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("Failed to read config: %v", err)
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
log.Printf("Failed to parse config: %v", err)
return nil, err
}
log.Printf("Successfully loaded config: %+v", cfg)
return &cfg, nil
}Debugging Decision Tree
Problem?
│
├─ Layout issue?
│ ├─ Panels covering title/status? → Check border accounting (Rule #1)
│ ├─ Panels misaligned? → Check text wrapping (Rule #2)
│ ├─ Borders missing? → Check terminal Unicode support
│ └─ Content overflow? → Check truncation
│
├─ Mouse issue?
│ ├─ Clicks not working? → Check mouse enabled + MouseMsg handling
│ ├─ Wrong panel focused? → Check layout orientation (Rule #3)
│ └─ Scrolling broken? → Check MouseWheel handling
│
├─ Rendering issue?
│ ├─ Flickering? → Check update frequency + alt screen
│ ├─ No colors? → Check terminal support + TERM variable
│ └─ Emoji alignment? → Check terminal emoji width settings
│
├─ Keyboard issue?
│ ├─ Shortcuts not working? → Log KeyMsg, check key.Matches
│ ├─ Special keys broken? → Use tea.KeyType constants
│ └─ Wrong component responding? → Check focus state
│
└─ Performance issue?
├─ Slow rendering? → Cache, virtual scrolling, visible-only
└─ High memory? → Limit cache, stream data
General Debugging Tips
1. Enable Debug Logging
// Create debug log file
func setupDebugLog() *os.File {
f, err := os.OpenFile("debug.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil
}
log.SetOutput(f)
return f
}
// In main()
logFile := setupDebugLog()
if logFile != nil {
defer logFile.Close()
}2. Log All Messages
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("Update: %T %+v", msg, msg)
// ... rest of update logic
}3. Inspect Terminal Capabilities
# Check terminal type
echo $TERM
# Check color support
tput colors
# Check dimensions
tput cols
tput lines
# Check capabilities
infocmp $TERM4. Test in Different Terminals
Try your app in multiple terminals:
- iTerm2 (macOS)
- Alacritty (cross-platform)
- kitty (cross-platform)
- WezTerm (cross-platform)
- Windows Terminal (Windows)
- Termux (Android)
5. Use Alt Screen
Always use alt screen for full-screen TUIs:
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(), // Essential!
tea.WithMouseCellMotion(),
)This prevents messing up the user's terminal when your app exits.
Getting Help
If you're still stuck:
1. Check the Golden Rules - 90% of issues are layout-related 2. Review the Components Guide for proper component usage 3. Check Bubbletea examples: https://github.com/charmbracelet/bubbletea/tree/master/examples 4. Ask in Charm Discord: https://charm.sh/discord 5. Search Bubbletea issues: https://github.com/charmbracelet/bubbletea/issues
Related skills
How it compares
Pick bubbletea over generic Go CLI snippets when you need Charm Bubble Tea architecture, Lipgloss layouts, and documented dual-pane TUI patterns.
FAQ
Which Go libraries does the bubbletea skill use?
The bubbletea skill centers on github.com/charmbracelet/bubbletea with Lipgloss styling, Bubbles UI components, and gopkg.in/yaml.v3 for configuration-driven terminal applications.
What are the Four Golden Rules in bubbletea?
The bubbletea skill's Four Golden Rules require accounting for border height in layout calculations, explicit text truncation, mouse detection aligned to layout orientation, and proportional weights instead of fixed pixel sizing.
How do developers install the bubbletea skill?
Developers install bubbletea with npx skills add ggprompts/tfe --skill bubbletea, which places the skill into the project's agent skills directory for Claude Code TUI workflows.