Slash Commands

Slash commands provide a way for users to trigger specific actions by typing / followed by a command name. The command system is built around the SlashCommand trait, which defines how commands are named, described, and executed. Commands have access to application functionality through CommandContext and can return various results to control what happens after execution.

The command architecture separates concerns cleanly: the trait defines the command interface, the registry manages available commands, and the context provides controlled access to app functionality. This design makes it easy to add custom commands while maintaining consistency with built-in ones.


SlashCommand Trait

The SlashCommand trait defines the interface that all commands must implement. It requires three methods: one for the command name, one for a description shown in the command popup, and one for the actual execution logic.

pub trait SlashCommand: Send + Sync {
    /// Command name without the leading slash (e.g., "clear").
    fn name(&self) -> &str;

    /// Short description shown in the slash popup.
    fn description(&self) -> &str;

    /// Execute the command.
    ///
    /// # Arguments
    /// * `args` - Everything after the command name, trimmed
    /// * `ctx` - Context providing access to app functionality
    ///
    /// # Returns
    /// A `CommandResult` indicating what happened
    fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult;
}

The Send + Sync bounds allow commands to be safely shared across threads, which is necessary for the async runtime.

name

Returns the command name without the leading slash. This name is used for matching user input and displaying in the command popup.

fn name(&self) -> &str {
    "deploy"
}

When the user types /deploy, the command system matches against this name.

description

Returns a short description displayed in the slash command popup. Keep descriptions concise but informative enough to help users understand what the command does.

fn description(&self) -> &str {
    "Deploy the application to production"
}

execute

Performs the command’s operation. The args parameter contains everything after the command name with leading and trailing whitespace trimmed. For /deploy staging --fast, args would be "staging --fast".

fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
    let parts: Vec<&str> = args.split_whitespace().collect();
    let target = parts.first().unwrap_or(&"production");
    CommandResult::Message(format!("Deploying to {}...", target))
}

CommandResult

The execute method returns a CommandResult enum that tells the application how to handle the result. Each variant serves a different purpose and triggers different behavior in the UI.

pub enum CommandResult {
    /// Command succeeded, no message to display.
    Ok,

    /// Command succeeded, display this message in the conversation.
    Message(String),

    /// Command failed with an error message.
    Error(String),

    /// Command requests the application to quit.
    Quit,

    /// Command handled its own UI (e.g., opened a picker).
    Handled,
}

Ok

Use when the command succeeds silently without needing to display output:

fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult {
    self.perform_background_operation();
    CommandResult::Ok
}

Message

Use when you want to display output in the conversation view:

fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
    let result = self.compute_something(args);
    CommandResult::Message(format!("Result: {}", result))
}

Error

Use when the command fails and you want to show an error to the user:

fn execute(&self, args: &str, _ctx: &mut CommandContext) -> CommandResult {
    if args.is_empty() {
        return CommandResult::Error("Missing required argument".to_string());
    }
    // ... process args
    CommandResult::Ok
}

Quit

Use when the command should exit the application:

fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
    ctx.request_quit();
    CommandResult::Quit
}

Handled

Use when the command opens a UI element (picker, overlay) and should not display additional output:

fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
    ctx.open_theme_picker();
    CommandResult::Handled
}

Implementing Custom Commands

Create custom commands by implementing the SlashCommand trait on a struct. The struct can hold state that persists across invocations.

use agent_air::tui::commands::{SlashCommand, CommandContext, CommandResult};
use std::sync::atomic::{AtomicUsize, Ordering};

pub struct CounterCommand {
    count: AtomicUsize,
}

impl CounterCommand {
    pub fn new() -> Self {
        Self { count: AtomicUsize::new(0) }
    }
}

impl SlashCommand for CounterCommand {
    fn name(&self) -> &str {
        "count"
    }

    fn description(&self) -> &str {
        "Increment and show a counter"
    }

    fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult {
        let n = self.count.fetch_add(1, Ordering::SeqCst);
        CommandResult::Message(format!("Count: {}", n + 1))
    }
}

CustomCommand Helper

For simple commands without internal state, use the CustomCommand helper instead of implementing the trait directly. This wrapper accepts a closure and implements SlashCommand for you.

use agent_air::tui::commands::{CustomCommand, CommandResult};

let cmd = CustomCommand::new(
    "greet",
    "Say hello to someone",
    |args, _ctx| {
        let name = if args.is_empty() { "World" } else { args };
        CommandResult::Message(format!("Hello, {}!", name))
    },
);

The CustomCommand struct stores the name, description, and handler closure:

pub struct CustomCommand {
    name: String,
    description: String,
    handler: Box<dyn Fn(&str, &mut CommandContext) -> CommandResult + Send + Sync>,
}

This approach is convenient for quick commands that don’t need to maintain state between invocations.


Command Registration

The CommandRegistry provides a builder-style API for configuring which slash commands are available in your agent. You can start with defaults, build from scratch, add custom commands, or remove unwanted commands.

Starting with Defaults

The with_defaults() method creates a registry with all built-in commands:

use agent_air::tui::commands::CommandRegistry;

let commands = CommandRegistry::with_defaults().build();

This includes: help, clear, compact, themes, sessions, status, version, new-session, and quit.

Building from Scratch

The new() method creates an empty registry, giving you complete control:

use agent_air::tui::commands::{
    CommandRegistry, HelpCommand, ClearCommand, ThemesCommand
};

let commands = CommandRegistry::new()
    .add(HelpCommand)
    .add(ClearCommand)
    .add(ThemesCommand)
    .build();

Adding Commands

The add() method adds a command that implements SlashCommand:

let commands = CommandRegistry::with_defaults()
    .add(CustomCommand::new("deploy", "Deploy the app", |args, _ctx| {
        CommandResult::Message(format!("Deploying to {}...", args))
    }))
    .build();

For boxed commands, use add_boxed():

let custom: Box<dyn SlashCommand> = Box::new(MyCommand::new());
let commands = CommandRegistry::with_defaults()
    .add_boxed(custom)
    .build();

Removing Commands

The remove() method removes a command by name:

let commands = CommandRegistry::with_defaults()
    .remove("quit")        // Disable quit command
    .remove("new-session") // Disable session creation
    .build();

Chaining Operations

Methods return Self, allowing chained calls:

let commands = CommandRegistry::with_defaults()
    .remove("quit")
    .add(CustomCommand::new("exit", "Custom exit", |_, ctx| {
        ctx.show_message("Goodbye!");
        ctx.request_quit();
        CommandResult::Quit
    }))
    .add(CustomCommand::new("ping", "Ping pong", |_, _| {
        CommandResult::Message("Pong!".to_string())
    }))
    .build();

Registering with AgentAir

Pass the built command list to AgentAir during configuration:

use agent_air::AgentAir;
use agent_air::tui::commands::{CommandRegistry, CustomCommand, CommandResult};

let commands = CommandRegistry::with_defaults()
    .add(CustomCommand::new("hello", "Say hello", |_, _| {
        CommandResult::Message("Hello!".to_string())
    }))
    .build();

let agent = AgentAir::builder()
    .config(my_config)
    .commands(commands)
    .build()?;

CommandContext

CommandContext is provided to slash commands during execution. It offers controlled access to app functionality through methods rather than exposing internal state directly. The context provides session information, UI operations, and access to extension data.

Session Information

Access basic session and agent information:

fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
    let id = ctx.session_id();
    let name = ctx.agent_name();
    let version = ctx.version();

    CommandResult::Message(format!("{} v{} (session {})", name, version, id))
}

Command Listing

Access the list of all registered commands, useful for implementing help:

fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
    let mut output = String::from("Commands:\n");
    for cmd in ctx.commands() {
        output.push_str(&format!("  /{} - {}\n", cmd.name(), cmd.description()));
    }
    CommandResult::Message(output)
}

Display Messages

Show messages directly in the conversation view:

fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
    ctx.show_message("Operation started...");
    // Perform operation
    ctx.show_message("Operation complete!");
    CommandResult::Ok
}

Deferred Actions

Some operations cannot be performed directly during command execution because they require App-level handling. These are requested through deferred action methods, and the App executes them after the command returns.

// Clear the conversation view
ctx.clear_conversation();

// Trigger context compaction
ctx.compact_conversation();

// Open the theme picker overlay
ctx.open_theme_picker();

// Open the session picker overlay
ctx.open_session_picker();

// Create a new LLM session
ctx.create_new_session();

// Request application quit
ctx.request_quit();

These methods queue a PendingAction that the App processes after execution:

pub enum PendingAction {
    OpenThemePicker,
    OpenSessionPicker,
    ClearConversation,
    CompactConversation,
    CreateNewSession,
    Quit,
}

Context Method Reference

MethodReturnsDescription
session_id()i64Current session ID
agent_name()&strAgent name
version()&strAgent version
commands()&[Box<dyn SlashCommand>]All registered commands
show_message(msg)()Display message in conversation
clear_conversation()()Request conversation clear
compact_conversation()()Request context compaction
open_theme_picker()()Request theme picker
open_session_picker()()Request session picker
create_new_session()()Request new session
request_quit()()Request application quit
extension::<T>()Option<&T>Get extension data

Extension Data

Extension data allows agents to provide custom, type-safe data that slash commands can access during execution. This enables commands to interact with agent-specific resources, configuration, or state without coupling the command system to particular agent implementations.

Setting Extension Data

Configure extension data when building your agent:

use agent_air::AgentAir;

struct MyContext {
    api_key: String,
    base_url: String,
}

let extension = MyContext {
    api_key: "secret-key".to_string(),
    base_url: "https://api.example.com".to_string(),
};

let agent = AgentAir::builder()
    .config(my_config)
    .command_extension(extension)
    .build()?;

Accessing Extension Data

Use ctx.extension::<T>() to retrieve the data with type-safe downcasting:

pub struct ApiCommand;

impl SlashCommand for ApiCommand {
    fn name(&self) -> &str { "api" }
    fn description(&self) -> &str { "Call the API" }

    fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
        let my_ctx = match ctx.extension::<MyContext>() {
            Some(c) => c,
            None => return CommandResult::Error("MyContext not configured".to_string()),
        };

        let url = format!("{}/endpoint?q={}", my_ctx.base_url, args);
        CommandResult::Message(format!("Calling: {}", url))
    }
}

The method returns None if no extension was configured or if the type doesn’t match.

Multiple Data Types

The extension system stores a single value. To provide multiple pieces of data, combine them in a struct:

struct AgentExtension {
    database: Database,
    cache: Cache,
    config: AppConfig,
}

// In command:
fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
    let ext = ctx.extension::<AgentExtension>().unwrap();
    let result = ext.database.query("...");
    ext.cache.set("key", result);
    // ...
}

Thread Safety

Extension types must be Send + Sync + 'static because commands may execute on different threads. Use Arc<RwLock<_>> for mutable shared state:

use std::sync::Arc;
use tokio::sync::RwLock;

struct SharedState {
    data: Arc<RwLock<HashMap<String, String>>>,
}

Complete Example

A comprehensive example showing command implementation, registration, and extension data:

use agent_air::AgentAir;
use agent_air::tui::commands::{
    CommandRegistry, CustomCommand, CommandResult, SlashCommand, CommandContext,
};
use std::sync::Arc;

// Extension data struct
struct DeployContext {
    environments: Vec<String>,
    current_env: Arc<std::sync::RwLock<String>>,
}

// Custom command using extension
struct DeployCommand;

impl SlashCommand for DeployCommand {
    fn name(&self) -> &str { "deploy" }
    fn description(&self) -> &str { "Deploy to an environment" }

    fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
        let deploy_ctx = match ctx.extension::<DeployContext>() {
            Some(c) => c,
            None => return CommandResult::Error("DeployContext not configured".to_string()),
        };

        let target = if args.is_empty() {
            deploy_ctx.current_env.read().unwrap().clone()
        } else {
            args.to_string()
        };

        if !deploy_ctx.environments.contains(&target) {
            return CommandResult::Error(format!(
                "Unknown environment '{}'. Available: {:?}",
                target, deploy_ctx.environments
            ));
        }

        CommandResult::Message(format!("Deploying to {}...", target))
    }
}

// Setup
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let extension = DeployContext {
        environments: vec!["staging".into(), "production".into()],
        current_env: Arc::new(std::sync::RwLock::new("staging".into())),
    };

    let commands = CommandRegistry::with_defaults()
        .remove("new-session")
        .add(DeployCommand)
        .add(CustomCommand::new("ping", "Ping test", |_, _| {
            CommandResult::Message("Pong!".to_string())
        }))
        .add(CustomCommand::new("echo", "Echo input", |args, _| {
            CommandResult::Message(args.to_string())
        }))
        .build();

    let agent = AgentAir::builder()
        .config(my_config)
        .commands(commands)
        .command_extension(extension)
        .build()?;

    Ok(())
}