diff --git a/crates/g3-cli/src/commands.rs b/crates/g3-cli/src/commands.rs index bcd2efd..e76ac96 100644 --- a/crates/g3-cli/src/commands.rs +++ b/crates/g3-cli/src/commands.rs @@ -16,6 +16,15 @@ use crate::project::load_and_validate_project; use crate::template::process_template; use crate::task_execution::execute_task_with_retry; +/// Result of handling a command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandResult { + /// Command was handled, continue the loop + Handled, + /// Enter plan mode (after /plan command) + EnterPlanMode, +} + // --- Research command helpers --- fn format_research_task_summary(task: &g3_core::pending_research::ResearchTask) -> String { @@ -53,7 +62,7 @@ pub async fn handle_command( rl: &mut Editor, show_prompt: bool, show_code: bool, -) -> Result { +) -> Result { match input { "/help" => { output.print(""); @@ -78,7 +87,7 @@ pub async fn handle_command( output.print(" /help - Show this help message"); output.print(" exit/quit - Exit the interactive session"); output.print(""); - Ok(true) + Ok(CommandResult::Handled) } "/compact" => { output.print_g3_progress("compacting session"); @@ -93,17 +102,17 @@ pub async fn handle_command( output.print_g3_status("compacting session", &format!("error: {}", e)); } } - Ok(true) + Ok(CommandResult::Handled) } "/thinnify" => { let result = agent.force_thin(); G3Status::thin_result(&result); - Ok(true) + Ok(CommandResult::Handled) } "/skinnify" => { let result = agent.force_thin_all(); G3Status::thin_result(&result); - Ok(true) + Ok(CommandResult::Handled) } "/fragments" => { if let Some(session_id) = agent.get_session_id() { @@ -129,7 +138,7 @@ pub async fn handle_command( } else { output.print("No active session - fragments are session-scoped."); } - Ok(true) + Ok(CommandResult::Handled) } cmd if cmd.starts_with("/rehydrate") => { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); @@ -159,7 +168,7 @@ pub async fn handle_command( output.print("No active session - fragments are session-scoped."); } } - Ok(true) + Ok(CommandResult::Handled) } cmd if cmd == "/research" || cmd.starts_with("/research ") => { let manager = agent.get_pending_research_manager(); @@ -209,7 +218,7 @@ pub async fn handle_command( } } } - Ok(true) + Ok(CommandResult::Handled) } cmd if cmd.starts_with("/run") => { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); @@ -245,7 +254,7 @@ pub async fn handle_command( } } } - Ok(true) + Ok(CommandResult::Handled) } "/dump" => { // Dump entire context window to a file for debugging @@ -253,7 +262,7 @@ pub async fn handle_command( if !dump_dir.exists() { if let Err(e) = std::fs::create_dir_all(dump_dir) { output.print(&format!("❌ Failed to create tmp directory: {}", e)); - return Ok(true); + return Ok(CommandResult::Handled); } } @@ -294,7 +303,7 @@ pub async fn handle_command( } Err(e) => output.print(&format!("❌ Failed to write dump: {}", e)), } - Ok(true) + Ok(CommandResult::Handled) } "/clear" => { use crate::g3_status::G3Status; @@ -302,7 +311,7 @@ pub async fn handle_command( agent.clear_session(); G3Status::done(); output.print("Starting fresh."); - Ok(true) + Ok(CommandResult::Handled) } "/readme" => { use crate::g3_status::G3Status; @@ -319,12 +328,12 @@ pub async fn handle_command( G3Status::error(&e.to_string()); } } - Ok(true) + Ok(CommandResult::Handled) } "/stats" => { let stats = agent.get_stats(); output.print(&stats); - Ok(true) + Ok(CommandResult::Handled) } "/resume" => { output.print("📋 Scanning for available sessions..."); @@ -333,7 +342,7 @@ pub async fn handle_command( Ok(sessions) => { if sessions.is_empty() { output.print("No sessions found for this directory."); - return Ok(true); + return Ok(CommandResult::Handled); } // Get current session ID to mark it @@ -408,7 +417,7 @@ pub async fn handle_command( } Err(e) => output.print(&format!("❌ Error listing sessions: {}", e)), } - Ok(true) + Ok(CommandResult::Handled) } cmd if cmd.starts_with("/project") => { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); @@ -451,7 +460,7 @@ pub async fn handle_command( } } } - Ok(true) + Ok(CommandResult::Handled) } cmd if cmd.starts_with("/plan") => { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); @@ -463,6 +472,7 @@ pub async fn handle_command( output.print(" 3. Request approval before coding"); output.print(""); output.print("Example: /plan Add CSV import for comic book metadata"); + Ok(CommandResult::Handled) } else { let feature_description = parts[1].trim(); @@ -478,9 +488,14 @@ pub async fn handle_command( feature_description ); + // Print the welcome message for plan mode + output.print(" what shall we build today?"); + execute_task_with_retry(agent, &prompt, show_prompt, show_code, output).await; + + // Return EnterPlanMode to signal interactive loop to switch prompts + Ok(CommandResult::EnterPlanMode) } - Ok(true) } "/unproject" => { if active_project.is_some() { @@ -494,14 +509,14 @@ pub async fn handle_command( } else { output.print("No project is currently loaded."); } - Ok(true) + Ok(CommandResult::Handled) } _ => { output.print(&format!( "❌ Unknown command: {}. Type /help for available commands.", input )); - Ok(true) + Ok(CommandResult::Handled) } } } diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index da0929c..1407e99 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -13,8 +13,9 @@ use tokio::sync::broadcast; use g3_core::ui_writer::UiWriter; use g3_core::Agent; +use g3_core::ToolCall; -use crate::commands::handle_command; +use crate::commands::{handle_command, CommandResult}; use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path}; use crate::g3_status::{G3Status, Status}; use crate::project::Project; @@ -25,15 +26,21 @@ use crate::template::process_template; use crate::task_execution::execute_task_with_retry; use crate::utils::display_context_progress; +/// Plan mode prompt string. +const PLAN_MODE_PROMPT: &str = " >> "; + /// Build the interactive prompt string. /// /// Format: /// - Multiline mode: `"... > "` +/// - Plan mode: `" >> "` /// - No project: `"agent_name> "` (defaults to "g3") /// - With project: `"agent_name | project_name> "` -pub fn build_prompt(in_multiline: bool, agent_name: Option<&str>, active_project: &Option) -> String { +pub fn build_prompt(in_multiline: bool, in_plan_mode: bool, agent_name: Option<&str>, active_project: &Option) -> String { if in_multiline { "... > ".to_string() + } else if in_plan_mode { + PLAN_MODE_PROMPT.to_string() } else { let base_name = agent_name.unwrap_or("g3"); if let Some(project) = active_project { @@ -48,6 +55,63 @@ pub fn build_prompt(in_multiline: bool, agent_name: Option<&str>, active_project } } +/// Check if the input is an approval command (for plan mode). +/// +/// Recognizes: "a", "approve", "approved", and common misspellings. +pub fn is_approval_input(input: &str) -> bool { + let normalized = input.trim().to_lowercase(); + // Strip trailing punctuation (!, ., ,) + let normalized = normalized.trim_end_matches(|c| c == '!' || c == '.' || c == ','); + + // Exact matches + if matches!(normalized, "a" | "approve" | "approved" | "yes" | "y" | "ok") { + return true; + } + + // Common misspellings of "approve" / "approved" + let misspellings = [ + "approv", // missing 'e' + "aprove", // missing 'p' + "aproved", // missing 'p' + "aprrove", // transposed + "appprove", // extra 'p' + "apporve", // transposed + "approev", // transposed + "approvd", // missing 'e' + "approed", // missing 'v' + "approvee", // extra 'e' + "approveed", // extra 'e' + ]; + + misspellings.iter().any(|&m| normalized == m) +} + +/// Execute plan_approve tool directly without going through the LLM. +/// +/// Returns (success, message) where success indicates if the plan was approved. +async fn execute_plan_approve_directly( + agent: &mut Agent, + output: &SimpleOutput, +) -> (bool, String) { + let tool_call = ToolCall { + tool: "plan_approve".to_string(), + args: serde_json::json!({}), + }; + + match agent.execute_tool_call(&tool_call).await { + Ok(result) => { + let success = result.contains("✅ Plan approved"); + output.print(&result); + (success, result) + } + Err(e) => { + let msg = format!("❌ Failed to approve plan: {}", e); + output.print(&msg); + (false, msg) + } + } +} + /// Execute user input with template processing and auto-memory reminder. /// /// This is the common path for both single-line and multiline input. @@ -183,12 +247,6 @@ pub async fn run_interactive( // Skip verbose welcome when coming from agent mode (it already printed context info) if !from_agent_mode { - output.print(""); - output.print("g3 programming agent"); - output.print(" >> what shall we build today?"); - output.print(""); - - // Display provider and model information match agent.get_provider_info() { Ok((provider, model)) => { print!( @@ -220,8 +278,15 @@ pub async fn run_interactive( // Display workspace path print_workspace_path(workspace_path); + + // Print welcome message right before the prompt output.print(""); + output.print("g3 programming agent"); + output.print(" what shall we build today?"); } + + // Track plan mode state (start in plan mode for non-agent mode) + let mut in_plan_mode = !from_agent_mode; // Initialize rustyline editor with history let config = Config::builder() @@ -284,7 +349,7 @@ pub async fn run_interactive( } // Build prompt - let prompt = build_prompt(in_multiline, agent_name, &active_project); + let prompt = build_prompt(in_multiline, in_plan_mode, agent_name, &active_project); // Update the shared prompt for the notification handler *current_prompt.write().unwrap() = prompt.clone(); @@ -335,6 +400,31 @@ pub async fn run_interactive( // Single line input let input = line.trim().to_string(); + // In plan mode, check for approval input before anything else + if in_plan_mode && is_approval_input(&input) { + // Add to history + rl.add_history_entry(&input)?; + + // Reprint input with formatting + reprint_formatted_input(&input, &prompt); + + is_busy.store(true, Ordering::SeqCst); + let (approved, result) = execute_plan_approve_directly(&mut agent, &output).await; + is_busy.store(false, Ordering::SeqCst); + + if approved { + // Exit plan mode on successful approval + in_plan_mode = false; + + // Add synthetic assistant message so LLM knows plan was approved + use g3_providers::{Message, MessageRole}; + let synthetic_msg = Message::new(MessageRole::Assistant, result); + agent.add_message_to_context(synthetic_msg); + } + // Stay in plan mode if approval failed + continue; + } + if input.is_empty() { continue; } @@ -349,11 +439,17 @@ pub async fn run_interactive( // Check for control commands if input.starts_with('/') { is_busy.store(true, Ordering::SeqCst); - let handled = handle_command(&input, &mut agent, workspace_path, &output, &mut active_project, &mut rl, show_prompt, show_code).await?; + let result = handle_command(&input, &mut agent, workspace_path, &output, &mut active_project, &mut rl, show_prompt, show_code).await?; is_busy.store(false, Ordering::SeqCst); - if handled { - continue; + match result { + CommandResult::Handled => { + continue; + } + CommandResult::EnterPlanMode => { + in_plan_mode = true; + continue; + } } } @@ -380,8 +476,16 @@ pub async fn run_interactive( continue; } Err(ReadlineError::Eof) => { - output.print("CTRL-D"); - break; + // CTRL-D: if in plan mode, exit plan mode first; otherwise exit g3 + if in_plan_mode { + output.print("CTRL-D (exiting plan mode)"); + in_plan_mode = false; + // Continue the loop with normal prompt + continue; + } else { + output.print("CTRL-D"); + break; + } } Err(err) => { error!("Error: {:?}", err); @@ -425,36 +529,54 @@ mod tests { #[test] fn test_build_prompt_default() { - let prompt = build_prompt(false, None, &None); + let prompt = build_prompt(false, false, None, &None); assert_eq!(prompt, "g3> "); } #[test] fn test_build_prompt_with_agent_name() { - let prompt = build_prompt(false, Some("butler"), &None); + let prompt = build_prompt(false, false, Some("butler"), &None); assert_eq!(prompt, "butler> "); } #[test] fn test_build_prompt_multiline() { - let prompt = build_prompt(true, None, &None); + let prompt = build_prompt(true, false, None, &None); assert_eq!(prompt, "... > "); // Multiline takes precedence over agent name - let prompt = build_prompt(true, Some("butler"), &None); + let prompt = build_prompt(true, false, Some("butler"), &None); assert_eq!(prompt, "... > "); // Multiline takes precedence over project let project = Some(create_test_project("myapp")); - let prompt = build_prompt(true, None, &project); + let prompt = build_prompt(true, false, None, &project); assert_eq!(prompt, "... > "); + + // Multiline takes precedence over plan mode + let prompt = build_prompt(true, true, None, &None); + assert_eq!(prompt, "... > "); + } + + #[test] + fn test_build_prompt_plan_mode() { + let prompt = build_prompt(false, true, None, &None); + assert_eq!(prompt, " >> "); + + // Plan mode takes precedence over agent name + let prompt = build_prompt(false, true, Some("butler"), &None); + assert_eq!(prompt, " >> "); + + // Plan mode takes precedence over project + let project = Some(create_test_project("myapp")); + let prompt = build_prompt(false, true, None, &project); + assert_eq!(prompt, " >> "); } #[test] fn test_build_prompt_with_project() { let project = Some(create_test_project("myapp")); - let prompt = build_prompt(false, None, &project); - // Should contain the project name in the prompt + let prompt = build_prompt(false, false, None, &project); assert!(prompt.contains("g3")); assert!(prompt.contains("myapp")); assert!(prompt.contains("|")); @@ -463,8 +585,7 @@ mod tests { #[test] fn test_build_prompt_with_agent_and_project() { let project = Some(create_test_project("myapp")); - let prompt = build_prompt(false, Some("carmack"), &project); - // Should contain both agent name and project name + let prompt = build_prompt(false, false, Some("carmack"), &project); assert!(prompt.contains("carmack")); assert!(prompt.contains("myapp")); assert!(prompt.contains("|")); @@ -474,25 +595,60 @@ mod tests { fn test_build_prompt_unproject_resets() { // Simulate /project loading let project = Some(create_test_project("myapp")); - let prompt_with_project = build_prompt(false, None, &project); + let prompt_with_project = build_prompt(false, false, None, &project); assert!(prompt_with_project.contains("myapp")); // Simulate /unproject (sets active_project to None) - let prompt_after_unproject = build_prompt(false, None, &None); + let prompt_after_unproject = build_prompt(false, false, None, &None); assert_eq!(prompt_after_unproject, "g3> "); assert!(!prompt_after_unproject.contains("myapp")); } #[test] fn test_build_prompt_project_name_from_path() { - // Test that project name is extracted from path let project = Some(Project { path: PathBuf::from("/Users/dev/projects/awesome-app"), content: "test".to_string(), loaded_files: vec![], }); - let prompt = build_prompt(false, None, &project); + let prompt = build_prompt(false, false, None, &project); assert!(prompt.contains("awesome-app")); } + + #[test] + fn test_is_approval_input() { + // Exact matches + assert!(is_approval_input("a")); + assert!(is_approval_input("approve")); + assert!(is_approval_input("approved")); + assert!(is_approval_input("yes")); + assert!(is_approval_input("y")); + assert!(is_approval_input("ok")); + + // Case insensitive + assert!(is_approval_input("APPROVE")); + assert!(is_approval_input("Approved")); + + // Misspellings + assert!(is_approval_input("approv")); + assert!(is_approval_input("aprove")); + assert!(is_approval_input("appprove")); + + // Non-approval inputs + assert!(!is_approval_input("no")); + assert!(!is_approval_input("reject")); + assert!(!is_approval_input("hello world")); + + // Should NOT match partial words in longer text + assert!(!is_approval_input("I want to approve this")); + assert!(!is_approval_input("please approve the plan")); + assert!(!is_approval_input("a]ppro ve")); // gibberish with 'a' at start + + // Should match with trailing punctuation + assert!(is_approval_input("approved!")); + assert!(is_approval_input("approve.")); + assert!(is_approval_input("yes!")); + assert!(is_approval_input("ok,")); + } }