1use ralph_proto::{Event, HatId};
4use std::collections::HashMap;
5use std::time::{Duration, Instant};
6
7#[derive(Debug, Clone, Default)]
14pub struct TaskSummary {
15 pub id: String,
17 pub title: String,
19 pub status: String,
21}
22
23impl TaskSummary {
24 pub fn new(id: impl Into<String>, title: impl Into<String>, status: impl Into<String>) -> Self {
26 Self {
27 id: id.into(),
28 title: title.into(),
29 status: status.into(),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Default)]
40pub struct TaskCounts {
41 pub total: usize,
43 pub open: usize,
45 pub closed: usize,
47 pub ready: usize,
49}
50
51impl TaskCounts {
52 pub fn new(total: usize, open: usize, closed: usize, ready: usize) -> Self {
54 Self {
55 total,
56 open,
57 closed,
58 ready,
59 }
60 }
61}
62
63#[derive(Debug, Default)]
70pub struct SearchState {
71 pub query: Option<String>,
73 pub matches: Vec<(usize, usize)>,
75 pub current_match: usize,
77 pub search_mode: bool,
79}
80
81impl SearchState {
82 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn clear(&mut self) {
89 self.query = None;
90 self.matches.clear();
91 self.current_match = 0;
92 self.search_mode = false;
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum GuidanceMode {
99 Next,
101 Now,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum GuidanceResult {
108 Queued,
110 Sent,
112 Failed,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum UpdateStatus {
119 Unknown,
121 UpToDate,
123 Available { latest: String },
125}
126
127pub struct WaveInfo {
135 pub hat_name: String,
136 pub total: u32,
137 pub completed: u32,
138 pub started_at: Instant,
139 pub worker_buffers: Vec<IterationBuffer>,
141}
142
143impl std::fmt::Debug for WaveInfo {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("WaveInfo")
146 .field("hat_name", &self.hat_name)
147 .field("total", &self.total)
148 .field("completed", &self.completed)
149 .field("started_at", &self.started_at)
150 .field("worker_buffers_len", &self.worker_buffers.len())
151 .finish()
152 }
153}
154
155impl WaveInfo {
156 pub fn new(hat_name: String, total: u32) -> Self {
158 let worker_buffers = (0..total)
159 .map(|i| {
160 let mut buf = IterationBuffer::new(i + 1);
161 buf.hat_display = Some(format!("Worker {}/{}", i + 1, total));
162 buf
163 })
164 .collect();
165 Self {
166 hat_name,
167 total,
168 completed: 0,
169 started_at: Instant::now(),
170 worker_buffers,
171 }
172 }
173}
174
175pub struct TuiState {
177 pub pending_hat: Option<(HatId, String)>,
179 pub pending_backend: Option<String>,
181 pub iteration: u32,
183 pub prev_iteration: u32,
185 pub loop_started: Option<Instant>,
187 pub iteration_started: Option<Instant>,
189 pub last_event: Option<String>,
191 pub last_event_at: Option<Instant>,
193 pub show_help: bool,
195 pub mouse_capture_enabled: bool,
198 pub in_scroll_mode: bool,
200 pub search_query: String,
202 pub search_forward: bool,
204 pub max_iterations: Option<u32>,
206 pub idle_timeout_remaining: Option<Duration>,
208 pub update_status: UpdateStatus,
210 current_branch: Option<String>,
212 hat_map: HashMap<String, (HatId, String)>,
216
217 pub iterations: Vec<IterationBuffer>,
222 pub current_view: usize,
224 pub following_latest: bool,
226 pub new_iteration_alert: Option<usize>,
229
230 pub search_state: SearchState,
235
236 pub loop_completed: bool,
241 pub final_iteration_elapsed: Option<Duration>,
243 pub final_loop_elapsed: Option<Duration>,
245
246 pub task_counts: TaskCounts,
251 pub active_task: Option<TaskSummary>,
253
254 pub wave_active: Option<WaveInfo>,
259 pub wave_active_iteration_idx: Option<usize>,
263 pub wave_view_active: bool,
265 pub wave_view_index: usize,
267
268 pub guidance_mode: Option<GuidanceMode>,
273 pub guidance_input: String,
275 pub guidance_next_queue: Arc<Mutex<Vec<String>>>,
277 pub events_path: Option<std::path::PathBuf>,
279 pub urgent_steer_path: Option<std::path::PathBuf>,
281 pub guidance_flash: Option<(GuidanceMode, GuidanceResult, Instant)>,
284
285 pub subprocess_error: Option<String>,
291
292 pub rpc_text_buffer: String,
299 pub rpc_text_line_count: usize,
303}
304
305impl TuiState {
306 pub fn new() -> Self {
308 Self {
309 pending_hat: None,
310 pending_backend: None,
311 iteration: 0,
312 prev_iteration: 0,
313 loop_started: Some(Instant::now()),
314 iteration_started: None,
315 last_event: None,
316 last_event_at: None,
317 show_help: false,
318 mouse_capture_enabled: false,
319 in_scroll_mode: false,
320 search_query: String::new(),
321 search_forward: true,
322 max_iterations: None,
323 idle_timeout_remaining: None,
324 update_status: UpdateStatus::Unknown,
325 current_branch: None,
326 hat_map: HashMap::new(),
327 iterations: Vec::new(),
329 current_view: 0,
330 following_latest: true,
331 new_iteration_alert: None,
332 search_state: SearchState::new(),
334 loop_completed: false,
336 final_iteration_elapsed: None,
337 final_loop_elapsed: None,
338 task_counts: TaskCounts::default(),
340 active_task: None,
341 wave_active: None,
343 wave_active_iteration_idx: None,
344 wave_view_active: false,
345 wave_view_index: 0,
346 guidance_mode: None,
348 guidance_input: String::new(),
349 guidance_next_queue: Arc::new(Mutex::new(Vec::new())),
350 events_path: None,
351 urgent_steer_path: None,
352 guidance_flash: None,
353 subprocess_error: None,
355 rpc_text_buffer: String::new(),
357 rpc_text_line_count: 0,
358 }
359 }
360
361 pub fn with_hat_map(hat_map: HashMap<String, (HatId, String)>) -> Self {
364 let mut state = Self::new();
365 state.hat_map = hat_map;
366 state
367 }
368
369 pub fn set_current_branch(&mut self, branch: Option<String>) {
371 self.current_branch = branch;
372 }
373
374 pub fn current_branch(&self) -> Option<&str> {
376 self.current_branch.as_deref()
377 }
378
379 pub fn set_hat_map(&mut self, hat_map: HashMap<String, (HatId, String)>) {
381 self.hat_map = hat_map;
382 }
383
384 pub fn update(&mut self, event: &Event) {
386 let now = Instant::now();
387 let topic = event.topic.as_str();
388
389 self.last_event = Some(topic.to_string());
390 self.last_event_at = Some(now);
391
392 let custom_hat = self.hat_map.get(topic).cloned();
393 if let Some((hat_id, hat_display)) = custom_hat.clone() {
394 self.pending_hat = Some((hat_id, hat_display));
395 if topic.starts_with("build.") {
397 self.iteration_started = Some(now);
398 }
399 }
400
401 match topic {
403 "task.start" => {
404 let saved_hat_map = std::mem::take(&mut self.hat_map);
406 let saved_loop_started = self.loop_started; let saved_max_iterations = self.max_iterations;
408 let saved_iterations = std::mem::take(&mut self.iterations);
410 let saved_current_view = self.current_view;
411 let saved_following_latest = self.following_latest;
412 let saved_new_iteration_alert = self.new_iteration_alert.take();
413 let saved_pending_backend = self.pending_backend.clone();
414 let saved_current_branch = self.current_branch.clone();
415 let saved_guidance_next_queue = Arc::clone(&self.guidance_next_queue);
416 let saved_events_path = self.events_path.clone();
417 let saved_urgent_steer_path = self.urgent_steer_path.clone();
418 *self = Self::new();
419 self.hat_map = saved_hat_map;
420 self.loop_started = saved_loop_started; self.max_iterations = saved_max_iterations;
422 self.iterations = saved_iterations;
423 self.current_view = saved_current_view;
424 self.following_latest = saved_following_latest;
425 self.new_iteration_alert = saved_new_iteration_alert;
426 self.pending_backend = saved_pending_backend;
427 self.current_branch = saved_current_branch;
428 self.guidance_next_queue = saved_guidance_next_queue;
429 self.events_path = saved_events_path;
430 self.urgent_steer_path = saved_urgent_steer_path;
431 if let Some((hat_id, hat_display)) = custom_hat {
432 self.pending_hat = Some((hat_id, hat_display));
433 } else {
434 self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
435 }
436 self.last_event = Some(topic.to_string());
437 self.last_event_at = Some(now);
438 }
439 "task.resume" => {
440 if custom_hat.is_none() {
442 self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
443 }
444 }
445 "build.task" => {
446 if custom_hat.is_none() {
447 self.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
448 }
449 self.final_loop_elapsed = None;
451 self.iteration_started = Some(now);
452 }
453 "build.done" => {
454 if custom_hat.is_none() {
455 self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
456 }
457 self.prev_iteration = self.iteration;
458 self.iteration += 1;
459 self.finish_latest_iteration();
460 self.freeze_loop_elapsed();
461 }
462 "build.blocked" => {
463 if custom_hat.is_none() {
464 self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
465 }
466 self.finish_latest_iteration();
467 self.freeze_loop_elapsed();
468 }
469 "loop.terminate" => {
470 self.pending_hat = None;
471 self.loop_completed = true;
472 self.final_iteration_elapsed = self.iteration_started.map(|start| start.elapsed());
474 self.freeze_loop_elapsed();
476 self.finish_latest_iteration();
477 }
478 _ => {
479 }
481 }
482 }
483
484 pub fn get_pending_hat_display(&self) -> String {
486 self.pending_hat
487 .as_ref()
488 .map_or_else(|| "—".to_string(), |(_, display)| display.clone())
489 }
490
491 pub fn get_loop_elapsed(&self) -> Option<Duration> {
493 if let Some(final_elapsed) = self.final_loop_elapsed {
494 return Some(final_elapsed);
495 }
496 self.loop_started.map(|start| start.elapsed())
497 }
498
499 pub fn get_iteration_elapsed(&self) -> Option<Duration> {
501 if let Some(buffer) = self.current_iteration() {
502 if let Some(elapsed) = buffer.elapsed {
503 return Some(elapsed);
504 }
505 if let Some(started_at) = buffer.started_at {
506 return Some(started_at.elapsed());
507 }
508 }
509 if let Some(final_elapsed) = self.final_iteration_elapsed {
510 return Some(final_elapsed);
511 }
512 self.iteration_started.map(|start| start.elapsed())
513 }
514
515 pub fn is_active(&self) -> bool {
517 self.last_event_at
518 .is_some_and(|t| t.elapsed() < Duration::from_secs(2))
519 }
520
521 pub fn iteration_changed(&self) -> bool {
523 self.iteration != self.prev_iteration
524 }
525
526 pub fn get_task_counts(&self) -> &TaskCounts {
532 &self.task_counts
533 }
534
535 pub fn get_active_task(&self) -> Option<&TaskSummary> {
537 self.active_task.as_ref()
538 }
539
540 pub fn set_task_counts(&mut self, counts: TaskCounts) {
542 self.task_counts = counts;
543 }
544
545 pub fn set_active_task(&mut self, task: Option<TaskSummary>) {
547 self.active_task = task;
548 }
549
550 pub fn has_open_tasks(&self) -> bool {
552 self.task_counts.open > 0
553 }
554
555 pub fn get_task_progress_display(&self) -> String {
557 if self.task_counts.total == 0 {
558 "No tasks".to_string()
559 } else {
560 format!(
561 "{}/{} tasks",
562 self.task_counts.closed, self.task_counts.total
563 )
564 }
565 }
566
567 pub fn start_new_iteration(&mut self) {
575 self.start_new_iteration_with_metadata(None, None);
576 }
577
578 pub fn start_new_iteration_with_metadata(
580 &mut self,
581 hat_display: Option<String>,
582 backend: Option<String>,
583 ) {
584 self.rpc_text_buffer.clear();
586 self.rpc_text_line_count = 0;
587
588 self.wave_view_active = false;
592
593 self.final_loop_elapsed = None;
596
597 let hat_display = hat_display.or_else(|| {
598 self.pending_hat
599 .as_ref()
600 .map(|(_, display)| display.clone())
601 });
602 let backend = backend.or_else(|| self.pending_backend.clone());
603 let number = (self.iterations.len() + 1) as u32;
604 let mut buffer = IterationBuffer::new(number);
605 buffer.hat_display = hat_display;
606 buffer.backend = backend;
607 buffer.started_at = Some(Instant::now());
608 if buffer.backend.is_some() {
609 self.pending_backend = buffer.backend.clone();
610 }
611 self.iterations.push(buffer);
612
613 if self.following_latest {
615 self.current_view = self.iterations.len().saturating_sub(1);
616 } else {
617 self.new_iteration_alert = Some(number as usize);
619 }
620 }
621
622 pub fn finish_latest_iteration(&mut self) {
624 let Some(buffer) = self.iterations.last_mut() else {
625 return;
626 };
627 if buffer.elapsed.is_some() {
628 return;
629 }
630 if let Some(started_at) = buffer.started_at {
631 buffer.elapsed = Some(started_at.elapsed());
632 }
633 }
634
635 fn freeze_loop_elapsed(&mut self) {
637 if self.final_loop_elapsed.is_some() {
638 return;
639 }
640 self.final_loop_elapsed = self.loop_started.map(|start| start.elapsed());
641 }
642
643 pub fn current_iteration_hat_display(&self) -> Option<&str> {
645 self.current_iteration()
646 .and_then(|buffer| buffer.hat_display.as_deref())
647 }
648
649 pub fn current_iteration_backend(&self) -> Option<&str> {
651 self.current_iteration()
652 .and_then(|buffer| buffer.backend.as_deref())
653 }
654
655 pub fn current_iteration(&self) -> Option<&IterationBuffer> {
657 self.iterations.get(self.current_view)
658 }
659
660 pub fn current_iteration_mut(&mut self) -> Option<&mut IterationBuffer> {
662 self.iterations.get_mut(self.current_view)
663 }
664
665 pub fn current_iteration_lines_handle(
670 &self,
671 ) -> Option<std::sync::Arc<std::sync::Mutex<Vec<Line<'static>>>>> {
672 self.iterations
673 .get(self.current_view)
674 .map(|buffer| buffer.lines_handle())
675 }
676
677 pub fn latest_iteration_lines_handle(
684 &self,
685 ) -> Option<std::sync::Arc<std::sync::Mutex<Vec<Line<'static>>>>> {
686 self.iterations.last().map(|buffer| buffer.lines_handle())
687 }
688
689 pub fn navigate_next(&mut self) {
692 if self.iterations.is_empty() {
693 return;
694 }
695 let max_index = self.iterations.len().saturating_sub(1);
696 if self.current_view < max_index {
697 self.current_view += 1;
698 if self.current_view == max_index {
700 self.following_latest = true;
701 self.new_iteration_alert = None;
702 }
703 }
704 }
705
706 pub fn navigate_prev(&mut self) {
709 if self.current_view > 0 {
710 self.current_view -= 1;
711 self.following_latest = false;
712 }
713 }
714
715 pub fn total_iterations(&self) -> usize {
717 self.iterations.len()
718 }
719
720 pub fn search(&mut self, query: &str) {
728 self.search_state.query = Some(query.to_string());
729 self.search_state.matches.clear();
730 self.search_state.current_match = 0;
731
732 if self.iterations.get(self.current_view).is_none() {
734 return;
735 }
736
737 let query_lower = query.to_lowercase();
738
739 let matches: Vec<(usize, usize)> = self
741 .iterations
742 .get(self.current_view)
743 .and_then(|buffer| {
744 let lines = buffer.lines.lock().ok()?;
745 let mut found = Vec::new();
746 for (line_idx, line) in lines.iter().enumerate() {
747 let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
749 let line_lower = line_text.to_lowercase();
750
751 let mut search_start = 0;
753 while let Some(pos) = line_lower[search_start..].find(&query_lower) {
754 let char_offset = search_start + pos;
755 found.push((line_idx, char_offset));
756 search_start = char_offset + query_lower.len();
757 }
758 }
759 Some(found)
760 })
761 .unwrap_or_default();
762
763 self.search_state.matches = matches;
764
765 if !self.search_state.matches.is_empty() {
767 self.jump_to_current_match();
768 }
769 }
770
771 pub fn next_match(&mut self) {
773 if self.search_state.matches.is_empty() {
774 return;
775 }
776
777 self.search_state.current_match =
778 (self.search_state.current_match + 1) % self.search_state.matches.len();
779 self.jump_to_current_match();
780 }
781
782 pub fn prev_match(&mut self) {
784 if self.search_state.matches.is_empty() {
785 return;
786 }
787
788 if self.search_state.current_match == 0 {
789 self.search_state.current_match = self.search_state.matches.len() - 1;
790 } else {
791 self.search_state.current_match -= 1;
792 }
793 self.jump_to_current_match();
794 }
795
796 pub fn clear_search(&mut self) {
798 self.search_state.clear();
799 }
800
801 fn jump_to_current_match(&mut self) {
803 if self.search_state.matches.is_empty() {
804 return;
805 }
806
807 let (line_idx, _) = self.search_state.matches[self.search_state.current_match];
808
809 let viewport_height = 20;
812 if let Some(buffer) = self.current_iteration_mut() {
813 if line_idx < buffer.scroll_offset {
815 buffer.scroll_offset = line_idx;
816 }
817 else if line_idx >= buffer.scroll_offset + viewport_height {
819 buffer.scroll_offset = line_idx.saturating_sub(viewport_height / 2);
820 }
821 }
822 }
823
824 pub fn start_guidance(&mut self, mode: GuidanceMode) {
830 self.guidance_mode = Some(mode);
831 self.guidance_input.clear();
832 self.guidance_flash = None;
833 }
834
835 pub fn cancel_guidance(&mut self) {
837 self.guidance_mode = None;
838 self.guidance_input.clear();
839 }
840
841 pub fn send_guidance(&mut self) -> bool {
849 let input = self.guidance_input.trim().to_string();
850 if input.is_empty() {
851 self.cancel_guidance();
852 return false;
853 }
854
855 let mode = match self.guidance_mode {
856 Some(m) => m,
857 None => return false,
858 };
859
860 let (ok, result) = match mode {
861 GuidanceMode::Next => {
862 if let Ok(mut queue) = self.guidance_next_queue.lock() {
863 queue.push(input);
864 (true, GuidanceResult::Queued)
865 } else {
866 (false, GuidanceResult::Failed)
867 }
868 }
869 GuidanceMode::Now => {
870 let ok =
871 self.write_urgent_steer_marker(&input) && self.write_guidance_event(&input);
872 if ok {
873 (true, GuidanceResult::Sent)
874 } else {
875 (false, GuidanceResult::Failed)
876 }
877 }
878 };
879
880 self.guidance_flash = Some((mode, result, Instant::now()));
881 self.guidance_mode = None;
882 self.guidance_input.clear();
883 ok
884 }
885
886 fn write_guidance_event(&self, message: &str) -> bool {
888 let Some(ref path) = self.events_path else {
889 return false;
890 };
891
892 let timestamp = chrono::Utc::now().to_rfc3339();
893 let event = serde_json::json!({
894 "topic": "human.guidance",
895 "payload": message,
896 "ts": timestamp,
897 });
898
899 let line = match serde_json::to_string(&event) {
900 Ok(l) => l,
901 Err(_) => return false,
902 };
903
904 use std::io::Write;
905 let mut file = match std::fs::OpenOptions::new()
906 .create(true)
907 .append(true)
908 .open(path)
909 {
910 Ok(f) => f,
911 Err(_) => return false,
912 };
913
914 file.write_all(line.as_bytes()).is_ok() && file.write_all(b"\n").is_ok()
915 }
916
917 fn write_urgent_steer_marker(&self, message: &str) -> bool {
918 let Some(ref path) = self.urgent_steer_path else {
919 return false;
920 };
921
922 ralph_core::UrgentSteerStore::new(path.clone())
923 .append_message(message.to_string())
924 .is_ok()
925 }
926
927 pub fn is_guidance_active(&self) -> bool {
929 self.guidance_mode.is_some()
930 }
931
932 pub fn clear_expired_guidance_flash(&mut self) {
934 if let Some((_, _, when)) = self.guidance_flash
935 && when.elapsed() >= Duration::from_secs(2)
936 {
937 self.guidance_flash = None;
938 }
939 }
940
941 pub fn active_guidance_flash(&self) -> Option<(GuidanceMode, GuidanceResult)> {
943 self.guidance_flash.and_then(|(mode, result, when)| {
944 if when.elapsed() < Duration::from_secs(2) {
945 Some((mode, result))
946 } else {
947 None
948 }
949 })
950 }
951
952 pub fn set_update_status(&mut self, status: UpdateStatus) {
954 self.update_status = status;
955 }
956
957 fn wave_info_for_view(&self) -> Option<&WaveInfo> {
967 if let Some(wave_iter) = self.wave_active_iteration_idx
969 && self.current_view == wave_iter
970 && self.wave_active.is_some()
971 {
972 return self.wave_active.as_ref();
973 }
974 self.iterations
976 .get(self.current_view)
977 .and_then(|buf| buf.wave_info.as_ref())
978 }
979
980 fn wave_info_for_view_mut(&mut self) -> Option<&mut WaveInfo> {
982 if let Some(wave_iter) = self.wave_active_iteration_idx
983 && self.current_view == wave_iter
984 && self.wave_active.is_some()
985 {
986 return self.wave_active.as_mut();
987 }
988 let idx = self.current_view;
989 self.iterations
990 .get_mut(idx)
991 .and_then(|buf| buf.wave_info.as_mut())
992 }
993
994 pub fn wave_info_for_wave_view(&self) -> Option<&WaveInfo> {
996 self.wave_info_for_view()
997 }
998
999 pub fn enter_wave_view(&mut self) {
1001 if self.wave_info_for_view().is_some() {
1002 self.wave_view_active = true;
1003 self.wave_view_index = 0;
1004 }
1005 }
1006
1007 pub fn exit_wave_view(&mut self) {
1009 self.wave_view_active = false;
1010 }
1011
1012 pub fn wave_view_next(&mut self) {
1014 if let Some(wave) = self.wave_info_for_view() {
1015 let total = wave.worker_buffers.len();
1016 if total > 0 {
1017 self.wave_view_index = (self.wave_view_index + 1) % total;
1018 }
1019 }
1020 }
1021
1022 pub fn wave_view_prev(&mut self) {
1024 if let Some(wave) = self.wave_info_for_view() {
1025 let total = wave.worker_buffers.len();
1026 if total > 0 {
1027 if self.wave_view_index == 0 {
1028 self.wave_view_index = total - 1;
1029 } else {
1030 self.wave_view_index -= 1;
1031 }
1032 }
1033 }
1034 }
1035
1036 pub fn current_wave_worker_buffer(&self) -> Option<&IterationBuffer> {
1038 self.wave_info_for_view()
1039 .and_then(|w| w.worker_buffers.get(self.wave_view_index))
1040 }
1041
1042 pub fn current_wave_worker_buffer_mut(&mut self) -> Option<&mut IterationBuffer> {
1044 let idx = self.wave_view_index;
1045 self.wave_info_for_view_mut()
1046 .and_then(|w| w.worker_buffers.get_mut(idx))
1047 }
1048}
1049
1050impl Default for TuiState {
1051 fn default() -> Self {
1052 Self::new()
1053 }
1054}
1055
1056use ratatui::text::Line;
1061use std::sync::{Arc, Mutex};
1062
1063pub struct IterationBuffer {
1070 pub number: u32,
1072 pub lines: Arc<Mutex<Vec<Line<'static>>>>,
1074 pub scroll_offset: usize,
1076 pub following_bottom: bool,
1080 pub hat_display: Option<String>,
1082 pub backend: Option<String>,
1084 pub started_at: Option<Instant>,
1086 pub elapsed: Option<Duration>,
1088 pub wave_info: Option<WaveInfo>,
1090}
1091
1092impl IterationBuffer {
1093 pub fn new(number: u32) -> Self {
1095 Self {
1096 number,
1097 lines: Arc::new(Mutex::new(Vec::new())),
1098 scroll_offset: 0,
1099 following_bottom: true, hat_display: None,
1101 backend: None,
1102 started_at: None,
1103 elapsed: None,
1104 wave_info: None,
1105 }
1106 }
1107
1108 pub fn lines_handle(&self) -> Arc<Mutex<Vec<Line<'static>>>> {
1113 Arc::clone(&self.lines)
1114 }
1115
1116 pub fn append_line(&mut self, line: Line<'static>) {
1118 if let Ok(mut lines) = self.lines.lock() {
1119 lines.push(line);
1120 }
1121 }
1122
1123 pub fn line_count(&self) -> usize {
1125 self.lines.lock().map(|l| l.len()).unwrap_or(0)
1126 }
1127
1128 pub fn visible_lines(&self, viewport_height: usize) -> Vec<Line<'static>> {
1132 let Ok(lines) = self.lines.lock() else {
1133 return Vec::new();
1134 };
1135 if lines.is_empty() {
1136 return Vec::new();
1137 }
1138 let start = self.scroll_offset.min(lines.len());
1139 let end = (start + viewport_height).min(lines.len());
1140 lines[start..end].to_vec()
1141 }
1142
1143 pub fn scroll_up(&mut self) {
1146 self.scroll_offset = self.scroll_offset.saturating_sub(1);
1147 self.following_bottom = false;
1148 }
1149
1150 pub fn scroll_down(&mut self, viewport_height: usize) {
1153 let max_scroll = self.max_scroll_offset(viewport_height);
1154 if self.scroll_offset < max_scroll {
1155 self.scroll_offset += 1;
1156 }
1157 if self.scroll_offset >= max_scroll {
1159 self.following_bottom = true;
1160 }
1161 }
1162
1163 pub fn scroll_top(&mut self) {
1166 self.scroll_offset = 0;
1167 self.following_bottom = false;
1168 }
1169
1170 pub fn scroll_bottom(&mut self, viewport_height: usize) {
1173 self.scroll_offset = self.max_scroll_offset(viewport_height);
1174 self.following_bottom = true;
1175 }
1176
1177 fn max_scroll_offset(&self, viewport_height: usize) -> usize {
1179 self.lines
1180 .lock()
1181 .map(|l| l.len().saturating_sub(viewport_height))
1182 .unwrap_or(0)
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189
1190 mod iteration_buffer {
1195 use super::*;
1196 use ratatui::text::Line;
1197
1198 #[test]
1199 fn new_creates_buffer_with_correct_initial_state() {
1200 let buffer = IterationBuffer::new(1);
1201 assert_eq!(buffer.number, 1);
1202 assert_eq!(buffer.line_count(), 0);
1203 assert_eq!(buffer.scroll_offset, 0);
1204 }
1205
1206 #[test]
1207 fn append_line_adds_lines_in_order() {
1208 let mut buffer = IterationBuffer::new(1);
1209 buffer.append_line(Line::from("first"));
1210 buffer.append_line(Line::from("second"));
1211 buffer.append_line(Line::from("third"));
1212
1213 assert_eq!(buffer.line_count(), 3);
1214 let lines = buffer.lines.lock().unwrap();
1216 assert_eq!(lines[0].spans[0].content, "first");
1217 assert_eq!(lines[1].spans[0].content, "second");
1218 assert_eq!(lines[2].spans[0].content, "third");
1219 }
1220
1221 #[test]
1222 fn line_count_returns_correct_count() {
1223 let mut buffer = IterationBuffer::new(1);
1224 assert_eq!(buffer.line_count(), 0);
1225
1226 for i in 0..10 {
1227 buffer.append_line(Line::from(format!("line {}", i)));
1228 }
1229 assert_eq!(buffer.line_count(), 10);
1230 }
1231
1232 #[test]
1233 fn visible_lines_returns_correct_slice_without_scroll() {
1234 let mut buffer = IterationBuffer::new(1);
1235 for i in 0..10 {
1236 buffer.append_line(Line::from(format!("line {}", i)));
1237 }
1238
1239 let visible = buffer.visible_lines(5);
1240 assert_eq!(visible.len(), 5);
1241 assert_eq!(visible[0].spans[0].content, "line 0");
1243 assert_eq!(visible[4].spans[0].content, "line 4");
1244 }
1245
1246 #[test]
1247 fn visible_lines_returns_correct_slice_with_scroll() {
1248 let mut buffer = IterationBuffer::new(1);
1249 for i in 0..10 {
1250 buffer.append_line(Line::from(format!("line {}", i)));
1251 }
1252 buffer.scroll_offset = 3;
1253
1254 let visible = buffer.visible_lines(5);
1255 assert_eq!(visible.len(), 5);
1256 assert_eq!(visible[0].spans[0].content, "line 3");
1258 assert_eq!(visible[4].spans[0].content, "line 7");
1259 }
1260
1261 #[test]
1262 fn visible_lines_handles_viewport_larger_than_content() {
1263 let mut buffer = IterationBuffer::new(1);
1264 for i in 0..3 {
1265 buffer.append_line(Line::from(format!("line {}", i)));
1266 }
1267
1268 let visible = buffer.visible_lines(10);
1269 assert_eq!(visible.len(), 3); }
1271
1272 #[test]
1273 fn visible_lines_handles_empty_buffer() {
1274 let buffer = IterationBuffer::new(1);
1275 let visible = buffer.visible_lines(5);
1276 assert!(visible.is_empty());
1277 }
1278
1279 #[test]
1280 fn scroll_down_increases_offset() {
1281 let mut buffer = IterationBuffer::new(1);
1282 for i in 0..10 {
1283 buffer.append_line(Line::from(format!("line {}", i)));
1284 }
1285
1286 assert_eq!(buffer.scroll_offset, 0);
1287 buffer.scroll_down(5); assert_eq!(buffer.scroll_offset, 1);
1289 buffer.scroll_down(5);
1290 assert_eq!(buffer.scroll_offset, 2);
1291 }
1292
1293 #[test]
1294 fn scroll_up_decreases_offset() {
1295 let mut buffer = IterationBuffer::new(1);
1296 for _ in 0..10 {
1297 buffer.append_line(Line::from("line"));
1298 }
1299 buffer.scroll_offset = 5;
1300
1301 buffer.scroll_up();
1302 assert_eq!(buffer.scroll_offset, 4);
1303 buffer.scroll_up();
1304 assert_eq!(buffer.scroll_offset, 3);
1305 }
1306
1307 #[test]
1308 fn scroll_up_does_not_underflow() {
1309 let mut buffer = IterationBuffer::new(1);
1310 buffer.append_line(Line::from("line"));
1311 buffer.scroll_offset = 0;
1312
1313 buffer.scroll_up();
1314 assert_eq!(buffer.scroll_offset, 0); }
1316
1317 #[test]
1318 fn scroll_down_does_not_overflow() {
1319 let mut buffer = IterationBuffer::new(1);
1320 for _ in 0..10 {
1321 buffer.append_line(Line::from("line"));
1322 }
1323 buffer.scroll_offset = 5;
1325
1326 buffer.scroll_down(5);
1327 assert_eq!(buffer.scroll_offset, 5); }
1329
1330 #[test]
1331 fn scroll_top_resets_to_zero() {
1332 let mut buffer = IterationBuffer::new(1);
1333 for _ in 0..10 {
1334 buffer.append_line(Line::from("line"));
1335 }
1336 buffer.scroll_offset = 5;
1337
1338 buffer.scroll_top();
1339 assert_eq!(buffer.scroll_offset, 0);
1340 }
1341
1342 #[test]
1343 fn scroll_bottom_sets_to_max() {
1344 let mut buffer = IterationBuffer::new(1);
1345 for _ in 0..10 {
1346 buffer.append_line(Line::from("line"));
1347 }
1348
1349 buffer.scroll_bottom(5); assert_eq!(buffer.scroll_offset, 5); }
1352
1353 #[test]
1354 fn scroll_bottom_handles_small_content() {
1355 let mut buffer = IterationBuffer::new(1);
1356 for _ in 0..3 {
1357 buffer.append_line(Line::from("line"));
1358 }
1359
1360 buffer.scroll_bottom(5); assert_eq!(buffer.scroll_offset, 0); }
1363
1364 #[test]
1365 fn scroll_down_handles_empty_buffer() {
1366 let mut buffer = IterationBuffer::new(1);
1367 buffer.scroll_down(5);
1368 assert_eq!(buffer.scroll_offset, 0);
1369 }
1370
1371 #[test]
1376 fn following_bottom_is_true_initially() {
1377 let buffer = IterationBuffer::new(1);
1378 assert!(
1379 buffer.following_bottom,
1380 "New buffer should start with following_bottom = true"
1381 );
1382 }
1383
1384 #[test]
1385 fn scroll_up_disables_following_bottom() {
1386 let mut buffer = IterationBuffer::new(1);
1387 for _ in 0..10 {
1388 buffer.append_line(Line::from("line"));
1389 }
1390 buffer.scroll_offset = 5;
1391 assert!(buffer.following_bottom);
1392
1393 buffer.scroll_up();
1394
1395 assert!(
1396 !buffer.following_bottom,
1397 "scroll_up should disable following_bottom"
1398 );
1399 }
1400
1401 #[test]
1402 fn scroll_top_disables_following_bottom() {
1403 let mut buffer = IterationBuffer::new(1);
1404 for _ in 0..10 {
1405 buffer.append_line(Line::from("line"));
1406 }
1407 assert!(buffer.following_bottom);
1408
1409 buffer.scroll_top();
1410
1411 assert!(
1412 !buffer.following_bottom,
1413 "scroll_top should disable following_bottom"
1414 );
1415 }
1416
1417 #[test]
1418 fn scroll_bottom_enables_following_bottom() {
1419 let mut buffer = IterationBuffer::new(1);
1420 for _ in 0..10 {
1421 buffer.append_line(Line::from("line"));
1422 }
1423 buffer.following_bottom = false;
1424
1425 buffer.scroll_bottom(5);
1426
1427 assert!(
1428 buffer.following_bottom,
1429 "scroll_bottom should enable following_bottom"
1430 );
1431 }
1432
1433 #[test]
1434 fn scroll_down_to_bottom_enables_following_bottom() {
1435 let mut buffer = IterationBuffer::new(1);
1436 for _ in 0..10 {
1437 buffer.append_line(Line::from("line"));
1438 }
1439 buffer.scroll_offset = 4; buffer.following_bottom = false;
1441
1442 buffer.scroll_down(5); assert!(
1445 buffer.following_bottom,
1446 "scroll_down to bottom should enable following_bottom"
1447 );
1448 }
1449
1450 #[test]
1451 fn scroll_down_not_at_bottom_keeps_following_false() {
1452 let mut buffer = IterationBuffer::new(1);
1453 for _ in 0..10 {
1454 buffer.append_line(Line::from("line"));
1455 }
1456 buffer.scroll_offset = 0;
1457 buffer.following_bottom = false;
1458
1459 buffer.scroll_down(5); assert!(
1462 !buffer.following_bottom,
1463 "scroll_down not reaching bottom should keep following_bottom false"
1464 );
1465 }
1466
1467 #[test]
1468 fn autoscroll_scenario_content_grows_past_viewport() {
1469 let mut buffer = IterationBuffer::new(1);
1471
1472 for _ in 0..5 {
1474 buffer.append_line(Line::from("line"));
1475 }
1476
1477 let viewport = 20;
1479 assert!(buffer.following_bottom);
1480 assert_eq!(buffer.scroll_offset, 0);
1481
1482 if buffer.following_bottom {
1484 let max_scroll = buffer.line_count().saturating_sub(viewport);
1485 buffer.scroll_offset = max_scroll;
1486 }
1487 assert_eq!(buffer.scroll_offset, 0); for _ in 0..25 {
1491 buffer.append_line(Line::from("more content"));
1492 }
1493 if buffer.following_bottom {
1498 let max_scroll = buffer.line_count().saturating_sub(viewport);
1499 buffer.scroll_offset = max_scroll;
1500 }
1501
1502 assert_eq!(
1504 buffer.scroll_offset, 10,
1505 "Auto-scroll should move to bottom when content grows past viewport"
1506 );
1507 }
1508 }
1509
1510 #[test]
1515 fn iteration_changed_detects_boundary() {
1516 let mut state = TuiState::new();
1517 assert!(!state.iteration_changed(), "no change at start");
1518
1519 let event = Event::new("build.done", "");
1521 state.update(&event);
1522
1523 assert_eq!(state.iteration, 1);
1524 assert_eq!(state.prev_iteration, 0);
1525 assert!(state.iteration_changed(), "should detect iteration change");
1526 }
1527
1528 #[test]
1529 fn iteration_changed_resets_after_check() {
1530 let mut state = TuiState::new();
1531 let event = Event::new("build.done", "");
1532 state.update(&event);
1533
1534 assert!(state.iteration_changed());
1535
1536 state.prev_iteration = state.iteration;
1538 assert!(!state.iteration_changed(), "flag should reset");
1539 }
1540
1541 #[test]
1542 fn multiple_iterations_tracked() {
1543 let mut state = TuiState::new();
1544
1545 for i in 1..=3 {
1546 let event = Event::new("build.done", "");
1547 state.update(&event);
1548 assert_eq!(state.iteration, i);
1549 assert!(state.iteration_changed());
1550 state.prev_iteration = state.iteration; }
1552 }
1553
1554 #[test]
1555 fn custom_hat_topics_update_pending_hat() {
1556 use std::collections::HashMap;
1558
1559 let mut hat_map = HashMap::new();
1561 hat_map.insert(
1562 "review.security".to_string(),
1563 (
1564 HatId::new("security_reviewer"),
1565 "🔒 Security Reviewer".to_string(),
1566 ),
1567 );
1568 hat_map.insert(
1569 "review.correctness".to_string(),
1570 (
1571 HatId::new("correctness_reviewer"),
1572 "🎯 Correctness Reviewer".to_string(),
1573 ),
1574 );
1575
1576 let mut state = TuiState::with_hat_map(hat_map);
1577
1578 let event = Event::new("review.security", "Review PR #123");
1580 state.update(&event);
1581
1582 assert_eq!(
1584 state.get_pending_hat_display(),
1585 "🔒 Security Reviewer",
1586 "Should display security reviewer hat for review.security topic"
1587 );
1588
1589 let event = Event::new("review.correctness", "Check logic");
1591 state.update(&event);
1592
1593 assert_eq!(
1595 state.get_pending_hat_display(),
1596 "🎯 Correctness Reviewer",
1597 "Should display correctness reviewer hat for review.correctness topic"
1598 );
1599 }
1600
1601 #[test]
1602 fn unknown_topics_keep_pending_hat_unchanged() {
1603 let mut state = TuiState::new();
1605
1606 state.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
1608
1609 let event = Event::new("unknown.topic", "Some payload");
1611 state.update(&event);
1612
1613 assert_eq!(
1615 state.get_pending_hat_display(),
1616 "📋Planner",
1617 "Unknown topics should not clear pending_hat"
1618 );
1619 }
1620
1621 #[test]
1622 fn task_start_preserves_iterations_across_reset() {
1623 let mut state = TuiState::new();
1627
1628 state.start_new_iteration();
1630 state.start_new_iteration();
1631 state.start_new_iteration();
1632 assert_eq!(state.total_iterations(), 3);
1633 assert_eq!(state.current_view, 2); state.navigate_prev();
1637 assert_eq!(state.current_view, 1);
1638 assert!(!state.following_latest);
1639
1640 let event = Event::new("task.start", "New task");
1642 state.update(&event);
1643
1644 assert_eq!(
1646 state.total_iterations(),
1647 3,
1648 "task.start should not wipe iteration buffers"
1649 );
1650 assert_eq!(
1651 state.current_view, 1,
1652 "task.start should preserve current_view position"
1653 );
1654 assert!(
1655 !state.following_latest,
1656 "task.start should preserve following_latest state"
1657 );
1658 }
1659
1660 #[test]
1661 fn loop_terminate_freezes_iteration_timer() {
1662 let mut state = TuiState::new();
1664 let start_event = Event::new("build.task", "");
1665 state.update(&start_event);
1666
1667 assert!(state.iteration_started.is_some());
1669 let elapsed_before = state.get_iteration_elapsed().unwrap();
1670 assert!(elapsed_before.as_nanos() > 0);
1671
1672 let terminate_event = Event::new("loop.terminate", "");
1674 state.update(&terminate_event);
1675
1676 assert!(state.loop_completed);
1678 assert!(state.final_iteration_elapsed.is_some());
1679
1680 let frozen_elapsed = state.get_iteration_elapsed().unwrap();
1682 std::thread::sleep(std::time::Duration::from_millis(10));
1683 let elapsed_after_sleep = state.get_iteration_elapsed().unwrap();
1684
1685 assert_eq!(
1686 frozen_elapsed, elapsed_after_sleep,
1687 "Timer should be frozen after loop.terminate"
1688 );
1689 }
1690
1691 #[test]
1692 fn loop_terminate_freezes_total_timer() {
1693 let mut state = TuiState::new();
1694 state.loop_started = Some(
1695 std::time::Instant::now()
1696 .checked_sub(std::time::Duration::from_secs(90))
1697 .unwrap(),
1698 );
1699
1700 let before = state.get_loop_elapsed().unwrap();
1701 assert!(before.as_secs() >= 90);
1702
1703 let terminate_event = Event::new("loop.terminate", "");
1704 state.update(&terminate_event);
1705
1706 let frozen = state.get_loop_elapsed().unwrap();
1707 std::thread::sleep(std::time::Duration::from_millis(10));
1708 let after = state.get_loop_elapsed().unwrap();
1709
1710 assert_eq!(
1711 frozen, after,
1712 "Loop elapsed time should be frozen after termination"
1713 );
1714 }
1715
1716 #[test]
1717 fn build_done_freezes_total_timer() {
1718 let mut state = TuiState::new();
1719 state.loop_started = Some(
1720 std::time::Instant::now()
1721 .checked_sub(std::time::Duration::from_secs(42))
1722 .unwrap(),
1723 );
1724
1725 let before = state.get_loop_elapsed().unwrap();
1726 assert!(before.as_secs() >= 42);
1727
1728 let done_event = Event::new("build.done", "");
1729 state.update(&done_event);
1730
1731 let frozen = state.get_loop_elapsed().unwrap();
1732 std::thread::sleep(std::time::Duration::from_millis(10));
1733 let after = state.get_loop_elapsed().unwrap();
1734
1735 assert_eq!(
1736 frozen, after,
1737 "Loop elapsed time should be frozen after build.done"
1738 );
1739 }
1740
1741 #[test]
1742 fn build_blocked_freezes_total_timer() {
1743 let mut state = TuiState::new();
1744 state.loop_started = Some(
1745 std::time::Instant::now()
1746 .checked_sub(std::time::Duration::from_secs(7))
1747 .unwrap(),
1748 );
1749
1750 let before = state.get_loop_elapsed().unwrap();
1751 assert!(before.as_secs() >= 7);
1752
1753 let blocked_event = Event::new("build.blocked", "");
1754 state.update(&blocked_event);
1755
1756 let frozen = state.get_loop_elapsed().unwrap();
1757 std::thread::sleep(std::time::Duration::from_millis(10));
1758 let after = state.get_loop_elapsed().unwrap();
1759
1760 assert_eq!(
1761 frozen, after,
1762 "Loop elapsed time should be frozen after build.blocked"
1763 );
1764 }
1765
1766 mod tui_state_iterations {
1771 use super::*;
1772
1773 #[test]
1774 fn start_new_iteration_creates_first_buffer() {
1775 let mut state = TuiState::new();
1777 assert_eq!(state.total_iterations(), 0);
1778
1779 state.start_new_iteration();
1781
1782 assert_eq!(state.total_iterations(), 1);
1784 assert_eq!(state.iterations[0].number, 1);
1785 }
1786
1787 #[test]
1788 fn start_new_iteration_creates_subsequent_buffers() {
1789 let mut state = TuiState::new();
1790 state.start_new_iteration();
1791 state.start_new_iteration();
1792 state.start_new_iteration();
1793
1794 assert_eq!(state.total_iterations(), 3);
1795 assert_eq!(state.iterations[0].number, 1);
1796 assert_eq!(state.iterations[1].number, 2);
1797 assert_eq!(state.iterations[2].number, 3);
1798 }
1799
1800 #[test]
1801 fn current_iteration_returns_correct_buffer() {
1802 let mut state = TuiState::new();
1804 state.start_new_iteration();
1805 state.start_new_iteration();
1806 state.start_new_iteration();
1807 state.current_view = 1;
1808
1809 let current = state.current_iteration();
1811
1812 assert!(current.is_some());
1814 assert_eq!(current.unwrap().number, 2);
1815 }
1816
1817 #[test]
1818 fn current_iteration_returns_none_when_empty() {
1819 let state = TuiState::new();
1820 assert!(state.current_iteration().is_none());
1821 }
1822
1823 #[test]
1824 fn current_iteration_mut_allows_modification() {
1825 let mut state = TuiState::new();
1826 state.start_new_iteration();
1827
1828 if let Some(buffer) = state.current_iteration_mut() {
1830 buffer.append_line(Line::from("test line"));
1831 }
1832
1833 assert_eq!(state.current_iteration().unwrap().line_count(), 1);
1835 }
1836
1837 #[test]
1838 fn navigate_next_increases_current_view() {
1839 let mut state = TuiState::new();
1841 state.start_new_iteration();
1842 state.start_new_iteration();
1843 state.start_new_iteration();
1844 state.current_view = 1;
1845 state.following_latest = false;
1846
1847 state.navigate_next();
1849
1850 assert_eq!(state.current_view, 2);
1852 }
1853
1854 #[test]
1855 fn navigate_prev_decreases_current_view() {
1856 let mut state = TuiState::new();
1858 state.start_new_iteration();
1859 state.start_new_iteration();
1860 state.start_new_iteration();
1861 state.current_view = 2;
1862
1863 state.navigate_prev();
1865
1866 assert_eq!(state.current_view, 1);
1868 }
1869
1870 #[test]
1871 fn navigate_next_does_not_exceed_bounds() {
1872 let mut state = TuiState::new();
1874 state.start_new_iteration();
1875 state.start_new_iteration();
1876 state.start_new_iteration();
1877 state.current_view = 2;
1878
1879 state.navigate_next();
1881
1882 assert_eq!(state.current_view, 2);
1884 }
1885
1886 #[test]
1887 fn navigate_prev_does_not_go_below_zero() {
1888 let mut state = TuiState::new();
1890 state.start_new_iteration();
1891 state.current_view = 0;
1892
1893 state.navigate_prev();
1895
1896 assert_eq!(state.current_view, 0);
1898 }
1899
1900 #[test]
1901 fn following_latest_initially_true() {
1902 let state = TuiState::new();
1905
1906 assert!(state.following_latest);
1908 }
1909
1910 #[test]
1911 fn following_latest_becomes_false_on_back_navigation() {
1912 let mut state = TuiState::new();
1914 state.start_new_iteration();
1915 state.start_new_iteration();
1916 state.start_new_iteration();
1917 state.current_view = 2;
1918 state.following_latest = true;
1919
1920 state.navigate_prev();
1922
1923 assert!(!state.following_latest);
1925 }
1926
1927 #[test]
1928 fn following_latest_restored_at_latest() {
1929 let mut state = TuiState::new();
1931 state.start_new_iteration();
1932 state.start_new_iteration();
1933 state.start_new_iteration();
1934 state.current_view = 1;
1935 state.following_latest = false;
1936
1937 state.navigate_next(); assert!(state.following_latest);
1942 }
1943
1944 #[test]
1945 fn total_iterations_reports_count() {
1946 let mut state = TuiState::new();
1948 state.start_new_iteration();
1949 state.start_new_iteration();
1950 state.start_new_iteration();
1951
1952 assert_eq!(state.total_iterations(), 3);
1955 }
1956
1957 #[test]
1958 fn start_new_iteration_auto_follows_latest() {
1959 let mut state = TuiState::new();
1960 state.following_latest = true;
1961 state.start_new_iteration();
1962 state.start_new_iteration();
1963
1964 assert_eq!(state.current_view, 1); }
1967
1968 #[test]
1973 fn per_iteration_scroll_independence() {
1974 let mut state = TuiState::new();
1976 state.start_new_iteration();
1977 state.start_new_iteration();
1978
1979 state.iterations[0].scroll_offset = 5;
1981 state.iterations[1].scroll_offset = 0;
1982
1983 state.current_view = 0;
1985 assert_eq!(
1986 state.current_iteration().unwrap().scroll_offset,
1987 5,
1988 "iteration 1 should have scroll_offset 5"
1989 );
1990
1991 state.navigate_next();
1992 assert_eq!(
1993 state.current_iteration().unwrap().scroll_offset,
1994 0,
1995 "iteration 2 should have scroll_offset 0"
1996 );
1997
1998 state.navigate_prev();
2000 assert_eq!(
2001 state.current_iteration().unwrap().scroll_offset,
2002 5,
2003 "iteration 1 should still have scroll_offset 5 after switching back"
2004 );
2005 }
2006
2007 #[test]
2008 fn scroll_within_iteration_does_not_affect_others() {
2009 let mut state = TuiState::new();
2011 state.start_new_iteration();
2012 state.start_new_iteration();
2013 state.start_new_iteration();
2014
2015 for i in 0..3 {
2017 for j in 0..20 {
2018 state.iterations[i].append_line(Line::from(format!(
2019 "iter {} line {}",
2020 i + 1,
2021 j
2022 )));
2023 }
2024 }
2025
2026 state.iterations[0].scroll_offset = 3;
2028 state.iterations[1].scroll_offset = 7;
2029 state.iterations[2].scroll_offset = 10;
2030
2031 state.current_view = 1;
2033 state.current_iteration_mut().unwrap().scroll_down(10);
2034
2035 assert_eq!(
2037 state.iterations[0].scroll_offset, 3,
2038 "iteration 1 unchanged"
2039 );
2040 assert_eq!(
2041 state.iterations[1].scroll_offset, 8,
2042 "iteration 2 scrolled down"
2043 );
2044 assert_eq!(
2045 state.iterations[2].scroll_offset, 10,
2046 "iteration 3 unchanged"
2047 );
2048 }
2049
2050 #[test]
2055 fn new_iteration_alert_set_when_not_following() {
2056 let mut state = TuiState::new();
2058 state.start_new_iteration(); state.start_new_iteration(); state.navigate_prev(); state.start_new_iteration(); assert_eq!(state.new_iteration_alert, Some(3));
2067 }
2068
2069 #[test]
2070 fn new_iteration_alert_not_set_when_following() {
2071 let mut state = TuiState::new();
2073 state.following_latest = true;
2074 state.start_new_iteration();
2075
2076 state.start_new_iteration();
2078
2079 assert_eq!(state.new_iteration_alert, None);
2081 }
2082
2083 #[test]
2084 fn alert_cleared_when_following_restored() {
2085 let mut state = TuiState::new();
2087 state.start_new_iteration();
2088 state.start_new_iteration();
2089 state.start_new_iteration();
2090 state.current_view = 0;
2091 state.following_latest = false;
2092 state.new_iteration_alert = Some(3);
2093
2094 state.navigate_next(); state.navigate_next(); assert_eq!(state.new_iteration_alert, None);
2100 }
2101
2102 #[test]
2103 fn alert_not_cleared_on_partial_navigation() {
2104 let mut state = TuiState::new();
2106 state.start_new_iteration();
2107 state.start_new_iteration();
2108 state.start_new_iteration();
2109 state.current_view = 0;
2110 state.following_latest = false;
2111 state.new_iteration_alert = Some(3);
2112
2113 state.navigate_next(); assert_eq!(state.new_iteration_alert, Some(3));
2118 assert!(!state.following_latest);
2119 }
2120
2121 #[test]
2122 fn alert_updates_for_multiple_new_iterations() {
2123 let mut state = TuiState::new();
2125 state.start_new_iteration(); state.start_new_iteration(); state.navigate_prev(); state.start_new_iteration(); assert_eq!(state.new_iteration_alert, Some(3));
2131
2132 state.start_new_iteration(); assert_eq!(state.new_iteration_alert, Some(4));
2137 }
2138 }
2139
2140 mod search_state {
2145 use super::*;
2146
2147 #[test]
2148 fn search_finds_matches_in_lines() {
2149 let mut state = TuiState::new();
2151 state.start_new_iteration();
2152 let buffer = state.current_iteration_mut().unwrap();
2153 buffer.append_line(Line::from("First error occurred"));
2154 buffer.append_line(Line::from("Normal line"));
2155 buffer.append_line(Line::from("Another error here"));
2156 buffer.append_line(Line::from("Final error message"));
2157
2158 state.search("error");
2160
2161 assert!(
2163 state.search_state.matches.len() >= 3,
2164 "expected at least 3 matches, got {}",
2165 state.search_state.matches.len()
2166 );
2167 assert_eq!(state.search_state.query, Some("error".to_string()));
2168 }
2169
2170 #[test]
2171 fn search_is_case_insensitive() {
2172 let mut state = TuiState::new();
2174 state.start_new_iteration();
2175 let buffer = state.current_iteration_mut().unwrap();
2176 buffer.append_line(Line::from("Error in uppercase"));
2177 buffer.append_line(Line::from("error in lowercase"));
2178 buffer.append_line(Line::from("ERROR all caps"));
2179
2180 state.search("error");
2182
2183 assert_eq!(
2185 state.search_state.matches.len(),
2186 3,
2187 "expected 3 case-insensitive matches"
2188 );
2189 }
2190
2191 #[test]
2192 fn next_match_cycles_forward() {
2193 let mut state = TuiState::new();
2195 state.start_new_iteration();
2196 let buffer = state.current_iteration_mut().unwrap();
2197 buffer.append_line(Line::from("match one"));
2198 buffer.append_line(Line::from("match two"));
2199 buffer.append_line(Line::from("match three"));
2200 state.search("match");
2201 state.search_state.current_match = 2;
2202
2203 state.next_match();
2205
2206 assert_eq!(state.search_state.current_match, 0);
2208 }
2209
2210 #[test]
2211 fn prev_match_cycles_backward() {
2212 let mut state = TuiState::new();
2214 state.start_new_iteration();
2215 let buffer = state.current_iteration_mut().unwrap();
2216 buffer.append_line(Line::from("match one"));
2217 buffer.append_line(Line::from("match two"));
2218 buffer.append_line(Line::from("match three"));
2219 state.search("match");
2220 state.search_state.current_match = 0;
2221
2222 state.prev_match();
2224
2225 assert_eq!(state.search_state.current_match, 2);
2227 }
2228
2229 #[test]
2230 fn search_jumps_to_match_line() {
2231 let mut state = TuiState::new();
2233 state.start_new_iteration();
2234 let buffer = state.current_iteration_mut().unwrap();
2235 for i in 0..60 {
2236 if i == 50 {
2237 buffer.append_line(Line::from("target match here"));
2238 } else {
2239 buffer.append_line(Line::from(format!("line {}", i)));
2240 }
2241 }
2242
2243 state.search("target");
2245
2246 let buffer = state.current_iteration().unwrap();
2248 assert!(
2250 buffer.scroll_offset <= 50,
2251 "scroll_offset {} should position line 50 in view",
2252 buffer.scroll_offset
2253 );
2254 }
2255
2256 #[test]
2257 fn clear_search_resets_state() {
2258 let mut state = TuiState::new();
2260 state.start_new_iteration();
2261 let buffer = state.current_iteration_mut().unwrap();
2262 buffer.append_line(Line::from("search term here"));
2263 state.search("term");
2264 assert!(state.search_state.query.is_some());
2265
2266 state.clear_search();
2268
2269 assert!(state.search_state.query.is_none());
2271 assert!(state.search_state.matches.is_empty());
2272 assert!(!state.search_state.search_mode);
2273 }
2274
2275 #[test]
2276 fn search_with_no_matches_sets_empty() {
2277 let mut state = TuiState::new();
2279 state.start_new_iteration();
2280 let buffer = state.current_iteration_mut().unwrap();
2281 buffer.append_line(Line::from("hello world"));
2282
2283 state.search("xyz");
2285
2286 assert_eq!(state.search_state.query, Some("xyz".to_string()));
2288 assert!(state.search_state.matches.is_empty());
2289 assert_eq!(state.search_state.current_match, 0);
2290 }
2291
2292 #[test]
2293 fn search_on_empty_iteration_handles_gracefully() {
2294 let mut state = TuiState::new();
2296 state.start_new_iteration();
2297
2298 state.search("anything");
2300
2301 assert!(state.search_state.matches.is_empty());
2303 }
2304
2305 #[test]
2306 fn next_match_with_no_matches_does_nothing() {
2307 let mut state = TuiState::new();
2309 state.start_new_iteration();
2310
2311 state.next_match();
2313
2314 assert_eq!(state.search_state.current_match, 0);
2316 }
2317
2318 #[test]
2319 fn multiple_matches_on_same_line() {
2320 let mut state = TuiState::new();
2322 state.start_new_iteration();
2323 let buffer = state.current_iteration_mut().unwrap();
2324 buffer.append_line(Line::from("error error error"));
2325
2326 state.search("error");
2328
2329 assert_eq!(
2331 state.search_state.matches.len(),
2332 3,
2333 "should find 3 matches on same line"
2334 );
2335 }
2336
2337 #[test]
2338 fn next_match_updates_scroll_to_show_match() {
2339 let mut state = TuiState::new();
2341 state.start_new_iteration();
2342 let buffer = state.current_iteration_mut().unwrap();
2343 for i in 0..100 {
2344 if i % 30 == 0 {
2345 buffer.append_line(Line::from("findme"));
2346 } else {
2347 buffer.append_line(Line::from(format!("line {}", i)));
2348 }
2349 }
2350 state.search("findme");
2351
2352 state.next_match();
2354
2355 let buffer = state.current_iteration().unwrap();
2357 assert!(buffer.scroll_offset <= 30, "scroll should show line 30");
2359 }
2360
2361 #[test]
2362 fn latest_iteration_lines_handle_returns_newest_iteration() {
2363 let mut state = TuiState::new();
2365 state.start_new_iteration(); state.start_new_iteration(); state.start_new_iteration(); state.current_view = 0;
2371 state.following_latest = false;
2372
2373 let current_handle = state.current_iteration_lines_handle();
2375 let latest_handle = state.latest_iteration_lines_handle();
2376
2377 assert!(current_handle.is_some());
2379 assert!(latest_handle.is_some());
2381
2382 {
2384 let latest = latest_handle.unwrap();
2385 latest
2386 .lock()
2387 .unwrap()
2388 .push(Line::from("output from iteration 3"));
2389 }
2390
2391 let current = state.current_iteration().unwrap();
2393 assert_eq!(
2394 current.lines.lock().unwrap().len(),
2395 0,
2396 "iteration 1 should have no lines"
2397 );
2398
2399 let latest_buffer = state.iterations.last().unwrap();
2401 assert_eq!(
2402 latest_buffer.lines.lock().unwrap().len(),
2403 1,
2404 "iteration 3 should have the output"
2405 );
2406 }
2407
2408 #[test]
2409 fn output_goes_to_correct_iteration_when_user_reviewing_history() {
2410 let mut state = TuiState::new();
2412
2413 for _ in 0..6 {
2415 state.start_new_iteration();
2416 }
2417
2418 state.current_view = 2;
2420 state.following_latest = false;
2421
2422 state.start_new_iteration();
2424
2425 let lines_handle = state.latest_iteration_lines_handle();
2427
2428 {
2430 let handle = lines_handle.unwrap();
2431 handle
2432 .lock()
2433 .unwrap()
2434 .push(Line::from("iteration 7 output"));
2435 }
2436
2437 let iteration_3 = &state.iterations[2];
2439 assert_eq!(
2440 iteration_3.lines.lock().unwrap().len(),
2441 0,
2442 "iteration 3 (being viewed) should have no output"
2443 );
2444
2445 let iteration_7 = state.iterations.last().unwrap();
2447 assert_eq!(
2448 iteration_7.lines.lock().unwrap().len(),
2449 1,
2450 "iteration 7 (latest) should have the output"
2451 );
2452 }
2453 }
2454
2455 mod guidance {
2460 use super::*;
2461
2462 #[test]
2463 fn start_guidance_sets_mode_and_clears_input() {
2464 let mut state = TuiState::new();
2465 state.guidance_input = "leftover".to_string();
2466 state.start_guidance(GuidanceMode::Next);
2467 assert_eq!(state.guidance_mode, Some(GuidanceMode::Next));
2468 assert!(state.guidance_input.is_empty());
2469 }
2470
2471 #[test]
2472 fn start_guidance_now_mode() {
2473 let mut state = TuiState::new();
2474 state.start_guidance(GuidanceMode::Now);
2475 assert_eq!(state.guidance_mode, Some(GuidanceMode::Now));
2476 }
2477
2478 #[test]
2479 fn cancel_guidance_clears_state() {
2480 let mut state = TuiState::new();
2481 state.start_guidance(GuidanceMode::Next);
2482 state.guidance_input = "some text".to_string();
2483 state.cancel_guidance();
2484 assert!(state.guidance_mode.is_none());
2485 assert!(state.guidance_input.is_empty());
2486 }
2487
2488 #[test]
2489 fn send_guidance_next_pushes_to_queue() {
2490 let mut state = TuiState::new();
2491 state.start_guidance(GuidanceMode::Next);
2492 state.guidance_input = "check auth.rs".to_string();
2493 assert!(state.send_guidance());
2494 assert!(state.guidance_mode.is_none());
2495 assert!(state.guidance_input.is_empty());
2496
2497 let queue = state.guidance_next_queue.lock().unwrap();
2498 assert_eq!(queue.len(), 1);
2499 assert_eq!(queue[0], "check auth.rs");
2500 }
2501
2502 #[test]
2503 fn send_guidance_empty_input_cancels() {
2504 let mut state = TuiState::new();
2505 state.start_guidance(GuidanceMode::Next);
2506 state.guidance_input = " ".to_string();
2507 assert!(!state.send_guidance());
2508 let queue = state.guidance_next_queue.lock().unwrap();
2509 assert!(queue.is_empty());
2510 }
2511
2512 #[test]
2513 fn send_guidance_sets_flash() {
2514 let mut state = TuiState::new();
2515 state.start_guidance(GuidanceMode::Next);
2516 state.guidance_input = "test".to_string();
2517 state.send_guidance();
2518 assert!(state.guidance_flash.is_some());
2519 assert_eq!(
2520 state.active_guidance_flash(),
2521 Some((GuidanceMode::Next, GuidanceResult::Queued))
2522 );
2523 }
2524
2525 #[test]
2526 fn send_guidance_now_writes_to_events_file() {
2527 let dir = tempfile::tempdir().unwrap();
2528 let events_path = dir.path().join("events.jsonl");
2529 let urgent_steer_path = dir.path().join("urgent-steer.json");
2530
2531 let mut state = TuiState::new();
2532 state.events_path = Some(events_path.clone());
2533 state.urgent_steer_path = Some(urgent_steer_path.clone());
2534 state.start_guidance(GuidanceMode::Now);
2535 state.guidance_input = "fix the bug now".to_string();
2536 assert!(state.send_guidance());
2537
2538 let content = std::fs::read_to_string(&events_path).unwrap();
2539 let event: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
2540 assert_eq!(event["topic"], "human.guidance");
2541 assert_eq!(event["payload"], "fix the bug now");
2542 assert!(event["ts"].is_string());
2543
2544 let steer = ralph_core::UrgentSteerStore::new(urgent_steer_path)
2545 .load()
2546 .unwrap()
2547 .expect("urgent steer");
2548 assert_eq!(steer.messages, vec!["fix the bug now"]);
2549 }
2550
2551 #[test]
2552 fn send_guidance_now_without_events_path_fails() {
2553 let mut state = TuiState::new();
2554 state.events_path = None;
2555 state.start_guidance(GuidanceMode::Now);
2556 state.guidance_input = "test".to_string();
2557 assert!(!state.send_guidance());
2558 }
2559
2560 #[test]
2561 fn is_guidance_active_reflects_mode() {
2562 let mut state = TuiState::new();
2563 assert!(!state.is_guidance_active());
2564 state.start_guidance(GuidanceMode::Next);
2565 assert!(state.is_guidance_active());
2566 state.cancel_guidance();
2567 assert!(!state.is_guidance_active());
2568 }
2569
2570 #[test]
2571 fn multiple_guidance_messages_queue_correctly() {
2572 let mut state = TuiState::new();
2573
2574 state.start_guidance(GuidanceMode::Next);
2575 state.guidance_input = "first".to_string();
2576 state.send_guidance();
2577
2578 state.start_guidance(GuidanceMode::Next);
2579 state.guidance_input = "second".to_string();
2580 state.send_guidance();
2581
2582 let queue = state.guidance_next_queue.lock().unwrap();
2583 assert_eq!(queue.len(), 2);
2584 assert_eq!(queue[0], "first");
2585 assert_eq!(queue[1], "second");
2586 }
2587
2588 #[test]
2589 fn task_start_preserves_guidance_queue() {
2590 let mut state = TuiState::new();
2591 state.start_new_iteration();
2592
2593 state.start_guidance(GuidanceMode::Next);
2595 state.guidance_input = "remember this".to_string();
2596 state.send_guidance();
2597
2598 let event = Event::new("task.start", "New task");
2600 state.update(&event);
2601
2602 let queue = state.guidance_next_queue.lock().unwrap();
2604 assert_eq!(queue.len(), 1);
2605 assert_eq!(queue[0], "remember this");
2606 }
2607 }
2608
2609 mod wave_view {
2614 use super::*;
2615
2616 fn simulate_wave(state: &mut TuiState, worker_count: u32) {
2619 let iter_idx = state.iterations.len().saturating_sub(1);
2620 state.wave_active = Some(WaveInfo::new("TestHat".to_string(), worker_count));
2621 state.wave_active_iteration_idx = Some(iter_idx);
2622 if let Some(ref wave) = state.wave_active {
2624 for (i, buf) in wave.worker_buffers.iter().enumerate() {
2625 let handle = buf.lines_handle();
2626 if let Ok(mut lines) = handle.lock() {
2627 lines.push(Line::from(format!("Worker {} output", i + 1)));
2628 }
2629 }
2630 }
2631 let wave_iter_idx = state.wave_active_iteration_idx.take();
2633 if let Some(wave) = state.wave_active.take() {
2634 let target = wave_iter_idx.unwrap_or(0);
2635 if let Some(buf) = state.iterations.get_mut(target) {
2636 buf.wave_info = Some(wave);
2637 }
2638 }
2639 }
2640
2641 #[test]
2642 fn wave_view_shows_correct_wave_for_historical_iteration() {
2643 let mut state = TuiState::new();
2644
2645 state.start_new_iteration();
2647 simulate_wave(&mut state, 5);
2648
2649 state.start_new_iteration();
2651 simulate_wave(&mut state, 3);
2652
2653 state.navigate_prev();
2655 assert_eq!(state.current_view, 0);
2656
2657 state.enter_wave_view();
2659 assert!(state.wave_view_active);
2660
2661 let wave = state.wave_info_for_wave_view().unwrap();
2662 assert_eq!(
2663 wave.total, 5,
2664 "Should show 5 workers from iteration 1, not 3"
2665 );
2666 assert_eq!(wave.worker_buffers.len(), 5);
2667 }
2668
2669 #[test]
2670 fn wave_view_shows_active_wave_on_current_iteration() {
2671 let mut state = TuiState::new();
2672
2673 state.start_new_iteration();
2675 simulate_wave(&mut state, 5);
2676
2677 state.start_new_iteration();
2679 state.wave_active = Some(WaveInfo::new("ActiveHat".to_string(), 3));
2680 state.wave_active_iteration_idx = Some(1);
2681
2682 assert_eq!(state.current_view, 1);
2684 state.enter_wave_view();
2685 assert!(state.wave_view_active);
2686
2687 let wave = state.wave_info_for_wave_view().unwrap();
2688 assert_eq!(wave.total, 3, "Should show active wave's 3 workers");
2689 }
2690
2691 #[test]
2692 fn wave_view_ignores_active_wave_when_viewing_historical() {
2693 let mut state = TuiState::new();
2694
2695 state.start_new_iteration();
2697 simulate_wave(&mut state, 5);
2698
2699 state.start_new_iteration();
2701 state.wave_active = Some(WaveInfo::new("ActiveHat".to_string(), 3));
2702 state.wave_active_iteration_idx = Some(1);
2703
2704 state.navigate_prev();
2706 assert_eq!(state.current_view, 0);
2707
2708 state.enter_wave_view();
2710 assert!(state.wave_view_active);
2711
2712 let wave = state.wave_info_for_wave_view().unwrap();
2713 assert_eq!(
2714 wave.total, 5,
2715 "Must show historical iteration's 5 workers, not active wave's 3"
2716 );
2717 }
2718
2719 #[test]
2720 fn wave_view_no_op_on_iteration_without_wave() {
2721 let mut state = TuiState::new();
2722
2723 state.start_new_iteration();
2725 simulate_wave(&mut state, 3);
2726
2727 state.start_new_iteration();
2729
2730 state.enter_wave_view();
2732 assert!(!state.wave_view_active);
2733 }
2734
2735 #[test]
2736 fn wave_worker_navigation_uses_correct_wave() {
2737 let mut state = TuiState::new();
2738
2739 state.start_new_iteration();
2741 simulate_wave(&mut state, 5);
2742
2743 state.start_new_iteration();
2745 simulate_wave(&mut state, 2);
2746
2747 state.navigate_prev();
2749 state.enter_wave_view();
2750
2751 for i in 0..5 {
2753 assert_eq!(state.wave_view_index, i);
2754 state.wave_view_next();
2755 }
2756 assert_eq!(state.wave_view_index, 0);
2758 }
2759 }
2760}