Event Loop
This page documents the main event loop that drives the TUI application. The event loop handles terminal events, processes controller messages, updates animations, and triggers rendering.
Event Loop Overview
┌─────────────────────────────────────────────────────────────────┐
│ Event Loop Iteration │
├─────────────────────────────────────────────────────────────────┤
│ 1. Process controller messages (non-blocking) │
│ 2. Update animation state │
│ 3. Render frame │
│ 4. Poll terminal events (non-blocking) │
│ 5. Handle keyboard/mouse input │
│ 6. Frame rate limiting (16ms sleep) │
└─────────────────────────────────────────────────────────────────┘
Main Run Loop
The run() method contains the main event loop:
pub fn run(&mut self) -> io::Result<()> {
// Terminal setup
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
io::stdout().execute(EnableMouseCapture)?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
while !self.should_quit {
// 1. Process pending controller messages
self.process_controller_messages();
// 2. Determine throbber state
let show_throbber = self.waiting_for_response
|| self.is_chat_streaming()
|| !self.executing_tools.is_empty();
// 3. Advance animations
if show_throbber {
self.animation_frame_counter = self.animation_frame_counter.wrapping_add(1);
if self.animation_frame_counter % 6 == 0 {
self.throbber_state.calc_next();
self.conversation_view.step_spinner();
}
}
// 4. Render frame
terminal.draw(|frame| {
self.render_frame(frame, show_throbber, prompt_len, indent_len);
})?;
// 5. Event handling
self.poll_and_handle_events()?;
// 6. Frame rate limiting
std::thread::sleep(std::time::Duration::from_millis(16));
}
// Cleanup
self.cleanup_terminal()?;
Ok(())
}
Terminal Setup
Raw mode and alternate screen enable full terminal control:
fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
io::stdout().execute(EnableMouseCapture)?;
Terminal::new(CrosstermBackend::new(io::stdout()))
}
fn cleanup_terminal() -> io::Result<()> {
io::stdout().execute(DisableMouseCapture)?;
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
| Mode | Purpose |
|---|---|
| Raw mode | Disables line buffering, passes all keys |
| Alternate screen | Preserves original terminal content |
| Mouse capture | Enables scroll wheel events |
Event Polling
Non-blocking event polling ensures responsiveness:
fn poll_and_handle_events(&mut self) -> io::Result<()> {
let mut net_scroll: i32 = 0;
// Poll all available events (non-blocking)
while event::poll(std::time::Duration::from_millis(0))? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
self.handle_key(key.code, key.modifiers);
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => net_scroll -= 1,
MouseEventKind::ScrollDown => net_scroll += 1,
_ => {}
},
Event::Resize(_, _) => {
// Terminal will re-render on next frame
}
_ => {}
}
}
// Apply accumulated scroll
self.apply_scroll(net_scroll);
Ok(())
}
Event Types
| Event | Handling |
|---|---|
Key | Dispatch to key handler and widgets |
Mouse::ScrollUp | Accumulate scroll delta |
Mouse::ScrollDown | Accumulate scroll delta |
Resize | Auto-handled by ratatui |
Controller Message Processing
Messages from the LLM controller are processed each iteration:
fn process_controller_messages(&mut self) {
let rx = match &mut self.from_controller {
Some(rx) => rx,
None => return,
};
// Drain all available messages (non-blocking)
let mut messages = Vec::new();
loop {
match rx.try_recv() {
Ok(msg) => messages.push(msg),
Err(mpsc::error::TryRecvError::Empty) => break,
Err(mpsc::error::TryRecvError::Disconnected) => break,
}
}
// Process each message
for msg in messages {
self.handle_ui_message(msg);
}
}
UiMessage Handling
fn handle_ui_message(&mut self, msg: UiMessage) {
match msg {
UiMessage::TextChunk { text, turn_id } => {
self.conversation_view.append_streaming(&text);
}
UiMessage::Complete { turn_id, stop_reason } => {
self.conversation_view.complete_streaming();
if stop_reason != "tool_use" {
self.waiting_for_response = false;
}
}
UiMessage::ToolExecuting { tool_use_id, display_name, .. } => {
self.executing_tools.insert(tool_use_id.clone());
self.conversation_view.add_tool_message(&tool_use_id, &display_name, "");
}
UiMessage::ToolCompleted { tool_use_id, status } => {
self.executing_tools.remove(&tool_use_id);
self.conversation_view.update_tool_status(&tool_use_id, status);
}
UiMessage::UserInteractionRequired { tool_use_id, request, .. } => {
self.activate_question_panel(tool_use_id, request);
}
UiMessage::PermissionRequired { tool_use_id, request, .. } => {
self.activate_permission_panel(tool_use_id, request);
}
UiMessage::TokenUpdate { input_tokens, output_tokens } => {
self.context_used = input_tokens;
}
UiMessage::Error { message } => {
self.conversation_view.add_system_message(message);
self.waiting_for_response = false;
}
// ... other message types
}
}
Key Event Dispatch
Key events are dispatched through multiple layers:
fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
let key_event = KeyEvent { code, modifiers };
let context = self.build_key_context();
// 1. Try key handler first
match self.key_handler.handle_key(key_event, &context) {
AppKeyResult::Action(action) => {
self.execute_key_action(action);
return;
}
AppKeyResult::Handled => return,
AppKeyResult::NotHandled => {}
}
// 2. Dispatch to widgets by priority
for widget_id in &self.widget_priority_order.clone() {
if let Some(widget) = self.widgets.get_mut(widget_id) {
if widget.is_active() {
let widget_ctx = self.build_widget_key_context();
match widget.handle_key(key_event, &widget_ctx) {
WidgetKeyResult::Action(action) => {
self.process_widget_action(action);
return;
}
WidgetKeyResult::Handled => return,
WidgetKeyResult::NotHandled => continue,
}
}
}
}
// 3. Fall through to text input
if !self.input_blocked() {
self.handle_text_input(key_event);
}
}
Key Dispatch Flow
KeyEvent
│
▼
┌─────────────────────┐
│ KeyHandler │ ──▶ AppKeyAction (quit, interrupt, etc.)
└─────────────────────┘
│ NotHandled
▼
┌─────────────────────┐
│ Widget (priority) │ ──▶ WidgetAction (submit, cancel, etc.)
│ - PermissionPanel │
│ - QuestionPanel │
│ - SlashPopup │
└─────────────────────┘
│ NotHandled
▼
┌─────────────────────┐
│ TextInput │ ──▶ Character input, cursor movement
└─────────────────────┘
Animation Updates
Animations advance every 6 frames:
if show_throbber {
self.animation_frame_counter = self.animation_frame_counter.wrapping_add(1);
if self.animation_frame_counter % 6 == 0 {
// Advance throbber animation
self.throbber_state.calc_next();
// Advance tool spinner
self.conversation_view.step_spinner();
}
}
At 60fps (16ms per frame), this results in animation updates roughly every 100ms.
Frame Rate Control
The loop targets approximately 60fps:
// Only sleep if not actively scrolling
if net_scroll == 0 {
std::thread::sleep(std::time::Duration::from_millis(16));
}
Skipping sleep during scrolling ensures responsive scroll behavior.
Quit Handling
The quit flag controls loop termination:
pub fn request_quit(&mut self) {
if let Some(exit_handler) = &self.exit_handler {
if exit_handler.on_exit() {
self.should_quit = true;
}
} else {
self.should_quit = true;
}
}
The exit handler allows cleanup or confirmation before quitting.
Interrupt Handling
User interrupts (Ctrl+C) are handled specially:
fn handle_interrupt(&mut self) {
if self.waiting_for_response || !self.executing_tools.is_empty() {
// Cancel current operation
if let Some(tx) = &self.to_controller {
let _ = tx.send(ControllerInputPayload::Interrupt);
}
self.waiting_for_response = false;
self.executing_tools.clear();
self.conversation_view.complete_streaming();
}
}
Thread Safety
The event loop runs on the main thread:
- Controller messages arrive via
mpsc::Receiver(thread-safe) - Widget state is accessed exclusively (single-threaded)
- Terminal operations are synchronous
Performance Characteristics
| Aspect | Behavior |
|---|---|
| Event polling | Non-blocking (0ms timeout) |
| Message processing | Drains all available |
| Rendering | Every iteration |
| Frame rate | ~60fps (16ms sleep) |
| Scroll responsiveness | Immediate (skip sleep) |
Next Steps
- Rendering Pipeline - Frame rendering details
- Widget System - Widget management
- App Structure - App state management
