Skip to main content

agent_procs/tui/
app.rs

1use crate::protocol::{ProcessInfo, Stream};
2use crate::tui::disk_log_reader::DiskLogReader;
3use std::collections::{HashMap, VecDeque};
4
5const MAX_BUFFER_LINES: usize = 10_000;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum StreamMode {
9    Stdout,
10    Stderr,
11    Both,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum LineSource {
16    Stdout,
17    Stderr,
18}
19
20/// Single ring buffer storing all output with source tags.
21/// Stdout/stderr views are filtered from the same data — no duplication.
22pub struct OutputBuffer {
23    lines: VecDeque<(LineSource, String)>,
24    max_lines: usize,
25    stdout_count: usize,
26    stderr_count: usize,
27}
28
29impl OutputBuffer {
30    pub fn new(max_lines: usize) -> Self {
31        Self {
32            lines: VecDeque::with_capacity(max_lines),
33            max_lines,
34            stdout_count: 0,
35            stderr_count: 0,
36        }
37    }
38
39    pub fn push(&mut self, source: LineSource, line: String) {
40        if self.lines.len() == self.max_lines
41            && let Some((evicted, _)) = self.lines.pop_front()
42        {
43            match evicted {
44                LineSource::Stdout => self.stdout_count -= 1,
45                LineSource::Stderr => self.stderr_count -= 1,
46            }
47        }
48        match source {
49            LineSource::Stdout => self.stdout_count += 1,
50            LineSource::Stderr => self.stderr_count += 1,
51        }
52        self.lines.push_back((source, line));
53    }
54
55    /// O(1) count of total lines.
56    pub fn len(&self) -> usize {
57        self.lines.len()
58    }
59
60    pub fn is_empty(&self) -> bool {
61        self.lines.is_empty()
62    }
63
64    /// O(1) count of stdout lines.
65    pub fn stdout_count(&self) -> usize {
66        self.stdout_count
67    }
68
69    /// O(1) count of stderr lines.
70    pub fn stderr_count(&self) -> usize {
71        self.stderr_count
72    }
73
74    pub fn stdout_lines(&self) -> impl Iterator<Item = &str> {
75        self.lines
76            .iter()
77            .filter(|(src, _)| *src == LineSource::Stdout)
78            .map(|(_, s)| s.as_str())
79    }
80
81    pub fn stderr_lines(&self) -> impl Iterator<Item = &str> {
82        self.lines
83            .iter()
84            .filter(|(src, _)| *src == LineSource::Stderr)
85            .map(|(_, s)| s.as_str())
86    }
87
88    pub fn all_lines(&self) -> impl Iterator<Item = (LineSource, &str)> {
89        self.lines.iter().map(|(src, s)| (*src, s.as_str()))
90    }
91}
92
93/// Cached index of matching line numbers for filtered disk scrollback.
94pub struct FilteredIndex {
95    pub filter: String,
96    pub stream_mode: StreamMode,
97    /// Line indices (in the stream mode's address space) that match the filter.
98    pub matching_lines: Vec<usize>,
99    /// Number of disk lines scanned so far (for incremental updates).
100    pub scanned_up_to: usize,
101}
102
103/// Input mode for the TUI.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub enum InputMode {
106    /// Normal keybinding mode.
107    Normal,
108    /// Typing a filter pattern.
109    FilterInput,
110}
111
112pub struct App {
113    pub processes: Vec<ProcessInfo>,
114    pub selected: usize,
115    pub buffers: HashMap<String, OutputBuffer>,
116    pub stream_mode: StreamMode,
117    pub paused: bool,
118    pub scroll_offsets: HashMap<String, usize>,
119    pub running: bool,
120    pub stop_all_on_quit: bool,
121    pub input_mode: InputMode,
122    /// In-progress filter text while the user is typing.
123    pub filter_buf: String,
124    /// Active filter applied to output lines. `None` means no filter.
125    pub filter: Option<String>,
126    /// Cached visible height of the output pane (set during render).
127    pub visible_height: usize,
128    /// Disk-backed log readers for each process.
129    pub disk_readers: HashMap<String, DiskLogReader>,
130    /// Cached filtered indices for filtered disk scrollback.
131    pub filtered_indices: HashMap<String, FilteredIndex>,
132}
133
134impl Default for App {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140impl App {
141    pub fn new() -> Self {
142        Self {
143            processes: Vec::new(),
144            selected: 0,
145            buffers: HashMap::new(),
146            stream_mode: StreamMode::Stdout,
147            paused: false,
148            scroll_offsets: HashMap::new(),
149            running: true,
150            stop_all_on_quit: false,
151            input_mode: InputMode::Normal,
152            filter_buf: String::new(),
153            filter: None,
154            visible_height: 20,
155            disk_readers: HashMap::new(),
156            filtered_indices: HashMap::new(),
157        }
158    }
159
160    pub fn update_processes(&mut self, processes: Vec<ProcessInfo>) {
161        self.processes = processes;
162        if self.selected >= self.processes.len() && !self.processes.is_empty() {
163            self.selected = self.processes.len() - 1;
164        }
165    }
166
167    pub fn selected_name(&self) -> Option<&str> {
168        self.processes.get(self.selected).map(|p| p.name.as_str())
169    }
170
171    pub fn select_next(&mut self) {
172        if !self.processes.is_empty() {
173            self.selected = (self.selected + 1) % self.processes.len();
174        }
175    }
176
177    pub fn select_prev(&mut self) {
178        if !self.processes.is_empty() {
179            self.selected = if self.selected == 0 {
180                self.processes.len() - 1
181            } else {
182                self.selected - 1
183            };
184        }
185    }
186
187    pub fn cycle_stream_mode(&mut self) {
188        self.stream_mode = match self.stream_mode {
189            StreamMode::Stdout => StreamMode::Stderr,
190            StreamMode::Stderr => StreamMode::Both,
191            StreamMode::Both => StreamMode::Stdout,
192        };
193        // Rebuild filtered indices if filter is active (different line numbering)
194        if self.filter.is_some() {
195            let names: Vec<String> = self.disk_readers.keys().cloned().collect();
196            for name in names {
197                self.build_filtered_index(&name);
198            }
199        }
200    }
201
202    pub fn toggle_pause(&mut self) {
203        self.paused = !self.paused;
204        if !self.paused {
205            // Reset scroll offset to bottom on unpause
206            if let Some(name) = self.processes.get(self.selected).map(|p| p.name.clone()) {
207                self.scroll_offsets.remove(&name);
208            }
209        }
210    }
211
212    /// Scroll up by the given number of lines. Auto-pauses if not already paused.
213    /// Clamps to the maximum scrollable range so overshooting the top is impossible.
214    pub fn scroll_up_by(&mut self, lines: usize) {
215        if !self.paused {
216            self.paused = true;
217        }
218        if let Some(name) = self.selected_name().map(str::to_string) {
219            let max_offset = self
220                .line_count_for(&name)
221                .saturating_sub(self.visible_height);
222            let offset = self.scroll_offsets.entry(name).or_insert(0);
223            *offset = offset.saturating_add(lines).min(max_offset);
224        }
225    }
226
227    /// Scroll up by half a page.
228    pub fn scroll_up(&mut self) {
229        let half_page = (self.visible_height / 2).max(1);
230        self.scroll_up_by(half_page);
231    }
232
233    /// Scroll down by the given number of lines. If we reach the bottom, unpause.
234    pub fn scroll_down_by(&mut self, lines: usize) {
235        if let Some(name) = self.selected_name().map(str::to_string) {
236            let offset = self.scroll_offsets.entry(name).or_insert(0);
237            *offset = offset.saturating_sub(lines);
238            if *offset == 0 {
239                self.paused = false;
240            }
241        }
242    }
243
244    /// Scroll down by half a page.
245    pub fn scroll_down(&mut self) {
246        let half_page = (self.visible_height / 2).max(1);
247        self.scroll_down_by(half_page);
248    }
249
250    pub fn scroll_to_top(&mut self) {
251        if !self.paused {
252            self.paused = true;
253        }
254        if let Some(name) = self.selected_name().map(str::to_string) {
255            let total = self.line_count_for(&name);
256            self.scroll_offsets.insert(name, total);
257        }
258    }
259
260    pub fn scroll_to_bottom(&mut self) {
261        if let Some(name) = self.selected_name().map(str::to_string) {
262            self.scroll_offsets.remove(&name);
263            self.paused = false;
264        }
265    }
266
267    /// Count visible lines for the selected process.
268    /// When a filter is active, uses the filtered index count.
269    /// Otherwise, uses the disk-backed total (authoritative).
270    fn line_count_for(&mut self, name: &str) -> usize {
271        if self.filter.is_some() {
272            return self
273                .filtered_indices
274                .get(name)
275                .map_or(0, |fi| fi.matching_lines.len());
276        }
277        self.total_line_count(name)
278    }
279
280    /// Total line count combining disk history and hot buffer.
281    /// Uses disk as authoritative; falls back to hot buffer if larger
282    /// (e.g. right after a process restart before disk catches up).
283    fn total_line_count(&mut self, name: &str) -> usize {
284        let hot = self.hot_line_count(name, None);
285        let disk = self.disk_line_count(name);
286        disk.max(hot)
287    }
288
289    /// Disk line count for the current stream mode.
290    fn disk_line_count(&mut self, name: &str) -> usize {
291        let mode = self.stream_mode;
292        self.disk_readers.get_mut(name).map_or(0, |r| match mode {
293            StreamMode::Stdout => r.line_count(LineSource::Stdout),
294            StreamMode::Stderr => r.line_count(LineSource::Stderr),
295            StreamMode::Both => r.line_count_both(),
296        })
297    }
298
299    /// Hot buffer line count, optionally filtered by a substring pattern.
300    /// O(1) when `filter` is `None`; O(n) when filtering.
301    fn hot_line_count(&self, name: &str, filter: Option<&str>) -> usize {
302        let Some(buf) = self.buffers.get(name) else {
303            return 0;
304        };
305        if let Some(pat) = filter {
306            let matches = |line: &str| line.contains(pat);
307            return match self.stream_mode {
308                StreamMode::Stdout => buf.stdout_lines().filter(|l| matches(l)).count(),
309                StreamMode::Stderr => buf.stderr_lines().filter(|l| matches(l)).count(),
310                StreamMode::Both => buf.all_lines().filter(|(_, l)| matches(l)).count(),
311            };
312        }
313        match self.stream_mode {
314            StreamMode::Stdout => buf.stdout_count(),
315            StreamMode::Stderr => buf.stderr_count(),
316            StreamMode::Both => buf.len(),
317        }
318    }
319
320    /// Fetch exactly the visible window of lines for rendering.
321    ///
322    /// When a filter is active, uses the `FilteredIndex` to read matching
323    /// lines from disk. Returns `Some` in all cases.
324    pub fn visible_lines(
325        &mut self,
326        name: &str,
327        visible_height: usize,
328    ) -> Option<Vec<(LineSource, String)>> {
329        if self.filter.is_some() {
330            return Some(self.filtered_visible_lines(name, visible_height));
331        }
332
333        let total = self.total_line_count(name);
334        let scroll_offset = if self.paused {
335            self.scroll_offsets.get(name).copied().unwrap_or(0)
336        } else {
337            0
338        };
339
340        let window_end = total.saturating_sub(scroll_offset);
341        let window_start = window_end.saturating_sub(visible_height);
342        let count = window_end - window_start;
343
344        if count == 0 {
345            return Some(Vec::new());
346        }
347
348        let hot_len = self.hot_line_count(name, None);
349        let disk_count = self.disk_line_count(name);
350        // Boundary: lines before this come from disk, at or after from hot buffer.
351        let disk_boundary = disk_count.saturating_sub(hot_len);
352
353        if window_start >= disk_boundary {
354            // Entire window in hot buffer
355            let hot_start = window_start - disk_boundary;
356            Some(self.hot_buffer_range(name, hot_start, count))
357        } else if window_end <= disk_boundary {
358            // Entire window on disk
359            Some(self.disk_read_range(name, window_start, count))
360        } else {
361            // Split at boundary
362            let disk_part = disk_boundary - window_start;
363            let hot_part = window_end - disk_boundary;
364            let mut lines = self.disk_read_range(name, window_start, disk_part);
365            lines.extend(self.hot_buffer_range(name, 0, hot_part));
366            Some(lines)
367        }
368    }
369
370    /// Read a range from the hot buffer (no filter).
371    fn hot_buffer_range(
372        &self,
373        name: &str,
374        start: usize,
375        count: usize,
376    ) -> Vec<(LineSource, String)> {
377        let Some(buf) = self.buffers.get(name) else {
378            return Vec::new();
379        };
380        match self.stream_mode {
381            StreamMode::Stdout => buf
382                .stdout_lines()
383                .skip(start)
384                .take(count)
385                .map(|l| (LineSource::Stdout, l.to_string()))
386                .collect(),
387            StreamMode::Stderr => buf
388                .stderr_lines()
389                .skip(start)
390                .take(count)
391                .map(|l| (LineSource::Stderr, l.to_string()))
392                .collect(),
393            StreamMode::Both => buf
394                .all_lines()
395                .skip(start)
396                .take(count)
397                .map(|(src, l)| (src, l.to_string()))
398                .collect(),
399        }
400    }
401
402    /// Read a range from the disk reader.
403    fn disk_read_range(
404        &mut self,
405        name: &str,
406        start: usize,
407        count: usize,
408    ) -> Vec<(LineSource, String)> {
409        let Some(reader) = self.disk_readers.get_mut(name) else {
410            return Vec::new();
411        };
412        match self.stream_mode {
413            StreamMode::Stdout => reader
414                .read_lines(LineSource::Stdout, start, count)
415                .unwrap_or_default()
416                .into_iter()
417                .map(|l| (LineSource::Stdout, l))
418                .collect(),
419            StreamMode::Stderr => reader
420                .read_lines(LineSource::Stderr, start, count)
421                .unwrap_or_default()
422                .into_iter()
423                .map(|l| (LineSource::Stderr, l))
424                .collect(),
425            StreamMode::Both => reader.read_interleaved(start, count).unwrap_or_default(),
426        }
427    }
428
429    /// Render the visible window of filtered lines using the `FilteredIndex`.
430    fn filtered_visible_lines(
431        &mut self,
432        name: &str,
433        visible_height: usize,
434    ) -> Vec<(LineSource, String)> {
435        let total = self
436            .filtered_indices
437            .get(name)
438            .map_or(0, |fi| fi.matching_lines.len());
439        if total == 0 {
440            return Vec::new();
441        }
442
443        let scroll_offset = if self.paused {
444            self.scroll_offsets.get(name).copied().unwrap_or(0)
445        } else {
446            0
447        };
448
449        let window_end = total.saturating_sub(scroll_offset);
450        let window_start = window_end.saturating_sub(visible_height);
451        let count = window_end - window_start;
452        if count == 0 {
453            return Vec::new();
454        }
455
456        // Get the line numbers we need to read
457        let line_numbers: Vec<usize> = self
458            .filtered_indices
459            .get(name)
460            .map(|fi| fi.matching_lines[window_start..window_end].to_vec())
461            .unwrap_or_default();
462
463        let mode = self.stream_mode;
464
465        // Read scattered lines from disk
466        if let Some(reader) = self.disk_readers.get_mut(name) {
467            reader.read_scattered_lines(mode, &line_numbers)
468        } else {
469            Vec::new()
470        }
471    }
472
473    /// Build or rebuild the filtered index for a process.
474    fn build_filtered_index(&mut self, name: &str) {
475        let Some(filter) = self.filter.clone() else {
476            return;
477        };
478        let mode = self.stream_mode;
479
480        let matching_lines = if let Some(reader) = self.disk_readers.get_mut(name) {
481            reader.scan_matching_lines(&filter, mode)
482        } else {
483            Vec::new()
484        };
485
486        let scanned_up_to = match mode {
487            StreamMode::Stdout => self
488                .disk_readers
489                .get_mut(name)
490                .map_or(0, |r| r.line_count(LineSource::Stdout)),
491            StreamMode::Stderr => self
492                .disk_readers
493                .get_mut(name)
494                .map_or(0, |r| r.line_count(LineSource::Stderr)),
495            StreamMode::Both => self
496                .disk_readers
497                .get_mut(name)
498                .map_or(0, DiskLogReader::line_count_both),
499        };
500
501        self.filtered_indices.insert(
502            name.to_string(),
503            FilteredIndex {
504                filter,
505                stream_mode: mode,
506                matching_lines,
507                scanned_up_to,
508            },
509        );
510    }
511
512    pub fn start_filter(&mut self) {
513        self.input_mode = InputMode::FilterInput;
514        self.filter_buf = self.filter.clone().unwrap_or_default();
515    }
516
517    pub fn confirm_filter(&mut self) {
518        self.input_mode = InputMode::Normal;
519        if self.filter_buf.is_empty() {
520            self.filter = None;
521            self.filtered_indices.clear();
522        } else {
523            self.filter = Some(self.filter_buf.clone());
524            // Build filtered index for all processes with disk readers
525            let names: Vec<String> = self.disk_readers.keys().cloned().collect();
526            for name in names {
527                self.build_filtered_index(&name);
528            }
529        }
530    }
531
532    pub fn cancel_filter(&mut self) {
533        self.input_mode = InputMode::Normal;
534        self.filter_buf.clear();
535    }
536
537    pub fn clear_filter(&mut self) {
538        self.filter = None;
539        self.filter_buf.clear();
540        self.filtered_indices.clear();
541    }
542
543    pub fn push_output(&mut self, process: &str, stream: Stream, line: &str) {
544        let buf = self
545            .buffers
546            .entry(process.to_string())
547            .or_insert_with(|| OutputBuffer::new(MAX_BUFFER_LINES));
548        let source = match stream {
549            Stream::Stdout => LineSource::Stdout,
550            Stream::Stderr => LineSource::Stderr,
551        };
552        buf.push(source, line.to_string());
553
554        // Incrementally update filtered index if a filter is active
555        if self.filter.is_some() {
556            self.update_filtered_index(process);
557        }
558    }
559
560    /// Scan new disk lines since last scan and append matches to the filtered index.
561    fn update_filtered_index(&mut self, process: &str) {
562        let Some(fi) = self.filtered_indices.get(process) else {
563            return;
564        };
565        let filter = fi.filter.clone();
566        let mode = fi.stream_mode;
567        let scanned_up_to = fi.scanned_up_to;
568
569        let reader = match self.disk_readers.get_mut(process) {
570            Some(r) => r,
571            None => return,
572        };
573
574        let current_total = match mode {
575            StreamMode::Stdout => reader.line_count(LineSource::Stdout),
576            StreamMode::Stderr => reader.line_count(LineSource::Stderr),
577            StreamMode::Both => reader.line_count_both(),
578        };
579
580        if current_total <= scanned_up_to {
581            return;
582        }
583
584        let new_matches = reader.scan_matching_lines_from(&filter, mode, scanned_up_to);
585
586        let fi = self.filtered_indices.get_mut(process).unwrap();
587        fi.matching_lines.extend(new_matches);
588        fi.scanned_up_to = current_total;
589    }
590
591    pub fn quit(&mut self) {
592        self.running = false;
593    }
594
595    pub fn quit_and_stop(&mut self) {
596        self.stop_all_on_quit = true;
597        self.running = false;
598    }
599
600    pub fn running_count(&self) -> usize {
601        self.processes
602            .iter()
603            .filter(|p| p.state == crate::protocol::ProcessState::Running)
604            .count()
605    }
606
607    pub fn exited_count(&self) -> usize {
608        self.processes
609            .iter()
610            .filter(|p| p.state == crate::protocol::ProcessState::Exited)
611            .count()
612    }
613
614    pub fn failed_count(&self) -> usize {
615        self.processes
616            .iter()
617            .filter(|p| p.state == crate::protocol::ProcessState::Failed)
618            .count()
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::protocol::{ProcessInfo, ProcessState, Stream};
626
627    fn make_process(name: &str, state: ProcessState) -> ProcessInfo {
628        let exit_code = if state == ProcessState::Exited {
629            Some(0)
630        } else {
631            None
632        };
633        let uptime_secs = if state == ProcessState::Running {
634            Some(10)
635        } else {
636            None
637        };
638        ProcessInfo {
639            name: name.into(),
640            id: format!("p-{}", name),
641            pid: 100,
642            state,
643            exit_code,
644            uptime_secs,
645            command: "true".into(),
646            port: None,
647            url: None,
648            restart_count: None,
649            max_restarts: None,
650            restart_policy: None,
651            watched: None,
652        }
653    }
654
655    #[test]
656    fn test_select_next_wraps() {
657        let mut app = App::new();
658        app.update_processes(vec![
659            make_process("a", ProcessState::Running),
660            make_process("b", ProcessState::Running),
661            make_process("c", ProcessState::Running),
662        ]);
663        app.selected = 2; // last item
664        app.select_next();
665        assert_eq!(app.selected, 0);
666    }
667
668    #[test]
669    fn test_select_prev_wraps() {
670        let mut app = App::new();
671        app.update_processes(vec![
672            make_process("a", ProcessState::Running),
673            make_process("b", ProcessState::Running),
674            make_process("c", ProcessState::Running),
675        ]);
676        app.selected = 0;
677        app.select_prev();
678        assert_eq!(app.selected, 2);
679    }
680
681    #[test]
682    fn test_cycle_stream_mode() {
683        let mut app = App::new();
684        assert_eq!(app.stream_mode, StreamMode::Stdout);
685        app.cycle_stream_mode();
686        assert_eq!(app.stream_mode, StreamMode::Stderr);
687        app.cycle_stream_mode();
688        assert_eq!(app.stream_mode, StreamMode::Both);
689        app.cycle_stream_mode();
690        assert_eq!(app.stream_mode, StreamMode::Stdout);
691    }
692
693    #[test]
694    fn test_toggle_pause() {
695        let mut app = App::new();
696        assert!(!app.paused);
697        app.toggle_pause();
698        assert!(app.paused);
699        app.toggle_pause();
700        assert!(!app.paused);
701    }
702
703    #[test]
704    fn test_push_output() {
705        let mut app = App::new();
706        app.push_output("web", Stream::Stdout, "hello world");
707        let buf = app.buffers.get("web").unwrap();
708        assert_eq!(buf.stdout_lines().count(), 1);
709        assert_eq!(buf.stdout_lines().next().unwrap(), "hello world");
710    }
711
712    #[test]
713    fn test_running_count() {
714        let mut app = App::new();
715        app.update_processes(vec![
716            make_process("a", ProcessState::Running),
717            make_process("b", ProcessState::Exited),
718            make_process("c", ProcessState::Running),
719        ]);
720        assert_eq!(app.running_count(), 2);
721    }
722
723    #[test]
724    fn test_exited_count() {
725        let mut app = App::new();
726        app.update_processes(vec![
727            make_process("a", ProcessState::Running),
728            make_process("b", ProcessState::Exited),
729            make_process("c", ProcessState::Exited),
730        ]);
731        assert_eq!(app.exited_count(), 2);
732    }
733
734    #[test]
735    fn test_output_buffer_counters() {
736        let mut buf = OutputBuffer::new(5);
737        assert_eq!(buf.len(), 0);
738        assert_eq!(buf.stdout_count(), 0);
739        assert_eq!(buf.stderr_count(), 0);
740        assert!(buf.is_empty());
741
742        buf.push(LineSource::Stdout, "a".into());
743        buf.push(LineSource::Stderr, "b".into());
744        buf.push(LineSource::Stdout, "c".into());
745        assert_eq!(buf.len(), 3);
746        assert_eq!(buf.stdout_count(), 2);
747        assert_eq!(buf.stderr_count(), 1);
748
749        // Fill to capacity and trigger eviction
750        buf.push(LineSource::Stdout, "d".into());
751        buf.push(LineSource::Stderr, "e".into());
752        assert_eq!(buf.len(), 5);
753        assert_eq!(buf.stdout_count(), 3);
754        assert_eq!(buf.stderr_count(), 2);
755
756        // Push one more — evicts "a" (Stdout)
757        buf.push(LineSource::Stderr, "f".into());
758        assert_eq!(buf.len(), 5);
759        assert_eq!(buf.stdout_count(), 2);
760        assert_eq!(buf.stderr_count(), 3);
761    }
762
763    #[test]
764    fn test_visible_lines_hot_buffer_only() {
765        let mut app = App::new();
766        // No disk readers, just hot buffer
767        for i in 0..20 {
768            app.push_output("web", Stream::Stdout, &format!("line {}", i));
769        }
770        app.update_processes(vec![make_process("web", ProcessState::Running)]);
771        app.visible_height = 10;
772
773        // Unpaused: should get the last 10 lines
774        let lines = app.visible_lines("web", 10).unwrap();
775        assert_eq!(lines.len(), 10);
776        assert_eq!(lines[0].1, "line 10");
777        assert_eq!(lines[9].1, "line 19");
778    }
779
780    #[test]
781    fn test_visible_lines_paused_scrolled() {
782        let mut app = App::new();
783        for i in 0..50 {
784            app.push_output("web", Stream::Stdout, &format!("line {}", i));
785        }
786        app.update_processes(vec![make_process("web", ProcessState::Running)]);
787        app.visible_height = 10;
788        app.paused = true;
789        app.scroll_offsets.insert("web".into(), 20);
790
791        let lines = app.visible_lines("web", 10).unwrap();
792        assert_eq!(lines.len(), 10);
793        // 50 total, scroll_offset=20, visible=10 → window [20..30)
794        assert_eq!(lines[0].1, "line 20");
795        assert_eq!(lines[9].1, "line 29");
796    }
797
798    #[test]
799    fn test_visible_lines_with_disk_reader() {
800        use crate::daemon::log_index::{IndexRecord, IndexWriter, idx_path_for};
801        use crate::tui::disk_log_reader::DiskLogReader;
802
803        let dir = tempfile::tempdir().unwrap();
804
805        // Create a disk log with 100 lines
806        let log_path = dir.path().join("web.stdout");
807        let idx_path = idx_path_for(&log_path);
808        let mut log_content = String::new();
809        let mut writer = IndexWriter::create(&idx_path, 0).unwrap();
810        let mut offset = 0u64;
811        for i in 0..100 {
812            let line = format!("disk line {}", i);
813            writer
814                .append(IndexRecord {
815                    byte_offset: offset,
816                    seq: i,
817                })
818                .unwrap();
819            log_content.push_str(&line);
820            log_content.push('\n');
821            offset += line.len() as u64 + 1;
822        }
823        writer.flush().unwrap();
824        std::fs::write(&log_path, log_content).unwrap();
825
826        let mut app = App::new();
827        app.disk_readers.insert(
828            "web".into(),
829            DiskLogReader::new(dir.path().to_path_buf(), "web".into()),
830        );
831
832        // Push the last 10 lines into hot buffer (simulating live streaming)
833        for i in 90..100 {
834            app.push_output("web", Stream::Stdout, &format!("disk line {}", i));
835        }
836
837        app.update_processes(vec![make_process("web", ProcessState::Running)]);
838        app.visible_height = 10;
839
840        // Total should be 100 (disk is authoritative)
841        assert_eq!(app.total_line_count("web"), 100);
842
843        // Unpaused: last 10 lines from hot buffer
844        let lines = app.visible_lines("web", 10).unwrap();
845        assert_eq!(lines.len(), 10);
846        assert_eq!(lines[0].1, "disk line 90");
847        assert_eq!(lines[9].1, "disk line 99");
848
849        // Scroll to top: should read from disk
850        app.paused = true;
851        app.scroll_offsets.insert("web".into(), 90);
852        let lines = app.visible_lines("web", 10).unwrap();
853        assert_eq!(lines.len(), 10);
854        assert_eq!(lines[0].1, "disk line 0");
855        assert_eq!(lines[9].1, "disk line 9");
856
857        // Scroll to middle (spanning disk/hot boundary)
858        // hot buffer has lines 90-99, disk boundary = 100 - 10 = 90
859        // scroll_offset=5 → window_end=95, window_start=85
860        // lines 85-89 from disk, lines 90-94 from hot
861        app.scroll_offsets.insert("web".into(), 5);
862        let lines = app.visible_lines("web", 10).unwrap();
863        assert_eq!(lines.len(), 10);
864        assert_eq!(lines[0].1, "disk line 85");
865        assert_eq!(lines[4].1, "disk line 89");
866        assert_eq!(lines[5].1, "disk line 90");
867        assert_eq!(lines[9].1, "disk line 94");
868    }
869
870    #[test]
871    fn test_visible_lines_with_filter_uses_filtered_index() {
872        let mut app = App::new();
873        app.push_output("web", Stream::Stdout, "hello");
874        app.filter = Some("hello".into());
875
876        // Without a disk reader, returns empty (no filtered index built)
877        let lines = app.visible_lines("web", 10).unwrap();
878        assert!(lines.is_empty());
879    }
880
881    #[test]
882    fn test_hot_line_count_with_and_without_filter() {
883        let mut app = App::new();
884        app.push_output("web", Stream::Stdout, "hello world");
885        app.push_output("web", Stream::Stdout, "goodbye world");
886        app.push_output("web", Stream::Stdout, "hello again");
887
888        // Unfiltered: O(1) count
889        assert_eq!(app.hot_line_count("web", None), 3);
890
891        // Filtered: O(n) scan
892        assert_eq!(app.hot_line_count("web", Some("hello")), 2);
893        assert_eq!(app.hot_line_count("web", Some("xyz")), 0);
894    }
895}