use color_eyre::eyre::{eyre, Result};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq)]
pub enum SlashCommand {
Config(PathBuf),
ConfigShow,
Load(PathBuf, bool), Clear,
Rebuild,
Stats,
Entities(Option<String>),
Reason(String),
Mode(String),
Export(PathBuf),
Workspace(String),
WorkspaceList,
WorkspaceSave(String),
WorkspaceDelete(String),
Help,
}
impl SlashCommand {
pub fn parse(input: &str) -> Result<Self> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return Err(eyre!("Not a slash command (must start with /)"));
}
let parts: Vec<&str> = trimmed[1..].split_whitespace().collect();
if parts.is_empty() {
return Err(eyre!("Empty command"));
}
let command = parts[0].to_lowercase();
let args = &parts[1..];
match command.as_str() {
"config" => {
let path_str = trimmed[1..].trim_start_matches("config").trim();
if path_str.is_empty() {
return Err(eyre!("Missing argument: /config <file> or /config show"));
}
if path_str.eq_ignore_ascii_case("show") {
return Ok(SlashCommand::ConfigShow);
}
tracing::debug!("Parsing config command - path_str: {:?}", path_str);
Ok(SlashCommand::Config(PathBuf::from(path_str)))
},
"load" => {
let rest = trimmed[1..].trim_start_matches("load").trim();
if rest.is_empty() {
return Err(eyre!("Missing argument: /load <file> [--rebuild]"));
}
let rebuild = rest.contains("--rebuild") || rest.contains("-r");
let path_str = rest
.replace("--rebuild", "")
.replace("-r", "")
.trim()
.to_string();
if path_str.is_empty() {
return Err(eyre!("Missing file path argument"));
}
tracing::debug!(
"Parsing load command - path_str: {:?}, rebuild: {}",
path_str,
rebuild
);
Ok(SlashCommand::Load(PathBuf::from(path_str), rebuild))
},
"clear" => {
if !args.is_empty() {
return Err(eyre!("/clear takes no arguments"));
}
Ok(SlashCommand::Clear)
},
"rebuild" => {
if !args.is_empty() {
return Err(eyre!("/rebuild takes no arguments"));
}
Ok(SlashCommand::Rebuild)
},
"stats" => {
if !args.is_empty() {
return Err(eyre!("/stats takes no arguments"));
}
Ok(SlashCommand::Stats)
},
"entities" => {
let filter = if args.is_empty() {
None
} else {
Some(args.join(" "))
};
Ok(SlashCommand::Entities(filter))
},
"workspace" | "ws" => {
if args.is_empty() {
return Err(eyre!(
"Missing argument. Usage: /workspace <name|list|save|delete>"
));
}
match args[0].to_lowercase().as_str() {
"list" | "ls" => {
if args.len() > 1 {
return Err(eyre!("/workspace list takes no additional arguments"));
}
Ok(SlashCommand::WorkspaceList)
},
"save" => {
if args.len() < 2 {
return Err(eyre!("Missing workspace name: /workspace save <name>"));
}
Ok(SlashCommand::WorkspaceSave(args[1].to_string()))
},
"delete" | "del" | "rm" => {
if args.len() < 2 {
return Err(eyre!("Missing workspace name: /workspace delete <name>"));
}
Ok(SlashCommand::WorkspaceDelete(args[1].to_string()))
},
name => {
Ok(SlashCommand::Workspace(name.to_string()))
},
}
},
"reason" => {
let q = args.join(" ");
if q.is_empty() {
return Err(eyre!("Missing query: /reason <your question>"));
}
Ok(SlashCommand::Reason(q))
},
"mode" => {
if args.is_empty() {
return Err(eyre!("Usage: /mode ask|explain|reason"));
}
Ok(SlashCommand::Mode(args[0].to_lowercase()))
},
"export" => {
let rest = trimmed[1..].trim_start_matches("export").trim();
if rest.is_empty() {
return Err(eyre!("Missing path: /export <file.md>"));
}
Ok(SlashCommand::Export(PathBuf::from(rest)))
},
"help" => {
if !args.is_empty() {
return Err(eyre!("/help takes no arguments"));
}
Ok(SlashCommand::Help)
},
_ => Err(eyre!(
"Unknown command: /{}. Type /help for available commands.",
command
)),
}
}
pub fn help_text() -> String {
r#"
Available Slash Commands:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/config <file> Load GraphRAG configuration file
Supports: JSON5, JSON, TOML
Example: /config docs-example/sym.json5
/config show Display the currently loaded configuration file
/load <file> [--rebuild] Load and process a document into the knowledge graph
--rebuild: Clear existing graph before building
Example: /load info/Symposium.txt
Example: /load info/Symposium.txt --rebuild
/clear Clear the knowledge graph (preserves documents)
Removes all entities and relationships
/rebuild Rebuild the knowledge graph from loaded documents
Clears graph and re-extracts entities/relationships
Useful after changing configuration or to fix issues
/stats Show knowledge graph statistics
Displays: entities, relationships, documents, chunks
/entities [filter] List entities in the knowledge graph
Example: /entities socrates
Example: /entities PERSON
/reason <query> Execute a one-shot reasoning query (query decomposition)
Splits complex questions into sub-queries for better answers
Example: /reason Compare the main themes of the book
/mode ask|explain|reason Switch the default query mode (sticky until changed)
ask: Plain answer (fastest, no metadata)
explain: Answer + confidence score + source references
reason: Query decomposition for complex multi-part questions
Example: /mode explain
/export <file.md> Export query history to a Markdown file
Example: /export /tmp/my_session.md
/workspace <command> Workspace management commands:
/ws list List all available workspaces with statistics
/ws save <name> Save current graph to a workspace
/ws <name> Load graph from a workspace
/ws delete <name> Delete a workspace permanently
Examples:
/workspace list
/workspace save my_project
/workspace my_project
/workspace delete old_project
/help Show this help message
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Keyboard Shortcuts:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FOCUS & NAVIGATION:
F1 Focus Results Viewer (LLM answer)
F2 Focus Raw Search Results
F3 Focus Info Panel (Tab cycles tabs within)
Esc Return focus to Input (enable typing)
INFO PANEL TABS (when F3 focused):
Tab Cycle tabs: Stats → Sources → History
j / k Scroll within Sources or History tab
SCROLLING (when Results/Raw viewer is focused):
j / k Scroll down / up one line
Ctrl+D / Ctrl+U Scroll down / up one page
Home / End Scroll to top / bottom
OTHER:
Ctrl+C / Ctrl+Q Quit application
? Toggle help
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Tip: Default mode is ASK. Use /mode explain for confidence scores and sources.
Tip: After an EXPLAIN query, the Sources tab in the Info Panel auto-opens.
Tip: Use --rebuild flag to force a fresh graph rebuild when loading documents.
"#
.trim()
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_config() {
let cmd = SlashCommand::parse("/config test.toml").unwrap();
assert_eq!(cmd, SlashCommand::Config(PathBuf::from("test.toml")));
}
#[test]
fn test_parse_config_with_path() {
let cmd = SlashCommand::parse("/config docs-example/sym.json5").unwrap();
assert_eq!(
cmd,
SlashCommand::Config(PathBuf::from("docs-example/sym.json5"))
);
}
#[test]
fn test_parse_config_with_spaces_in_dirname() {
let cmd = SlashCommand::parse("/config my docs/config.toml").unwrap();
assert_eq!(
cmd,
SlashCommand::Config(PathBuf::from("my docs/config.toml"))
);
}
#[test]
fn test_parse_load() {
let cmd = SlashCommand::parse("/load doc.txt").unwrap();
assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), false));
}
#[test]
fn test_parse_load_with_rebuild() {
let cmd = SlashCommand::parse("/load doc.txt --rebuild").unwrap();
assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), true));
}
#[test]
fn test_parse_load_with_rebuild_short() {
let cmd = SlashCommand::parse("/load doc.txt -r").unwrap();
assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), true));
}
#[test]
fn test_parse_clear() {
let cmd = SlashCommand::parse("/clear").unwrap();
assert_eq!(cmd, SlashCommand::Clear);
}
#[test]
fn test_parse_rebuild() {
let cmd = SlashCommand::parse("/rebuild").unwrap();
assert_eq!(cmd, SlashCommand::Rebuild);
}
#[test]
fn test_parse_stats() {
let cmd = SlashCommand::parse("/stats").unwrap();
assert_eq!(cmd, SlashCommand::Stats);
}
#[test]
fn test_parse_entities_no_filter() {
let cmd = SlashCommand::parse("/entities").unwrap();
assert_eq!(cmd, SlashCommand::Entities(None));
}
#[test]
fn test_parse_entities_with_filter() {
let cmd = SlashCommand::parse("/entities socrates").unwrap();
assert_eq!(cmd, SlashCommand::Entities(Some("socrates".to_string())));
}
#[test]
fn test_parse_workspace() {
let cmd = SlashCommand::parse("/workspace test").unwrap();
assert_eq!(cmd, SlashCommand::Workspace("test".to_string()));
}
#[test]
fn test_parse_help() {
let cmd = SlashCommand::parse("/help").unwrap();
assert_eq!(cmd, SlashCommand::Help);
}
#[test]
fn test_parse_unknown_command() {
let result = SlashCommand::parse("/unknown");
assert!(result.is_err());
}
#[test]
fn test_parse_not_slash_command() {
let result = SlashCommand::parse("config test.toml");
assert!(result.is_err());
}
#[test]
fn test_parse_missing_arguments() {
assert!(SlashCommand::parse("/config").is_err());
assert!(SlashCommand::parse("/load").is_err());
assert!(SlashCommand::parse("/workspace").is_err());
}
}