From b0513baefe9e77be8260946b213f5f01bbcac677 Mon Sep 17 00:00:00 2001 From: James Peter Date: Mon, 27 Oct 2025 14:24:57 +1000 Subject: [PATCH] feat(auto-drive): launch commit review after diagnostics --- .../src/auto_coordinator.rs | 49 ++ code-rs/code-auto-drive-core/src/lib.rs | 1 + code-rs/core/src/config_types.rs | 2 +- code-rs/core/src/git_worktree.rs | 126 +++++ code-rs/core/src/lib.rs | 5 + code-rs/protocol/src/protocol.rs | 3 + code-rs/tui/src/app.rs | 11 + code-rs/tui/src/app_event.rs | 9 + code-rs/tui/src/auto_review.rs | 212 ++++++++ .../bottom_pane/auto_drive_settings_view.rs | 27 +- code-rs/tui/src/chatwidget.rs | 495 ++++++++---------- code-rs/tui/src/lib.rs | 1 + 12 files changed, 666 insertions(+), 275 deletions(-) create mode 100644 code-rs/tui/src/auto_review.rs diff --git a/code-rs/code-auto-drive-core/src/auto_coordinator.rs b/code-rs/code-auto-drive-core/src/auto_coordinator.rs index d9c85e5eab0..b607d768866 100644 --- a/code-rs/code-auto-drive-core/src/auto_coordinator.rs +++ b/code-rs/code-auto-drive-core/src/auto_coordinator.rs @@ -101,6 +101,8 @@ pub enum AutoCoordinatorEvent { agents_timing: Option, agents: Vec, transcript: Vec, + turn_descriptor: Option, + review_commit: Option, }, Thinking { delta: String, @@ -151,10 +153,24 @@ struct PendingDecision { agents_timing: Option, agents: Vec, transcript: Vec, + turn_descriptor: Option, + review_commit: Option, } impl PendingDecision { fn into_event(self) -> AutoCoordinatorEvent { + let (turn_descriptor, review_commit) = if matches!(self.status, AutoCoordinatorStatus::Success) { + match self.turn_descriptor.clone() { + Some(descriptor) if descriptor.diagnostics_enabled => ( + Some(descriptor), + self.review_commit.clone(), + ), + _ => (None, None), + } + } else { + (None, None) + }; + AutoCoordinatorEvent::Decision { status: self.status, progress_past: self.progress_past, @@ -164,6 +180,8 @@ impl PendingDecision { agents_timing: self.agents_timing, agents: self.agents, transcript: self.transcript, + turn_descriptor, + review_commit, } } } @@ -262,9 +280,18 @@ pub struct TurnDescriptor { #[serde(default)] pub review_strategy: Option, #[serde(default)] + pub diagnostics_enabled: bool, + #[serde(default)] pub text_format_override: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct ReviewCommitDescriptor { + pub source: String, + #[serde(default)] + pub sha: Option, +} + impl Default for TurnDescriptor { fn default() -> Self { Self { @@ -273,6 +300,7 @@ impl Default for TurnDescriptor { complexity: None, agent_preferences: None, review_strategy: None, + diagnostics_enabled: false, text_format_override: None, } } @@ -294,6 +322,7 @@ mod tests { assert!(descriptor.complexity.is_none()); assert!(descriptor.agent_preferences.is_none()); assert!(descriptor.review_strategy.is_none()); + assert!(!descriptor.diagnostics_enabled); } #[test] @@ -598,6 +627,10 @@ struct CoordinatorDecisionNew { agents: Option, #[serde(default)] goal: Option, + #[serde(default)] + turn_descriptor: Option, + #[serde(default)] + review_commit: Option, } #[derive(Debug, Deserialize)] @@ -689,6 +722,8 @@ struct ParsedCoordinatorDecision { agents: Vec, goal: Option, response_items: Vec, + turn_descriptor: Option, + review_commit: Option, } #[derive(Debug, Clone)] @@ -870,6 +905,8 @@ fn run_auto_loop( mut agents_timing, mut agents, mut response_items, + turn_descriptor, + review_commit, }) => { retry_conversation.take(); if !include_agents { @@ -898,6 +935,8 @@ fn run_auto_loop( agents_timing, agents: agents.iter().map(agent_action_to_event).collect(), transcript: std::mem::take(&mut response_items), + turn_descriptor: None, + review_commit: None, }; event_tx.send(event); continue; @@ -912,6 +951,8 @@ fn run_auto_loop( agents_timing, agents: agents.iter().map(agent_action_to_event).collect(), transcript: response_items, + turn_descriptor, + review_commit, }; let should_stop = matches!(decision_event.status, AutoCoordinatorStatus::Failed); @@ -962,6 +1003,8 @@ fn run_auto_loop( agents_timing: None, agents: Vec::new(), transcript: Vec::new(), + turn_descriptor: None, + review_commit: None, }; event_tx.send(event); stopped = true; @@ -2048,6 +2091,8 @@ fn convert_decision_new( cli, agents: agent_payloads, goal, + turn_descriptor, + review_commit, } = decision; let progress_past = clean_optional(progress.past); @@ -2116,6 +2161,8 @@ fn convert_decision_new( agents: agent_actions, goal, response_items: Vec::new(), + turn_descriptor, + review_commit, }) } @@ -2161,6 +2208,8 @@ fn convert_decision_legacy( agents: Vec::new(), goal, response_items: Vec::new(), + turn_descriptor: None, + review_commit: None, }) } diff --git a/code-rs/code-auto-drive-core/src/lib.rs b/code-rs/code-auto-drive-core/src/lib.rs index c6095247429..b1479641a83 100644 --- a/code-rs/code-auto-drive-core/src/lib.rs +++ b/code-rs/code-auto-drive-core/src/lib.rs @@ -18,6 +18,7 @@ pub use auto_coordinator::{ AutoTurnAgentsAction, AutoTurnAgentsTiming, AutoTurnCliAction, + ReviewCommitDescriptor, TurnComplexity, TurnConfig, TurnDescriptor, diff --git a/code-rs/core/src/config_types.rs b/code-rs/core/src/config_types.rs index a61d5fed18d..70e3097d61a 100644 --- a/code-rs/core/src/config_types.rs +++ b/code-rs/core/src/config_types.rs @@ -724,7 +724,7 @@ impl Default for Tui { spinner: SpinnerSelection::default(), notifications: Notifications::default(), alternate_screen: true, - review_auto_resolve: false, + review_auto_resolve: true, } } } diff --git a/code-rs/core/src/git_worktree.rs b/code-rs/core/src/git_worktree.rs index 8259d0ba2f1..6f2f94edec5 100644 --- a/code-rs/core/src/git_worktree.rs +++ b/code-rs/core/src/git_worktree.rs @@ -2,6 +2,7 @@ use base64::Engine; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::fs as stdfs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use tokio::fs::OpenOptions; use tokio::process::Command; @@ -89,6 +90,8 @@ pub fn generate_branch_name_from_task(task: Option<&str>) -> String { pub const LOCAL_DEFAULT_REMOTE: &str = "local-default"; const BRANCH_METADATA_DIR: &str = "_branch-meta"; +const REVIEW_WORKTREES_DIR: &str = "reviews"; +const REVIEW_WORKTREE_PREFIX: &str = "review"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct BranchMetadata { @@ -102,6 +105,22 @@ pub struct BranchMetadata { pub remote_url: Option, } +#[derive(Debug, Clone)] +pub struct ReviewWorktreeCleanupToken { + git_root: PathBuf, + worktree_path: PathBuf, +} + +impl ReviewWorktreeCleanupToken { + pub fn git_root(&self) -> &Path { + &self.git_root + } + + pub fn worktree_path(&self) -> &Path { + &self.worktree_path + } +} + /// Resolve the git repository root (top-level) for the given cwd. pub async fn get_git_root_from(cwd: &Path) -> Result { let output = Command::new("git") @@ -195,6 +214,69 @@ pub async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<(PathBuf Ok((worktree_path, effective_branch)) } +pub async fn setup_review_worktree( + git_root: &Path, + revision: &str, + name_hint: Option<&str>, +) -> Result<(PathBuf, ReviewWorktreeCleanupToken), String> { + let repo_name = git_root + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("repo"); + + let mut base_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + base_dir = base_dir + .join(".code") + .join("working") + .join(repo_name) + .join(REVIEW_WORKTREES_DIR); + tokio::fs::create_dir_all(&base_dir) + .await + .map_err(|e| format!("Failed to create review worktree directory: {}", e))?; + + let slug = name_hint + .map(sanitize_ref_component) + .filter(|candidate| !candidate.is_empty()); + let timestamp = Utc::now().format("%Y%m%d-%H%M%S"); + let base_name = match slug { + Some(ref slug) => format!("{REVIEW_WORKTREE_PREFIX}-{slug}-{timestamp}"), + None => format!("{REVIEW_WORKTREE_PREFIX}-{timestamp}"), + }; + + let mut worktree_name = base_name.clone(); + let mut worktree_path = base_dir.join(&worktree_name); + let mut suffix = 1usize; + while worktree_path.exists() { + worktree_name = format!("{base_name}-{suffix}"); + worktree_path = base_dir.join(&worktree_name); + suffix += 1; + } + + let worktree_str = worktree_path + .to_str() + .ok_or_else(|| format!("Review worktree path not valid UTF-8: {}", worktree_path.display()))? + .to_string(); + + let output = Command::new("git") + .current_dir(git_root) + .args(["worktree", "add", "--detach", &worktree_str, revision]) + .output() + .await + .map_err(|e| format!("Failed to create review worktree: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("Failed to create review worktree: {stderr}")); + } + + record_worktree_in_session(git_root, &worktree_path).await; + let token = ReviewWorktreeCleanupToken { + git_root: git_root.to_path_buf(), + worktree_path: worktree_path.clone(), + }; + + Ok((worktree_path, token)) +} + /// Append the created worktree to a per-process session file so the TUI can /// clean it up on exit without touching worktrees from other processes. async fn record_worktree_in_session(git_root: &Path, worktree_path: &Path) { @@ -499,6 +581,50 @@ pub async fn copy_uncommitted_to_worktree(src_root: &Path, worktree_path: &Path) Ok(count) } +pub async fn cleanup_review_worktree(token: ReviewWorktreeCleanupToken) -> Result<(), String> { + cleanup_review_worktree_at(token.git_root(), token.worktree_path()).await +} + +pub async fn cleanup_review_worktree_at( + git_root: &Path, + worktree_path: &Path, +) -> Result<(), String> { + let worktree_str = worktree_path + .to_str() + .ok_or_else(|| format!("Review worktree path not valid UTF-8: {}", worktree_path.display()))? + .to_string(); + + let output = Command::new("git") + .current_dir(git_root) + .args(["worktree", "remove", "--force", &worktree_str]) + .output() + .await + .map_err(|e| format!("Failed to remove review worktree: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); + if !trimmed.is_empty() + && !trimmed.contains("not found") + && !trimmed.contains("No such file or directory") + && !trimmed.contains("not a worktree") + { + return Err(format!("Failed to remove review worktree: {trimmed}")); + } + } + + match tokio::fs::remove_dir_all(worktree_path).await { + Ok(_) => {} + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => return Err(format!("Failed to delete review worktree directory: {err}")), + } + + if let Some(parent) = worktree_path.parent() { + let _ = tokio::fs::remove_dir(parent).await; + } + + Ok(()) +} + /// Determine repository default branch. Prefers `origin/HEAD` symbolic ref, then local `main`/`master`. pub async fn detect_default_branch(cwd: &Path) -> Option { // Try origin/HEAD first diff --git a/code-rs/core/src/lib.rs b/code-rs/core/src/lib.rs index 6b2f13388fb..ac074a00892 100644 --- a/code-rs/core/src/lib.rs +++ b/code-rs/core/src/lib.rs @@ -132,3 +132,8 @@ pub use environment_context::ToolCandidate; pub use environment_context::TOOL_CANDIDATES; pub use openai_tools::{get_openai_tools, OpenAiTool, ToolsConfig}; pub use otel_init::*; +pub use git_worktree::cleanup_review_worktree; +pub use git_worktree::cleanup_review_worktree_at; +pub use git_worktree::copy_uncommitted_to_worktree; +pub use git_worktree::setup_review_worktree; +pub use git_worktree::ReviewWorktreeCleanupToken; diff --git a/code-rs/protocol/src/protocol.rs b/code-rs/protocol/src/protocol.rs index 489b196ab9a..d437d6dcac4 100644 --- a/code-rs/protocol/src/protocol.rs +++ b/code-rs/protocol/src/protocol.rs @@ -1112,6 +1112,9 @@ pub struct ReviewContextMetadata { #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] pub current_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub worktree_path: Option, } /// Structured review result produced by a child review session. diff --git a/code-rs/tui/src/app.rs b/code-rs/tui/src/app.rs index b64c65196b2..d31f7c57f17 100644 --- a/code-rs/tui/src/app.rs +++ b/code-rs/tui/src/app.rs @@ -1774,6 +1774,7 @@ impl App<'_> { cross_check_enabled, qa_automation_enabled, continue_mode, + review_auto_resolve, } => { if let AppState::Chat { widget } = &mut self.app_state { widget.apply_auto_drive_settings( @@ -1782,6 +1783,7 @@ impl App<'_> { cross_check_enabled, qa_automation_enabled, continue_mode, + review_auto_resolve, ); } } @@ -1792,6 +1794,11 @@ impl App<'_> { } } } + AppEvent::AutoReviewCommitReady { outcome } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.handle_auto_review_commit_ready(outcome); + } + } AppEvent::AgentsOverviewSelectionChanged { index } => { if let AppState::Chat { widget } = &mut self.app_state { widget.set_agents_overview_selection(index); @@ -1811,6 +1818,8 @@ impl App<'_> { agents_timing, agents, transcript, + turn_descriptor, + review_commit, } => { if let AppState::Chat { widget } = &mut self.app_state { widget.auto_handle_decision( @@ -1822,6 +1831,8 @@ impl App<'_> { agents_timing, agents, transcript, + turn_descriptor, + review_commit, ); } } diff --git a/code-rs/tui/src/app_event.rs b/code-rs/tui/src/app_event.rs index d89d995ff1d..688764b85b7 100644 --- a/code-rs/tui/src/app_event.rs +++ b/code-rs/tui/src/app_event.rs @@ -19,6 +19,7 @@ use code_git_tooling::{GhostCommit, GitToolingError}; use code_cloud_tasks_client::{ApplyOutcome, CloudTaskError, CreatedTask, TaskSummary}; use crate::app::ChatWidgetArgs; +use crate::auto_review::AutoReviewOutcome; use crate::chrome_launch::ChromeLaunchOption; use crate::slash_command::SlashCommand; use code_protocol::models::ResponseItem; @@ -83,6 +84,8 @@ pub(crate) use code_auto_drive_core::{ AutoTurnAgentsAction, AutoTurnAgentsTiming, AutoTurnCliAction, + ReviewCommitDescriptor, + TurnDescriptor, }; #[allow(clippy::large_enum_variant)] @@ -141,6 +144,8 @@ pub(crate) enum AppEvent { agents_timing: Option, agents: Vec, transcript: Vec, + turn_descriptor: Option, + review_commit: Option, }, AutoCoordinatorUserReply { user_response: Option, @@ -167,8 +172,12 @@ pub(crate) enum AppEvent { cross_check_enabled: bool, qa_automation_enabled: bool, continue_mode: AutoContinueMode, + review_auto_resolve: bool, }, + /// Commit review preparation finished; launch or skip accordingly. + AutoReviewCommitReady { outcome: AutoReviewOutcome }, + /// Dispatch a recognized slash command from the UI (composer) to the app /// layer so it can be handled centrally. Includes the full command text. DispatchCommand(SlashCommand, String), diff --git a/code-rs/tui/src/auto_review.rs b/code-rs/tui/src/auto_review.rs new file mode 100644 index 00000000000..7e423eb9dcc --- /dev/null +++ b/code-rs/tui/src/auto_review.rs @@ -0,0 +1,212 @@ +use std::path::{Path, PathBuf}; + +use tokio::process::Command; + +use code_core::git_worktree::{ + cleanup_review_worktree, + detect_default_branch, + get_git_root_from, + setup_review_worktree, + ReviewWorktreeCleanupToken, +}; +use code_git_tooling::{create_ghost_commit, CreateGhostCommitOptions, GhostCommit, GitToolingError}; + +/// Owned snapshot of the base ghost commit captured before a turn begins. +#[derive(Debug, Clone)] +pub struct BaseCommitSnapshot { + pub id: String, + pub parent: Option, +} + +impl BaseCommitSnapshot { + fn as_ghost(&self) -> GhostCommit { + GhostCommit::new(self.id.clone(), self.parent.clone()) + } +} + +/// Data required to launch a commit-scoped review. +#[derive(Debug)] +pub struct PreparedCommitReview { + pub commit_sha: String, + pub short_sha: String, + pub file_count: usize, + pub subject: Option, + pub worktree_path: PathBuf, + pub cleanup: ReviewWorktreeCleanupToken, + pub base_branch: Option, + pub current_branch: Option, +} + +#[derive(Debug)] +pub enum AutoReviewOutcome { + Skip { reason: String }, + Commit(PreparedCommitReview), + Error(String), +} + +/// Prepare a commit review by capturing a snapshot, diffing against the turn base, and +/// provisioning a detached worktree pointed at the resulting commit. +pub async fn prepare_commit_review( + repo_root: &Path, + base_commit: Option, + name_hint: Option<&str>, +) -> AutoReviewOutcome { + let git_root = match get_git_root_from(repo_root).await { + Ok(root) => root, + Err(err) => { + return AutoReviewOutcome::Skip { + reason: format!("Not a git repository: {err}"), + } + } + }; + + let base_ghost = base_commit.as_ref().map(BaseCommitSnapshot::as_ghost); + let base_ghost_ref = base_ghost.as_ref(); + + let final_commit = match capture_commit(repo_root, base_ghost_ref).await { + Ok(commit) => commit, + Err(err) => { + return AutoReviewOutcome::Error(format!("Failed to capture workspace snapshot: {err}")); + } + }; + + let diff_count = match count_changed_paths(&git_root, base_ghost_ref, &final_commit).await { + Ok(count) => count, + Err(err) => { + return AutoReviewOutcome::Error(format!("Failed to diff commit: {err}")); + } + }; + + if diff_count == 0 { + return AutoReviewOutcome::Skip { + reason: "Auto Drive turn produced no file changes".to_string(), + }; + } + + let (worktree_path, cleanup) = match setup_review_worktree(&git_root, final_commit.id(), name_hint).await { + Ok(value) => value, + Err(err) => { + return AutoReviewOutcome::Error(format!("Failed to provision review worktree: {err}")); + } + }; + + let subject = match read_commit_subject(&git_root, final_commit.id()).await { + Ok(value) => value, + Err(err) => { + let _ = cleanup_review_worktree(cleanup).await; + return AutoReviewOutcome::Error(format!("Failed to read commit subject: {err}")); + } + }; + + let base_branch = detect_default_branch(&git_root).await; + let current_branch = match read_current_branch(&git_root).await { + Ok(branch) => branch, + Err(_) => None, + }; + + let prepared = PreparedCommitReview { + commit_sha: final_commit.id().to_string(), + short_sha: final_commit.id()[..final_commit.id().len().min(8)].to_string(), + file_count: diff_count, + subject, + worktree_path, + cleanup, + base_branch, + current_branch, + }; + + AutoReviewOutcome::Commit(prepared) +} + +async fn capture_commit( + repo_root: &Path, + base_commit: Option<&GhostCommit>, +) -> Result { + let mut options = CreateGhostCommitOptions::new(repo_root).message("Auto Drive turn snapshot"); + if let Some(parent) = base_commit { + options = options.parent(parent.id()); + } + create_ghost_commit(&options) +} + +async fn count_changed_paths( + git_root: &Path, + base_commit: Option<&GhostCommit>, + final_commit: &GhostCommit, +) -> Result { + let mut args: Vec = Vec::new(); + if let Some(base) = base_commit { + args.extend([ + "diff".to_string(), + "--name-only".to_string(), + format!("{}..{}", base.id(), final_commit.id()), + ]); + } else { + args.extend([ + "diff-tree".to_string(), + "--no-commit-id".to_string(), + "--name-only".to_string(), + "-r".to_string(), + final_commit.id().to_string(), + ]); + } + + let output = Command::new("git") + .current_dir(git_root) + .args(&args) + .output() + .await + .map_err(|e| format!("git {:?} failed: {e}", args))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git {:?} failed: {}", args, stderr.trim())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .count()) +} + +async fn read_commit_subject(git_root: &Path, commit: &str) -> Result, String> { + let output = Command::new("git") + .current_dir(git_root) + .args(["show", "-s", "--format=%s", commit]) + .output() + .await + .map_err(|e| format!("git show failed: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.trim().to_string()); + } + + let subject = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if subject.is_empty() { + Ok(None) + } else { + Ok(Some(subject)) + } +} + +async fn read_current_branch(git_root: &Path) -> Result, String> { + let output = Command::new("git") + .current_dir(git_root) + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .await + .map_err(|e| format!("git rev-parse failed: {e}"))?; + + if !output.status.success() { + return Ok(None); + } + + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch == "HEAD" || branch.is_empty() { + Ok(None) + } else { + Ok(Some(branch)) + } +} diff --git a/code-rs/tui/src/bottom_pane/auto_drive_settings_view.rs b/code-rs/tui/src/bottom_pane/auto_drive_settings_view.rs index 897007893f3..36053670049 100644 --- a/code-rs/tui/src/bottom_pane/auto_drive_settings_view.rs +++ b/code-rs/tui/src/bottom_pane/auto_drive_settings_view.rs @@ -20,6 +20,7 @@ pub(crate) struct AutoDriveSettingsView { agents_enabled: bool, cross_check_enabled: bool, qa_automation_enabled: bool, + review_auto_resolve: bool, diagnostics_enabled: bool, continue_mode: AutoContinueMode, closing: bool, @@ -35,6 +36,7 @@ impl AutoDriveSettingsView { cross_check_enabled: bool, qa_automation_enabled: bool, continue_mode: AutoContinueMode, + review_auto_resolve: bool, ) -> Self { let diagnostics_enabled = qa_automation_enabled && (review_enabled || cross_check_enabled); @@ -45,6 +47,7 @@ impl AutoDriveSettingsView { agents_enabled, cross_check_enabled, qa_automation_enabled, + review_auto_resolve, diagnostics_enabled, continue_mode, closing: false, @@ -52,7 +55,7 @@ impl AutoDriveSettingsView { } fn option_count() -> usize { - 3 + 4 } fn send_update(&self) { @@ -62,6 +65,7 @@ impl AutoDriveSettingsView { cross_check_enabled: self.cross_check_enabled, qa_automation_enabled: self.qa_automation_enabled, continue_mode: self.continue_mode, + review_auto_resolve: self.review_auto_resolve, }); } @@ -93,7 +97,11 @@ impl AutoDriveSettingsView { self.set_diagnostics(next); self.send_update(); } - 2 => self.cycle_continue_mode(true), + 2 => { + self.review_auto_resolve = !self.review_auto_resolve; + self.send_update(); + } + 3 => self.cycle_continue_mode(true), _ => {} } } @@ -135,6 +143,10 @@ impl AutoDriveSettingsView { self.diagnostics_enabled, ), 2 => ( + "Review auto-resolve (automatically rerun fixes after review)", + self.review_auto_resolve, + ), + 3 => ( "Auto-continue delay", matches!(self.continue_mode, AutoContinueMode::Manual), ), @@ -151,14 +163,14 @@ impl AutoDriveSettingsView { let mut spans = vec![Span::styled(prefix, label_style)]; match index { - 0 | 1 => { + 0 | 1 | 2 => { let checkbox = if enabled { "[x]" } else { "[ ]" }; spans.push(Span::styled( format!("{checkbox} {label}"), label_style, )); } - 2 => { + 3 => { spans.push(Span::styled(label.to_string(), label_style)); spans.push(Span::raw(" ")); spans.push(Span::styled( @@ -179,6 +191,7 @@ impl AutoDriveSettingsView { lines.push(self.option_label(0)); lines.push(self.option_label(1)); lines.push(self.option_label(2)); + lines.push(self.option_label(3)); lines.push(Line::default()); let footer_style = Style::default().fg(colors::text_dim()); @@ -230,13 +243,13 @@ impl AutoDriveSettingsView { self.app_event_tx.send(AppEvent::RequestRedraw); } KeyCode::Left => { - if self.selected_index == 2 { + if self.selected_index == 3 { self.cycle_continue_mode(false); self.app_event_tx.send(AppEvent::RequestRedraw); } } KeyCode::Right => { - if self.selected_index == 2 { + if self.selected_index == 3 { self.cycle_continue_mode(true); self.app_event_tx.send(AppEvent::RequestRedraw); } @@ -306,7 +319,7 @@ impl<'a> BottomPaneView<'a> for AutoDriveSettingsView { } fn desired_height(&self, _width: u16) -> u16 { - 9 + 10 } fn render(&self, area: Rect, buf: &mut Buffer) { diff --git a/code-rs/tui/src/chatwidget.rs b/code-rs/tui/src/chatwidget.rs index a1bc96045e1..f99c92adca1 100644 --- a/code-rs/tui/src/chatwidget.rs +++ b/code-rs/tui/src/chatwidget.rs @@ -104,6 +104,7 @@ use code_auto_drive_core::{ AutoTurnAgentsTiming, AutoTurnCliAction, AutoTurnReviewState, + ReviewCommitDescriptor, AutoResolveState, AutoResolvePhase, AUTO_RESTART_MAX_ATTEMPTS, @@ -120,6 +121,7 @@ use self::rate_limit_refresh::start_rate_limit_refresh; use self::history_render::{ CachedLayout, HistoryRenderState, RenderRequest, RenderRequestKind, RenderSettings, VisibleCell, }; +use crate::auto_review::{self, AutoReviewOutcome, BaseCommitSnapshot, PreparedCommitReview}; use code_core::parse_command::ParsedCommand; use code_core::TextFormat; use code_core::protocol::AgentMessageDeltaEvent; @@ -151,6 +153,7 @@ use code_core::protocol::SessionConfiguredEvent; use code_core::protocol::Op; use code_core::protocol::ReviewOutputEvent; use code_core::protocol::{ReviewContextMetadata, ReviewRequest}; +use code_core::{cleanup_review_worktree, ReviewWorktreeCleanupToken}; use code_core::protocol::PatchApplyBeginEvent; use code_core::protocol::PatchApplyEndEvent; use code_core::protocol::TaskCompleteEvent; @@ -179,6 +182,7 @@ use crate::exec_command::strip_bash_lc_and_escape; #[cfg(feature = "code-fork")] use crate::tui_event_extensions::handle_browser_screenshot; use crate::chatwidget::message::UserMessage; +use tokio::spawn; pub(crate) const DOUBLE_ESC_HINT: &str = "undo timeline"; const AUTO_ESC_EXIT_HINT: &str = "Press Esc again to exit Auto Drive"; @@ -693,21 +697,6 @@ pub(crate) struct GhostState { queued_user_messages: VecDeque, } -#[cfg(any(test, feature = "test-helpers"))] -#[allow(dead_code)] -struct AutoReviewCommitScope { - commit: String, - file_count: usize, -} - -#[cfg(any(test, feature = "test-helpers"))] -#[allow(dead_code)] -enum AutoReviewOutcome { - Skip, - Workspace, - Commit(AutoReviewCommitScope), -} - #[cfg(test)] pub(super) type CaptureAutoTurnCommitStub = Box< dyn Fn(&'static str, Option) -> Result + Send + Sync, @@ -809,6 +798,9 @@ pub(crate) struct ChatWidget<'a> { // New: coordinator-provided hints for the next Auto turn pending_turn_descriptor: Option, pending_auto_turn_config: Option, + pending_review_commit: Option, + auto_review_cleanup: Option, + auto_review_pending: bool, overall_task_status: String, active_plan_title: Option, /// Runtime timing per-agent (by id) to improve visibility in the HUD @@ -3975,6 +3967,9 @@ impl ChatWidget<'_> { auto_resolve_state: None, pending_turn_descriptor: None, pending_auto_turn_config: None, + pending_review_commit: None, + auto_review_cleanup: None, + auto_review_pending: false, overall_task_status: "preparing".to_string(), active_plan_title: None, agent_runtime: HashMap::new(), @@ -4287,6 +4282,9 @@ impl ChatWidget<'_> { auto_resolve_state: None, pending_turn_descriptor: None, pending_auto_turn_config: None, + pending_review_commit: None, + auto_review_cleanup: None, + auto_review_pending: false, overall_task_status: "preparing".to_string(), active_plan_title: None, agent_runtime: HashMap::new(), @@ -9757,6 +9755,10 @@ impl ChatWidget<'_> { self.mark_needs_redraw(); self.flush_history_snapshot_if_needed(true); + if self.should_schedule_auto_commit_review() { + self.spawn_auto_commit_review(); + } + } EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, @@ -12194,6 +12196,7 @@ impl ChatWidget<'_> { let cross = self.auto_state.cross_check_enabled; let qa = self.auto_state.qa_automation_enabled; let mode = self.auto_state.continue_mode; + let review_auto_resolve = self.config.tui.review_auto_resolve; let view = AutoDriveSettingsView::new( self.app_event_tx.clone(), review, @@ -12201,6 +12204,7 @@ impl ChatWidget<'_> { cross, qa, mode, + review_auto_resolve, ); AutoDriveSettingsContent::new(view) } @@ -13161,6 +13165,7 @@ fi\n\ self.auto_state.set_phase(AutoRunPhase::AwaitingGoalEntry); self.auto_state.goal = None; self.auto_pending_goal_request = false; + self.auto_goal_bootstrap_done = false; let seed_intro = self.auto_state.take_intro_pending(); if seed_intro { @@ -13256,6 +13261,9 @@ fi\n\ continue_mode, reduced_motion, ); + self.auto_review_pending = false; + self.auto_review_cleanup = None; + self.pending_review_commit = None; self.config.auto_drive.cross_check_enabled = cross_check_enabled; self.config.auto_drive.qa_automation_enabled = qa_automation_enabled; let coordinator_events = { @@ -13271,6 +13279,8 @@ fi\n\ agents_timing, agents, transcript, + turn_descriptor, + review_commit, } => { app_event_tx.send(AppEvent::AutoCoordinatorDecision { status, @@ -13281,6 +13291,8 @@ fi\n\ agents_timing, agents, transcript, + turn_descriptor, + review_commit, }); } AutoCoordinatorEvent::Thinking { delta, summary_index } => { @@ -13414,6 +13426,7 @@ fi\n\ cross_check_enabled: bool, qa_automation_enabled: bool, continue_mode: AutoContinueMode, + review_auto_resolve: bool, ) { let mut changed = false; if self.auto_state.review_enabled != review_enabled { @@ -13437,6 +13450,15 @@ fi\n\ self.auto_apply_controller_effects(effects); changed = true; } + if self.config.tui.review_auto_resolve != review_auto_resolve { + self.config.tui.review_auto_resolve = review_auto_resolve; + changed = true; + if let Ok(home) = code_core::config::find_code_home() { + if let Err(err) = code_core::config::set_tui_review_auto_resolve(&home, review_auto_resolve) { + tracing::warn!("Failed to persist review auto resolve setting: {err}"); + } + } + } if !changed { return; @@ -13606,7 +13628,6 @@ fi\n\ handle.cancel(); } - self.pending_turn_descriptor = None; self.pending_auto_turn_config = None; let effects = self @@ -13625,6 +13646,8 @@ fi\n\ agents_timing: Option, agents: Vec, transcript: Vec, + turn_descriptor: Option, + review_commit: Option, ) { if !self.auto_state.is_active() { return; @@ -13666,12 +13689,25 @@ fi\n\ self.auto_state.current_display_is_summary; self.auto_state.on_resume_from_manual(); - self.pending_turn_descriptor = None; self.pending_auto_turn_config = None; let mut promoted_agents: Vec = Vec::new(); let continue_status = matches!(status, AutoCoordinatorStatus::Continue); + if let Some(descriptor) = turn_descriptor.clone() { + self.pending_turn_descriptor = Some(descriptor); + } else if !continue_status { + self.pending_turn_descriptor = None; + } + + match review_commit.clone() { + Some(commit) => self.pending_review_commit = Some(commit), + None if !continue_status => { + self.pending_review_commit = None; + } + _ => {} + } + let resolved_agents: Vec = agents .into_iter() .map(|mut action| { @@ -13827,6 +13863,160 @@ Have we met every part of this goal and is there no further work to do?"# self.request_redraw(); } + fn should_schedule_auto_commit_review(&self) -> bool { + if self.auto_review_pending { + return false; + } + if !self.auto_state.is_active() { + return false; + } + if !self.auto_state.qa_automation_enabled { + return false; + } + if !self.auto_state.review_enabled { + return false; + } + if self + .pending_review_commit + .as_ref() + .and_then(|desc| desc.sha.as_ref()) + .is_none() + { + return false; + } + matches!( + self.pending_review_commit + .as_ref() + .map(|desc| desc.source.as_str()), + Some("commit") + ) + } + + fn spawn_auto_commit_review(&mut self) { + let repo_root = self.config.cwd.clone(); + let base_snapshot = self + .auto_turn_review_state + .as_ref() + .and_then(|state| state.base_commit.as_ref()) + .map(|commit| BaseCommitSnapshot { + id: commit.id().to_string(), + parent: commit.parent().map(|p| p.to_string()), + }); + let name_hint = self + .pending_review_commit + .as_ref() + .and_then(|desc| desc.sha.clone()) + .or_else(|| self.auto_state.goal.clone()); + let app_event_tx = self.app_event_tx.clone(); + self.auto_review_pending = true; + + spawn(async move { + let outcome = auto_review::prepare_commit_review( + &repo_root, + base_snapshot, + name_hint.as_deref(), + ) + .await; + app_event_tx.send(AppEvent::AutoReviewCommitReady { outcome }); + }); + } + + pub(crate) fn handle_auto_review_commit_ready(&mut self, outcome: AutoReviewOutcome) { + self.auto_review_pending = false; + match outcome { + AutoReviewOutcome::Skip { reason } => { + self.pending_review_commit = None; + self.push_background_tail(format!("Auto Drive review skipped: {reason}")); + self.cleanup_active_review_worktree(); + } + AutoReviewOutcome::Error(err) => { + self.pending_review_commit = None; + self.push_background_tail(format!("Auto Drive review prep failed: {err}")); + tracing::warn!("auto_review_prep_failed" = %err); + self.cleanup_active_review_worktree(); + } + AutoReviewOutcome::Commit(prepared) => { + self.launch_commit_review(prepared); + } + } + self.request_redraw(); + } + + fn launch_commit_review(&mut self, prepared: PreparedCommitReview) { + let file_label = if prepared.file_count == 1 { + "1 file".to_string() + } else { + format!("{} files", prepared.file_count) + }; + let mut prompt = format!( + "Review commit {} generated during the latest Auto Drive turn. Highlight bugs, regressions, risky patterns, and missing tests before merge.", + prepared.commit_sha + ); + let mut hint = format!( + "auto turn changes — {} ({})", + prepared.short_sha, + file_label + ); + if let Some(subject) = prepared.subject.as_ref().filter(|s| !s.is_empty()) { + hint = format!("{hint} — {subject}"); + } + + if let Some(descriptor) = self.pending_turn_descriptor.as_ref() { + if let Some(strategy) = descriptor.review_strategy.as_ref() { + if let Some(custom_prompt) = strategy + .custom_prompt + .as_ref() + .and_then(|text| { + let trimmed = text.trim(); + (!trimmed.is_empty()).then_some(trimmed) + }) + { + prompt = custom_prompt.to_string(); + } + + if let Some(scope_hint) = strategy + .scope_hint + .as_ref() + .and_then(|text| { + let trimmed = text.trim(); + (!trimmed.is_empty()).then_some(trimmed) + }) + { + hint = scope_hint.to_string(); + } + } + } + + let metadata = ReviewContextMetadata { + scope: Some("commit".to_string()), + commit: Some(prepared.commit_sha.clone()), + base_branch: prepared.base_branch.clone(), + current_branch: prepared.current_branch.clone(), + worktree_path: Some(prepared.worktree_path.display().to_string()), + }; + + let preparation = format!("Preparing code review for commit {}", prepared.short_sha); + self.auto_state.on_begin_review(false); + self.auto_review_cleanup = Some(prepared.cleanup); + self.begin_review(prompt, hint, Some(preparation), Some(metadata)); + self.pending_review_commit = None; + self.auto_turn_review_state = None; + self.auto_card_add_action( + format!("Auto Drive prepared commit review for {}", prepared.short_sha), + AutoDriveActionKind::Info, + ); + } + + fn cleanup_active_review_worktree(&mut self) { + if let Some(token) = self.auto_review_cleanup.take() { + spawn(async move { + if let Err(err) = cleanup_review_worktree(token).await { + tracing::warn!("failed to cleanup review worktree: {err}"); + } + }); + } + } + fn schedule_auto_cli_prompt(&mut self, prompt_text: String) { self.schedule_auto_cli_prompt_with_override(prompt_text, None); } @@ -14105,102 +14295,6 @@ Have we met every part of this goal and is there no further work to do?"# self.auto_on_reasoning_delta(&delta, summary_index); } - #[cfg(any(test, feature = "test-helpers"))] - #[allow(dead_code)] - fn auto_handle_post_turn_review( - &mut self, - cfg: TurnConfig, - descriptor: Option<&TurnDescriptor>, - ) { - if !self.auto_state.review_enabled { - self.auto_turn_review_state = None; - return; - } - if cfg.read_only { - self.auto_turn_review_state = None; - return; - } - - match self.auto_prepare_commit_scope() { - AutoReviewOutcome::Skip => { - self.auto_turn_review_state = None; - if self.auto_state.awaiting_review() { - self.maybe_resume_auto_after_review(); - } - } - AutoReviewOutcome::Workspace => { - self.auto_turn_review_state = None; - self.auto_start_post_turn_review(None, descriptor); - } - AutoReviewOutcome::Commit(scope) => { - self.auto_turn_review_state = None; - self.auto_start_post_turn_review(Some(scope), descriptor); - } - } - } - - #[cfg(any(test, feature = "test-helpers"))] - #[allow(dead_code)] - fn auto_prepare_commit_scope(&mut self) -> AutoReviewOutcome { - let Some(state) = self.auto_turn_review_state.take() else { - return AutoReviewOutcome::Workspace; - }; - - let Some(base_commit) = state.base_commit else { - return AutoReviewOutcome::Workspace; - }; - - let final_commit = match self.capture_auto_turn_commit("auto turn change snapshot", Some(&base_commit)) { - Ok(commit) => commit, - Err(err) => { - tracing::warn!("failed to capture auto turn change snapshot: {err}"); - return AutoReviewOutcome::Workspace; - } - }; - - let diff_paths = match self.git_diff_name_only_between(base_commit.id(), final_commit.id()) { - Ok(paths) => paths, - Err(err) => { - tracing::warn!("failed to diff auto turn snapshots: {err}"); - return AutoReviewOutcome::Workspace; - } - }; - - if diff_paths.is_empty() { - self.push_background_tail("Auto review skipped: no file changes detected this turn.".to_string()); - return AutoReviewOutcome::Skip; - } - - AutoReviewOutcome::Commit(AutoReviewCommitScope { - commit: final_commit.id().to_string(), - file_count: diff_paths.len(), - }) - } - - #[cfg(any(test, feature = "test-helpers"))] - #[allow(dead_code)] - fn auto_turn_has_diff(&self) -> bool { - if self.worktree_has_uncommitted_changes().unwrap_or(false) { - return true; - } - - if let Some(base_commit) = self - .auto_turn_review_state - .as_ref() - .and_then(|state| state.base_commit.as_ref()) - { - if let Some(head) = self.current_head_commit_sha() { - if let Ok(paths) = self.git_diff_name_only_between(base_commit.id(), &head) { - if !paths.is_empty() { - return true; - } - } - } - } - - false - } - fn prepare_auto_turn_review_state(&mut self) { if !self.auto_state.is_active() || !self.auto_state.review_enabled { self.auto_turn_review_state = None; @@ -14248,31 +14342,6 @@ Have we met every part of this goal and is there no further work to do?"# create_ghost_commit(&options) } - #[cfg(any(test, feature = "test-helpers"))] - #[allow(dead_code)] - fn git_diff_name_only_between( - &self, - base_commit: &str, - head_commit: &str, - ) -> Result, String> { - #[cfg(test)] - if let Some(stub) = GIT_DIFF_NAME_ONLY_BETWEEN_STUB.lock().unwrap().as_ref() { - return stub(base_commit.to_string(), head_commit.to_string()); - } - self.run_git_command( - ["diff", "--name-only", base_commit, head_commit], - |stdout| { - let changes = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(|line| line.to_string()) - .collect(); - Ok(changes) - }, - ) - } - fn auto_submit_prompt(&mut self) { if !self.auto_state.is_active() { return; @@ -14558,6 +14627,9 @@ Have we met every part of this goal and is there no further work to do?"# self.next_cli_text_format = None; self.auto_pending_goal_request = false; self.auto_goal_bootstrap_done = false; + self.cleanup_active_review_worktree(); + self.auto_review_pending = false; + self.pending_review_commit = None; let effects = self .auto_state .stop_run(Instant::now(), message); @@ -14604,122 +14676,6 @@ Have we met every part of this goal and is there no further work to do?"# } } - #[cfg(any(test, feature = "test-helpers"))] - #[allow(dead_code)] - fn auto_start_post_turn_review( - &mut self, - scope: Option, - descriptor: Option<&TurnDescriptor>, - ) { - if !self.auto_state.review_enabled { - return; - } - let strategy = descriptor.and_then(|d| d.review_strategy.as_ref()); - let (mut prompt, mut hint, mut auto_metadata, mut review_metadata, preparation) = match scope { - Some(scope) => { - let commit_id = scope.commit; - let commit_for_prompt = commit_id.clone(); - let short_sha: String = commit_for_prompt.chars().take(8).collect(); - let file_label = if scope.file_count == 1 { - "1 file".to_string() - } else { - format!("{} files", scope.file_count) - }; - let prompt = format!( - "Review commit {} generated during the latest Auto Drive turn. Highlight bugs, regressions, risky patterns, and missing tests before merge.", - commit_for_prompt - ); - let hint = format!("auto turn changes — {} ({})", short_sha, file_label); - let preparation = format!("Preparing code review for commit {}", short_sha); - let review_metadata = Some(ReviewContextMetadata { - scope: Some("commit".to_string()), - commit: Some(commit_id), - ..Default::default() - }); - let auto_metadata = Some(ReviewContextMetadata { - scope: Some("workspace".to_string()), - ..Default::default() - }); - (prompt, hint, auto_metadata, review_metadata, preparation) - } - None => { - let prompt = "Review the current workspace changes and highlight bugs, regressions, risky patterns, and missing tests before merge.".to_string(); - let hint = "current workspace changes".to_string(); - let review_metadata = Some(ReviewContextMetadata { - scope: Some("workspace".to_string()), - ..Default::default() - }); - let preparation = "Preparing code review request...".to_string(); - ( - prompt, - hint, - review_metadata.clone(), - review_metadata, - preparation, - ) - } - }; - - if let Some(strategy) = strategy { - if let Some(custom_prompt) = strategy - .custom_prompt - .as_ref() - .and_then(|text| { - let trimmed = text.trim(); - (!trimmed.is_empty()).then_some(trimmed) - }) - { - prompt = custom_prompt.to_string(); - } - - if let Some(scope_hint) = strategy - .scope_hint - .as_ref() - .and_then(|text| { - let trimmed = text.trim(); - (!trimmed.is_empty()).then_some(trimmed) - }) - { - hint = scope_hint.to_string(); - - let apply_scope = |meta: &mut ReviewContextMetadata| { - meta.scope = Some(scope_hint.to_string()); - }; - - match review_metadata.as_mut() { - Some(meta) => apply_scope(meta), - None => { - review_metadata = Some(ReviewContextMetadata { - scope: Some(scope_hint.to_string()), - ..Default::default() - }); - } - } - - match auto_metadata.as_mut() { - Some(meta) => apply_scope(meta), - None => { - auto_metadata = Some(ReviewContextMetadata { - scope: Some(scope_hint.to_string()), - ..Default::default() - }); - } - } - } - } - - if self.config.tui.review_auto_resolve { - self.auto_resolve_state = Some(AutoResolveState::new( - prompt.clone(), - hint.clone(), - auto_metadata.clone(), - )); - } else { - self.auto_resolve_state = None; - } - self.begin_review(prompt, hint, Some(preparation), review_metadata); - } - fn auto_rebuild_live_ring(&mut self) { if !self.auto_state.is_active() { if self.auto_state.should_show_goal_entry() { @@ -22731,19 +22687,21 @@ mod tests { { let chat = harness.chat(); - chat.auto_handle_decision( - AutoCoordinatorStatus::Continue, - None, - None, - Some("Finish migrations".to_string()), - Some(AutoTurnCliAction { - prompt: "echo ready".to_string(), - context: None, - }), - None, - Vec::new(), - Vec::new(), - ); + chat.auto_handle_decision( + AutoCoordinatorStatus::Continue, + None, + None, + Some("Finish migrations".to_string()), + Some(AutoTurnCliAction { + prompt: "echo ready".to_string(), + context: None, + }), + None, + Vec::new(), + Vec::new(), + None, + None, + ); } let chat = harness.chat(); @@ -23223,6 +23181,8 @@ mod tests { models: None, }], Vec::new(), + None, + None, ); assert_eq!( @@ -24395,6 +24355,7 @@ impl ChatWidget<'_> { if self.is_review_flow_active() || self.auto_resolve_should_block_auto_resume() { return; } + self.cleanup_active_review_worktree(); self.auto_state.on_complete_review(); if !self.auto_state.should_bypass_coordinator_next_submit() { self.auto_send_conversation(); diff --git a/code-rs/tui/src/lib.rs b/code-rs/tui/src/lib.rs index c8005cd5d47..785d8059e5f 100644 --- a/code-rs/tui/src/lib.rs +++ b/code-rs/tui/src/lib.rs @@ -55,6 +55,7 @@ mod get_git_diff; mod glitch_animation; mod auto_drive_strings; mod auto_drive_style; +mod auto_review; mod header_wave; mod history_cell; mod history;