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:
| Category | Events |
|---|---|
| Streaming Lifecycle | StreamStart, Complete |
| Content | TextChunk |
| Tool Execution | ToolUseStart, ToolUse, ToolResult |
| Monitoring | TokenUpdate |
| Errors | Error |
| Control | CommandComplete |
| User Interaction | UserInteractionRequired, 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>,
}
| Field | Description |
|---|---|
session_id | The session receiving this response |
message_id | Unique identifier for this message |
model | Model name (e.g., “claude-3-sonnet”) |
turn_id | Assistant 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>,
}
| Field | Description |
|---|---|
session_id | The session that completed |
stop_reason | Why the response ended |
turn_id | Assistant turn ID |
Stop Reasons:
| Value | Meaning |
|---|---|
"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>,
}
| Field | Description |
|---|---|
session_id | The session receiving text |
text | The text chunk (may be partial words) |
turn_id | Assistant 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>,
}
| Field | Description |
|---|---|
session_id | The session using tools |
tool_id | Unique ID for this tool use |
tool_name | Name of the tool being called |
turn_id | Assistant 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>,
}
| Field | Description |
|---|---|
session_id | The session using tools |
tool | Complete tool use information |
display_name | UI-friendly name from DisplayConfig |
display_title | Dynamic title based on input |
turn_id | Assistant 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’sDisplayConfig(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>,
}
| Field | Description |
|---|---|
session_id | The session that ran the tool |
tool_use_id | ID matching the original ToolUse |
tool_name | Name of the tool |
display_name | UI-friendly name |
status | Execution status |
content | Result content |
error | Error message if failed |
turn_id | Assistant 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,
}
| Field | Description |
|---|---|
session_id | The session being tracked |
input_tokens | Tokens used for input |
output_tokens | Tokens generated for output |
context_limit | Maximum 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>,
}
| Field | Description |
|---|---|
session_id | The session where error occurred |
error | Human-readable error message |
turn_id | Turn 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>,
}
| Field | Description |
|---|---|
session_id | The session that processed the command |
command | Which command was executed |
success | Whether the command succeeded |
message | Optional 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>,
}
| Field | Description |
|---|---|
session_id | The session requesting input |
tool_use_id | ID of the blocked tool use |
request | The questions to ask |
turn_id | Assistant 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>,
}
| Field | Description |
|---|---|
session_id | The session requesting permission |
tool_use_id | ID of the blocked tool use |
request | Permission details |
turn_id | Assistant 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
| ControllerEvent | UiMessage | Notes |
|---|---|---|
StreamStart | System (empty) | Silent in TUI, useful for logging |
TextChunk | TextChunk | Direct mapping, tokens set to 0 |
ToolUseStart | Display | ”Executing tool: {name}” message |
ToolUse | ToolExecuting | Falls back to tool.name if no display_name |
ToolResult | ToolCompleted | Drops content, keeps status/error |
Complete | Complete | Token counts are 0 (come via TokenUpdate) |
Error | Error | Direct mapping |
TokenUpdate | TokenUpdate | turn_id set to None (session-level) |
CommandComplete | CommandComplete | Direct mapping |
UserInteractionRequired | UserInteractionRequired | Direct mapping |
PermissionRequired | PermissionRequired | Direct mapping |
Why Separate Types?
The separation between ControllerEvent and UiMessage serves several purposes:
- Decoupling: The controller can evolve independently from the TUI
- Optimization: UiMessage fields are optimized for rendering, not processing
- Simplification: UiMessage can omit fields the TUI does not need
- 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
- UiMessage Types - The TUI-side message format
- Message Handling - The controller event loop
