graphrag-cli 0.2.0

Modern Terminal User Interface (TUI) for GraphRAG operations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Slash command system for the TUI
//!
//! Provides parsing and execution of slash commands like:
//! - /config <file>
//! - /load <file>
//! - /stats
//! - /entities [filter]
//! - /workspace <name>

use color_eyre::eyre::{eyre, Result};
use std::path::PathBuf;

/// Slash command enum
#[derive(Debug, Clone, PartialEq)]
pub enum SlashCommand {
    /// Load a configuration file; or show current config ("show" subcommand)
    Config(PathBuf),
    /// Display current loaded configuration
    ConfigShow,
    /// Load a document (with optional rebuild flag)
    Load(PathBuf, bool), // (path, rebuild)
    /// Clear the knowledge graph
    Clear,
    /// Rebuild the knowledge graph from existing documents
    Rebuild,
    /// Show graph statistics
    Stats,
    /// List entities (with optional filter)
    Entities(Option<String>),
    /// Execute a one-shot reason-mode query (ask_with_reasoning)
    Reason(String),
    /// Switch the default query mode (ask | explain | reason)
    Mode(String),
    /// Export query history to a Markdown file
    Export(PathBuf),
    /// Switch workspace (load)
    Workspace(String),
    /// List available workspaces
    WorkspaceList,
    /// Save current graph to workspace
    WorkspaceSave(String),
    /// Delete a workspace
    WorkspaceDelete(String),
    /// Show help
    Help,
}

impl SlashCommand {
    /// Parse a slash command from input string
    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" => {
                // Get everything after "config" as the file path (join all args)
                // This handles paths with spaces or multiple parts
                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);
                }

                // Debug log to see what's being parsed
                tracing::debug!("Parsing config command - path_str: {:?}", path_str);
                Ok(SlashCommand::Config(PathBuf::from(path_str)))
            },
            "load" => {
                // Get everything after "load" command
                let rest = trimmed[1..].trim_start_matches("load").trim();

                if rest.is_empty() {
                    return Err(eyre!("Missing argument: /load <file> [--rebuild]"));
                }

                // Check for --rebuild flag
                let rebuild = rest.contains("--rebuild") || rest.contains("-r");

                // Remove flags to get the file path
                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" => {
                // /workspace <name> - load workspace
                // /workspace list - list workspaces
                // /workspace save <name> - save current graph
                // /workspace delete <name> - delete workspace

                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 => {
                        // Default: load workspace
                        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
            )),
        }
    }

    /// Get help text for all slash commands
    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());
    }
}