GitHub - ccheshirecat/flywheel: A high-performance, Rust-native rendering engine purpose-built for streaming LLM outputs at 60+ FPS without tearing, flickering, or input lag.

9 min read Original article ↗

The Zero-Flicker Terminal Compositor for Agentic CLIs

A high-performance, Rust-native rendering engine purpose-built for streaming LLM outputs at 60+ FPS without tearing, flickering, or input lag.

QuickstartFeaturesArchitectureAPIExamples


The Problem

Building an "AI coding assistant" CLI that streams LLM responses directly to the terminal sounds simple—until you try it. Existing TUI frameworks are designed for static layouts (menus, dashboards) that update sporadically. When used for high-frequency streaming (50+ tokens/second), they suffer from:

Issue Symptom
Flickering clear() + redraw() on every character creates strobing artifacts.
Blocking Render calls starve the input handler, making Ctrl+C unresponsive.
Inefficiency Diffing the entire 80x24 grid for 1 new character is O(n²) waste.
State Desync Direct stdout writes conflict with the framework's internal cursor tracking.

Flywheel was designed from the ground up to solve this.


Features

Feature Description
🚀 Zero-Flicker Rendering Double-buffered diffing outputs only the delta between frames. No screen clears.
Sub-Millisecond Input Latency Actor model decouples input polling from rendering. Ctrl+C always works.
🎯 Fast Path Optimization For simple character appends, bypass the buffer entirely—emit ANSI codes directly.
📜 Infinite Scrollback StreamWidget stores 100k+ lines efficiently with "sticky scroll" UX.
🎨 True Color (24-bit RGB) Full RGB attribute support for syntax highlighting and theming.
🦀 Safe Rust Core Core library is #![forbid(unsafe_code)]. FFI module uses unsafe as required by C ABI.
🔌 C FFI Stable extern "C" interface for Python, Node.js, Go, and C/C++ bindings.

Quickstart

Installation

[dependencies]
flywheel-compositor = "0.1"

Minimal Example

use flywheel::{Engine, StreamWidget, Rect, Rgb};

fn main() -> std::io::Result<()> {
    let mut engine = Engine::new()?;
    let mut stream = StreamWidget::new(Rect::new(0, 0, engine.width(), engine.height()));

    // Simulate LLM streaming
    for token in ["Hello, ", "world! ", "This ", "is ", "Flywheel."] {
        stream.set_fg(Rgb::new(0, 255, 128)); // Green text
        
        // Just push. The engine handles Fast/Slow path automatically.
        stream.push(&engine, token);
        
        std::thread::sleep(std::time::Duration::from_millis(100));
    }

    // Event loop
    while engine.is_running() {
        for event in engine.poll_input() {
            match event {
                flywheel::InputEvent::Key { code: flywheel::KeyCode::Esc, .. } => engine.stop(),
                _ => {}
            }
        }
    }

    Ok(())
}

Run the Demo

cargo run --example streaming_demo --release

This showcases:

  • 100% GPU-free flicker elimination at 60 FPS
  • 3000+ characters/second matrix generation
  • Real-time input handling with cursor blinking
  • Live CPU/Memory usage display

Architecture

Flywheel implements a 3-Actor Pipeline:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Input Actor   │────▶│  Main Thread    │────▶│ Renderer Actor  │
│  (crossterm)    │     │  (Your Code)    │     │    (stdout)     │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                       │                       │
    Keyboard            Buffer Updates           ANSI Sequences
    Mouse Events        Widget Logic             Diff Output
    Resize              State Management         Cursor Control

Core Axioms

Axiom Principle
A: Double Buffering Next buffer holds pending changes. Current buffer holds what's on screen. Diffing produces minimal escape sequences.
B: Append-Optimized StreamWidget::append() returns FastPath or SlowPath. Fast path bypasses diffing for O(1) writes.
C: Thread Isolation Only Renderer Actor touches stdout. Zero contention. Zero deadlocks.
D: Event-Driven Main loop uses recv_timeout() for input events. No polling. No sleeping. Sub-ms latency.

Fast Path vs Slow Path

let result = stream.append("x");

match result {
    AppendResult::FastPath { row, start_col, .. } => {
        // Character appended within viewport, no wrapping.
        // Emit: MoveTo(row, col) + SetColor + PrintChar
        // Cost: ~20 bytes to stdout
    }
    AppendResult::SlowPath => {
        // Wrapping or scrolling required.
        // Full frame rendered via diffing engine.
        // Cost: ~200 bytes to stdout (only changed cells)
    }
}

The append_fast_into() helper encapsulates this:

let mut raw_output = Vec::new();
stream.append_fast_into("x", &mut raw_output);
engine.write_raw(raw_output); // Sends RawOutput command to Renderer

API Reference

Engine

The central coordinator. Manages terminal lifecycle and actor threads.

// Initialization
let mut engine = Engine::new()?;                    // Default config
let mut engine = Engine::with_config(config)?;     // Custom FPS, mouse, etc.

// Dimensions
engine.width();   // Terminal columns
engine.height();  // Terminal rows

// Event Loop
engine.is_running();                                // Check if still alive
engine.poll_input();                                // Non-blocking: Vec<InputEvent>
engine.input_receiver().recv_timeout(duration);     // Blocking: for event-driven loops

// Rendering
engine.buffer_mut();        // Get mutable reference to the Next buffer
engine.request_update();    // Send buffer to Renderer (diff-based)
engine.request_redraw();    // Send buffer to Renderer (full redraw)
engine.write_raw(bytes);    // Bypass buffer, write ANSI directly (Fast Path)

// Lifecycle
engine.stop();              // Signal shutdown

StreamWidget

A scrolling text viewport optimized for streaming content.

let mut stream = StreamWidget::new(Rect::new(x, y, width, height));

// Styling
stream.set_fg(Rgb::new(255, 128, 0));  // Orange text
stream.set_bg(Rgb::new(20, 20, 20));   // Dark background
stream.set_bold(true);

// Content (Recommended API)
stream.push(&engine, "Hello");          // Automatic Fast/Slow path handling
stream.newline();
stream.clear();

// Low-level API (for advanced use cases)
stream.append("text");                  // Returns AppendResult, manual handling
stream.append_fast_into("x", &mut buf); // Manual Fast Path with raw output

// Scrolling (Sticky Scroll: auto-scroll only if at bottom)
stream.scroll_up(lines);
stream.scroll_down(lines);

// Rendering
stream.render(&mut buffer);             // Write to Buffer
stream.needs_redraw();                  // Check if dirty

Buffer

Low-level grid of cells representing the terminal screen.

let mut buffer = Buffer::new(80, 24);

buffer.set(x, y, Cell::new('A').with_fg(Rgb::RED));
buffer.get(x, y);                      // Option<&Cell>
buffer.draw_text(x, y, "text", fg, bg);
buffer.fill_rect(x, y, w, h, cell);
buffer.clear();

InputEvent

Events received from the terminal.

match event {
    InputEvent::Key { code, modifiers } => { /* KeyCode::Char, Esc, Enter, etc. */ }
    InputEvent::MouseClick { x, y, button } => { /* Left, Right, Middle */ }
    InputEvent::MouseScroll { x, y, delta } => { /* +1 up, -1 down */ }
    InputEvent::Resize { width, height } => { /* Terminal resized */ }
    InputEvent::Shutdown => { /* SIGTERM or similar */ }
    _ => {}
}

V2 Widgets

Flywheel V2 introduces a proper widget system with composable UI components.

TextInput

Single-line text input with cursor, editing, and navigation:

use flywheel::{TextInput, Widget, Rect};

let mut input = TextInput::new(Rect::new(0, 23, 80, 1));

// Configure
input.set_content("Initial text");
input.set_focused(true);

// Handle input events
if input.handle_input(&event) {
    // Event was consumed by the widget
}

// Render
input.render(buffer);

// Get content
let text = input.content();

StatusBar

Three-section status bar (left, center, right):

use flywheel::{StatusBar, Widget, Rect};

let mut status = StatusBar::new(Rect::new(0, 0, 80, 1));
status.set_all("Flywheel", "v2.0", "60 FPS");

// Or set individually
status.set_left("App Name");
status.set_center("Status");
status.set_right("12:34");

status.render(buffer);

ProgressBar

Animated horizontal progress indicator:

use flywheel::{ProgressBar, Widget, Rect, ProgressStyle};

let mut progress = ProgressBar::new(Rect::new(0, 5, 60, 1));
progress.set_progress(0.5);  // 50%
progress.set_label("Loading");
progress.increment(0.1);     // +10%

progress.render(buffer);

Widget Trait

All widgets implement the Widget trait:

pub trait Widget {
    fn bounds(&self) -> Rect;
    fn set_bounds(&mut self, bounds: Rect);
    fn render(&self, buffer: &mut Buffer);
    fn handle_input(&mut self, event: &InputEvent) -> bool;
    fn needs_redraw(&self) -> bool;
    fn clear_redraw(&mut self);
}

Examples

Event-Driven Loop with TickerActor (Recommended)

Use the V2 TickerActor for non-blocking frame pacing:

use flywheel::{Engine, TickerActor, InputEvent, KeyCode};
use crossbeam_channel::select;
use std::time::Duration;

let engine = Engine::new()?;
let ticker = TickerActor::spawn(Duration::from_micros(16_666)); // 60 FPS

while engine.is_running() {
    select! {
        recv(engine.input_receiver()) -> result => {
            if let Ok(event) = result {
                match event {
                    InputEvent::Key { code: KeyCode::Esc, .. } => engine.stop(),
                    _ => handle_input(event),
                }
            }
        }
        recv(ticker.receiver()) -> _ => {
            // Tick: generate content, update animations
            generate_content(&mut stream);
            stream.render(engine.buffer_mut());
            engine.request_update();
        }
    }
}

ticker.join();

Legacy Event Loop

For simpler applications without the ticker:

use crossbeam_channel::RecvTimeoutError;
use std::time::Duration;

let target_fps = Duration::from_micros(16_666); // 60 FPS
let mut last_tick = Instant::now();

while engine.is_running() {
    let timeout = target_fps.saturating_sub(last_tick.elapsed());
    
    match engine.input_receiver().recv_timeout(timeout) {
        Ok(event) => {
            // Handle input IMMEDIATELY
            handle_input(event);
            redraw_ui(&mut engine);
            engine.request_update();
        }
        Err(RecvTimeoutError::Timeout) => {
            // Tick: generate content, update animations
            last_tick = Instant::now();
            generate_content(&mut stream);
            stream.render(engine.buffer_mut());
            engine.request_update();
        }
        Err(_) => break,
    }
}

C FFI Usage

#include "flywheel.h"

int main() {
    FlywheelEngine* engine = flywheel_engine_new();
    FlywheelStream* stream = flywheel_stream_new(0, 0, 80, 24);

    flywheel_stream_set_fg(stream, 0, 255, 128);
    flywheel_stream_append(stream, "Hello from C!");
    flywheel_stream_render(stream, flywheel_engine_buffer(engine));
    flywheel_engine_request_update(engine);

    // Event loop...

    flywheel_stream_destroy(stream);
    flywheel_engine_destroy(engine);
    return 0;
}

Performance

Benchmarked on Apple Silicon (criterion, release build):

Cell Operations

Operation Time
Cell equality (same) 2.09 ns
Cell equality (diff grapheme) 650 ps
Cell equality (diff color) 921 ps
Cell from ASCII char 1.73 ns
Cell from CJK char 2.56 ns

Buffer Diffing (200×50 = 10,000 cells)

Scenario Time Notes
Identical buffers 33.3 µs No-op diff
Single cell change 33.6 µs Minimal output
Line change (200 cells) 33.7 µs Optimized cursor moves
Full change (10K cells) 289 µs ~2.9M cells/second
Full render 318 µs No diffing

RopeBuffer (Chunked Storage)

Operation Time Notes
Append single char 2.69 ns O(1) amortized
Newline 9.29 ns Creates new line
Append 80 cells 178 ns Full line
Push complete line 93 ns Pre-built line
Get line (50K lines) 537 ps O(1) chunk lookup
Visible lines iterator 194 ns 50 lines
Push 100K lines 404 µs ~247M lines/second

Scaling

Buffer Size Diff Time (full change)
80×24 (1,920 cells) 55 µs
120×40 (4,800 cells) 140 µs
200×50 (10,000 cells) 291 µs
300×80 (24,000 cells) 693 µs

Run Benchmarks

cargo bench --bench cell_benchmark
cargo bench --bench diff_benchmark
cargo bench --bench rope_benchmark
cargo bench --bench comparison_benchmark  # Flywheel vs Ratatui

Flywheel vs Ratatui (Head-to-Head)

Operation Flywheel Ratatui Speedup
Buffer Creation (80×24) 546 ns 2.02 µs 3.7×
Buffer Creation (200×50) 3.17 µs 9.96 µs 3.1×
Cell Write 1.32 ns 1.53 ns 1.2×
Buffer Fill (80×24) 796 ns 3.20 µs 4.0×
Buffer Fill (200×50) 4.17 µs 16.1 µs 3.9×
Buffer Diff (80×24) 8.05 µs 21.6 µs 2.7×
Buffer Diff (200×50) 39.2 µs 109 µs 2.8×
Cell Clone/Copy 1.77 ns 2.03 ns 1.1×
Text Render (47 chars) 91.1 ns 137 ns 1.5×

Comparison

Feature Flywheel ratatui crossterm (raw)
Zero-flicker streaming
Non-blocking input
Fast Path optimization N/A
Sticky scroll N/A
Actor-based rendering
Widget system
RopeBuffer (1M+ lines) N/A
C FFI

Roadmap

V2.0 ✅ Complete

  • Buffer synchronization fix (ghost character elimination)
  • Async-friendly TickerActor
  • RopeBuffer for 1M+ line documents
  • Widget system (TextInput, StatusBar, ProgressBar)
  • Comprehensive documentation and benchmarks

Future

  • V2.1: Layout containers (VSplit, HSplit, Stack)
  • V2.2: Focus management system
  • V3.0: WASM target for browser terminals
  • V3.1: Plugin system for custom widgets

License

MIT


Built with ❤️ for the AI-native CLI era.