ralph_tui/
state.rs

1//! State management for the TUI.
2
3use ralph_proto::{Event, HatId};
4use std::collections::HashMap;
5use std::time::{Duration, Instant};
6
7/// Loop execution mode.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum LoopMode {
10    Auto,
11    Paused,
12}
13
14/// Observable state derived from loop events.
15pub struct TuiState {
16    /// Which hat will process next event (ID + display name).
17    pub pending_hat: Option<(HatId, String)>,
18    /// Current iteration number (0-indexed, display as +1).
19    pub iteration: u32,
20    /// Previous iteration number (for detecting changes).
21    pub prev_iteration: u32,
22    /// When loop began.
23    pub loop_started: Option<Instant>,
24    /// When current iteration began.
25    pub iteration_started: Option<Instant>,
26    /// Most recent event topic.
27    pub last_event: Option<String>,
28    /// Timestamp of last event.
29    pub last_event_at: Option<Instant>,
30    /// Whether to show help overlay.
31    pub show_help: bool,
32    /// Loop execution mode.
33    pub loop_mode: LoopMode,
34    /// Whether in scroll mode.
35    pub in_scroll_mode: bool,
36    /// Current search query (if in search input mode).
37    pub search_query: String,
38    /// Search direction (true = forward, false = backward).
39    pub search_forward: bool,
40    /// Maximum iterations from config.
41    pub max_iterations: Option<u32>,
42    /// Idle timeout countdown.
43    pub idle_timeout_remaining: Option<Duration>,
44    /// Map of event topics to hat display information (for custom hats).
45    /// Key: event topic (e.g., "review.security")
46    /// Value: (HatId, display name including emoji)
47    hat_map: HashMap<String, (HatId, String)>,
48}
49
50impl TuiState {
51    /// Creates empty state.
52    pub fn new() -> Self {
53        Self {
54            pending_hat: None,
55            iteration: 0,
56            prev_iteration: 0,
57            loop_started: None,
58            iteration_started: None,
59            last_event: None,
60            last_event_at: None,
61            show_help: false,
62            loop_mode: LoopMode::Auto,
63            in_scroll_mode: false,
64            search_query: String::new(),
65            search_forward: true,
66            max_iterations: None,
67            idle_timeout_remaining: None,
68            hat_map: HashMap::new(),
69        }
70    }
71
72    /// Creates state with a custom hat map for dynamic topic-to-hat resolution.
73    pub fn with_hat_map(hat_map: HashMap<String, (HatId, String)>) -> Self {
74        Self {
75            pending_hat: None,
76            iteration: 0,
77            prev_iteration: 0,
78            loop_started: None,
79            iteration_started: None,
80            last_event: None,
81            last_event_at: None,
82            show_help: false,
83            loop_mode: LoopMode::Auto,
84            in_scroll_mode: false,
85            search_query: String::new(),
86            search_forward: true,
87            max_iterations: None,
88            idle_timeout_remaining: None,
89            hat_map,
90        }
91    }
92
93    /// Updates state based on event topic.
94    pub fn update(&mut self, event: &Event) {
95        let now = Instant::now();
96        let topic = event.topic.as_str();
97
98        self.last_event = Some(topic.to_string());
99        self.last_event_at = Some(now);
100
101        // First, check if we have a custom hat mapping for this topic
102        if let Some((hat_id, hat_display)) = self.hat_map.get(topic) {
103            self.pending_hat = Some((hat_id.clone(), hat_display.clone()));
104            // Handle iteration timing for custom hats
105            if topic.starts_with("build.") {
106                self.iteration_started = Some(now);
107            }
108            return;
109        }
110
111        // Fall back to hardcoded mappings for backward compatibility
112        match topic {
113            "task.start" => {
114                // Save hat_map before resetting
115                let saved_hat_map = std::mem::take(&mut self.hat_map);
116                *self = Self::new();
117                self.hat_map = saved_hat_map;
118                self.loop_started = Some(now);
119                self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
120                self.last_event = Some(topic.to_string());
121                self.last_event_at = Some(now);
122            }
123            "task.resume" => {
124                self.loop_started = Some(now);
125                self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
126            }
127            "build.task" => {
128                self.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
129                self.iteration_started = Some(now);
130            }
131            "build.done" => {
132                self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
133                self.prev_iteration = self.iteration;
134                self.iteration += 1;
135            }
136            "build.blocked" => {
137                self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
138            }
139            "loop.terminate" => {
140                self.pending_hat = None;
141            }
142            _ => {
143                // Unknown topic - don't change pending_hat
144            }
145        }
146    }
147
148    /// Returns formatted hat display (emoji + name).
149    pub fn get_pending_hat_display(&self) -> String {
150        self.pending_hat
151            .as_ref()
152            .map_or_else(|| "—".to_string(), |(_, display)| display.clone())
153    }
154
155    /// Time since loop started.
156    pub fn get_loop_elapsed(&self) -> Option<Duration> {
157        self.loop_started.map(|start| start.elapsed())
158    }
159
160    /// Time since iteration started.
161    pub fn get_iteration_elapsed(&self) -> Option<Duration> {
162        self.iteration_started.map(|start| start.elapsed())
163    }
164
165    /// True if event received in last 2 seconds.
166    pub fn is_active(&self) -> bool {
167        self.last_event_at
168            .is_some_and(|t| t.elapsed() < Duration::from_secs(2))
169    }
170
171    /// True if iteration changed since last check.
172    pub fn iteration_changed(&self) -> bool {
173        self.iteration != self.prev_iteration
174    }
175
176}
177
178impl Default for TuiState {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn iteration_changed_detects_boundary() {
190        let mut state = TuiState::new();
191        assert!(!state.iteration_changed(), "no change at start");
192
193        // Simulate build.done event (increments iteration)
194        let event = Event::new("build.done", "");
195        state.update(&event);
196
197        assert_eq!(state.iteration, 1);
198        assert_eq!(state.prev_iteration, 0);
199        assert!(state.iteration_changed(), "should detect iteration change");
200    }
201
202    #[test]
203    fn iteration_changed_resets_after_check() {
204        let mut state = TuiState::new();
205        let event = Event::new("build.done", "");
206        state.update(&event);
207
208        assert!(state.iteration_changed());
209
210        // Simulate clearing the flag (app.rs does this by updating prev_iteration)
211        state.prev_iteration = state.iteration;
212        assert!(!state.iteration_changed(), "flag should reset");
213    }
214
215    #[test]
216    fn multiple_iterations_tracked() {
217        let mut state = TuiState::new();
218
219        for i in 1..=3 {
220            let event = Event::new("build.done", "");
221            state.update(&event);
222            assert_eq!(state.iteration, i);
223            assert!(state.iteration_changed());
224            state.prev_iteration = state.iteration; // simulate app clearing flag
225        }
226    }
227
228    #[test]
229    fn custom_hat_topics_update_pending_hat() {
230        // Test that custom hat topics (not hardcoded) update pending_hat correctly
231        use std::collections::HashMap;
232
233        // Create a hat map for custom hats
234        let mut hat_map = HashMap::new();
235        hat_map.insert(
236            "review.security".to_string(),
237            (HatId::new("security_reviewer"), "🔒 Security Reviewer".to_string())
238        );
239        hat_map.insert(
240            "review.correctness".to_string(),
241            (HatId::new("correctness_reviewer"), "🎯 Correctness Reviewer".to_string())
242        );
243
244        let mut state = TuiState::with_hat_map(hat_map);
245
246        // Publish review.security event
247        let event = Event::new("review.security", "Review PR #123");
248        state.update(&event);
249
250        // Should update pending_hat to security reviewer
251        assert_eq!(
252            state.get_pending_hat_display(),
253            "🔒 Security Reviewer",
254            "Should display security reviewer hat for review.security topic"
255        );
256
257        // Publish review.correctness event
258        let event = Event::new("review.correctness", "Check logic");
259        state.update(&event);
260
261        // Should update to correctness reviewer
262        assert_eq!(
263            state.get_pending_hat_display(),
264            "🎯 Correctness Reviewer",
265            "Should display correctness reviewer hat for review.correctness topic"
266        );
267    }
268
269    #[test]
270    fn unknown_topics_keep_pending_hat_unchanged() {
271        // Test that unknown topics don't clear pending_hat
272        let mut state = TuiState::new();
273
274        // Set initial hat
275        state.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
276
277        // Publish unknown event
278        let event = Event::new("unknown.topic", "Some payload");
279        state.update(&event);
280
281        // Should keep the planner hat
282        assert_eq!(
283            state.get_pending_hat_display(),
284            "📋Planner",
285            "Unknown topics should not clear pending_hat"
286        );
287    }
288}