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 aRenderDebouncer 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:| File | Sites | Purpose |
|---|---|---|
atomic-cell-injector.ts | 6 | Grid injection render |
terminal-instance.ts | 2 | Terminal initialization |
terminal-scrollbar.ts | 3 | Scroll position updates |
terminal-renderer-selector.ts | 1 | Renderer switching |
fonts-terminal.ts | 3 | Font 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
| Aspect | Async (refresh) | Sync (_renderRows) |
|---|---|---|
| Delay | 1 frame (~16.67ms) | 0ms (immediate) |
| Flicker | Possible | Eliminated |
| API | Public | Private (internal) |
| Use Case | Traditional terminals | AI-native terminals |
Related Documentation
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