Skip to content

fix(tui): dual-mode compositor to preserve footer in nested TUIs#92

Merged
dpup merged 2 commits into
mainfrom
tui
Jan 30, 2026
Merged

fix(tui): dual-mode compositor to preserve footer in nested TUIs#92
dpup merged 2 commits into
mainfrom
tui

Conversation

@dpup
Copy link
Copy Markdown
Collaborator

@dpup dpup commented Jan 29, 2026

Switch between DECSTBM passthrough (scroll mode) and a VT emulator compositor when the child process enters alternate screen mode. This prevents nested TUIs like Claude Code and vim from clobbering the status bar footer.

  • Detect alternate screen enter/exit sequences (including split writes)
  • Compositor mode: feed output to charmbracelet/x/vt emulator, render at ~60fps with line-by-line absolute positioning
  • Scroll mode: redraw footer after every write to repair clobbering
  • Fix "Initializing..." message missing trailing newline

@claude
Copy link
Copy Markdown

claude Bot commented Jan 29, 2026

📦 Previous Review (superseded by newer review)

Code Review: Dual-Mode TUI Compositor

This PR implements a sophisticated dual-mode terminal compositor that switches between scroll mode (DECSTBM passthrough) and compositor mode (VT emulator) to preserve the footer in nested TUIs. The implementation is well-designed and thoroughly tested.

Strengths

1. Excellent Architecture

  • Clean separation between scroll mode and compositor mode with clear mode transitions
  • Smart detection of alternate screen sequences (including split writes across boundaries)
  • Proper resource management with goroutine lifecycle for the render loop
  • Comprehensive test coverage (261 new test lines covering all key scenarios)

2. Performance Considerations

  • 60fps render rate (16ms tick) with dirty flag optimization prevents unnecessary renders
  • Zero overhead in scroll mode (passthrough with scrollback preserved)
  • Compositor mode only activates when child enters alternate screen

3. Robust Edge Case Handling

  • Split escape sequence handling across Write() calls (writer.go:159-168, 221-226)
  • Proper cleanup on mode transitions and terminal resize
  • Mutex protection for all shared state
  • Buffer size limits for Apple container initialization (maxInitBuffer)

Issues to Address

1. Potential Goroutine Leak on Error

In enterCompositorLocked() (writer.go:338), if the goroutine starts but w.out.Write() fails, the render loop keeps running:

if _, err := w.out.Write(buf.Bytes()); err != nil {
    return err  // ⚠️ Leaks goroutine - stopRender not closed
}

// Start render ticker
w.stopRender = make(chan struct{})
w.renderTicker = time.NewTicker(renderInterval)
go w.renderLoop(w.renderTicker, w.stopRender)

Fix: Start the goroutine after the write succeeds, or call stopRenderLoop() on error before returning.

2. Race Condition in Render Loop

The render loop (writer.go:377-391) checks multiple fields without holding the mutex for the entire operation:

w.mu.Lock()
if w.dirty && w.altScreen && w.emulator != nil {
    w.renderCompositorLocked()  // Holds lock internally
    w.dirty = false
}
w.mu.Unlock()

This is actually safe because renderCompositorLocked() re-checks state, but the logic could be clearer.

3. Ignored Error in Critical Path

Multiple locations use //nolint:errcheck for w.out.Write() (writer.go:441, 496, 538). While these are in best-effort rendering paths, silent failures could hide terminal communication issues.

Recommendation: Consider at least logging errors, especially in redrawFooterLocked() where footer corruption might indicate terminal issues.

4. Potential Panic on Nil Emulator

In renderCompositorLocked() (writer.go:408-442), there's an early nil check, but exitCompositorLocked() calls it after setting w.emulator = nil (writer.go:356). This is safe because the nil check happens first, but the ordering is fragile.

5. Missing Documentation for Public API

redrawFooterLocked() (writer.go:531-539) is well-commented internally but has no godoc. Consider adding public documentation explaining when/why this is needed.

Performance Concerns

1. Render Loop Allocation

renderCompositorLocked() (writer.go:421-422) allocates on every render:

rendered := w.emulator.Render()
lines := strings.Split(rendered, "\r\n")

For 60fps rendering, this creates significant GC pressure. Consider:

  • Reusing line slice buffers
  • Investigating if charmbracelet/x/vt supports incremental rendering

2. Footer Redraw on Every Write in Scroll Mode

Line 177-178 calls redrawFooterLocked() after every write in scroll mode. This adds ~100 bytes of escape sequences per write. While correct (tmux does this), it may cause performance issues with high-throughput output.

Consider: Rate-limiting footer redraws (e.g., max once per 16ms) if this becomes a bottleneck.

Security & Correctness

1. Escape Sequence Injection

The code intercepts specific escape sequences but passes through all others. This is correct, but ensure the VT emulator sanitizes output to prevent terminal exploitation.

2. Terminal State Consistency

Excellent handling of terminal state transitions:

  • ✅ Proper cleanup in Cleanup() exits alt screen and resets DECSTBM
  • ✅ Resize correctly handles both modes
  • ✅ Footer restoration after mode switches

3. Concurrent Access

All public methods properly use w.mu for thread safety. Good job!

Testing

Outstanding test coverage:

  • ✅ Alternate screen enter/exit transitions
  • ✅ Split escape sequences across writes
  • ✅ Compositor mode output routing
  • ✅ Resize in both modes
  • ✅ Multiple alt screen sequence variants (?1049h, ?47h, ?1047h)
  • ✅ Dirty flag behavior
  • ✅ Apple container initialization flow

Missing test cases:

  • Concurrent writes from multiple goroutines (stress test the mutex)
  • Error handling paths (e.g., w.out.Write() failures)
  • Very large terminal sizes (edge cases for contentHeight calculation)

Minor Issues

1. Magic Numbers

  • Line 38: 16 * time.Millisecond - consider naming this constant (already done, but why 16ms vs 60fps exactly?)
  • Line 222: Why maxAltScreenSeqLen() instead of a computed constant?

2. Code Duplication
The alt screen sequence arrays (writer.go:26-35) could potentially be a map for cleaner lookup, though the current approach is likely faster.

3. exec.go Change
The change from fmt.Print() to fmt.Println() (exec.go:103) is correct and addresses the issue mentioned in the PR body about the missing newline. Clean fix.

Recommendations

Must Fix:

  1. Fix goroutine leak in enterCompositorLocked() error path

Should Fix:
2. Add error logging for ignored write errors
3. Document redrawFooterLocked() with godoc

Nice to Have:
4. Profile render loop allocations under high throughput
5. Add concurrency stress tests
6. Consider rate-limiting footer redraws in scroll mode

Verdict

This is excellent work with a well-thought-out design. The dual-mode approach elegantly solves the nested TUI footer problem while maintaining scrollback in normal operation. The code is clean, well-tested, and mostly production-ready.

Approve with minor fixes (goroutine leak #1).

Dependencies

The new charmbracelet/x/vt dependency appears legitimate and well-maintained. The version hash 20260127155452-b72a9a918687 suggests this is cutting-edge (likely from Jan 27, 2026). Verify this is a stable commit or consider pinning to a tagged release.

Switch between DECSTBM passthrough (scroll mode) and a VT emulator
compositor when the child process enters alternate screen mode. This
prevents nested TUIs like Claude Code and vim from clobbering the
status bar footer.

- Detect alternate screen enter/exit sequences (including split writes)
- Compositor mode: feed output to charmbracelet/x/vt emulator, render
  at ~60fps with line-by-line absolute positioning
- Scroll mode: redraw footer after every write to repair clobbering
- Fix "Initializing..." message missing trailing newline
@claude
Copy link
Copy Markdown

claude Bot commented Jan 30, 2026

📦 Previous Review (superseded by newer review)

Pull Request Review: Dual-mode compositor to preserve footer in nested TUIs

This is a well-designed solution to a challenging problem. The implementation shows strong technical understanding of terminal emulation and careful attention to edge cases.

✅ Strengths

Architecture & Design

  • The dual-mode approach is elegant: scroll mode for normal operation with zero overhead, compositor mode for nested TUIs. This preserves scrollback in the common case while solving the footer clobbering issue.
  • Clear separation of concerns between escape sequence detection (processDataLocked), mode switching (enterCompositorLocked/exitCompositorLocked), and rendering (renderCompositorLocked).
  • The buffering mechanism for split escape sequences (escBuf) is thoughtful and handles edge cases like sequences split across Write() calls.

Code Quality

  • Excellent test coverage with 261 new test lines covering all major scenarios: mode transitions, split sequences, resizing in both modes, and edge cases.
  • Good use of comments explaining non-obvious behavior (e.g., why DECSTBM isn't used in compositor mode at writer.go:317-320).
  • Proper resource cleanup with render loop goroutine management.

User Experience

  • The status bar enhancements (grants display, runtime colors, truncation cascade) provide better visibility into the moat environment.
  • The fix for the "Initializing..." message (adding newline) is a nice attention to detail.

🔍 Issues & Recommendations

1. Goroutine Leak Risk (Medium Priority)

Location: writer.go:342

The render loop goroutine is started in enterCompositorLocked() but relies on stopRenderLoop() being called during cleanup. If Writer.Cleanup() is never called (e.g., panic, process kill), the goroutine leaks.

Recommendation: Consider adding a context-based approach or making cleanup more defensive:

func NewWriter(w io.Writer, bar *StatusBar, runtime string) *Writer {
    return &Writer{
        // ... existing fields
        stopRender: make(chan struct{}), // Pre-allocate
    }
}

// Then check for nil before closing in stopRenderLoop
func (w *Writer) stopRenderLoop() {
    if w.renderTicker != nil {
        w.renderTicker.Stop()
        w.renderTicker = nil
    }
    if w.stopRender != nil {
        select {
        case <-w.stopRender: // Already closed
        default:
            close(w.stopRender)
        }
        w.stopRender = nil
    }
}

2. Accidental Configuration Change (High Priority - Should Not Merge)

Location: agent.yaml:10

The PR includes a commented-out github grant:

grants:
  - anthropic
-  - github
+#  - github
   - ssh:github.com

This appears to be an accidental development change and should be reverted before merging.

3. Potential Race Condition (Low Priority)

Location: writer.go:387-392

The render loop checks w.dirty and w.altScreen under lock, which is correct. However, there's a theoretical race where:

  1. Render loop reads dirty=true, altScreen=true
  2. Between unlock and re-lock, exitCompositorLocked() is called
  3. renderCompositorLocked() might be called with emulator=nil

The nil check at line 413 protects against this, but the pattern is subtle.

Recommendation: Document this defensive nil check or make the pattern more explicit with early returns after checking state consistency.

4. Error Handling Inconsistency (Low Priority)

Location: writer.go:445, 500, 542

Several write operations ignore errors with //nolint:errcheck. While this may be intentional for cleanup/render paths, it could hide issues.

Recommendation: Consider logging these errors at debug level rather than silently ignoring them, especially for the compositor render path where write failures could indicate terminal issues.

5. Performance Consideration (Informational)

Location: writer.go:426, 430-433

The compositor renders the entire screen on every tick when dirty:

  • Splits rendered output by \r\n (allocates slice)
  • Clears and rewrites every line with absolute positioning
  • Runs at 60fps (~16ms interval)

For large terminal windows (e.g., 200x60), this could be 12,000+ bytes written 60 times per second.

Recommendation: Consider a dirty region tracking or differential rendering approach if performance becomes an issue. Not critical for initial implementation.

6. Missing Edge Case Test (Low Priority)

The test coverage is excellent, but one scenario isn't tested: what happens if the terminal enters/exits alternate screen mode multiple times rapidly?

Recommendation: Add a test for rapid mode switching:

func TestWriter_RapidModeSwitch(t *testing.T) {
    // Enter/exit alt screen 10 times rapidly
    // Verify no goroutine leaks, emulator state is consistent
}

📋 Minor Observations

  1. Documentation: The dual-mode architecture is well-commented in the code. Consider adding a brief architecture note to CLAUDE.md or docs explaining when/why compositor mode activates.

  2. Magic Numbers: renderInterval = 16 * time.Millisecond (60fps) is hardcoded. Consider making it configurable if different refresh rates are needed for testing or power-constrained environments.

  3. Dependencies: The PR adds charmbracelet/x/vt and several related packages. These are well-maintained libraries, so no concerns about the dependency additions.

  4. Commit Messages: The commit messages follow Conventional Commits format correctly. Good adherence to project standards.

🎯 Verdict

Recommendation: Approve with required changes

The core implementation is solid and well-tested. The architecture is sound and the code quality is high. However, the accidental agent.yaml change must be reverted before merge.

The goroutine leak risk should also be addressed, though it's less critical since Cleanup() is called in the normal path.

Required Changes Before Merge:

  1. ✅ Revert the agent.yaml change (remove the github grant comment)
  2. ✅ Address the goroutine leak concern (either with defensive cleanup or documentation that Cleanup() is always called)

Recommended Follow-ups (Post-Merge):

  • Add differential rendering if compositor performance becomes an issue
  • Consider making render interval configurable
  • Add documentation about the dual-mode architecture

@claude
Copy link
Copy Markdown

claude Bot commented Jan 30, 2026

Pull Request Review: Dual-Mode Compositor for Nested TUIs

This PR implements a sophisticated solution to a real problem with nested TUI applications. The dual-mode approach (scroll mode vs compositor mode) is well-designed. Here's my detailed feedback:


Code Quality & Architecture

Strengths

  1. Well-documented design: The dual-mode approach is clearly explained in comments (writer.go:41-49). The distinction between scroll mode (DECSTBM passthrough) and compositor mode (VT emulator) is well-articulated.

  2. Clean separation of concerns: The StatusBar and Writer responsibilities are properly separated. The new grants display and runtime color coding are nicely integrated.

  3. Comprehensive test coverage: The PR adds 319 lines of tests covering:

    • Alt screen enter/exit sequences
    • Split escape sequences across writes
    • Mode switching
    • Compositor rendering
    • Resize handling in compositor mode
    • Multiple alt screen sequence variants (?47h, ?1047h, ?1049h)
  4. Robust escape sequence detection: The buffering logic for split escape sequences (escBuf) is correct and handles edge cases like partial sequences at buffer boundaries.

  5. Proper resource cleanup: stopRenderLoop() guards against double-close with the select/default pattern (writer.go:405-412).


Potential Issues & Concerns

⚠️ Performance Considerations

  1. Footer redraw on every write in scroll mode (writer.go:177-179):

    if !w.altScreen && err == nil {
        w.redrawFooterLocked()
    }

    This happens after every Write() call, even for single bytes. For high-frequency output (like streaming logs), this could cause:

    • Excessive terminal I/O
    • Visual flicker
    • Performance degradation

    Recommendation: Consider coalescing footer redraws with a debounce/throttle mechanism similar to the compositor's 60fps ticker.

  2. 60fps render ticker always running: When in compositor mode, the ticker runs continuously even if there's no output. This is acceptable but could be optimized to pause when not dirty for extended periods.

🐛 Potential Bugs

  1. Race condition in renderLoop (writer.go:381-395):
    The goroutine could fire between exitCompositorLocked() setting altScreen=false and clearing the emulator. While there's a defensive nil check in renderCompositorLocked() (line 422), the race window exists where:

    • exitCompositorLocked() sets altScreen=false
    • Ticker fires, sees altScreen=true (stale read), enters lock
    • Finds emulator=nil

    The code handles this correctly, but the comment at line 421 could be clearer about this being an intentional race handled by the nil check.

  2. Missing error propagation (writer.go:454):

    w.out.Write(buf.Bytes()) //nolint:errcheck

    Write errors in renderCompositorLocked() are silently ignored. If the terminal is disconnected or has issues, this could lead to silent failures. Consider logging errors or tracking failure count.

  3. Escape sequence parser doesn't validate sequence structure: The matchAltScreen() function uses simple prefix matching. Malformed sequences like \x1b[?1049hX would match and consume the sequence but leave the 'X'. This is probably fine for well-behaved applications but could cause issues with malformed output.

🔒 Security Concerns

  1. maxInitBuffer limit (64KB) is good practice to prevent unbounded memory growth, but there's no similar limit on escBuf. While maxAltScreenSeqLen() is small (~11 bytes), it would be good to add an explicit size check or comment explaining why it's bounded.

  2. VT emulator trust: The charmbracelet/x/vt emulator processes untrusted container output. Ensure this library is kept up to date for any security fixes related to terminal escape sequence parsing.


Best Practices

Good patterns observed

  1. Error handling in state transitions: enterCompositorLocked() reverts state on write failure before starting the goroutine (lines 331-334). This prevents goroutine leaks.

  2. Test coverage for edge cases: The TestWriter_RapidModeSwitch test validates no goroutine leaks after 10 rapid mode switches.

  3. Mutex discipline: Consistent lock/unlock patterns with proper defer usage.

📝 Suggestions

  1. StatusBar.buildContent recursive calls (statusbar.go:102-108):
    The truncation cascade uses recursion which is fine for 2-3 levels, but could be written iteratively for clarity:

    // Current: s.buildContent(false, showName) -> s.buildContent(false, false)
    // Consider: explicit cascade with early returns
  2. Magic numbers:

    • renderInterval = 16ms (60fps) - good, documented
    • maxInitBuffer = 64KB - good, documented
    • Consider adding constants for ANSI codes used multiple places (\x1b7, \x1b8, etc.)
  3. runeLen vs displayWidth: The runeLen() function (statusbar.go:156-158) counts runes but doesn't account for combining characters or emoji that may have different display widths. For production, consider using a library that handles Unicode display width properly (e.g., github.com/mattn/go-runewidth which is already in go.mod).


Testing Recommendations

  1. Add tests for:

    • Concurrent writes during mode transitions
    • Large burst writes in compositor mode to verify dirty flag coalescing
    • Terminal disconnection/error scenarios
    • Very narrow terminal widths (< 20 columns)
  2. Consider integration tests with actual nested TUI apps (vim, tmux) to validate the fix works in practice.


Minor Issues

  1. exec.go line 17-19: The removal of fmt.Print and os.Stdout.Sync() in favor of fmt.Println is good (fixes the "missing trailing newline" mentioned in PR description).

  2. go.mod dependencies: The PR adds charmbracelet/x/vt and related dependencies. These are well-maintained libraries, but note:

    • github.com/charmbracelet/x/vt v0.0.0-20260127155452-b72a9a918687 is a pseudo-version (not a tagged release)
    • Consider tracking upstream for a stable tagged release

Summary

Recommendation: ✅ Approve with minor suggestions

The implementation is solid and solves the stated problem effectively. The dual-mode approach is architecturally sound, and the code quality is high with good test coverage.

Key action items (non-blocking):

  1. Consider throttling footer redraws in scroll mode for better performance
  2. Add error logging for write failures in compositor render loop
  3. Add bounds check or comment for escBuf size limit
  4. Consider using proper Unicode display width calculation

The nested TUI preservation logic is well-designed and the tests provide good confidence. This is a significant improvement for the user experience when running tools like Claude Code or vim inside moat.


Code style compliance: ✅ Follows Go conventions and project style guide
Security: ✅ No major concerns, standard terminal I/O risks
Performance: ⚠️ Minor concern with per-write footer redraws
Test coverage: ✅ Comprehensive

@dpup dpup merged commit 8e72632 into main Jan 30, 2026
4 checks passed
@dpup dpup deleted the tui branch January 30, 2026 01:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant