1use ralph_proto::{Event, HatId};
4use std::collections::HashMap;
5use std::time::{Duration, Instant};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum LoopMode {
10 Auto,
11 Paused,
12}
13
14pub struct TuiState {
16 pub pending_hat: Option<(HatId, String)>,
18 pub iteration: u32,
20 pub prev_iteration: u32,
22 pub loop_started: Option<Instant>,
24 pub iteration_started: Option<Instant>,
26 pub last_event: Option<String>,
28 pub last_event_at: Option<Instant>,
30 pub show_help: bool,
32 pub loop_mode: LoopMode,
34 pub in_scroll_mode: bool,
36 pub search_query: String,
38 pub search_forward: bool,
40 pub max_iterations: Option<u32>,
42 pub idle_timeout_remaining: Option<Duration>,
44 hat_map: HashMap<String, (HatId, String)>,
48}
49
50impl TuiState {
51 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 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 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 if let Some((hat_id, hat_display)) = self.hat_map.get(topic) {
103 self.pending_hat = Some((hat_id.clone(), hat_display.clone()));
104 if topic.starts_with("build.") {
106 self.iteration_started = Some(now);
107 }
108 return;
109 }
110
111 match topic {
113 "task.start" => {
114 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 }
145 }
146 }
147
148 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 pub fn get_loop_elapsed(&self) -> Option<Duration> {
157 self.loop_started.map(|start| start.elapsed())
158 }
159
160 pub fn get_iteration_elapsed(&self) -> Option<Duration> {
162 self.iteration_started.map(|start| start.elapsed())
163 }
164
165 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 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 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 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; }
226 }
227
228 #[test]
229 fn custom_hat_topics_update_pending_hat() {
230 use std::collections::HashMap;
232
233 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 let event = Event::new("review.security", "Review PR #123");
248 state.update(&event);
249
250 assert_eq!(
252 state.get_pending_hat_display(),
253 "🔒 Security Reviewer",
254 "Should display security reviewer hat for review.security topic"
255 );
256
257 let event = Event::new("review.correctness", "Check logic");
259 state.update(&event);
260
261 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 let mut state = TuiState::new();
273
274 state.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
276
277 let event = Event::new("unknown.topic", "Some payload");
279 state.update(&event);
280
281 assert_eq!(
283 state.get_pending_hat_display(),
284 "📋Planner",
285 "Unknown topics should not clear pending_hat"
286 );
287 }
288}