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(())
}
ModePurpose
Raw modeDisables line buffering, passes all keys
Alternate screenPreserves original terminal content
Mouse captureEnables 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

EventHandling
KeyDispatch to key handler and widgets
Mouse::ScrollUpAccumulate scroll delta
Mouse::ScrollDownAccumulate scroll delta
ResizeAuto-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

AspectBehavior
Event pollingNon-blocking (0ms timeout)
Message processingDrains all available
RenderingEvery iteration
Frame rate~60fps (16ms sleep)
Scroll responsivenessImmediate (skip sleep)

Next Steps