use ralph_proto::{Event, HatId};
use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoopMode {
Auto,
Paused,
}
pub struct TuiState {
pub pending_hat: Option<(HatId, String)>,
pub iteration: u32,
pub prev_iteration: u32,
pub loop_started: Option<Instant>,
pub iteration_started: Option<Instant>,
pub last_event: Option<String>,
pub last_event_at: Option<Instant>,
pub show_help: bool,
pub loop_mode: LoopMode,
pub in_scroll_mode: bool,
pub search_query: String,
pub search_forward: bool,
pub max_iterations: Option<u32>,
pub idle_timeout_remaining: Option<Duration>,
hat_map: HashMap<String, (HatId, String)>,
}
impl TuiState {
pub fn new() -> Self {
Self {
pending_hat: None,
iteration: 0,
prev_iteration: 0,
loop_started: None,
iteration_started: None,
last_event: None,
last_event_at: None,
show_help: false,
loop_mode: LoopMode::Auto,
in_scroll_mode: false,
search_query: String::new(),
search_forward: true,
max_iterations: None,
idle_timeout_remaining: None,
hat_map: HashMap::new(),
}
}
pub fn with_hat_map(hat_map: HashMap<String, (HatId, String)>) -> Self {
Self {
pending_hat: None,
iteration: 0,
prev_iteration: 0,
loop_started: None,
iteration_started: None,
last_event: None,
last_event_at: None,
show_help: false,
loop_mode: LoopMode::Auto,
in_scroll_mode: false,
search_query: String::new(),
search_forward: true,
max_iterations: None,
idle_timeout_remaining: None,
hat_map,
}
}
pub fn update(&mut self, event: &Event) {
let now = Instant::now();
let topic = event.topic.as_str();
self.last_event = Some(topic.to_string());
self.last_event_at = Some(now);
if let Some((hat_id, hat_display)) = self.hat_map.get(topic) {
self.pending_hat = Some((hat_id.clone(), hat_display.clone()));
if topic.starts_with("build.") {
self.iteration_started = Some(now);
}
return;
}
match topic {
"task.start" => {
let saved_hat_map = std::mem::take(&mut self.hat_map);
*self = Self::new();
self.hat_map = saved_hat_map;
self.loop_started = Some(now);
self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
self.last_event = Some(topic.to_string());
self.last_event_at = Some(now);
}
"task.resume" => {
self.loop_started = Some(now);
self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
}
"build.task" => {
self.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
self.iteration_started = Some(now);
}
"build.done" => {
self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
self.prev_iteration = self.iteration;
self.iteration += 1;
}
"build.blocked" => {
self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
}
"loop.terminate" => {
self.pending_hat = None;
}
_ => {
}
}
}
pub fn get_pending_hat_display(&self) -> String {
self.pending_hat
.as_ref()
.map_or_else(|| "—".to_string(), |(_, display)| display.clone())
}
pub fn get_loop_elapsed(&self) -> Option<Duration> {
self.loop_started.map(|start| start.elapsed())
}
pub fn get_iteration_elapsed(&self) -> Option<Duration> {
self.iteration_started.map(|start| start.elapsed())
}
pub fn is_active(&self) -> bool {
self.last_event_at
.is_some_and(|t| t.elapsed() < Duration::from_secs(2))
}
pub fn iteration_changed(&self) -> bool {
self.iteration != self.prev_iteration
}
}
impl Default for TuiState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iteration_changed_detects_boundary() {
let mut state = TuiState::new();
assert!(!state.iteration_changed(), "no change at start");
let event = Event::new("build.done", "");
state.update(&event);
assert_eq!(state.iteration, 1);
assert_eq!(state.prev_iteration, 0);
assert!(state.iteration_changed(), "should detect iteration change");
}
#[test]
fn iteration_changed_resets_after_check() {
let mut state = TuiState::new();
let event = Event::new("build.done", "");
state.update(&event);
assert!(state.iteration_changed());
state.prev_iteration = state.iteration;
assert!(!state.iteration_changed(), "flag should reset");
}
#[test]
fn multiple_iterations_tracked() {
let mut state = TuiState::new();
for i in 1..=3 {
let event = Event::new("build.done", "");
state.update(&event);
assert_eq!(state.iteration, i);
assert!(state.iteration_changed());
state.prev_iteration = state.iteration; }
}
#[test]
fn custom_hat_topics_update_pending_hat() {
use std::collections::HashMap;
let mut hat_map = HashMap::new();
hat_map.insert(
"review.security".to_string(),
(
HatId::new("security_reviewer"),
"🔒 Security Reviewer".to_string(),
),
);
hat_map.insert(
"review.correctness".to_string(),
(
HatId::new("correctness_reviewer"),
"🎯 Correctness Reviewer".to_string(),
),
);
let mut state = TuiState::with_hat_map(hat_map);
let event = Event::new("review.security", "Review PR #123");
state.update(&event);
assert_eq!(
state.get_pending_hat_display(),
"🔒 Security Reviewer",
"Should display security reviewer hat for review.security topic"
);
let event = Event::new("review.correctness", "Check logic");
state.update(&event);
assert_eq!(
state.get_pending_hat_display(),
"🎯 Correctness Reviewer",
"Should display correctness reviewer hat for review.correctness topic"
);
}
#[test]
fn unknown_topics_keep_pending_hat_unchanged() {
let mut state = TuiState::new();
state.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
let event = Event::new("unknown.topic", "Some payload");
state.update(&event);
assert_eq!(
state.get_pending_hat_display(),
"📋Planner",
"Unknown topics should not clear pending_hat"
);
}
}