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

  1. User presses Ctrl+D (with empty input)
  2. Handler enters AwaitingConfirmation state
  3. Status hint shows “Press again to exit”
  4. If user presses Ctrl+D again within timeout, app exits
  5. 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()?;