mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 17:59:43 +08:00
feat: add ecc2 completion summary notifications
This commit is contained in:
parent
a4d0a4fc14
commit
b45a6ca810
@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::notifications::DesktopNotificationConfig;
|
use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@ -48,6 +48,7 @@ pub struct Config {
|
|||||||
pub auto_create_worktrees: bool,
|
pub auto_create_worktrees: bool,
|
||||||
pub auto_merge_ready_worktrees: bool,
|
pub auto_merge_ready_worktrees: bool,
|
||||||
pub desktop_notifications: DesktopNotificationConfig,
|
pub desktop_notifications: DesktopNotificationConfig,
|
||||||
|
pub completion_summary_notifications: CompletionSummaryConfig,
|
||||||
pub cost_budget_usd: f64,
|
pub cost_budget_usd: f64,
|
||||||
pub token_budget: u64,
|
pub token_budget: u64,
|
||||||
pub budget_alert_thresholds: BudgetAlertThresholds,
|
pub budget_alert_thresholds: BudgetAlertThresholds,
|
||||||
@ -111,6 +112,7 @@ impl Default for Config {
|
|||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
auto_merge_ready_worktrees: false,
|
auto_merge_ready_worktrees: false,
|
||||||
desktop_notifications: DesktopNotificationConfig::default(),
|
desktop_notifications: DesktopNotificationConfig::default(),
|
||||||
|
completion_summary_notifications: CompletionSummaryConfig::default(),
|
||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS,
|
budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS,
|
||||||
@ -616,6 +618,24 @@ end_hour = 7
|
|||||||
assert_eq!(config.desktop_notifications.quiet_hours.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]
|
#[test]
|
||||||
fn invalid_budget_alert_thresholds_fall_back_to_defaults() {
|
fn invalid_budget_alert_thresholds_fall_back_to_defaults() {
|
||||||
let config: Config = toml::from_str(
|
let config: Config = toml::from_str(
|
||||||
@ -643,6 +663,8 @@ critical = 1.10
|
|||||||
config.auto_create_worktrees = false;
|
config.auto_create_worktrees = false;
|
||||||
config.auto_merge_ready_worktrees = true;
|
config.auto_merge_ready_worktrees = true;
|
||||||
config.desktop_notifications.session_completed = false;
|
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.enabled = true;
|
||||||
config.desktop_notifications.quiet_hours.start_hour = 21;
|
config.desktop_notifications.quiet_hours.start_hour = 21;
|
||||||
config.desktop_notifications.quiet_hours.end_hour = 7;
|
config.desktop_notifications.quiet_hours.end_hour = 7;
|
||||||
@ -666,6 +688,10 @@ critical = 1.10
|
|||||||
assert!(!loaded.auto_create_worktrees);
|
assert!(!loaded.auto_create_worktrees);
|
||||||
assert!(loaded.auto_merge_ready_worktrees);
|
assert!(loaded.auto_merge_ready_worktrees);
|
||||||
assert!(!loaded.desktop_notifications.session_completed);
|
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!(loaded.desktop_notifications.quiet_hours.enabled);
|
||||||
assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21);
|
assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21);
|
||||||
assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7);
|
assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7);
|
||||||
|
|||||||
@ -1634,7 +1634,11 @@ fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> Stri
|
|||||||
for entry in &report.blocked_entries {
|
for entry in &report.blocked_entries {
|
||||||
lines.push(format!(
|
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) {
|
for blocker in entry.blocked_by.iter().take(2) {
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
@ -2781,7 +2785,9 @@ mod tests {
|
|||||||
state: session::SessionState::Stopped,
|
state: session::SessionState::Stopped,
|
||||||
conflicts: vec!["README.md".to_string()],
|
conflicts: vec!["README.md".to_string()],
|
||||||
summary: "merge after alpha1234 to avoid branch conflicts".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,
|
blocker_patch_preview: None,
|
||||||
}],
|
}],
|
||||||
suggested_action: "merge after alpha1234".to_string(),
|
suggested_action: "merge after alpha1234".to_string(),
|
||||||
|
|||||||
@ -32,6 +32,22 @@ pub struct DesktopNotificationConfig {
|
|||||||
pub quiet_hours: QuietHoursConfig,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DesktopNotifier {
|
pub struct DesktopNotifier {
|
||||||
config: DesktopNotificationConfig,
|
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 {
|
impl DesktopNotifier {
|
||||||
pub fn new(config: DesktopNotificationConfig) -> Self {
|
pub fn new(config: DesktopNotificationConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@ -46,7 +46,16 @@ pub async fn create_session_with_grouping(
|
|||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let repo_root =
|
let repo_root =
|
||||||
std::env::current_dir().context("Failed to resolve current working directory")?;
|
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<Vec<Session>> {
|
pub fn list_sessions(db: &StateStore) -> Result<Vec<Session>> {
|
||||||
@ -219,7 +228,7 @@ pub async fn drain_inbox(
|
|||||||
use_worktree,
|
use_worktree,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&runner_program,
|
&runner_program,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -1037,7 +1046,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
|
|||||||
|
|
||||||
if matches!(
|
if matches!(
|
||||||
session.state,
|
session.state,
|
||||||
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale
|
SessionState::Pending
|
||||||
|
| SessionState::Running
|
||||||
|
| SessionState::Idle
|
||||||
|
| SessionState::Stale
|
||||||
) {
|
) {
|
||||||
blocked_by.push(MergeQueueBlocker {
|
blocked_by.push(MergeQueueBlocker {
|
||||||
session_id: session.id.clone(),
|
session_id: session.id.clone(),
|
||||||
@ -1085,10 +1097,7 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
|
|||||||
branch: blocker_worktree.branch.clone(),
|
branch: blocker_worktree.branch.clone(),
|
||||||
state: blocker.state.clone(),
|
state: blocker.state.clone(),
|
||||||
conflicts: conflict.conflicts,
|
conflicts: conflict.conflicts,
|
||||||
summary: format!(
|
summary: format!("merge after {} to avoid branch conflicts", blocker.id),
|
||||||
"merge after {} to avoid branch conflicts",
|
|
||||||
blocker.id
|
|
||||||
),
|
|
||||||
conflicting_patch_preview: conflict.right_patch_preview,
|
conflicting_patch_preview: conflict.right_patch_preview,
|
||||||
blocker_patch_preview: conflict.left_patch_preview,
|
blocker_patch_preview: conflict.left_patch_preview,
|
||||||
});
|
});
|
||||||
@ -1107,7 +1116,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
|
|||||||
|
|
||||||
let suggested_action = if let Some(position) = queue_position {
|
let suggested_action = if let Some(position) = queue_position {
|
||||||
format!("merge in queue order #{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
|
blocked_by
|
||||||
.first()
|
.first()
|
||||||
.map(|blocker| blocker.summary.clone())
|
.map(|blocker| blocker.summary.clone())
|
||||||
@ -1369,15 +1381,8 @@ async fn queue_session_in_dir_with_runner_program(
|
|||||||
runner_program: &Path,
|
runner_program: &Path,
|
||||||
grouping: SessionGrouping,
|
grouping: SessionGrouping,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let session = build_session_record(
|
let session =
|
||||||
db,
|
build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?;
|
||||||
task,
|
|
||||||
agent_type,
|
|
||||||
use_worktree,
|
|
||||||
cfg,
|
|
||||||
repo_root,
|
|
||||||
grouping,
|
|
||||||
)?;
|
|
||||||
db.insert_session(&session)?;
|
db.insert_session(&session)?;
|
||||||
|
|
||||||
if use_worktree && session.worktree.is_none() {
|
if use_worktree && session.worktree.is_none() {
|
||||||
@ -1523,7 +1528,10 @@ fn attached_worktree_count(db: &StateStore) -> Result<usize> {
|
|||||||
fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime<chrono::Utc>) {
|
fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime<chrono::Utc>) {
|
||||||
let active_rank = match session.state {
|
let active_rank = match session.state {
|
||||||
SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0,
|
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)
|
(active_rank, session.updated_at)
|
||||||
}
|
}
|
||||||
@ -2238,6 +2246,8 @@ mod tests {
|
|||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
auto_merge_ready_worktrees: false,
|
auto_merge_ready_worktrees: false,
|
||||||
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
||||||
|
completion_summary_notifications:
|
||||||
|
crate::notifications::CompletionSummaryConfig::default(),
|
||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS,
|
budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS,
|
||||||
@ -3534,7 +3544,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -3607,7 +3617,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -3820,7 +3830,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -3893,7 +3903,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,18 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
|||||||
|
|
||||||
if event::poll(Duration::from_millis(250))? {
|
if event::poll(Duration::from_millis(250))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
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() {
|
if dashboard.is_input_mode() {
|
||||||
match (key.modifiers, key.code) {
|
match (key.modifiers, key.code) {
|
||||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||||
|
|||||||
@ -3,11 +3,12 @@ use crossterm::event::KeyEvent;
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
widgets::{
|
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 regex::Regex;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
@ -52,6 +53,28 @@ struct ThemePalette {
|
|||||||
help_border: Color,
|
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<String>,
|
||||||
|
key_decisions: Vec<String>,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
struct TestRunSummary {
|
||||||
|
total: usize,
|
||||||
|
passed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Dashboard {
|
pub struct Dashboard {
|
||||||
db: StateStore,
|
db: StateStore,
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
@ -112,6 +135,8 @@ pub struct Dashboard {
|
|||||||
search_agent_filter: SearchAgentFilter,
|
search_agent_filter: SearchAgentFilter,
|
||||||
search_matches: Vec<SearchMatch>,
|
search_matches: Vec<SearchMatch>,
|
||||||
selected_search_match: usize,
|
selected_search_match: usize,
|
||||||
|
active_completion_popup: Option<SessionCompletionSummary>,
|
||||||
|
queued_completion_popups: VecDeque<SessionCompletionSummary>,
|
||||||
session_table_state: TableState,
|
session_table_state: TableState,
|
||||||
last_cost_metrics_signature: Option<(u64, u128)>,
|
last_cost_metrics_signature: Option<(u64, u128)>,
|
||||||
last_tool_activity_signature: Option<(u64, u128)>,
|
last_tool_activity_signature: Option<(u64, u128)>,
|
||||||
@ -296,6 +321,108 @@ struct TeamSummary {
|
|||||||
stopped: usize,
|
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 {
|
impl Dashboard {
|
||||||
pub fn new(db: StateStore, cfg: Config) -> Self {
|
pub fn new(db: StateStore, cfg: Config) -> Self {
|
||||||
Self::with_output_store(db, cfg, SessionOutputStore::default())
|
Self::with_output_store(db, cfg, SessionOutputStore::default())
|
||||||
@ -394,6 +521,8 @@ impl Dashboard {
|
|||||||
search_agent_filter: SearchAgentFilter::AllAgents,
|
search_agent_filter: SearchAgentFilter::AllAgents,
|
||||||
search_matches: Vec::new(),
|
search_matches: Vec::new(),
|
||||||
selected_search_match: 0,
|
selected_search_match: 0,
|
||||||
|
active_completion_popup: None,
|
||||||
|
queued_completion_popups: VecDeque::new(),
|
||||||
session_table_state,
|
session_table_state,
|
||||||
last_cost_metrics_signature: initial_cost_metrics_signature,
|
last_cost_metrics_signature: initial_cost_metrics_signature,
|
||||||
last_tool_activity_signature: initial_tool_activity_signature,
|
last_tool_activity_signature: initial_tool_activity_signature,
|
||||||
@ -403,6 +532,7 @@ impl Dashboard {
|
|||||||
};
|
};
|
||||||
sort_sessions_for_display(&mut dashboard.sessions);
|
sort_sessions_for_display(&mut dashboard.sessions);
|
||||||
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
|
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
|
||||||
|
dashboard.sync_approval_queue();
|
||||||
dashboard.sync_handoff_backlog_counts();
|
dashboard.sync_handoff_backlog_counts();
|
||||||
dashboard.sync_global_handoff_backlog();
|
dashboard.sync_global_handoff_backlog();
|
||||||
dashboard.sync_selected_output();
|
dashboard.sync_selected_output();
|
||||||
@ -444,6 +574,10 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.render_status_bar(frame, chunks[2]);
|
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) {
|
fn render_header(&self, frame: &mut Frame, area: Rect) {
|
||||||
@ -1045,7 +1179,9 @@ impl Dashboard {
|
|||||||
self.theme_label()
|
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 |")
|
format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |")
|
||||||
} else if let Some(input) = self.commit_input.as_ref() {
|
} else if let Some(input) = self.commit_input.as_ref() {
|
||||||
format!(" commit>{input}_ | [Enter] commit [Esc] cancel |")
|
format!(" commit>{input}_ | [Enter] commit [Esc] cancel |")
|
||||||
@ -1076,7 +1212,8 @@ impl Dashboard {
|
|||||||
String::new()
|
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.commit_input.is_some()
|
||||||
|| self.pr_input.is_some()
|
|| self.pr_input.is_some()
|
||||||
|| self.search_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) {
|
fn render_help(&self, frame: &mut Frame, area: Rect) {
|
||||||
let help = vec![
|
let help = vec![
|
||||||
"Keyboard Shortcuts:".to_string(),
|
"Keyboard Shortcuts:".to_string(),
|
||||||
@ -2697,6 +2859,16 @@ impl Dashboard {
|
|||||||
self.search_query.is_some()
|
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) {
|
pub fn begin_spawn_prompt(&mut self) {
|
||||||
if self.search_input.is_some() {
|
if self.search_input.is_some() {
|
||||||
self.set_operator_note(
|
self.set_operator_note(
|
||||||
@ -3401,10 +3573,11 @@ impl Dashboard {
|
|||||||
HashMap::new()
|
HashMap::new()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.sync_session_state_notifications();
|
self.sync_approval_queue();
|
||||||
self.sync_approval_notifications();
|
|
||||||
self.sync_handoff_backlog_counts();
|
self.sync_handoff_backlog_counts();
|
||||||
self.sync_worktree_health_by_session();
|
self.sync_worktree_health_by_session();
|
||||||
|
self.sync_session_state_notifications();
|
||||||
|
self.sync_approval_notifications();
|
||||||
self.sync_global_handoff_backlog();
|
self.sync_global_handoff_backlog();
|
||||||
self.sync_daemon_activity();
|
self.sync_daemon_activity();
|
||||||
self.sync_output_cache();
|
self.sync_output_cache();
|
||||||
@ -3480,6 +3653,8 @@ impl Dashboard {
|
|||||||
|
|
||||||
fn sync_session_state_notifications(&mut self) {
|
fn sync_session_state_notifications(&mut self) {
|
||||||
let mut next_states = HashMap::new();
|
let mut next_states = HashMap::new();
|
||||||
|
let mut completion_summaries = Vec::new();
|
||||||
|
let mut failed_notifications = Vec::new();
|
||||||
|
|
||||||
for session in &self.sessions {
|
for session in &self.sessions {
|
||||||
let previous_state = self.last_session_states.get(&session.id);
|
let previous_state = self.last_session_states.get(&session.id);
|
||||||
@ -3487,26 +3662,29 @@ impl Dashboard {
|
|||||||
if previous_state != &session.state {
|
if previous_state != &session.state {
|
||||||
match session.state {
|
match session.state {
|
||||||
SessionState::Completed => {
|
SessionState::Completed => {
|
||||||
self.notify_desktop(
|
if self.cfg.completion_summary_notifications.enabled {
|
||||||
NotificationEvent::SessionCompleted,
|
completion_summaries.push(self.build_completion_summary(session));
|
||||||
"ECC 2.0: Session completed",
|
} else if self.cfg.desktop_notifications.session_completed {
|
||||||
&format!(
|
self.notify_desktop(
|
||||||
"{} | {}",
|
NotificationEvent::SessionCompleted,
|
||||||
format_session_id(&session.id),
|
"ECC 2.0: Session completed",
|
||||||
truncate_for_dashboard(&session.task, 96)
|
&format!(
|
||||||
),
|
"{} | {}",
|
||||||
);
|
format_session_id(&session.id),
|
||||||
|
truncate_for_dashboard(&session.task, 96)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SessionState::Failed => {
|
SessionState::Failed => {
|
||||||
self.notify_desktop(
|
failed_notifications.push((
|
||||||
NotificationEvent::SessionFailed,
|
"ECC 2.0: Session failed".to_string(),
|
||||||
"ECC 2.0: Session failed",
|
format!(
|
||||||
&format!(
|
|
||||||
"{} | {}",
|
"{} | {}",
|
||||||
format_session_id(&session.id),
|
format_session_id(&session.id),
|
||||||
truncate_for_dashboard(&session.task, 96)
|
truncate_for_dashboard(&session.task, 96)
|
||||||
),
|
),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@ -3516,6 +3694,16 @@ impl Dashboard {
|
|||||||
next_states.insert(session.id.clone(), session.state.clone());
|
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;
|
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) {
|
fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) {
|
||||||
let _ = self.notifier.notify(event, title, body);
|
let _ = self.notifier.notify(event, title, body);
|
||||||
}
|
}
|
||||||
@ -6743,6 +7015,254 @@ fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec<String> {
|
|||||||
lines
|
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::<serde_json::Value>(&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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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 {
|
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
|
||||||
match action {
|
match action {
|
||||||
crate::session::FileActivityAction::Read => "read",
|
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]
|
#[test]
|
||||||
fn refresh_syncs_tool_activity_metrics_from_hook_file() {
|
fn refresh_syncs_tool_activity_metrics_from_hook_file() {
|
||||||
let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4()));
|
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_agent_filter: SearchAgentFilter::AllAgents,
|
||||||
search_matches: Vec::new(),
|
search_matches: Vec::new(),
|
||||||
selected_search_match: 0,
|
selected_search_match: 0,
|
||||||
|
active_completion_popup: None,
|
||||||
|
queued_completion_popups: VecDeque::new(),
|
||||||
session_table_state,
|
session_table_state,
|
||||||
last_cost_metrics_signature: None,
|
last_cost_metrics_signature: None,
|
||||||
last_tool_activity_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_create_worktrees: true,
|
||||||
auto_merge_ready_worktrees: false,
|
auto_merge_ready_worktrees: false,
|
||||||
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
||||||
|
completion_summary_notifications:
|
||||||
|
crate::notifications::CompletionSummaryConfig::default(),
|
||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS,
|
budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS,
|
||||||
|
|||||||
@ -349,7 +349,9 @@ pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result<String> {
|
|||||||
anyhow::bail!("git rev-parse failed: {stderr}");
|
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<String> {
|
pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result<String> {
|
||||||
@ -604,7 +606,9 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
||||||
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<MergeOutcome> {
|
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
||||||
@ -925,8 +929,12 @@ fn dependency_fingerprint(root: &Path, files: &[&str]) -> Result<String> {
|
|||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
for rel in files {
|
for rel in files {
|
||||||
let path = root.join(rel);
|
let path = root.join(rel);
|
||||||
let content = fs::read(&path)
|
let content = fs::read(&path).with_context(|| {
|
||||||
.with_context(|| format!("Failed to read dependency fingerprint input {}", path.display()))?;
|
format!(
|
||||||
|
"Failed to read dependency fingerprint input {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
hasher.update(rel.as_bytes());
|
hasher.update(rel.as_bytes());
|
||||||
hasher.update([0]);
|
hasher.update([0]);
|
||||||
hasher.update(&content);
|
hasher.update(&content);
|
||||||
@ -957,10 +965,8 @@ fn is_symlink_to(path: &Path, target: &Path) -> Result<bool> {
|
|||||||
fn remove_symlink(path: &Path) -> Result<()> {
|
fn remove_symlink(path: &Path) -> Result<()> {
|
||||||
match fs::remove_file(path) {
|
match fs::remove_file(path) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => {
|
Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => fs::remove_dir(path)
|
||||||
fs::remove_dir(path)
|
.with_context(|| format!("Failed to remove dependency cache link {}", path.display())),
|
||||||
.with_context(|| format!("Failed to remove dependency cache link {}", path.display()))
|
|
||||||
}
|
|
||||||
Err(error) => Err(error)
|
Err(error) => Err(error)
|
||||||
.with_context(|| format!("Failed to remove dependency cache link {}", path.display())),
|
.with_context(|| format!("Failed to remove dependency cache link {}", path.display())),
|
||||||
}
|
}
|
||||||
@ -1072,10 +1078,7 @@ fn parse_git_status_entry(line: &str) -> Option<GitStatusEntry> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
let conflicted = matches!(
|
let conflicted = matches!(
|
||||||
(index_status, worktree_status),
|
(index_status, worktree_status),
|
||||||
('U', _)
|
('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D')
|
||||||
| (_, 'U')
|
|
||||||
| ('A', 'A')
|
|
||||||
| ('D', 'D')
|
|
||||||
);
|
);
|
||||||
Some(GitStatusEntry {
|
Some(GitStatusEntry {
|
||||||
path: normalized_path,
|
path: normalized_path,
|
||||||
@ -1491,8 +1494,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> {
|
fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> {
|
||||||
let root = std::env::temp_dir()
|
let root = std::env::temp_dir().join(format!(
|
||||||
.join(format!("ecc2-worktree-branch-conflict-preview-{}", Uuid::new_v4()));
|
"ecc2-worktree-branch-conflict-preview-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
let repo = init_repo(&root)?;
|
let repo = init_repo(&root)?;
|
||||||
|
|
||||||
let left_dir = root.join("wt-left");
|
let left_dir = root.join("wt-left");
|
||||||
@ -1538,8 +1543,8 @@ mod tests {
|
|||||||
base_branch: "main".to_string(),
|
base_branch: "main".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let preview = branch_conflict_preview(&left, &right, 12)?
|
let preview =
|
||||||
.expect("expected branch conflict preview");
|
branch_conflict_preview(&left, &right, 12)?.expect("expected branch conflict preview");
|
||||||
assert_eq!(preview.conflicts, vec!["README.md".to_string()]);
|
assert_eq!(preview.conflicts, vec!["README.md".to_string()]);
|
||||||
assert!(preview
|
assert!(preview
|
||||||
.left_patch_preview
|
.left_patch_preview
|
||||||
@ -1622,7 +1627,10 @@ mod tests {
|
|||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(["log", "-1", "--pretty=%s"])
|
.args(["log", "-1", "--pretty=%s"])
|
||||||
.output()?;
|
.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);
|
let _ = fs::remove_dir_all(root);
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -1652,8 +1660,19 @@ mod tests {
|
|||||||
let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4()));
|
let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4()));
|
||||||
let repo = init_repo(&root)?;
|
let repo = init_repo(&root)?;
|
||||||
let remote = root.join("remote.git");
|
let remote = root.join("remote.git");
|
||||||
run_git(&root, &["init", "--bare", remote.to_str().expect("utf8 path")])?;
|
run_git(
|
||||||
run_git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf8 path")])?;
|
&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, &["push", "-u", "origin", "main"])?;
|
||||||
run_git(&repo, &["checkout", "-b", "feat/pr-test"])?;
|
run_git(&repo, &["checkout", "-b", "feat/pr-test"])?;
|
||||||
fs::write(repo.join("README.md"), "pr test\n")?;
|
fs::write(repo.join("README.md"), "pr test\n")?;
|
||||||
@ -1713,10 +1732,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_for_session_links_shared_node_modules_cache() -> Result<()> {
|
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)?;
|
let repo = init_repo(&root)?;
|
||||||
fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?;
|
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::create_dir_all(repo.join("node_modules"))?;
|
||||||
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
|
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
|
||||||
run_git(&repo, &["add", "package.json", "package-lock.json"])?;
|
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 worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?;
|
||||||
|
|
||||||
let node_modules = worktree.path.join("node_modules");
|
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"));
|
assert_eq!(fs::read_link(&node_modules)?, repo.join("node_modules"));
|
||||||
|
|
||||||
remove(&worktree)?;
|
remove(&worktree)?;
|
||||||
@ -1741,7 +1766,10 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4()));
|
std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4()));
|
||||||
let repo = init_repo(&root)?;
|
let repo = init_repo(&root)?;
|
||||||
fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?;
|
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::create_dir_all(repo.join("node_modules"))?;
|
||||||
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
|
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
|
||||||
run_git(&repo, &["add", "package.json", "package-lock.json"])?;
|
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 worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?;
|
||||||
|
|
||||||
let node_modules = worktree.path.join("node_modules");
|
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(
|
fs::write(
|
||||||
worktree.path.join("package-lock.json"),
|
worktree.path.join("package-lock.json"),
|
||||||
@ -1761,7 +1791,9 @@ mod tests {
|
|||||||
let applied = sync_shared_dependency_dirs(&worktree)?;
|
let applied = sync_shared_dependency_dirs(&worktree)?;
|
||||||
assert!(applied.is_empty());
|
assert!(applied.is_empty());
|
||||||
assert!(node_modules.is_dir());
|
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());
|
assert!(repo.join("node_modules/.cache-marker").exists());
|
||||||
|
|
||||||
remove(&worktree)?;
|
remove(&worktree)?;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user