Require explicit transition prompts

This commit is contained in:
2026-05-25 21:38:58 -04:00
parent 7303eeacaf
commit 932c9ac807
2 changed files with 271 additions and 30 deletions
+5
View File
@@ -26,3 +26,8 @@ pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
pub const STATUS_QUIT: &str = "antidrift shutting down"; pub const STATUS_QUIT: &str = "antidrift shutting down";
pub const STATUS_TRANSITION: &str = "↔ antidrift transition"; pub const STATUS_TRANSITION: &str = "↔ antidrift transition";
pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition"; 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! ";
+266 -30
View File
@@ -32,6 +32,9 @@ enum State {
InputIntention, InputIntention,
InputSuccessCondition, InputSuccessCondition,
InputDuration, InputDuration,
InputTransitionReason,
InputTransitionReturn,
InputTransitionDuration,
InProgress, InProgress,
Paused, Paused,
End, End,
@@ -96,6 +99,9 @@ struct App {
user_success_condition: String, user_success_condition: String,
user_duration_str: String, user_duration_str: String,
user_duration: Duration, user_duration: Duration,
transition_reason: String,
transition_return_target: String,
transition_duration_str: String,
current_window_title: Rc<String>, current_window_title: Rc<String>,
evidence_health: EvidenceHealth, evidence_health: EvidenceHealth,
session_controller: SessionController, session_controller: SessionController,
@@ -179,6 +185,9 @@ impl App {
user_success_condition, user_success_condition,
user_duration_str, user_duration_str,
user_duration, user_duration,
transition_reason: String::new(),
transition_return_target: String::new(),
transition_duration_str: "2".to_string(),
current_window_title: window_snapshot.title.into(), current_window_title: window_snapshot.title.into(),
evidence_health: window_snapshot.health, evidence_health: window_snapshot.health,
session_controller, session_controller,
@@ -239,13 +248,40 @@ impl App {
} }
fn pause(&mut self) -> Result<()> { fn pause(&mut self) -> Result<()> {
if self.session_controller.runtime_state() == RuntimeState::Active { self.transition_reason.clear();
self.session_controller.start_transition( self.transition_return_target = self.user_intention.clone();
"legacy pause", self.transition_duration_str = "2".to_string();
self.user_intention.clone(), self.state = State::InputTransitionReason;
Duration::from_secs(60), 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::<u64>()
.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; self.state = State::Paused;
Ok(()) Ok(())
} }
@@ -281,14 +317,17 @@ impl App {
fn write_status(&self) { fn write_status(&self) {
let status = match self.state { let status = match self.state {
State::InputIntention | State::InputSuccessCondition | State::InputDuration => { State::InputIntention
match self.session_controller.runtime_state() { | State::InputSuccessCondition
RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), | State::InputDuration
RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), | State::InputTransitionReason
RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), | State::InputTransitionReturn
_ => constants::STATUS_CONFIGURE.to_string(), | 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!( State::InProgress => format!(
"🎯 {} - {}", "🎯 {} - {}",
self.user_intention, self.user_intention,
@@ -316,7 +355,12 @@ impl App {
fn tick_50ms(&mut self) -> Result<()> { fn tick_50ms(&mut self) -> Result<()> {
match self.state { 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::<u64>() { if let Ok(user_duration_mins) = self.user_duration_str.parse::<u64>() {
self.user_duration = Duration::from_secs(user_duration_mins * 60); self.user_duration = Duration::from_secs(user_duration_mins * 60);
} else { } 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<Rc<String>, Duration>) -> Vec<SessionRating> { fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> {
let mut stats: Vec<_> = session_stats let mut stats: Vec<_> = session_stats
.iter() .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 { State::InProgress => match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
app.to_end()?; 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] = let [layout_intention, layout_success_condition, layout_duration, layout_titles, layout_status] =
layout.areas(frame.size()); layout.areas(frame.size());
let border_type_intention = if app.state == State::InputIntention { let transition_input = is_transition_input_state(&app.state);
BorderType::Thick
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 { } 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( frame.render_widget(
Paragraph::new(input_intention).block( Paragraph::new(input_intention).block(
Block::bordered() Block::bordered()
.border_type(border_type_intention) .border_type(border_type_intention)
.title(constants::INTENTION_TITLE), .title(intention_title),
), ),
layout_intention, layout_intention,
); );
let border_type_success_condition = if app.state == State::InputSuccessCondition { let border_type_success_condition =
BorderType::Thick 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 { } 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( frame.render_widget(
Paragraph::new(input_success_condition).block( Paragraph::new(input_success_condition).block(
Block::bordered() Block::bordered()
.border_type(border_type_success_condition) .border_type(border_type_success_condition)
.title(constants::SUCCESS_CONDITION_TITLE), .title(success_condition_title),
), ),
layout_success_condition, layout_success_condition,
); );
@@ -640,20 +748,31 @@ fn ui(frame: &mut Frame, app: &App) {
let s = duration_as_str(&app.session_remaining); let s = duration_as_str(&app.session_remaining);
vec![Line::from(Span::raw(s))] 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))] vec![Line::from(Span::raw(&app.user_duration_str))]
} }
}; };
let border_type_duration = if app.state == State::InputDuration { let border_type_duration =
BorderType::Thick 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 { } else {
BorderType::Plain constants::DURATION_TITLE
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(input_duration).block( Paragraph::new(input_duration).block(
Block::bordered() Block::bordered()
.border_type(border_type_duration) .border_type(border_type_duration)
.title(constants::DURATION_TITLE), .title(duration_title),
), ),
layout_duration, layout_duration,
); );
@@ -677,7 +796,36 @@ fn ui(frame: &mut Frame, app: &App) {
} }
let mut spans: Vec<Span> = Vec::new(); let mut spans: Vec<Span> = 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::<u64>()
.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( let span = Span::styled(
constants::PROVIDE_INTENTION, constants::PROVIDE_INTENTION,
Style::new().fg(Color::LightRed), Style::new().fg(Color::LightRed),
@@ -711,6 +859,9 @@ fn ui(frame: &mut Frame, app: &App) {
spans.push(span); spans.push(span);
} }
} }
State::InputTransitionReason
| State::InputTransitionReturn
| State::InputTransitionDuration => {}
State::InProgress => { State::InProgress => {
let span = Span::styled( let span = Span::styled(
constants::SESSION_IN_PROGRESS, constants::SESSION_IN_PROGRESS,
@@ -843,6 +994,10 @@ mod tests {
app.to_in_progress().unwrap(); app.to_in_progress().unwrap();
std::thread::sleep(Duration::from_millis(1200)); std::thread::sleep(Duration::from_millis(1200));
app.pause().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();
std::thread::sleep(Duration::from_millis(2200)); std::thread::sleep(Duration::from_millis(2200));
app.resume().unwrap(); app.resume().unwrap();
} }
@@ -871,6 +1026,10 @@ mod tests {
app.to_in_progress().unwrap(); app.to_in_progress().unwrap();
std::thread::sleep(Duration::from_millis(1200)); std::thread::sleep(Duration::from_millis(1200));
app.pause().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();
std::thread::sleep(Duration::from_millis(2200)); std::thread::sleep(Duration::from_millis(2200));
} }
@@ -900,6 +1059,10 @@ mod tests {
app.user_duration_str = "1".to_string(); app.user_duration_str = "1".to_string();
app.to_in_progress().unwrap(); app.to_in_progress().unwrap();
app.pause().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(); let mut app = App::new_with_event_log_path(&path).unwrap();
@@ -926,4 +1089,77 @@ mod tests {
let _ = fs::remove_file(path); 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);
}
} }