This guide documents rendering patterns and best practices specific to VT Code, based on Ratatui conventions.
VT Code follows the Ratatui recipe of rendering everything in a single terminal.draw() closure per frame cycle.
loop {
terminal.draw(|f| {
f.render_widget(widget1, area);
})?;
terminal.draw(|f| {
f.render_widget(widget2, area);
})?;
terminal.draw(|f| {
f.render_widget(widget3, area);
})?;
}Why it fails: Ratatui uses double buffering—only the last draw() call within a frame cycle gets rendered. The first two calls are overwritten.
loop {
terminal.draw(|f| {
f.render_widget(widget1, area1);
f.render_widget(widget2, area2);
f.render_widget(widget3, area3);
})?;
}Implementation in VT Code:
File: vtcode-core/src/ui/tui/session.rs
The Session::render() method orchestrates all UI components:
- Header (top bar with model/status)
- Navigation pane (left sidebar)
- Transcript (message history, center)
- Input area (bottom with user input)
- Modals/overlays (palettes, search, etc.)
All rendering happens in one frame cycle.
Ratatui allocates one rendering buffer for the entire terminal area. When you call terminal.draw(), all widgets write to this buffer. At frame end, diffs are sent to the terminal.
Impact on VT Code:
- Overlapping areas are safe - Later renders overwrite earlier ones
- Off-screen rendering is safe - Clamped to buffer bounds (mostly)
- Partial updates are automatic - Only changed cells redraw
Ratatui does not prevent panics from rendering outside the buffer. VT Code must defend against this.
Pattern (from Ratatui FAQ):
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Clamp area to buffer bounds before rendering
let area = area.intersection(buf.area);
// Now safe to render...
}Best practices:
- Use
Rect::intersection(other)to clamp to valid regions - Use
Rect::clamp(constraining_rect)to clamp coordinates - Use
Rect::columns()andRect::rows()iterators (safe by design)
VT Code uses Ratatui's Layout system, which guarantees valid region calculations:
use ratatui::layout::{Constraint, Direction, Layout};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Header
Constraint::Min(1), // Content
Constraint::Length(1), // Input
])
.split(area);Safety guarantees:
Constraint::Length(n)- Exactly n rows/colsConstraint::Percentage(p)- p% of available spaceConstraint::Min(n)- At least n, fill remainingConstraint::Max(n)- At most n
The Layout algorithm ensures all constraints fit within area.
If you calculate regions manually, be defensive:
// Unsafe: Unguarded math can overflow
let width = area.width - 2;
let y = area.top() + some_offset;
// Safe: Use `saturating_sub()` and `clamp()`
let width = area.width.saturating_sub(2);
let y = (area.top() as u32 + offset).min(area.bottom() as u32) as u16;Better yet, use iterators:
// Safest: Iterator-based (can't go out of bounds)
for (i, cell) in f.buffer_mut().content.iter_mut().enumerate() {
if i < max_items {
// Safe to write
}
}VT Code composes widgets hierarchically. Each "pane" renders into its allocated area:
Structure:
Header: Info, status, theme Render size: full_width × 1
Nav Transcript Modal
(messages) (if
any)
Input bar (user text) Render size: full_width × 1-3
Implementation:
pub fn render(&mut self, f: &mut Frame) {
let area = f.area();
// Split into main regions
let [header_area, body_area, input_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(3),
])
.areas(area);
// Render each component
self.render_header(f, header_area);
self.render_body(f, body_area); // Handles nav + transcript + modal
self.render_input(f, input_area);
}VT Code uses Ratatui's widget trait (Widget) for reusable components. Rendering happens via widget.render() call.
Pattern:
// Stateless widget (implements Widget trait)
impl Widget for MyCustomWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
// Write to buf directly
if area.width < 2 { return; } // Defensive
// Safe rendering...
}
}
// In main render loop:
f.render_widget(my_widget, area);VT Code's message transcript must reflow when terminal resizes. This is expensive, so VT Code caches reflowed text:
Pattern (from vtcode-core):
struct Message {
text: String,
cached_lines: Mutex<Option<Vec<String>>>,
cached_width: Mutex<u16>,
}
fn get_reflowed_lines(&self, width: u16) -> Vec<String> {
let mut cache = self.cached_lines.lock();
let mut cache_width = self.cached_width.lock();
if *cache_width != width {
// Reflow needed
let lines = textwrap::wrap(&self.text, width);
*cache = Some(lines.clone());
*cache_width = width;
return lines;
}
cache.clone().unwrap_or_default()
}On Event::Resize(w, h):
- Clear reflow caches
- Recalculate layout constraints
- Trigger next
Event::Render
VT Code automatically clears caches and re-renders on resize.
VT Code uses Ratatui's style system for portable colors:
use ratatui::style::{Color, Modifier, Style};
let style = Style::default()
.fg(Color::Cyan)
.bg(Color::Black)
.add_modifier(Modifier::BOLD);
f.render_widget(
Paragraph::new("Hello").style(style),
area
);Portable color options:
- Named:
Color::Red,Color::Blue, etc. - Indexed:
Color::Indexed(200)(256-color palette) - RGB:
Color::Rgb(255, 0, 0)(24-bit color, terminal permitting)
VT Code parses terminal ANSI escape codes and converts them to Ratatui styles:
File: vtcode-core/src/ui/tui/style.rs
Converts ANSI SGR codes (like \x1b[1;31m for bold red) to Ratatui Style.
Ratatui's double buffer means:
- Only cells that changed are sent to terminal
- No "flicker" (frame is complete before display)
- Terminal I/O is optimized via escape sequence diffing
VT Code optimization:
- Only send
Event::Renderat 60 FPS (not on every state change) - Batch state updates into
Event::Tick(4 Hz) - Let Ratatui handle diff logic
VT Code uses the Tick/Render split pattern:
Event::Tick (4 Hz)
Update internal state (messages, selection, etc.)
Event::Render (60 FPS)
Redraw UI from state (Ratatui handles diffing)
This separates "state updates" from "rendering," making both efficient.
Text widgets cache strings when possible:
// Bad: Allocates new string every frame
loop {
let text = format!("Status: {}", status);
f.render_widget(Paragraph::new(text), area);
}
// Good: Reuse string if unchanged
if status_changed {
self.status_str = format!("Status: {}", status);
}
f.render_widget(Paragraph::new(&self.status_str), area);Cause: Hard-coded area sizes (e.g., area.top() + 10 without bounds checking)
Fix: Use Constraint and Layout, not manual offsets.
Cause: Not clamping to buffer area
Fix: Use area.intersection(buf.area) before rendering.
Cause: Off-bounds cell access in custom widget
Fix: Add bounds checks and use Rect::intersection().
VT Code includes render tests in vtcode-core:
#[test]
fn test_render_header() {
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|f| {
let mut session = Session::new();
session.render_header(f, f.area());
}).unwrap();
let buffer = terminal.backend().buffer();
// Assert expected cell content
}Use TestBackend for offline rendering tests (no terminal needed).
- Ratatui: Layout
- Ratatui: Widgets
- Ratatui: Styling
- Ratatui: Custom Widgets
src/tui.rs- Terminal event handlervtcode-core/src/ui/tui/session.rs- Main render orchestration