refactor(g3-cli): extract commands module and fix test organization

- Extract handle_command() from interactive.rs to new commands.rs module
  (320 lines, 15 match arms for /help, /compact, /thinnify, etc.)
- Fix orphaned tests in completion.rs that were outside mod tests block
- Add #[allow(dead_code)] to with_include_prompt_filename() (used in tests)
- interactive.rs reduced from 595 to 290 lines

Agent: fowler
This commit is contained in:
Dhanji R. Prasanna
2026-01-20 14:30:50 +05:30
parent 710c54105b
commit dec22f5e58
6 changed files with 336 additions and 309 deletions

View File

@@ -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()`
@@ -286,3 +286,13 @@ 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
### 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

View File

@@ -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<W: UiWriter>(
input: &str,
agent: &mut Agent<W>,
output: &SimpleOutput,
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
show_prompt: bool,
show_code: bool,
) -> Result<bool> {
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 <file> - 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 <fragment_id>");
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 <file-path>");
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::<usize>() {
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)
}
}
}

View File

@@ -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());
}
}

View File

@@ -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<String>) -> Self {
if self.include_prompt_filename.is_some() {
self.include_prompt_filename = filename;

View File

@@ -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<W: UiWriter>(
output.print("👋 Goodbye!");
Ok(())
}
/// Handle a control command. Returns true if the command was handled and the loop should continue.
async fn handle_command<W: UiWriter>(
input: &str,
agent: &mut Agent<W>,
output: &SimpleOutput,
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
show_prompt: bool,
show_code: bool,
) -> Result<bool> {
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 <file> - 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 <fragment_id>");
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 <file-path>");
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::<usize>() {
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)
}
}
}

View File

@@ -12,6 +12,7 @@ mod agent_mode;
mod autonomous;
mod cli_args;
mod coach_feedback;
mod commands;
mod display;
mod interactive;
mod simple_output;