diff --git a/Cargo.lock b/Cargo.lock index c4080c9..c628cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3085,12 +3085,24 @@ dependencies = [ "memchr", "nix", "radix_trie", + "rustyline-derive", "unicode-segmentation", "unicode-width 0.2.0", "utf8parse", "windows-sys 0.60.2", ] +[[package]] +name = "rustyline-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ryu" version = "1.0.20" diff --git a/crates/g3-cli/Cargo.toml b/crates/g3-cli/Cargo.toml index 072cf65..0969f92 100644 --- a/crates/g3-cli/Cargo.toml +++ b/crates/g3-cli/Cargo.toml @@ -17,7 +17,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -rustyline = "17.0.1" +rustyline = { version = "17.0.1", features = ["derive", "with-dirs"] } dirs = "5.0" tokio-util = "0.7" sha2 = "0.10" diff --git a/crates/g3-cli/src/completion.rs b/crates/g3-cli/src/completion.rs new file mode 100644 index 0000000..766b0c0 --- /dev/null +++ b/crates/g3-cli/src/completion.rs @@ -0,0 +1,148 @@ +//! Tab completion support for g3 interactive mode. +//! +//! Provides: +//! - Command completion for `/` commands +//! - File path completion for `/run ` +//! - Extensible for future semantic completions (sessions, fragments, etc.) + +use rustyline::completion::{Completer, FilenameCompleter, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{Context, Helper}; + +/// Available `/` commands for completion +const COMMANDS: &[&str] = &[ + "/clear", + "/compact", + "/dump", + "/fragments", + "/help", + "/readme", + "/rehydrate", + "/resume", + "/run", + "/skinnify", + "/stats", + "/thinnify", +]; + +/// Helper struct for rustyline that provides tab completion. +pub struct G3Helper { + /// File path completer for `/run` command + file_completer: FilenameCompleter, +} + +impl G3Helper { + pub fn new() -> Self { + Self { + file_completer: FilenameCompleter::new(), + } + } +} + +impl Default for G3Helper { + fn default() -> Self { + Self::new() + } +} + +impl Completer for G3Helper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + ctx: &Context<'_>, + ) -> Result<(usize, Vec), ReadlineError> { + // Only complete up to cursor position + let line_to_cursor = &line[..pos]; + + // Case 1: `/run ` - complete file paths + if line_to_cursor.starts_with("/run ") { + // Delegate to file completer + return self.file_completer.complete(line, pos, ctx); + } + + // Case 2: `/rehydrate ` - future: complete fragment IDs + // Case 3: `/resume ` - future: complete session IDs + + // Case 4: `/` commands - complete command names + if line_to_cursor.starts_with('/') { + let prefix = line_to_cursor; + let matches: Vec = COMMANDS + .iter() + .filter(|cmd| cmd.starts_with(prefix)) + .map(|cmd| Pair { + display: cmd.to_string(), + replacement: cmd.to_string(), + }) + .collect(); + return Ok((0, matches)); + } + + // No completion for regular prompts + Ok((pos, vec![])) + } +} + +// Required trait implementations for Helper +impl Hinter for G3Helper { + type Hint = String; + + fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + None + } +} + +impl Highlighter for G3Helper {} + +impl Validator for G3Helper {} + +impl Helper for G3Helper {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_command_completion() { + let helper = G3Helper::new(); + let history = rustyline::history::DefaultHistory::new(); + let ctx = Context::new(&history); + + // Complete "/com" -> "/compact" + let (start, matches) = helper.complete("/com", 4, &ctx).unwrap(); + assert_eq!(start, 0); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].replacement, "/compact"); + } + + #[test] + fn test_command_completion_multiple() { + let helper = G3Helper::new(); + let history = rustyline::history::DefaultHistory::new(); + let ctx = Context::new(&history); + + // Complete "/s" -> "/skinnify", "/stats" + let (start, matches) = helper.complete("/s", 2, &ctx).unwrap(); + assert_eq!(start, 0); + assert_eq!(matches.len(), 2); + assert!(matches.iter().any(|m| m.replacement == "/skinnify")); + assert!(matches.iter().any(|m| m.replacement == "/stats")); + } + + #[test] + fn test_no_completion_for_regular_input() { + let helper = G3Helper::new(); + let history = rustyline::history::DefaultHistory::new(); + let ctx = Context::new(&history); + + // Regular text should not complete + let (start, matches) = helper.complete("hello world", 11, &ctx).unwrap(); + assert_eq!(start, 11); + assert!(matches.is_empty()); + } +} diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index aba1d11..3e90710 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -3,7 +3,8 @@ use anyhow::Result; use crossterm::style::{Color, ResetColor, SetForegroundColor}; use rustyline::error::ReadlineError; -use rustyline::DefaultEditor; +use rustyline::{Config, Editor}; +use crate::completion::G3Helper; use std::path::Path; use tracing::{debug, error}; @@ -166,7 +167,11 @@ pub async fn run_interactive( } // Initialize rustyline editor with history - let mut rl = DefaultEditor::new()?; + let config = Config::builder() + .completion_type(rustyline::CompletionType::List) + .build(); + let mut rl = Editor::with_config(config)?; + rl.set_helper(Some(G3Helper::new())); // Try to load history from a file in the user's home directory let history_file = dirs::home_dir().map(|mut path| { @@ -334,7 +339,7 @@ async fn handle_command( input: &str, agent: &mut Agent, output: &SimpleOutput, - rl: &mut DefaultEditor, + rl: &mut Editor, show_prompt: bool, show_code: bool, ) -> Result { diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 313c1ea..b852640 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -18,6 +18,7 @@ mod task_execution; mod ui_writer_impl; mod utils; mod g3_status; +mod completion; use anyhow::Result; use std::path::PathBuf;