diff --git a/src/main.rs b/src/main.rs index 7630b7b..2cfd955 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,7 @@ struct App { session_ratings_index: usize, session_results: Vec, acknowledged_unknown_window: Option, + acknowledged_unknown_window_handoff_pending: bool, last_tick_50ms: Instant, last_tick_1s: Instant, } @@ -223,6 +224,7 @@ impl App { session_ratings_index: 0, session_results: Vec::new(), acknowledged_unknown_window: None, + acknowledged_unknown_window_handoff_pending: false, last_tick_50ms: now, last_tick_1s: now, }) @@ -460,9 +462,16 @@ impl App { } fn tick_active_session(&mut self) -> Result<()> { + self.tick_active_session_with_snapshot(window::get_snapshot()) + } + + fn tick_active_session_with_snapshot( + &mut self, + window_snapshot: window::WindowSnapshot, + ) -> Result<()> { let elapsed = self.session_start.elapsed(); self.session_remaining = self.user_duration.saturating_sub(elapsed); - let window_snapshot = update_session_stats(self); + let window_snapshot = update_session_stats_with_snapshot(self, window_snapshot); let should_prompt = self.state == State::InProgress && self.session_controller.runtime_state() == RuntimeState::Active @@ -495,6 +504,7 @@ impl App { fn acknowledge_unknown_window(&mut self, window_snapshot: &window::WindowSnapshot) { self.acknowledged_unknown_window = Some(WindowIdentity::from(window_snapshot)); + self.acknowledged_unknown_window_handoff_pending = true; } fn is_unknown_window_acknowledged(&mut self, window_snapshot: &window::WindowSnapshot) -> bool { @@ -503,7 +513,14 @@ impl App { return true; } + if self.acknowledged_unknown_window_handoff_pending + && is_post_dismissal_app_foreground(window_snapshot) + { + return true; + } + self.acknowledged_unknown_window = None; + self.acknowledged_unknown_window_handoff_pending = false; false } @@ -628,6 +645,13 @@ fn should_prompt_for_unknown_window( !(title_matches || class_matches) } +fn is_post_dismissal_app_foreground(window_snapshot: &window::WindowSnapshot) -> bool { + window_snapshot + .title + .to_lowercase() + .contains(&constants::APP_TITLE.to_lowercase()) +} + fn usable_title_evidence(window_snapshot: &window::WindowSnapshot) -> bool { let title = window_snapshot.title.trim(); !title.is_empty() @@ -843,7 +867,13 @@ fn handle_key_press(app: &mut App, key_code: KeyCode) -> Result<()> { } fn update_session_stats(app: &mut App) -> window::WindowSnapshot { - let window_snapshot = window::get_snapshot(); + update_session_stats_with_snapshot(app, window::get_snapshot()) +} + +fn update_session_stats_with_snapshot( + app: &mut App, + window_snapshot: window::WindowSnapshot, +) -> window::WindowSnapshot { app.evidence_health = window_snapshot.health.clone(); let window_title = if app.state == State::Paused { constants::PAUSED.to_string() @@ -1424,7 +1454,8 @@ mod tests { assert_eq!(app.state, State::InProgress); assert!(app.is_unknown_window_acknowledged(&prompted_unknown)); - assert!(!app.is_unknown_window_acknowledged(&dismissal_foreground)); + assert!(app.is_unknown_window_acknowledged(&dismissal_foreground)); + assert!(app.is_unknown_window_acknowledged(&prompted_unknown)); let records = EventLog::open(&path).unwrap().records().unwrap(); let violation = records @@ -1451,6 +1482,78 @@ mod tests { let _ = fs::remove_file(path); } + #[test] + fn post_dismissal_app_foreground_does_not_prompt_or_clear_triggering_acknowledgement() { + let path = unique_event_log_path("violation-prompt-app-handoff"); + let mut app = App::new_with_event_log_path(&path).unwrap(); + let prompted_unknown = window::WindowSnapshot { + title: "Browser - unrelated".to_string(), + class: Some("firefox".to_string()), + health: antidrift::domain::EvidenceHealth::Available, + }; + let dismissal_foreground = window::WindowSnapshot { + title: "AntiDrift".to_string(), + class: Some("terminal".to_string()), + health: antidrift::domain::EvidenceHealth::Available, + }; + let different_unknown = window::WindowSnapshot { + title: "Chat - unrelated".to_string(), + class: Some("chat".to_string()), + health: antidrift::domain::EvidenceHealth::Available, + }; + + app.user_intention = "write tests".to_string(); + app.user_success_condition = "tests pass".to_string(); + app.user_duration_str = "1".to_string(); + app.to_in_progress().unwrap(); + + let commitment_id = app + .session_controller + .active_commitment() + .unwrap() + .id + .clone(); + let mut log = EventLog::open(&path).unwrap(); + log.append( + EventType::PolicyApplied, + RuntimeState::Active, + Some(commitment_id.clone()), + serde_json::json!({ + "schema_version": antidrift::domain::POLICY_SCHEMA_VERSION, + "policy_id": "policy-app-handoff-test", + "commitment_id": commitment_id, + "runtime_state": "active", + "enforcement_level": "warn", + "allowed_context": { + "window_classes": [], + "window_title_substrings": ["Editor"], + "domains": [], + "repos": [], + "commands": [], + }, + }), + ) + .unwrap(); + + let mut app = App::new_with_event_log_path(&path).unwrap(); + app.enter_violation_prompt(&prompted_unknown); + app.violation_dismissal_reason = "Needed to inspect docs".to_string(); + app.dismiss_violation_if_valid().unwrap(); + + app.tick_active_session_with_snapshot(dismissal_foreground) + .unwrap(); + + assert_eq!(app.state, State::InProgress); + assert!(app.is_unknown_window_acknowledged(&prompted_unknown)); + + app.tick_active_session_with_snapshot(different_unknown) + .unwrap(); + + assert_eq!(app.state, State::ViolationPrompt); + + let _ = fs::remove_file(path); + } + #[test] fn rating_completion_returns_controller_to_planning_for_next_session() { let path = unique_event_log_path("rating-completion");