Controller Events

ControllerEvent is the enum used to communicate from the LLMController to the TUI layer. Events are emitted during LLM streaming, tool execution, and control command processing. This page documents all event variants and when they are emitted.

Overview

Events flow from the controller through a callback function to the TUI:

pub type EventFunc = Box<dyn Fn(ControllerEvent) + Send + Sync>;

let controller = LLMController::new(Some(event_handler));

The event handler converts events to UiMessage and sends them to the TUI channel:

let event_handler = Box::new(move |event: ControllerEvent| {
    let msg = convert_controller_event_to_ui_message(event);
    ui_tx.try_send(msg).ok();
});

Event Categories

Events fall into several categories:

CategoryEvents
Streaming LifecycleStreamStart, Complete
ContentTextChunk
Tool ExecutionToolUseStart, ToolUse, ToolResult
MonitoringTokenUpdate
ErrorsError
ControlCommandComplete
User InteractionUserInteractionRequired, PermissionRequired

Streaming Lifecycle Events

StreamStart

Emitted when the LLM begins streaming a response:

ControllerEvent::StreamStart {
    session_id: i64,
    message_id: String,
    model: String,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session receiving this response
message_idUnique identifier for this message
modelModel name (e.g., “claude-3-sonnet”)
turn_idAssistant turn ID (e.g., a1, a2)

This event is typically silent in the TUI but can be used to initialize streaming state or show a loading indicator.

Complete

Emitted when the LLM response is complete:

ControllerEvent::Complete {
    session_id: i64,
    stop_reason: Option<String>,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session that completed
stop_reasonWhy the response ended
turn_idAssistant turn ID

Stop Reasons:

ValueMeaning
"end_turn"Natural end of response
"tool_use"LLM is waiting for tool results
"max_tokens"Hit token limit
"stop_sequence"Hit a stop sequence

When stop_reason is "tool_use", the TUI knows more activity will follow as tools execute.

Content Events

TextChunk

Emitted for each chunk of streamed text:

ControllerEvent::TextChunk {
    session_id: i64,
    text: String,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session receiving text
textThe text chunk (may be partial words)
turn_idAssistant turn ID

Multiple TextChunk events are emitted during streaming. The TUI appends each chunk to build the complete response.

Tool Execution Events

ToolUseStart

Emitted when a tool use block begins streaming (before input is complete):

ControllerEvent::ToolUseStart {
    session_id: i64,
    tool_id: String,
    tool_name: String,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session using tools
tool_idUnique ID for this tool use
tool_nameName of the tool being called
turn_idAssistant turn ID

This event provides early notification that a tool will be called, useful for showing progress before the full tool input is available.

ToolUse

Emitted when a tool use is complete and ready for execution:

ControllerEvent::ToolUse {
    session_id: i64,
    tool: ToolUseInfo,
    display_name: Option<String>,
    display_title: Option<String>,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session using tools
toolComplete tool use information
display_nameUI-friendly name from DisplayConfig
display_titleDynamic title based on input
turn_idAssistant turn ID

ToolUseInfo:

pub struct ToolUseInfo {
    pub id: String,           // Unique ID (e.g., "toolu_01abc...")
    pub name: String,         // Tool name (e.g., "web_search")
    pub input: serde_json::Value,  // Input parameters as JSON
}

The display_name and display_title fields support customized tool display:

  • display_name: From the tool’s DisplayConfig (e.g., “Web Search” instead of “web_search”)
  • display_title: Generated from input (e.g., “Seattle, WA” for a weather tool)

ToolResult

Emitted when an individual tool completes execution:

ControllerEvent::ToolResult {
    session_id: i64,
    tool_use_id: String,
    tool_name: String,
    display_name: Option<String>,
    status: ToolResultStatus,
    content: String,
    error: Option<String>,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session that ran the tool
tool_use_idID matching the original ToolUse
tool_nameName of the tool
display_nameUI-friendly name
statusExecution status
contentResult content
errorError message if failed
turn_idAssistant turn ID

ToolResultStatus:

pub enum ToolResultStatus {
    Success,
    Error,
    Timeout,
}

This event enables real-time UI feedback during batch tool execution, showing each tool’s completion status.

Monitoring Events

TokenUpdate

Emitted with token usage information:

ControllerEvent::TokenUpdate {
    session_id: i64,
    input_tokens: i64,
    output_tokens: i64,
    context_limit: i32,
}
FieldDescription
session_idThe session being tracked
input_tokensTokens used for input
output_tokensTokens generated for output
context_limitMaximum context window

Used to update status bar displays showing context usage.

Error Events

Error

Emitted when an error occurs:

ControllerEvent::Error {
    session_id: i64,
    error: String,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session where error occurred
errorHuman-readable error message
turn_idTurn ID if error is associated with a turn

Common error scenarios:

  • API rate limits
  • Network failures
  • Invalid tool responses
  • Context window exceeded

Control Events

CommandComplete

Emitted when a control command completes:

ControllerEvent::CommandComplete {
    session_id: i64,
    command: ControlCmd,
    success: bool,
    message: Option<String>,
}
FieldDescription
session_idThe session that processed the command
commandWhich command was executed
successWhether the command succeeded
messageOptional status message

ControlCmd:

pub enum ControlCmd {
    Interrupt,   // Cancel current operation
    Shutdown,    // Shut down the controller
    Clear,       // Clear conversation history
    Compact,     // Trigger context compaction
}

User Interaction Events

UserInteractionRequired

Emitted when a tool needs user input:

ControllerEvent::UserInteractionRequired {
    session_id: i64,
    tool_use_id: String,
    request: AskUserQuestionsRequest,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session requesting input
tool_use_idID of the blocked tool use
requestThe questions to ask
turn_idAssistant turn ID

The TUI should display a question panel and collect user responses. The response is submitted via the UserInteractionRegistry.

PermissionRequired

Emitted when a tool needs user permission:

ControllerEvent::PermissionRequired {
    session_id: i64,
    tool_use_id: String,
    request: PermissionRequest,
    turn_id: Option<TurnId>,
}
FieldDescription
session_idThe session requesting permission
tool_use_idID of the blocked tool use
requestPermission details
turn_idAssistant turn ID

The TUI should display a permission panel and collect the user’s decision. The response is submitted via the PermissionRegistry.

Event Sequencing

A typical conversation produces events in this order:

1. StreamStart          - Response begins
2. TextChunk (n times)  - Streamed text
3. ToolUse (optional)   - Tool requested
4. Complete             - LLM response done (stop_reason: "tool_use")
5. ToolResult           - Tool finished
6. StreamStart          - Next response begins
7. TextChunk (n times)  - More text
8. Complete             - Final response (stop_reason: "end_turn")
9. TokenUpdate          - Usage stats

For user interaction tools:

1. ToolUse                    - Tool requested
2. UserInteractionRequired    - Tool blocked
   ... user provides input ...
3. ToolResult                 - Tool completes

Handling Events

The standard conversion function maps events to UI messages:

pub fn convert_controller_event_to_ui_message(event: ControllerEvent) -> UiMessage {
    match event {
        ControllerEvent::StreamStart { session_id, .. } => {
            UiMessage::System { session_id, message: String::new() }
        }
        ControllerEvent::TextChunk { session_id, text, turn_id } => {
            UiMessage::TextChunk { session_id, turn_id, text, input_tokens: 0, output_tokens: 0 }
        }
        ControllerEvent::ToolUse { session_id, tool, display_name, display_title, turn_id } => {
            UiMessage::ToolExecuting {
                session_id,
                turn_id,
                tool_use_id: tool.id,
                display_name: display_name.unwrap_or(tool.name),
                display_title: display_title.unwrap_or_default(),
            }
        }
        // ... other conversions
    }
}

Event to UI Message Conversion

Events flow from the controller through a conversion layer to the TUI:

┌────────────────────┐     ┌─────────────────────────────────────┐     ┌─────────────┐
│  LLMController     │────▶│ convert_controller_event_to_ui_message │────▶│    TUI      │
│  (ControllerEvent) │     │                                     │     │ (UiMessage) │
└────────────────────┘     └─────────────────────────────────────┘     └─────────────┘

The conversion happens in the event handler callback:

let event_handler = Box::new(move |event: ControllerEvent| {
    let msg = convert_controller_event_to_ui_message(event);
    ui_tx.try_send(msg).ok();
});

let controller = LLMController::new(Some(event_handler));

Conversion Mappings

ControllerEventUiMessageNotes
StreamStartSystem (empty)Silent in TUI, useful for logging
TextChunkTextChunkDirect mapping, tokens set to 0
ToolUseStartDisplay”Executing tool: {name}” message
ToolUseToolExecutingFalls back to tool.name if no display_name
ToolResultToolCompletedDrops content, keeps status/error
CompleteCompleteToken counts are 0 (come via TokenUpdate)
ErrorErrorDirect mapping
TokenUpdateTokenUpdateturn_id set to None (session-level)
CommandCompleteCommandCompleteDirect mapping
UserInteractionRequiredUserInteractionRequiredDirect mapping
PermissionRequiredPermissionRequiredDirect mapping

Why Separate Types?

The separation between ControllerEvent and UiMessage serves several purposes:

  1. Decoupling: The controller can evolve independently from the TUI
  2. Optimization: UiMessage fields are optimized for rendering, not processing
  3. Simplification: UiMessage can omit fields the TUI does not need
  4. Flexibility: Custom TUIs can implement different conversion logic

Custom Conversion

For custom TUI implementations, you can provide your own conversion:

let custom_handler = Box::new(move |event: ControllerEvent| {
    let msg = match event {
        ControllerEvent::StreamStart { session_id, model, .. } => {
            // Show loading indicator with model name
            UiMessage::Display {
                session_id,
                turn_id: None,
                message: format!("Starting {} response...", model),
            }
        }
        other => convert_controller_event_to_ui_message(other),
    };
    ui_tx.try_send(msg).ok();
});

The non-blocking try_send() ensures the controller is never blocked by a slow TUI.

Next Steps