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.
Quickstart • Features • Architecture • API • Examples
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 RatatuiFlywheel 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.