From 6ce6bb97ab21b64a5f6cdafa90d2c252e07b76ef Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 25 May 2026 21:13:35 -0400 Subject: [PATCH] Wire commitments into session UI --- src/constants.rs | 8 ++ src/main.rs | 213 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 195 insertions(+), 26 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index 3791d25..4c0caa3 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,6 +1,10 @@ pub const APP_TITLE: &str = "AntiDrift"; pub const DEFAULT_DURATION: &str = "25"; pub const DURATION_TITLE: &str = "Duration"; +#[allow(dead_code)] +pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence"; +pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! "; +pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl"; pub const INTENTION_TITLE: &str = "Intention"; pub const PAUSED: &str = "paused"; pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions"; @@ -15,6 +19,10 @@ pub const STATUS_FILE: &str = "~/.antidrift_status"; pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl"; pub const STATUS_TITLE: &str = "Status"; pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session"; +pub const STATUS_LOCKED: &str = "🔒 antidrift locked"; +pub const STATUS_PLANNING: &str = "🧭 antidrift planning"; pub const STATUS_PAUSED: &str = "⏸ antidrift paused"; 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"; diff --git a/src/main.rs b/src/main.rs index 187065f..af2684a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,16 @@ 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::window; +use antidrift::{ + domain::{EvidenceHealth, RuntimeState}, + session::SessionController, + window, +}; use ratatui::{ crossterm::{ event::{self, Event, KeyCode}, @@ -22,9 +27,10 @@ use ratatui::{ widgets::*, }; -#[derive(PartialEq)] +#[derive(Debug, PartialEq)] enum State { InputIntention, + InputSuccessCondition, InputDuration, InProgress, Paused, @@ -87,9 +93,12 @@ struct SessionRating { struct App { state: State, user_intention: String, + user_success_condition: String, user_duration_str: String, user_duration: Duration, current_window_title: Rc, + evidence_health: EvidenceHealth, + session_controller: SessionController, current_window_time: Instant, session_start: Instant, session_stats: HashMap, Duration>, @@ -120,25 +129,61 @@ fn save_session(session: &SessionResult) { } impl App { - fn new() -> Self { - let window_title = window::get_title_clean(); + fn new() -> Result { + let event_log_path = shellexpand::tilde(constants::EVENT_LOG_FILE).to_string(); + Self::new_with_event_log_path(event_log_path) + } - App { - state: State::InputIntention, - user_intention: String::new(), - user_duration_str: constants::DEFAULT_DURATION.to_string(), - user_duration: Duration::ZERO, - current_window_title: window_title.into(), + 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)?; + if 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, + _ => 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()); + + Ok(App { + state, + user_intention, + user_success_condition, + user_duration_str, + user_duration, + current_window_title: window_snapshot.title.into(), + evidence_health: window_snapshot.health, + session_controller, current_window_time: Instant::now(), session_start: Instant::now(), session_stats: HashMap::new(), - session_remaining: Duration::ZERO, + session_remaining: user_duration, session_ratings: Vec::new(), session_ratings_index: 0, session_results: Vec::new(), last_tick_50ms: Instant::now(), last_tick_1s: Instant::now(), - } + }) } fn handle_ticks(&mut self) { @@ -150,15 +195,38 @@ impl App { } } - fn to_in_progress(&mut self) { - if self.user_intention.len() == 0 || self.user_duration == Duration::ZERO { - return; + fn to_in_progress(&mut self) -> Result<()> { + self.user_duration = self + .user_duration_str + .parse::() + .map(|minutes| Duration::from_secs(minutes * 60)) + .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 to_end(&mut self) { @@ -173,7 +241,14 @@ impl App { fn write_status(&self) { let status = match self.state { - State::InputIntention | State::InputDuration => constants::STATUS_CONFIGURE.to_string(), + 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::InProgress => format!( "🎯 {} - {}", self.user_intention, @@ -201,7 +276,7 @@ impl App { fn tick_50ms(&mut self) { match self.state { - State::InputIntention | State::InputDuration => { + State::InputIntention | State::InputSuccessCondition | State::InputDuration => { if let Ok(user_duration_mins) = self.user_duration_str.parse::() { self.user_duration = Duration::from_secs(user_duration_mins * 60); } else { @@ -313,11 +388,11 @@ fn session_stats_as_vec(session_stats: &HashMap, Duration>) -> Vec 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 mut app = App::new(); while app.state != State::ShouldQuit { terminal.draw(|frame| ui(frame, &app))?; @@ -350,8 +425,8 @@ fn handle_events(app: &mut App) -> Result<()> { match app.state { State::InputIntention => match key.code { - KeyCode::Enter => app.state = State::InputDuration, - KeyCode::Tab => app.state = State::InputDuration, + KeyCode::Enter => app.state = State::InputSuccessCondition, + KeyCode::Tab => app.state = State::InputSuccessCondition, KeyCode::Backspace => { let _ = app.user_intention.pop(); } @@ -360,8 +435,19 @@ fn handle_events(app: &mut App) -> Result<()> { } _ => {} }, + 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::Enter => app.to_in_progress()?, KeyCode::Tab => app.state = State::InputIntention, KeyCode::Backspace => { let _ = app.user_duration_str.pop(); @@ -425,11 +511,14 @@ fn handle_events(app: &mut App) -> Result<()> { } fn update_session_stats(app: &mut App) { + let window_snapshot = window::get_snapshot(); + app.evidence_health = window_snapshot.health; let window_title = if app.state == State::Paused { - constants::PAUSED.to_string().into() + constants::PAUSED.to_string() } else { - window::get_title_clean().into() + window_snapshot.title }; + 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)) { @@ -446,12 +535,13 @@ fn update_session_stats(app: &mut App) { fn ui(frame: &mut Frame, app: &App) { let layout = Layout::vertical([ + Constraint::Min(3), Constraint::Min(3), Constraint::Min(3), Constraint::Percentage(100), Constraint::Min(3), ]); - let [layout_intention, layout_duration, layout_titles, layout_status] = + 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 { @@ -469,6 +559,21 @@ fn ui(frame: &mut Frame, app: &App) { layout_intention, ); + let border_type_success_condition = if app.state == State::InputSuccessCondition { + BorderType::Thick + } else { + BorderType::Plain + }; + 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), + ), + layout_success_condition, + ); + let input_duration: Vec = match app.state { State::InProgress | State::Paused => { let s = duration_as_str(&app.session_remaining); @@ -492,7 +597,10 @@ fn ui(frame: &mut Frame, app: &App) { layout_duration, ); - if app.state == State::InputIntention || app.state == State::InputDuration { + if matches!( + app.state, + State::InputIntention | State::InputSuccessCondition | State::InputDuration + ) { let results = app.get_session_results(); frame.render_widget( Paragraph::new(results) @@ -516,6 +624,14 @@ fn ui(frame: &mut Frame, app: &App) { 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 app.user_duration.is_zero() { let span = Span::styled( constants::PROVIDE_VALID_DURATION, @@ -525,7 +641,7 @@ fn ui(frame: &mut Frame, app: &App) { } match app.state { - State::InputIntention | State::InputDuration => { + State::InputIntention | State::InputSuccessCondition | State::InputDuration => { if spans.len() == 0 { let span = Span::styled( constants::READY_TO_START, @@ -558,3 +674,48 @@ fn ui(frame: &mut Frame, app: &App) { layout_status, ); } + +#[cfg(test)] +mod tests { + use super::*; + use antidrift::domain::RuntimeState; + 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); + } +}