diff --git a/analysis/memory.md b/analysis/memory.md index 81b4332..e81cf8b 100644 --- a/analysis/memory.md +++ b/analysis/memory.md @@ -1,5 +1,5 @@ # Project Memory -> Updated: 2026-01-20T04:10:35Z | Size: 15.8k chars +> Updated: 2026-01-20T08:53:25Z | Size: 16.3k chars ### Remember Tool Wiring - `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()` @@ -285,4 +285,14 @@ Semantic data for context thinning operations, replacing pre-formatted strings. - `crates/g3-cli/src/g3_status.rs` - `Status::NoChanges` [42] - new status variant for thinning with no changes - - `G3Status::thin_result()` [265..292] - formats ThinResult with proper colors/styling \ No newline at end of file + - `G3Status::thin_result()` [265..292] - formats ThinResult with proper colors/styling + +### CLI Display Utilities +Shared display functions for interactive and agent modes. + +- `crates/g3-cli/src/display.rs` + - `format_workspace_path()` [9..17] - formats path with ~ for home dir + - `print_workspace_path()` [20..29] - prints formatted workspace path + - `LoadedContent` [32..39] - tracks loaded project files (README, AGENTS.md, Memory, include prompt) + - `print_loaded_status()` [87..103] - prints "โœ“ README โœ“ AGENTS.md" status line + - `print_project_heading()` [106..114] - prints project name from README \ No newline at end of file diff --git a/crates/g3-cli/src/commands.rs b/crates/g3-cli/src/commands.rs new file mode 100644 index 0000000..e8ce771 --- /dev/null +++ b/crates/g3-cli/src/commands.rs @@ -0,0 +1,320 @@ +//! Interactive command handlers for G3 CLI. +//! +//! Handles `/` commands in interactive mode. + +use anyhow::Result; +use rustyline::Editor; + +use g3_core::ui_writer::UiWriter; +use g3_core::Agent; + +use crate::completion::G3Helper; +use crate::g3_status::{G3Status, Status}; +use crate::simple_output::SimpleOutput; +use crate::task_execution::execute_task_with_retry; + +/// Handle a control command. Returns true if the command was handled and the loop should continue. +pub async fn handle_command( + input: &str, + agent: &mut Agent, + output: &SimpleOutput, + rl: &mut Editor, + show_prompt: bool, + show_code: bool, +) -> Result { + match input { + "/help" => { + output.print(""); + output.print("๐Ÿ“– Control Commands:"); + output.print(" /compact - Trigger compaction (compacts conversation history)"); + output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)"); + output.print(" /skinnify - Trigger full context thinning (like /thinnify but for entire context, not just first third)"); + output.print(" /clear - Clear session and start fresh (discards continuation artifacts)"); + output.print(" /fragments - List dehydrated context fragments (ACD)"); + output.print(" /rehydrate - Restore a dehydrated fragment by ID"); + output.print(" /resume - List and switch to a previous session"); + output.print(" /dump - Dump entire context window to file for debugging"); + output.print(" /readme - Reload README.md and AGENTS.md from disk"); + output.print(" /stats - Show detailed context and performance statistics"); + output.print(" /run - Read file and execute as prompt"); + output.print(" /help - Show this help message"); + output.print(" exit/quit - Exit the interactive session"); + output.print(""); + Ok(true) + } + "/compact" => { + output.print_g3_progress("compacting session"); + match agent.force_compact().await { + Ok(true) => { + output.print_g3_status("compacting session", "done"); + } + Ok(false) => { + output.print_g3_status("compacting session", "failed"); + } + Err(e) => { + output.print_g3_status("compacting session", &format!("error: {}", e)); + } + } + Ok(true) + } + "/thinnify" => { + let result = agent.force_thin(); + G3Status::thin_result(&result); + Ok(true) + } + "/skinnify" => { + let result = agent.force_thin_all(); + G3Status::thin_result(&result); + Ok(true) + } + "/fragments" => { + if let Some(session_id) = agent.get_session_id() { + match g3_core::acd::list_fragments(session_id) { + Ok(fragments) => { + if fragments.is_empty() { + output.print("No dehydrated fragments found for this session."); + } else { + output.print(&format!( + "๐Ÿ“ฆ {} dehydrated fragment(s):\n", + fragments.len() + )); + for fragment in &fragments { + output.print(&fragment.generate_stub()); + output.print(""); + } + } + } + Err(e) => { + output.print(&format!("โŒ Error listing fragments: {}", e)); + } + } + } else { + output.print("No active session - fragments are session-scoped."); + } + Ok(true) + } + cmd if cmd.starts_with("/rehydrate") => { + let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); + if parts.len() < 2 || parts[1].trim().is_empty() { + output.print("Usage: /rehydrate "); + output.print("Use /fragments to list available fragment IDs."); + } else { + let fragment_id = parts[1].trim(); + if let Some(session_id) = agent.get_session_id() { + match g3_core::acd::Fragment::load(session_id, fragment_id) { + Ok(fragment) => { + output.print(&format!( + "โœ… Fragment '{}' loaded ({} messages, ~{} tokens)", + fragment_id, fragment.message_count, fragment.estimated_tokens + )); + output.print(""); + output.print(&fragment.generate_stub()); + } + Err(e) => { + output.print(&format!( + "โŒ Failed to load fragment '{}': {}", + fragment_id, e + )); + } + } + } else { + output.print("No active session - fragments are session-scoped."); + } + } + Ok(true) + } + cmd if cmd.starts_with("/run") => { + let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); + if parts.len() < 2 || parts[1].trim().is_empty() { + output.print("Usage: /run "); + output.print("Reads the file and executes its content as a prompt."); + } else { + let file_path = parts[1].trim(); + // Expand tilde + let expanded_path = if file_path.starts_with("~/") { + if let Some(home) = dirs::home_dir() { + home.join(&file_path[2..]) + } else { + std::path::PathBuf::from(file_path) + } + } else { + std::path::PathBuf::from(file_path) + }; + match std::fs::read_to_string(&expanded_path) { + Ok(content) => { + let prompt = content.trim(); + if prompt.is_empty() { + output.print("โŒ File is empty."); + } else { + G3Status::progress(&format!("loading {}", file_path)); + G3Status::done(); + execute_task_with_retry(agent, prompt, show_prompt, show_code, output).await; + } + } + Err(e) => { + output.print(&format!("โŒ Failed to read file '{}': {}", file_path, e)); + } + } + } + Ok(true) + } + "/dump" => { + // Dump entire context window to a file for debugging + let dump_dir = std::path::Path::new("tmp"); + 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); + } + } + + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let dump_path = dump_dir.join(format!("context_dump_{}.txt", timestamp)); + + let context = agent.get_context_window(); + let mut dump_content = String::new(); + dump_content.push_str("# Context Window Dump\n"); + dump_content.push_str(&format!("# Timestamp: {}\n", chrono::Utc::now())); + dump_content.push_str(&format!( + "# Messages: {}\n", + context.conversation_history.len() + )); + dump_content.push_str(&format!( + "# Used tokens: {} / {} ({:.1}%)\n\n", + context.used_tokens, + context.total_tokens, + context.percentage_used() + )); + + for (i, msg) in context.conversation_history.iter().enumerate() { + dump_content.push_str(&format!("=== Message {} ===\n", i)); + dump_content.push_str(&format!("Role: {:?}\n", msg.role)); + dump_content.push_str(&format!("Kind: {:?}\n", msg.kind)); + dump_content.push_str(&format!("Content ({} chars):\n", msg.content.len())); + dump_content.push_str(&msg.content); + dump_content.push_str("\n\n"); + } + + match std::fs::write(&dump_path, &dump_content) { + Ok(_) => output.print(&format!("๐Ÿ“„ Context dumped to: {}", dump_path.display())), + Err(e) => output.print(&format!("โŒ Failed to write dump: {}", e)), + } + Ok(true) + } + "/clear" => { + output.print("๐Ÿงน Clearing session..."); + agent.clear_session(); + output.print("โœ… Session cleared. Starting fresh."); + Ok(true) + } + "/readme" => { + output.print("๐Ÿ“š Reloading README.md and AGENTS.md..."); + match agent.reload_readme() { + Ok(true) => { + output.print("โœ… README content reloaded successfully") + } + Ok(false) => { + output.print("โš ๏ธ No README was loaded at startup, cannot reload") + } + Err(e) => output.print(&format!("โŒ Error reloading README: {}", e)), + } + Ok(true) + } + "/stats" => { + let stats = agent.get_stats(); + output.print(&stats); + Ok(true) + } + "/resume" => { + output.print("๐Ÿ“‹ Scanning for available sessions..."); + + match g3_core::list_sessions_for_directory() { + Ok(sessions) => { + if sessions.is_empty() { + output.print("No sessions found for this directory."); + return Ok(true); + } + + // Get current session ID to mark it + let current_session_id = agent.get_session_id().map(|s| s.to_string()); + + output.print(""); + output.print("Available sessions:"); + for (i, session) in sessions.iter().enumerate() { + let time_str = g3_core::format_session_time(&session.created_at); + let context_str = format!("{:.0}%", session.context_percentage); + let current_marker = + if current_session_id.as_deref() == Some(&session.session_id) { + " (current)" + } else { + "" + }; + let todo_marker = if session.has_incomplete_todos() { + " ๐Ÿ“" + } else { + "" + }; + + // Use description if available, otherwise fall back to session ID + let display_name = match &session.description { + Some(desc) => format!("'{}'", desc), + None => { + if session.session_id.len() > 40 { + format!("{}...", &session.session_id[..40]) + } else { + session.session_id.clone() + } + } + }; + output.print(&format!( + " {}. [{}] {} ({}){}{}\n", + i + 1, + time_str, + display_name, + context_str, + todo_marker, + current_marker + )); + } + + output.print_inline("\nSession number to resume (Enter to cancel): "); + // Read user selection + if let Ok(selection) = rl.readline("") { + let selection = selection.trim(); + if selection.is_empty() { + output.print("Cancelled."); + } else if let Ok(num) = selection.parse::() { + if num >= 1 && num <= sessions.len() { + let selected = &sessions[num - 1]; + match agent.switch_to_session(selected) { + Ok(true) => { + G3Status::resuming(&selected.session_id, Status::Done); + } + Ok(false) => { + G3Status::resuming_summary(&selected.session_id); + } + Err(e) => { + G3Status::resuming(&selected.session_id, Status::Error(e.to_string())); + } + } + } else { + output.print("Invalid selection."); + } + } else { + output.print("Invalid input. Please enter a number."); + } + } + } + Err(e) => output.print(&format!("โŒ Error listing sessions: {}", e)), + } + Ok(true) + } + _ => { + output.print(&format!( + "โŒ Unknown command: {}. Type /help for available commands.", + input + )); + Ok(true) + } + } +} diff --git a/crates/g3-cli/src/completion.rs b/crates/g3-cli/src/completion.rs index a84e336..4aadcaa 100644 --- a/crates/g3-cli/src/completion.rs +++ b/crates/g3-cli/src/completion.rs @@ -509,7 +509,6 @@ mod tests { println!("Line: '{}', start: {}, completions: {}", line, start, completions.len()); assert_eq!(completions.len(), 0, "Quoted non-path should not trigger completion"); } -} #[test] fn test_resume_completion_lists_sessions() { @@ -561,3 +560,4 @@ mod tests { // The important thing is it doesn't panic println!("list_sessions returned {} sessions", sessions.len()); } +} diff --git a/crates/g3-cli/src/display.rs b/crates/g3-cli/src/display.rs index 4a139a5..126d45e 100644 --- a/crates/g3-cli/src/display.rs +++ b/crates/g3-cli/src/display.rs @@ -64,6 +64,7 @@ impl LoadedContent { } /// Create with explicit include prompt filename. + #[allow(dead_code)] // Used in tests, may be useful for future callers pub fn with_include_prompt_filename(mut self, filename: Option) -> Self { if self.include_prompt_filename.is_some() { self.include_prompt_filename = filename; diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index 6c2bfb3..33bebd3 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -11,6 +11,7 @@ use tracing::{debug, error}; use g3_core::ui_writer::UiWriter; use g3_core::Agent; +use crate::commands::handle_command; use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path}; use crate::g3_status::{G3Status, Status}; use crate::project_files::extract_readme_heading; @@ -287,309 +288,3 @@ pub async fn run_interactive( output.print("๐Ÿ‘‹ Goodbye!"); Ok(()) } - -/// Handle a control command. Returns true if the command was handled and the loop should continue. -async fn handle_command( - input: &str, - agent: &mut Agent, - output: &SimpleOutput, - rl: &mut Editor, - show_prompt: bool, - show_code: bool, -) -> Result { - match input { - "/help" => { - output.print(""); - output.print("๐Ÿ“– Control Commands:"); - output.print(" /compact - Trigger compaction (compacts conversation history)"); - output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)"); - output.print(" /skinnify - Trigger full context thinning (like /thinnify but for entire context, not just first third)"); - output.print(" /clear - Clear session and start fresh (discards continuation artifacts)"); - output.print(" /fragments - List dehydrated context fragments (ACD)"); - output.print(" /rehydrate - Restore a dehydrated fragment by ID"); - output.print(" /resume - List and switch to a previous session"); - output.print(" /dump - Dump entire context window to file for debugging"); - output.print(" /readme - Reload README.md and AGENTS.md from disk"); - output.print(" /stats - Show detailed context and performance statistics"); - output.print(" /run - Read file and execute as prompt"); - output.print(" /help - Show this help message"); - output.print(" exit/quit - Exit the interactive session"); - output.print(""); - Ok(true) - } - "/compact" => { - output.print_g3_progress("compacting session"); - match agent.force_compact().await { - Ok(true) => { - output.print_g3_status("compacting session", "done"); - } - Ok(false) => { - output.print_g3_status("compacting session", "failed"); - } - Err(e) => { - output.print_g3_status("compacting session", &format!("error: {}", e)); - } - } - Ok(true) - } - "/thinnify" => { - let result = agent.force_thin(); - G3Status::thin_result(&result); - Ok(true) - } - "/skinnify" => { - let result = agent.force_thin_all(); - G3Status::thin_result(&result); - Ok(true) - } - "/fragments" => { - if let Some(session_id) = agent.get_session_id() { - match g3_core::acd::list_fragments(session_id) { - Ok(fragments) => { - if fragments.is_empty() { - output.print("No dehydrated fragments found for this session."); - } else { - output.print(&format!( - "๐Ÿ“ฆ {} dehydrated fragment(s):\n", - fragments.len() - )); - for fragment in &fragments { - output.print(&fragment.generate_stub()); - output.print(""); - } - } - } - Err(e) => { - output.print(&format!("โŒ Error listing fragments: {}", e)); - } - } - } else { - output.print("No active session - fragments are session-scoped."); - } - Ok(true) - } - cmd if cmd.starts_with("/rehydrate") => { - let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); - if parts.len() < 2 || parts[1].trim().is_empty() { - output.print("Usage: /rehydrate "); - output.print("Use /fragments to list available fragment IDs."); - } else { - let fragment_id = parts[1].trim(); - if let Some(session_id) = agent.get_session_id() { - match g3_core::acd::Fragment::load(session_id, fragment_id) { - Ok(fragment) => { - output.print(&format!( - "โœ… Fragment '{}' loaded ({} messages, ~{} tokens)", - fragment_id, fragment.message_count, fragment.estimated_tokens - )); - output.print(""); - output.print(&fragment.generate_stub()); - } - Err(e) => { - output.print(&format!( - "โŒ Failed to load fragment '{}': {}", - fragment_id, e - )); - } - } - } else { - output.print("No active session - fragments are session-scoped."); - } - } - Ok(true) - } - cmd if cmd.starts_with("/run") => { - let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); - if parts.len() < 2 || parts[1].trim().is_empty() { - output.print("Usage: /run "); - output.print("Reads the file and executes its content as a prompt."); - } else { - let file_path = parts[1].trim(); - // Expand tilde - let expanded_path = if file_path.starts_with("~/") { - if let Some(home) = dirs::home_dir() { - home.join(&file_path[2..]) - } else { - std::path::PathBuf::from(file_path) - } - } else { - std::path::PathBuf::from(file_path) - }; - match std::fs::read_to_string(&expanded_path) { - Ok(content) => { - let prompt = content.trim(); - if prompt.is_empty() { - output.print("โŒ File is empty."); - } else { - G3Status::progress(&format!("loading {}", file_path)); - G3Status::done(); - execute_task_with_retry(agent, prompt, show_prompt, show_code, output).await; - } - } - Err(e) => { - output.print(&format!("โŒ Failed to read file '{}': {}", file_path, e)); - } - } - } - Ok(true) - } - "/dump" => { - // Dump entire context window to a file for debugging - let dump_dir = std::path::Path::new("tmp"); - 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); - } - } - - let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); - let dump_path = dump_dir.join(format!("context_dump_{}.txt", timestamp)); - - let context = agent.get_context_window(); - let mut dump_content = String::new(); - dump_content.push_str("# Context Window Dump\n"); - dump_content.push_str(&format!("# Timestamp: {}\n", chrono::Utc::now())); - dump_content.push_str(&format!( - "# Messages: {}\n", - context.conversation_history.len() - )); - dump_content.push_str(&format!( - "# Used tokens: {} / {} ({:.1}%)\n\n", - context.used_tokens, - context.total_tokens, - context.percentage_used() - )); - - for (i, msg) in context.conversation_history.iter().enumerate() { - dump_content.push_str(&format!("=== Message {} ===\n", i)); - dump_content.push_str(&format!("Role: {:?}\n", msg.role)); - dump_content.push_str(&format!("Kind: {:?}\n", msg.kind)); - dump_content.push_str(&format!("Content ({} chars):\n", msg.content.len())); - dump_content.push_str(&msg.content); - dump_content.push_str("\n\n"); - } - - match std::fs::write(&dump_path, &dump_content) { - Ok(_) => output.print(&format!("๐Ÿ“„ Context dumped to: {}", dump_path.display())), - Err(e) => output.print(&format!("โŒ Failed to write dump: {}", e)), - } - Ok(true) - } - "/clear" => { - output.print("๐Ÿงน Clearing session..."); - agent.clear_session(); - output.print("โœ… Session cleared. Starting fresh."); - Ok(true) - } - "/readme" => { - output.print("๐Ÿ“š Reloading README.md and AGENTS.md..."); - match agent.reload_readme() { - Ok(true) => { - output.print("โœ… README content reloaded successfully") - } - Ok(false) => { - output.print("โš ๏ธ No README was loaded at startup, cannot reload") - } - Err(e) => output.print(&format!("โŒ Error reloading README: {}", e)), - } - Ok(true) - } - "/stats" => { - let stats = agent.get_stats(); - output.print(&stats); - Ok(true) - } - "/resume" => { - output.print("๐Ÿ“‹ Scanning for available sessions..."); - - match g3_core::list_sessions_for_directory() { - Ok(sessions) => { - if sessions.is_empty() { - output.print("No sessions found for this directory."); - return Ok(true); - } - - // Get current session ID to mark it - let current_session_id = agent.get_session_id().map(|s| s.to_string()); - - output.print(""); - output.print("Available sessions:"); - for (i, session) in sessions.iter().enumerate() { - let time_str = g3_core::format_session_time(&session.created_at); - let context_str = format!("{:.0}%", session.context_percentage); - let current_marker = - if current_session_id.as_deref() == Some(&session.session_id) { - " (current)" - } else { - "" - }; - let todo_marker = if session.has_incomplete_todos() { - " ๐Ÿ“" - } else { - "" - }; - - // Use description if available, otherwise fall back to session ID - let display_name = match &session.description { - Some(desc) => format!("'{}'", desc), - None => { - if session.session_id.len() > 40 { - format!("{}...", &session.session_id[..40]) - } else { - session.session_id.clone() - } - } - }; - output.print(&format!( - " {}. [{}] {} ({}){}{}\n", - i + 1, - time_str, - display_name, - context_str, - todo_marker, - current_marker - )); - } - - output.print_inline("\nSession number to resume (Enter to cancel): "); - // Read user selection - if let Ok(selection) = rl.readline("") { - let selection = selection.trim(); - if selection.is_empty() { - output.print("Cancelled."); - } else if let Ok(num) = selection.parse::() { - if num >= 1 && num <= sessions.len() { - let selected = &sessions[num - 1]; - match agent.switch_to_session(selected) { - Ok(true) => { - G3Status::resuming(&selected.session_id, Status::Done); - } - Ok(false) => { - G3Status::resuming_summary(&selected.session_id); - } - Err(e) => { - G3Status::resuming(&selected.session_id, Status::Error(e.to_string())); - } - } - } else { - output.print("Invalid selection."); - } - } else { - output.print("Invalid input. Please enter a number."); - } - } - } - Err(e) => output.print(&format!("โŒ Error listing sessions: {}", e)), - } - Ok(true) - } - _ => { - output.print(&format!( - "โŒ Unknown command: {}. Type /help for available commands.", - input - )); - Ok(true) - } - } -} diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 00803c7..3563dd9 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -12,6 +12,7 @@ mod agent_mode; mod autonomous; mod cli_args; mod coach_feedback; +mod commands; mod display; mod interactive; mod simple_output;