From 932c9ac8077d2c6fed0ad9f42a36266bb4e11e2a Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 25 May 2026 21:38:58 -0400 Subject: [PATCH] Require explicit transition prompts --- src/constants.rs | 5 + src/main.rs | 296 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 271 insertions(+), 30 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index 4c0caa3..bcefc4a 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -26,3 +26,8 @@ pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!"; pub const STATUS_QUIT: &str = "antidrift shutting down"; pub const STATUS_TRANSITION: &str = "↔ antidrift transition"; pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition"; +pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes"; +pub const TRANSITION_REASON_TITLE: &str = "Transition Reason"; +pub const TRANSITION_RETURN_TITLE: &str = "Return Target"; +pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! "; +pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! "; diff --git a/src/main.rs b/src/main.rs index d2c29b0..6c88c80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,9 @@ enum State { InputIntention, InputSuccessCondition, InputDuration, + InputTransitionReason, + InputTransitionReturn, + InputTransitionDuration, InProgress, Paused, End, @@ -96,6 +99,9 @@ struct App { user_success_condition: String, user_duration_str: String, user_duration: Duration, + transition_reason: String, + transition_return_target: String, + transition_duration_str: String, current_window_title: Rc, evidence_health: EvidenceHealth, session_controller: SessionController, @@ -179,6 +185,9 @@ impl App { user_success_condition, user_duration_str, user_duration, + transition_reason: String::new(), + transition_return_target: String::new(), + transition_duration_str: "2".to_string(), current_window_title: window_snapshot.title.into(), evidence_health: window_snapshot.health, session_controller, @@ -239,13 +248,40 @@ impl App { } fn pause(&mut self) -> Result<()> { - if self.session_controller.runtime_state() == RuntimeState::Active { - self.session_controller.start_transition( - "legacy pause", - self.user_intention.clone(), - Duration::from_secs(60), - )?; + 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 = self + .transition_duration_str + .parse::() + .map(|minutes| Duration::from_secs(minutes * 60)) + .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(()) } @@ -281,14 +317,17 @@ impl App { fn write_status(&self) { let status = match self.state { - 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::InputIntention + | State::InputSuccessCondition + | State::InputDuration + | 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 => format!( "🎯 {} - {}", self.user_intention, @@ -316,7 +355,12 @@ impl App { fn tick_50ms(&mut self) -> Result<()> { match self.state { - State::InputIntention | State::InputSuccessCondition | State::InputDuration => { + State::InputIntention + | State::InputSuccessCondition + | State::InputDuration + | State::InputTransitionReason + | State::InputTransitionReturn + | State::InputTransitionDuration => { if let Ok(user_duration_mins) = self.user_duration_str.parse::() { self.user_duration = Duration::from_secs(user_duration_mins * 60); } else { @@ -432,6 +476,15 @@ fn duration_as_str(duration: &Duration) -> String { ) } +fn is_transition_input_state(state: &State) -> bool { + matches!( + state, + State::InputTransitionReason + | State::InputTransitionReturn + | State::InputTransitionDuration + ) +} + fn session_stats_as_vec(session_stats: &HashMap, Duration>) -> Vec { let mut stats: Vec<_> = session_stats .iter() @@ -528,6 +581,39 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, + 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()?; @@ -605,32 +691,54 @@ fn ui(frame: &mut Frame, app: &App) { let [layout_intention, layout_success_condition, layout_duration, layout_titles, layout_status] = layout.areas(frame.size()); - let border_type_intention = if app.state == State::InputIntention { - BorderType::Thick + 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 { - BorderType::Plain + Line::from(Span::raw(&app.user_intention)) + }; + let intention_title = if transition_input { + constants::TRANSITION_REASON_TITLE + } else { + constants::INTENTION_TITLE }; - let input_intention = Line::from(Span::raw(&app.user_intention)); frame.render_widget( Paragraph::new(input_intention).block( Block::bordered() .border_type(border_type_intention) - .title(constants::INTENTION_TITLE), + .title(intention_title), ), layout_intention, ); - let border_type_success_condition = if app.state == State::InputSuccessCondition { - BorderType::Thick + 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 { - BorderType::Plain + Line::from(Span::raw(&app.user_success_condition)) + }; + let success_condition_title = if transition_input { + constants::TRANSITION_RETURN_TITLE + } else { + constants::SUCCESS_CONDITION_TITLE }; - let input_success_condition = Line::from(Span::raw(&app.user_success_condition)); frame.render_widget( Paragraph::new(input_success_condition).block( Block::bordered() .border_type(border_type_success_condition) - .title(constants::SUCCESS_CONDITION_TITLE), + .title(success_condition_title), ), layout_success_condition, ); @@ -640,20 +748,31 @@ fn ui(frame: &mut Frame, app: &App) { 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 { - BorderType::Thick + 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 { - BorderType::Plain + constants::DURATION_TITLE }; frame.render_widget( Paragraph::new(input_duration).block( Block::bordered() .border_type(border_type_duration) - .title(constants::DURATION_TITLE), + .title(duration_title), ), layout_duration, ); @@ -677,7 +796,36 @@ fn ui(frame: &mut Frame, app: &App) { } let mut spans: Vec = Vec::new(); - if app.user_intention.len() == 0 { + 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); + } + + let valid_transition_duration = app + .transition_duration_str + .parse::() + .map(|minutes| minutes > 0) + .unwrap_or(false); + if !valid_transition_duration { + 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), @@ -711,6 +859,9 @@ fn ui(frame: &mut Frame, app: &App) { spans.push(span); } } + State::InputTransitionReason + | State::InputTransitionReturn + | State::InputTransitionDuration => {} State::InProgress => { let span = Span::styled( constants::SESSION_IN_PROGRESS, @@ -843,6 +994,10 @@ mod tests { 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(); } @@ -871,6 +1026,10 @@ mod tests { 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)); } @@ -900,6 +1059,10 @@ mod tests { 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(); @@ -926,4 +1089,77 @@ mod tests { 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); + } }