Skip to main content

koda_cli/
scroll_buffer.rs

1//! Render cache for fullscreen TUI history panel.
2//!
3//! Stores styled `Line`s in a `VecDeque` and provides virtual scrolling
4//! with "sticky bottom" behavior. The buffer is a **render cache** — the
5//! DB is the source of truth. Lines evicted from the buffer can be
6//! re-rendered from DB on demand.
7//!
8//! See #472 for the fullscreen migration RFC.
9
10use ratatui::text::Line;
11use std::collections::VecDeque;
12
13/// Maximum lines held in the render cache.
14/// Enough for ~50 pages of scroll-up at 50 lines/page.
15const MAX_CACHE_LINES: usize = 2500;
16
17/// Scrollable buffer of rendered `Line`s with sticky-bottom auto-scroll.
18pub struct ScrollBuffer {
19    /// The rendered lines (ring buffer).
20    lines: VecDeque<Line<'static>>,
21
22    /// Per-line gutter width (columns to skip during NoSelect copy).
23    /// 0 = no gutter (normal text). Parallel with `lines`.
24    gutter_widths: VecDeque<u16>,
25
26    /// Scroll offset: number of lines scrolled UP from the bottom.
27    /// 0 = viewing the bottom (latest content).
28    scroll_offset: usize,
29
30    /// When true, new lines auto-scroll to keep the bottom visible.
31    /// Disengages when the user scrolls up; re-engages when they
32    /// scroll back to the bottom.
33    sticky_bottom: bool,
34
35    /// Oldest DB message ID currently rendered in the buffer.
36    /// Used by virtual scroll to know which page to fetch next.
37    /// `None` means no DB messages have been loaded yet.
38    oldest_message_id: Option<i64>,
39
40    /// Cached terminal width for eviction offset adjustment.
41    /// Updated on scroll operations and used by `enforce_capacity()`
42    /// to compute visual height of evicted lines.
43    cached_term_width: usize,
44}
45
46impl ScrollBuffer {
47    pub fn new(capacity: usize) -> Self {
48        let cap = capacity.min(4096);
49        Self {
50            lines: VecDeque::with_capacity(cap),
51            gutter_widths: VecDeque::with_capacity(cap),
52            scroll_offset: 0,
53            sticky_bottom: true,
54            oldest_message_id: None,
55            cached_term_width: 80,
56        }
57    }
58
59    /// Append a single line to the buffer.
60    ///
61    /// If sticky bottom is active, the view stays pinned to the latest
62    /// content. If the buffer exceeds the max cache size, the oldest
63    /// lines are evicted from the front.
64    pub fn push(&mut self, line: Line<'static>) {
65        self.lines.push_back(line);
66        self.gutter_widths.push_back(0);
67        self.enforce_capacity();
68
69        // If sticky, keep scroll at bottom
70        if self.sticky_bottom {
71            self.scroll_offset = 0;
72        }
73    }
74
75    /// Append a line with a gutter width (for NoSelect diff lines).
76    pub fn push_with_gutter(&mut self, line: Line<'static>, gutter_width: u16) {
77        self.lines.push_back(line);
78        self.gutter_widths.push_back(gutter_width);
79        self.enforce_capacity();
80
81        if self.sticky_bottom {
82            self.scroll_offset = 0;
83        }
84    }
85
86    /// Append multiple lines at once.
87    pub fn push_lines(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
88        for line in lines {
89            self.lines.push_back(line);
90            self.gutter_widths.push_back(0);
91        }
92        self.enforce_capacity();
93
94        if self.sticky_bottom {
95            self.scroll_offset = 0;
96        }
97    }
98
99    /// Scroll up by `n` visual lines. Disengages sticky bottom.
100    pub fn scroll_up(&mut self, n: usize, term_width: usize, viewport_height: usize) {
101        self.cached_term_width = term_width;
102        let total = self.total_visual_lines(term_width);
103        let max_offset = total.saturating_sub(viewport_height);
104        self.scroll_offset = (self.scroll_offset + n).min(max_offset);
105        self.sticky_bottom = false;
106    }
107
108    /// Scroll down by `n` lines. Re-engages sticky bottom if we reach
109    /// the bottom.
110    pub fn scroll_down(&mut self, n: usize) {
111        self.scroll_offset = self.scroll_offset.saturating_sub(n);
112        if self.scroll_offset == 0 {
113            self.sticky_bottom = true;
114        }
115    }
116
117    /// Re-clamp `scroll_offset` to valid bounds for the current
118    /// terminal dimensions. Must be called after any resize so the
119    /// offset doesn't exceed `total_visual - viewport_height`.
120    pub fn clamp_offset(&mut self, term_width: usize, viewport_height: usize) {
121        self.cached_term_width = term_width;
122        let total = self.total_visual_lines(term_width);
123        let max_offset = total.saturating_sub(viewport_height);
124        if self.scroll_offset > max_offset {
125            self.scroll_offset = max_offset;
126        }
127        if self.scroll_offset == 0 {
128            self.sticky_bottom = true;
129        }
130    }
131
132    /// Jump to the bottom and re-engage sticky mode.
133    pub fn scroll_to_bottom(&mut self) {
134        self.scroll_offset = 0;
135        self.sticky_bottom = true;
136    }
137
138    /// Jump to the top of the buffer.
139    pub fn scroll_to_top(&mut self, term_width: usize, viewport_height: usize) {
140        self.cached_term_width = term_width;
141        if !self.lines.is_empty() {
142            let total = self.total_visual_lines(term_width);
143            self.scroll_offset = total.saturating_sub(viewport_height);
144            self.sticky_bottom = false;
145        }
146    }
147
148    /// Returns `true` when the user has scrolled to the very top of the
149    /// buffer. Used to trigger loading older messages from the DB.
150    #[allow(dead_code)] // wired when virtual scroll pagination lands
151    pub fn at_top(&self, term_width: usize, viewport_height: usize) -> bool {
152        if self.lines.is_empty() {
153            return false;
154        }
155        let total = self.total_visual_lines(term_width);
156        let max_offset = total.saturating_sub(viewport_height);
157        self.scroll_offset >= max_offset && max_offset > 0
158    }
159
160    /// Return all lines in the buffer.
161    ///
162    /// Used by `render_history()` which passes everything to
163    /// `Paragraph::wrap().scroll()` — ratatui handles the visual
164    /// line math for word-wrapped content.
165    pub fn all_lines(&self) -> impl Iterator<Item = &Line<'static>> {
166        self.lines.iter()
167    }
168
169    /// Return the per-line gutter widths (parallel with `all_lines()`).
170    ///
171    /// Used by mouse selection to skip gutter columns during copy.
172    pub fn gutter_widths(&self) -> &VecDeque<u16> {
173        &self.gutter_widths
174    }
175
176    /// Compute the total number of visual (wrapped) lines at a given
177    /// terminal width. Used for scrollbar state and offset clamping.
178    pub fn total_visual_lines(&self, term_width: usize) -> usize {
179        let w = term_width.max(1);
180        self.lines.iter().map(|l| visual_height(l, w)).sum()
181    }
182
183    /// Compute the Paragraph scroll-from-top offset for the current
184    /// scroll position. Returns `(row_offset, 0)` for `Paragraph::scroll()`.
185    ///
186    /// `scroll_offset` is visual lines from the bottom.
187    /// Paragraph wants visual lines from the top.
188    pub fn paragraph_scroll(&self, viewport_height: usize, term_width: usize) -> (u16, u16) {
189        let total = self.total_visual_lines(term_width);
190        let from_top = total
191            .saturating_sub(viewport_height)
192            .saturating_sub(self.scroll_offset);
193        // Clamp to u16::MAX to prevent silent truncation (#528).
194        (from_top.min(u16::MAX as usize) as u16, 0)
195    }
196
197    /// Total number of lines in the buffer.
198    pub fn len(&self) -> usize {
199        self.lines.len()
200    }
201
202    /// Whether the buffer is empty.
203    #[allow(dead_code)]
204    pub fn is_empty(&self) -> bool {
205        self.lines.is_empty()
206    }
207
208    /// Current scroll offset (lines from bottom).
209    pub fn offset(&self) -> usize {
210        self.scroll_offset
211    }
212
213    /// Whether sticky bottom is active.
214    pub fn is_sticky(&self) -> bool {
215        self.sticky_bottom
216    }
217
218    /// Get the oldest DB message ID rendered in this buffer.
219    #[allow(dead_code)] // wired when virtual scroll pagination lands
220    pub fn oldest_message_id(&self) -> Option<i64> {
221        self.oldest_message_id
222    }
223
224    /// Set the oldest DB message ID (called after rendering history).
225    pub fn set_oldest_message_id(&mut self, id: i64) {
226        self.oldest_message_id = Some(id);
227    }
228
229    /// Clear all lines and reset scroll state.
230    #[allow(dead_code)]
231    pub fn clear(&mut self) {
232        self.lines.clear();
233        self.gutter_widths.clear();
234        self.scroll_offset = 0;
235        self.sticky_bottom = true;
236    }
237
238    /// Extract the last fenced code block from the buffer.
239    ///
240    /// Scans backward for ``` fences and returns the content between them.
241    /// Used by Ctrl+Y (copy last code block).
242    pub fn last_code_block(&self) -> Option<String> {
243        let mut end_fence = None;
244        let mut start_fence = None;
245
246        // Scan backward through lines
247        for (i, line) in self.lines.iter().enumerate().rev() {
248            let text = line_text(line);
249            let trimmed = text.trim();
250
251            if trimmed == "```" || trimmed.starts_with("```") {
252                if end_fence.is_none() {
253                    // Found closing fence
254                    end_fence = Some(i);
255                } else {
256                    // Found opening fence
257                    start_fence = Some(i);
258                    break;
259                }
260            }
261        }
262
263        match (start_fence, end_fence) {
264            (Some(start), Some(end)) if start < end => {
265                let code: Vec<String> = (start + 1..end)
266                    .map(|i| line_text(&self.lines[i]))
267                    .collect();
268                Some(code.join("\n"))
269            }
270            _ => None,
271        }
272    }
273
274    /// Extract the last assistant response from the buffer.
275    ///
276    /// Scans backward for the response separator ("───") and returns
277    /// everything after it. Used by Ctrl+U / Ctrl+Shift+Y (copy last response).
278    ///
279    /// The separator is exactly 3 `─` characters (emitted by ResponseStart).
280    /// Markdown HRs (60+ dashes) are explicitly excluded.
281    pub fn last_response(&self) -> Option<String> {
282        let mut sep_idx = None;
283
284        for (i, line) in self.lines.iter().enumerate().rev() {
285            let text = line_text(line);
286            let trimmed = text.trim();
287            // Match exactly 3 ─ chars (ResponseStart), not markdown HRs (60 ─ chars)
288            if trimmed.chars().all(|c| c == '─') && trimmed.chars().count() == 3 {
289                sep_idx = Some(i);
290                break;
291            }
292        }
293
294        sep_idx.map(|start| {
295            let response: Vec<String> = (start + 1..self.lines.len())
296                .map(|i| line_text(&self.lines[i]))
297                .collect();
298            response.join("\n").trim().to_string()
299        })
300    }
301
302    /// Evict oldest lines if we exceed capacity.
303    ///
304    /// Adjusts `scroll_offset` by the evicted line's visual height
305    /// (not a flat 1) to prevent scroll position drift when wrapped
306    /// lines are evicted (#528).
307    fn enforce_capacity(&mut self) {
308        let w = self.cached_term_width.max(1);
309        while self.lines.len() > MAX_CACHE_LINES {
310            if let Some(evicted) = self.lines.pop_front()
311                && self.scroll_offset > 0
312            {
313                let vis = visual_height(&evicted, w);
314                self.scroll_offset = self.scroll_offset.saturating_sub(vis);
315            }
316            self.gutter_widths.pop_front();
317        }
318    }
319
320    /// Prepend lines at the top of the buffer (for DB-backed virtual scroll).
321    ///
322    /// Used when the user scrolls past the top of the cache and older
323    /// messages are fetched from the DB and re-rendered.
324    #[allow(dead_code)] // wired in a follow-up PR
325    pub fn prepend_lines(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
326        let lines: Vec<_> = lines.into_iter().collect();
327        let count = lines.len();
328        for line in lines.into_iter().rev() {
329            self.lines.push_front(line);
330            self.gutter_widths.push_front(0);
331        }
332        // Adjust scroll offset to keep the viewport stable
333        // (content shifted down by `count` logical lines)
334        self.scroll_offset += count;
335        self.enforce_capacity();
336    }
337}
338
339/// Extract plain text from a `Line` by concatenating all span contents.
340fn line_text(line: &Line<'_>) -> String {
341    line.spans.iter().map(|s| s.content.as_ref()).collect()
342}
343
344/// Compute how many visual rows a `Line` occupies at the given terminal width.
345///
346/// Delegates to `wrap_util::visual_line_count` — the single source of truth
347/// for word-boundary wrapping consistent with `Wrap { trim: false }`.
348fn visual_height(line: &Line<'_>, term_width: usize) -> usize {
349    crate::wrap_util::visual_line_count(&line_text(line), term_width)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use ratatui::text::Span;
356
357    const W: usize = 80; // test terminal width
358    const H: usize = 50; // test viewport height
359
360    fn make_line(text: &str) -> Line<'static> {
361        Line::from(Span::raw(text.to_string()))
362    }
363
364    /// Collect the visible lines via paragraph_scroll logic.
365    /// For tests with short lines that don't wrap, this matches the old visible_lines().
366    fn visible_text(buf: &ScrollBuffer, height: usize) -> Vec<String> {
367        let lines: Vec<Line<'_>> = buf.all_lines().cloned().collect();
368        let total_visual = buf.total_visual_lines(W);
369        let from_top = total_visual
370            .saturating_sub(height)
371            .saturating_sub(buf.offset());
372        // Simulate what Paragraph would show
373        lines
374            .iter()
375            .skip(from_top)
376            .take(height)
377            .map(line_text)
378            .collect()
379    }
380
381    #[test]
382    fn test_push_and_visible() {
383        let mut buf = ScrollBuffer::new(2500);
384        for i in 0..10 {
385            buf.push(make_line(&format!("line {i}")));
386        }
387        assert_eq!(buf.len(), 10);
388
389        // Viewport of 3 lines at bottom
390        let visible = visible_text(&buf, 3);
391        assert_eq!(visible.len(), 3);
392        assert_eq!(visible[0], "line 7");
393        assert_eq!(visible[1], "line 8");
394        assert_eq!(visible[2], "line 9");
395    }
396
397    #[test]
398    fn test_sticky_bottom() {
399        let mut buf = ScrollBuffer::new(2500);
400        for i in 0..5 {
401            buf.push(make_line(&format!("line {i}")));
402        }
403        assert!(buf.is_sticky());
404        assert_eq!(buf.offset(), 0);
405
406        // New lines keep us at bottom
407        buf.push(make_line("line 5"));
408        assert_eq!(buf.offset(), 0);
409        let visible = visible_text(&buf, 2);
410        assert_eq!(visible[1], "line 5");
411    }
412
413    #[test]
414    fn test_scroll_up_breaks_sticky() {
415        let mut buf = ScrollBuffer::new(2500);
416        for i in 0..10 {
417            buf.push(make_line(&format!("line {i}")));
418        }
419
420        // Use viewport smaller than content so scroll has room
421        buf.scroll_up(3, W, 5);
422        assert!(!buf.is_sticky());
423        assert_eq!(buf.offset(), 3);
424    }
425
426    #[test]
427    fn test_scroll_down_restores_sticky() {
428        let mut buf = ScrollBuffer::new(2500);
429        for i in 0..10 {
430            buf.push(make_line(&format!("line {i}")));
431        }
432
433        buf.scroll_up(5, W, 5);
434        assert!(!buf.is_sticky());
435
436        buf.scroll_down(5);
437        assert!(buf.is_sticky());
438        assert_eq!(buf.offset(), 0);
439    }
440
441    #[test]
442    fn test_scroll_up_clamped() {
443        let mut buf = ScrollBuffer::new(2500);
444        for i in 0..5 {
445            buf.push(make_line(&format!("line {i}")));
446        }
447
448        // Viewport height 3, 5 lines total → max offset = 5-3 = 2
449        buf.scroll_up(100, W, 3);
450        assert_eq!(buf.offset(), 2);
451    }
452
453    #[test]
454    fn test_eviction() {
455        let mut buf = ScrollBuffer::new(2500);
456        for i in 0..MAX_CACHE_LINES + 100 {
457            buf.push(make_line(&format!("line {i}")));
458        }
459        assert_eq!(buf.len(), MAX_CACHE_LINES);
460        // Latest line should be at the bottom
461        let visible = visible_text(&buf, 1);
462        assert_eq!(visible[0], format!("line {}", MAX_CACHE_LINES + 99));
463    }
464
465    #[test]
466    fn test_empty_buffer() {
467        let buf = ScrollBuffer::new(2500);
468        assert_eq!(buf.all_lines().count(), 0);
469        assert_eq!(buf.total_visual_lines(80), 0);
470    }
471
472    #[test]
473    fn test_scroll_to_top_and_bottom() {
474        let mut buf = ScrollBuffer::new(2500);
475        for i in 0..20 {
476            buf.push(make_line(&format!("line {i}")));
477        }
478
479        // 20 lines, viewport 10 → max offset = 10
480        buf.scroll_to_top(W, 10);
481        assert!(!buf.is_sticky());
482        assert_eq!(buf.offset(), 10);
483
484        buf.scroll_to_bottom();
485        assert!(buf.is_sticky());
486        assert_eq!(buf.offset(), 0);
487    }
488
489    #[test]
490    fn test_last_code_block() {
491        let mut buf = ScrollBuffer::new(2500);
492        buf.push(make_line("some text"));
493        buf.push(make_line("```rust"));
494        buf.push(make_line("  fn main() {}"));
495        buf.push(make_line("  let x = 42;"));
496        buf.push(make_line("```"));
497        buf.push(make_line("more text"));
498
499        let code = buf.last_code_block().unwrap();
500        assert_eq!(code, "  fn main() {}\n  let x = 42;");
501    }
502
503    #[test]
504    fn test_last_code_block_none() {
505        let mut buf = ScrollBuffer::new(2500);
506        buf.push(make_line("no code here"));
507        assert!(buf.last_code_block().is_none());
508    }
509
510    #[test]
511    fn test_last_response() {
512        let mut buf = ScrollBuffer::new(2500);
513        buf.push(make_line("user message"));
514        buf.push(make_line("  ───"));
515        buf.push(make_line("  response line 1"));
516        buf.push(make_line("  response line 2"));
517
518        let response = buf.last_response().unwrap();
519        assert!(response.contains("response line 1"));
520        assert!(response.contains("response line 2"));
521    }
522
523    /// Verify that `last_response()` skips markdown HRs (60 dashes)
524    /// and only matches the ResponseStart separator (exactly 3 dashes).
525    #[test]
526    fn test_last_response_skips_markdown_hr() {
527        let mut buf = ScrollBuffer::new(2500);
528        buf.push(make_line("user message"));
529        buf.push(make_line("  ───")); // ResponseStart
530        buf.push(make_line("  first paragraph"));
531        // Markdown HR: rendered as 60 ─ chars by md_render
532        let hr = format!("  {}", "─".repeat(60));
533        buf.push(make_line(&hr));
534        buf.push(make_line("  second paragraph"));
535
536        let response = buf.last_response().unwrap();
537        // Should include BOTH paragraphs (found ResponseStart, not the HR)
538        assert!(
539            response.contains("first paragraph"),
540            "Should include content before HR: {response}"
541        );
542        assert!(
543            response.contains("second paragraph"),
544            "Should include content after HR: {response}"
545        );
546    }
547
548    #[test]
549    fn test_push_lines_batch() {
550        let mut buf = ScrollBuffer::new(2500);
551        let batch: Vec<Line<'static>> = (0..5).map(|i| make_line(&format!("line {i}"))).collect();
552        buf.push_lines(batch);
553        assert_eq!(buf.len(), 5);
554    }
555
556    #[test]
557    fn test_eviction_adjusts_scroll_offset() {
558        let mut buf = ScrollBuffer::new(2500);
559        // Fill to capacity
560        for i in 0..MAX_CACHE_LINES {
561            buf.push(make_line(&format!("line {i}")));
562        }
563        // Scroll up
564        buf.scroll_up(100, W, H);
565        let offset_before = buf.offset();
566
567        // Push more lines, triggering eviction
568        for i in 0..50 {
569            buf.push(make_line(&format!("new {i}")));
570        }
571
572        // Offset should have been adjusted down
573        assert!(buf.offset() < offset_before);
574        assert_eq!(buf.len(), MAX_CACHE_LINES);
575    }
576
577    // ── Visual line math ──
578
579    #[test]
580    fn test_visual_height_short_line() {
581        let line = make_line("hello"); // 5 chars
582        assert_eq!(visual_height(&line, 80), 1);
583    }
584
585    #[test]
586    fn test_visual_height_wrapping_line() {
587        // 160 chars in an 80-column terminal = 2 visual lines
588        let line = make_line(&"x".repeat(160));
589        assert_eq!(visual_height(&line, 80), 2);
590    }
591
592    #[test]
593    fn test_visual_height_empty_line() {
594        let line = make_line("");
595        assert_eq!(visual_height(&line, 80), 1);
596    }
597
598    // ── Word-wrap regression tests (#520) ──
599
600    #[test]
601    fn test_visual_height_word_wrap_breaks_before_word() {
602        // 76 chars of 'a' + space + 6-char word = 83 chars
603        // Character wrap: row 1 = 80 chars, row 2 = 3 chars = 2 rows
604        // Word wrap: row 1 = 76 chars + space (77), row 2 = 6-char word = 2 rows
605        let text = format!("{}  foobar", "a".repeat(76));
606        let line = make_line(&text);
607        assert_eq!(visual_height(&line, 80), 2);
608    }
609
610    #[test]
611    fn test_visual_height_word_wrap_longer_word() {
612        // 78 chars + space + 5-char word = 84 chars
613        // Character wrap: row 1 = 80 chars, row 2 = 4 chars = 2 rows
614        // Word wrap: row 1 = 78+space (79), word "hello" doesn't fit (79+5=84>80)
615        //   -> wrap before "hello": row 1 = 79, row 2 = 5 = 2 rows
616        let text = format!("{} hello", "x".repeat(78));
617        let line = make_line(&text);
618        assert_eq!(visual_height(&line, 80), 2);
619    }
620
621    #[test]
622    fn test_visual_height_word_wrap_three_rows() {
623        // Two long words that each nearly fill a row + a short word
624        // "<75 a's> <75 b's> end"
625        let text = format!("{} {} end", "a".repeat(75), "b".repeat(75));
626        let line = make_line(&text);
627        // Row 1: 75 a's + space (76), "b"*75 doesn't fit (76+75=151>80)
628        // Row 2: 75 b's + space (76), "end" fits (76+3=79<=80) -> actually...
629        // Wait: row 2 starts with 75 b's = 75, + space = 76, + "end" = 79 <= 80
630        // So: 2 rows
631        // Actually: row 1 = "aaa... " (76), b's don't fit, wrap
632        // row 2 = "bbb... end" (75+1+3=79)
633        // = 2 rows
634        assert_eq!(visual_height(&line, 80), 2);
635    }
636
637    #[test]
638    fn test_visual_height_single_word_longer_than_width() {
639        // Single word with no spaces, 200 chars at width 80
640        // Must force-break: ceil(200/80) = 3 rows
641        let line = make_line(&"x".repeat(200));
642        assert_eq!(visual_height(&line, 80), 3);
643    }
644
645    #[test]
646    fn test_visual_height_exact_width() {
647        // Exactly 80 chars = 1 row, not 2
648        let line = make_line(&"x".repeat(80));
649        assert_eq!(visual_height(&line, 80), 1);
650    }
651
652    #[test]
653    fn test_visual_height_exact_width_plus_one() {
654        let line = make_line(&"x".repeat(81));
655        assert_eq!(visual_height(&line, 80), 2);
656    }
657
658    #[test]
659    fn test_total_visual_lines() {
660        let mut buf = ScrollBuffer::new(2500);
661        buf.push(make_line("short")); // 1 visual line
662        buf.push(make_line(&"x".repeat(160))); // 2 visual lines
663        buf.push(make_line("")); // 1 visual line
664        assert_eq!(buf.total_visual_lines(80), 4);
665    }
666
667    #[test]
668    fn test_paragraph_scroll_at_bottom() {
669        let mut buf = ScrollBuffer::new(2500);
670        for i in 0..20 {
671            buf.push(make_line(&format!("line {i}")));
672        }
673        // At bottom: offset=0, viewport=10, total=20
674        // → scroll from top = 20 - 10 - 0 = 10
675        let (row, _) = buf.paragraph_scroll(10, 80);
676        assert_eq!(row, 10);
677    }
678
679    #[test]
680    fn test_paragraph_scroll_at_top() {
681        let mut buf = ScrollBuffer::new(2500);
682        for i in 0..20 {
683            buf.push(make_line(&format!("line {i}")));
684        }
685        buf.scroll_to_top(80, 10);
686        // At top: offset=10, viewport=10, total=20
687        // → scroll from top = 20 - 10 - 10 = 0
688        let (row, _) = buf.paragraph_scroll(10, 80);
689        assert_eq!(row, 0);
690    }
691
692    // ── Prepend ──
693
694    // ── Clamp offset ──
695
696    #[test]
697    fn test_clamp_offset_after_resize() {
698        let mut buf = ScrollBuffer::new(2500);
699        for i in 0..20 {
700            buf.push(make_line(&format!("line {i}")));
701        }
702        // Scroll to top: 20 lines, viewport 10 → offset = 10
703        buf.scroll_to_top(W, 10);
704        assert_eq!(buf.offset(), 10);
705        assert!(!buf.is_sticky());
706
707        // Simulate resize to viewport height 18 → max_offset = 2
708        buf.clamp_offset(W, 18);
709        assert_eq!(buf.offset(), 2);
710    }
711
712    #[test]
713    fn test_clamp_offset_restores_sticky() {
714        let mut buf = ScrollBuffer::new(2500);
715        for i in 0..5 {
716            buf.push(make_line(&format!("line {i}")));
717        }
718        // Scroll up then grow viewport to fit everything
719        buf.scroll_up(3, W, 3);
720        assert!(!buf.is_sticky());
721
722        buf.clamp_offset(W, 10); // viewport bigger than content
723        assert_eq!(buf.offset(), 0);
724        assert!(buf.is_sticky());
725    }
726
727    /// Test code block detection with markdown-rendered content.
728    ///
729    /// The actual TUI renders fences as `Span::raw("  ") + Span::styled("```rust", DIM)`.
730    /// `last_code_block()` must find these despite the leading indent.
731    #[test]
732    fn test_last_code_block_rendered() {
733        use ratatui::style::{Color, Style};
734        let dim = Style::default().fg(Color::DarkGray);
735
736        let mut buf = ScrollBuffer::new(2500);
737        buf.push(Line::from(vec![
738            Span::raw("  "),
739            Span::styled("Some intro text".to_string(), Style::default()),
740        ]));
741        buf.push(Line::from(vec![
742            Span::raw("  "),
743            Span::styled("```rust".to_string(), dim),
744        ]));
745        buf.push(Line::from(vec![
746            Span::raw("    "),
747            Span::styled(
748                "fn main() {}".to_string(),
749                Style::default().fg(Color::Green),
750            ),
751        ]));
752        buf.push(Line::from(vec![
753            Span::raw("  "),
754            Span::styled("```".to_string(), dim),
755        ]));
756        buf.push(Line::from(vec![
757            Span::raw("  "),
758            Span::styled("After block".to_string(), Style::default()),
759        ]));
760
761        let code = buf.last_code_block();
762        assert!(
763            code.is_some(),
764            "Should detect code block in rendered content"
765        );
766        let code = code.unwrap();
767        assert!(code.contains("fn main()"), "Code block content: {code}");
768    }
769
770    /// Test response detection with markdown-rendered separator.
771    ///
772    /// The TUI renders ResponseStart as `Span::styled("  ───", DIM)`.
773    #[test]
774    fn test_last_response_rendered() {
775        use ratatui::style::{Color, Style};
776        let dim = Style::default().fg(Color::DarkGray);
777
778        let mut buf = ScrollBuffer::new(2500);
779        buf.push(Line::from(vec![Span::raw("user question".to_string())]));
780        buf.push(Line::styled("  ───".to_string(), dim));
781        buf.push(Line::from(vec![
782            Span::raw("  "),
783            Span::styled("The answer is 42.".to_string(), Style::default()),
784        ]));
785
786        let resp = buf.last_response();
787        assert!(
788            resp.is_some(),
789            "Should detect response separator in rendered content"
790        );
791        let resp = resp.unwrap();
792        assert!(
793            resp.contains("The answer is 42"),
794            "Response content: {resp}"
795        );
796    }
797
798    #[test]
799    fn test_prepend_lines() {
800        let mut buf = ScrollBuffer::new(2500);
801        buf.push(make_line("current"));
802        buf.scroll_up(0, W, H); // stay at bottom
803
804        let old_lines = vec![make_line("old1"), make_line("old2")];
805        buf.prepend_lines(old_lines);
806
807        assert_eq!(buf.len(), 3);
808        // Offset adjusted by prepend count
809        assert_eq!(buf.offset(), 2);
810        // First line is now "old1"
811        let first = line_text(buf.all_lines().next().unwrap());
812        assert_eq!(first, "old1");
813    }
814}