use anyhow::Result; use serde::{Serialize, Serializer}; use shellexpand; use std::collections::HashMap; use std::io::{stdout, Write}; use std::path::Path; use std::rc::Rc; use std::time::{Duration, Instant}; // <--- Add this mod constants; use antidrift::{ context::{window_class_allowed, window_title_allowed}, domain::{EvidenceHealth, PolicySnapshot, RuntimeState}, session::SessionController, window, }; use ratatui::{ crossterm::{ event::{self, Event, KeyCode}, execute, terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle, }, ExecutableCommand, }, prelude::*, style::Color, widgets::*, }; #[derive(Debug, PartialEq)] enum State { InputIntention, InputSuccessCondition, InputDuration, InputTransitionReason, InputTransitionReturn, InputTransitionDuration, InProgress, Paused, ViolationPrompt, End, ShouldQuit, } fn serialize_duration(duration: &Duration, serializer: S) -> Result where S: Serializer, { serializer.serialize_u64(duration.as_secs()) } #[derive(Serialize)] struct SessionResult { intention: String, #[serde(serialize_with = "serialize_duration")] duration: Duration, session_ratings: Vec, rating: u8, rating_f64: f64, } impl SessionResult { fn rate(&mut self) { let mut rating = 0_f64; let total_duration = self .session_ratings .iter() .map(|r| r.duration) .fold(Duration::ZERO, |acc, dur| acc.saturating_add(dur)); for session_rating in &self.session_ratings { let ratio: f64 = session_rating.duration.as_secs_f64() / total_duration.as_secs_f64(); rating += (session_rating.rating as f64) * ratio; } self.rating_f64 = rating; if rating > 2.5 { self.rating = 3; } else if rating > 2.0 { self.rating = 2; } else if rating > 1.0 { self.rating = 1; } else { self.rating = 0; } } } #[derive(Clone, Serialize)] struct SessionRating { window_title: Rc, duration: Duration, rating: u8, } #[derive(Clone, Debug, PartialEq, Eq)] struct WindowIdentity { title: String, class: Option, } impl From<&window::WindowSnapshot> for WindowIdentity { fn from(snapshot: &window::WindowSnapshot) -> Self { Self { title: snapshot.title.clone(), class: snapshot.class.clone(), } } } #[derive(Clone, Debug, PartialEq, Eq)] struct EvidenceObservationKey { runtime_state: RuntimeState, commitment_id: Option, health: EvidenceHealth, } struct App { state: State, user_intention: String, user_success_condition: String, user_duration_str: String, user_duration: Duration, transition_reason: String, transition_return_target: String, transition_duration_str: String, violation_message: String, violation_window_snapshot: Option, violation_dismissal_reason: String, current_window_title: Rc, evidence_health: EvidenceHealth, session_controller: SessionController, current_window_time: Instant, session_start: Instant, session_stats: HashMap, Duration>, session_remaining: Duration, session_ratings: Vec, session_ratings_index: usize, session_results: Vec, acknowledged_unknown_window: Option, acknowledged_unknown_window_handoff_pending: bool, last_recorded_evidence: Option, last_tick_50ms: Instant, last_tick_1s: Instant, } fn save_session(session: &SessionResult) { let path = shellexpand::tilde(constants::HISTORY_FILE).to_string(); // 1. Open file in append mode, create if missing let file = std::fs::OpenOptions::new() .create(true) .append(true) .open(path); if let Ok(mut f) = file { // 2. Serialize to JSON if let Ok(json) = serde_json::to_string(session) { // 3. Write newline appended let _ = writeln!(f, "{}", json); } } } impl App { fn new() -> Result { let event_log_path = shellexpand::tilde(constants::EVENT_LOG_FILE).to_string(); Self::new_with_event_log_path(event_log_path) } fn new_with_event_log_path(event_log_path: impl AsRef) -> Result { let window_snapshot = window::get_snapshot(); let mut session_controller = SessionController::new(event_log_path)?; match session_controller.runtime_state() { RuntimeState::Locked => session_controller.enter_planning()?, _ => {} } let active_commitment = session_controller.active_commitment().cloned(); let state = match session_controller.runtime_state() { RuntimeState::Active if active_commitment.is_some() => State::InProgress, RuntimeState::Transition if active_commitment.is_some() => State::Paused, RuntimeState::Review => State::End, _ => State::InputIntention, }; let user_intention = active_commitment .as_ref() .map(|commitment| commitment.next_action.clone()) .unwrap_or_default(); let user_success_condition = active_commitment .as_ref() .map(|commitment| commitment.success_condition.clone()) .unwrap_or_default(); let user_duration = active_commitment .as_ref() .map(|commitment| Duration::from_secs(commitment.timebox_secs)) .unwrap_or(Duration::ZERO); let user_duration_str = active_commitment .as_ref() .map(|commitment| (commitment.timebox_secs / 60).to_string()) .unwrap_or_else(|| constants::DEFAULT_DURATION.to_string()); let restored_elapsed = session_controller .timing_summary()? .map(|summary| Duration::from_secs(summary.active_elapsed_secs)) .unwrap_or(Duration::ZERO); let restored_elapsed_capped = restored_elapsed.min(user_duration); let now = Instant::now(); let session_start = now.checked_sub(restored_elapsed_capped).unwrap_or(now); let session_remaining = user_duration.saturating_sub(restored_elapsed); let mut session_ratings = Vec::new(); if session_controller.runtime_state() == RuntimeState::Review { session_ratings.push(recoverable_review_rating()); } Ok(App { state, user_intention, user_success_condition, user_duration_str, user_duration, transition_reason: String::new(), transition_return_target: String::new(), transition_duration_str: "2".to_string(), violation_message: String::new(), violation_window_snapshot: None, violation_dismissal_reason: String::new(), current_window_title: window_snapshot.title.into(), evidence_health: window_snapshot.health, session_controller, current_window_time: now, session_start, session_stats: HashMap::new(), session_remaining, session_ratings, session_ratings_index: 0, session_results: Vec::new(), acknowledged_unknown_window: None, acknowledged_unknown_window_handoff_pending: false, last_recorded_evidence: None, last_tick_50ms: now, last_tick_1s: now, }) } fn handle_ticks(&mut self) -> Result<()> { if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) { self.tick_50ms()?; } if self.last_tick_1s.elapsed() >= Duration::from_secs(1) { self.tick_1s(); } Ok(()) } fn to_in_progress(&mut self) -> Result<()> { self.user_duration = parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO); if self.user_intention.trim().is_empty() { self.state = State::InputIntention; return Ok(()); } if self.user_success_condition.trim().is_empty() { self.state = State::InputSuccessCondition; return Ok(()); } if self.user_duration == Duration::ZERO { self.state = State::InputDuration; return Ok(()); } self.session_controller.start_manual_commitment( self.user_intention.clone(), self.user_success_condition.clone(), self.user_duration, )?; self.state = State::InProgress; self.current_window_time = Instant::now(); self.session_start = self.current_window_time; self.session_stats = HashMap::new(); self.session_ratings_index = 0; Ok(()) } fn pause(&mut self) -> Result<()> { self.transition_reason.clear(); self.transition_return_target = self.user_intention.clone(); self.transition_duration_str = "2".to_string(); self.state = State::InputTransitionReason; Ok(()) } fn start_transition_if_valid(&mut self) -> Result<()> { if self.transition_reason.trim().is_empty() { self.state = State::InputTransitionReason; return Ok(()); } if self.transition_return_target.trim().is_empty() { self.state = State::InputTransitionReturn; return Ok(()); } let expected_duration = parse_duration_minutes(&self.transition_duration_str).unwrap_or(Duration::ZERO); if expected_duration.is_zero() { self.state = State::InputTransitionDuration; return Ok(()); } self.session_controller.start_transition( self.transition_reason.clone(), self.transition_return_target.clone(), expected_duration, )?; self.state = State::Paused; Ok(()) } fn resume(&mut self) -> Result<()> { if self.session_controller.runtime_state() == RuntimeState::Transition { self.session_controller.return_from_transition()?; } self.state = State::InProgress; Ok(()) } fn enter_violation_prompt(&mut self, window_snapshot: &window::WindowSnapshot) { self.violation_message = format!("{}: {}", constants::VIOLATION_STATUS, window_snapshot.title); self.violation_window_snapshot = Some(window_snapshot.clone()); self.violation_dismissal_reason.clear(); self.state = State::ViolationPrompt; } fn dismiss_violation_if_valid(&mut self) -> Result<()> { if self.violation_dismissal_reason.trim().is_empty() { return Ok(()); } let violation_window_snapshot = self.violation_window_snapshot.clone(); let window_title = violation_window_snapshot .as_ref() .map(|snapshot| snapshot.title.clone()) .unwrap_or_else(|| self.current_window_title.as_ref().clone()); let evidence_health = violation_window_snapshot .as_ref() .map(|snapshot| snapshot.health.clone()) .unwrap_or_else(|| self.evidence_health.clone()); self.session_controller.record_violation( self.violation_dismissal_reason.clone(), serde_json::json!({ "message": self.violation_message, "window_title": window_title, "evidence_health": evidence_health, }), )?; if let Some(window_snapshot) = violation_window_snapshot.as_ref() { self.acknowledge_unknown_window(window_snapshot); } self.violation_window_snapshot = None; self.state = State::InProgress; Ok(()) } fn to_end(&mut self) -> Result<()> { match self.session_controller.runtime_state() { RuntimeState::Transition => { self.session_controller.return_from_transition()?; self.session_controller.complete_for_review()?; } RuntimeState::Active => { self.session_controller.complete_for_review()?; } _ => {} } self.state = State::End; self.session_ratings = session_stats_as_vec(&self.session_stats); self.ensure_recoverable_review_rating(); Ok(()) } fn ensure_recoverable_review_rating(&mut self) { if self.session_controller.runtime_state() == RuntimeState::Review && self.session_ratings.is_empty() { self.session_ratings.push(recoverable_review_rating()); } } fn cleanup(&self) { let path = shellexpand::tilde(constants::STATUS_FILE).to_string(); let _ = std::fs::remove_file(path); } fn write_status(&self) { let status = self.status_text(); let path = shellexpand::tilde(constants::STATUS_FILE).to_string(); if let Ok(mut file) = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&path) { let _ = file.write_all(status.as_bytes()); } } fn status_text(&self) -> String { match self.state { State::InputTransitionReason | State::InputTransitionReturn | State::InputTransitionDuration if self.session_controller.runtime_state() == RuntimeState::Active => { self.active_status_text() } State::InputIntention | State::InputSuccessCondition | State::InputDuration => { match self.session_controller.runtime_state() { RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), _ => constants::STATUS_CONFIGURE.to_string(), } } State::InputTransitionReason | State::InputTransitionReturn | State::InputTransitionDuration => match self.session_controller.runtime_state() { RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), _ => constants::STATUS_CONFIGURE.to_string(), }, State::InProgress => self.active_status_text(), State::Paused => format!( "{} - {}", constants::STATUS_PAUSED, duration_as_str(&self.session_remaining) ), State::ViolationPrompt => constants::VIOLATION_STATUS.to_string(), State::End => constants::STATUS_RATE_SESSION.to_string(), State::ShouldQuit => constants::STATUS_QUIT.to_string(), } } fn active_status_text(&self) -> String { format!( "🎯 {} - {}", self.user_intention, duration_as_str(&self.session_remaining) ) } fn tick_50ms(&mut self) -> Result<()> { match self.state { State::InputIntention | State::InputSuccessCondition | State::InputDuration => { self.user_duration = parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO); window::minimize_other(&constants::APP_TITLE); } State::InputTransitionReason | State::InputTransitionReturn | State::InputTransitionDuration | State::InProgress => self.tick_active_session()?, State::ShouldQuit => {} State::Paused => { window::minimize_other(&constants::APP_TITLE); update_session_stats(self)?; } State::ViolationPrompt => {} State::End => { window::minimize_other(&constants::APP_TITLE); } } self.last_tick_50ms = Instant::now(); Ok(()) } 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_with_snapshot(self, window_snapshot)?; let should_prompt = self.state == State::InProgress && self.session_controller.runtime_state() == RuntimeState::Active && !self.is_unknown_window_acknowledged(&window_snapshot) && self .session_controller .active_policy() .is_some_and(|policy| should_prompt_for_unknown_window(policy, &window_snapshot)); if should_prompt { self.enter_violation_prompt(&window_snapshot); return Ok(()); } if self.session_remaining.is_zero() { self.to_end()?; } Ok(()) } fn tick_1s(&mut self) { self.last_tick_1s = Instant::now(); self.write_status(); if self.state == State::Paused { self.user_duration = self.user_duration.saturating_add(Duration::from_secs(1)); } } 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 { let current = WindowIdentity::from(window_snapshot); if self.acknowledged_unknown_window.as_ref() == Some(¤t) { 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 } fn timeout(&self) -> Duration { Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed()) } fn complete_rating(&mut self) -> Result<()> { self.ensure_recoverable_review_rating(); let mut session_result = SessionResult { intention: self.user_intention.clone(), duration: self.session_start.elapsed(), session_ratings: self.session_ratings.clone(), rating: 0, rating_f64: 0.0, }; session_result.rate(); if self.session_controller.runtime_state() == RuntimeState::Review { self.session_controller .complete_review_and_return_to_planning( session_result.rating, session_result.rating_f64, session_result.session_ratings.len() as u32, )?; } self.state = State::InputIntention; self.session_ratings.clear(); save_session(&session_result); self.session_results.push(session_result); Ok(()) } fn record_evidence_if_needed(&mut self, health: &EvidenceHealth) -> Result<()> { if matches!(health, EvidenceHealth::Available) { self.last_recorded_evidence = None; return Ok(()); } let key = EvidenceObservationKey { runtime_state: self.session_controller.runtime_state(), commitment_id: self .session_controller .active_commitment() .map(|commitment| commitment.id.clone()), health: health.clone(), }; if self.last_recorded_evidence.as_ref() == Some(&key) { return Ok(()); } self.session_controller.record_evidence(health.clone())?; self.last_recorded_evidence = Some(key); Ok(()) } fn get_session_results(&self) -> Vec> { self.session_results .iter() .map(|r| { Line::from(Span::styled( format!("{} {}", duration_as_str(&r.duration), r.intention,), match r.rating { 2 => Style::new().fg(Color::LightYellow), 3 => Style::new().fg(Color::LightGreen), _ => Style::new().fg(Color::LightRed), }, )) }) .collect() } fn get_session_stats(&self) -> Vec> { let mut zero_encountered = if self.state != State::End { true } else { false }; self.session_ratings .iter() .map(|s| { Line::from(Span::styled( format!("{}: {}", duration_as_str(&s.duration), s.window_title), match s.rating { 0 if !zero_encountered => { zero_encountered = true; Style::new().fg(Color::LightBlue) } 0 if zero_encountered => Style::new(), 1 => Style::new().fg(Color::LightRed), 2 => Style::new().fg(Color::LightYellow), 3 => Style::new().fg(Color::LightGreen), _ => Style::new().fg(Color::LightBlue), }, )) }) .collect() } } fn duration_as_str(duration: &Duration) -> String { format!( "{:3}:{:02}", duration.as_secs() / 60, duration.as_secs() % 60 ) } fn parse_duration_minutes(input: &str) -> Option { let minutes = input.parse::().ok()?; if minutes == 0 { return None; } minutes.checked_mul(60).map(Duration::from_secs) } fn is_transition_input_state(state: &State) -> bool { matches!( state, State::InputTransitionReason | State::InputTransitionReturn | State::InputTransitionDuration ) } fn should_prompt_for_unknown_window( policy: &PolicySnapshot, window_snapshot: &window::WindowSnapshot, ) -> bool { let allowed_context = &policy.allowed_context; let title_policy = !allowed_context.window_title_substrings.is_empty(); let class_policy = !allowed_context.window_classes.is_empty(); if !title_policy && !class_policy { return false; } let title_usable = usable_title_evidence(window_snapshot); let class_usable = usable_class_evidence(window_snapshot); if (title_policy && !title_usable) || (class_policy && !class_usable) { return false; } let title_matches = title_policy && window_title_allowed(allowed_context, &window_snapshot.title); let class_matches = class_policy && window_snapshot .class .as_ref() .is_some_and(|class| window_class_allowed(allowed_context, class)); !(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() && title != "none" && !evidence_unavailable(&window_snapshot.health) && !degraded_evidence_mentions(&window_snapshot.health, "title unavailable") } fn usable_class_evidence(window_snapshot: &window::WindowSnapshot) -> bool { window_snapshot .class .as_deref() .is_some_and(|class| !class.trim().is_empty()) && !evidence_unavailable(&window_snapshot.health) && !degraded_evidence_mentions(&window_snapshot.health, "class unavailable") } fn evidence_unavailable(health: &EvidenceHealth) -> bool { matches!(health, EvidenceHealth::Unavailable(_)) } fn degraded_evidence_mentions(health: &EvidenceHealth, needle: &str) -> bool { match health { EvidenceHealth::Degraded(reason) => reason.to_lowercase().contains(needle), EvidenceHealth::Available | EvidenceHealth::Unavailable(_) => false, } } fn session_stats_as_vec(session_stats: &HashMap, Duration>) -> Vec { let mut stats: Vec<_> = session_stats .iter() .filter(|(_, duration)| duration.as_secs() > 30) .map(|(title, duration)| SessionRating { window_title: title.clone(), duration: duration.clone(), rating: 0, }) .collect(); stats.sort_by(|a, b| b.duration.cmp(&a.duration)); stats } fn recoverable_review_rating() -> SessionRating { SessionRating { window_title: Rc::new("Recovered review summary".to_string()), duration: Duration::from_secs(1), rating: 0, } } fn main() -> Result<()> { let result = run_app(); let raw_mode_result = disable_raw_mode(); let screen_result = stdout().execute(LeaveAlternateScreen).map(|_| ()); result?; raw_mode_result?; screen_result?; Ok(()) } fn run_app() -> Result<()> { let mut app = App::new()?; enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; execute!(stdout(), SetTitle(constants::APP_TITLE))?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let result = (|| -> Result<()> { while app.state != State::ShouldQuit { terminal.draw(|frame| ui(frame, &app))?; handle_events(&mut app)?; app.handle_ticks()?; } Ok(()) })(); app.cleanup(); result } fn handle_events(app: &mut App) -> Result<()> { if !event::poll(app.timeout())? { return Ok(()); } let Event::Key(key) = event::read()? else { return Ok(()); }; if key.kind != event::KeyEventKind::Press { return Ok(()); } handle_key_press(app, key.code) } fn handle_key_press(app: &mut App, key_code: KeyCode) -> Result<()> { if key_code == KeyCode::Esc && app.state != State::ViolationPrompt { if app.state == State::End && app.session_controller.runtime_state() == RuntimeState::Review { return Ok(()); } app.state = State::ShouldQuit; } match app.state { State::InputIntention => match key_code { KeyCode::Enter => app.state = State::InputSuccessCondition, KeyCode::Tab => app.state = State::InputSuccessCondition, KeyCode::Backspace => { let _ = app.user_intention.pop(); } KeyCode::Char(c) => { app.user_intention.push(c); } _ => {} }, State::InputSuccessCondition => match key_code { KeyCode::Enter => app.state = State::InputDuration, KeyCode::Tab => app.state = State::InputDuration, KeyCode::Backspace => { let _ = app.user_success_condition.pop(); } KeyCode::Char(c) => { app.user_success_condition.push(c); } _ => {} }, State::InputDuration => match key_code { KeyCode::Enter => app.to_in_progress()?, KeyCode::Tab => app.state = State::InputIntention, KeyCode::Backspace => { let _ = app.user_duration_str.pop(); } KeyCode::Char(c) if c.is_ascii_digit() => { app.user_duration_str.push(c); } _ => {} }, State::InputTransitionReason => match key_code { KeyCode::Enter => app.state = State::InputTransitionReturn, KeyCode::Tab => app.state = State::InputTransitionReturn, KeyCode::Backspace => { let _ = app.transition_reason.pop(); } KeyCode::Char(c) => { app.transition_reason.push(c); } _ => {} }, State::InputTransitionReturn => match key_code { KeyCode::Enter => app.state = State::InputTransitionDuration, KeyCode::Tab => app.state = State::InputTransitionDuration, KeyCode::Backspace => { let _ = app.transition_return_target.pop(); } KeyCode::Char(c) => { app.transition_return_target.push(c); } _ => {} }, State::InputTransitionDuration => match key_code { KeyCode::Enter => app.start_transition_if_valid()?, KeyCode::Tab => app.state = State::InputTransitionReason, KeyCode::Backspace => { let _ = app.transition_duration_str.pop(); } KeyCode::Char(c) if c.is_ascii_digit() => { app.transition_duration_str.push(c); } _ => {} }, State::InProgress => match key_code { KeyCode::Char('q') => { app.to_end()?; } KeyCode::Char('p') => { app.pause()?; } KeyCode::Char('a') => { app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60)); } KeyCode::Char('x') => { app.user_duration = app.user_duration.saturating_sub(Duration::from_secs(60)); } _ => {} }, State::Paused => { if key_code == KeyCode::Char('p') { app.resume()?; } } State::ViolationPrompt => match key_code { KeyCode::Enter => app.dismiss_violation_if_valid()?, KeyCode::Backspace => { let _ = app.violation_dismissal_reason.pop(); } KeyCode::Char(c) => { app.violation_dismissal_reason.push(c); } _ => {} }, State::ShouldQuit => (), State::End => { let code = match key_code { KeyCode::Char('1') => 1, KeyCode::Char('2') => 2, KeyCode::Char('3') => 3, _ => 0, }; if app.session_ratings_index < app.session_ratings.len() && code != 0 { app.session_ratings[app.session_ratings_index].rating = code; app.session_ratings_index += 1; } if app.session_ratings_index >= app.session_ratings.len() { app.complete_rating()?; } } } Ok(()) } fn update_session_stats(app: &mut App) -> Result { update_session_stats_with_snapshot(app, window::get_snapshot()) } fn update_session_stats_with_snapshot( app: &mut App, window_snapshot: window::WindowSnapshot, ) -> Result { app.evidence_health = window_snapshot.health.clone(); app.record_evidence_if_needed(&window_snapshot.health)?; let window_title = if app.state == State::Paused { constants::PAUSED.to_string() } else { window_snapshot.title.clone() }; let window_title = Rc::new(window_title); let delta = app.current_window_time.elapsed(); if app.current_window_title != window_title || (delta > Duration::from_secs(1)) { let entry = app .session_stats .entry(app.current_window_title.clone()) .or_insert_with(|| Duration::default()); *entry += app.current_window_time.elapsed(); app.current_window_time = Instant::now(); app.current_window_title = window_title; app.session_ratings = session_stats_as_vec(&app.session_stats); } Ok(window_snapshot) } fn ui(frame: &mut Frame, app: &App) { if app.state == State::ViolationPrompt { render_violation_prompt(frame, app); return; } let layout = Layout::vertical([ Constraint::Min(3), Constraint::Min(3), Constraint::Min(3), Constraint::Percentage(100), Constraint::Min(3), ]); let [layout_intention, layout_success_condition, layout_duration, layout_titles, layout_status] = layout.areas(frame.size()); let transition_input = is_transition_input_state(&app.state); let border_type_intention = if app.state == State::InputIntention || app.state == State::InputTransitionReason { BorderType::Thick } else { BorderType::Plain }; let input_intention = if transition_input { Line::from(Span::raw(&app.transition_reason)) } else { Line::from(Span::raw(&app.user_intention)) }; let intention_title = if transition_input { constants::TRANSITION_REASON_TITLE } else { constants::INTENTION_TITLE }; frame.render_widget( Paragraph::new(input_intention).block( Block::bordered() .border_type(border_type_intention) .title(intention_title), ), layout_intention, ); let border_type_success_condition = if app.state == State::InputSuccessCondition || app.state == State::InputTransitionReturn { BorderType::Thick } else { BorderType::Plain }; let input_success_condition = if transition_input { Line::from(Span::raw(&app.transition_return_target)) } else { Line::from(Span::raw(&app.user_success_condition)) }; let success_condition_title = if transition_input { constants::TRANSITION_RETURN_TITLE } else { constants::SUCCESS_CONDITION_TITLE }; frame.render_widget( Paragraph::new(input_success_condition).block( Block::bordered() .border_type(border_type_success_condition) .title(success_condition_title), ), layout_success_condition, ); let input_duration: Vec = match app.state { State::InProgress | State::Paused => { let s = duration_as_str(&app.session_remaining); vec![Line::from(Span::raw(s))] } State::InputTransitionReason | State::InputTransitionReturn | State::InputTransitionDuration => { vec![Line::from(Span::raw(&app.transition_duration_str))] } _ => { vec![Line::from(Span::raw(&app.user_duration_str))] } }; let border_type_duration = if app.state == State::InputDuration || app.state == State::InputTransitionDuration { BorderType::Thick } else { BorderType::Plain }; let duration_title = if transition_input { constants::TRANSITION_DURATION_TITLE } else { constants::DURATION_TITLE }; frame.render_widget( Paragraph::new(input_duration).block( Block::bordered() .border_type(border_type_duration) .title(duration_title), ), layout_duration, ); if matches!( app.state, State::InputIntention | State::InputSuccessCondition | State::InputDuration ) { let results = app.get_session_results(); frame.render_widget( Paragraph::new(results) .block(Block::bordered().title(constants::PREVIOUS_SESSIONS_TITLE)), layout_titles, ); } else { let stats = app.get_session_stats(); frame.render_widget( Paragraph::new(stats).block(Block::bordered().title(constants::SESSION_STATS_TITLE)), layout_titles, ); } let mut spans: Vec = Vec::new(); if transition_input { if app.transition_reason.trim().is_empty() { let span = Span::styled( constants::PROVIDE_TRANSITION_REASON, Style::new().fg(Color::LightRed), ); spans.push(span); } if app.transition_return_target.trim().is_empty() { let span = Span::styled( constants::PROVIDE_TRANSITION_RETURN, Style::new().fg(Color::LightRed), ); spans.push(span); } if parse_duration_minutes(&app.transition_duration_str).is_none() { let span = Span::styled( constants::PROVIDE_VALID_DURATION, Style::new().fg(Color::LightRed), ); spans.push(span); } } else if app.user_intention.len() == 0 { let span = Span::styled( constants::PROVIDE_INTENTION, Style::new().fg(Color::LightRed), ); spans.push(span); } if app.user_success_condition.trim().is_empty() { let span = Span::styled( constants::ENTER_SUCCESS_CONDITION, Style::new().fg(Color::LightRed), ); spans.push(span); } if !transition_input && parse_duration_minutes(&app.user_duration_str).is_none() { let span = Span::styled( constants::PROVIDE_VALID_DURATION, Style::new().fg(Color::LightRed), ); spans.push(span); } match app.state { State::InputIntention | State::InputSuccessCondition | State::InputDuration => { if spans.len() == 0 { let span = Span::styled( constants::READY_TO_START, Style::new().fg(Color::LightGreen), ); spans.push(span); } } State::InputTransitionReason | State::InputTransitionReturn | State::InputTransitionDuration => {} State::InProgress => { let span = Span::styled( constants::SESSION_IN_PROGRESS, Style::new().fg(Color::LightGreen), ); spans.push(span); } State::Paused => { let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue)); spans.push(span); } State::ViolationPrompt => {} State::ShouldQuit => {} State::End => { let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue)); spans.push(span); } } if let Some(text) = evidence_status_text(&app.evidence_health) { if !spans.is_empty() { spans.push(Span::raw(" | ")); } spans.push(Span::styled( text, Style::new().fg(evidence_status_color(&app.evidence_health)), )); } let input_status: Vec = vec![Line::from(spans)]; frame.render_widget( Paragraph::new(input_status).block(Block::bordered().title(constants::STATUS_TITLE)), layout_status, ); } fn evidence_status_text(health: &EvidenceHealth) -> Option { match health { EvidenceHealth::Available => None, EvidenceHealth::Degraded(reason) => Some(format!("Evidence degraded: {}", reason)), EvidenceHealth::Unavailable(reason) => Some(format!("Evidence unavailable: {}", reason)), } } fn evidence_status_color(health: &EvidenceHealth) -> Color { match health { EvidenceHealth::Available => Color::Reset, EvidenceHealth::Degraded(_) => Color::LightYellow, EvidenceHealth::Unavailable(_) => Color::LightRed, } } fn render_violation_prompt(frame: &mut Frame, app: &App) { let layout = Layout::vertical([ Constraint::Min(5), Constraint::Length(3), Constraint::Length(3), ]); let [layout_message, layout_reason, layout_status] = layout.areas(frame.size()); frame.render_widget( Paragraph::new(Line::from(Span::raw(&app.violation_message))).block( Block::bordered() .border_type(BorderType::Thick) .title(constants::VIOLATION_TITLE), ), layout_message, ); frame.render_widget( Paragraph::new(Line::from(Span::raw(&app.violation_dismissal_reason))).block( Block::bordered() .border_type(BorderType::Thick) .title(constants::VIOLATION_REASON_TITLE), ), layout_reason, ); frame.render_widget( Paragraph::new(Line::from(Span::styled( constants::VIOLATION_STATUS, Style::new().fg(Color::LightRed), ))) .block(Block::bordered().title(constants::STATUS_TITLE)), layout_status, ); } #[cfg(test)] mod tests { use super::*; use antidrift::domain::{AllowedContext, EnforcementLevel, PolicySnapshot, RuntimeState}; use antidrift::event_log::{EventLog, EventType}; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn unique_event_log_path(test_name: &str) -> std::path::PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); std::env::temp_dir().join(format!( "antidrift-{test_name}-{}-{}.jsonl", std::process::id(), nanos )) } #[test] fn starting_session_requires_success_condition_and_logs_commitment() { let path = unique_event_log_path("success-condition"); let mut app = App::new_with_event_log_path(&path).unwrap(); app.user_intention = "write tests".to_string(); app.user_duration_str = "1".to_string(); app.to_in_progress().unwrap(); assert_eq!(app.state, State::InputSuccessCondition); assert_eq!( app.session_controller.runtime_state(), RuntimeState::Planning ); app.user_success_condition = "tests pass".to_string(); app.to_in_progress().unwrap(); assert_eq!(app.state, State::InProgress); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); assert!(app.session_controller.active_commitment().is_some()); let _ = fs::remove_file(path); } #[test] fn unknown_window_prompt_is_inactive_for_empty_allowlist() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext::default(), ); let snapshot = window::WindowSnapshot { title: "Any Window".to_string(), class: Some("any-class".to_string()), health: antidrift::domain::EvidenceHealth::Available, }; assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); } #[test] fn unknown_window_prompt_is_inactive_for_matching_title() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext { window_title_substrings: vec!["Editor".to_string()], ..AllowedContext::default() }, ); let snapshot = window::WindowSnapshot { title: "Code Editor - antidrift".to_string(), class: None, health: antidrift::domain::EvidenceHealth::Available, }; assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); } #[test] fn unknown_window_prompt_uses_shared_title_normalization() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext { window_title_substrings: vec![" Editor ".to_string()], ..AllowedContext::default() }, ); let snapshot = window::WindowSnapshot { title: "Code Editor - antidrift".to_string(), class: None, health: antidrift::domain::EvidenceHealth::Available, }; assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); } #[test] fn unknown_window_prompt_uses_shared_class_normalization() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext { window_classes: vec![" Code ".to_string()], ..AllowedContext::default() }, ); let snapshot = window::WindowSnapshot { title: "Unrelated title".to_string(), class: Some(" code ".to_string()), health: antidrift::domain::EvidenceHealth::Available, }; assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); } #[test] fn unknown_window_prompt_is_active_for_nonmatching_title() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext { window_title_substrings: vec!["Editor".to_string()], ..AllowedContext::default() }, ); let snapshot = window::WindowSnapshot { title: "Browser - unrelated".to_string(), class: None, health: antidrift::domain::EvidenceHealth::Available, }; assert!(should_prompt_for_unknown_window(&policy, &snapshot)); } #[test] fn unknown_window_prompt_ignores_unavailable_title_evidence() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext { window_title_substrings: vec!["Editor".to_string()], ..AllowedContext::default() }, ); let snapshot = window::WindowSnapshot::unavailable("no active window"); assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); } #[test] fn unknown_window_prompt_ignores_class_policy_without_class_evidence() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext { window_classes: vec!["Code".to_string()], ..AllowedContext::default() }, ); let snapshot = window::WindowSnapshot { title: "Browser - unrelated".to_string(), class: None, health: antidrift::domain::EvidenceHealth::Degraded( "window class unavailable on current Windows adapter".to_string(), ), }; assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); } #[test] fn unknown_window_prompt_requires_usable_evidence_for_mixed_policy() { let policy = PolicySnapshot::for_runtime( "commitment-test".to_string(), RuntimeState::Active, EnforcementLevel::Warn, AllowedContext { window_title_substrings: vec!["Editor".to_string()], window_classes: vec!["Code".to_string()], ..AllowedContext::default() }, ); let missing_class = window::WindowSnapshot { title: "Browser - unrelated".to_string(), class: None, health: antidrift::domain::EvidenceHealth::Degraded( "window class unavailable on current Windows adapter".to_string(), ), }; let proved_unknown = window::WindowSnapshot { title: "Browser - unrelated".to_string(), class: Some("firefox".to_string()), health: antidrift::domain::EvidenceHealth::Available, }; assert!(!should_prompt_for_unknown_window(&policy, &missing_class)); assert!(should_prompt_for_unknown_window(&policy, &proved_unknown)); } #[test] fn acknowledged_unknown_window_is_suppressed_until_window_changes() { let path = unique_event_log_path("violation-ack-suppression"); let mut app = App::new_with_event_log_path(&path).unwrap(); let unknown = window::WindowSnapshot { title: "AntiDrift".to_string(), class: Some("terminal".to_string()), health: antidrift::domain::EvidenceHealth::Available, }; let other_unknown = window::WindowSnapshot { title: "Browser - unrelated".to_string(), class: Some("firefox".to_string()), health: antidrift::domain::EvidenceHealth::Available, }; assert!(!app.is_unknown_window_acknowledged(&unknown)); app.acknowledge_unknown_window(&unknown); assert!(app.is_unknown_window_acknowledged(&unknown)); assert!(!app.is_unknown_window_acknowledged(&other_unknown)); assert!(!app.is_unknown_window_acknowledged(&unknown)); let _ = fs::remove_file(path); } #[test] fn violation_prompt_ignores_esc() { let path = unique_event_log_path("violation-prompt-esc"); let mut app = App::new_with_event_log_path(&path).unwrap(); app.state = State::ViolationPrompt; app.violation_message = "Unknown context detected: Browser - unrelated".to_string(); handle_key_press(&mut app, KeyCode::Esc).unwrap(); assert_eq!(app.state, State::ViolationPrompt); assert!(app.violation_dismissal_reason.is_empty()); let _ = fs::remove_file(path); } #[test] fn violation_prompt_requires_reason_then_logs_and_returns_to_progress() { let path = unique_event_log_path("violation-prompt-dismissal"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.state = State::ViolationPrompt; app.violation_message = "Unknown context detected: Browser - unrelated".to_string(); app.dismiss_violation_if_valid().unwrap(); assert_eq!(app.state, State::ViolationPrompt); app.violation_dismissal_reason = "Needed to inspect docs".to_string(); app.dismiss_violation_if_valid().unwrap(); assert_eq!(app.state, State::InProgress); let records = EventLog::open(&path).unwrap().records().unwrap(); assert!(records .iter() .any(|record| record.event_type == EventType::ViolationObserved && record.payload_json["reason"] == "Needed to inspect docs")); let _ = fs::remove_file(path); } #[test] fn violation_dismissal_acknowledges_prompted_window_not_current_foreground() { let path = unique_event_log_path("violation-prompt-original-ack"); let mut app = App::new_with_event_log_path(&path).unwrap(); let prompted_unknown = window::WindowSnapshot { title: format!( "Prompted Unknown Window {}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos() ), class: Some("prompted-unknown-class".to_string()), health: antidrift::domain::EvidenceHealth::Available, }; let dismissal_foreground = window::WindowSnapshot { title: "AntiDrift Terminal".to_string(), class: Some("terminal".to_string()), health: antidrift::domain::EvidenceHealth::Degraded( "terminal foreground during dismissal".to_string(), ), }; 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(); app.enter_violation_prompt(&prompted_unknown); app.current_window_title = Rc::new(dismissal_foreground.title.clone()); app.evidence_health = dismissal_foreground.health.clone(); app.violation_dismissal_reason = "Needed to inspect docs".to_string(); app.dismiss_violation_if_valid().unwrap(); 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(&prompted_unknown)); let records = EventLog::open(&path).unwrap().records().unwrap(); let violation = records .iter() .find(|record| record.event_type == EventType::ViolationObserved) .expect("dismissal should record a violation"); assert_eq!( violation.payload_json["evidence"]["window_title"], prompted_unknown.title ); assert_eq!( violation.payload_json["evidence"]["message"], format!( "{}: {}", constants::VIOLATION_STATUS, prompted_unknown.title ) ); assert_eq!( violation.payload_json["evidence"]["evidence_health"], serde_json::json!("available") ); 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"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.to_end().unwrap(); assert_eq!(app.state, State::End); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); app.session_ratings[0].rating = 2; app.complete_rating().unwrap(); assert_eq!(app.state, State::InputIntention); assert_eq!( app.session_controller.runtime_state(), RuntimeState::Planning ); app.to_in_progress().unwrap(); assert_eq!(app.state, State::InProgress); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); let records = EventLog::open(&path).unwrap().records().unwrap(); let review_completed_index = records .iter() .position(|record| record.event_type == EventType::ReviewCompleted) .expect("review completion must be recorded durably"); let commitment_complete_index = records .iter() .position(|record| { record.event_type == EventType::CommitmentTransition && record.payload_json["action"] == "complete" }) .expect("review completion must complete the commitment"); let planning_transition_index = records .iter() .position(|record| { record.event_type == EventType::RuntimeTransition && record.runtime_state == RuntimeState::Planning && record.payload_json["action"] == "return_to_planning_after_review" }) .expect("review must return to planning after completion"); assert_eq!(commitment_complete_index, review_completed_index + 1); assert_eq!(planning_transition_index, commitment_complete_index + 1); assert_eq!( records[review_completed_index].commitment_id, records[commitment_complete_index].commitment_id ); assert_eq!( records[review_completed_index].payload_json["outcome"], "completed" ); let _ = fs::remove_file(path); } #[test] fn hydrated_review_session_stays_recoverable_until_rating_completion() { let path = unique_event_log_path("hydrated-review-cycle"); { let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.to_end().unwrap(); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); } let mut app = App::new_with_event_log_path(&path).unwrap(); assert_eq!(app.state, State::End); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); assert_eq!(app.session_ratings.len(), 1); handle_key_press(&mut app, KeyCode::Esc).unwrap(); assert_eq!(app.state, State::End); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); app.user_intention = "write next test".to_string(); app.user_success_condition = "next test passes".to_string(); app.user_duration_str = "1".to_string(); let err = app .to_in_progress() .expect_err("review must be completed before starting another commitment"); assert!(err .chain() .any(|cause| cause.to_string().contains("activate runtime"))); app.session_ratings[0].rating = 3; app.complete_rating().unwrap(); assert_eq!(app.state, State::InputIntention); assert_eq!( app.session_controller.runtime_state(), RuntimeState::Planning ); app.to_in_progress().unwrap(); assert_eq!(app.state, State::InProgress); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); assert_eq!( app.session_controller .active_commitment() .unwrap() .next_action, "write next test" ); let records = EventLog::open(&path).unwrap().records().unwrap(); let review_completed_index = records .iter() .position(|record| record.event_type == EventType::ReviewCompleted) .expect("review completion must be recorded"); let commitment_complete_index = records .iter() .position(|record| { record.event_type == EventType::CommitmentTransition && record.payload_json["action"] == "complete" }) .expect("review completion must complete the commitment"); let planning_transition_index = records .iter() .position(|record| { record.event_type == EventType::RuntimeTransition && record.runtime_state == RuntimeState::Planning && record.payload_json["action"] == "return_to_planning_after_review" }) .expect("review completion must return to planning"); assert_eq!(commitment_complete_index, review_completed_index + 1); assert_eq!(planning_transition_index, commitment_complete_index + 1); assert_eq!( records[review_completed_index].commitment_id, records[commitment_complete_index].commitment_id ); assert_eq!( records[review_completed_index].payload_json["outcome"], "completed" ); let _ = fs::remove_file(path); } #[test] fn hydrated_active_session_keeps_elapsed_time() { let path = unique_event_log_path("hydrated-active-timer"); { let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); } std::thread::sleep(Duration::from_millis(1100)); let app = App::new_with_event_log_path(&path).unwrap(); assert_eq!(app.state, State::InProgress); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); assert!(app.session_start.elapsed() >= Duration::from_secs(1)); assert!(app.session_remaining <= Duration::from_secs(59)); let _ = fs::remove_file(path); } #[test] fn hydrated_active_session_excludes_completed_pause_time() { let path = unique_event_log_path("hydrated-active-after-pause-timer"); { let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); std::thread::sleep(Duration::from_millis(1200)); app.pause().unwrap(); app.transition_reason = "stretch".to_string(); app.transition_return_target = "write tests".to_string(); app.transition_duration_str = "2".to_string(); app.start_transition_if_valid().unwrap(); std::thread::sleep(Duration::from_millis(2200)); app.resume().unwrap(); } let app = App::new_with_event_log_path(&path).unwrap(); assert_eq!(app.state, State::InProgress); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); assert!( app.session_remaining >= Duration::from_secs(58), "completed pause time should not reduce remaining time, got {:?}", app.session_remaining ); let _ = fs::remove_file(path); } #[test] fn hydrated_transition_session_freezes_remaining_at_pause_start() { let path = unique_event_log_path("hydrated-open-pause-timer"); { let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); std::thread::sleep(Duration::from_millis(1200)); app.pause().unwrap(); app.transition_reason = "stretch".to_string(); app.transition_return_target = "write tests".to_string(); app.transition_duration_str = "2".to_string(); app.start_transition_if_valid().unwrap(); std::thread::sleep(Duration::from_millis(2200)); } let app = App::new_with_event_log_path(&path).unwrap(); assert_eq!(app.state, State::Paused); assert_eq!( app.session_controller.runtime_state(), RuntimeState::Transition ); assert!( app.session_remaining >= Duration::from_secs(58), "open pause time should not reduce remaining time, got {:?}", app.session_remaining ); let _ = fs::remove_file(path); } #[test] fn hydrated_transition_can_resume_end_and_return_to_planning() { let path = unique_event_log_path("hydrated-transition-resume"); { let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.pause().unwrap(); app.transition_reason = "stretch".to_string(); app.transition_return_target = "write tests".to_string(); app.transition_duration_str = "2".to_string(); app.start_transition_if_valid().unwrap(); } let mut app = App::new_with_event_log_path(&path).unwrap(); assert_eq!(app.state, State::Paused); assert_eq!( app.session_controller.runtime_state(), RuntimeState::Transition ); app.resume().unwrap(); assert_eq!(app.state, State::InProgress); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); app.to_end().unwrap(); assert_eq!(app.state, State::End); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); app.complete_rating().unwrap(); assert_eq!(app.state, State::InputIntention); assert_eq!( app.session_controller.runtime_state(), RuntimeState::Planning ); let _ = fs::remove_file(path); } #[test] fn pause_from_in_progress_opens_transition_reason_without_starting_controller_transition() { let path = unique_event_log_path("transition-prompt"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.pause().unwrap(); assert_eq!(app.state, State::InputTransitionReason); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); assert_eq!(app.transition_reason, ""); assert_eq!(app.transition_return_target, "write tests"); assert_eq!(app.transition_duration_str, "2"); let _ = fs::remove_file(path); } #[test] fn valid_transition_inputs_enter_paused_and_start_controller_transition() { let path = unique_event_log_path("valid-transition-inputs"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.pause().unwrap(); app.transition_reason = "stretch".to_string(); app.transition_return_target = "write tests".to_string(); app.transition_duration_str = "3".to_string(); app.start_transition_if_valid().unwrap(); assert_eq!(app.state, State::Paused); assert_eq!( app.session_controller.runtime_state(), RuntimeState::Transition ); let _ = fs::remove_file(path); } #[test] fn missing_transition_reason_or_return_target_does_not_start_transition() { let path = unique_event_log_path("invalid-transition-inputs"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.pause().unwrap(); app.transition_reason = "".to_string(); app.transition_return_target = "write tests".to_string(); app.transition_duration_str = "3".to_string(); app.start_transition_if_valid().unwrap(); assert_eq!(app.state, State::InputTransitionReason); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); app.transition_reason = "stretch".to_string(); app.transition_return_target = "".to_string(); app.start_transition_if_valid().unwrap(); assert_eq!(app.state, State::InputTransitionReturn); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); let _ = fs::remove_file(path); } #[test] fn parse_duration_minutes_rejects_invalid_and_overflowing_values() { assert_eq!(parse_duration_minutes(""), None); assert_eq!(parse_duration_minutes("0"), None); assert_eq!(parse_duration_minutes("abc"), None); assert_eq!( parse_duration_minutes("25"), Some(Duration::from_secs(25 * 60)) ); assert_eq!(parse_duration_minutes(&u64::MAX.to_string()), None); } #[test] fn transition_prompt_tick_keeps_active_time_and_can_end_session() { let path = unique_event_log_path("transition-prompt-active-time"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.pause().unwrap(); app.session_start = Instant::now().checked_sub(Duration::from_secs(61)).unwrap(); app.tick_50ms().unwrap(); assert_eq!(app.state, State::End); assert_eq!(app.session_remaining, Duration::ZERO); assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); let _ = fs::remove_file(path); } #[test] fn transition_prompt_status_shows_active_countdown() { let path = unique_event_log_path("transition-prompt-status"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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(); app.pause().unwrap(); assert_eq!( app.status_text(), format!( "🎯 write tests - {}", duration_as_str(&app.session_remaining) ) ); let _ = fs::remove_file(path); } #[test] fn evidence_status_text_describes_degraded_and_unavailable_health() { assert_eq!(evidence_status_text(&EvidenceHealth::Available), None); assert_eq!( evidence_status_text(&EvidenceHealth::Degraded( "window class unavailable".to_string(), )), Some("Evidence degraded: window class unavailable".to_string()) ); assert_eq!( evidence_status_text(&EvidenceHealth::Unavailable( "xdotool active window unavailable".to_string(), )), Some("Evidence unavailable: xdotool active window unavailable".to_string()) ); } #[test] fn active_ticks_record_degraded_evidence_once_until_health_changes() { let path = unique_event_log_path("evidence-dedup"); let mut app = App::new_with_event_log_path(&path).unwrap(); 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 degraded = window::WindowSnapshot { title: "Editor".to_string(), class: None, health: EvidenceHealth::Degraded("window class unavailable".to_string()), }; app.tick_active_session_with_snapshot(degraded.clone()) .unwrap(); app.tick_active_session_with_snapshot(degraded).unwrap(); let records = EventLog::open(&path).unwrap().records().unwrap(); assert_eq!( records .iter() .filter(|record| record.event_type == EventType::EvidenceObserved) .count(), 1 ); let unavailable = window::WindowSnapshot::unavailable("xdotool active window unavailable"); app.tick_active_session_with_snapshot(unavailable).unwrap(); let records = EventLog::open(&path).unwrap().records().unwrap(); assert_eq!( records .iter() .filter(|record| record.event_type == EventType::EvidenceObserved) .count(), 2 ); assert_eq!( app.evidence_health, EvidenceHealth::Unavailable("xdotool active window unavailable".to_string()) ); let _ = fs::remove_file(path); } }