Skip to main content

Synchronous Rendering

MonoTerm uses synchronous rendering to eliminate the 1-frame flicker that occurs with xterm.js’s default async rendering path.

The Problem: RenderDebouncer Delay

xterm.js uses a RenderDebouncer that schedules all rendering for the next requestAnimationFrame. This creates a 1-frame delay between buffer update and screen display.
╔═══════════════════════════════════════════════════════════════════════════════╗
║  THE ASYNC RENDERING PROBLEM                                                  ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║                                                                               ║
║  Timeline (16.67ms per frame @ 60fps)                                         ║
║  ─────────────────────────────────────                                        ║
║                                                                               ║
║  Frame N                              Frame N+1                               ║
║  ├──────────────────────────────────┼──────────────────────────────────┤      ║
║  0ms                              16.67ms                           33.33ms   ║
║                                                                               ║
║                                                                               ║
║  T=0ms: GridUpdate arrives                                                    ║
║    │                                                                          ║
║    ▼                                                                          ║
║  ┌─────────────────────────┐                                                  ║
║  │ Buffer Injection        │  ← New data written to buffer                    ║
║  │ (cells written)         │                                                  ║
║  └───────────┬─────────────┘                                                  ║
║              │                                                                ║
║              ▼                                                                ║
║  ┌─────────────────────────┐                                                  ║
║  │ term.refresh()          │                                                  ║
║  │   ↓                     │                                                  ║
║  │ RenderDebouncer         │  ← "I'll render on next RAF"                     ║
║  │   ↓                     │                                                  ║
║  │ requestAnimationFrame() │  ← Schedules and returns immediately             ║
║  └───────────┬─────────────┘                                                  ║
║              │                                                                ║
║              │  ┌─────────────────────────────────────┐                       ║
║              │  │                                     │                       ║
║              │  │  What user sees:                    │                       ║
║              │  │                                     │                       ║
║              │  │  ┌───────────────────────────┐     │                       ║
║              │  │  │ OLD DATA (previous frame) │     │                       ║
║              │  │  │                           │     │  FLICKER!              ║
║              │  │  │ Buffer=NEW, GPU=OLD       │     │  Buffer/screen mismatch║
║              │  │  └───────────────────────────┘     │                       ║
║              │  │                                     │                       ║
║              │  └─────────────────────────────────────┘                       ║
║              │                                                                ║
║              │ ─ ─ ─ ─ ─ ─ ─ ─ ─(16.67ms wait)─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ▶  ║
║              │                                        │                       ║
║              │                              Frame N+1 │                       ║
║              │                                        ▼                       ║
║              │                              ┌─────────────────────────┐       ║
║              └─────────────────────────────▶│ RAF callback fires      │       ║
║                                             │   ↓                     │       ║
║                                             │ _renderRows() executes  │       ║
║                                             │   ↓                     │       ║
║                                             │ WebGL Render            │       ║
║                                             └───────────┬─────────────┘       ║
║                                                         │                     ║
║                                                         ▼                     ║
║                                             ┌─────────────────────────┐       ║
║                                             │ Finally shows NEW DATA  │       ║
║                                             └─────────────────────────┘       ║
║                                                                               ║
║  Total delay: ~16.67ms (1 frame)                                              ║
║                                                                               ║
╚═══════════════════════════════════════════════════════════════════════════════╝

The Solution: Direct _renderRows() Call

MonoTerm bypasses the RenderDebouncer by calling _renderRows() directly, achieving immediate rendering.
╔═══════════════════════════════════════════════════════════════════════════════╗
║  SYNC RENDERING - IMMEDIATE DISPLAY                                           ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║                                                                               ║
║  Frame N                              Frame N+1                               ║
║  ├──────────────────────────────────┼──────────────────────────────────┤      ║
║                                                                               ║
║  T=0ms: GridUpdate arrives                                                    ║
║    │                                                                          ║
║    ▼                                                                          ║
║  ┌─────────────────────────┐                                                  ║
║  │ Buffer Injection        │  ← New data written to buffer                    ║
║  │ (cells written)         │                                                  ║
║  └───────────┬─────────────┘                                                  ║
║              │                                                                ║
║              ▼                                                                ║
║  ┌─────────────────────────┐                                                  ║
║  │ _renderRows() direct    │  ← RenderDebouncer BYPASSED                      ║
║  │   ↓                     │                                                  ║
║  │ WebGL Render (immediate)│  ← Executes in same JS tick                      ║
║  └───────────┬─────────────┘                                                  ║
║              │                                                                ║
║              ▼                                                                ║
║  ┌─────────────────────────────────────┐                                      ║
║  │                                     │                                      ║
║  │  What user sees:                    │                                      ║
║  │                                     │                                      ║
║  │  ┌───────────────────────────┐     │                                      ║
║  │  │ NEW DATA (immediately!)   │     │  NO FLICKER!                         ║
║  │  │                           │     │                                      ║
║  │  │ Buffer = Screen = Synced  │     │  Perfect match                       ║
║  │  └───────────────────────────┘     │                                      ║
║  │                                     │                                      ║
║  └─────────────────────────────────────┘                                      ║
║                                                                               ║
║  Total delay: ~0ms (immediate)                                                ║
║                                                                               ║
╚═══════════════════════════════════════════════════════════════════════════════╝

Code-Level Difference

Async Path (xterm.js Public API)

// Public API - schedules for next frame
term.refresh(0, rows - 1);


RenderService.refresh()


RenderDebouncer.refresh()


this._animationFrame = requestAnimationFrame(() => {
    this._innerRefresh();  // ← Executes on NEXT frame
});

Sync Path (MonoTerm Private Call)

// Direct call - immediate execution
private _forceImmediateRender(term: Terminal, viewportRows: number): void {
    const core = (term as any)._core;
    const rs = core?._renderService;

    if (rs && typeof rs._renderRows === "function") {
        rs._renderRows(0, Math.max(0, viewportRows - 1));  // ← Immediate!
    } else {
        // Fallback to async if private API unavailable
        term.refresh(0, Math.max(0, viewportRows - 1));
    }
}

Why This Matters for AI CLI

AI tools produce rapid output streams. With async rendering, every update creates a visible delay.
╔═══════════════════════════════════════════════════════════════════════════════╗
║  AI CLI OUTPUT SCENARIO                                                       ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║                                                                               ║
║  T=0ms    GridUpdate #1 arrives                                               ║
║  T=2ms    GridUpdate #2 arrives                                               ║
║  T=5ms    GridUpdate #3 arrives                                               ║
║  ...                                                                          ║
║  T=16ms   Frame N+1 starts                                                    ║
║                                                                               ║
║                                                                               ║
║  ASYNC:                              SYNC:                                    ║
║  ──────                              ─────                                    ║
║                                                                               ║
║  #1 buffer update → screen OLD       #1 buffer update → screen NEW            ║
║  #2 buffer update → screen OLD       #2 buffer update → screen NEW            ║
║  #3 buffer update → screen OLD       #3 buffer update → screen NEW            ║
║  ...                                 ...                                      ║
║  Frame N+1: finally shows #3         (already displayed)                      ║
║                                                                               ║
║                                                                               ║
║  User Experience:                    User Experience:                         ║
║  ────────────────                    ────────────────                         ║
║  Screen "lags behind"                Real-time response                       ║
║  Visible flicker on fast output      Always smooth                            ║
║                                                                               ║
╚═══════════════════════════════════════════════════════════════════════════════╝

Implementation Sites

MonoTerm applies synchronous rendering at 15 critical points across 5 files:
FileSitesPurpose
atomic-cell-injector.ts6Grid injection render
terminal-instance.ts2Terminal initialization
terminal-scrollbar.ts3Scroll position updates
terminal-renderer-selector.ts1Renderer switching
fonts-terminal.ts3Font change refresh

Why Debouncer Exists (And Why We Bypass It)

The RenderDebouncer serves a purpose in traditional terminals:
╔═══════════════════════════════════════════════════════════════════════════════╗
║  DEBOUNCER PURPOSE vs MONOTERM REALITY                                        ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║                                                                               ║
║  Why xterm.js uses RenderDebouncer:                                           ║
║  ──────────────────────────────────                                           ║
║                                                                               ║
║  1. Coalesce rapid updates into single render                                 ║
║  2. Prevent excessive GPU calls                                               ║
║  3. Sync with browser's natural 60fps refresh                                 ║
║                                                                               ║
║                                                                               ║
║  Why MonoTerm can bypass it:                                                  ║
║  ───────────────────────────                                                  ║
║                                                                               ║
║  1. Rust AtomicState already calculates diff (Full/Partial/None/Skip)         ║
║     → Redundant to debounce what's already optimized                          ║
║                                                                               ║
║  2. ACK flow control limits update rate                                       ║
║     → Backend won't flood frontend                                            ║
║                                                                               ║
║  3. BSU/ESU protocol batches atomic frames                                    ║
║     → Updates arrive pre-batched                                              ║
║                                                                               ║
║  4. Frontend RAF already caps at 60fps                                        ║
║     → Natural rate limiting exists                                            ║
║                                                                               ║
║                                                                               ║
║  Result: Debouncer is REDUNDANT in Atomic Architecture                        ║
║          Bypassing it gives immediate response with no downside               ║
║                                                                               ║
╚═══════════════════════════════════════════════════════════════════════════════╝

Comparison Summary

AspectAsync (refresh)Sync (_renderRows)
Delay1 frame (~16.67ms)0ms (immediate)
FlickerPossibleEliminated
APIPublicPrivate (internal)
Use CaseTraditional terminalsAI-native terminals

Diff Rendering

How AtomicState calculates minimal updates

ACK Flow Control

Backend rate limiting that enables sync render

BSU/ESU Protocol

Atomic frame batching from PTY

Atomic Loop

The complete rendering cycle