diff --git a/crates/autoresearch/src/git_integration.rs b/crates/autoresearch/src/git_integration.rs index 92d39f7..5ab81cb 100644 --- a/crates/autoresearch/src/git_integration.rs +++ b/crates/autoresearch/src/git_integration.rs @@ -7,6 +7,26 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use tokio::process::Command; +fn sanitize_git_env(cmd: &mut Command) -> &mut Command { + for key in [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_PREFIX", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + ] { + cmd.env_remove(key); + } + cmd +} + /// Git manager for autonomous research workflows pub struct GitManager { repo_path: PathBuf, @@ -22,7 +42,9 @@ impl GitManager { /// Run a git command and return the output async fn run(&self, args: &[&str]) -> Result { - let output = Command::new("git") + let mut cmd = Command::new("git"); + sanitize_git_env(&mut cmd); + let output = cmd .current_dir(&self.repo_path) .args(args) .output() @@ -294,21 +316,35 @@ mod tests { let temp = TempDir::new()?; // Initialize git repo - Command::new("git") - .current_dir(temp.path()) + let mut init = Command::new("git"); + sanitize_git_env(&mut init); + init.current_dir(temp.path()) .args(["init"]) .output() .await .expect("git init failed"); - Command::new("git") + let mut config_hooks = Command::new("git"); + sanitize_git_env(&mut config_hooks); + config_hooks + .current_dir(temp.path()) + .args(["config", "core.hooksPath", "/dev/null"]) + .output() + .await + .expect("git config failed"); + + let mut config_email = Command::new("git"); + sanitize_git_env(&mut config_email); + config_email .current_dir(temp.path()) .args(["config", "user.email", "test@test.com"]) .output() .await .expect("git config failed"); - Command::new("git") + let mut config_name = Command::new("git"); + sanitize_git_env(&mut config_name); + config_name .current_dir(temp.path()) .args(["config", "user.name", "Test"]) .output() @@ -316,7 +352,9 @@ mod tests { .expect("git config failed"); // Add an initial commit so HEAD is valid - Command::new("git") + let mut initial_commit = Command::new("git"); + sanitize_git_env(&mut initial_commit); + initial_commit .current_dir(temp.path()) .args(["commit", "--allow-empty", "-m", "initial"]) .output() diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 660404a..0eb6a54 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -1301,6 +1301,42 @@ fn parse_approval_verdict(line: &str) -> Option { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PrimaryInputMode { + Approval, + QuestionModal, + Normal, +} + +fn primary_input_mode(active_approval: bool, question_modal_open: bool) -> PrimaryInputMode { + if active_approval { + PrimaryInputMode::Approval + } else if question_modal_open { + PrimaryInputMode::QuestionModal + } else { + PrimaryInputMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApprovalShortcutAction { + Approve, + Deny, + AllowPattern, +} + +fn approval_shortcut_action( + code: KeyCode, + modifiers: KeyModifiers, +) -> Option { + match (code, modifiers) { + (KeyCode::Char('y'), KeyModifiers::CONTROL) => Some(ApprovalShortcutAction::Approve), + (KeyCode::Char('n'), KeyModifiers::CONTROL) => Some(ApprovalShortcutAction::Deny), + (KeyCode::Char('u'), KeyModifiers::CONTROL) => Some(ApprovalShortcutAction::AllowPattern), + _ => None, + } +} + fn parse_md_line(line: &str) -> Line<'static> { if line.starts_with("```") { return Line::from(Span::styled( @@ -3177,8 +3213,55 @@ pub fn run_blocking( continue; } + let primary_mode = + primary_input_mode(g.active_approval.is_some(), g.question_modal_open); + + // Approval shortcuts take precedence over question-modal handling. + if matches!(primary_mode, PrimaryInputMode::Approval) + && let Some(req) = g.active_approval.clone() + && let Some(action) = approval_shortcut_action(key.code, key.modifiers) + { + let call_id = req.call_id.clone(); + g.input_buffer.clear(); + g.cursor_char_idx = 0; + match action { + ApprovalShortcutAction::Approve => { + drop(g); + if let Some(ref tx) = approval_answer_tx { + let _ = tx.send(ApprovalAnswer::Verdict { + call_id, + approved: true, + }); + } + } + ApprovalShortcutAction::Deny => { + drop(g); + if let Some(ref tx) = approval_answer_tx { + let _ = tx.send(ApprovalAnswer::Verdict { + call_id, + approved: false, + }); + } + } + ApprovalShortcutAction::AllowPattern => { + let input_json: serde_json::Value = + serde_json::from_str(&req.input).unwrap_or_default(); + let pattern = suggest_allow_pattern(&req.tool, &input_json); + g.blocks.push(DisplayBlock::System(format!( + "Always allowing: {pattern}" + ))); + drop(g); + if let Some(ref tx) = approval_answer_tx { + let _ = + tx.send(ApprovalAnswer::AllowPattern { call_id, pattern }); + } + } + } + continue; + } + // Question modal keyboard handling. - if g.question_modal_open { + if matches!(primary_mode, PrimaryInputMode::QuestionModal) { if let Some(ref q) = g.active_question.clone() { // Total items: 1 (suggested) + options.len() + (1 if allow_custom for "Chat about this") let total = 1 + q.options.len() + if q.allow_custom { 1 } else { 0 }; @@ -3460,55 +3543,6 @@ pub fn run_blocking( drop(g); let _ = cmd_tx.send(TuiCmd::CycleModel(false)); } - (KeyCode::Char('y'), KeyModifiers::CONTROL) => { - if let Some(req) = g.active_approval.clone() { - let call_id = req.call_id.clone(); - g.input_buffer.clear(); - g.cursor_char_idx = 0; - drop(g); - if let Some(ref tx) = approval_answer_tx { - let _ = tx.send(ApprovalAnswer::Verdict { - call_id, - approved: true, - }); - } - continue; - } - } - (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - if let Some(req) = g.active_approval.clone() { - let call_id = req.call_id.clone(); - g.input_buffer.clear(); - g.cursor_char_idx = 0; - drop(g); - if let Some(ref tx) = approval_answer_tx { - let _ = tx.send(ApprovalAnswer::Verdict { - call_id, - approved: false, - }); - } - continue; - } - } - (KeyCode::Char('u'), KeyModifiers::CONTROL) => { - if let Some(req) = g.active_approval.clone() { - let input_json: serde_json::Value = - serde_json::from_str(&req.input).unwrap_or_default(); - let pattern = suggest_allow_pattern(&req.tool, &input_json); - let call_id = req.call_id.clone(); - g.input_buffer.clear(); - g.cursor_char_idx = 0; - g.blocks.push(DisplayBlock::System(format!( - "Always allowing: {pattern}" - ))); - drop(g); - if let Some(ref tx) = approval_answer_tx { - let _ = - tx.send(ApprovalAnswer::AllowPattern { call_id, pattern }); - } - continue; - } - } (KeyCode::Enter, _) => { if let Some((buf, cidx)) = apply_selected_at_completion( &workspace_files, @@ -3775,10 +3809,12 @@ pub fn run_blocking( #[cfg(test)] mod approval_parse_tests { use super::{ - TuiCmd, apply_selected_at_completion, branch_picker_enter_command, + ApprovalShortcutAction, PrimaryInputMode, TuiCmd, apply_selected_at_completion, + approval_shortcut_action, branch_picker_enter_command, completed_at_mention_range_before_cursor, composer_line, delete_completed_at_mention, - filtered_branch_indices, parse_approval_verdict, + filtered_branch_indices, parse_approval_verdict, primary_input_mode, }; + use crossterm::event::{KeyCode, KeyModifiers}; #[test] fn parses_yes_with_punctuation_and_synonyms() { @@ -3889,4 +3925,55 @@ mod approval_parse_tests { assert_eq!(mention_span.style.bg, Some(super::theme::MENTION_BG)); } + + #[test] + fn primary_input_mode_prefers_approval_when_both_active() { + assert_eq!(primary_input_mode(true, true), PrimaryInputMode::Approval); + } + + #[test] + fn primary_input_mode_uses_question_modal_without_approval() { + assert_eq!( + primary_input_mode(false, true), + PrimaryInputMode::QuestionModal + ); + } + + #[test] + fn approval_hotkeys_map_ctrl_y_and_ctrl_n() { + assert_eq!( + approval_shortcut_action(KeyCode::Char('y'), KeyModifiers::CONTROL), + Some(ApprovalShortcutAction::Approve) + ); + assert_eq!( + approval_shortcut_action(KeyCode::Char('n'), KeyModifiers::CONTROL), + Some(ApprovalShortcutAction::Deny) + ); + } + + #[test] + fn approval_hotkeys_map_ctrl_u() { + assert_eq!( + approval_shortcut_action(KeyCode::Char('u'), KeyModifiers::CONTROL), + Some(ApprovalShortcutAction::AllowPattern) + ); + } + + #[test] + fn approval_shortcuts_stay_active_when_question_modal_open() { + let mode = primary_input_mode(true, true); + assert_eq!(mode, PrimaryInputMode::Approval); + assert_eq!( + approval_shortcut_action(KeyCode::Char('y'), KeyModifiers::CONTROL), + Some(ApprovalShortcutAction::Approve) + ); + assert_eq!( + approval_shortcut_action(KeyCode::Char('n'), KeyModifiers::CONTROL), + Some(ApprovalShortcutAction::Deny) + ); + assert_eq!( + approval_shortcut_action(KeyCode::Char('u'), KeyModifiers::CONTROL), + Some(ApprovalShortcutAction::AllowPattern) + ); + } } diff --git a/crates/cli/tests/cli_commands.rs b/crates/cli/tests/cli_commands.rs index 383f865..74715e1 100644 --- a/crates/cli/tests/cli_commands.rs +++ b/crates/cli/tests/cli_commands.rs @@ -400,6 +400,9 @@ model = "claude-3-7-sonnet-latest" .expect("binary") .current_dir(temp.path()) .env("HOME", temp.path()) + .env_remove("NCA_MODEL") + .env_remove("NCA_DEFAULT_PROVIDER") + .env_remove("ANTHROPIC_MODEL") .arg("doctor") .arg("--json") .assert()