Skip to main content

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// ============================================================================
8// TaskSummary - Summary of a single task for TUI display
9// ============================================================================
10
11/// Summary of a task for TUI display.
12/// Contains only the fields needed for rendering.
13#[derive(Debug, Clone, Default)]
14pub struct TaskSummary {
15    /// Task identifier (e.g., "task-1737372000-a1b2").
16    pub id: String,
17    /// Task title/description.
18    pub title: String,
19    /// Task status (e.g., "open", "closed", "blocked").
20    pub status: String,
21}
22
23impl TaskSummary {
24    /// Creates a new task summary.
25    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// ============================================================================
35// TaskCounts - Aggregate task statistics for TUI display
36// ============================================================================
37
38/// Aggregate task statistics for TUI display.
39#[derive(Debug, Clone, Default)]
40pub struct TaskCounts {
41    /// Total number of tasks.
42    pub total: usize,
43    /// Number of open tasks.
44    pub open: usize,
45    /// Number of closed tasks.
46    pub closed: usize,
47    /// Number of ready (unblocked) tasks.
48    pub ready: usize,
49}
50
51impl TaskCounts {
52    /// Creates new task counts.
53    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// ============================================================================
64// SearchState - Search functionality for TUI content
65// ============================================================================
66
67/// Search state for finding and navigating matches in TUI content.
68/// Tracks the current query, match positions, and navigation index.
69#[derive(Debug, Default)]
70pub struct SearchState {
71    /// Current search query (None when no active search).
72    pub query: Option<String>,
73    /// Match positions as (line_index, char_offset) pairs.
74    pub matches: Vec<(usize, usize)>,
75    /// Index into matches vector for current match.
76    pub current_match: usize,
77    /// Whether search input mode is active (user is typing query).
78    pub search_mode: bool,
79}
80
81impl SearchState {
82    /// Creates a new empty search state.
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Clears all search state.
88    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/// Observable state derived from loop events.
97pub struct TuiState {
98    /// Which hat will process next event (ID + display name).
99    pub pending_hat: Option<(HatId, String)>,
100    /// Backend expected for the next iteration (used when metadata is missing).
101    pub pending_backend: Option<String>,
102    /// Current iteration number (0-indexed, display as +1).
103    pub iteration: u32,
104    /// Previous iteration number (for detecting changes).
105    pub prev_iteration: u32,
106    /// When loop began.
107    pub loop_started: Option<Instant>,
108    /// When current iteration began.
109    pub iteration_started: Option<Instant>,
110    /// Most recent event topic.
111    pub last_event: Option<String>,
112    /// Timestamp of last event.
113    pub last_event_at: Option<Instant>,
114    /// Whether to show help overlay.
115    pub show_help: bool,
116    /// Whether in scroll mode.
117    pub in_scroll_mode: bool,
118    /// Current search query (if in search input mode).
119    pub search_query: String,
120    /// Search direction (true = forward, false = backward).
121    pub search_forward: bool,
122    /// Maximum iterations from config.
123    pub max_iterations: Option<u32>,
124    /// Idle timeout countdown.
125    pub idle_timeout_remaining: Option<Duration>,
126    /// Map of event topics to hat display information (for custom hats).
127    /// Key: event topic (e.g., "review.security")
128    /// Value: (HatId, display name including emoji)
129    hat_map: HashMap<String, (HatId, String)>,
130
131    // ========================================================================
132    // Iteration Management (new fields for TUI refactor)
133    // ========================================================================
134    /// Content buffers for each iteration.
135    pub iterations: Vec<IterationBuffer>,
136    /// Index of the iteration currently being viewed (0-indexed).
137    pub current_view: usize,
138    /// Whether to automatically follow the latest iteration.
139    pub following_latest: bool,
140    /// Alert about a new iteration (shown when viewing history and new iteration arrives).
141    /// Contains the iteration number to alert about. Cleared when navigating to latest.
142    pub new_iteration_alert: Option<usize>,
143
144    // ========================================================================
145    // Search State
146    // ========================================================================
147    /// Search state for finding and navigating matches in iteration content.
148    pub search_state: SearchState,
149
150    // ========================================================================
151    // Completion State
152    // ========================================================================
153    /// Whether the loop has completed (received loop.terminate event).
154    pub loop_completed: bool,
155    /// Frozen elapsed time when loop completed (timer stops at this value).
156    pub final_iteration_elapsed: Option<Duration>,
157    /// Frozen total elapsed time when loop completed (footer timer stops).
158    pub final_loop_elapsed: Option<Duration>,
159
160    // ========================================================================
161    // Task Tracking State
162    // ========================================================================
163    /// Aggregate task counts for display in TUI widgets.
164    pub task_counts: TaskCounts,
165    /// Currently active task (if any) for display in TUI widgets.
166    pub active_task: Option<TaskSummary>,
167}
168
169impl TuiState {
170    /// Creates empty state. Timer starts immediately at creation.
171    pub fn new() -> Self {
172        Self {
173            pending_hat: None,
174            pending_backend: None,
175            iteration: 0,
176            prev_iteration: 0,
177            loop_started: Some(Instant::now()),
178            iteration_started: None,
179            last_event: None,
180            last_event_at: None,
181            show_help: false,
182            in_scroll_mode: false,
183            search_query: String::new(),
184            search_forward: true,
185            max_iterations: None,
186            idle_timeout_remaining: None,
187            hat_map: HashMap::new(),
188            // Iteration management
189            iterations: Vec::new(),
190            current_view: 0,
191            following_latest: true,
192            new_iteration_alert: None,
193            // Search state
194            search_state: SearchState::new(),
195            // Completion state
196            loop_completed: false,
197            final_iteration_elapsed: None,
198            final_loop_elapsed: None,
199            // Task tracking state
200            task_counts: TaskCounts::default(),
201            active_task: None,
202        }
203    }
204
205    /// Creates state with a custom hat map for dynamic topic-to-hat resolution.
206    /// Timer starts immediately at creation.
207    pub fn with_hat_map(hat_map: HashMap<String, (HatId, String)>) -> Self {
208        Self {
209            pending_hat: None,
210            pending_backend: None,
211            iteration: 0,
212            prev_iteration: 0,
213            loop_started: Some(Instant::now()),
214            iteration_started: None,
215            last_event: None,
216            last_event_at: None,
217            show_help: false,
218            in_scroll_mode: false,
219            search_query: String::new(),
220            search_forward: true,
221            max_iterations: None,
222            idle_timeout_remaining: None,
223            hat_map,
224            // Iteration management
225            iterations: Vec::new(),
226            current_view: 0,
227            following_latest: true,
228            new_iteration_alert: None,
229            // Search state
230            search_state: SearchState::new(),
231            // Completion state
232            loop_completed: false,
233            final_iteration_elapsed: None,
234            final_loop_elapsed: None,
235            // Task tracking state
236            task_counts: TaskCounts::default(),
237            active_task: None,
238        }
239    }
240
241    /// Updates state based on event topic.
242    pub fn update(&mut self, event: &Event) {
243        let now = Instant::now();
244        let topic = event.topic.as_str();
245
246        self.last_event = Some(topic.to_string());
247        self.last_event_at = Some(now);
248
249        let custom_hat = self.hat_map.get(topic).cloned();
250        if let Some((hat_id, hat_display)) = custom_hat.clone() {
251            self.pending_hat = Some((hat_id, hat_display));
252            // Handle iteration timing for custom hats
253            if topic.starts_with("build.") {
254                self.iteration_started = Some(now);
255            }
256        }
257
258        // Fall back to hardcoded mappings for backward compatibility
259        match topic {
260            "task.start" => {
261                // Save state we want to preserve across reset
262                let saved_hat_map = std::mem::take(&mut self.hat_map);
263                let saved_loop_started = self.loop_started; // Preserve timer from TUI init
264                let saved_max_iterations = self.max_iterations;
265                // Preserve iteration buffers so TUI history survives across task restarts
266                let saved_iterations = std::mem::take(&mut self.iterations);
267                let saved_current_view = self.current_view;
268                let saved_following_latest = self.following_latest;
269                let saved_new_iteration_alert = self.new_iteration_alert.take();
270                let saved_pending_backend = self.pending_backend.clone();
271                *self = Self::new();
272                self.hat_map = saved_hat_map;
273                self.loop_started = saved_loop_started; // Keep original timer
274                self.max_iterations = saved_max_iterations;
275                self.iterations = saved_iterations;
276                self.current_view = saved_current_view;
277                self.following_latest = saved_following_latest;
278                self.new_iteration_alert = saved_new_iteration_alert;
279                self.pending_backend = saved_pending_backend;
280                if let Some((hat_id, hat_display)) = custom_hat.clone() {
281                    self.pending_hat = Some((hat_id, hat_display));
282                } else {
283                    self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
284                }
285                self.last_event = Some(topic.to_string());
286                self.last_event_at = Some(now);
287            }
288            "task.resume" => {
289                // Don't reset timer on resume - keep counting from TUI init
290                if custom_hat.is_none() {
291                    self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
292                }
293            }
294            "build.task" => {
295                if custom_hat.is_none() {
296                    self.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
297                }
298                // Resume the loop timer if a new iteration starts after a freeze.
299                self.final_loop_elapsed = None;
300                self.iteration_started = Some(now);
301            }
302            "build.done" => {
303                if custom_hat.is_none() {
304                    self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
305                }
306                self.prev_iteration = self.iteration;
307                self.iteration += 1;
308                self.finish_latest_iteration();
309                self.freeze_loop_elapsed();
310            }
311            "build.blocked" => {
312                if custom_hat.is_none() {
313                    self.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
314                }
315                self.finish_latest_iteration();
316                self.freeze_loop_elapsed();
317            }
318            "loop.terminate" => {
319                self.pending_hat = None;
320                self.loop_completed = true;
321                // Freeze the iteration timer at its current value
322                self.final_iteration_elapsed = self.iteration_started.map(|start| start.elapsed());
323                // Freeze the total loop timer for the footer display
324                self.freeze_loop_elapsed();
325                self.finish_latest_iteration();
326            }
327            _ => {
328                // Unknown topic - don't change pending_hat
329            }
330        }
331    }
332
333    /// Returns formatted hat display (emoji + name).
334    pub fn get_pending_hat_display(&self) -> String {
335        self.pending_hat
336            .as_ref()
337            .map_or_else(|| "—".to_string(), |(_, display)| display.clone())
338    }
339
340    /// Time since loop started.
341    pub fn get_loop_elapsed(&self) -> Option<Duration> {
342        if let Some(final_elapsed) = self.final_loop_elapsed {
343            return Some(final_elapsed);
344        }
345        self.loop_started.map(|start| start.elapsed())
346    }
347
348    /// Time since iteration started, or frozen value if loop completed.
349    pub fn get_iteration_elapsed(&self) -> Option<Duration> {
350        if let Some(buffer) = self.current_iteration() {
351            if let Some(elapsed) = buffer.elapsed {
352                return Some(elapsed);
353            }
354            if let Some(started_at) = buffer.started_at {
355                return Some(started_at.elapsed());
356            }
357        }
358        if let Some(final_elapsed) = self.final_iteration_elapsed {
359            return Some(final_elapsed);
360        }
361        self.iteration_started.map(|start| start.elapsed())
362    }
363
364    /// True if event received in last 2 seconds.
365    pub fn is_active(&self) -> bool {
366        self.last_event_at
367            .is_some_and(|t| t.elapsed() < Duration::from_secs(2))
368    }
369
370    /// True if iteration changed since last check.
371    pub fn iteration_changed(&self) -> bool {
372        self.iteration != self.prev_iteration
373    }
374
375    // ========================================================================
376    // Task Tracking Methods
377    // ========================================================================
378
379    /// Returns a reference to the current task counts.
380    pub fn get_task_counts(&self) -> &TaskCounts {
381        &self.task_counts
382    }
383
384    /// Returns a reference to the active task, if any.
385    pub fn get_active_task(&self) -> Option<&TaskSummary> {
386        self.active_task.as_ref()
387    }
388
389    /// Updates the task counts.
390    pub fn set_task_counts(&mut self, counts: TaskCounts) {
391        self.task_counts = counts;
392    }
393
394    /// Sets the active task.
395    pub fn set_active_task(&mut self, task: Option<TaskSummary>) {
396        self.active_task = task;
397    }
398
399    /// Returns true if there are any open tasks.
400    pub fn has_open_tasks(&self) -> bool {
401        self.task_counts.open > 0
402    }
403
404    /// Returns a formatted string for task progress display (e.g., "3/5 tasks").
405    pub fn get_task_progress_display(&self) -> String {
406        if self.task_counts.total == 0 {
407            "No tasks".to_string()
408        } else {
409            format!(
410                "{}/{} tasks",
411                self.task_counts.closed, self.task_counts.total
412            )
413        }
414    }
415
416    // ========================================================================
417    // Iteration Management Methods
418    // ========================================================================
419
420    /// Starts a new iteration, creating a new IterationBuffer.
421    /// If following_latest is true, current_view is updated to the new iteration.
422    /// If not following, sets the new_iteration_alert to notify the user.
423    pub fn start_new_iteration(&mut self) {
424        self.start_new_iteration_with_metadata(None, None);
425    }
426
427    /// Starts a new iteration with optional metadata for hat and backend display.
428    pub fn start_new_iteration_with_metadata(
429        &mut self,
430        hat_display: Option<String>,
431        backend: Option<String>,
432    ) {
433        let hat_display = hat_display.or_else(|| {
434            self.pending_hat
435                .as_ref()
436                .map(|(_, display)| display.clone())
437        });
438        let backend = backend.or_else(|| self.pending_backend.clone());
439        let number = (self.iterations.len() + 1) as u32;
440        let mut buffer = IterationBuffer::new(number);
441        buffer.hat_display = hat_display;
442        buffer.backend = backend;
443        buffer.started_at = Some(Instant::now());
444        if buffer.backend.is_some() {
445            self.pending_backend = buffer.backend.clone();
446        }
447        self.iterations.push(buffer);
448
449        // Auto-follow if enabled
450        if self.following_latest {
451            self.current_view = self.iterations.len().saturating_sub(1);
452        } else {
453            // Alert user about new iteration when reviewing history
454            self.new_iteration_alert = Some(number as usize);
455        }
456    }
457
458    /// Finalizes the latest iteration's elapsed time if it isn't already set.
459    pub fn finish_latest_iteration(&mut self) {
460        let Some(buffer) = self.iterations.last_mut() else {
461            return;
462        };
463        if buffer.elapsed.is_some() {
464            return;
465        }
466        if let Some(started_at) = buffer.started_at {
467            buffer.elapsed = Some(started_at.elapsed());
468        }
469    }
470
471    /// Freeze total loop elapsed time for the footer if it is still ticking.
472    fn freeze_loop_elapsed(&mut self) {
473        if self.final_loop_elapsed.is_some() {
474            return;
475        }
476        self.final_loop_elapsed = self.loop_started.map(|start| start.elapsed());
477    }
478
479    /// Returns the hat display for the currently viewed iteration, if available.
480    pub fn current_iteration_hat_display(&self) -> Option<&str> {
481        self.current_iteration()
482            .and_then(|buffer| buffer.hat_display.as_deref())
483    }
484
485    /// Returns the backend display for the currently viewed iteration, if available.
486    pub fn current_iteration_backend(&self) -> Option<&str> {
487        self.current_iteration()
488            .and_then(|buffer| buffer.backend.as_deref())
489    }
490
491    /// Returns a reference to the currently viewed iteration buffer.
492    pub fn current_iteration(&self) -> Option<&IterationBuffer> {
493        self.iterations.get(self.current_view)
494    }
495
496    /// Returns a mutable reference to the currently viewed iteration buffer.
497    pub fn current_iteration_mut(&mut self) -> Option<&mut IterationBuffer> {
498        self.iterations.get_mut(self.current_view)
499    }
500
501    /// Returns a shared handle to the current iteration's lines buffer.
502    ///
503    /// This allows stream handlers to write directly to the buffer,
504    /// enabling real-time streaming to the TUI during execution.
505    pub fn current_iteration_lines_handle(
506        &self,
507    ) -> Option<std::sync::Arc<std::sync::Mutex<Vec<Line<'static>>>>> {
508        self.iterations
509            .get(self.current_view)
510            .map(|buffer| buffer.lines_handle())
511    }
512
513    /// Returns a shared handle to the latest iteration's lines buffer.
514    ///
515    /// This should be used when writing output from the currently executing
516    /// iteration, regardless of which iteration the user is viewing.
517    /// This prevents output from being written to the wrong iteration when
518    /// the user is reviewing an older iteration.
519    pub fn latest_iteration_lines_handle(
520        &self,
521    ) -> Option<std::sync::Arc<std::sync::Mutex<Vec<Line<'static>>>>> {
522        self.iterations.last().map(|buffer| buffer.lines_handle())
523    }
524
525    /// Navigates to the next iteration (if not at the last one).
526    /// If reaching the last iteration, re-enables following_latest and clears alerts.
527    pub fn navigate_next(&mut self) {
528        if self.iterations.is_empty() {
529            return;
530        }
531        let max_index = self.iterations.len().saturating_sub(1);
532        if self.current_view < max_index {
533            self.current_view += 1;
534            // Re-enable following when reaching the latest
535            if self.current_view == max_index {
536                self.following_latest = true;
537                self.new_iteration_alert = None;
538            }
539        }
540    }
541
542    /// Navigates to the previous iteration (if not at the first one).
543    /// Disables following_latest when navigating backwards.
544    pub fn navigate_prev(&mut self) {
545        if self.current_view > 0 {
546            self.current_view -= 1;
547            self.following_latest = false;
548        }
549    }
550
551    /// Returns the total number of iterations.
552    pub fn total_iterations(&self) -> usize {
553        self.iterations.len()
554    }
555
556    // ========================================================================
557    // Search Methods
558    // ========================================================================
559
560    /// Searches for the given query in the current iteration's content.
561    /// Populates matches with (line_index, char_offset) pairs.
562    /// Search is case-insensitive.
563    pub fn search(&mut self, query: &str) {
564        self.search_state.query = Some(query.to_string());
565        self.search_state.matches.clear();
566        self.search_state.current_match = 0;
567
568        // Check if we have an iteration to search
569        if self.iterations.get(self.current_view).is_none() {
570            return;
571        }
572
573        let query_lower = query.to_lowercase();
574
575        // Collect matches first (avoid borrow conflicts)
576        let matches: Vec<(usize, usize)> = self
577            .iterations
578            .get(self.current_view)
579            .and_then(|buffer| {
580                let lines = buffer.lines.lock().ok()?;
581                let mut found = Vec::new();
582                for (line_idx, line) in lines.iter().enumerate() {
583                    // Get the text content of the line
584                    let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
585                    let line_lower = line_text.to_lowercase();
586
587                    // Find all occurrences in this line
588                    let mut search_start = 0;
589                    while let Some(pos) = line_lower[search_start..].find(&query_lower) {
590                        let char_offset = search_start + pos;
591                        found.push((line_idx, char_offset));
592                        search_start = char_offset + query_lower.len();
593                    }
594                }
595                Some(found)
596            })
597            .unwrap_or_default();
598
599        self.search_state.matches = matches;
600
601        // Jump to first match if any exist
602        if !self.search_state.matches.is_empty() {
603            self.jump_to_current_match();
604        }
605    }
606
607    /// Navigates to the next match, cycling back to the first if at the end.
608    pub fn next_match(&mut self) {
609        if self.search_state.matches.is_empty() {
610            return;
611        }
612
613        self.search_state.current_match =
614            (self.search_state.current_match + 1) % self.search_state.matches.len();
615        self.jump_to_current_match();
616    }
617
618    /// Navigates to the previous match, cycling to the last if at the beginning.
619    pub fn prev_match(&mut self) {
620        if self.search_state.matches.is_empty() {
621            return;
622        }
623
624        if self.search_state.current_match == 0 {
625            self.search_state.current_match = self.search_state.matches.len() - 1;
626        } else {
627            self.search_state.current_match -= 1;
628        }
629        self.jump_to_current_match();
630    }
631
632    /// Clears the search state.
633    pub fn clear_search(&mut self) {
634        self.search_state.clear();
635    }
636
637    /// Jumps to the current match by adjusting scroll_offset to show the match line.
638    fn jump_to_current_match(&mut self) {
639        if self.search_state.matches.is_empty() {
640            return;
641        }
642
643        let (line_idx, _) = self.search_state.matches[self.search_state.current_match];
644
645        // Adjust scroll to show the match line
646        // Use a default viewport height for calculation (will be overridden by actual render)
647        let viewport_height = 20;
648        if let Some(buffer) = self.current_iteration_mut() {
649            // If the match line is above the current view, scroll up to it
650            if line_idx < buffer.scroll_offset {
651                buffer.scroll_offset = line_idx;
652            }
653            // If the match line is below the current view, scroll down to show it
654            else if line_idx >= buffer.scroll_offset + viewport_height {
655                buffer.scroll_offset = line_idx.saturating_sub(viewport_height / 2);
656            }
657        }
658    }
659}
660
661impl Default for TuiState {
662    fn default() -> Self {
663        Self::new()
664    }
665}
666
667// ============================================================================
668// IterationBuffer - Content storage for a single iteration
669// ============================================================================
670
671use ratatui::text::Line;
672use std::sync::{Arc, Mutex};
673
674/// Stores formatted output content for a single Ralph iteration.
675/// Each iteration has its own buffer with independent scroll state.
676///
677/// The `lines` field is wrapped in `Arc<Mutex<>>` to allow sharing
678/// with stream handlers during execution, enabling real-time streaming
679/// to the TUI instead of batch transfer after execution completes.
680pub struct IterationBuffer {
681    /// Iteration number (1-indexed for display)
682    pub number: u32,
683    /// Formatted lines of output (shared for streaming)
684    pub lines: Arc<Mutex<Vec<Line<'static>>>>,
685    /// Scroll position within this buffer
686    pub scroll_offset: usize,
687    /// Whether to auto-scroll to bottom as new content arrives.
688    /// Starts true, becomes false when user scrolls up, restored when user
689    /// scrolls to bottom (G key) or manually scrolls down to reach bottom.
690    pub following_bottom: bool,
691    /// Hat display name (emoji + name) for this iteration.
692    pub hat_display: Option<String>,
693    /// Backend used for this iteration (e.g., "claude", "kiro").
694    pub backend: Option<String>,
695    /// When this iteration started (for elapsed time calculation).
696    pub started_at: Option<Instant>,
697    /// Frozen elapsed duration for this iteration (set when completed).
698    pub elapsed: Option<Duration>,
699}
700
701impl IterationBuffer {
702    /// Creates a new buffer for the given iteration number.
703    pub fn new(number: u32) -> Self {
704        Self {
705            number,
706            lines: Arc::new(Mutex::new(Vec::new())),
707            scroll_offset: 0,
708            following_bottom: true, // Start following bottom for auto-scroll
709            hat_display: None,
710            backend: None,
711            started_at: None,
712            elapsed: None,
713        }
714    }
715
716    /// Returns a shared handle to the lines buffer for streaming.
717    ///
718    /// This allows stream handlers to write directly to the buffer,
719    /// enabling real-time streaming to the TUI.
720    pub fn lines_handle(&self) -> Arc<Mutex<Vec<Line<'static>>>> {
721        Arc::clone(&self.lines)
722    }
723
724    /// Appends a line to the buffer.
725    pub fn append_line(&mut self, line: Line<'static>) {
726        if let Ok(mut lines) = self.lines.lock() {
727            lines.push(line);
728        }
729    }
730
731    /// Returns the total number of lines in the buffer.
732    pub fn line_count(&self) -> usize {
733        self.lines.lock().map(|l| l.len()).unwrap_or(0)
734    }
735
736    /// Returns a clone of the visible lines based on scroll offset and viewport height.
737    ///
738    /// Note: Returns owned Vec instead of slice due to interior mutability.
739    pub fn visible_lines(&self, viewport_height: usize) -> Vec<Line<'static>> {
740        let Ok(lines) = self.lines.lock() else {
741            return Vec::new();
742        };
743        if lines.is_empty() {
744            return Vec::new();
745        }
746        let start = self.scroll_offset.min(lines.len());
747        let end = (start + viewport_height).min(lines.len());
748        lines[start..end].to_vec()
749    }
750
751    /// Scrolls up by one line.
752    /// Disables auto-scroll since user is moving away from bottom.
753    pub fn scroll_up(&mut self) {
754        self.scroll_offset = self.scroll_offset.saturating_sub(1);
755        self.following_bottom = false;
756    }
757
758    /// Scrolls down by one line, respecting the viewport bounds.
759    /// Re-enables auto-scroll if user reaches the bottom.
760    pub fn scroll_down(&mut self, viewport_height: usize) {
761        let max_scroll = self.max_scroll_offset(viewport_height);
762        if self.scroll_offset < max_scroll {
763            self.scroll_offset += 1;
764        }
765        // Re-enable following if user scrolled to or past the bottom
766        if self.scroll_offset >= max_scroll {
767            self.following_bottom = true;
768        }
769    }
770
771    /// Scrolls to the top of the buffer.
772    /// Disables auto-scroll since user is moving away from bottom.
773    pub fn scroll_top(&mut self) {
774        self.scroll_offset = 0;
775        self.following_bottom = false;
776    }
777
778    /// Scrolls to the bottom of the buffer.
779    /// Re-enables auto-scroll since user explicitly went to bottom.
780    pub fn scroll_bottom(&mut self, viewport_height: usize) {
781        self.scroll_offset = self.max_scroll_offset(viewport_height);
782        self.following_bottom = true;
783    }
784
785    /// Calculates the maximum scroll offset for the given viewport height.
786    fn max_scroll_offset(&self, viewport_height: usize) -> usize {
787        self.lines
788            .lock()
789            .map(|l| l.len().saturating_sub(viewport_height))
790            .unwrap_or(0)
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    // ========================================================================
799    // IterationBuffer Tests
800    // ========================================================================
801
802    mod iteration_buffer {
803        use super::*;
804        use ratatui::text::Line;
805
806        #[test]
807        fn new_creates_buffer_with_correct_initial_state() {
808            let buffer = IterationBuffer::new(1);
809            assert_eq!(buffer.number, 1);
810            assert_eq!(buffer.line_count(), 0);
811            assert_eq!(buffer.scroll_offset, 0);
812        }
813
814        #[test]
815        fn append_line_adds_lines_in_order() {
816            let mut buffer = IterationBuffer::new(1);
817            buffer.append_line(Line::from("first"));
818            buffer.append_line(Line::from("second"));
819            buffer.append_line(Line::from("third"));
820
821            assert_eq!(buffer.line_count(), 3);
822            // Verify order by checking raw content
823            let lines = buffer.lines.lock().unwrap();
824            assert_eq!(lines[0].spans[0].content, "first");
825            assert_eq!(lines[1].spans[0].content, "second");
826            assert_eq!(lines[2].spans[0].content, "third");
827        }
828
829        #[test]
830        fn line_count_returns_correct_count() {
831            let mut buffer = IterationBuffer::new(1);
832            assert_eq!(buffer.line_count(), 0);
833
834            for i in 0..10 {
835                buffer.append_line(Line::from(format!("line {}", i)));
836            }
837            assert_eq!(buffer.line_count(), 10);
838        }
839
840        #[test]
841        fn visible_lines_returns_correct_slice_without_scroll() {
842            let mut buffer = IterationBuffer::new(1);
843            for i in 0..10 {
844                buffer.append_line(Line::from(format!("line {}", i)));
845            }
846
847            let visible = buffer.visible_lines(5);
848            assert_eq!(visible.len(), 5);
849            // Should be lines 0-4
850            assert_eq!(visible[0].spans[0].content, "line 0");
851            assert_eq!(visible[4].spans[0].content, "line 4");
852        }
853
854        #[test]
855        fn visible_lines_returns_correct_slice_with_scroll() {
856            let mut buffer = IterationBuffer::new(1);
857            for i in 0..10 {
858                buffer.append_line(Line::from(format!("line {}", i)));
859            }
860            buffer.scroll_offset = 3;
861
862            let visible = buffer.visible_lines(5);
863            assert_eq!(visible.len(), 5);
864            // Should be lines 3-7
865            assert_eq!(visible[0].spans[0].content, "line 3");
866            assert_eq!(visible[4].spans[0].content, "line 7");
867        }
868
869        #[test]
870        fn visible_lines_handles_viewport_larger_than_content() {
871            let mut buffer = IterationBuffer::new(1);
872            for i in 0..3 {
873                buffer.append_line(Line::from(format!("line {}", i)));
874            }
875
876            let visible = buffer.visible_lines(10);
877            assert_eq!(visible.len(), 3); // Only 3 lines exist
878        }
879
880        #[test]
881        fn visible_lines_handles_empty_buffer() {
882            let buffer = IterationBuffer::new(1);
883            let visible = buffer.visible_lines(5);
884            assert!(visible.is_empty());
885        }
886
887        #[test]
888        fn scroll_down_increases_offset() {
889            let mut buffer = IterationBuffer::new(1);
890            for i in 0..10 {
891                buffer.append_line(Line::from(format!("line {}", i)));
892            }
893
894            assert_eq!(buffer.scroll_offset, 0);
895            buffer.scroll_down(5); // viewport height 5
896            assert_eq!(buffer.scroll_offset, 1);
897            buffer.scroll_down(5);
898            assert_eq!(buffer.scroll_offset, 2);
899        }
900
901        #[test]
902        fn scroll_up_decreases_offset() {
903            let mut buffer = IterationBuffer::new(1);
904            for _ in 0..10 {
905                buffer.append_line(Line::from("line"));
906            }
907            buffer.scroll_offset = 5;
908
909            buffer.scroll_up();
910            assert_eq!(buffer.scroll_offset, 4);
911            buffer.scroll_up();
912            assert_eq!(buffer.scroll_offset, 3);
913        }
914
915        #[test]
916        fn scroll_up_does_not_underflow() {
917            let mut buffer = IterationBuffer::new(1);
918            buffer.append_line(Line::from("line"));
919            buffer.scroll_offset = 0;
920
921            buffer.scroll_up();
922            assert_eq!(buffer.scroll_offset, 0); // Should stay at 0
923        }
924
925        #[test]
926        fn scroll_down_does_not_overflow() {
927            let mut buffer = IterationBuffer::new(1);
928            for _ in 0..10 {
929                buffer.append_line(Line::from("line"));
930            }
931            // With 10 lines and viewport 5, max scroll is 5 (shows lines 5-9)
932            buffer.scroll_offset = 5;
933
934            buffer.scroll_down(5);
935            assert_eq!(buffer.scroll_offset, 5); // Should stay at max
936        }
937
938        #[test]
939        fn scroll_top_resets_to_zero() {
940            let mut buffer = IterationBuffer::new(1);
941            for _ in 0..10 {
942                buffer.append_line(Line::from("line"));
943            }
944            buffer.scroll_offset = 5;
945
946            buffer.scroll_top();
947            assert_eq!(buffer.scroll_offset, 0);
948        }
949
950        #[test]
951        fn scroll_bottom_sets_to_max() {
952            let mut buffer = IterationBuffer::new(1);
953            for _ in 0..10 {
954                buffer.append_line(Line::from("line"));
955            }
956
957            buffer.scroll_bottom(5); // viewport height 5
958            assert_eq!(buffer.scroll_offset, 5); // max = 10 - 5 = 5
959        }
960
961        #[test]
962        fn scroll_bottom_handles_small_content() {
963            let mut buffer = IterationBuffer::new(1);
964            for _ in 0..3 {
965                buffer.append_line(Line::from("line"));
966            }
967
968            buffer.scroll_bottom(5); // viewport larger than content
969            assert_eq!(buffer.scroll_offset, 0); // Can't scroll
970        }
971
972        #[test]
973        fn scroll_down_handles_empty_buffer() {
974            let mut buffer = IterationBuffer::new(1);
975            buffer.scroll_down(5);
976            assert_eq!(buffer.scroll_offset, 0);
977        }
978
979        // =====================================================================
980        // Auto-scroll (following_bottom) Tests
981        // =====================================================================
982
983        #[test]
984        fn following_bottom_is_true_initially() {
985            let buffer = IterationBuffer::new(1);
986            assert!(
987                buffer.following_bottom,
988                "New buffer should start with following_bottom = true"
989            );
990        }
991
992        #[test]
993        fn scroll_up_disables_following_bottom() {
994            let mut buffer = IterationBuffer::new(1);
995            for _ in 0..10 {
996                buffer.append_line(Line::from("line"));
997            }
998            buffer.scroll_offset = 5;
999            assert!(buffer.following_bottom);
1000
1001            buffer.scroll_up();
1002
1003            assert!(
1004                !buffer.following_bottom,
1005                "scroll_up should disable following_bottom"
1006            );
1007        }
1008
1009        #[test]
1010        fn scroll_top_disables_following_bottom() {
1011            let mut buffer = IterationBuffer::new(1);
1012            for _ in 0..10 {
1013                buffer.append_line(Line::from("line"));
1014            }
1015            assert!(buffer.following_bottom);
1016
1017            buffer.scroll_top();
1018
1019            assert!(
1020                !buffer.following_bottom,
1021                "scroll_top should disable following_bottom"
1022            );
1023        }
1024
1025        #[test]
1026        fn scroll_bottom_enables_following_bottom() {
1027            let mut buffer = IterationBuffer::new(1);
1028            for _ in 0..10 {
1029                buffer.append_line(Line::from("line"));
1030            }
1031            buffer.following_bottom = false;
1032
1033            buffer.scroll_bottom(5);
1034
1035            assert!(
1036                buffer.following_bottom,
1037                "scroll_bottom should enable following_bottom"
1038            );
1039        }
1040
1041        #[test]
1042        fn scroll_down_to_bottom_enables_following_bottom() {
1043            let mut buffer = IterationBuffer::new(1);
1044            for _ in 0..10 {
1045                buffer.append_line(Line::from("line"));
1046            }
1047            buffer.scroll_offset = 4; // One away from max (5 with viewport 5)
1048            buffer.following_bottom = false;
1049
1050            buffer.scroll_down(5); // Now at max (5)
1051
1052            assert!(
1053                buffer.following_bottom,
1054                "scroll_down to bottom should enable following_bottom"
1055            );
1056        }
1057
1058        #[test]
1059        fn scroll_down_not_at_bottom_keeps_following_false() {
1060            let mut buffer = IterationBuffer::new(1);
1061            for _ in 0..10 {
1062                buffer.append_line(Line::from("line"));
1063            }
1064            buffer.scroll_offset = 0;
1065            buffer.following_bottom = false;
1066
1067            buffer.scroll_down(5); // Now at 1, max is 5
1068
1069            assert!(
1070                !buffer.following_bottom,
1071                "scroll_down not reaching bottom should keep following_bottom false"
1072            );
1073        }
1074
1075        #[test]
1076        fn autoscroll_scenario_content_grows_past_viewport() {
1077            // This tests the core bug fix: content growing from small to large
1078            let mut buffer = IterationBuffer::new(1);
1079
1080            // Start with small content that fits in viewport
1081            for _ in 0..5 {
1082                buffer.append_line(Line::from("line"));
1083            }
1084
1085            // Simulate initial state: following_bottom = true, scroll_offset = 0
1086            let viewport = 20;
1087            assert!(buffer.following_bottom);
1088            assert_eq!(buffer.scroll_offset, 0);
1089
1090            // Simulate auto-scroll logic: if following_bottom, scroll to bottom
1091            if buffer.following_bottom {
1092                let max_scroll = buffer.line_count().saturating_sub(viewport);
1093                buffer.scroll_offset = max_scroll;
1094            }
1095            assert_eq!(buffer.scroll_offset, 0); // max_scroll is 0 when content < viewport
1096
1097            // Content grows past viewport size
1098            for _ in 0..25 {
1099                buffer.append_line(Line::from("more content"));
1100            }
1101            // Now we have 30 lines, viewport is 20, max_scroll = 10
1102
1103            // The bug was: scroll_offset = 0, but old logic checked if 0 >= 10-1 (false)
1104            // With following_bottom flag, we just check the flag:
1105            if buffer.following_bottom {
1106                let max_scroll = buffer.line_count().saturating_sub(viewport);
1107                buffer.scroll_offset = max_scroll;
1108            }
1109
1110            // Now scroll_offset should be at the bottom
1111            assert_eq!(
1112                buffer.scroll_offset, 10,
1113                "Auto-scroll should move to bottom when content grows past viewport"
1114            );
1115        }
1116    }
1117
1118    // ========================================================================
1119    // TuiState Tests (existing)
1120    // ========================================================================
1121
1122    #[test]
1123    fn iteration_changed_detects_boundary() {
1124        let mut state = TuiState::new();
1125        assert!(!state.iteration_changed(), "no change at start");
1126
1127        // Simulate build.done event (increments iteration)
1128        let event = Event::new("build.done", "");
1129        state.update(&event);
1130
1131        assert_eq!(state.iteration, 1);
1132        assert_eq!(state.prev_iteration, 0);
1133        assert!(state.iteration_changed(), "should detect iteration change");
1134    }
1135
1136    #[test]
1137    fn iteration_changed_resets_after_check() {
1138        let mut state = TuiState::new();
1139        let event = Event::new("build.done", "");
1140        state.update(&event);
1141
1142        assert!(state.iteration_changed());
1143
1144        // Simulate clearing the flag (app.rs does this by updating prev_iteration)
1145        state.prev_iteration = state.iteration;
1146        assert!(!state.iteration_changed(), "flag should reset");
1147    }
1148
1149    #[test]
1150    fn multiple_iterations_tracked() {
1151        let mut state = TuiState::new();
1152
1153        for i in 1..=3 {
1154            let event = Event::new("build.done", "");
1155            state.update(&event);
1156            assert_eq!(state.iteration, i);
1157            assert!(state.iteration_changed());
1158            state.prev_iteration = state.iteration; // simulate app clearing flag
1159        }
1160    }
1161
1162    #[test]
1163    fn custom_hat_topics_update_pending_hat() {
1164        // Test that custom hat topics (not hardcoded) update pending_hat correctly
1165        use std::collections::HashMap;
1166
1167        // Create a hat map for custom hats
1168        let mut hat_map = HashMap::new();
1169        hat_map.insert(
1170            "review.security".to_string(),
1171            (
1172                HatId::new("security_reviewer"),
1173                "🔒 Security Reviewer".to_string(),
1174            ),
1175        );
1176        hat_map.insert(
1177            "review.correctness".to_string(),
1178            (
1179                HatId::new("correctness_reviewer"),
1180                "🎯 Correctness Reviewer".to_string(),
1181            ),
1182        );
1183
1184        let mut state = TuiState::with_hat_map(hat_map);
1185
1186        // Publish review.security event
1187        let event = Event::new("review.security", "Review PR #123");
1188        state.update(&event);
1189
1190        // Should update pending_hat to security reviewer
1191        assert_eq!(
1192            state.get_pending_hat_display(),
1193            "🔒 Security Reviewer",
1194            "Should display security reviewer hat for review.security topic"
1195        );
1196
1197        // Publish review.correctness event
1198        let event = Event::new("review.correctness", "Check logic");
1199        state.update(&event);
1200
1201        // Should update to correctness reviewer
1202        assert_eq!(
1203            state.get_pending_hat_display(),
1204            "🎯 Correctness Reviewer",
1205            "Should display correctness reviewer hat for review.correctness topic"
1206        );
1207    }
1208
1209    #[test]
1210    fn unknown_topics_keep_pending_hat_unchanged() {
1211        // Test that unknown topics don't clear pending_hat
1212        let mut state = TuiState::new();
1213
1214        // Set initial hat
1215        state.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
1216
1217        // Publish unknown event
1218        let event = Event::new("unknown.topic", "Some payload");
1219        state.update(&event);
1220
1221        // Should keep the planner hat
1222        assert_eq!(
1223            state.get_pending_hat_display(),
1224            "📋Planner",
1225            "Unknown topics should not clear pending_hat"
1226        );
1227    }
1228
1229    #[test]
1230    fn task_start_preserves_iterations_across_reset() {
1231        // Regression test: task.start used to do *self = Self::new() which wiped
1232        // iteration buffers, causing the header to show "iter 1/0" and losing all
1233        // previous iteration output.
1234        let mut state = TuiState::new();
1235
1236        // Create 3 iterations with content
1237        state.start_new_iteration();
1238        state.start_new_iteration();
1239        state.start_new_iteration();
1240        assert_eq!(state.total_iterations(), 3);
1241        assert_eq!(state.current_view, 2); // following latest
1242
1243        // Navigate back to review history
1244        state.navigate_prev();
1245        assert_eq!(state.current_view, 1);
1246        assert!(!state.following_latest);
1247
1248        // When task.start fires (e.g., new task planning session)
1249        let event = Event::new("task.start", "New task");
1250        state.update(&event);
1251
1252        // Then iterations are preserved
1253        assert_eq!(
1254            state.total_iterations(),
1255            3,
1256            "task.start should not wipe iteration buffers"
1257        );
1258        assert_eq!(
1259            state.current_view, 1,
1260            "task.start should preserve current_view position"
1261        );
1262        assert!(
1263            !state.following_latest,
1264            "task.start should preserve following_latest state"
1265        );
1266    }
1267
1268    #[test]
1269    fn loop_terminate_freezes_iteration_timer() {
1270        // Given a running iteration with elapsed time
1271        let mut state = TuiState::new();
1272        let start_event = Event::new("build.task", "");
1273        state.update(&start_event);
1274
1275        // Verify timer is running
1276        assert!(state.iteration_started.is_some());
1277        let elapsed_before = state.get_iteration_elapsed().unwrap();
1278        assert!(elapsed_before.as_nanos() > 0);
1279
1280        // When loop.terminate is received
1281        let terminate_event = Event::new("loop.terminate", "");
1282        state.update(&terminate_event);
1283
1284        // Then the timer is frozen
1285        assert!(state.loop_completed);
1286        assert!(state.final_iteration_elapsed.is_some());
1287
1288        // The elapsed time should be frozen (not increasing)
1289        let frozen_elapsed = state.get_iteration_elapsed().unwrap();
1290        std::thread::sleep(std::time::Duration::from_millis(10));
1291        let elapsed_after_sleep = state.get_iteration_elapsed().unwrap();
1292
1293        assert_eq!(
1294            frozen_elapsed, elapsed_after_sleep,
1295            "Timer should be frozen after loop.terminate"
1296        );
1297    }
1298
1299    #[test]
1300    fn loop_terminate_freezes_total_timer() {
1301        let mut state = TuiState::new();
1302        state.loop_started = Some(
1303            std::time::Instant::now()
1304                .checked_sub(std::time::Duration::from_secs(90))
1305                .unwrap(),
1306        );
1307
1308        let before = state.get_loop_elapsed().unwrap();
1309        assert!(before.as_secs() >= 90);
1310
1311        let terminate_event = Event::new("loop.terminate", "");
1312        state.update(&terminate_event);
1313
1314        let frozen = state.get_loop_elapsed().unwrap();
1315        std::thread::sleep(std::time::Duration::from_millis(10));
1316        let after = state.get_loop_elapsed().unwrap();
1317
1318        assert_eq!(
1319            frozen, after,
1320            "Loop elapsed time should be frozen after termination"
1321        );
1322    }
1323
1324    #[test]
1325    fn build_done_freezes_total_timer() {
1326        let mut state = TuiState::new();
1327        state.loop_started = Some(
1328            std::time::Instant::now()
1329                .checked_sub(std::time::Duration::from_secs(42))
1330                .unwrap(),
1331        );
1332
1333        let before = state.get_loop_elapsed().unwrap();
1334        assert!(before.as_secs() >= 42);
1335
1336        let done_event = Event::new("build.done", "");
1337        state.update(&done_event);
1338
1339        let frozen = state.get_loop_elapsed().unwrap();
1340        std::thread::sleep(std::time::Duration::from_millis(10));
1341        let after = state.get_loop_elapsed().unwrap();
1342
1343        assert_eq!(
1344            frozen, after,
1345            "Loop elapsed time should be frozen after build.done"
1346        );
1347    }
1348
1349    #[test]
1350    fn build_blocked_freezes_total_timer() {
1351        let mut state = TuiState::new();
1352        state.loop_started = Some(
1353            std::time::Instant::now()
1354                .checked_sub(std::time::Duration::from_secs(7))
1355                .unwrap(),
1356        );
1357
1358        let before = state.get_loop_elapsed().unwrap();
1359        assert!(before.as_secs() >= 7);
1360
1361        let blocked_event = Event::new("build.blocked", "");
1362        state.update(&blocked_event);
1363
1364        let frozen = state.get_loop_elapsed().unwrap();
1365        std::thread::sleep(std::time::Duration::from_millis(10));
1366        let after = state.get_loop_elapsed().unwrap();
1367
1368        assert_eq!(
1369            frozen, after,
1370            "Loop elapsed time should be frozen after build.blocked"
1371        );
1372    }
1373
1374    // ========================================================================
1375    // TuiState Iteration Management Tests
1376    // ========================================================================
1377
1378    mod tui_state_iterations {
1379        use super::*;
1380
1381        #[test]
1382        fn start_new_iteration_creates_first_buffer() {
1383            // Given TuiState with 0 iterations
1384            let mut state = TuiState::new();
1385            assert_eq!(state.total_iterations(), 0);
1386
1387            // When start_new_iteration() is called
1388            state.start_new_iteration();
1389
1390            // Then iterations.len() == 1 and new IterationBuffer exists
1391            assert_eq!(state.total_iterations(), 1);
1392            assert_eq!(state.iterations[0].number, 1);
1393        }
1394
1395        #[test]
1396        fn start_new_iteration_creates_subsequent_buffers() {
1397            let mut state = TuiState::new();
1398            state.start_new_iteration();
1399            state.start_new_iteration();
1400            state.start_new_iteration();
1401
1402            assert_eq!(state.total_iterations(), 3);
1403            assert_eq!(state.iterations[0].number, 1);
1404            assert_eq!(state.iterations[1].number, 2);
1405            assert_eq!(state.iterations[2].number, 3);
1406        }
1407
1408        #[test]
1409        fn current_iteration_returns_correct_buffer() {
1410            // Given TuiState with 3 iterations and current_view = 1
1411            let mut state = TuiState::new();
1412            state.start_new_iteration();
1413            state.start_new_iteration();
1414            state.start_new_iteration();
1415            state.current_view = 1;
1416
1417            // When current_iteration() is called
1418            let current = state.current_iteration();
1419
1420            // Then the buffer at index 1 is returned (iteration number 2)
1421            assert!(current.is_some());
1422            assert_eq!(current.unwrap().number, 2);
1423        }
1424
1425        #[test]
1426        fn current_iteration_returns_none_when_empty() {
1427            let state = TuiState::new();
1428            assert!(state.current_iteration().is_none());
1429        }
1430
1431        #[test]
1432        fn current_iteration_mut_allows_modification() {
1433            let mut state = TuiState::new();
1434            state.start_new_iteration();
1435
1436            // Add a line via mutable reference
1437            if let Some(buffer) = state.current_iteration_mut() {
1438                buffer.append_line(Line::from("test line"));
1439            }
1440
1441            // Verify modification persisted
1442            assert_eq!(state.current_iteration().unwrap().line_count(), 1);
1443        }
1444
1445        #[test]
1446        fn navigate_next_increases_current_view() {
1447            // Given TuiState with current_view = 1 and 3 iterations
1448            let mut state = TuiState::new();
1449            state.start_new_iteration();
1450            state.start_new_iteration();
1451            state.start_new_iteration();
1452            state.current_view = 1;
1453            state.following_latest = false;
1454
1455            // When navigate_next() is called
1456            state.navigate_next();
1457
1458            // Then current_view == 2
1459            assert_eq!(state.current_view, 2);
1460        }
1461
1462        #[test]
1463        fn navigate_prev_decreases_current_view() {
1464            // Given TuiState with current_view = 2
1465            let mut state = TuiState::new();
1466            state.start_new_iteration();
1467            state.start_new_iteration();
1468            state.start_new_iteration();
1469            state.current_view = 2;
1470
1471            // When navigate_prev() is called
1472            state.navigate_prev();
1473
1474            // Then current_view == 1
1475            assert_eq!(state.current_view, 1);
1476        }
1477
1478        #[test]
1479        fn navigate_next_does_not_exceed_bounds() {
1480            // Given TuiState with current_view = 2 and 3 iterations (max index 2)
1481            let mut state = TuiState::new();
1482            state.start_new_iteration();
1483            state.start_new_iteration();
1484            state.start_new_iteration();
1485            state.current_view = 2;
1486
1487            // When navigate_next() is called
1488            state.navigate_next();
1489
1490            // Then current_view stays at 2
1491            assert_eq!(state.current_view, 2);
1492        }
1493
1494        #[test]
1495        fn navigate_prev_does_not_go_below_zero() {
1496            // Given TuiState with current_view = 0
1497            let mut state = TuiState::new();
1498            state.start_new_iteration();
1499            state.current_view = 0;
1500
1501            // When navigate_prev() is called
1502            state.navigate_prev();
1503
1504            // Then current_view stays at 0
1505            assert_eq!(state.current_view, 0);
1506        }
1507
1508        #[test]
1509        fn following_latest_initially_true() {
1510            // Given new TuiState
1511            // When created
1512            let state = TuiState::new();
1513
1514            // Then following_latest == true
1515            assert!(state.following_latest);
1516        }
1517
1518        #[test]
1519        fn following_latest_becomes_false_on_back_navigation() {
1520            // Given TuiState with following_latest = true and current_view = 2
1521            let mut state = TuiState::new();
1522            state.start_new_iteration();
1523            state.start_new_iteration();
1524            state.start_new_iteration();
1525            state.current_view = 2;
1526            state.following_latest = true;
1527
1528            // When navigate_prev() is called
1529            state.navigate_prev();
1530
1531            // Then following_latest == false
1532            assert!(!state.following_latest);
1533        }
1534
1535        #[test]
1536        fn following_latest_restored_at_latest() {
1537            // Given TuiState with following_latest = false
1538            let mut state = TuiState::new();
1539            state.start_new_iteration();
1540            state.start_new_iteration();
1541            state.start_new_iteration();
1542            state.current_view = 1;
1543            state.following_latest = false;
1544
1545            // When navigate_next() reaches the last iteration
1546            state.navigate_next(); // 1 -> 2 (last)
1547
1548            // Then following_latest == true
1549            assert!(state.following_latest);
1550        }
1551
1552        #[test]
1553        fn total_iterations_reports_count() {
1554            // Given TuiState with 3 iterations
1555            let mut state = TuiState::new();
1556            state.start_new_iteration();
1557            state.start_new_iteration();
1558            state.start_new_iteration();
1559
1560            // When total_iterations() is called
1561            // Then 3 is returned
1562            assert_eq!(state.total_iterations(), 3);
1563        }
1564
1565        #[test]
1566        fn start_new_iteration_auto_follows_latest() {
1567            let mut state = TuiState::new();
1568            state.following_latest = true;
1569            state.start_new_iteration();
1570            state.start_new_iteration();
1571
1572            // When following latest, current_view should track new iterations
1573            assert_eq!(state.current_view, 1); // Index of second iteration
1574        }
1575
1576        // ========================================================================
1577        // Per-Iteration Scroll Independence Tests (Task 08)
1578        // ========================================================================
1579
1580        #[test]
1581        fn per_iteration_scroll_independence() {
1582            // Given iteration 1 with scroll_offset 5 and iteration 2 with scroll_offset 0
1583            let mut state = TuiState::new();
1584            state.start_new_iteration();
1585            state.start_new_iteration();
1586
1587            // Set different scroll offsets for each iteration
1588            state.iterations[0].scroll_offset = 5;
1589            state.iterations[1].scroll_offset = 0;
1590
1591            // When switching between iterations
1592            state.current_view = 0;
1593            assert_eq!(
1594                state.current_iteration().unwrap().scroll_offset,
1595                5,
1596                "iteration 1 should have scroll_offset 5"
1597            );
1598
1599            state.navigate_next();
1600            assert_eq!(
1601                state.current_iteration().unwrap().scroll_offset,
1602                0,
1603                "iteration 2 should have scroll_offset 0"
1604            );
1605
1606            // Then each iteration's scroll_offset is preserved
1607            state.navigate_prev();
1608            assert_eq!(
1609                state.current_iteration().unwrap().scroll_offset,
1610                5,
1611                "iteration 1 should still have scroll_offset 5 after switching back"
1612            );
1613        }
1614
1615        #[test]
1616        fn scroll_within_iteration_does_not_affect_others() {
1617            // Given multiple iterations with different scroll offsets
1618            let mut state = TuiState::new();
1619            state.start_new_iteration();
1620            state.start_new_iteration();
1621            state.start_new_iteration();
1622
1623            // Add content to each iteration
1624            for i in 0..3 {
1625                for j in 0..20 {
1626                    state.iterations[i].append_line(Line::from(format!(
1627                        "iter {} line {}",
1628                        i + 1,
1629                        j
1630                    )));
1631                }
1632            }
1633
1634            // Set initial scroll offsets
1635            state.iterations[0].scroll_offset = 3;
1636            state.iterations[1].scroll_offset = 7;
1637            state.iterations[2].scroll_offset = 10;
1638
1639            // When scrolling in iteration 2
1640            state.current_view = 1;
1641            state.current_iteration_mut().unwrap().scroll_down(10);
1642
1643            // Then only iteration 2's scroll changed
1644            assert_eq!(
1645                state.iterations[0].scroll_offset, 3,
1646                "iteration 1 unchanged"
1647            );
1648            assert_eq!(
1649                state.iterations[1].scroll_offset, 8,
1650                "iteration 2 scrolled down"
1651            );
1652            assert_eq!(
1653                state.iterations[2].scroll_offset, 10,
1654                "iteration 3 unchanged"
1655            );
1656        }
1657
1658        // ========================================================================
1659        // New Iteration Alert Tests (Task 07)
1660        // ========================================================================
1661
1662        #[test]
1663        fn new_iteration_alert_set_when_not_following() {
1664            // Given following_latest = false and new iteration arrives
1665            let mut state = TuiState::new();
1666            state.start_new_iteration(); // Iteration 1
1667            state.start_new_iteration(); // Iteration 2
1668            state.navigate_prev(); // Go back to iteration 1, following_latest = false
1669
1670            // When start_new_iteration() is called
1671            state.start_new_iteration(); // Iteration 3
1672
1673            // Then new_iteration_alert is set to the new iteration number
1674            assert_eq!(state.new_iteration_alert, Some(3));
1675        }
1676
1677        #[test]
1678        fn new_iteration_alert_not_set_when_following() {
1679            // Given following_latest = true
1680            let mut state = TuiState::new();
1681            state.following_latest = true;
1682            state.start_new_iteration();
1683
1684            // When start_new_iteration() is called
1685            state.start_new_iteration();
1686
1687            // Then new_iteration_alert remains None
1688            assert_eq!(state.new_iteration_alert, None);
1689        }
1690
1691        #[test]
1692        fn alert_cleared_when_following_restored() {
1693            // Given new_iteration_alert = Some(5)
1694            let mut state = TuiState::new();
1695            state.start_new_iteration();
1696            state.start_new_iteration();
1697            state.start_new_iteration();
1698            state.current_view = 0;
1699            state.following_latest = false;
1700            state.new_iteration_alert = Some(3);
1701
1702            // When navigation restores following_latest = true
1703            state.navigate_next(); // 0 -> 1
1704            state.navigate_next(); // 1 -> 2 (last, restores following)
1705
1706            // Then new_iteration_alert is cleared to None
1707            assert_eq!(state.new_iteration_alert, None);
1708        }
1709
1710        #[test]
1711        fn alert_not_cleared_on_partial_navigation() {
1712            // Given new_iteration_alert = Some(3) and not at last iteration
1713            let mut state = TuiState::new();
1714            state.start_new_iteration();
1715            state.start_new_iteration();
1716            state.start_new_iteration();
1717            state.current_view = 0;
1718            state.following_latest = false;
1719            state.new_iteration_alert = Some(3);
1720
1721            // When navigate_next() but not reaching last
1722            state.navigate_next(); // 0 -> 1
1723
1724            // Then alert is still set (not at latest yet)
1725            assert_eq!(state.new_iteration_alert, Some(3));
1726            assert!(!state.following_latest);
1727        }
1728
1729        #[test]
1730        fn alert_updates_for_multiple_new_iterations() {
1731            // Given not following and multiple new iterations arrive
1732            let mut state = TuiState::new();
1733            state.start_new_iteration(); // 1
1734            state.start_new_iteration(); // 2
1735            state.navigate_prev(); // Go back, stop following
1736
1737            state.start_new_iteration(); // 3 arrives
1738            assert_eq!(state.new_iteration_alert, Some(3));
1739
1740            // When another iteration arrives
1741            state.start_new_iteration(); // 4 arrives
1742
1743            // Then alert should show the newest
1744            assert_eq!(state.new_iteration_alert, Some(4));
1745        }
1746    }
1747
1748    // ========================================================================
1749    // SearchState Tests (Task 09)
1750    // ========================================================================
1751
1752    mod search_state {
1753        use super::*;
1754
1755        #[test]
1756        fn search_finds_matches_in_lines() {
1757            // Given current iteration with "error" in 3 lines
1758            let mut state = TuiState::new();
1759            state.start_new_iteration();
1760            let buffer = state.current_iteration_mut().unwrap();
1761            buffer.append_line(Line::from("First error occurred"));
1762            buffer.append_line(Line::from("Normal line"));
1763            buffer.append_line(Line::from("Another error here"));
1764            buffer.append_line(Line::from("Final error message"));
1765
1766            // When search("error") is called
1767            state.search("error");
1768
1769            // Then matches.len() >= 3
1770            assert!(
1771                state.search_state.matches.len() >= 3,
1772                "expected at least 3 matches, got {}",
1773                state.search_state.matches.len()
1774            );
1775            assert_eq!(state.search_state.query, Some("error".to_string()));
1776        }
1777
1778        #[test]
1779        fn search_is_case_insensitive() {
1780            // Given current iteration with "Error" and "error"
1781            let mut state = TuiState::new();
1782            state.start_new_iteration();
1783            let buffer = state.current_iteration_mut().unwrap();
1784            buffer.append_line(Line::from("Error in uppercase"));
1785            buffer.append_line(Line::from("error in lowercase"));
1786            buffer.append_line(Line::from("ERROR all caps"));
1787
1788            // When search("error") is called
1789            state.search("error");
1790
1791            // Then all 3 are found
1792            assert_eq!(
1793                state.search_state.matches.len(),
1794                3,
1795                "expected 3 case-insensitive matches"
1796            );
1797        }
1798
1799        #[test]
1800        fn next_match_cycles_forward() {
1801            // Given 3 matches and current_match = 2 (last)
1802            let mut state = TuiState::new();
1803            state.start_new_iteration();
1804            let buffer = state.current_iteration_mut().unwrap();
1805            buffer.append_line(Line::from("match one"));
1806            buffer.append_line(Line::from("match two"));
1807            buffer.append_line(Line::from("match three"));
1808            state.search("match");
1809            state.search_state.current_match = 2;
1810
1811            // When next_match() is called
1812            state.next_match();
1813
1814            // Then current_match becomes 0 (cycles back)
1815            assert_eq!(state.search_state.current_match, 0);
1816        }
1817
1818        #[test]
1819        fn prev_match_cycles_backward() {
1820            // Given 3 matches and current_match = 0 (first)
1821            let mut state = TuiState::new();
1822            state.start_new_iteration();
1823            let buffer = state.current_iteration_mut().unwrap();
1824            buffer.append_line(Line::from("match one"));
1825            buffer.append_line(Line::from("match two"));
1826            buffer.append_line(Line::from("match three"));
1827            state.search("match");
1828            state.search_state.current_match = 0;
1829
1830            // When prev_match() is called
1831            state.prev_match();
1832
1833            // Then current_match becomes 2 (cycles back)
1834            assert_eq!(state.search_state.current_match, 2);
1835        }
1836
1837        #[test]
1838        fn search_jumps_to_match_line() {
1839            // Given match at line 50
1840            let mut state = TuiState::new();
1841            state.start_new_iteration();
1842            let buffer = state.current_iteration_mut().unwrap();
1843            for i in 0..60 {
1844                if i == 50 {
1845                    buffer.append_line(Line::from("target match here"));
1846                } else {
1847                    buffer.append_line(Line::from(format!("line {}", i)));
1848                }
1849            }
1850
1851            // When search finds match at line 50
1852            state.search("target");
1853
1854            // Then scroll_offset is updated so line 50 is visible
1855            let buffer = state.current_iteration().unwrap();
1856            // With viewport of ~20, scroll should position line 50 in view
1857            assert!(
1858                buffer.scroll_offset <= 50,
1859                "scroll_offset {} should position line 50 in view",
1860                buffer.scroll_offset
1861            );
1862        }
1863
1864        #[test]
1865        fn clear_search_resets_state() {
1866            // Given active search
1867            let mut state = TuiState::new();
1868            state.start_new_iteration();
1869            let buffer = state.current_iteration_mut().unwrap();
1870            buffer.append_line(Line::from("search term here"));
1871            state.search("term");
1872            assert!(state.search_state.query.is_some());
1873
1874            // When clear_search() is called
1875            state.clear_search();
1876
1877            // Then query = None, matches cleared, search_mode = false
1878            assert!(state.search_state.query.is_none());
1879            assert!(state.search_state.matches.is_empty());
1880            assert!(!state.search_state.search_mode);
1881        }
1882
1883        #[test]
1884        fn search_with_no_matches_sets_empty() {
1885            // Given iteration with no matching content
1886            let mut state = TuiState::new();
1887            state.start_new_iteration();
1888            let buffer = state.current_iteration_mut().unwrap();
1889            buffer.append_line(Line::from("hello world"));
1890
1891            // When searching for non-existent term
1892            state.search("xyz");
1893
1894            // Then matches is empty but query is set
1895            assert_eq!(state.search_state.query, Some("xyz".to_string()));
1896            assert!(state.search_state.matches.is_empty());
1897            assert_eq!(state.search_state.current_match, 0);
1898        }
1899
1900        #[test]
1901        fn search_on_empty_iteration_handles_gracefully() {
1902            // Given empty iteration
1903            let mut state = TuiState::new();
1904            state.start_new_iteration();
1905
1906            // When searching
1907            state.search("anything");
1908
1909            // Then no panic, empty matches
1910            assert!(state.search_state.matches.is_empty());
1911        }
1912
1913        #[test]
1914        fn next_match_with_no_matches_does_nothing() {
1915            // Given no active search or empty matches
1916            let mut state = TuiState::new();
1917            state.start_new_iteration();
1918
1919            // When next_match is called
1920            state.next_match();
1921
1922            // Then no panic, current_match stays 0
1923            assert_eq!(state.search_state.current_match, 0);
1924        }
1925
1926        #[test]
1927        fn multiple_matches_on_same_line() {
1928            // Given line with multiple occurrences
1929            let mut state = TuiState::new();
1930            state.start_new_iteration();
1931            let buffer = state.current_iteration_mut().unwrap();
1932            buffer.append_line(Line::from("error error error"));
1933
1934            // When searching
1935            state.search("error");
1936
1937            // Then finds all 3 matches
1938            assert_eq!(
1939                state.search_state.matches.len(),
1940                3,
1941                "should find 3 matches on same line"
1942            );
1943        }
1944
1945        #[test]
1946        fn next_match_updates_scroll_to_show_match() {
1947            // Given many lines with matches spread out
1948            let mut state = TuiState::new();
1949            state.start_new_iteration();
1950            let buffer = state.current_iteration_mut().unwrap();
1951            for i in 0..100 {
1952                if i % 30 == 0 {
1953                    buffer.append_line(Line::from("findme"));
1954                } else {
1955                    buffer.append_line(Line::from(format!("line {}", i)));
1956                }
1957            }
1958            state.search("findme");
1959
1960            // Navigate to second match (at line 30)
1961            state.next_match();
1962
1963            // Then scroll should position line 30 in view
1964            let buffer = state.current_iteration().unwrap();
1965            // Match at line 30, scroll should be adjusted
1966            assert!(buffer.scroll_offset <= 30, "scroll should show line 30");
1967        }
1968
1969        #[test]
1970        fn latest_iteration_lines_handle_returns_newest_iteration() {
1971            // Given a user viewing iteration 1 while iteration 3 is executing
1972            let mut state = TuiState::new();
1973            state.start_new_iteration(); // iteration 1
1974            state.start_new_iteration(); // iteration 2
1975            state.start_new_iteration(); // iteration 3
1976
1977            // User navigates back to iteration 1
1978            state.current_view = 0;
1979            state.following_latest = false;
1980
1981            // When getting line handles
1982            let current_handle = state.current_iteration_lines_handle();
1983            let latest_handle = state.latest_iteration_lines_handle();
1984
1985            // Then current_iteration_lines_handle returns iteration 1's buffer
1986            assert!(current_handle.is_some());
1987            // And latest_iteration_lines_handle returns iteration 3's buffer
1988            assert!(latest_handle.is_some());
1989
1990            // Write to latest and verify it doesn't affect current view
1991            {
1992                let latest = latest_handle.unwrap();
1993                latest
1994                    .lock()
1995                    .unwrap()
1996                    .push(Line::from("output from iteration 3"));
1997            }
1998
1999            // Current view (iteration 1) should be empty
2000            let current = state.current_iteration().unwrap();
2001            assert_eq!(
2002                current.lines.lock().unwrap().len(),
2003                0,
2004                "iteration 1 should have no lines"
2005            );
2006
2007            // Latest (iteration 3) should have the output
2008            let latest_buffer = state.iterations.last().unwrap();
2009            assert_eq!(
2010                latest_buffer.lines.lock().unwrap().len(),
2011                1,
2012                "iteration 3 should have the output"
2013            );
2014        }
2015
2016        #[test]
2017        fn output_goes_to_correct_iteration_when_user_reviewing_history() {
2018            // This reproduces the bug: user is on page 3 of 6, but active agent writes to page 3
2019            let mut state = TuiState::new();
2020
2021            // Create 6 iterations
2022            for _ in 0..6 {
2023                state.start_new_iteration();
2024            }
2025
2026            // User navigates to iteration 3 (index 2)
2027            state.current_view = 2;
2028            state.following_latest = false;
2029
2030            // New iteration starts (iteration 7)
2031            state.start_new_iteration();
2032
2033            // Get handle for writing output - MUST use latest, not current
2034            let lines_handle = state.latest_iteration_lines_handle();
2035
2036            // Write output
2037            {
2038                let handle = lines_handle.unwrap();
2039                handle
2040                    .lock()
2041                    .unwrap()
2042                    .push(Line::from("iteration 7 output"));
2043            }
2044
2045            // Verify: iteration 3 (what user is viewing) should be unaffected
2046            let iteration_3 = &state.iterations[2];
2047            assert_eq!(
2048                iteration_3.lines.lock().unwrap().len(),
2049                0,
2050                "iteration 3 (being viewed) should have no output"
2051            );
2052
2053            // Verify: iteration 7 (latest) should have the output
2054            let iteration_7 = state.iterations.last().unwrap();
2055            assert_eq!(
2056                iteration_7.lines.lock().unwrap().len(),
2057                1,
2058                "iteration 7 (latest) should have the output"
2059            );
2060        }
2061    }
2062}