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/// Whether guidance is being entered for the next or current iteration.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum GuidanceMode {
99    /// Guidance for the next prompt boundary.
100    Next,
101    /// Urgent steer for the active iteration.
102    Now,
103}
104
105/// Result of attempting to send guidance.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum GuidanceResult {
108    /// Next-iteration guidance was queued successfully.
109    Queued,
110    /// Urgent steer was persisted successfully.
111    Sent,
112    /// Guidance could not be queued/written.
113    Failed,
114}
115
116/// Status of the background update check.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum UpdateStatus {
119    /// No check result yet.
120    Unknown,
121    /// The running version matches the latest known release.
122    UpToDate,
123    /// A newer release is available.
124    Available { latest: String },
125}
126
127// ============================================================================
128// WaveInfo - Active wave tracking for TUI header display
129// ============================================================================
130
131/// Tracks active wave execution for header display and per-worker output.
132///
133/// Manual `Debug` impl because `worker_buffers` contains `Arc<Mutex<>>` fields.
134pub struct WaveInfo {
135    pub hat_name: String,
136    pub total: u32,
137    pub completed: u32,
138    pub started_at: Instant,
139    /// Per-worker output buffers (indexed by worker_index).
140    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    /// Creates a new WaveInfo with N empty worker buffers.
157    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
175/// Observable state derived from loop events.
176pub struct TuiState {
177    /// Which hat will process next event (ID + display name).
178    pub pending_hat: Option<(HatId, String)>,
179    /// Backend expected for the next iteration (used when metadata is missing).
180    pub pending_backend: Option<String>,
181    /// Current iteration number (0-indexed, display as +1).
182    pub iteration: u32,
183    /// Previous iteration number (for detecting changes).
184    pub prev_iteration: u32,
185    /// When loop began.
186    pub loop_started: Option<Instant>,
187    /// When current iteration began.
188    pub iteration_started: Option<Instant>,
189    /// Most recent event topic.
190    pub last_event: Option<String>,
191    /// Timestamp of last event.
192    pub last_event_at: Option<Instant>,
193    /// Whether to show help overlay.
194    pub show_help: bool,
195    /// Whether mouse capture is enabled for wheel scrolling.
196    /// When false, the terminal keeps native drag-to-select behavior.
197    pub mouse_capture_enabled: bool,
198    /// Whether in scroll mode.
199    pub in_scroll_mode: bool,
200    /// Current search query (if in search input mode).
201    pub search_query: String,
202    /// Search direction (true = forward, false = backward).
203    pub search_forward: bool,
204    /// Maximum iterations from config.
205    pub max_iterations: Option<u32>,
206    /// Idle timeout countdown.
207    pub idle_timeout_remaining: Option<Duration>,
208    /// Status of the asynchronous update check.
209    pub update_status: UpdateStatus,
210    /// Git branch for the workspace the TUI was launched from.
211    current_branch: Option<String>,
212    /// Map of event topics to hat display information (for custom hats).
213    /// Key: event topic (e.g., "review.security")
214    /// Value: (HatId, display name including emoji)
215    hat_map: HashMap<String, (HatId, String)>,
216
217    // ========================================================================
218    // Iteration Management (new fields for TUI refactor)
219    // ========================================================================
220    /// Content buffers for each iteration.
221    pub iterations: Vec<IterationBuffer>,
222    /// Index of the iteration currently being viewed (0-indexed).
223    pub current_view: usize,
224    /// Whether to automatically follow the latest iteration.
225    pub following_latest: bool,
226    /// Alert about a new iteration (shown when viewing history and new iteration arrives).
227    /// Contains the iteration number to alert about. Cleared when navigating to latest.
228    pub new_iteration_alert: Option<usize>,
229
230    // ========================================================================
231    // Search State
232    // ========================================================================
233    /// Search state for finding and navigating matches in iteration content.
234    pub search_state: SearchState,
235
236    // ========================================================================
237    // Completion State
238    // ========================================================================
239    /// Whether the loop has completed (received loop.terminate event).
240    pub loop_completed: bool,
241    /// Frozen elapsed time when loop completed (timer stops at this value).
242    pub final_iteration_elapsed: Option<Duration>,
243    /// Frozen total elapsed time when loop completed (footer timer stops).
244    pub final_loop_elapsed: Option<Duration>,
245
246    // ========================================================================
247    // Task Tracking State
248    // ========================================================================
249    /// Aggregate task counts for display in TUI widgets.
250    pub task_counts: TaskCounts,
251    /// Currently active task (if any) for display in TUI widgets.
252    pub active_task: Option<TaskSummary>,
253
254    // ========================================================================
255    // Wave State
256    // ========================================================================
257    /// Active wave info for header display (only set while a wave is running).
258    pub wave_active: Option<WaveInfo>,
259    /// Index into `iterations` that the active wave belongs to.
260    /// Used by `wave_info_for_view()` to only return `wave_active` when viewing
261    /// the specific iteration that owns the running wave.
262    pub wave_active_iteration_idx: Option<usize>,
263    /// Whether the wave worker drill-down view is active.
264    pub wave_view_active: bool,
265    /// Index of the worker currently being viewed in wave view (0-indexed).
266    pub wave_view_index: usize,
267
268    // ========================================================================
269    // Guidance State
270    // ========================================================================
271    /// Active guidance input mode (None when not entering guidance).
272    pub guidance_mode: Option<GuidanceMode>,
273    /// Text being typed in guidance input.
274    pub guidance_input: String,
275    /// Queue of guidance messages for the next iteration (drained by loop_runner).
276    pub guidance_next_queue: Arc<Mutex<Vec<String>>>,
277    /// Path to events.jsonl for writing urgent guidance for the next prompt.
278    pub events_path: Option<std::path::PathBuf>,
279    /// Path to the urgent-steer marker file used to gate `ralph emit`.
280    pub urgent_steer_path: Option<std::path::PathBuf>,
281    /// Brief flash message after attempting to send guidance.
282    /// (mode, result, when)
283    pub guidance_flash: Option<(GuidanceMode, GuidanceResult, Instant)>,
284
285    // ========================================================================
286    // Subprocess Error State
287    // ========================================================================
288    /// Error message set when subprocess exits before sending any RPC events.
289    /// When set, the TUI displays an error state instead of empty content.
290    pub subprocess_error: Option<String>,
291
292    // ========================================================================
293    // RPC Text Accumulation State
294    // ========================================================================
295    /// Buffer for accumulating streaming text deltas received via RPC.
296    /// Text is rendered as a group when frozen (on tool call, error, or iteration end)
297    /// rather than rendering each small delta independently.
298    pub rpc_text_buffer: String,
299    /// Number of lines in the current iteration buffer that belong to the
300    /// current (unfrozen) text. When new text arrives, these lines are
301    /// replaced with a fresh render of the full accumulated text.
302    pub rpc_text_line_count: usize,
303}
304
305impl TuiState {
306    /// Creates empty state. Timer starts immediately at creation.
307    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            // Iteration management
328            iterations: Vec::new(),
329            current_view: 0,
330            following_latest: true,
331            new_iteration_alert: None,
332            // Search state
333            search_state: SearchState::new(),
334            // Completion state
335            loop_completed: false,
336            final_iteration_elapsed: None,
337            final_loop_elapsed: None,
338            // Task tracking state
339            task_counts: TaskCounts::default(),
340            active_task: None,
341            // Wave state
342            wave_active: None,
343            wave_active_iteration_idx: None,
344            wave_view_active: false,
345            wave_view_index: 0,
346            // Guidance state
347            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 state
354            subprocess_error: None,
355            // RPC text accumulation state
356            rpc_text_buffer: String::new(),
357            rpc_text_line_count: 0,
358        }
359    }
360
361    /// Creates state with a custom hat map for dynamic topic-to-hat resolution.
362    /// Timer starts immediately at creation.
363    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    /// Sets the git branch displayed by the TUI.
370    pub fn set_current_branch(&mut self, branch: Option<String>) {
371        self.current_branch = branch;
372    }
373
374    /// Returns the git branch displayed by the TUI, if known.
375    pub fn current_branch(&self) -> Option<&str> {
376        self.current_branch.as_deref()
377    }
378
379    /// Replaces the dynamic topic-to-hat mapping without resetting the rest of the state.
380    pub fn set_hat_map(&mut self, hat_map: HashMap<String, (HatId, String)>) {
381        self.hat_map = hat_map;
382    }
383
384    /// Updates state based on event topic.
385    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            // Handle iteration timing for custom hats
396            if topic.starts_with("build.") {
397                self.iteration_started = Some(now);
398            }
399        }
400
401        // Fall back to hardcoded mappings for backward compatibility
402        match topic {
403            "task.start" => {
404                // Save state we want to preserve across reset
405                let saved_hat_map = std::mem::take(&mut self.hat_map);
406                let saved_loop_started = self.loop_started; // Preserve timer from TUI init
407                let saved_max_iterations = self.max_iterations;
408                // Preserve iteration buffers so TUI history survives across task restarts
409                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; // Keep original timer
421                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                // Don't reset timer on resume - keep counting from TUI init
441                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                // Resume the loop timer if a new iteration starts after a freeze.
450                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                // Freeze the iteration timer at its current value
473                self.final_iteration_elapsed = self.iteration_started.map(|start| start.elapsed());
474                // Freeze the total loop timer for the footer display
475                self.freeze_loop_elapsed();
476                self.finish_latest_iteration();
477            }
478            _ => {
479                // Unknown topic - don't change pending_hat
480            }
481        }
482    }
483
484    /// Returns formatted hat display (emoji + name).
485    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    /// Time since loop started.
492    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    /// Time since iteration started, or frozen value if loop completed.
500    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    /// True if event received in last 2 seconds.
516    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    /// True if iteration changed since last check.
522    pub fn iteration_changed(&self) -> bool {
523        self.iteration != self.prev_iteration
524    }
525
526    // ========================================================================
527    // Task Tracking Methods
528    // ========================================================================
529
530    /// Returns a reference to the current task counts.
531    pub fn get_task_counts(&self) -> &TaskCounts {
532        &self.task_counts
533    }
534
535    /// Returns a reference to the active task, if any.
536    pub fn get_active_task(&self) -> Option<&TaskSummary> {
537        self.active_task.as_ref()
538    }
539
540    /// Updates the task counts.
541    pub fn set_task_counts(&mut self, counts: TaskCounts) {
542        self.task_counts = counts;
543    }
544
545    /// Sets the active task.
546    pub fn set_active_task(&mut self, task: Option<TaskSummary>) {
547        self.active_task = task;
548    }
549
550    /// Returns true if there are any open tasks.
551    pub fn has_open_tasks(&self) -> bool {
552        self.task_counts.open > 0
553    }
554
555    /// Returns a formatted string for task progress display (e.g., "3/5 tasks").
556    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    // ========================================================================
568    // Iteration Management Methods
569    // ========================================================================
570
571    /// Starts a new iteration, creating a new IterationBuffer.
572    /// If following_latest is true, current_view is updated to the new iteration.
573    /// If not following, sets the new_iteration_alert to notify the user.
574    pub fn start_new_iteration(&mut self) {
575        self.start_new_iteration_with_metadata(None, None);
576    }
577
578    /// Starts a new iteration with optional metadata for hat and backend display.
579    pub fn start_new_iteration_with_metadata(
580        &mut self,
581        hat_display: Option<String>,
582        backend: Option<String>,
583    ) {
584        // Reset text accumulation buffer for the new iteration
585        self.rpc_text_buffer.clear();
586        self.rpc_text_line_count = 0;
587
588        // Exit wave view on new iteration — the user can re-enter with 'w'.
589        // Wave data is preserved per-iteration on the IterationBuffer, so users
590        // can navigate to any iteration and press 'w' to review its wave workers.
591        self.wave_view_active = false;
592
593        // Resume the total loop timer — it gets frozen on build.done/build.blocked
594        // but should keep ticking when the next iteration starts.
595        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        // Auto-follow if enabled
614        if self.following_latest {
615            self.current_view = self.iterations.len().saturating_sub(1);
616        } else {
617            // Alert user about new iteration when reviewing history
618            self.new_iteration_alert = Some(number as usize);
619        }
620    }
621
622    /// Finalizes the latest iteration's elapsed time if it isn't already set.
623    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    /// Freeze total loop elapsed time for the footer if it is still ticking.
636    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    /// Returns the hat display for the currently viewed iteration, if available.
644    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    /// Returns the backend display for the currently viewed iteration, if available.
650    pub fn current_iteration_backend(&self) -> Option<&str> {
651        self.current_iteration()
652            .and_then(|buffer| buffer.backend.as_deref())
653    }
654
655    /// Returns a reference to the currently viewed iteration buffer.
656    pub fn current_iteration(&self) -> Option<&IterationBuffer> {
657        self.iterations.get(self.current_view)
658    }
659
660    /// Returns a mutable reference to the currently viewed iteration buffer.
661    pub fn current_iteration_mut(&mut self) -> Option<&mut IterationBuffer> {
662        self.iterations.get_mut(self.current_view)
663    }
664
665    /// Returns a shared handle to the current iteration's lines buffer.
666    ///
667    /// This allows stream handlers to write directly to the buffer,
668    /// enabling real-time streaming to the TUI during execution.
669    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    /// Returns a shared handle to the latest iteration's lines buffer.
678    ///
679    /// This should be used when writing output from the currently executing
680    /// iteration, regardless of which iteration the user is viewing.
681    /// This prevents output from being written to the wrong iteration when
682    /// the user is reviewing an older iteration.
683    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    /// Navigates to the next iteration (if not at the last one).
690    /// If reaching the last iteration, re-enables following_latest and clears alerts.
691    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            // Re-enable following when reaching the latest
699            if self.current_view == max_index {
700                self.following_latest = true;
701                self.new_iteration_alert = None;
702            }
703        }
704    }
705
706    /// Navigates to the previous iteration (if not at the first one).
707    /// Disables following_latest when navigating backwards.
708    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    /// Returns the total number of iterations.
716    pub fn total_iterations(&self) -> usize {
717        self.iterations.len()
718    }
719
720    // ========================================================================
721    // Search Methods
722    // ========================================================================
723
724    /// Searches for the given query in the current iteration's content.
725    /// Populates matches with (line_index, char_offset) pairs.
726    /// Search is case-insensitive.
727    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        // Check if we have an iteration to search
733        if self.iterations.get(self.current_view).is_none() {
734            return;
735        }
736
737        let query_lower = query.to_lowercase();
738
739        // Collect matches first (avoid borrow conflicts)
740        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                    // Get the text content of the line
748                    let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
749                    let line_lower = line_text.to_lowercase();
750
751                    // Find all occurrences in this line
752                    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        // Jump to first match if any exist
766        if !self.search_state.matches.is_empty() {
767            self.jump_to_current_match();
768        }
769    }
770
771    /// Navigates to the next match, cycling back to the first if at the end.
772    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    /// Navigates to the previous match, cycling to the last if at the beginning.
783    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    /// Clears the search state.
797    pub fn clear_search(&mut self) {
798        self.search_state.clear();
799    }
800
801    /// Jumps to the current match by adjusting scroll_offset to show the match line.
802    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        // Adjust scroll to show the match line
810        // Use a default viewport height for calculation (will be overridden by actual render)
811        let viewport_height = 20;
812        if let Some(buffer) = self.current_iteration_mut() {
813            // If the match line is above the current view, scroll up to it
814            if line_idx < buffer.scroll_offset {
815                buffer.scroll_offset = line_idx;
816            }
817            // If the match line is below the current view, scroll down to show it
818            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    // ========================================================================
825    // Guidance Methods
826    // ========================================================================
827
828    /// Enters guidance input mode.
829    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    /// Cancels guidance input without sending.
836    pub fn cancel_guidance(&mut self) {
837        self.guidance_mode = None;
838        self.guidance_input.clear();
839    }
840
841    /// Sends the current guidance input.
842    ///
843    /// For `GuidanceMode::Next`, pushes to the shared queue (drained by loop_runner).
844    /// For `GuidanceMode::Now`, writes an urgent-steer marker immediately and
845    /// records `human.guidance` for the next prompt boundary.
846    ///
847    /// Returns true if guidance was sent successfully.
848    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    /// Writes a human.guidance event directly to events.jsonl.
887    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    /// Returns true if guidance input is currently active.
928    pub fn is_guidance_active(&self) -> bool {
929        self.guidance_mode.is_some()
930    }
931
932    /// Clears flash message if it has expired.
933    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    /// Returns active guidance flash (mode + result) if still within display window (2 seconds).
942    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    /// Updates the cached result of the asynchronous version check.
953    pub fn set_update_status(&mut self, status: UpdateStatus) {
954        self.update_status = status;
955    }
956
957    // ========================================================================
958    // Wave View Methods
959    // ========================================================================
960
961    /// Returns the WaveInfo to use for wave view.
962    ///
963    /// If viewing the iteration that owns the active wave, returns the live
964    /// `wave_active` (for real-time streaming). Otherwise returns the stored
965    /// wave data from the currently viewed iteration buffer.
966    fn wave_info_for_view(&self) -> Option<&WaveInfo> {
967        // Active wave takes priority when viewing its owning iteration
968        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        // Fall back to the per-iteration stored wave data
975        self.iterations
976            .get(self.current_view)
977            .and_then(|buf| buf.wave_info.as_ref())
978    }
979
980    /// Returns the WaveInfo to use for wave view (mutable).
981    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    /// Returns the WaveInfo for the current wave view (public, for header rendering).
995    pub fn wave_info_for_wave_view(&self) -> Option<&WaveInfo> {
996        self.wave_info_for_view()
997    }
998
999    /// Enters wave worker drill-down view. No-op if no wave data exists.
1000    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    /// Exits wave worker drill-down view.
1008    pub fn exit_wave_view(&mut self) {
1009        self.wave_view_active = false;
1010    }
1011
1012    /// Cycles to the next worker in wave view.
1013    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    /// Cycles to the previous worker in wave view.
1023    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    /// Returns the current wave worker buffer (immutable) for rendering.
1037    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    /// Returns the current wave worker buffer (mutable) for scrolling.
1043    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
1056// ============================================================================
1057// IterationBuffer - Content storage for a single iteration
1058// ============================================================================
1059
1060use ratatui::text::Line;
1061use std::sync::{Arc, Mutex};
1062
1063/// Stores formatted output content for a single Ralph iteration.
1064/// Each iteration has its own buffer with independent scroll state.
1065///
1066/// The `lines` field is wrapped in `Arc<Mutex<>>` to allow sharing
1067/// with stream handlers during execution, enabling real-time streaming
1068/// to the TUI instead of batch transfer after execution completes.
1069pub struct IterationBuffer {
1070    /// Iteration number (1-indexed for display)
1071    pub number: u32,
1072    /// Formatted lines of output (shared for streaming)
1073    pub lines: Arc<Mutex<Vec<Line<'static>>>>,
1074    /// Scroll position within this buffer
1075    pub scroll_offset: usize,
1076    /// Whether to auto-scroll to bottom as new content arrives.
1077    /// Starts true, becomes false when user scrolls up, restored when user
1078    /// scrolls to bottom (G key) or manually scrolls down to reach bottom.
1079    pub following_bottom: bool,
1080    /// Hat display name (emoji + name) for this iteration.
1081    pub hat_display: Option<String>,
1082    /// Backend used for this iteration (e.g., "claude", "kiro").
1083    pub backend: Option<String>,
1084    /// When this iteration started (for elapsed time calculation).
1085    pub started_at: Option<Instant>,
1086    /// Frozen elapsed duration for this iteration (set when completed).
1087    pub elapsed: Option<Duration>,
1088    /// Wave data associated with this iteration (stored on wave completion).
1089    pub wave_info: Option<WaveInfo>,
1090}
1091
1092impl IterationBuffer {
1093    /// Creates a new buffer for the given iteration number.
1094    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, // Start following bottom for auto-scroll
1100            hat_display: None,
1101            backend: None,
1102            started_at: None,
1103            elapsed: None,
1104            wave_info: None,
1105        }
1106    }
1107
1108    /// Returns a shared handle to the lines buffer for streaming.
1109    ///
1110    /// This allows stream handlers to write directly to the buffer,
1111    /// enabling real-time streaming to the TUI.
1112    pub fn lines_handle(&self) -> Arc<Mutex<Vec<Line<'static>>>> {
1113        Arc::clone(&self.lines)
1114    }
1115
1116    /// Appends a line to the buffer.
1117    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    /// Returns the total number of lines in the buffer.
1124    pub fn line_count(&self) -> usize {
1125        self.lines.lock().map(|l| l.len()).unwrap_or(0)
1126    }
1127
1128    /// Returns a clone of the visible lines based on scroll offset and viewport height.
1129    ///
1130    /// Note: Returns owned Vec instead of slice due to interior mutability.
1131    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    /// Scrolls up by one line.
1144    /// Disables auto-scroll since user is moving away from bottom.
1145    pub fn scroll_up(&mut self) {
1146        self.scroll_offset = self.scroll_offset.saturating_sub(1);
1147        self.following_bottom = false;
1148    }
1149
1150    /// Scrolls down by one line, respecting the viewport bounds.
1151    /// Re-enables auto-scroll if user reaches the bottom.
1152    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        // Re-enable following if user scrolled to or past the bottom
1158        if self.scroll_offset >= max_scroll {
1159            self.following_bottom = true;
1160        }
1161    }
1162
1163    /// Scrolls to the top of the buffer.
1164    /// Disables auto-scroll since user is moving away from bottom.
1165    pub fn scroll_top(&mut self) {
1166        self.scroll_offset = 0;
1167        self.following_bottom = false;
1168    }
1169
1170    /// Scrolls to the bottom of the buffer.
1171    /// Re-enables auto-scroll since user explicitly went to bottom.
1172    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    /// Calculates the maximum scroll offset for the given viewport height.
1178    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    // ========================================================================
1191    // IterationBuffer Tests
1192    // ========================================================================
1193
1194    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            // Verify order by checking raw content
1215            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            // Should be lines 0-4
1242            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            // Should be lines 3-7
1257            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); // Only 3 lines exist
1270        }
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); // viewport height 5
1288            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); // Should stay at 0
1315        }
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            // With 10 lines and viewport 5, max scroll is 5 (shows lines 5-9)
1324            buffer.scroll_offset = 5;
1325
1326            buffer.scroll_down(5);
1327            assert_eq!(buffer.scroll_offset, 5); // Should stay at max
1328        }
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); // viewport height 5
1350            assert_eq!(buffer.scroll_offset, 5); // max = 10 - 5 = 5
1351        }
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); // viewport larger than content
1361            assert_eq!(buffer.scroll_offset, 0); // Can't scroll
1362        }
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        // =====================================================================
1372        // Auto-scroll (following_bottom) Tests
1373        // =====================================================================
1374
1375        #[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; // One away from max (5 with viewport 5)
1440            buffer.following_bottom = false;
1441
1442            buffer.scroll_down(5); // Now at max (5)
1443
1444            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); // Now at 1, max is 5
1460
1461            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            // This tests the core bug fix: content growing from small to large
1470            let mut buffer = IterationBuffer::new(1);
1471
1472            // Start with small content that fits in viewport
1473            for _ in 0..5 {
1474                buffer.append_line(Line::from("line"));
1475            }
1476
1477            // Simulate initial state: following_bottom = true, scroll_offset = 0
1478            let viewport = 20;
1479            assert!(buffer.following_bottom);
1480            assert_eq!(buffer.scroll_offset, 0);
1481
1482            // Simulate auto-scroll logic: if following_bottom, scroll to bottom
1483            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); // max_scroll is 0 when content < viewport
1488
1489            // Content grows past viewport size
1490            for _ in 0..25 {
1491                buffer.append_line(Line::from("more content"));
1492            }
1493            // Now we have 30 lines, viewport is 20, max_scroll = 10
1494
1495            // The bug was: scroll_offset = 0, but old logic checked if 0 >= 10-1 (false)
1496            // With following_bottom flag, we just check the flag:
1497            if buffer.following_bottom {
1498                let max_scroll = buffer.line_count().saturating_sub(viewport);
1499                buffer.scroll_offset = max_scroll;
1500            }
1501
1502            // Now scroll_offset should be at the bottom
1503            assert_eq!(
1504                buffer.scroll_offset, 10,
1505                "Auto-scroll should move to bottom when content grows past viewport"
1506            );
1507        }
1508    }
1509
1510    // ========================================================================
1511    // TuiState Tests (existing)
1512    // ========================================================================
1513
1514    #[test]
1515    fn iteration_changed_detects_boundary() {
1516        let mut state = TuiState::new();
1517        assert!(!state.iteration_changed(), "no change at start");
1518
1519        // Simulate build.done event (increments iteration)
1520        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        // Simulate clearing the flag (app.rs does this by updating prev_iteration)
1537        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; // simulate app clearing flag
1551        }
1552    }
1553
1554    #[test]
1555    fn custom_hat_topics_update_pending_hat() {
1556        // Test that custom hat topics (not hardcoded) update pending_hat correctly
1557        use std::collections::HashMap;
1558
1559        // Create a hat map for custom hats
1560        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        // Publish review.security event
1579        let event = Event::new("review.security", "Review PR #123");
1580        state.update(&event);
1581
1582        // Should update pending_hat to security reviewer
1583        assert_eq!(
1584            state.get_pending_hat_display(),
1585            "🔒 Security Reviewer",
1586            "Should display security reviewer hat for review.security topic"
1587        );
1588
1589        // Publish review.correctness event
1590        let event = Event::new("review.correctness", "Check logic");
1591        state.update(&event);
1592
1593        // Should update to correctness reviewer
1594        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        // Test that unknown topics don't clear pending_hat
1604        let mut state = TuiState::new();
1605
1606        // Set initial hat
1607        state.pending_hat = Some((HatId::new("planner"), "📋Planner".to_string()));
1608
1609        // Publish unknown event
1610        let event = Event::new("unknown.topic", "Some payload");
1611        state.update(&event);
1612
1613        // Should keep the planner hat
1614        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        // Regression test: task.start used to do *self = Self::new() which wiped
1624        // iteration buffers, causing the header to show "iter 1/0" and losing all
1625        // previous iteration output.
1626        let mut state = TuiState::new();
1627
1628        // Create 3 iterations with content
1629        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); // following latest
1634
1635        // Navigate back to review history
1636        state.navigate_prev();
1637        assert_eq!(state.current_view, 1);
1638        assert!(!state.following_latest);
1639
1640        // When task.start fires (e.g., new task planning session)
1641        let event = Event::new("task.start", "New task");
1642        state.update(&event);
1643
1644        // Then iterations are preserved
1645        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        // Given a running iteration with elapsed time
1663        let mut state = TuiState::new();
1664        let start_event = Event::new("build.task", "");
1665        state.update(&start_event);
1666
1667        // Verify timer is running
1668        assert!(state.iteration_started.is_some());
1669        let elapsed_before = state.get_iteration_elapsed().unwrap();
1670        assert!(elapsed_before.as_nanos() > 0);
1671
1672        // When loop.terminate is received
1673        let terminate_event = Event::new("loop.terminate", "");
1674        state.update(&terminate_event);
1675
1676        // Then the timer is frozen
1677        assert!(state.loop_completed);
1678        assert!(state.final_iteration_elapsed.is_some());
1679
1680        // The elapsed time should be frozen (not increasing)
1681        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    // ========================================================================
1767    // TuiState Iteration Management Tests
1768    // ========================================================================
1769
1770    mod tui_state_iterations {
1771        use super::*;
1772
1773        #[test]
1774        fn start_new_iteration_creates_first_buffer() {
1775            // Given TuiState with 0 iterations
1776            let mut state = TuiState::new();
1777            assert_eq!(state.total_iterations(), 0);
1778
1779            // When start_new_iteration() is called
1780            state.start_new_iteration();
1781
1782            // Then iterations.len() == 1 and new IterationBuffer exists
1783            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            // Given TuiState with 3 iterations and current_view = 1
1803            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            // When current_iteration() is called
1810            let current = state.current_iteration();
1811
1812            // Then the buffer at index 1 is returned (iteration number 2)
1813            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            // Add a line via mutable reference
1829            if let Some(buffer) = state.current_iteration_mut() {
1830                buffer.append_line(Line::from("test line"));
1831            }
1832
1833            // Verify modification persisted
1834            assert_eq!(state.current_iteration().unwrap().line_count(), 1);
1835        }
1836
1837        #[test]
1838        fn navigate_next_increases_current_view() {
1839            // Given TuiState with current_view = 1 and 3 iterations
1840            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            // When navigate_next() is called
1848            state.navigate_next();
1849
1850            // Then current_view == 2
1851            assert_eq!(state.current_view, 2);
1852        }
1853
1854        #[test]
1855        fn navigate_prev_decreases_current_view() {
1856            // Given TuiState with current_view = 2
1857            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            // When navigate_prev() is called
1864            state.navigate_prev();
1865
1866            // Then current_view == 1
1867            assert_eq!(state.current_view, 1);
1868        }
1869
1870        #[test]
1871        fn navigate_next_does_not_exceed_bounds() {
1872            // Given TuiState with current_view = 2 and 3 iterations (max index 2)
1873            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            // When navigate_next() is called
1880            state.navigate_next();
1881
1882            // Then current_view stays at 2
1883            assert_eq!(state.current_view, 2);
1884        }
1885
1886        #[test]
1887        fn navigate_prev_does_not_go_below_zero() {
1888            // Given TuiState with current_view = 0
1889            let mut state = TuiState::new();
1890            state.start_new_iteration();
1891            state.current_view = 0;
1892
1893            // When navigate_prev() is called
1894            state.navigate_prev();
1895
1896            // Then current_view stays at 0
1897            assert_eq!(state.current_view, 0);
1898        }
1899
1900        #[test]
1901        fn following_latest_initially_true() {
1902            // Given new TuiState
1903            // When created
1904            let state = TuiState::new();
1905
1906            // Then following_latest == true
1907            assert!(state.following_latest);
1908        }
1909
1910        #[test]
1911        fn following_latest_becomes_false_on_back_navigation() {
1912            // Given TuiState with following_latest = true and current_view = 2
1913            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            // When navigate_prev() is called
1921            state.navigate_prev();
1922
1923            // Then following_latest == false
1924            assert!(!state.following_latest);
1925        }
1926
1927        #[test]
1928        fn following_latest_restored_at_latest() {
1929            // Given TuiState with following_latest = false
1930            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            // When navigate_next() reaches the last iteration
1938            state.navigate_next(); // 1 -> 2 (last)
1939
1940            // Then following_latest == true
1941            assert!(state.following_latest);
1942        }
1943
1944        #[test]
1945        fn total_iterations_reports_count() {
1946            // Given TuiState with 3 iterations
1947            let mut state = TuiState::new();
1948            state.start_new_iteration();
1949            state.start_new_iteration();
1950            state.start_new_iteration();
1951
1952            // When total_iterations() is called
1953            // Then 3 is returned
1954            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            // When following latest, current_view should track new iterations
1965            assert_eq!(state.current_view, 1); // Index of second iteration
1966        }
1967
1968        // ========================================================================
1969        // Per-Iteration Scroll Independence Tests (Task 08)
1970        // ========================================================================
1971
1972        #[test]
1973        fn per_iteration_scroll_independence() {
1974            // Given iteration 1 with scroll_offset 5 and iteration 2 with scroll_offset 0
1975            let mut state = TuiState::new();
1976            state.start_new_iteration();
1977            state.start_new_iteration();
1978
1979            // Set different scroll offsets for each iteration
1980            state.iterations[0].scroll_offset = 5;
1981            state.iterations[1].scroll_offset = 0;
1982
1983            // When switching between iterations
1984            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            // Then each iteration's scroll_offset is preserved
1999            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            // Given multiple iterations with different scroll offsets
2010            let mut state = TuiState::new();
2011            state.start_new_iteration();
2012            state.start_new_iteration();
2013            state.start_new_iteration();
2014
2015            // Add content to each iteration
2016            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            // Set initial scroll offsets
2027            state.iterations[0].scroll_offset = 3;
2028            state.iterations[1].scroll_offset = 7;
2029            state.iterations[2].scroll_offset = 10;
2030
2031            // When scrolling in iteration 2
2032            state.current_view = 1;
2033            state.current_iteration_mut().unwrap().scroll_down(10);
2034
2035            // Then only iteration 2's scroll changed
2036            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        // ========================================================================
2051        // New Iteration Alert Tests (Task 07)
2052        // ========================================================================
2053
2054        #[test]
2055        fn new_iteration_alert_set_when_not_following() {
2056            // Given following_latest = false and new iteration arrives
2057            let mut state = TuiState::new();
2058            state.start_new_iteration(); // Iteration 1
2059            state.start_new_iteration(); // Iteration 2
2060            state.navigate_prev(); // Go back to iteration 1, following_latest = false
2061
2062            // When start_new_iteration() is called
2063            state.start_new_iteration(); // Iteration 3
2064
2065            // Then new_iteration_alert is set to the new iteration number
2066            assert_eq!(state.new_iteration_alert, Some(3));
2067        }
2068
2069        #[test]
2070        fn new_iteration_alert_not_set_when_following() {
2071            // Given following_latest = true
2072            let mut state = TuiState::new();
2073            state.following_latest = true;
2074            state.start_new_iteration();
2075
2076            // When start_new_iteration() is called
2077            state.start_new_iteration();
2078
2079            // Then new_iteration_alert remains None
2080            assert_eq!(state.new_iteration_alert, None);
2081        }
2082
2083        #[test]
2084        fn alert_cleared_when_following_restored() {
2085            // Given new_iteration_alert = Some(5)
2086            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            // When navigation restores following_latest = true
2095            state.navigate_next(); // 0 -> 1
2096            state.navigate_next(); // 1 -> 2 (last, restores following)
2097
2098            // Then new_iteration_alert is cleared to None
2099            assert_eq!(state.new_iteration_alert, None);
2100        }
2101
2102        #[test]
2103        fn alert_not_cleared_on_partial_navigation() {
2104            // Given new_iteration_alert = Some(3) and not at last iteration
2105            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            // When navigate_next() but not reaching last
2114            state.navigate_next(); // 0 -> 1
2115
2116            // Then alert is still set (not at latest yet)
2117            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            // Given not following and multiple new iterations arrive
2124            let mut state = TuiState::new();
2125            state.start_new_iteration(); // 1
2126            state.start_new_iteration(); // 2
2127            state.navigate_prev(); // Go back, stop following
2128
2129            state.start_new_iteration(); // 3 arrives
2130            assert_eq!(state.new_iteration_alert, Some(3));
2131
2132            // When another iteration arrives
2133            state.start_new_iteration(); // 4 arrives
2134
2135            // Then alert should show the newest
2136            assert_eq!(state.new_iteration_alert, Some(4));
2137        }
2138    }
2139
2140    // ========================================================================
2141    // SearchState Tests (Task 09)
2142    // ========================================================================
2143
2144    mod search_state {
2145        use super::*;
2146
2147        #[test]
2148        fn search_finds_matches_in_lines() {
2149            // Given current iteration with "error" in 3 lines
2150            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            // When search("error") is called
2159            state.search("error");
2160
2161            // Then matches.len() >= 3
2162            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            // Given current iteration with "Error" and "error"
2173            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            // When search("error") is called
2181            state.search("error");
2182
2183            // Then all 3 are found
2184            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            // Given 3 matches and current_match = 2 (last)
2194            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            // When next_match() is called
2204            state.next_match();
2205
2206            // Then current_match becomes 0 (cycles back)
2207            assert_eq!(state.search_state.current_match, 0);
2208        }
2209
2210        #[test]
2211        fn prev_match_cycles_backward() {
2212            // Given 3 matches and current_match = 0 (first)
2213            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            // When prev_match() is called
2223            state.prev_match();
2224
2225            // Then current_match becomes 2 (cycles back)
2226            assert_eq!(state.search_state.current_match, 2);
2227        }
2228
2229        #[test]
2230        fn search_jumps_to_match_line() {
2231            // Given match at line 50
2232            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            // When search finds match at line 50
2244            state.search("target");
2245
2246            // Then scroll_offset is updated so line 50 is visible
2247            let buffer = state.current_iteration().unwrap();
2248            // With viewport of ~20, scroll should position line 50 in view
2249            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            // Given active search
2259            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            // When clear_search() is called
2267            state.clear_search();
2268
2269            // Then query = None, matches cleared, search_mode = false
2270            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            // Given iteration with no matching content
2278            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            // When searching for non-existent term
2284            state.search("xyz");
2285
2286            // Then matches is empty but query is set
2287            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            // Given empty iteration
2295            let mut state = TuiState::new();
2296            state.start_new_iteration();
2297
2298            // When searching
2299            state.search("anything");
2300
2301            // Then no panic, empty matches
2302            assert!(state.search_state.matches.is_empty());
2303        }
2304
2305        #[test]
2306        fn next_match_with_no_matches_does_nothing() {
2307            // Given no active search or empty matches
2308            let mut state = TuiState::new();
2309            state.start_new_iteration();
2310
2311            // When next_match is called
2312            state.next_match();
2313
2314            // Then no panic, current_match stays 0
2315            assert_eq!(state.search_state.current_match, 0);
2316        }
2317
2318        #[test]
2319        fn multiple_matches_on_same_line() {
2320            // Given line with multiple occurrences
2321            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            // When searching
2327            state.search("error");
2328
2329            // Then finds all 3 matches
2330            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            // Given many lines with matches spread out
2340            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            // Navigate to second match (at line 30)
2353            state.next_match();
2354
2355            // Then scroll should position line 30 in view
2356            let buffer = state.current_iteration().unwrap();
2357            // Match at line 30, scroll should be adjusted
2358            assert!(buffer.scroll_offset <= 30, "scroll should show line 30");
2359        }
2360
2361        #[test]
2362        fn latest_iteration_lines_handle_returns_newest_iteration() {
2363            // Given a user viewing iteration 1 while iteration 3 is executing
2364            let mut state = TuiState::new();
2365            state.start_new_iteration(); // iteration 1
2366            state.start_new_iteration(); // iteration 2
2367            state.start_new_iteration(); // iteration 3
2368
2369            // User navigates back to iteration 1
2370            state.current_view = 0;
2371            state.following_latest = false;
2372
2373            // When getting line handles
2374            let current_handle = state.current_iteration_lines_handle();
2375            let latest_handle = state.latest_iteration_lines_handle();
2376
2377            // Then current_iteration_lines_handle returns iteration 1's buffer
2378            assert!(current_handle.is_some());
2379            // And latest_iteration_lines_handle returns iteration 3's buffer
2380            assert!(latest_handle.is_some());
2381
2382            // Write to latest and verify it doesn't affect current view
2383            {
2384                let latest = latest_handle.unwrap();
2385                latest
2386                    .lock()
2387                    .unwrap()
2388                    .push(Line::from("output from iteration 3"));
2389            }
2390
2391            // Current view (iteration 1) should be empty
2392            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            // Latest (iteration 3) should have the output
2400            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            // This reproduces the bug: user is on page 3 of 6, but active agent writes to page 3
2411            let mut state = TuiState::new();
2412
2413            // Create 6 iterations
2414            for _ in 0..6 {
2415                state.start_new_iteration();
2416            }
2417
2418            // User navigates to iteration 3 (index 2)
2419            state.current_view = 2;
2420            state.following_latest = false;
2421
2422            // New iteration starts (iteration 7)
2423            state.start_new_iteration();
2424
2425            // Get handle for writing output - MUST use latest, not current
2426            let lines_handle = state.latest_iteration_lines_handle();
2427
2428            // Write output
2429            {
2430                let handle = lines_handle.unwrap();
2431                handle
2432                    .lock()
2433                    .unwrap()
2434                    .push(Line::from("iteration 7 output"));
2435            }
2436
2437            // Verify: iteration 3 (what user is viewing) should be unaffected
2438            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            // Verify: iteration 7 (latest) should have the output
2446            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    // ========================================================================
2456    // Guidance Tests
2457    // ========================================================================
2458
2459    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            // Queue some guidance
2594            state.start_guidance(GuidanceMode::Next);
2595            state.guidance_input = "remember this".to_string();
2596            state.send_guidance();
2597
2598            // Simulate task.start reset
2599            let event = Event::new("task.start", "New task");
2600            state.update(&event);
2601
2602            // Queue should be preserved (same Arc)
2603            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    // ========================================================================
2610    // Wave View Per-Iteration Tests
2611    // ========================================================================
2612
2613    mod wave_view {
2614        use super::*;
2615
2616        /// Simulates wave lifecycle: start a wave on the given state,
2617        /// then complete it, storing the data on the correct iteration.
2618        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            // Add some content to worker buffers
2623            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            // Complete the wave — move to iteration buffer
2632            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            // Iteration 1: wave with 5 workers
2646            state.start_new_iteration();
2647            simulate_wave(&mut state, 5);
2648
2649            // Iteration 2: wave with 3 workers
2650            state.start_new_iteration();
2651            simulate_wave(&mut state, 3);
2652
2653            // Navigate to iteration 1
2654            state.navigate_prev();
2655            assert_eq!(state.current_view, 0);
2656
2657            // Press 'w' — should show iteration 1's wave (5 workers)
2658            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            // Iteration 1: completed wave with 5 workers
2674            state.start_new_iteration();
2675            simulate_wave(&mut state, 5);
2676
2677            // Iteration 2: active wave with 3 workers (not completed)
2678            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            // Viewing iteration 2 (latest) — should see active wave
2683            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            // Iteration 1: completed wave with 5 workers
2696            state.start_new_iteration();
2697            simulate_wave(&mut state, 5);
2698
2699            // Iteration 2: active wave with 3 workers (not completed)
2700            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            // Navigate back to iteration 1
2705            state.navigate_prev();
2706            assert_eq!(state.current_view, 0);
2707
2708            // Press 'w' — should show iteration 1's completed wave, NOT the active wave
2709            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            // Iteration 1: has a wave
2724            state.start_new_iteration();
2725            simulate_wave(&mut state, 3);
2726
2727            // Iteration 2: no wave
2728            state.start_new_iteration();
2729
2730            // Viewing iteration 2 — pressing 'w' should be a no-op
2731            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            // Iteration 1: wave with 5 workers
2740            state.start_new_iteration();
2741            simulate_wave(&mut state, 5);
2742
2743            // Iteration 2: wave with 2 workers
2744            state.start_new_iteration();
2745            simulate_wave(&mut state, 2);
2746
2747            // Navigate to iteration 1 and enter wave view
2748            state.navigate_prev();
2749            state.enter_wave_view();
2750
2751            // Cycle through workers — should wrap at 5, not 2
2752            for i in 0..5 {
2753                assert_eq!(state.wave_view_index, i);
2754                state.wave_view_next();
2755            }
2756            // After 5 nexts, should wrap back to 0
2757            assert_eq!(state.wave_view_index, 0);
2758        }
2759    }
2760}