From b45a6ca81027ee31dc991d64a574fb66382d0f4b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:59:24 -0700 Subject: [PATCH] feat: add ecc2 completion summary notifications --- ecc2/src/config/mod.rs | 28 +- ecc2/src/main.rs | 10 +- ecc2/src/notifications.rs | 43 +++ ecc2/src/session/manager.rs | 54 +-- ecc2/src/tui/app.rs | 12 + ecc2/src/tui/dashboard.rs | 669 ++++++++++++++++++++++++++++++++++-- ecc2/src/worktree/mod.rs | 82 +++-- 7 files changed, 828 insertions(+), 70 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 8259207e..2b0fbe6a 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use crate::notifications::DesktopNotificationConfig; +use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -48,6 +48,7 @@ pub struct Config { pub auto_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, pub desktop_notifications: DesktopNotificationConfig, + pub completion_summary_notifications: CompletionSummaryConfig, pub cost_budget_usd: f64, pub token_budget: u64, pub budget_alert_thresholds: BudgetAlertThresholds, @@ -111,6 +112,7 @@ impl Default for Config { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: DesktopNotificationConfig::default(), + completion_summary_notifications: CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, @@ -616,6 +618,24 @@ end_hour = 7 assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7); } + #[test] + fn completion_summary_notifications_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[completion_summary_notifications] +enabled = true +delivery = "desktop_and_tui_popup" +"#, + ) + .unwrap(); + + assert!(config.completion_summary_notifications.enabled); + assert_eq!( + config.completion_summary_notifications.delivery, + crate::notifications::CompletionSummaryDelivery::DesktopAndTuiPopup + ); + } + #[test] fn invalid_budget_alert_thresholds_fall_back_to_defaults() { let config: Config = toml::from_str( @@ -643,6 +663,8 @@ critical = 1.10 config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; config.desktop_notifications.session_completed = false; + config.completion_summary_notifications.delivery = + crate::notifications::CompletionSummaryDelivery::TuiPopup; config.desktop_notifications.quiet_hours.enabled = true; config.desktop_notifications.quiet_hours.start_hour = 21; config.desktop_notifications.quiet_hours.end_hour = 7; @@ -666,6 +688,10 @@ critical = 1.10 assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); assert!(!loaded.desktop_notifications.session_completed); + assert_eq!( + loaded.completion_summary_notifications.delivery, + crate::notifications::CompletionSummaryDelivery::TuiPopup + ); assert!(loaded.desktop_notifications.quiet_hours.enabled); assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21); assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7); diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 0f043382..8095cfca 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1634,7 +1634,11 @@ fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> Stri for entry in &report.blocked_entries { lines.push(format!( "- {} [{}] | {} / {} | {}", - entry.session_id, entry.branch, entry.project, entry.task_group, entry.suggested_action + entry.session_id, + entry.branch, + entry.project, + entry.task_group, + entry.suggested_action )); for blocker in entry.blocked_by.iter().take(2) { lines.push(format!( @@ -2781,7 +2785,9 @@ mod tests { state: session::SessionState::Stopped, conflicts: vec!["README.md".to_string()], summary: "merge after alpha1234 to avoid branch conflicts".to_string(), - conflicting_patch_preview: Some("--- Branch diff vs main ---\nREADME.md".to_string()), + conflicting_patch_preview: Some( + "--- Branch diff vs main ---\nREADME.md".to_string(), + ), blocker_patch_preview: None, }], suggested_action: "merge after alpha1234".to_string(), diff --git a/ecc2/src/notifications.rs b/ecc2/src/notifications.rs index c4c70711..e4238627 100644 --- a/ecc2/src/notifications.rs +++ b/ecc2/src/notifications.rs @@ -32,6 +32,22 @@ pub struct DesktopNotificationConfig { pub quiet_hours: QuietHoursConfig, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionSummaryDelivery { + #[default] + Desktop, + TuiPopup, + DesktopAndTuiPopup, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CompletionSummaryConfig { + pub enabled: bool, + pub delivery: CompletionSummaryDelivery, +} + #[derive(Debug, Clone)] pub struct DesktopNotifier { config: DesktopNotificationConfig, @@ -112,6 +128,33 @@ impl DesktopNotificationConfig { } } +impl Default for CompletionSummaryConfig { + fn default() -> Self { + Self { + enabled: true, + delivery: CompletionSummaryDelivery::Desktop, + } + } +} + +impl CompletionSummaryConfig { + pub fn desktop_enabled(&self) -> bool { + self.enabled + && matches!( + self.delivery, + CompletionSummaryDelivery::Desktop | CompletionSummaryDelivery::DesktopAndTuiPopup + ) + } + + pub fn popup_enabled(&self) -> bool { + self.enabled + && matches!( + self.delivery, + CompletionSummaryDelivery::TuiPopup | CompletionSummaryDelivery::DesktopAndTuiPopup + ) + } +} + impl DesktopNotifier { pub fn new(config: DesktopNotificationConfig) -> Self { Self { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index c80929d2..edbaa539 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -46,7 +46,16 @@ pub async fn create_session_with_grouping( ) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; - queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root, grouping).await + queue_session_in_dir( + db, + cfg, + task, + agent_type, + use_worktree, + &repo_root, + grouping, + ) + .await } pub fn list_sessions(db: &StateStore) -> Result> { @@ -219,7 +228,7 @@ pub async fn drain_inbox( use_worktree, &repo_root, &runner_program, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -1037,7 +1046,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result { if matches!( session.state, - SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale ) { blocked_by.push(MergeQueueBlocker { session_id: session.id.clone(), @@ -1085,10 +1097,7 @@ pub fn build_merge_queue(db: &StateStore) -> Result { branch: blocker_worktree.branch.clone(), state: blocker.state.clone(), conflicts: conflict.conflicts, - summary: format!( - "merge after {} to avoid branch conflicts", - blocker.id - ), + summary: format!("merge after {} to avoid branch conflicts", blocker.id), conflicting_patch_preview: conflict.right_patch_preview, blocker_patch_preview: conflict.left_patch_preview, }); @@ -1107,7 +1116,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result { let suggested_action = if let Some(position) = queue_position { format!("merge in queue order #{position}") - } else if blocked_by.iter().any(|blocker| blocker.session_id == session.id) { + } else if blocked_by + .iter() + .any(|blocker| blocker.session_id == session.id) + { blocked_by .first() .map(|blocker| blocker.summary.clone()) @@ -1369,15 +1381,8 @@ async fn queue_session_in_dir_with_runner_program( runner_program: &Path, grouping: SessionGrouping, ) -> Result { - let session = build_session_record( - db, - task, - agent_type, - use_worktree, - cfg, - repo_root, - grouping, - )?; + let session = + build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?; db.insert_session(&session)?; if use_worktree && session.worktree.is_none() { @@ -1523,7 +1528,10 @@ fn attached_worktree_count(db: &StateStore) -> Result { fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime) { let active_rank = match session.state { SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0, - SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale => 1, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale => 1, }; (active_rank, session.updated_at) } @@ -2238,6 +2246,8 @@ mod tests { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + completion_summary_notifications: + crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS, @@ -3534,7 +3544,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3607,7 +3617,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3820,7 +3830,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3893,7 +3903,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index a2ce9ab7..78da92b0 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -27,6 +27,18 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { + if dashboard.has_active_completion_popup() { + match (key.modifiers, key.code) { + (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (_, KeyCode::Esc) | (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => { + dashboard.dismiss_completion_popup(); + } + _ => {} + } + + continue; + } + if dashboard.is_input_mode() { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 60d86ff6..f2f5e8d2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -3,11 +3,12 @@ use crossterm::event::KeyEvent; use ratatui::{ prelude::*, widgets::{ - Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap, + Block, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, + Wrap, }, }; use regex::Regex; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::time::UNIX_EPOCH; use tokio::sync::broadcast; @@ -52,6 +53,28 @@ struct ThemePalette { help_border: Color, } +#[derive(Debug, Clone)] +struct SessionCompletionSummary { + session_id: String, + task: String, + state: SessionState, + files_changed: u32, + tokens_used: u64, + duration_secs: u64, + cost_usd: f64, + tests_run: usize, + tests_passed: usize, + recent_files: Vec, + key_decisions: Vec, + warnings: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TestRunSummary { + total: usize, + passed: usize, +} + pub struct Dashboard { db: StateStore, cfg: Config, @@ -112,6 +135,8 @@ pub struct Dashboard { search_agent_filter: SearchAgentFilter, search_matches: Vec, selected_search_match: usize, + active_completion_popup: Option, + queued_completion_popups: VecDeque, session_table_state: TableState, last_cost_metrics_signature: Option<(u64, u128)>, last_tool_activity_signature: Option<(u64, u128)>, @@ -296,6 +321,108 @@ struct TeamSummary { stopped: usize, } +impl SessionCompletionSummary { + fn title(&self) -> String { + match self.state { + SessionState::Completed => "ECC 2.0: Session completed".to_string(), + SessionState::Failed => "ECC 2.0: Session failed".to_string(), + _ => "ECC 2.0: Session summary".to_string(), + } + } + + fn subtitle(&self) -> String { + format!( + "{} | {}", + format_session_id(&self.session_id), + truncate_for_dashboard(&self.task, 88) + ) + } + + fn notification_body(&self) -> String { + let tests_line = if self.tests_run > 0 { + format!( + "Tests {} run / {} passed", + self.tests_run, self.tests_passed + ) + } else { + "Tests not detected".to_string() + }; + + let warnings_line = if self.warnings.is_empty() { + "Warnings none".to_string() + } else { + format!( + "Warnings {}", + truncate_for_dashboard(&self.warnings.join("; "), 88) + ) + }; + + [ + self.subtitle(), + format!( + "Files {} | Tokens {} | Duration {}", + self.files_changed, + format_token_count(self.tokens_used), + format_duration(self.duration_secs) + ), + tests_line, + warnings_line, + ] + .join("\n") + } + + fn popup_text(&self) -> String { + let mut lines = vec![ + self.subtitle(), + String::new(), + format!( + "Files {} | Tokens {} | Cost {} | Duration {}", + self.files_changed, + format_token_count(self.tokens_used), + format_currency(self.cost_usd), + format_duration(self.duration_secs) + ), + ]; + + if self.tests_run > 0 { + lines.push(format!( + "Tests {} run / {} passed", + self.tests_run, self.tests_passed + )); + } else { + lines.push("Tests not detected".to_string()); + } + + if !self.recent_files.is_empty() { + lines.push(String::new()); + lines.push("Recent files".to_string()); + for item in &self.recent_files { + lines.push(format!("- {item}")); + } + } + + if !self.key_decisions.is_empty() { + lines.push(String::new()); + lines.push("Key decisions".to_string()); + for item in &self.key_decisions { + lines.push(format!("- {item}")); + } + } + + if !self.warnings.is_empty() { + lines.push(String::new()); + lines.push("Warnings".to_string()); + for item in &self.warnings { + lines.push(format!("- {item}")); + } + } + + lines.push(String::new()); + lines.push("[Enter]/[Space]/[Esc] dismiss".to_string()); + lines.join("\n") + } +} + impl Dashboard { pub fn new(db: StateStore, cfg: Config) -> Self { Self::with_output_store(db, cfg, SessionOutputStore::default()) @@ -394,6 +521,8 @@ impl Dashboard { search_agent_filter: SearchAgentFilter::AllAgents, search_matches: Vec::new(), selected_search_match: 0, + active_completion_popup: None, + queued_completion_popups: VecDeque::new(), session_table_state, last_cost_metrics_signature: initial_cost_metrics_signature, last_tool_activity_signature: initial_tool_activity_signature, @@ -403,6 +532,7 @@ impl Dashboard { }; sort_sessions_for_display(&mut dashboard.sessions); dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); + dashboard.sync_approval_queue(); dashboard.sync_handoff_backlog_counts(); dashboard.sync_global_handoff_backlog(); dashboard.sync_selected_output(); @@ -444,6 +574,10 @@ impl Dashboard { } self.render_status_bar(frame, chunks[2]); + + if let Some(summary) = self.active_completion_popup.as_ref() { + self.render_completion_popup(frame, summary); + } } fn render_header(&self, frame: &mut Frame, area: Rect) { @@ -1045,7 +1179,9 @@ impl Dashboard { self.theme_label() ); - let search_prefix = if let Some(input) = self.spawn_input.as_ref() { + let search_prefix = if self.active_completion_popup.is_some() { + " completion summary | [Enter]/[Space]/[Esc] dismiss |".to_string() + } else if let Some(input) = self.spawn_input.as_ref() { format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |") } else if let Some(input) = self.commit_input.as_ref() { format!(" commit>{input}_ | [Enter] commit [Esc] cancel |") @@ -1076,7 +1212,8 @@ impl Dashboard { String::new() }; - let text = if self.spawn_input.is_some() + let text = if self.active_completion_popup.is_some() + || self.spawn_input.is_some() || self.commit_input.is_some() || self.pr_input.is_some() || self.search_input.is_some() @@ -1121,6 +1258,31 @@ impl Dashboard { ); } + fn render_completion_popup(&self, frame: &mut Frame, summary: &SessionCompletionSummary) { + let popup_area = centered_rect(72, 65, frame.area()); + if popup_area.is_empty() { + return; + } + + frame.render_widget(Clear, popup_area); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", summary.title())) + .border_style(self.pane_border_style(Pane::Output)); + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + if inner.is_empty() { + return; + } + + frame.render_widget( + Paragraph::new(summary.popup_text()) + .wrap(Wrap { trim: true }) + .scroll((0, 0)), + inner, + ); + } + fn render_help(&self, frame: &mut Frame, area: Rect) { let help = vec![ "Keyboard Shortcuts:".to_string(), @@ -2697,6 +2859,16 @@ impl Dashboard { self.search_query.is_some() } + pub fn has_active_completion_popup(&self) -> bool { + self.active_completion_popup.is_some() + } + + pub fn dismiss_completion_popup(&mut self) { + if self.active_completion_popup.take().is_some() { + self.active_completion_popup = self.queued_completion_popups.pop_front(); + } + } + pub fn begin_spawn_prompt(&mut self) { if self.search_input.is_some() { self.set_operator_note( @@ -3401,10 +3573,11 @@ impl Dashboard { HashMap::new() } }; - self.sync_session_state_notifications(); - self.sync_approval_notifications(); + self.sync_approval_queue(); self.sync_handoff_backlog_counts(); self.sync_worktree_health_by_session(); + self.sync_session_state_notifications(); + self.sync_approval_notifications(); self.sync_global_handoff_backlog(); self.sync_daemon_activity(); self.sync_output_cache(); @@ -3480,6 +3653,8 @@ impl Dashboard { fn sync_session_state_notifications(&mut self) { let mut next_states = HashMap::new(); + let mut completion_summaries = Vec::new(); + let mut failed_notifications = Vec::new(); for session in &self.sessions { let previous_state = self.last_session_states.get(&session.id); @@ -3487,26 +3662,29 @@ impl Dashboard { if previous_state != &session.state { match session.state { SessionState::Completed => { - self.notify_desktop( - NotificationEvent::SessionCompleted, - "ECC 2.0: Session completed", - &format!( - "{} | {}", - format_session_id(&session.id), - truncate_for_dashboard(&session.task, 96) - ), - ); + if self.cfg.completion_summary_notifications.enabled { + completion_summaries.push(self.build_completion_summary(session)); + } else if self.cfg.desktop_notifications.session_completed { + self.notify_desktop( + NotificationEvent::SessionCompleted, + "ECC 2.0: Session completed", + &format!( + "{} | {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + ); + } } SessionState::Failed => { - self.notify_desktop( - NotificationEvent::SessionFailed, - "ECC 2.0: Session failed", - &format!( + failed_notifications.push(( + "ECC 2.0: Session failed".to_string(), + format!( "{} | {}", format_session_id(&session.id), truncate_for_dashboard(&session.task, 96) ), - ); + )); } _ => {} } @@ -3516,6 +3694,16 @@ impl Dashboard { next_states.insert(session.id.clone(), session.state.clone()); } + for summary in completion_summaries { + self.deliver_completion_summary(summary); + } + + if self.cfg.desktop_notifications.session_failed { + for (title, body) in failed_notifications { + self.notify_desktop(NotificationEvent::SessionFailed, &title, &body); + } + } + self.last_session_states = next_states; } @@ -3554,6 +3742,90 @@ impl Dashboard { ); } + fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) { + if self.cfg.completion_summary_notifications.desktop_enabled() + && self.cfg.desktop_notifications.session_completed + { + self.notify_desktop( + NotificationEvent::SessionCompleted, + &summary.title(), + &summary.notification_body(), + ); + } + + if self.cfg.completion_summary_notifications.popup_enabled() { + if self.active_completion_popup.is_none() { + self.active_completion_popup = Some(summary); + } else { + self.queued_completion_popups.push_back(summary); + } + } + } + + fn build_completion_summary(&self, session: &Session) -> SessionCompletionSummary { + let file_activity = match self.db.list_file_activity(&session.id, 5) { + Ok(entries) => entries, + Err(error) => { + tracing::warn!( + "Failed to load file activity for completion summary {}: {error}", + session.id + ); + Vec::new() + } + }; + let tool_logs = match self.db.list_tool_logs_for_session(&session.id) { + Ok(entries) => entries, + Err(error) => { + tracing::warn!( + "Failed to load tool logs for completion summary {}: {error}", + session.id + ); + Vec::new() + } + }; + let overlaps = match self.db.list_file_overlaps(&session.id, 3) { + Ok(entries) => entries, + Err(error) => { + tracing::warn!( + "Failed to load file overlaps for completion summary {}: {error}", + session.id + ); + Vec::new() + } + }; + + let tests = summarize_test_runs(&tool_logs, session.state == SessionState::Completed); + let recent_files = recent_completion_files(&file_activity, session.metrics.files_changed); + let key_decisions = + summarize_completion_decisions(&tool_logs, &file_activity, &session.task); + let warnings = summarize_completion_warnings( + session, + &tool_logs, + &tests, + self.worktree_health_by_session.get(&session.id), + self.approval_queue_counts + .get(&session.id) + .copied() + .unwrap_or(0), + overlaps.len(), + ); + + SessionCompletionSummary { + session_id: session.id.clone(), + task: session.task.clone(), + state: session.state.clone(), + files_changed: session.metrics.files_changed, + tokens_used: session.metrics.tokens_used, + duration_secs: session.metrics.duration_secs, + cost_usd: session.metrics.cost_usd, + tests_run: tests.total, + tests_passed: tests.passed, + recent_files, + key_decisions, + warnings, + } + } + fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) { let _ = self.notifier.notify(event, title, body); } @@ -6743,6 +7015,254 @@ fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec { lines } +fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} + +fn summarize_test_runs( + tool_logs: &[ToolLogEntry], + assume_success_on_completion: bool, +) -> TestRunSummary { + let mut summary = TestRunSummary::default(); + + for entry in tool_logs { + if !tool_log_looks_like_test(entry) { + continue; + } + + summary.total += 1; + let failed = tool_log_looks_failed(entry); + let passed = tool_log_looks_passed(entry); + if !failed && (passed || assume_success_on_completion) { + summary.passed += 1; + } + } + + summary +} + +fn tool_log_looks_like_test(entry: &ToolLogEntry) -> bool { + let haystack = format!( + "{} {} {} {}", + entry.tool_name, + entry.input_summary, + extract_tool_command(entry), + entry.output_summary + ) + .to_ascii_lowercase(); + const TEST_MARKERS: &[&str] = &[ + "cargo test", + "npm test", + "pnpm test", + "pnpm exec vitest", + "pnpm exec playwright", + "yarn test", + "bun test", + "vitest", + "jest", + "pytest", + "go test", + "playwright test", + "cypress", + "rspec", + "phpunit", + "e2e", + ]; + + TEST_MARKERS.iter().any(|marker| haystack.contains(marker)) +} + +fn tool_log_looks_failed(entry: &ToolLogEntry) -> bool { + let haystack = format!( + "{} {} {} {}", + entry.tool_name, + entry.input_summary, + extract_tool_command(entry), + entry.output_summary + ) + .to_ascii_lowercase(); + const FAILURE_MARKERS: &[&str] = &[ + " fail", + "failed", + " error", + "panic", + "timed out", + "non-zero", + "exit code 1", + "exited with", + ]; + + FAILURE_MARKERS + .iter() + .any(|marker| haystack.contains(marker)) +} + +fn tool_log_looks_passed(entry: &ToolLogEntry) -> bool { + let haystack = format!( + "{} {} {} {}", + entry.tool_name, + entry.input_summary, + extract_tool_command(entry), + entry.output_summary + ) + .to_ascii_lowercase(); + const SUCCESS_MARKERS: &[&str] = &[" pass", "passed", " ok", "success", "green", "completed"]; + + SUCCESS_MARKERS + .iter() + .any(|marker| haystack.contains(marker)) +} + +fn extract_tool_command(entry: &ToolLogEntry) -> String { + let Ok(value) = serde_json::from_str::(&entry.input_params_json) else { + return String::new(); + }; + + value + .get("command") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .unwrap_or_default() +} + +fn recent_completion_files(file_activity: &[FileActivityEntry], files_changed: u32) -> Vec { + if !file_activity.is_empty() { + return file_activity + .iter() + .take(3) + .map(file_activity_summary) + .collect(); + } + + if files_changed > 0 { + return vec![format!("files touched {}", files_changed)]; + } + + Vec::new() +} + +fn summarize_completion_decisions( + tool_logs: &[ToolLogEntry], + file_activity: &[FileActivityEntry], + session_task: &str, +) -> Vec { + let mut seen = HashSet::new(); + let mut decisions = Vec::new(); + + for entry in tool_logs.iter().rev() { + let mut candidates = Vec::new(); + if !entry.trigger_summary.trim().is_empty() + && entry.trigger_summary.trim() != session_task.trim() + { + candidates.push(format!( + "why {}", + truncate_for_dashboard(&entry.trigger_summary, 72) + )); + } + + let action = if entry.tool_name.eq_ignore_ascii_case("Bash") { + truncate_for_dashboard(&extract_tool_command(entry), 72) + } else if !entry.output_summary.trim().is_empty() && entry.output_summary.trim() != "ok" { + truncate_for_dashboard(&entry.output_summary, 72) + } else { + truncate_for_dashboard(&entry.input_summary, 72) + }; + + if !action.trim().is_empty() { + candidates.push(action); + } + + for candidate in candidates { + let normalized = candidate.to_ascii_lowercase(); + if seen.insert(normalized) { + decisions.push(candidate); + } + if decisions.len() >= 3 { + return decisions; + } + } + } + + for entry in file_activity.iter().take(3) { + let candidate = file_activity_summary(entry); + let normalized = candidate.to_ascii_lowercase(); + if seen.insert(normalized) { + decisions.push(candidate); + } + if decisions.len() >= 3 { + break; + } + } + + decisions +} + +fn summarize_completion_warnings( + session: &Session, + tool_logs: &[ToolLogEntry], + tests: &TestRunSummary, + worktree_health: Option<&worktree::WorktreeHealth>, + approval_backlog: usize, + overlap_count: usize, +) -> Vec { + let mut warnings = Vec::new(); + let high_risk_tool_calls = tool_logs + .iter() + .filter(|entry| entry.risk_score >= Config::RISK_THRESHOLDS.review) + .count(); + + if session.metrics.files_changed > 0 && tests.total == 0 { + warnings.push("no test runs detected".to_string()); + } + if tests.total > tests.passed { + warnings.push(format!( + "{} detected test run(s) were not confirmed passed", + tests.total - tests.passed + )); + } + if high_risk_tool_calls > 0 { + warnings.push(format!( + "{high_risk_tool_calls} high-risk tool call(s) recorded" + )); + } + if approval_backlog > 0 { + warnings.push(format!( + "{approval_backlog} approval/conflict request(s) remained unread" + )); + } + if overlap_count > 0 { + warnings.push(format!( + "{overlap_count} potential file overlap(s) remained" + )); + } + match worktree_health { + Some(worktree::WorktreeHealth::Conflicted) => { + warnings.push("worktree still has unresolved conflicts".to_string()); + } + Some(worktree::WorktreeHealth::InProgress) => { + warnings.push("worktree still has unmerged changes".to_string()); + } + Some(worktree::WorktreeHealth::Clear) | None => {} + } + + warnings +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -9151,6 +9671,111 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn refresh_builds_completion_summary_popup_from_metrics_activity_and_logs() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-completion-popup-{}", Uuid::new_v4())); + fs::create_dir_all(root.join(".claude").join("metrics"))?; + + let mut cfg = build_config(&root.join(".claude")); + cfg.completion_summary_notifications.delivery = + crate::notifications::CompletionSummaryDelivery::TuiPopup; + cfg.desktop_notifications.session_completed = false; + + let db = StateStore::open(&cfg.db_path)?; + let mut session = sample_session( + "done-12345678", + "claude", + SessionState::Running, + Some("ecc/done"), + 384, + 95, + ); + session.task = "Finish session summary notifications".to_string(); + db.insert_session(&session)?; + + let metrics_path = cfg.tool_activity_metrics_path(); + fs::create_dir_all(metrics_path.parent().unwrap())?; + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"done-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ session summary notifications\",\"patch_preview\":\"+ session summary notifications\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n", + "{\"id\":\"evt-3\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"rm -rf build\",\"input_params_json\":\"{\\\"command\\\":\\\"rm -rf build\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:02:00Z\"}\n" + ), + )?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard + .db + .update_state("done-12345678", &SessionState::Completed)?; + + dashboard.refresh(); + + let popup = dashboard + .active_completion_popup + .as_ref() + .expect("completion summary popup"); + let popup_text = popup.popup_text(); + assert!(popup_text.contains("done-123")); + assert!(popup_text.contains("Tests 1 run / 1 passed")); + assert!(popup_text.contains("Recent files")); + assert!(popup_text.contains("create README.md")); + assert!(popup_text.contains("Warnings")); + assert!(popup_text.contains("high-risk tool call")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn dismiss_completion_popup_promotes_the_next_summary() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.active_completion_popup = Some(SessionCompletionSummary { + session_id: "sess-a".to_string(), + task: "First".to_string(), + state: SessionState::Completed, + files_changed: 1, + tokens_used: 10, + duration_secs: 5, + cost_usd: 0.01, + tests_run: 1, + tests_passed: 1, + recent_files: vec!["create README.md".to_string()], + key_decisions: vec!["cargo test -q".to_string()], + warnings: Vec::new(), + }); + dashboard + .queued_completion_popups + .push_back(SessionCompletionSummary { + session_id: "sess-b".to_string(), + task: "Second".to_string(), + state: SessionState::Completed, + files_changed: 2, + tokens_used: 20, + duration_secs: 8, + cost_usd: 0.02, + tests_run: 0, + tests_passed: 0, + recent_files: vec!["modify src/lib.rs".to_string()], + key_decisions: vec!["updated lib".to_string()], + warnings: vec!["no test runs detected".to_string()], + }); + + dashboard.dismiss_completion_popup(); + + assert_eq!( + dashboard + .active_completion_popup + .as_ref() + .map(|summary| summary.session_id.as_str()), + Some("sess-b") + ); + assert!(dashboard.queued_completion_popups.is_empty()); + + dashboard.dismiss_completion_popup(); + assert!(dashboard.active_completion_popup.is_none()); + } + #[test] fn refresh_syncs_tool_activity_metrics_from_hook_file() { let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4())); @@ -11284,6 +11909,8 @@ diff --git a/src/lib.rs b/src/lib.rs search_agent_filter: SearchAgentFilter::AllAgents, search_matches: Vec::new(), selected_search_match: 0, + active_completion_popup: None, + queued_completion_popups: VecDeque::new(), session_table_state, last_cost_metrics_signature: None, last_tool_activity_signature: None, @@ -11310,6 +11937,8 @@ diff --git a/src/lib.rs b/src/lib.rs auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + completion_summary_notifications: + crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 6703b01c..caab2466 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -349,7 +349,9 @@ pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result { anyhow::bail!("git rev-parse failed: {stderr}"); } - Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string()) + Ok(String::from_utf8_lossy(&rev_parse.stdout) + .trim() + .to_string()) } pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result { @@ -604,7 +606,9 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result { } pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result { - Ok(git_status_entries(worktree)?.iter().any(|entry| entry.staged)) + Ok(git_status_entries(worktree)? + .iter() + .any(|entry| entry.staged)) } pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { @@ -925,8 +929,12 @@ fn dependency_fingerprint(root: &Path, files: &[&str]) -> Result { let mut hasher = Sha256::new(); for rel in files { let path = root.join(rel); - let content = fs::read(&path) - .with_context(|| format!("Failed to read dependency fingerprint input {}", path.display()))?; + let content = fs::read(&path).with_context(|| { + format!( + "Failed to read dependency fingerprint input {}", + path.display() + ) + })?; hasher.update(rel.as_bytes()); hasher.update([0]); hasher.update(&content); @@ -957,10 +965,8 @@ fn is_symlink_to(path: &Path, target: &Path) -> Result { fn remove_symlink(path: &Path) -> Result<()> { match fs::remove_file(path) { Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => { - fs::remove_dir(path) - .with_context(|| format!("Failed to remove dependency cache link {}", path.display())) - } + Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => fs::remove_dir(path) + .with_context(|| format!("Failed to remove dependency cache link {}", path.display())), Err(error) => Err(error) .with_context(|| format!("Failed to remove dependency cache link {}", path.display())), } @@ -1072,10 +1078,7 @@ fn parse_git_status_entry(line: &str) -> Option { .to_string(); let conflicted = matches!( (index_status, worktree_status), - ('U', _) - | (_, 'U') - | ('A', 'A') - | ('D', 'D') + ('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D') ); Some(GitStatusEntry { path: normalized_path, @@ -1491,8 +1494,10 @@ mod tests { #[test] fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> { - let root = std::env::temp_dir() - .join(format!("ecc2-worktree-branch-conflict-preview-{}", Uuid::new_v4())); + let root = std::env::temp_dir().join(format!( + "ecc2-worktree-branch-conflict-preview-{}", + Uuid::new_v4() + )); let repo = init_repo(&root)?; let left_dir = root.join("wt-left"); @@ -1538,8 +1543,8 @@ mod tests { base_branch: "main".to_string(), }; - let preview = branch_conflict_preview(&left, &right, 12)? - .expect("expected branch conflict preview"); + let preview = + branch_conflict_preview(&left, &right, 12)?.expect("expected branch conflict preview"); assert_eq!(preview.conflicts, vec!["README.md".to_string()]); assert!(preview .left_patch_preview @@ -1622,7 +1627,10 @@ mod tests { .arg(&repo) .args(["log", "-1", "--pretty=%s"]) .output()?; - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "update readme"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "update readme" + ); let _ = fs::remove_dir_all(root); Ok(()) @@ -1652,8 +1660,19 @@ mod tests { let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4())); let repo = init_repo(&root)?; let remote = root.join("remote.git"); - run_git(&root, &["init", "--bare", remote.to_str().expect("utf8 path")])?; - run_git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf8 path")])?; + run_git( + &root, + &["init", "--bare", remote.to_str().expect("utf8 path")], + )?; + run_git( + &repo, + &[ + "remote", + "add", + "origin", + remote.to_str().expect("utf8 path"), + ], + )?; run_git(&repo, &["push", "-u", "origin", "main"])?; run_git(&repo, &["checkout", "-b", "feat/pr-test"])?; fs::write(repo.join("README.md"), "pr test\n")?; @@ -1713,10 +1732,14 @@ mod tests { #[test] fn create_for_session_links_shared_node_modules_cache() -> Result<()> { - let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); + let root = + std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); let repo = init_repo(&root)?; fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; - fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; + fs::write( + repo.join("package-lock.json"), + "{\n \"lockfileVersion\": 3\n}\n", + )?; fs::create_dir_all(repo.join("node_modules"))?; fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; run_git(&repo, &["add", "package.json", "package-lock.json"])?; @@ -1727,7 +1750,9 @@ mod tests { let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; let node_modules = worktree.path.join("node_modules"); - assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert!(fs::symlink_metadata(&node_modules)? + .file_type() + .is_symlink()); assert_eq!(fs::read_link(&node_modules)?, repo.join("node_modules")); remove(&worktree)?; @@ -1741,7 +1766,10 @@ mod tests { std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4())); let repo = init_repo(&root)?; fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; - fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; + fs::write( + repo.join("package-lock.json"), + "{\n \"lockfileVersion\": 3\n}\n", + )?; fs::create_dir_all(repo.join("node_modules"))?; fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; run_git(&repo, &["add", "package.json", "package-lock.json"])?; @@ -1752,7 +1780,9 @@ mod tests { let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; let node_modules = worktree.path.join("node_modules"); - assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert!(fs::symlink_metadata(&node_modules)? + .file_type() + .is_symlink()); fs::write( worktree.path.join("package-lock.json"), @@ -1761,7 +1791,9 @@ mod tests { let applied = sync_shared_dependency_dirs(&worktree)?; assert!(applied.is_empty()); assert!(node_modules.is_dir()); - assert!(!fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert!(!fs::symlink_metadata(&node_modules)? + .file_type() + .is_symlink()); assert!(repo.join("node_modules/.cache-marker").exists()); remove(&worktree)?;