use oxicode_vtui::tui::core::{
InlineHandle, InlineListItem, InlineListSelection, 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 items: Vec<InlineListItem> = self
.builtins
.iter()
.map(|c| {
let mut title = format!("/{}", c.name());
for alias in c.aliases() {
title.push_str(&format!(", /{alias}"));
}
InlineListItem {
title,
subtitle: Some(c.description().to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand(c.name().to_string())),
search_value: None,
}
})
.collect();
items.sort_by(|a, b| a.title.cmp(&b.title));
ctx.handle.show_list_modal(
"Commands".to_string(),
vec!["Select a command (Esc to close)".to_string()],
items,
None,
None,
);
}
}
struct SettingsCommand;
impl SlashCommand for SettingsCommand {
fn name(&self) -> &'static str {
"settings"
}
fn aliases(&self) -> &'static [&'static str] {
&["config"]
}
fn description(&self) -> &'static str {
"Show settings overlay (toggle thinking, compaction, advisor)"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
use oxicode_vtui::tui::core::{
InlineListItem, InlineListSearchConfig, InlineListSelection,
};
let session = ctx.session;
let model = session.model_id();
let thinking = session.thinking_level();
let auto_compaction = session.auto_compaction_enabled();
let auto_retry = session.auto_retry_enabled();
let advisor = session.is_advisor_enabled();
let items = vec![
InlineListItem {
title: format!("Model: {model}"),
subtitle: Some("Use /model to switch".into()),
badge: None,
indent: 0,
selection: None,
search_value: Some("model".into()),
},
InlineListItem {
title: format!("Thinking: {thinking:?}"),
subtitle: Some("Enter to cycle".into()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("thinking_level".into())),
search_value: Some("thinking".into()),
},
InlineListItem {
title: format!(
"Auto-compaction: {}",
if auto_compaction { "on" } else { "off" }
),
subtitle: Some("Enter to toggle".into()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("auto_compaction".into())),
search_value: Some("compaction".into()),
},
InlineListItem {
title: format!("Auto-retry: {}", if auto_retry { "on" } else { "off" }),
subtitle: Some("Enter to toggle".into()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("auto_retry".into())),
search_value: Some("retry".into()),
},
InlineListItem {
title: format!("Advisor: {}", if advisor { "on" } else { "off" }),
subtitle: Some("Enter to toggle".into()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("advisor".into())),
search_value: Some("advisor".into()),
},
];
let search = InlineListSearchConfig {
label: "Filter settings".into(),
placeholder: Some("Type to filter\u{2026}".into()),
};
ctx.handle.show_list_modal(
"Settings".into(),
vec!["Select a setting to toggle/cycle (Esc to close)".into()],
items,
None,
Some(search),
);
SlashOutcome::Handled
}
}
struct SessionsCommand;
impl SlashCommand for SessionsCommand {
fn name(&self) -> &'static str {
"sessions"
}
fn aliases(&self) -> &'static [&'static str] {
&["resume"]
}
fn description(&self) -> &'static str {
"Browse and resume past sessions"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
use oxicode_vtui::tui::core::{InlineListItem, InlineListSearchConfig};
let session_dir = dirs::home_dir()
.map(|h| h.join(".oxicode").join("sessions"))
.unwrap_or_else(|| std::path::PathBuf::from(".oxicode/sessions"));
let mut entries: Vec<(String, std::time::SystemTime)> = Vec::new();
if let Ok(dir) = std::fs::read_dir(&session_dir) {
for entry in dir.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
let id = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
entries.push((id, mtime));
}
}
}
entries.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
entries.truncate(30);
if entries.is_empty() {
ctx.reply(InlineMessageKind::Info, "No saved sessions found.");
return SlashOutcome::Handled;
}
let items: Vec<InlineListItem> = entries
.iter()
.map(|(id, mtime)| {
let time_str = format_relative_time(*mtime);
InlineListItem {
title: format!("{id} \u{00b7} {time_str}"),
subtitle: Some("Enter to resume".into()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::Session(id.clone())),
search_value: Some(id.clone()),
}
})
.collect();
let search = InlineListSearchConfig {
label: "Filter sessions".into(),
placeholder: Some("Type to filter\u{2026}".into()),
};
ctx.handle.show_list_modal(
"Sessions".into(),
vec!["Select a session to resume (Esc to close)".into()],
items,
None,
Some(search),
);
SlashOutcome::Handled
}
}
fn format_relative_time(t: std::time::SystemTime) -> String {
let now = std::time::SystemTime::now();
match now.duration_since(t) {
Ok(d) => {
let mins = d.as_secs() / 60;
if mins < 1 {
"just now".into()
} else if mins < 60 {
format!("{mins}m ago")
} else if mins < 60 * 24 {
format!("{}h ago", mins / 60)
} else if mins < 60 * 24 * 7 {
format!("{}d ago", mins / (60 * 24))
} else {
format!("{}w ago", mins / (60 * 24 * 7))
}
}
Err(_) => "unknown".into(),
}
}
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));
registry.register(Box::new(VimCommand));
registry.register(Box::new(AgentsCommand));
registry.register(Box::new(ThemeCommand));
registry.register(Box::new(FindCommand));
registry.register(Box::new(SessionsCommand));
}
struct VimCommand;
impl SlashCommand for VimCommand {
fn name(&self) -> &'static str {
"vim"
}
fn description(&self) -> &'static str {
"Toggle vim mode for prompt editing"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let enabled = !ctx.state.vim_state.enabled();
ctx.state.vim_state.set_enabled(enabled);
ctx.reply(
InlineMessageKind::Info,
if enabled {
"Vim mode: ON — press Esc for Normal, i for Insert".to_string()
} else {
"Vim mode: OFF".to_string()
},
);
SlashOutcome::Handled
}
}
struct AgentsCommand;
impl SlashCommand for AgentsCommand {
fn name(&self) -> &'static str {
"agents"
}
fn aliases(&self) -> &'static [&'static str] {
&["hub"]
}
fn description(&self) -> &'static str {
"Open the Agent Hub overlay (alias: /hub)"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
ctx.state.agent_hub_open = true;
ctx.state.hub_entries = ctx.session.hub().snapshot();
SlashOutcome::Handled
}
}
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 oxicode (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 {
if !args.split_whitespace().any(|a| a == "--yes") {
ctx.state.confirmation = Some(super::super::main_loop::clear_confirmation());
return SlashOutcome::Handled;
}
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() {
"" => {
let models = ctx.session.scoped_models();
if models.is_empty() {
ctx.reply(
InlineMessageKind::Info,
format!("Current model: {}", ctx.session.model_id()),
);
} else {
ctx.state.overlay_model_ids = models
.iter()
.map(|m| format!("{}/{}", m.provider, m.model_id))
.collect();
let current = ctx.session.model_id();
let items: Vec<InlineListItem> = models
.iter()
.enumerate()
.map(|(i, m)| {
let id = format!("{}/{}", m.provider, m.model_id);
InlineListItem {
title: id.clone(),
subtitle: Some(m.provider.clone()),
badge: if id == current {
Some("active".to_string())
} else {
None
},
indent: 0,
selection: Some(InlineListSelection::Model(i)),
search_value: None,
}
})
.collect();
ctx.handle.show_list_modal(
"Models".to_string(),
vec!["Select a model (Esc to close)".to_string()],
items,
None,
None,
);
}
}
"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
}
}
struct ThemeCommand;
impl SlashCommand for ThemeCommand {
fn name(&self) -> &'static str {
"theme"
}
fn aliases(&self) -> &'static [&'static str] {
&["t"]
}
fn description(&self) -> &'static str {
"Cycle or pick a color theme (/theme [name|list])"
}
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
use oxicode_vtui::theme::{
active_theme_id, available_themes, set_active_theme, theme_label,
};
match args.trim() {
"" | "next" | "cycle" => {
let themes = available_themes();
if themes.len() <= 1 {
ctx.reply(InlineMessageKind::Info, "Only one theme available.");
} else {
let current = active_theme_id();
let pos = themes.iter().position(|t| *t == current).unwrap_or(0);
let next_id = &themes[(pos + 1) % themes.len()];
match set_active_theme(next_id) {
Ok(()) => {
let label = theme_label(next_id).unwrap_or(next_id.as_ref());
ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
}
Err(e) => ctx.reply(
InlineMessageKind::Error,
format!("Failed to set theme: {e}"),
),
}
}
}
"list" | "picker" => {
let themes = available_themes();
let current = active_theme_id();
let items: Vec<InlineListItem> = themes
.iter()
.map(|id| InlineListItem {
title: theme_label(id).unwrap_or(id.as_ref()).to_string(),
subtitle: Some(id.to_string()),
badge: if *id == current {
Some("active".to_string())
} else {
None
},
indent: 0,
selection: Some(InlineListSelection::Theme(id.to_string())),
search_value: Some(id.to_string()),
})
.collect();
ctx.handle.show_list_modal(
"Themes".to_string(),
vec!["Select a theme (Esc to close, Enter to apply)".to_string()],
items,
None,
None,
);
}
name => match set_active_theme(name) {
Ok(()) => {
let label = theme_label(name).unwrap_or(name);
ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
}
Err(e) => ctx.reply(
InlineMessageKind::Error,
format!("Unknown theme '{name}': {e}"),
),
},
}
SlashOutcome::Handled
}
}
struct FindCommand;
impl SlashCommand for FindCommand {
fn name(&self) -> &'static str {
"find"
}
fn aliases(&self) -> &'static [&'static str] {
&["search", "/"]
}
fn description(&self) -> &'static str {
"Search transcript (/find <query>, n/N to navigate)"
}
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let query = args.trim();
if query.is_empty() {
ctx.state.search = None;
ctx.reply(InlineMessageKind::Info, "Search cleared.");
} else {
ctx.state.start_search(query);
let count = ctx
.state
.search
.as_ref()
.map(|s| s.matches.len())
.unwrap_or(0);
if count == 0 {
ctx.reply(
InlineMessageKind::Warning,
format!("No matches for '{query}'."),
);
} else {
ctx.reply(
InlineMessageKind::Info,
format!(
"{count} match{} for '{query}'",
if count == 1 { "" } else { "es" }
),
);
}
}
SlashOutcome::Handled
}
}
struct ShortcutsCommand;
impl SlashCommand for ShortcutsCommand {
fn name(&self) -> &'static str {
"shortcuts"
}
fn aliases(&self) -> &'static [&'static str] {
&["keys", "cheatsheet"]
}
fn description(&self) -> &'static str {
"Show keyboard shortcuts (alias: ?)"
}
fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
ctx.handle
.show_modal("Keyboard Shortcuts".to_string(), shortcuts_lines(), None);
SlashOutcome::Handled
}
}
fn shortcuts_lines() -> Vec<String> {
vec![
"".into(),
" Navigation".into(),
" j / ↓ Scroll down (line)".into(),
" k / ↑ Scroll up (line)".into(),
" Shift+J Next assistant turn".into(),
" Shift+K Previous user turn".into(),
" PgDn Scroll down (page)".into(),
" PgUp Scroll up (page)".into(),
" G Jump to bottom (follow)".into(),
" g Jump to top".into(),
"".into(),
" Blocks".into(),
" e Cycle block (collapse/truncate/expand)".into(),
" Shift+E Expand all blocks".into(),
" Ctrl+E Collapse all blocks".into(),
"".into(),
" Search".into(),
" /find <q> Search transcript".into(),
" n Next match".into(),
" N Previous match".into(),
" Esc Clear search".into(),
"".into(),
" Input".into(),
" Ctrl+M Toggle multiline input".into(),
" Ctrl+P Command palette".into(),
" Ctrl+Enter Send now (abort + submit)".into(),
" Esc Cancel run / quit (y to confirm)".into(),
"".into(),
" Other".into(),
" ? Show this cheatsheet".into(),
" /theme Cycle color theme".into(),
" /model Pick a model".into(),
" /vim Toggle vim mode".into(),
" Ctrl+C Cancel run (then y to quit)".into(),
"".into(),
]
}
#[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"));
}
}