TUI Rendering Test Guide
This guide explains how to use the rendering test apparatus to debug and prevent TUI layout issues.
Overview
The rendering test apparatus (internal/tui/rendering_test_utils.go) provides tools to:
- Analyze and verify View() output structure
- Write regression tests for rendering bugs
- Detect overlaps, misalignment, and other layout problems
- Simulate different terminal conditions
Core Components
RenderingAnalyzer
Analyzes a View() output string to verify structure and catch regressions.
Key Methods:
AssertLineCount(n)— verify exact line countAssertContains(text)— verify text appears in outputAssertLineSeparation(text1, text2)— verify text1 and text2 are on different lines (crucial for overlap bugs!)AssertNoHorizontalConcat(text1, text2)— verify text1 and text2 don't appear on the same lineAssertStructure(elements...)— verify output contains expected elementsDump()— print full output with line numbers for manual reviewStrippedLines()— get output with ANSI codes removed
Example:
func TestQueueIndicatorNotOverlapped(t *testing.T) {
output := /* your View() output */
analyzer := tui.NewRenderingAnalyzer(t, output)
// Verify the bug doesn't regress:
analyzer.AssertLineSeparation("routing status", "⏳")
analyzer.AssertNoHorizontalConcat("resolved:", "queue:")
}
Real-World Example: The Overlap Bug
The bug we just fixed: queue indicator appearing on the same terminal line as routing status.
Test that catches this bug:
func TestModeAwaitingLLMQueueIndicatorNotOverlapped(t *testing.T) {
// Set up the scenario
m := newTestModel()
m.mode = ModeAwaitingLLM
m.transcript.AppendLive("↳ resolved: view")
m.transcript.FinalizeLive("")
// Render
output := m.View()
analyzer := tui.NewRenderingAnalyzer(t, output)
// Regression test - if someone breaks this, the test fails
analyzer.AssertLineSeparation("resolved", "⏳")
analyzer.AssertNoHorizontalConcat("resolved", "queue:")
}
Common Rendering Problems and How to Test Them
1. Status Row Bleeding Into Scrollback
Symptom: A status line appears on the same terminal line as content above.
Root Cause: JoinVertical's padding or double newlines causing cursor misalignment.
Test:
func TestStatusRowDoesNotBleed(t *testing.T) {
analyzer := tui.NewRenderingAnalyzer(t, output)
// Find the status row and the line before it
analyzer.AssertLineSeparation("last content line", "framework: awaiting")
}
2. Horizontal Concatenation When Should Be Vertical
Symptom: Two parts appear on the same line with padding between them.
Root Cause: Using JoinVertical with mismatched line counts, or padding being applied incorrectly.
Test:
func TestNoUnintendedHorizontalLayout(t *testing.T) {
analyzer := tui.NewRenderingAnalyzer(t, output)
// These should never appear on the same line
analyzer.AssertNoHorizontalConcat("indicator", "status_row")
}
3. Embedded Newlines Being Stripped or Doubled
Symptom: Multi-line parts (like indicator\n + prompt) render incorrectly.
Root Cause: TrimRight or padding affecting embedded newlines.
Test:
func TestEmbeddedNewlinesPreserved(t *testing.T) {
analyzer := tui.NewRenderingAnalyzer(t, output)
// Multi-line prompt should still have both lines
analyzer.AssertLineCount(expectedLines)
analyzer.AssertLineSeparation("⏳ queue indicator", "↳ prompt")
}
Writing Your Own Regression Tests
Step 1: Identify the Bug Symptoms
Example: "Queue indicator appears on same line as routing status"
Step 2: Create a Test That Reproduces the Bug
func TestBugName(t *testing.T) {
// Set up the exact conditions that trigger the bug
m := setupModelForBug()
// Render
output := m.View()
// Verify the bug would be caught
analyzer := tui.NewRenderingAnalyzer(t, output)
// These assertions SHOULD FAIL if the bug exists
// If they pass, the bug is fixed
analyzer.AssertExpectedStructure()
}
Step 3: Add to View Rendering Tests
Place your test in rendering_regression_test.go or create a new *_rendering_test.go file.
Step 4: Verify the Test Catches Regressions
Temporarily revert the fix, run the test, and confirm it fails. Then re-apply the fix and confirm it passes.
Debugging Rendering Issues
1. Use Dump() to See Full Output
analyzer.Dump() // Prints line-by-line output with byte counts
This shows:
- Raw output with ANSI codes
- Line numbers and byte lengths (useful for width issues)
- Stripped output for reading visible content
2. Check for ANSI Code Artifacts
ANSI codes affect byte length but not visible width. The analyzer handles this automatically:
analyzer.AssertLineSeparation(...) // Works correctly with ANSI codes
analyzer.StrippedLines() // Access ANSI-free content
3. Test Different Terminal Widths
If the bug only manifests at certain widths:
for _, width := range []int{80, 120, 200} {
t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
m.SetWidth(width)
output := m.View()
analyzer := tui.NewRenderingAnalyzer(t, output)
analyzer.AssertLineSeparation("text1", "text2")
})
}
Integration with Main Test Suite
All rendering tests are in internal/tui/*_test.go files:
# Run all rendering tests
go test ./internal/tui -run Rendering -v
# Run a specific regression test
go test ./internal/tui -run TestQueueIndicatorNotOverlapped -v
# Show full output for debugging
go test ./internal/tui -run TestName -v -args -test.v
Common Mistakes to Avoid
- Not comparing lines correctly — use
AssertLineSeparation()not string contains checks - Forgetting ANSI codes — the analyzer handles them, but stripped content is what users see
- Testing the implementation, not the behavior — test what users see, not internal line counts
- Making tests too specific — focus on the critical structure, not exact byte positions
Key Assertion Patterns
Pattern 1: No Overlap
analyzer.AssertLineSeparation("status", "indicator")
analyzer.AssertNoHorizontalConcat("status", "indicator")
Pattern 2: Correct Structure
analyzer.AssertStructure("header", "content", "footer")
Pattern 3: Multi-line Parts Work
analyzer.AssertLineCount(expectedCount)
analyzer.AssertLineSeparation("line1_of_part", "line2_of_part")
Pattern 4: Content is Present
analyzer.AssertContains("expected_text")
analyzer.AssertNotContains("error_marker")
Performance Considerations
RenderingAnalyzeris lightweight - suitable for every test- ANSI stripping is O(n) in output length
- All assertions are fast (string operations)
- No external dependencies
Future Enhancements
The framework is designed to be extended:
- Full RootModel Integration —
RenderingTestHelpercan be expanded to actually construct and render RootModel instances - Visual Regression — add image comparison for theme/style testing
- Performance Metrics — measure rendering time under different conditions
- Snapshot Testing — compare full output against golden files
- Terminal Emulation — simulate actual terminal rendering edge cases
See Also
rendering_test_utils.go— implementation and APIrendering_regression_test.go— example testsview_parts_assembly_test.go— JoinVertical/concatenation comparison- Main TUI code:
tui.go(View() method)