Skip to main content

graphrag_cli/commands/
mod.rs

1//! Slash command system for the TUI
2//!
3//! Provides parsing and execution of slash commands like:
4//! - /config <file>
5//! - /load <file>
6//! - /stats
7//! - /entities [filter]
8//! - /workspace <name>
9
10use color_eyre::eyre::{eyre, Result};
11use std::path::PathBuf;
12
13/// Slash command enum
14#[derive(Debug, Clone, PartialEq)]
15pub enum SlashCommand {
16    /// Load a configuration file; or show current config ("show" subcommand)
17    Config(PathBuf),
18    /// Display current loaded configuration
19    ConfigShow,
20    /// Load a document (with optional rebuild flag)
21    Load(PathBuf, bool), // (path, rebuild)
22    /// Clear the knowledge graph
23    Clear,
24    /// Rebuild the knowledge graph from existing documents
25    Rebuild,
26    /// Show graph statistics
27    Stats,
28    /// List entities (with optional filter)
29    Entities(Option<String>),
30    /// Execute a one-shot reason-mode query (ask_with_reasoning)
31    Reason(String),
32    /// Switch the default query mode (ask | explain | reason)
33    Mode(String),
34    /// Export query history to a Markdown file
35    Export(PathBuf),
36    /// Switch workspace (load)
37    Workspace(String),
38    /// List available workspaces
39    WorkspaceList,
40    /// Save current graph to workspace
41    WorkspaceSave(String),
42    /// Delete a workspace
43    WorkspaceDelete(String),
44    /// Show help
45    Help,
46}
47
48impl SlashCommand {
49    /// Parse a slash command from input string
50    pub fn parse(input: &str) -> Result<Self> {
51        let trimmed = input.trim();
52
53        if !trimmed.starts_with('/') {
54            return Err(eyre!("Not a slash command (must start with /)"));
55        }
56
57        let parts: Vec<&str> = trimmed[1..].split_whitespace().collect();
58
59        if parts.is_empty() {
60            return Err(eyre!("Empty command"));
61        }
62
63        let command = parts[0].to_lowercase();
64        let args = &parts[1..];
65
66        match command.as_str() {
67            "config" => {
68                // Get everything after "config" as the file path (join all args)
69                // This handles paths with spaces or multiple parts
70                let path_str = trimmed[1..].trim_start_matches("config").trim();
71
72                if path_str.is_empty() {
73                    return Err(eyre!("Missing argument: /config <file> or /config show"));
74                }
75
76                if path_str.eq_ignore_ascii_case("show") {
77                    return Ok(SlashCommand::ConfigShow);
78                }
79
80                // Debug log to see what's being parsed
81                tracing::debug!("Parsing config command - path_str: {:?}", path_str);
82                Ok(SlashCommand::Config(PathBuf::from(path_str)))
83            },
84            "load" => {
85                // Get everything after "load" command
86                let rest = trimmed[1..].trim_start_matches("load").trim();
87
88                if rest.is_empty() {
89                    return Err(eyre!("Missing argument: /load <file> [--rebuild]"));
90                }
91
92                // Check for --rebuild flag
93                let rebuild = rest.contains("--rebuild") || rest.contains("-r");
94
95                // Remove flags to get the file path
96                let path_str = rest
97                    .replace("--rebuild", "")
98                    .replace("-r", "")
99                    .trim()
100                    .to_string();
101
102                if path_str.is_empty() {
103                    return Err(eyre!("Missing file path argument"));
104                }
105
106                tracing::debug!(
107                    "Parsing load command - path_str: {:?}, rebuild: {}",
108                    path_str,
109                    rebuild
110                );
111                Ok(SlashCommand::Load(PathBuf::from(path_str), rebuild))
112            },
113            "clear" => {
114                if !args.is_empty() {
115                    return Err(eyre!("/clear takes no arguments"));
116                }
117                Ok(SlashCommand::Clear)
118            },
119            "rebuild" => {
120                if !args.is_empty() {
121                    return Err(eyre!("/rebuild takes no arguments"));
122                }
123                Ok(SlashCommand::Rebuild)
124            },
125            "stats" => {
126                if !args.is_empty() {
127                    return Err(eyre!("/stats takes no arguments"));
128                }
129                Ok(SlashCommand::Stats)
130            },
131            "entities" => {
132                let filter = if args.is_empty() {
133                    None
134                } else {
135                    Some(args.join(" "))
136                };
137                Ok(SlashCommand::Entities(filter))
138            },
139            "workspace" | "ws" => {
140                // /workspace <name> - load workspace
141                // /workspace list - list workspaces
142                // /workspace save <name> - save current graph
143                // /workspace delete <name> - delete workspace
144
145                if args.is_empty() {
146                    return Err(eyre!(
147                        "Missing argument. Usage: /workspace <name|list|save|delete>"
148                    ));
149                }
150
151                match args[0].to_lowercase().as_str() {
152                    "list" | "ls" => {
153                        if args.len() > 1 {
154                            return Err(eyre!("/workspace list takes no additional arguments"));
155                        }
156                        Ok(SlashCommand::WorkspaceList)
157                    },
158                    "save" => {
159                        if args.len() < 2 {
160                            return Err(eyre!("Missing workspace name: /workspace save <name>"));
161                        }
162                        Ok(SlashCommand::WorkspaceSave(args[1].to_string()))
163                    },
164                    "delete" | "del" | "rm" => {
165                        if args.len() < 2 {
166                            return Err(eyre!("Missing workspace name: /workspace delete <name>"));
167                        }
168                        Ok(SlashCommand::WorkspaceDelete(args[1].to_string()))
169                    },
170                    name => {
171                        // Default: load workspace
172                        Ok(SlashCommand::Workspace(name.to_string()))
173                    },
174                }
175            },
176            "reason" => {
177                let q = args.join(" ");
178                if q.is_empty() {
179                    return Err(eyre!("Missing query: /reason <your question>"));
180                }
181                Ok(SlashCommand::Reason(q))
182            },
183            "mode" => {
184                if args.is_empty() {
185                    return Err(eyre!("Usage: /mode ask|explain|reason"));
186                }
187                Ok(SlashCommand::Mode(args[0].to_lowercase()))
188            },
189            "export" => {
190                let rest = trimmed[1..].trim_start_matches("export").trim();
191                if rest.is_empty() {
192                    return Err(eyre!("Missing path: /export <file.md>"));
193                }
194                Ok(SlashCommand::Export(PathBuf::from(rest)))
195            },
196            "help" => {
197                if !args.is_empty() {
198                    return Err(eyre!("/help takes no arguments"));
199                }
200                Ok(SlashCommand::Help)
201            },
202            _ => Err(eyre!(
203                "Unknown command: /{}. Type /help for available commands.",
204                command
205            )),
206        }
207    }
208
209    /// Get help text for all slash commands
210    pub fn help_text() -> String {
211        r#"
212Available Slash Commands:
213━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
214
215/config <file>          Load GraphRAG configuration file
216                        Supports: JSON5, JSON, TOML
217                        Example: /config docs-example/sym.json5
218
219/config show            Display the currently loaded configuration file
220
221/load <file> [--rebuild] Load and process a document into the knowledge graph
222                        --rebuild: Clear existing graph before building
223                        Example: /load info/Symposium.txt
224                        Example: /load info/Symposium.txt --rebuild
225
226/clear                  Clear the knowledge graph (preserves documents)
227                        Removes all entities and relationships
228
229/rebuild                Rebuild the knowledge graph from loaded documents
230                        Clears graph and re-extracts entities/relationships
231                        Useful after changing configuration or to fix issues
232
233/stats                  Show knowledge graph statistics
234                        Displays: entities, relationships, documents, chunks
235
236/entities [filter]      List entities in the knowledge graph
237                        Example: /entities socrates
238                        Example: /entities PERSON
239
240/reason <query>         Execute a one-shot reasoning query (query decomposition)
241                        Splits complex questions into sub-queries for better answers
242                        Example: /reason Compare the main themes of the book
243
244/mode ask|explain|reason Switch the default query mode (sticky until changed)
245                        ask:    Plain answer (fastest, no metadata)
246                        explain: Answer + confidence score + source references
247                        reason:  Query decomposition for complex multi-part questions
248                        Example: /mode explain
249
250/export <file.md>       Export query history to a Markdown file
251                        Example: /export /tmp/my_session.md
252
253/workspace <command>    Workspace management commands:
254  /ws list              List all available workspaces with statistics
255  /ws save <name>       Save current graph to a workspace
256  /ws <name>            Load graph from a workspace
257  /ws delete <name>     Delete a workspace permanently
258
259                        Examples:
260                        /workspace list
261                        /workspace save my_project
262                        /workspace my_project
263                        /workspace delete old_project
264
265/help                   Show this help message
266
267━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
268
269Keyboard Shortcuts:
270━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
271
272FOCUS & NAVIGATION:
273F1                      Focus Results Viewer (LLM answer)
274F2                      Focus Raw Search Results
275F3                      Focus Info Panel (Tab cycles tabs within)
276Esc                     Return focus to Input (enable typing)
277
278INFO PANEL TABS (when F3 focused):
279Tab                     Cycle tabs: Stats → Sources → History
280j / k                   Scroll within Sources or History tab
281
282SCROLLING (when Results/Raw viewer is focused):
283j / k                   Scroll down / up one line
284Ctrl+D / Ctrl+U         Scroll down / up one page
285Home / End              Scroll to top / bottom
286
287OTHER:
288Ctrl+C / Ctrl+Q         Quit application
289?                       Toggle help
290
291━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
292
293Tip: Default mode is ASK. Use /mode explain for confidence scores and sources.
294Tip: After an EXPLAIN query, the Sources tab in the Info Panel auto-opens.
295Tip: Use --rebuild flag to force a fresh graph rebuild when loading documents.
296"#
297        .trim()
298        .to_string()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_parse_config() {
308        let cmd = SlashCommand::parse("/config test.toml").unwrap();
309        assert_eq!(cmd, SlashCommand::Config(PathBuf::from("test.toml")));
310    }
311
312    #[test]
313    fn test_parse_config_with_path() {
314        let cmd = SlashCommand::parse("/config docs-example/sym.json5").unwrap();
315        assert_eq!(
316            cmd,
317            SlashCommand::Config(PathBuf::from("docs-example/sym.json5"))
318        );
319    }
320
321    #[test]
322    fn test_parse_config_with_spaces_in_dirname() {
323        let cmd = SlashCommand::parse("/config my docs/config.toml").unwrap();
324        assert_eq!(
325            cmd,
326            SlashCommand::Config(PathBuf::from("my docs/config.toml"))
327        );
328    }
329
330    #[test]
331    fn test_parse_load() {
332        let cmd = SlashCommand::parse("/load doc.txt").unwrap();
333        assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), false));
334    }
335
336    #[test]
337    fn test_parse_load_with_rebuild() {
338        let cmd = SlashCommand::parse("/load doc.txt --rebuild").unwrap();
339        assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), true));
340    }
341
342    #[test]
343    fn test_parse_load_with_rebuild_short() {
344        let cmd = SlashCommand::parse("/load doc.txt -r").unwrap();
345        assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), true));
346    }
347
348    #[test]
349    fn test_parse_clear() {
350        let cmd = SlashCommand::parse("/clear").unwrap();
351        assert_eq!(cmd, SlashCommand::Clear);
352    }
353
354    #[test]
355    fn test_parse_rebuild() {
356        let cmd = SlashCommand::parse("/rebuild").unwrap();
357        assert_eq!(cmd, SlashCommand::Rebuild);
358    }
359
360    #[test]
361    fn test_parse_stats() {
362        let cmd = SlashCommand::parse("/stats").unwrap();
363        assert_eq!(cmd, SlashCommand::Stats);
364    }
365
366    #[test]
367    fn test_parse_entities_no_filter() {
368        let cmd = SlashCommand::parse("/entities").unwrap();
369        assert_eq!(cmd, SlashCommand::Entities(None));
370    }
371
372    #[test]
373    fn test_parse_entities_with_filter() {
374        let cmd = SlashCommand::parse("/entities socrates").unwrap();
375        assert_eq!(cmd, SlashCommand::Entities(Some("socrates".to_string())));
376    }
377
378    #[test]
379    fn test_parse_workspace() {
380        let cmd = SlashCommand::parse("/workspace test").unwrap();
381        assert_eq!(cmd, SlashCommand::Workspace("test".to_string()));
382    }
383
384    #[test]
385    fn test_parse_help() {
386        let cmd = SlashCommand::parse("/help").unwrap();
387        assert_eq!(cmd, SlashCommand::Help);
388    }
389
390    #[test]
391    fn test_parse_unknown_command() {
392        let result = SlashCommand::parse("/unknown");
393        assert!(result.is_err());
394    }
395
396    #[test]
397    fn test_parse_not_slash_command() {
398        let result = SlashCommand::parse("config test.toml");
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn test_parse_missing_arguments() {
404        assert!(SlashCommand::parse("/config").is_err());
405        assert!(SlashCommand::parse("/load").is_err());
406        assert!(SlashCommand::parse("/workspace").is_err());
407    }
408}