Key Handlers
The KeyHandler trait defines how key events are processed at the application level. Key handlers sit at the top of the input processing chain, receiving every key press before widgets or default text handling. This architecture enables complete control over keyboard behavior, including modal editing, custom shortcuts, and exit confirmation flows.
Understanding the key handling flow is essential for building responsive, intuitive interfaces. The handler decides whether to consume a key, pass it through to widgets, or trigger an application action. This decision can depend on context—whether the input is empty, whether a modal is active, or whether the agent is processing a request.
Key Processing Flow
Keys flow through a well-defined pipeline. The handler gets first opportunity to process each key, and its decision determines what happens next.
Key Press
│
▼
KeyHandler.handle_key(key, context)
│
├── If Action → App executes the action
│
├── If Handled → Stop processing
│
└── If NotHandled → Widget dispatch (modals get priority)
│
└── If still unhandled → Default text input
KeyHandler Trait
The trait defines three methods: one for handling keys, one for providing status hints, and one for accessing bindings.
pub trait KeyHandler: Send + 'static {
/// Handle a key event.
///
/// Called for every key press. Return:
/// - `NotHandled` to pass to widgets and default handling
/// - `Handled` to consume the key
/// - `Action(...)` to execute an app action
fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult;
/// Get a status hint to display in the status bar.
fn status_hint(&self) -> Option<String> {
None
}
/// Get a reference to the key bindings.
fn bindings(&self) -> &KeyBindings;
}
The Send + 'static bounds allow the handler to be used across async boundaries.
AppKeyResult
The return type indicates how the key should be handled:
pub enum AppKeyResult {
/// Key was handled, stop processing.
Handled,
/// Key was not handled, continue to widget dispatch.
NotHandled,
/// Execute an application action.
Action(AppKeyAction),
}
- Handled: The key was consumed. No further processing occurs.
- NotHandled: Pass the key to widgets and then default text handling.
- Action: Execute a specific application action.
AppKeyAction
Application-level actions the handler can request:
pub enum AppKeyAction {
// Navigation
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
MoveLineStart,
MoveLineEnd,
// Editing
DeleteCharBefore,
DeleteCharAt,
KillLine,
InsertNewline,
InsertChar(char),
// Application control
Submit,
Interrupt,
Quit,
RequestExit,
// Widget activation
ActivateSlashPopup,
// Custom
Custom(Arc<dyn Any + Send + Sync>),
}
Use AppKeyAction::custom() for agent-specific actions that don’t fit the standard set.
KeyContext
Context provided to the handler for making decisions:
pub struct KeyContext {
/// Whether the input buffer is empty.
pub input_empty: bool,
/// Whether currently processing (waiting for LLM/tools).
pub is_processing: bool,
/// Whether a modal widget is blocking input.
pub widget_blocking: bool,
}
Use this context to make conditional decisions:
fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult {
// Only allow quit when input is empty
if matches!(key.code, KeyCode::Esc) && context.input_empty {
return AppKeyResult::Action(AppKeyAction::Quit);
}
// Block most keys during processing
if context.is_processing && !self.is_interrupt_key(&key) {
return AppKeyResult::Handled;
}
// Let modals handle their own keys
if context.widget_blocking {
return AppKeyResult::NotHandled;
}
// ... continue processing
AppKeyResult::NotHandled
}
DefaultKeyHandler
The framework provides DefaultKeyHandler which implements KeyHandler with configurable bindings and exit confirmation support.
use agent_air::tui::keys::{DefaultKeyHandler, KeyBindings};
// Use with a preset
let handler = DefaultKeyHandler::new(KeyBindings::emacs());
// Use default (bare_minimum bindings)
let handler = DefaultKeyHandler::default();
Custom Bindings on DefaultKeyHandler
Add custom key bindings that trigger custom actions:
use agent_air::tui::keys::{DefaultKeyHandler, KeyBindings, KeyCombo, AppKeyAction};
let handler = DefaultKeyHandler::new(KeyBindings::emacs())
.with_custom_binding(KeyCombo::ctrl('t'), || {
AppKeyAction::custom("toggle_something")
})
.with_custom_binding(KeyCombo::alt('h'), || {
AppKeyAction::custom("show_help")
});
Custom bindings are checked before standard bindings, allowing you to override default behavior.
ComposedKeyHandler
For more complex scenarios, wrap a handler with ComposedKeyHandler to add pre-processing hooks:
use agent_air::tui::keys::{
ComposedKeyHandler, DefaultKeyHandler, KeyBindings, AppKeyResult, AppKeyAction
};
use crossterm::event::KeyCode;
let base = DefaultKeyHandler::new(KeyBindings::emacs());
let handler = ComposedKeyHandler::new(base)
.with_pre_hook(|key, _ctx| {
// Intercept F1 for help
if key.code == KeyCode::F(1) {
return Some(AppKeyResult::Action(AppKeyAction::custom("help")));
}
None // Let inner handler process
})
.with_pre_hook(|key, ctx| {
// Log all key presses during development
eprintln!("Key: {:?}, processing: {}", key.code, ctx.is_processing);
None
});
Hooks are called in order. If a hook returns Some(result), that result is used and subsequent hooks and the inner handler are skipped.
Pre-Hook Signature
fn with_pre_hook<F>(self, hook: F) -> Self
where
F: Fn(&KeyEvent, &KeyContext) -> Option<AppKeyResult> + Send + 'static
Return Some(AppKeyResult) to handle the key, or None to pass through.
Exit Confirmation
The emacs binding preset implements a two-key exit sequence using ExitState. This prevents accidental exits by requiring confirmation.
pub enum ExitState {
/// Normal operation, no exit pending.
Normal,
/// Awaiting exit confirmation within the timeout.
AwaitingConfirmation {
since: Instant,
timeout_secs: u64,
},
}
How It Works
- User presses Ctrl+D (with empty input)
- Handler enters
AwaitingConfirmationstate - Status hint shows “Press again to exit”
- If user presses Ctrl+D again within timeout, app exits
- If user presses any other key or timeout expires, exit is cancelled
// First Ctrl+D: enter exit mode
if self.is_exit_key(&key) && context.input_empty {
self.exit_state = ExitState::awaiting_confirmation(
self.bindings.exit_timeout_secs,
);
return AppKeyResult::Handled;
}
// In awaiting state
if self.exit_state.is_awaiting() {
if self.is_exit_key(&key) {
self.exit_state.reset();
return AppKeyResult::Action(AppKeyAction::RequestExit);
}
// Any other key cancels exit mode
self.exit_state.reset();
}
ExitState Methods
impl ExitState {
/// Create a new awaiting confirmation state.
pub fn awaiting_confirmation(timeout_secs: u64) -> Self;
/// Check if the confirmation has expired.
pub fn is_expired(&self) -> bool;
/// Check if awaiting confirmation (and not expired).
pub fn is_awaiting(&self) -> bool;
/// Reset to normal state.
pub fn reset(&mut self);
}
Widget Blocking
When a modal widget (like a picker or question panel) is active, it sets widget_blocking: true in the KeyContext. This signals that most keys should be passed through to the widget rather than handled by the key handler.
impl KeyHandler for DefaultKeyHandler {
fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult {
if context.widget_blocking {
// Still allow force-quit even in modals
if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
return AppKeyResult::Action(AppKeyAction::Quit);
}
// Let the modal widget handle everything else
return AppKeyResult::NotHandled;
}
// ... normal key handling
}
}
This allows modal widgets to receive keys for navigation and selection while preserving an escape hatch (Ctrl+Q force quit).
Processing State
During LLM processing, the handler should block most input to prevent interference:
if context.is_processing {
if KeyBindings::matches_any(&self.bindings.interrupt, &key) {
return AppKeyResult::Action(AppKeyAction::Interrupt);
}
if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
return AppKeyResult::Action(AppKeyAction::Quit);
}
// Handle exit mode even during processing
if self.is_exit_key(&key) && context.input_empty {
// ... exit mode logic
}
// Ignore all other keys during processing
return AppKeyResult::Handled;
}
Only interrupt, force quit, and exit mode keys are processed during LLM requests.
Status Hints
The status_hint() method returns optional text for the status bar:
fn status_hint(&self) -> Option<String> {
if self.exit_state.is_awaiting() {
Some("Press again to exit".to_string())
} else {
None
}
}
Use this to communicate handler state to the user, such as exit confirmation prompts or mode indicators.
ExitHandler Trait
Implement ExitHandler to run cleanup code before the application exits:
pub trait ExitHandler: Send + 'static {
/// Called when exit is confirmed.
/// Return true to proceed, false to cancel exit.
fn on_exit(&mut self) -> bool {
true
}
}
Example implementations:
// Save session on exit
struct SaveOnExitHandler {
session_file: PathBuf,
}
impl ExitHandler for SaveOnExitHandler {
fn on_exit(&mut self) -> bool {
if let Err(e) = self.save_session() {
eprintln!("Failed to save session: {}", e);
}
true // proceed with exit
}
}
// Cancel exit if unsaved changes
struct UnsavedChangesHandler {
has_unsaved_changes: bool,
}
impl ExitHandler for UnsavedChangesHandler {
fn on_exit(&mut self) -> bool {
!self.has_unsaved_changes // Cancel if unsaved
}
}
Custom KeyHandler Implementation
For complete control, implement the trait directly. This example shows a vim-style modal handler:
use agent_air::tui::keys::{
KeyHandler, KeyBindings, KeyContext, AppKeyResult, AppKeyAction,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub struct VimKeyHandler {
bindings: KeyBindings,
mode: VimMode,
}
enum VimMode {
Normal,
Insert,
Command,
}
impl VimKeyHandler {
pub fn new() -> Self {
Self {
bindings: KeyBindings::bare_minimum(),
mode: VimMode::Normal,
}
}
}
impl KeyHandler for VimKeyHandler {
fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult {
// Let modals handle their keys
if context.widget_blocking {
if key.code == KeyCode::Char('q') && key.modifiers == KeyModifiers::CONTROL {
return AppKeyResult::Action(AppKeyAction::Quit);
}
return AppKeyResult::NotHandled;
}
match self.mode {
VimMode::Normal => self.handle_normal_mode(key, context),
VimMode::Insert => self.handle_insert_mode(key, context),
VimMode::Command => self.handle_command_mode(key, context),
}
}
fn status_hint(&self) -> Option<String> {
Some(match self.mode {
VimMode::Normal => "-- NORMAL --".to_string(),
VimMode::Insert => "-- INSERT --".to_string(),
VimMode::Command => ":".to_string(),
})
}
fn bindings(&self) -> &KeyBindings {
&self.bindings
}
}
impl VimKeyHandler {
fn handle_normal_mode(&mut self, key: KeyEvent, _ctx: &KeyContext) -> AppKeyResult {
match key.code {
KeyCode::Char('i') => {
self.mode = VimMode::Insert;
AppKeyResult::Handled
}
KeyCode::Char('j') => AppKeyResult::Action(AppKeyAction::MoveDown),
KeyCode::Char('k') => AppKeyResult::Action(AppKeyAction::MoveUp),
KeyCode::Char('h') => AppKeyResult::Action(AppKeyAction::MoveLeft),
KeyCode::Char('l') => AppKeyResult::Action(AppKeyAction::MoveRight),
KeyCode::Char(':') => {
self.mode = VimMode::Command;
AppKeyResult::Handled
}
_ => AppKeyResult::Handled, // Block unknown keys in normal mode
}
}
fn handle_insert_mode(&mut self, key: KeyEvent, _ctx: &KeyContext) -> AppKeyResult {
match key.code {
KeyCode::Esc => {
self.mode = VimMode::Normal;
AppKeyResult::Handled
}
KeyCode::Char(c) => AppKeyResult::Action(AppKeyAction::InsertChar(c)),
KeyCode::Backspace => AppKeyResult::Action(AppKeyAction::DeleteCharBefore),
KeyCode::Enter => AppKeyResult::Action(AppKeyAction::Submit),
_ => AppKeyResult::NotHandled,
}
}
fn handle_command_mode(&mut self, key: KeyEvent, _ctx: &KeyContext) -> AppKeyResult {
match key.code {
KeyCode::Esc => {
self.mode = VimMode::Normal;
AppKeyResult::Handled
}
KeyCode::Enter => {
// Process command here
self.mode = VimMode::Normal;
AppKeyResult::Handled
}
_ => AppKeyResult::Handled,
}
}
}
Custom Actions
Use AppKeyAction::custom() for agent-specific actions:
#[derive(Debug)]
enum MyAction {
ToggleDebug,
ShowMetrics,
ReloadConfig,
}
fn handle_key(&mut self, key: KeyEvent, _context: &KeyContext) -> AppKeyResult {
match key.code {
KeyCode::F(1) => AppKeyResult::Action(AppKeyAction::custom(MyAction::ShowMetrics)),
KeyCode::F(5) => AppKeyResult::Action(AppKeyAction::custom(MyAction::ReloadConfig)),
_ => AppKeyResult::NotHandled,
}
}
Handle custom actions in your app’s event loop by downcasting:
if let AppKeyAction::Custom(any) = action {
if let Some(my_action) = any.downcast_ref::<MyAction>() {
match my_action {
MyAction::ToggleDebug => self.toggle_debug(),
MyAction::ShowMetrics => self.show_metrics(),
MyAction::ReloadConfig => self.reload_config(),
}
}
}
Key Flow Summary
Key Event
│
▼
Check exit timeout expiry
│
▼
widget_blocking? ──► Yes ──► Force quit only, else NotHandled
│
No
▼
is_processing? ──► Yes ──► Interrupt/Force quit/Exit mode only
│
No
▼
Exit confirmation active? ──► Yes ──► Confirm or cancel
│
No
▼
Check custom bindings
│
▼
Check standard bindings
│
▼
Character input → InsertChar
│
▼
NotHandled → Widgets → Default text handling
Complete Example
use agent_air::AgentAir;
use agent_air::tui::keys::{
DefaultKeyHandler, KeyBindings, KeyCombo, AppKeyAction,
ComposedKeyHandler, AppKeyResult,
};
use crossterm::event::KeyCode;
// Start with emacs and customize
let bindings = KeyBindings::emacs()
.without_kill_line() // Disable Ctrl+K
.with_exit_timeout_secs(3) // 3 second exit window
.add_interrupt(KeyCombo::ctrl('c')); // Add Ctrl+C to interrupt
// Create handler with custom action
let base_handler = DefaultKeyHandler::new(bindings)
.with_custom_binding(KeyCombo::ctrl('t'), || {
AppKeyAction::custom("toggle_debug")
});
// Wrap with pre-hooks
let handler = ComposedKeyHandler::new(base_handler)
.with_pre_hook(|key, ctx| {
// Prevent F keys during processing
if ctx.is_processing {
if let KeyCode::F(_) = key.code {
return Some(AppKeyResult::Handled);
}
}
None
});
let agent = AgentAir::builder()
.config(my_config)
.key_handler(handler)
.build()?; 