| name | rendering-tests |
| description | Use when debugging TUI layout issues, writing regression tests for rendering problems, or analyzing View() output structure with RenderingAnalyzer. |
TUI Rendering Test Skill
Use this when: Debugging TUI layout issues, writing regression tests for rendering problems, or analyzing View() output structure.
Quick Start
For any rendering bug, use the test apparatus to:
output := m.View()
analyzer := tui.NewRenderingAnalyzer(t, output)
analyzer.AssertLineSeparation("routing status", "queue indicator")
analyzer.AssertNoHorizontalConcat("status", "queue:")
analyzer.AssertContains("expected text")
analyzer.Dump()
Key Methods
| Method | Use For |
|---|
AssertLineSeparation(text1, text2) | Prevent overlap bugs — verify items are on different lines |
AssertNoHorizontalConcat(text1, text2) | Detect horizontal concatenation when should be vertical |
AssertContains(text) | Verify expected content is in output |
AssertLineCount(n) | Verify exact line count |
AssertStructure(elements...) | Verify output contains expected elements |
Dump() | Print full output with line numbers for debugging |
StrippedLines() | Get output with ANSI codes removed |
Real Example: The Overlap Bug
Bug: Queue indicator appeared on same terminal line as routing status.
Test that catches it:
func TestQueueIndicatorNotOverlapped(t *testing.T) {
m := newTestModel()
m.mode = ModeAwaitingLLM
m.transcript.AppendLive("↳ resolved: view")
output := m.View()
analyzer := tui.NewRenderingAnalyzer(t, output)
analyzer.AssertLineSeparation("resolved", "⏳")
analyzer.AssertNoHorizontalConcat("resolved", "queue:")
}
Common Rendering Problems
1. Status Row Appears on Same Line as Content Above
Symptom: "last content framework: awaiting" on one line
Test:
analyzer.AssertLineSeparation("content above", "framework: awaiting")
Root Cause: JoinVertical padding or double newlines causing cursor misalignment.
2. Multi-line Parts Render Incorrectly
Symptom: Indicator and prompt on same line when should be separate
Test:
analyzer.AssertLineSeparation("⏳ queue", "↳ prompt")
analyzer.AssertLineCount(expectedLines)
3. ANSI Code Artifacts
Symptom: Weird characters in output or misaligned styling
Test:
analyzer.StrippedLines()
analyzer.AssertContains("visible text")
Writing Your Regression Test
Step 1: Identify Bug Symptoms
Example: "Queue indicator appears on same line as routing status"
Step 2: Set Up Test Scenario
func TestBugName(t *testing.T) {
m := newTestModel()
m.mode = ModeAwaitingLLM
m.transcript.AppendLive("↳ status")
m.inputQueue = append(m.inputQueue, "queued input")
output := m.View()
analyzer := tui.NewRenderingAnalyzer(t, output)
Step 3: Add Assertions
analyzer.AssertLineSeparation("status", "queue:")
analyzer.AssertNoHorizontalConcat("status", "queue:")
}
Step 4: Verify Test Catches Regression
- Temporarily revert the fix
- Run test — it should fail
- Re-apply fix — test should pass
Debugging with Dump()
analyzer.Dump()
Shows:
- Each line with byte count
- ANSI codes included
- Stripped version for reading
- Line numbers for reference
Example output:
=== Rendered Output ===
Line 0 (bytes= 35): "↳ resolved: deterministic · 1.00"
Line 1 (bytes=240): "─────────────────────────────…"
Line 2 (bytes= 60): "⏳ ⠋ thinking… · queue: 0"
Line 3 (bytes= 14): "↳ test input"
Testing Different Terminal Widths
If 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")
})
}
Files
rendering_test_utils.go — RenderingAnalyzer implementation
rendering_regression_test.go — Example tests (copy these patterns)
docs/tui/rendering-tests.md — Full documentation
Common Mistakes
❌ Using string Contains for layout — use AssertLineSeparation() instead
❌ Forgetting about ANSI codes — analyzer handles them, but be aware of byte vs visible length
❌ Testing implementation, not behavior — test what users see
❌ Tests too specific — focus on critical structure, not exact positions
⚠️ Critical Pitfall: When Unit Tests Aren't Enough
The mistake: Testing View() output in isolation and declaring bugs fixed based on test passes.
When this fails: Bugs involving:
- Concurrent I/O (slog writes while TUI renders)
- Interaction with external systems (files, terminal, subprocesses)
- Timing-dependent behavior (race conditions)
- Terminal state (cursor position, alt-screen mode, terminal width)
Example: The slog/TUI Race Condition
User reported: "Queue indicator appears at end of log lines"
2026/05/29 05:40:41 INFO metamode.oracle.event ... ⏳ ⠏ running…
Isolated test (❌ can't catch it):
func TestQueueIndicator(t *testing.T) {
output := m.View()
analyzer := tui.NewRenderingAnalyzer(t, output)
analyzer.AssertLineSeparation("queue", "status")
}
Integration test (✅ catches it):
func TestSlogDoesNotMixWithQueueIndicator(t *testing.T) {
var stderrBuf strings.Builder
oldDefault := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&stderrBuf, nil)))
defer slog.SetDefault(oldDefault)
go func() {
for i := 0; i < 50; i++ {
slog.Info("metamode.oracle.event", "type", "system")
time.Sleep(time.Microsecond)
}
}()
for i := 0; i < 50; i++ {
m.View()
time.Sleep(time.Microsecond)
}
stderrLines := strings.Split(stderrBuf.String(), "\n")
for _, line := range stderrLines {
if strings.Contains(line, "INFO") && strings.Contains(line, "⏳") {
t.Fatalf("queue indicator mixed into log: %q", line)
}
}
}
The Pattern: Test What Users See, Not Function Returns
When to use isolated tests:
- ✅ Testing View() composition logic (elements on separate lines)
- ✅ Testing ANSI codes and styling
- ✅ Testing template rendering
When you must add integration tests:
- ❌ Bug involves concurrent I/O (slog, files, pipes)
- ❌ Bug manifests only when external systems interact
- ❌ User's bug report shows mixed output from multiple sources
- ❌ The symptom is "things appeared on same line" from different subsystems
Red Flags in Bug Reports
If the user's symptom involves:
- Log lines mixed with TUI elements → Concurrent I/O test needed
- File contents mixed/corrupted → Concurrent write test needed
- Terminal corruption across multiple renders → Integration test needed
- Timing-dependent behavior → Add delays/concurrency to test
Action: Don't just run the isolated test suite. Build a test that:
- Captures actual I/O (not just return values)
- Introduces the concurrency that causes the bug
- Asserts on what the user would observe
Verify the Test Would Fail
Before declaring a bug fixed:
- Run the test without the fix — it should FAIL
- Apply the fix — test should PASS
- If you can't make the test fail first, your test doesn't catch the bug
This is non-negotiable. A passing test that would pass even without your fix is not testing the fix.
Run Tests
go test ./internal/tui -run Rendering -v
go test ./internal/tui -run TestQueueIndicatorNotOverlapped -v
go test ./internal/tui -run TestName -v
Key Insight: The Overlap Bug Fix
The bug was caused by lipgloss.JoinVertical() applying width-based padding that caused unintended horizontal alignment of multi-line parts.
Fix: Use simple string concatenation instead.
return lipgloss.JoinVertical(lipgloss.Left, parts...)
var output strings.Builder
for _, part := range parts {
trimmed := strings.TrimRight(part, "\n")
if trimmed != "" {
if output.Len() > 0 {
output.WriteString("\n")
}
output.WriteString(trimmed)
}
}
return output.String()
This testing apparatus prevents this class of bugs from recurring.