Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions crates/autoresearch/src/git_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,7 +42,9 @@ impl GitManager {

/// Run a git command and return the output
async fn run(&self, args: &[&str]) -> Result<String> {
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()
Expand Down Expand Up @@ -294,29 +316,45 @@ 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()
.await
.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()
Expand Down
191 changes: 139 additions & 52 deletions crates/cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,42 @@ fn parse_approval_verdict(line: &str) -> Option<bool> {
}
}

#[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<ApprovalShortcutAction> {
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(
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
);
}
}
3 changes: 3 additions & 0 deletions crates/cli/tests/cli_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading