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
| Method | Returns | Description |
|---|---|---|
session_id() | i64 | Current session ID |
agent_name() | &str | Agent name |
version() | &str | Agent 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(())
} 