Widgets
Widgets are the building blocks of the TUI. Agent Air provides several core widgets for common patterns like conversation display, text input, and permission prompts. You can also create custom widgets to extend the interface with specialized functionality unique to your agent.
Each widget is a self-contained unit that manages its own state, handles keyboard input, and renders itself to its allocated screen area. This encapsulation makes widgets reusable and composable—you can mix built-in widgets with custom ones to create exactly the interface your agent needs.
Widget Trait
Every widget implements the Widget trait, which defines how widgets identify themselves, handle input, and render to the screen. The trait includes required methods for core functionality and optional methods with sensible defaults that you can override when needed.
The trait design balances flexibility with ease of implementation. Simple widgets only need to implement the required methods, while complex widgets can override optional methods to customize priority, sizing, and modal behavior.
pub trait Widget: Send + 'static {
fn id(&self) -> &'static str;
fn is_active(&self) -> bool;
fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult;
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme);
// Optional with defaults
fn priority(&self) -> u8 { 100 }
fn required_height(&self, available: u16) -> u16 { 0 }
fn blocks_input(&self) -> bool { false }
fn is_overlay(&self) -> bool { false }
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn into_any(self: Box<Self>) -> Box<dyn Any>;
}
Core Widgets
Agent Air includes widgets for the most common UI needs. These widgets are fully themed and integrate with the layout system automatically, providing a polished experience out of the box. Understanding what the core widgets offer helps you decide whether to use them directly, customize them, or build your own.
ChatView
The ChatView displays conversation messages with streaming support, markdown rendering, and tool execution status. It handles scrolling, auto-follow for new content, and message role styling. This is typically the largest widget in the interface and the primary way users see agent responses.
The ChatView automatically receives conversation updates from the controller. Messages stream in character by character during LLM responses, and tool executions display with status indicators that update as tools complete. Markdown rendering includes syntax highlighting for code blocks, styled headings, links, and other formatting.
pub struct ChatViewConfig {
pub user_prefix: String,
pub system_prefix: String,
pub timestamp_prefix: String,
pub spinner_chars: Vec<char>,
pub default_title: String,
pub empty_message: String,
pub tool_icon: String,
pub tool_executing_arrow: String,
pub tool_completed_checkmark: String,
pub tool_failed_icon: String,
}
TextInput
The TextInput widget provides a text buffer with cursor management, multi-line editing, and visual line wrapping. It handles all standard editing operations including character insertion, deletion, cursor movement, and selection. Users type their messages here before sending them to the agent.
The widget grows vertically as the user types multiple lines, up to a configurable maximum. Visual line wrapping ensures long lines display correctly without horizontal scrolling. The input integrates with the slash command popup, triggering it when the user types a forward slash.
Key features:
- Multi-line input with automatic line wrapping
- Cursor positioning and movement
- Optional vim-style bindings
- Integrates with the slash command popup
StatusBar
The StatusBar displays application state at the bottom of the screen. By default it shows the current working directory, model name, context usage, and help hints. This information helps users understand the current state without interrupting their workflow.
The status bar updates automatically as the application state changes. Context usage shows how much of the token limit has been consumed, helping users anticipate when compaction might occur. Custom renderers let you display different information or change the layout entirely.
pub struct StatusBarConfig {
pub height: u16,
pub show_cwd: bool,
pub show_model: bool,
pub show_context: bool,
pub show_hints: bool,
pub content_renderer: Option<StatusBarRenderer>,
}
Customize what appears in the status bar by toggling the show flags, or provide a completely custom renderer:
let status_bar = StatusBar::new()
.with_renderer(|data, theme| {
vec![
Line::from(format!(" {} | {}", data.model_name, data.session_id)),
Line::from(format!(" Context: {}/{}", data.context_used, data.context_limit)),
]
});
PermissionPanel
The PermissionPanel displays permission requests from tools. When a tool needs to perform a sensitive operation like writing a file or executing a command, this panel shows what the tool wants to do and lets the user grant or deny the request. The panel provides enough context for users to make informed security decisions.
The panel appears automatically when a tool requests permission, pushing other content up temporarily. Users can grant permission for just this operation, for similar operations throughout the session, or deny the request entirely.
Options presented:
- Grant Once - Allow this single operation
- Grant for Session - Allow all similar operations this session
- Deny - Refuse the operation
The panel displays the action description, reason, and affected resources to help users make informed decisions.
QuestionPanel
The QuestionPanel displays questions from the ask_user_questions tool. It supports single choice, multiple choice, and free text input with required field validation. This allows agents to gather information from users in a structured way rather than through free-form conversation.
When the LLM needs clarification or user input, this panel presents the questions with clear options and collects responses. The structured format makes it easy for users to understand what’s being asked and provide appropriate answers.
SessionPicker
The SessionPicker overlay allows users to view and switch between sessions. It displays session metadata including name, creation time, and other relevant information. Users can continue previous conversations or start fresh sessions depending on their needs.
The picker appears as an overlay on top of the main content, ensuring it has the user’s full attention while making a session choice.
SlashPopup
The SlashPopup appears when the user types / in the input. It shows available slash commands filtered by what the user has typed, with command names and descriptions. This provides discoverability for commands without requiring users to memorize them.
As the user continues typing, the list filters to show only matching commands. Selecting a command either executes it immediately or inserts it into the input for further editing.
ThemePicker
The ThemePicker overlay displays available themes and allows runtime theme switching. Selecting a theme applies it immediately, giving users instant feedback on how the new theme looks with their current content.
Widget ID Constants
Built-in widgets use constant IDs defined in the widget_ids module. These constants ensure consistency when referring to widgets in layouts and other configuration. Using constants rather than string literals helps catch typos at compile time.
pub mod widget_ids {
pub const CHAT_VIEW: &str = "chat_view";
pub const TEXT_INPUT: &str = "text_input";
pub const PERMISSION_PANEL: &str = "permission_panel";
pub const QUESTION_PANEL: &str = "question_panel";
pub const SESSION_PICKER: &str = "session_picker";
pub const SLASH_POPUP: &str = "slash_popup";
pub const THEME_PICKER: &str = "theme_picker";
pub const STATUS_BAR: &str = "status_bar";
}
Use these constants when configuring layouts to ensure correct widget references.
Registering Widgets
Widgets are registered with the agent before starting the TUI. The register_widget method accepts any type implementing the Widget trait. Registration tells the TUI which widgets are available; the layout determines which ones actually appear on screen.
The registration pattern separates widget creation from widget usage. This lets you create widgets with custom configuration, then register them without the layout system needing to know how they were constructed.
use agent_air::AgentAir;
use agent_air::tui::widgets::{PermissionPanel, QuestionPanel, StatusBar};
let mut agent = AgentAir::with_config(&config)?;
agent
.register_widget(PermissionPanel::new())
.register_widget(QuestionPanel::new())
.register_widget(StatusBar::new());
agent.into_tui().run()?;
Widgets must be registered before calling run(). The method returns &mut Self for method chaining.
Creating Custom Widgets
Implement the Widget trait to create custom widgets. At minimum, provide the four required methods. Custom widgets integrate fully with the layout system, theme, and event handling—they’re first-class citizens alongside the built-in widgets.
Consider creating custom widgets when you need specialized UI elements that the core widgets don’t provide, such as file browsers, progress displays, or domain-specific visualizations.
Required Methods
The four required methods form the core contract of the Widget trait. Each serves a distinct purpose in the widget lifecycle.
id() - Returns a unique identifier for the widget type. This ID is used for layout mapping and widget lookup, so it must be unique across all registered widgets:
fn id(&self) -> &'static str {
"my_custom_widget"
}
is_active() - Returns whether the widget should be rendered and receive events. Inactive widgets are skipped during rendering and key handling, reducing overhead and preventing interaction with hidden elements:
fn is_active(&self) -> bool {
self.visible
}
handle_key() - Processes key events and returns how the event was handled. The return value tells the system whether to continue propagating the event to other widgets:
fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
match key.code {
KeyCode::Enter => {
self.submit();
WidgetKeyResult::Handled
}
KeyCode::Esc => {
self.cancel();
WidgetKeyResult::Action(WidgetAction::Close)
}
_ => WidgetKeyResult::NotHandled,
}
}
render() - Draws the widget to the frame within its allocated area. Use theme properties for styling to ensure visual consistency with the rest of the interface:
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.border)
.title("My Widget");
let content = Paragraph::new("Hello, World!")
.style(theme.text)
.block(block);
frame.render_widget(content, area);
}
Optional Methods
Optional methods have default implementations but can be overridden to customize behavior. These methods control how the widget participates in the broader TUI system.
priority() - Controls the order in which widgets receive key events. Higher values are checked first. Modal dialogs typically use high priority to intercept keys before other widgets:
fn priority(&self) -> u8 {
200 // High priority for modal dialogs
}
required_height() - Returns the height the widget needs. The layout system uses this to allocate space appropriately:
fn required_height(&self, available: u16) -> u16 {
10 // Request 10 rows
}
blocks_input() - Returns true if this widget should prevent text input when active. Modal widgets typically block input to ensure the user addresses them before continuing:
fn blocks_input(&self) -> bool {
true // Modal behavior
}
is_overlay() - Returns true if this widget renders as a full-screen overlay. Overlays render on top of all other content:
fn is_overlay(&self) -> bool {
true
}
WidgetKeyResult
The result of handling a key event tells the system how to proceed. This three-option enum covers the common cases: ignoring the event, consuming it quietly, or consuming it with a requested action.
pub enum WidgetKeyResult {
NotHandled, // Key was not consumed
Handled, // Key was consumed, no action needed
Action(WidgetAction), // Key was consumed, request an action
}
Widget Actions
Actions that widgets can request from the application provide a way for widgets to trigger behavior they can’t implement themselves. Submitting a question response, for example, requires coordination with the tool system that the widget doesn’t have direct access to.
pub enum WidgetAction {
SubmitQuestion { tool_use_id: String, response: AskUserQuestionsResponse },
CancelQuestion { tool_use_id: String },
SubmitPermission { tool_use_id: String, response: PermissionResponse },
CancelPermission { tool_use_id: String },
SwitchSession { session_id: i64 },
ExecuteCommand { command: String },
Close,
}
WidgetKeyContext
The context passed to handle_key provides access to resources the widget might need during event handling. This avoids requiring widgets to store references to shared state.
pub struct WidgetKeyContext<'a> {
pub theme: &'a Theme,
pub nav: NavigationHelper<'a>,
}
The NavigationHelper lets widgets respect configured key bindings rather than hardcoding specific keys. This ensures your widget works correctly regardless of how the user has customized their bindings:
fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
if ctx.nav.is_up(key) {
self.move_up();
return WidgetKeyResult::Handled;
}
if ctx.nav.is_down(key) {
self.move_down();
return WidgetKeyResult::Handled;
}
WidgetKeyResult::NotHandled
}
Priority-Based Key Handling
Widgets receive key events in priority order, highest first. When a widget returns Handled or Action, the event stops propagating. This allows modal widgets like dialogs to intercept keys before other widgets see them.
The priority system eliminates complex event routing logic. Instead of explicitly managing which widget should receive events, you simply assign appropriate priorities and let the system handle distribution.
// Priority order (highest first):
// 1. Overlay widgets (typically 200+)
// 2. Modal panels (typically 150)
// 3. Default widgets (100)
// 4. Background widgets (below 100)
Complete Custom Widget Example
A notification widget demonstrates how the pieces fit together. This widget displays a message and dismisses when the user presses Enter or Escape. It uses high priority to intercept keys and blocks input to ensure the user acknowledges the notification.
use std::any::Any;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
widgets::{Block, Borders, Paragraph},
Frame,
};
use agent_air::tui::themes::Theme;
use agent_air::tui::widgets::{Widget, WidgetKeyContext, WidgetKeyResult};
pub struct NotificationWidget {
message: String,
visible: bool,
}
impl NotificationWidget {
pub fn new() -> Self {
Self { message: String::new(), visible: false }
}
pub fn show(&mut self, message: impl Into<String>) {
self.message = message.into();
self.visible = true;
}
pub fn hide(&mut self) {
self.visible = false;
}
}
impl Widget for NotificationWidget {
fn id(&self) -> &'static str { "notification" }
fn priority(&self) -> u8 { 150 }
fn is_active(&self) -> bool { self.visible }
fn handle_key(&mut self, key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
match key.code {
KeyCode::Enter | KeyCode::Esc => {
self.hide();
WidgetKeyResult::Handled
}
_ => WidgetKeyResult::NotHandled,
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.border_focused)
.title("Notification");
let content = Paragraph::new(self.message.as_str())
.style(theme.text)
.block(block);
frame.render_widget(content, area);
}
fn required_height(&self, _available: u16) -> u16 { 5 }
fn blocks_input(&self) -> bool { true }
fn as_any(&self) -> &dyn Any { self }
fn as_any_mut(&mut self) -> &mut dyn Any { self }
fn into_any(self: Box<Self>) -> Box<dyn Any> { self }
}
Layout Integration
Registered widgets must be included in the layout to be rendered. The standard layout includes default widget IDs, but custom widgets need to be added explicitly. This separation between registration and layout gives you flexibility in which widgets appear and where.
use agent_air::tui::layout::{LayoutTemplate, StandardOptions};
use agent_air::tui::widgets::widget_ids;
let layout = LayoutTemplate::Standard(StandardOptions {
panel_widget_ids: vec![
widget_ids::PERMISSION_PANEL,
widget_ids::QUESTION_PANEL,
"my_custom_panel",
],
..Default::default()
});
agent.into_tui().with_layout(layout).run()?;
Accessing Widget State
Use downcasting to access widget state from outside the widget. This technique lets you update widget state programmatically, such as showing a notification or updating a display based on external events.
The as_any_mut() method on the Widget trait enables this pattern by providing a way to convert from the trait object back to the concrete type.
if let Some(widget) = app.get_widget_mut("notification") {
if let Some(notification) = widget.as_any_mut().downcast_mut::<NotificationWidget>() {
notification.show("Operation complete!");
}
}
ConversationView Trait
For advanced conversation display customization, implement the ConversationView trait. This trait defines the full interface for conversation widgets, including message management, streaming support, and state persistence.
Implementing a custom conversation view lets you change how messages are displayed, add custom rendering for specific content types, or integrate with external systems for message storage.
pub trait ConversationView: Send + 'static {
fn add_user_message(&mut self, content: &str, timestamp: Option<String>);
fn add_assistant_message(&mut self, content: &str, timestamp: Option<String>);
fn add_system_message(&mut self, content: &str, timestamp: Option<String>);
fn append_streaming(&mut self, text: &str);
fn complete_streaming(&mut self, final_text: &str);
fn discard_streaming(&mut self);
fn add_tool_message(&mut self, data: ToolMessageData);
fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus);
fn scroll_up(&mut self, lines: usize);
fn scroll_down(&mut self, lines: usize);
fn enable_auto_scroll(&mut self);
fn save_state(&self) -> Box<dyn Any + Send>;
fn restore_state(&mut self, state: Box<dyn Any + Send>);
fn clear(&mut self);
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, pending: Option<&str>);
fn step_spinner(&mut self);
}
Create a custom conversation view factory to use your implementation per session.
