use super::{CompletionEntry, SlashCtx, SlashOutcome};
use crate::app::agent_session::AgentSession;
use crate::extensions::{WasmCommandDef, WasmExtensionManager};
use crate::tui::app::AppState;
use crate::tui::completion::CompletionItem;
use std::sync::Arc;
#[allow(dead_code)] pub(crate) trait SlashCommand: Send + Sync {
fn name(&self) -> &str;
fn aliases(&self) -> &[&str] {
&[]
}
fn description(&self) -> &str;
fn usage(&self) -> &str {
""
}
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome;
fn complete_arg(
&self,
prefix: &str,
_session: &AgentSession,
_state: &AppState,
) -> Vec<CompletionItem> {
let _ = prefix;
Vec::new()
}
fn matches(&self, token: &str) -> bool {
let t = token.strip_prefix('/').unwrap_or(token);
t.eq_ignore_ascii_case(self.name())
|| self.aliases().iter().any(|a| t.eq_ignore_ascii_case(a))
}
}
pub(crate) struct SlashRegistry {
builtin: Vec<Box<dyn SlashCommand>>,
extensions: Vec<ExtensionCmdAdapter>,
}
pub(crate) struct ExtensionCmdAdapter {
ext_name: String,
def: WasmCommandDef,
mgr: Arc<WasmExtensionManager>,
}
impl ExtensionCmdAdapter {
fn matches_token(&self, token: &str) -> bool {
let t = token.strip_prefix('/').unwrap_or(token);
t.eq_ignore_ascii_case(&self.def.name)
}
}
impl SlashCommand for ExtensionCmdAdapter {
fn name(&self) -> &str {
&self.def.name
}
fn description(&self) -> &str {
&self.def.description
}
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let output = self
.mgr
.execute_command(&self.def.name, args)
.unwrap_or_else(|e| format!("Error: {}", e));
ctx.state.add_notification(
format!("[{}] {}", self.ext_name, output),
crate::tui::app::NotificationKind::Info,
);
SlashOutcome::Handled
}
}
impl SlashRegistry {
pub(crate) fn builtins() -> Self {
let mut registry = SlashRegistry {
builtin: Vec::new(),
extensions: Vec::new(),
};
super::builtin::register_all(&mut registry);
registry
}
pub(crate) fn register(&mut self, cmd: Box<dyn SlashCommand>) {
self.builtin.push(cmd);
}
pub(crate) fn sync_extensions(&mut self, mgr: Option<&std::sync::Arc<WasmExtensionManager>>) {
self.extensions.clear();
if let Some(mgr) = mgr {
for (ext_name, def) in mgr.all_command_defs() {
self.extensions.push(ExtensionCmdAdapter {
ext_name: ext_name.to_string(),
def: def.clone(),
mgr: std::sync::Arc::clone(mgr),
});
}
}
}
pub(crate) fn complete_command(&self, query: &str) -> Vec<CompletionEntry> {
let mut entries: Vec<CompletionEntry> = self
.builtin
.iter()
.flat_map(|cmd| {
let canonical = format!("/{}", cmd.name());
let mut e = vec![CompletionEntry {
display: canonical.clone(),
canonical: canonical.clone(),
description: cmd.description().to_string(),
is_extension: false,
}];
for alias in cmd.aliases() {
let display = format!("/{}", alias);
e.push(CompletionEntry {
display,
canonical: canonical.clone(),
description: cmd.description().to_string(),
is_extension: false,
});
}
e
})
.collect();
for ext in &self.extensions {
entries.push(CompletionEntry {
display: format!("/{}", ext.name()),
canonical: format!("/{}", ext.name()),
description: ext.description().to_string(),
is_extension: true,
});
}
entries
.into_iter()
.filter(|e| {
e.display[1..]
.to_lowercase()
.starts_with(&query.to_lowercase())
})
.collect()
}
pub(crate) fn dispatch(&self, input: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let trimmed = input.trim();
let (cmd_token, arg) = match trimmed.find(' ') {
Some(space) => (&trimmed[..space], Some(trimmed[space + 1..].trim())),
None => (trimmed, None),
};
let token = cmd_token.strip_prefix('/').unwrap_or(cmd_token);
for command in &self.builtin {
if command.matches(token) {
return command.execute(arg.unwrap_or(""), ctx);
}
}
for ext in &self.extensions {
if ext.matches_token(token) {
return ext.execute(arg.unwrap_or(""), ctx);
}
}
SlashOutcome::NotHandled
}
pub(crate) fn complete_arg(
&self,
cmd_token: &str,
arg_prefix: &str,
session: &AgentSession,
state: &AppState,
) -> Vec<CompletionItem> {
let token = cmd_token.strip_prefix('/').unwrap_or(cmd_token);
for command in &self.builtin {
if command.matches(token) {
return command.complete_arg(arg_prefix, session, state);
}
}
for ext in &self.extensions {
if ext.matches_token(token) {
return ext.complete_arg(arg_prefix, session, state);
}
}
Vec::new()
}
#[allow(dead_code)] pub(crate) fn usage_for(&self, cmd_token: &str) -> Option<&str> {
let token = cmd_token.strip_prefix('/').unwrap_or(cmd_token);
for command in &self.builtin {
if command.matches(token) {
let u = command.usage();
return if u.is_empty() { None } else { Some(u) };
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyCommand {
name: &'static str,
aliases: Vec<&'static str>,
description: &'static str,
}
impl SlashCommand for DummyCommand {
fn name(&self) -> &str {
self.name
}
fn aliases(&self) -> &[&str] {
&self.aliases
}
fn description(&self) -> &str {
self.description
}
fn execute(&self, _args: &str, _ctx: &mut SlashCtx<'_>) -> SlashOutcome {
SlashOutcome::Handled
}
}
fn registry_with(cmds: Vec<Box<dyn SlashCommand>>) -> SlashRegistry {
SlashRegistry {
builtin: cmds,
extensions: Vec::new(),
}
}
#[test]
fn complete_command_includes_canonical_and_aliases() {
let reg = registry_with(vec![
Box::new(DummyCommand {
name: "extensions",
aliases: vec!["ext"],
description: "List extensions",
}),
Box::new(DummyCommand {
name: "model",
aliases: vec![],
description: "Select model",
}),
]);
let all = reg.complete_command("");
let names: Vec<&str> = all.iter().map(|e| e.display.as_str()).collect();
assert!(names.contains(&"/extensions"));
assert!(names.contains(&"/ext"));
assert!(names.contains(&"/model"));
}
#[test]
fn complete_command_filters_by_prefix_case_insensitive() {
let reg = registry_with(vec![Box::new(DummyCommand {
name: "Model",
aliases: vec![],
description: "d",
})]);
let res = reg.complete_command("mo");
assert_eq!(res.len(), 1);
assert_eq!(res[0].display, "/Model");
}
#[test]
fn matches_resolves_alias_case_insensitively() {
let cmd = DummyCommand {
name: "hotkeys",
aliases: vec!["keys"],
description: "d",
};
assert!(cmd.matches("/hotkeys"));
assert!(cmd.matches("/KEYS"));
assert!(cmd.matches("keys"));
assert!(!cmd.matches("/unknown"));
}
#[test]
fn alias_entries_point_at_canonical() {
let reg = registry_with(vec![Box::new(DummyCommand {
name: "extensions",
aliases: vec!["ext"],
description: "List extensions",
})]);
let ext = reg
.complete_command("ext")
.into_iter()
.find(|e| e.display == "/ext")
.expect("/ext present");
assert_eq!(ext.canonical, "/extensions");
assert!(!ext.is_extension);
}
}