Rustyline needs ANSI escape codes wrapped in \x01...\x02 markers to correctly calculate visible prompt length. Without this, tab completion breaks because rustyline miscalculates cursor position.
410 lines
14 KiB
Rust
410 lines
14 KiB
Rust
//! Interactive mode for G3 CLI.
|
|
|
|
use anyhow::Result;
|
|
use crossterm::style::{Color, ResetColor, SetForegroundColor};
|
|
use rustyline::error::ReadlineError;
|
|
use rustyline::{Config, Editor};
|
|
use crate::completion::G3Helper;
|
|
use std::path::Path;
|
|
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::Project;
|
|
use crate::project_files::extract_readme_heading;
|
|
use crate::simple_output::SimpleOutput;
|
|
use crate::task_execution::execute_task_with_retry;
|
|
use crate::utils::display_context_progress;
|
|
|
|
/// Build the interactive prompt string.
|
|
///
|
|
/// Format:
|
|
/// Note: ANSI escape codes are wrapped in \x01...\x02 markers for rustyline
|
|
/// to correctly calculate visible prompt length (required for tab completion).
|
|
/// - Multiline mode: `"... > "`
|
|
/// - No project: `"agent_name> "` (defaults to "g3")
|
|
/// - With project: `"agent_name | project_name> "` where `| project_name>` is blue
|
|
pub fn build_prompt(in_multiline: bool, agent_name: Option<&str>, active_project: &Option<Project>) -> String {
|
|
if in_multiline {
|
|
"... > ".to_string()
|
|
} else {
|
|
let base_name = agent_name.unwrap_or("g3");
|
|
if let Some(project) = active_project {
|
|
let project_name = project.path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("project");
|
|
// Wrap ANSI codes in \x01...\x02 for rustyline to ignore them in length calculation
|
|
let blue = format!("\x01{}\x02", SetForegroundColor(Color::Blue));
|
|
let reset = format!("\x01{}\x02", ResetColor);
|
|
format!(
|
|
"{} {}| {}>{} ",
|
|
base_name,
|
|
blue,
|
|
project_name,
|
|
reset
|
|
)
|
|
} else {
|
|
format!("{}> ", base_name)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run interactive mode with console output.
|
|
/// If `agent_name` is Some, we're in agent+chat mode: skip session resume/verbose welcome,
|
|
/// and use the agent name as the prompt (e.g., "butler>").
|
|
pub async fn run_interactive<W: UiWriter>(
|
|
mut agent: Agent<W>,
|
|
show_prompt: bool,
|
|
show_code: bool,
|
|
combined_content: Option<String>,
|
|
workspace_path: &Path,
|
|
new_session: bool,
|
|
agent_name: Option<&str>,
|
|
) -> Result<()> {
|
|
let output = SimpleOutput::new();
|
|
let from_agent_mode = agent_name.is_some();
|
|
|
|
// Check for session continuation (skip if --new-session was passed or coming from agent mode)
|
|
// Agent mode with --chat should start fresh without prompting
|
|
if !new_session && !from_agent_mode {
|
|
if let Ok(Some(continuation)) = g3_core::load_continuation() {
|
|
// Print session info and prompt on same line (no newline)
|
|
print!(
|
|
"\n >> session in progress: {}{}{} | {:.1}% used | resume? [y/n] ",
|
|
SetForegroundColor(Color::Cyan),
|
|
&continuation.session_id[..continuation.session_id.len().min(20)],
|
|
ResetColor,
|
|
continuation.context_percentage
|
|
);
|
|
use std::io::Write;
|
|
std::io::stdout().flush()?;
|
|
|
|
// Read user input
|
|
let mut input = String::new();
|
|
std::io::stdin().read_line(&mut input)?;
|
|
let input = input.trim().to_lowercase();
|
|
|
|
if input.is_empty() || input == "y" || input == "yes" {
|
|
// Resume the session
|
|
match agent.restore_from_continuation(&continuation) {
|
|
Ok(true) => {
|
|
G3Status::resuming(&continuation.session_id, Status::Done);
|
|
}
|
|
Ok(false) => {
|
|
G3Status::resuming_summary(&continuation.session_id);
|
|
}
|
|
Err(e) => {
|
|
G3Status::resuming(&continuation.session_id, Status::Error(e.to_string()));
|
|
// Clear the invalid continuation
|
|
let _ = g3_core::clear_continuation();
|
|
}
|
|
}
|
|
} else {
|
|
// User declined, clear the continuation
|
|
G3Status::info_inline("starting fresh");
|
|
let _ = g3_core::clear_continuation();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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!(
|
|
"🔧 {}{}{} | {}{}{}\n",
|
|
SetForegroundColor(Color::Cyan),
|
|
provider,
|
|
ResetColor,
|
|
SetForegroundColor(Color::Yellow),
|
|
model,
|
|
ResetColor
|
|
);
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to get provider info: {}", e);
|
|
}
|
|
}
|
|
|
|
// Display message if AGENTS.md or README was loaded
|
|
if let Some(ref content) = combined_content {
|
|
let loaded = LoadedContent::from_combined_content(content);
|
|
|
|
// Extract project name if README is loaded
|
|
if loaded.has_readme {
|
|
if let Some(name) = extract_readme_heading(content) {
|
|
print_project_heading(&name);
|
|
}
|
|
}
|
|
|
|
print_loaded_status(&loaded);
|
|
}
|
|
|
|
// Display workspace path
|
|
print_workspace_path(workspace_path);
|
|
output.print("");
|
|
}
|
|
|
|
// Initialize rustyline editor with history
|
|
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| {
|
|
path.push(".g3_history");
|
|
path
|
|
});
|
|
|
|
if let Some(ref history_path) = history_file {
|
|
let _ = rl.load_history(history_path);
|
|
}
|
|
|
|
// Track multiline input
|
|
let mut multiline_buffer = String::new();
|
|
let mut in_multiline = false;
|
|
|
|
// Track active project
|
|
let mut active_project: Option<Project> = None;
|
|
|
|
loop {
|
|
// Display context window progress bar before each prompt
|
|
display_context_progress(&agent, &output);
|
|
|
|
// Build prompt (shows project name in blue when active)
|
|
let prompt = build_prompt(in_multiline, agent_name, &active_project);
|
|
|
|
let readline = rl.readline(&prompt);
|
|
match readline {
|
|
Ok(line) => {
|
|
let trimmed = line.trim_end();
|
|
|
|
// Check if line ends with backslash for continuation
|
|
if let Some(without_backslash) = trimmed.strip_suffix('\\') {
|
|
// Remove the backslash and add to buffer
|
|
multiline_buffer.push_str(without_backslash);
|
|
multiline_buffer.push('\n');
|
|
in_multiline = true;
|
|
continue;
|
|
}
|
|
|
|
// If we're in multiline mode and no backslash, this is the final line
|
|
if in_multiline {
|
|
multiline_buffer.push_str(&line);
|
|
in_multiline = false;
|
|
// Process the complete multiline input
|
|
let input = multiline_buffer.trim().to_string();
|
|
multiline_buffer.clear();
|
|
|
|
if input.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Add complete multiline to history
|
|
rl.add_history_entry(&input)?;
|
|
|
|
if input == "exit" || input == "quit" {
|
|
break;
|
|
}
|
|
|
|
// Process the multiline input
|
|
execute_task_with_retry(
|
|
&mut agent,
|
|
&input,
|
|
show_prompt,
|
|
show_code,
|
|
&output,
|
|
)
|
|
.await;
|
|
|
|
// Send auto-memory reminder if enabled and tools were called
|
|
// Skip per-turn reminders when from_agent_mode - we'll send once on exit
|
|
if !from_agent_mode {
|
|
if let Err(e) = agent.send_auto_memory_reminder().await {
|
|
debug!("Auto-memory reminder failed: {}", e);
|
|
}
|
|
}
|
|
} else {
|
|
// Single line input
|
|
let input = line.trim().to_string();
|
|
|
|
if input.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if input == "exit" || input == "quit" {
|
|
break;
|
|
}
|
|
|
|
// Add to history
|
|
rl.add_history_entry(&input)?;
|
|
|
|
// Check for control commands
|
|
if input.starts_with('/') {
|
|
if handle_command(&input, &mut agent, workspace_path, &output, &mut active_project, &mut rl, show_prompt, show_code).await? {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Process the single line input
|
|
execute_task_with_retry(
|
|
&mut agent,
|
|
&input,
|
|
show_prompt,
|
|
show_code,
|
|
&output,
|
|
)
|
|
.await;
|
|
|
|
// Send auto-memory reminder if enabled and tools were called
|
|
// Skip per-turn reminders when from_agent_mode - we'll send once on exit
|
|
if !from_agent_mode {
|
|
if let Err(e) = agent.send_auto_memory_reminder().await {
|
|
debug!("Auto-memory reminder failed: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(ReadlineError::Interrupted) => {
|
|
// Ctrl-C pressed
|
|
if in_multiline {
|
|
// Cancel multiline input
|
|
output.print("Multi-line input cancelled");
|
|
multiline_buffer.clear();
|
|
in_multiline = false;
|
|
} else {
|
|
output.print("CTRL-C");
|
|
}
|
|
continue;
|
|
}
|
|
Err(ReadlineError::Eof) => {
|
|
output.print("CTRL-D");
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
error!("Error: {:?}", err);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save history before exiting
|
|
if let Some(ref history_path) = history_file {
|
|
let _ = rl.save_history(history_path);
|
|
}
|
|
|
|
// Save session continuation for resume capability
|
|
agent.save_session_continuation(None);
|
|
|
|
// Send auto-memory reminder once on exit when in agent+chat mode
|
|
// (Per-turn reminders were skipped to avoid being too onerous)
|
|
if from_agent_mode {
|
|
if let Err(e) = agent.send_auto_memory_reminder().await {
|
|
debug!("Auto-memory reminder on exit failed: {}", e);
|
|
}
|
|
}
|
|
|
|
output.print("👋 Goodbye!");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
fn create_test_project(name: &str) -> Project {
|
|
Project {
|
|
path: PathBuf::from(format!("/test/projects/{}", name)),
|
|
content: "test content".to_string(),
|
|
loaded_files: vec!["brief.md".to_string()],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_prompt_default() {
|
|
let prompt = build_prompt(false, None, &None);
|
|
assert_eq!(prompt, "g3> ");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_prompt_with_agent_name() {
|
|
let prompt = build_prompt(false, Some("butler"), &None);
|
|
assert_eq!(prompt, "butler> ");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_prompt_multiline() {
|
|
let prompt = build_prompt(true, None, &None);
|
|
assert_eq!(prompt, "... > ");
|
|
|
|
// Multiline takes precedence over agent name
|
|
let prompt = build_prompt(true, 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);
|
|
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
|
|
assert!(prompt.contains("g3"));
|
|
assert!(prompt.contains("myapp"));
|
|
assert!(prompt.contains("|"));
|
|
}
|
|
|
|
#[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
|
|
assert!(prompt.contains("carmack"));
|
|
assert!(prompt.contains("myapp"));
|
|
assert!(prompt.contains("|"));
|
|
}
|
|
|
|
#[test]
|
|
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);
|
|
assert!(prompt_with_project.contains("myapp"));
|
|
|
|
// Simulate /unproject (sets active_project to None)
|
|
let prompt_after_unproject = build_prompt(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);
|
|
assert!(prompt.contains("awesome-app"));
|
|
}
|
|
}
|
|
|