use oxi_vtui::tui::core::{InlineHandle, InlineMessageKind};
use crate::app::agent_session::AgentSessionHandle;
use crate::tui_vt::main_loop::{RenderState, plain_segment};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SlashOutcome {
Handled,
Quit,
NotHandled,
}
pub(crate) struct SlashCtx<'a> {
pub session: &'a AgentSessionHandle,
pub handle: &'a InlineHandle,
pub state: &'a mut RenderState,
}
impl SlashCtx<'_> {
pub(crate) fn reply(&self, kind: InlineMessageKind, text: impl Into<String>) {
let text = text.into();
for line in text.split('\n') {
self.handle
.append_line(kind, vec![plain_segment(line.to_string())]);
}
}
}
pub(crate) trait SlashCommand: Send + Sync {
fn name(&self) -> &'static str;
fn aliases(&self) -> &'static [&'static str] {
&[]
}
fn description(&self) -> &'static str;
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome;
fn matches(&self, token: &str) -> bool {
token.eq_ignore_ascii_case(self.name())
|| self.aliases().iter().any(|a| token.eq_ignore_ascii_case(a))
}
}
pub struct SlashRegistry {
builtins: Vec<Box<dyn SlashCommand>>,
}
impl SlashRegistry {
pub fn builtins() -> Self {
let mut registry = SlashRegistry {
builtins: Vec::new(),
};
register_all(&mut registry);
registry
}
pub(crate) fn register(&mut self, cmd: Box<dyn SlashCommand>) {
self.builtins.push(cmd);
}
pub fn builtin_commands() -> Vec<(&'static str, &'static str, Vec<&'static str>)> {
Self::builtins()
.builtins
.iter()
.map(|c| (c.name(), c.description(), c.aliases().to_vec()))
.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], trimmed[space + 1..].trim()),
None => (trimmed, ""),
};
let token = cmd_token.strip_prefix('/').unwrap_or(cmd_token);
if matches!(token, "help" | "?" | "commands") {
self.render_help(ctx);
return SlashOutcome::Handled;
}
for command in &self.builtins {
if command.matches(token) {
return command.execute(arg, ctx);
}
}
SlashOutcome::NotHandled
}
fn render_help(&self, ctx: &mut SlashCtx<'_>) {
let mut entries: Vec<(String, String)> = self
.builtins
.iter()
.map(|c| {
let mut names = format!("/{}", c.name());
for alias in c.aliases() {
names.push_str(&format!(", /{alias}"));
}
(names, c.description().to_string())
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut lines = vec!["Available commands:".to_string()];
for (names, desc) in &entries {
lines.push(format!(" {names:<18} {desc}"));
}
ctx.reply(InlineMessageKind::Info, lines.join("\n"));
}
}
fn register_all(registry: &mut SlashRegistry) {
registry.register(Box::new(QuitCommand));
registry.register(Box::new(ClearCommand));
registry.register(Box::new(CompactCommand));
registry.register(Box::new(ModelCommand));
registry.register(Box::new(CancelCommand));
registry.register(Box::new(StatusCommand));
}
struct QuitCommand;
impl SlashCommand for QuitCommand {
fn name(&self) -> &'static str {
"quit"
}
fn aliases(&self) -> &'static [&'static str] {
&["exit", "q"]
}
fn description(&self) -> &'static str {
"Quit oxi (aliases: /exit, /q)"
}
fn execute(&self, _args: &str, _ctx: &mut SlashCtx<'_>) -> SlashOutcome {
SlashOutcome::Quit
}
}
struct ClearCommand;
impl SlashCommand for ClearCommand {
fn name(&self) -> &'static str {
"clear"
}
fn aliases(&self) -> &'static [&'static str] {
&["cls"]
}
fn description(&self) -> &'static str {
"Clear the conversation and transcript (alias: /cls)"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
ctx.session.reset();
ctx.state.transcript.clear();
ctx.state.message_buffer.clear();
ctx.state.scroll_offset = usize::MAX;
ctx.reply(InlineMessageKind::Info, "Conversation cleared.");
SlashOutcome::Handled
}
}
struct CompactCommand;
impl SlashCommand for CompactCommand {
fn name(&self) -> &'static str {
"compact"
}
fn description(&self) -> &'static str {
"Compact the context (optional: /compact <instructions>)"
}
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let instructions = args.trim();
let arg = if instructions.is_empty() {
None
} else {
Some(instructions.to_string())
};
let session = ctx.session.clone();
ctx.reply(InlineMessageKind::Info, "Compacting\u{2026}");
tokio::spawn(async move {
match session.compact(arg).await {
Ok(result) => tracing::info!(?result, "manual compaction complete"),
Err(err) => tracing::warn!(%err, "manual compaction failed"),
}
});
SlashOutcome::Handled
}
}
struct ModelCommand;
impl SlashCommand for ModelCommand {
fn name(&self) -> &'static str {
"model"
}
fn description(&self) -> &'static str {
"Show or switch model (/model [<id>|next])"
}
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
match args.trim() {
"" => ctx.reply(
InlineMessageKind::Info,
format!("Current model: {}", ctx.session.model_id()),
),
"next" | "cycle" => match ctx.session.cycle_model() {
Some(new_id) => ctx.reply(InlineMessageKind::Info, format!("Switched to {new_id}")),
None => ctx.reply(
InlineMessageKind::Warning,
"No scoped models configured to cycle.",
),
},
id => match ctx.session.set_model(id) {
Ok(()) => ctx.reply(InlineMessageKind::Info, format!("Switched to {id}")),
Err(err) => ctx.reply(
InlineMessageKind::Error,
format!("Failed to set model {id}: {err}"),
),
},
}
SlashOutcome::Handled
}
}
struct CancelCommand;
impl SlashCommand for CancelCommand {
fn name(&self) -> &'static str {
"cancel"
}
fn aliases(&self) -> &'static [&'static str] {
&["stop"]
}
fn description(&self) -> &'static str {
"Abort the current run (alias: /stop)"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let session = ctx.session.clone();
tokio::spawn(async move {
session.abort().await;
});
SlashOutcome::Handled
}
}
struct StatusCommand;
impl SlashCommand for StatusCommand {
fn name(&self) -> &'static str {
"status"
}
fn description(&self) -> &'static str {
"Show model and session stats"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let stats = ctx.session.session_stats();
let model = ctx.session.model_id();
ctx.reply(
InlineMessageKind::Info,
format!(
"Model: {model}\n\
Messages: {} user / {} assistant\n\
Tool calls: {} (results: {})\n\
Total: {}",
stats.user_messages,
stats.assistant_messages,
stats.tool_calls,
stats.tool_results,
stats.total_messages,
),
);
SlashOutcome::Handled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtins_register_expected_commands() {
let reg = SlashRegistry::builtins();
let names: Vec<&str> = reg.builtins.iter().map(|c| c.name()).collect();
assert!(names.contains(&"quit"));
assert!(names.contains(&"clear"));
assert!(names.contains(&"compact"));
assert!(names.contains(&"model"));
assert!(names.contains(&"cancel"));
assert!(names.contains(&"status"));
}
#[test]
fn matches_resolves_aliases_case_insensitively() {
let cmd = QuitCommand;
assert!(cmd.matches("quit"));
assert!(cmd.matches("EXIT"));
assert!(cmd.matches("q"));
assert!(!cmd.matches("quitter"));
}
#[test]
fn builtin_commands_exposes_aliases_for_rpc() {
let catalog = SlashRegistry::builtin_commands();
let quit = catalog
.iter()
.find(|(name, _, _)| *name == "quit")
.expect("quit command present");
assert!(quit.2.contains(&"exit"));
assert!(quit.2.contains(&"q"));
assert!(!quit.1.is_empty(), "quit has a description");
}
#[test]
fn dispatch_help_is_intercepted() {
let _reg = SlashRegistry::builtins();
assert!(matches!("help".strip_prefix('/').unwrap_or("help"), "help"));
}
}