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
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.
| 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. |
[dependencies]
flywheel-compositor = "0.1"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(())
}cargo run --example streaming_demo --releaseThis 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
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
| 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. |
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 RendererThe 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 shutdownA 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 dirtyLow-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();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 */ }
_ => {}
}Flywheel V2 introduces a proper widget system with composable UI components.
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();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);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);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);
}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();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,
}
}#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;
}Benchmarked on Apple Silicon (criterion, release build):
| 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 |
| 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 |
| 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 |
| 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 |
cargo bench --bench cell_benchmark
cargo bench --bench diff_benchmark
cargo bench --bench rope_benchmark
cargo bench --bench comparison_benchmark # Flywheel vs Ratatui| 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× |
| 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 | ✅ | ❌ | ❌ |
- Buffer synchronization fix (ghost character elimination)
- Async-friendly TickerActor
- RopeBuffer for 1M+ line documents
- Widget system (TextInput, StatusBar, ProgressBar)
- Comprehensive documentation and benchmarks
- 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
MIT
Built with ❤️ for the AI-native CLI era.