feat: Plan Mode interactive flow with approval shortcuts

- Start g3 in plan mode with ' >>' prompt and welcome message
- Add is_approval_input() to detect 'approve', 'a', 'yes', etc. and misspellings
- Allow trailing punctuation (!, ., ,) on approval words
- Call plan_approve tool directly without LLM when approval detected
- Add synthetic assistant message after approval for LLM context
- Exit plan mode after successful approval, return to 'g3>' prompt
- CTRL-D in plan mode exits plan mode first, then exits g3
- /plan command enters plan mode and shows welcome message
- Agent mode (--agent) does not start in plan mode
- Add CommandResult enum to signal plan mode entry from commands
This commit is contained in:
Dhanji R. Prasanna
2026-02-02 16:59:52 +11:00
parent 9024f693fa
commit f8448e5622
2 changed files with 218 additions and 47 deletions

View File

@@ -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<W: UiWriter>(
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
show_prompt: bool,
show_code: bool,
) -> Result<bool> {
) -> Result<CommandResult> {
match input {
"/help" => {
output.print("");
@@ -78,7 +87,7 @@ pub async fn handle_command<W: UiWriter>(
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<W: UiWriter>(
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<W: UiWriter>(
} 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<W: UiWriter>(
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<W: UiWriter>(
}
}
}
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<W: UiWriter>(
}
}
}
Ok(true)
Ok(CommandResult::Handled)
}
"/dump" => {
// Dump entire context window to a file for debugging
@@ -253,7 +262,7 @@ pub async fn handle_command<W: UiWriter>(
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<W: UiWriter>(
}
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<W: UiWriter>(
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<W: UiWriter>(
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<W: UiWriter>(
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<W: UiWriter>(
}
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<W: UiWriter>(
}
}
}
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<W: UiWriter>(
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<W: UiWriter>(
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<W: UiWriter>(
} 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)
}
}
}