Skip to main content

thndrs_lib/cli/renderer/
alternate.rs

1//! Ratatui alternate-screen renderer.
2//!
3//! The production terminal surface has one lifecycle owner and one complete
4//! frame per dirty application update. Pure viewport projection remains
5//! separate from terminal I/O so navigation and layout can use `TestBackend`.
6//!
7//! - [`AlternateLayout`] reserves a bottom-pinned composer and gives the
8//!   remaining rows to an application-owned transcript viewport;
9//! - [`AlternateViewport`] owns semantic transcript navigation;
10//! - [`AlternateScreenSession`] owns raw and alternate-screen modes;
11//! - [`render_logical_frame`] adapts renderer-owned rows to one Ratatui frame.
12
13use std::io::{self, Write};
14use std::time::Duration;
15
16use crossterm::cursor::Show;
17use crossterm::event::{
18    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, KeyCode, KeyEventKind,
19    KeyModifiers, MouseEventKind,
20};
21use crossterm::execute;
22use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
23use ratatui::Frame as RatatuiFrame;
24use ratatui::layout::{Position, Rect};
25use ratatui::style::{Color as RatatuiColor, Modifier, Style};
26use ratatui::text::{Line, Span as RatatuiSpan};
27use ratatui::widgets::{Clear, Paragraph};
28
29use crate::app::{App, Entry, PromptAccessory, PromptState};
30
31use super::row::{Frame, Row};
32use super::style::{CellStyle, Color};
33use super::view::{LiveView, RendererView, SemanticUiView, TranscriptView, project_transcript_entry};
34
35/// Owns terminal modes for one alternate-screen application session.
36///
37/// Cleanup is best-effort in `Drop`, so normal return, errors, and unwinding all
38/// restore the shell-facing terminal state through the same owner.
39#[derive(Debug)]
40pub struct AlternateScreenSession {
41    mouse_capture: bool,
42    active: bool,
43}
44
45impl AlternateScreenSession {
46    /// Enter raw mode and the alternate screen.
47    pub fn enter(mouse_capture: bool) -> io::Result<Self> {
48        enable_raw_mode()?;
49        let result = if mouse_capture {
50            execute!(
51                io::stdout(),
52                EnterAlternateScreen,
53                EnableBracketedPaste,
54                EnableMouseCapture
55            )
56        } else {
57            execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)
58        };
59        if let Err(error) = result {
60            let _ = disable_raw_mode();
61            return Err(error);
62        }
63        Ok(Self { mouse_capture, active: true })
64    }
65
66    /// Temporarily restore the shell terminal for an external interactive app.
67    pub fn suspend(&mut self) -> io::Result<()> {
68        if !self.active {
69            return Ok(());
70        }
71        drain_terminal_events();
72        restore_terminal(self.mouse_capture)?;
73        self.active = false;
74        Ok(())
75    }
76
77    /// Re-enter the owned alternate-screen terminal after suspension.
78    pub fn resume(&mut self) -> io::Result<()> {
79        if self.active {
80            return Ok(());
81        }
82        enable_raw_mode()?;
83        let result = if self.mouse_capture {
84            execute!(
85                io::stdout(),
86                EnterAlternateScreen,
87                EnableBracketedPaste,
88                EnableMouseCapture
89            )
90        } else {
91            execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)
92        };
93        if result.is_ok() {
94            self.active = true;
95        } else {
96            let _ = disable_raw_mode();
97        }
98        result
99    }
100}
101
102impl Drop for AlternateScreenSession {
103    fn drop(&mut self) {
104        if self.active {
105            drain_terminal_events();
106            let _ = restore_terminal(self.mouse_capture);
107            self.active = false;
108        }
109    }
110}
111
112fn drain_terminal_events() {
113    while crossterm::event::poll(Duration::ZERO).unwrap_or(false) {
114        if crossterm::event::read().is_err() {
115            break;
116        }
117    }
118}
119
120fn restore_terminal(mouse_capture: bool) -> io::Result<()> {
121    let mut stdout = io::stdout();
122    if mouse_capture {
123        execute!(
124            stdout,
125            Show,
126            DisableMouseCapture,
127            DisableBracketedPaste,
128            LeaveAlternateScreen
129        )?;
130    } else {
131        execute!(stdout, Show, DisableBracketedPaste, LeaveAlternateScreen)?;
132    }
133    stdout.flush()?;
134    disable_raw_mode()
135}
136
137/// Stable transcript position used while the reader is away from the tail.
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum TranscriptPosition {
140    /// A row in the startup banner, before transcript entries exist.
141    Banner { row: usize },
142    /// A logical rendered row within an append-only transcript entry.
143    Entry { entry_index: usize, row_in_entry: usize },
144}
145
146/// Transcript navigation mode.
147#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
148pub enum ViewportState {
149    /// Keep the newest transcript row visible as content arrives.
150    #[default]
151    FollowingTail,
152    /// Preserve the selected transcript position while content changes below it.
153    Anchored(TranscriptPosition),
154}
155
156/// Application-owned transcript viewport for the alternate-screen renderer.
157#[derive(Debug, Default)]
158pub struct AlternateViewport {
159    state: ViewportState,
160    transcript_cache: TranscriptProjectionCache,
161    last_positions: Vec<TranscriptPosition>,
162    last_top: usize,
163    last_page_rows: usize,
164}
165
166#[derive(Clone, Debug)]
167struct CachedEntryProjection {
168    source: Entry,
169    width: usize,
170    tool_group_start: bool,
171    stable_rows: Vec<Row>,
172    live_rows: Vec<Row>,
173}
174
175#[derive(Debug, Default)]
176struct TranscriptProjectionCache {
177    banner_width: Option<usize>,
178    banner_rows: Vec<Row>,
179    entries: Vec<CachedEntryProjection>,
180}
181
182impl TranscriptProjectionCache {
183    fn project(&mut self, app: &App, width: usize) -> TranscriptView {
184        if self.banner_width != Some(width) {
185            self.banner_rows = app.render_banner_rows(width);
186            self.banner_width = Some(width);
187        }
188        self.entries.truncate(app.transcript.len());
189        for (entry_index, entry) in app.transcript.iter().enumerate() {
190            let tool_group_start = entry_index
191                .checked_sub(1)
192                .and_then(|index| app.transcript.get(index))
193                .is_none_or(|entry| !matches!(entry, Entry::Tool { .. }));
194            let reusable = self.entries.get(entry_index).is_some_and(|cached| {
195                cached.width == width && cached.tool_group_start == tool_group_start && cached.source == *entry
196            });
197            if reusable {
198                continue;
199            }
200            let (stable_rows, live_rows) = project_transcript_entry(app, entry_index, width);
201            let projection =
202                CachedEntryProjection { source: entry.clone(), width, tool_group_start, stable_rows, live_rows };
203            if entry_index < self.entries.len() {
204                self.entries[entry_index] = projection;
205            } else {
206                self.entries.push(projection);
207            }
208        }
209
210        if app.transcript.is_empty() {
211            return TranscriptView {
212                rows: self.banner_rows.clone(),
213                banner_rows: self.banner_rows.clone(),
214                stable_rows: Vec::new(),
215                live_rows: Vec::new(),
216            };
217        }
218
219        let mut rows = self.banner_rows.clone();
220        let mut stable_rows = self.banner_rows.clone();
221        let mut live_rows = Vec::new();
222        for entry in &self.entries {
223            rows.extend(entry.stable_rows.iter().cloned());
224            rows.extend(entry.live_rows.iter().cloned());
225            stable_rows.extend(entry.stable_rows.iter().cloned());
226            live_rows.extend(entry.live_rows.iter().cloned());
227        }
228        TranscriptView { rows, banner_rows: Vec::new(), stable_rows, live_rows }
229    }
230}
231
232impl AlternateViewport {
233    /// Current navigation state.
234    pub fn state(&self) -> ViewportState {
235        self.state
236    }
237
238    /// Return to the newest transcript content.
239    pub fn follow_tail(&mut self) {
240        self.state = ViewportState::FollowingTail;
241    }
242
243    /// Forget navigation state after a transcript clear.
244    pub fn reset(&mut self) {
245        *self = Self::default();
246    }
247
248    /// Apply transcript navigation when no higher-priority surface owns input.
249    pub fn handle_navigation(&mut self, app: &App, event: &Event) -> bool {
250        let transcript_focused = app.first_run_recovery.is_none()
251            && !app.detail_pane.open
252            && app.pending_permission.is_none()
253            && matches!(app.prompt_accessory, PromptAccessory::None);
254        if !transcript_focused {
255            return false;
256        }
257        match event {
258            Event::Key(key) if key.kind != KeyEventKind::Release => match (key.code, key.modifiers) {
259                (KeyCode::PageUp, modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
260                    self.half_page_up();
261                    true
262                }
263                (KeyCode::PageDown, modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
264                    self.half_page_down();
265                    true
266                }
267                (KeyCode::PageUp, _) => {
268                    self.page_up();
269                    true
270                }
271                (KeyCode::PageDown, _) => {
272                    self.page_down();
273                    true
274                }
275                (KeyCode::Up, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
276                    self.line_up();
277                    true
278                }
279                (KeyCode::Down, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
280                    self.line_down();
281                    true
282                }
283                (KeyCode::Home, modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
284                    self.top();
285                    true
286                }
287                (KeyCode::End, modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
288                    self.follow_tail();
289                    true
290                }
291                _ => false,
292            },
293            Event::Mouse(mouse) => match mouse.kind {
294                MouseEventKind::ScrollUp => {
295                    self.line_up();
296                    true
297                }
298                MouseEventKind::ScrollDown => {
299                    self.line_down();
300                    true
301                }
302                _ => false,
303            },
304            _ => false,
305        }
306    }
307
308    /// Scroll one visual row toward older content.
309    pub fn line_up(&mut self) {
310        self.scroll_up(1);
311    }
312
313    /// Scroll one visual row toward newer content.
314    pub fn line_down(&mut self) {
315        self.scroll_down(1);
316    }
317
318    /// Scroll half a visible transcript page toward older content.
319    pub fn half_page_up(&mut self) {
320        self.scroll_up(self.last_page_rows.div_ceil(2).max(1));
321    }
322
323    /// Scroll half a visible transcript page toward newer content.
324    pub fn half_page_down(&mut self) {
325        self.scroll_down(self.last_page_rows.div_ceil(2).max(1));
326    }
327
328    /// Scroll one visible transcript page toward older content.
329    pub fn page_up(&mut self) {
330        self.scroll_up(self.last_page_rows.max(1));
331    }
332
333    /// Scroll one visible transcript page toward newer content.
334    pub fn page_down(&mut self) {
335        self.scroll_down(self.last_page_rows.max(1));
336    }
337
338    /// Jump to the oldest projected transcript row.
339    pub fn top(&mut self) {
340        if let Some(position) = self.last_positions.first().copied() {
341            self.state = ViewportState::Anchored(position);
342        }
343    }
344
345    /// Build one complete terminal-sized logical frame.
346    pub fn build_frame(&mut self, app: &App, width: usize, height: usize) -> Frame {
347        let semantic = SemanticUiView::from(app);
348        let transcript = self.transcript_cache.project(app, width);
349        let live = LiveView::build(app, width, height, &transcript, &semantic);
350        let view = RendererView { semantic, transcript, live, width, height };
351        let chrome = build_chrome_frame(&view);
352        let chrome_height = chrome.rows.len().min(height);
353        let transcript_height = height.saturating_sub(chrome_height);
354        let rows = &view.transcript.rows;
355        let positions = transcript_positions(rows);
356        let max_top = rows.len().saturating_sub(transcript_height);
357        let top = match self.state {
358            ViewportState::FollowingTail => max_top,
359            ViewportState::Anchored(anchor) => resolve_position(&positions, anchor).unwrap_or(max_top).min(max_top),
360        };
361
362        self.last_positions = positions;
363        self.last_top = top;
364        self.last_page_rows = transcript_height.max(1);
365
366        let mut frame = Frame::new(width);
367        let visible_end = top.saturating_add(transcript_height).min(rows.len());
368        let visible = &rows[top.min(rows.len())..visible_end];
369        for _ in 0..transcript_height.saturating_sub(visible.len()) {
370            frame.push(Row::blank(width, CellStyle::new()));
371        }
372        frame.rows.extend(visible.iter().cloned());
373
374        let chrome_start = chrome.rows.len().saturating_sub(chrome_height);
375        let chrome_offset = frame.rows.len();
376        frame
377            .rows
378            .extend(chrome.rows.into_iter().skip(chrome_start).take(chrome_height));
379        frame.cursor = chrome.cursor.and_then(|mut cursor| {
380            if cursor.row < chrome_start {
381                return None;
382            }
383            cursor.row = cursor.row - chrome_start + chrome_offset;
384            Some(cursor)
385        });
386        frame.cursor_visible = !matches!(app.prompt_state(), PromptState::Stopped | PromptState::Errored);
387        while frame.rows.len() < height {
388            frame.push(Row::blank(width, CellStyle::new()));
389        }
390        frame
391    }
392
393    fn scroll_up(&mut self, amount: usize) {
394        let target = self.last_top.saturating_sub(amount);
395        if let Some(position) = self.last_positions.get(target).copied() {
396            self.state = ViewportState::Anchored(position);
397        }
398    }
399
400    fn scroll_down(&mut self, amount: usize) {
401        let max_top = self.last_positions.len().saturating_sub(self.last_page_rows);
402        let target = self.last_top.saturating_add(amount);
403        if target >= max_top {
404            self.follow_tail();
405        } else if let Some(position) = self.last_positions.get(target).copied() {
406            self.state = ViewportState::Anchored(position);
407        }
408    }
409}
410
411/// Build the bottom-pinned prompt, focused surface, queue summary, and footer.
412fn build_chrome_frame(view: &RendererView) -> Frame {
413    let width = view.width;
414    let height = view.height;
415    let live = &view.live;
416    let surface_bg = CellStyle::new().bg(super::style::palette().panel_bg);
417    let min_prompt_chrome = live.prompt_rows.len() + 1;
418    let keep_prompt_gutters = height >= min_prompt_chrome + 3;
419
420    let mut footer = vec![live.static_status.clone()];
421    if keep_prompt_gutters {
422        footer.push(Row::blank(width, surface_bg));
423    }
424    let prompt_gutter =
425        keep_prompt_gutters.then(|| Row::blank(width, CellStyle::new().bg(super::style::palette().panel_bg)));
426    let accessory = if live.detail_pane.is_empty() { live.accessory_rows.clone() } else { live.detail_pane.clone() };
427    let queued: Vec<Row> = live.queued_summary.clone().into_iter().collect();
428    let reserved = footer.len() + live.prompt_rows.len() + usize::from(prompt_gutter.is_some());
429    let remaining = height.saturating_sub(reserved);
430    let accessory_budget = accessory.len().min(remaining);
431    let queued_budget = queued.len().min(remaining.saturating_sub(accessory_budget));
432
433    let mut frame = Frame::new(width);
434    frame.rows.extend(clip_from_top(queued, queued_budget));
435    frame.rows.extend(clip_from_top(accessory, accessory_budget));
436    if let Some(row) = prompt_gutter {
437        frame.push(row);
438    }
439    let prompt_offset = frame.len();
440    frame.rows.extend(live.prompt_rows.iter().cloned());
441    if let Some(mut cursor) = live.prompt_cursor {
442        cursor.row += prompt_offset;
443        frame.set_cursor(cursor);
444    }
445    frame.rows.extend(footer);
446    frame
447}
448
449fn clip_from_top(mut rows: Vec<Row>, budget: usize) -> Vec<Row> {
450    if budget == 0 {
451        return Vec::new();
452    }
453    if rows.len() > budget {
454        rows = rows.split_off(rows.len() - budget);
455    }
456    rows
457}
458
459fn transcript_positions(rows: &[Row]) -> Vec<TranscriptPosition> {
460    let mut banner_row = 0;
461    let mut previous_entry = None;
462    let mut row_in_entry = 0;
463    rows.iter()
464        .map(|row| match row.group_id {
465            Some(group) => {
466                if previous_entry == Some(group.entry_index) {
467                    row_in_entry += 1;
468                } else {
469                    previous_entry = Some(group.entry_index);
470                    row_in_entry = 0;
471                }
472                TranscriptPosition::Entry { entry_index: group.entry_index, row_in_entry }
473            }
474            None => {
475                let position = TranscriptPosition::Banner { row: banner_row };
476                banner_row += 1;
477                position
478            }
479        })
480        .collect()
481}
482
483fn resolve_position(positions: &[TranscriptPosition], anchor: TranscriptPosition) -> Option<usize> {
484    positions.iter().position(|position| *position == anchor).or_else(|| {
485        match anchor {
486        TranscriptPosition::Entry { entry_index, .. } => positions.iter().position(|position| {
487            matches!(position, TranscriptPosition::Entry { entry_index: candidate, .. } if *candidate == entry_index)
488        }),
489        TranscriptPosition::Banner { .. } => positions.first().map(|_| 0),
490    }
491    })
492}
493
494/// Rectangles for an app-owned transcript above a bottom-pinned composer.
495#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
496pub struct AlternateLayout {
497    /// Scrollable transcript viewport.
498    pub transcript: Rect,
499    /// Bottom-pinned composer, status, and focused prompt surfaces.
500    pub composer: Rect,
501}
502
503impl AlternateLayout {
504    /// Allocate a bottom-pinned composer and give all preceding rows to the
505    /// transcript.
506    ///
507    /// A non-empty terminal always retains at least one composer row. Requested
508    /// composer height is clamped to the available height, so tiny terminals
509    /// cannot place the composer outside the frame.
510    pub fn new(area: Rect, requested_composer_height: u16) -> Self {
511        if area.height == 0 {
512            return Self { transcript: Rect::new(area.x, area.y, area.width, 0), composer: area };
513        }
514
515        let composer_height = requested_composer_height.max(1).min(area.height);
516        let transcript_height = area.height.saturating_sub(composer_height);
517        let transcript = Rect::new(area.x, area.y, area.width, transcript_height);
518        let composer = Rect::new(
519            area.x,
520            area.y.saturating_add(transcript_height),
521            area.width,
522            composer_height,
523        );
524        Self { transcript, composer }
525    }
526}
527
528/// Render one complete renderer-owned frame through Ratatui.
529///
530/// The logical frame is bottom-aligned when its height differs from the
531/// terminal height. This keeps its composer and cursor pinned while the future
532/// alternate-screen projection is being separated into transcript and composer
533/// surfaces. Ratatui remains the only writer during the draw.
534pub fn render_logical_frame(frame: &mut RatatuiFrame<'_>, logical: &Frame) {
535    let area = frame.area();
536    frame.render_widget(Clear, area);
537    if area.width == 0 || area.height == 0 {
538        return;
539    }
540
541    let visible_height = area.height as usize;
542    let source_start = logical.rows.len().saturating_sub(visible_height);
543    let visible_rows = &logical.rows[source_start..];
544    let destination_start = visible_height.saturating_sub(visible_rows.len());
545
546    for (index, row) in visible_rows.iter().enumerate() {
547        let y = area.y.saturating_add((destination_start + index) as u16);
548        let row_area = Rect::new(area.x, y, area.width, 1);
549        frame.render_widget(Paragraph::new(line_from_row(row)), row_area);
550    }
551
552    if !logical.cursor_visible {
553        return;
554    }
555    let Some(cursor) = logical.cursor else {
556        return;
557    };
558    if cursor.row < source_start {
559        return;
560    }
561
562    let visible_row = cursor.row - source_start + destination_start;
563    if visible_row >= visible_height {
564        return;
565    }
566    let x = area
567        .x
568        .saturating_add((cursor.col as u16).min(area.width.saturating_sub(1)));
569    let y = area.y.saturating_add(visible_row as u16);
570    frame.set_cursor_position(Position::new(x, y));
571}
572
573fn line_from_row(row: &Row) -> Line<'static> {
574    let spans = row
575        .spans
576        .iter()
577        .map(|span| RatatuiSpan::styled(span.text.clone(), ratatui_style(span.style)))
578        .collect::<Vec<_>>();
579    Line::from(spans)
580}
581
582fn ratatui_style(style: CellStyle) -> Style {
583    let mut result = Style::default().fg(ratatui_color(style.fg)).bg(ratatui_color(style.bg));
584    let mut modifiers = Modifier::empty();
585    if style.bold {
586        modifiers.insert(Modifier::BOLD);
587    }
588    if style.italic {
589        modifiers.insert(Modifier::ITALIC);
590    }
591    if style.underlined {
592        modifiers.insert(Modifier::UNDERLINED);
593    }
594    if style.dim {
595        modifiers.insert(Modifier::DIM);
596    }
597    result = result.add_modifier(modifiers);
598    result
599}
600
601fn ratatui_color(color: Color) -> RatatuiColor {
602    match color {
603        Color::Reset => RatatuiColor::Reset,
604        Color::Black => RatatuiColor::Black,
605        Color::DarkGrey => RatatuiColor::DarkGray,
606        Color::Red => RatatuiColor::LightRed,
607        Color::DarkRed => RatatuiColor::Red,
608        Color::Green => RatatuiColor::LightGreen,
609        Color::DarkGreen => RatatuiColor::Green,
610        Color::Yellow => RatatuiColor::LightYellow,
611        Color::DarkYellow => RatatuiColor::Yellow,
612        Color::Blue => RatatuiColor::LightBlue,
613        Color::DarkBlue => RatatuiColor::Blue,
614        Color::Magenta => RatatuiColor::LightMagenta,
615        Color::DarkMagenta => RatatuiColor::Magenta,
616        Color::Cyan => RatatuiColor::LightCyan,
617        Color::DarkCyan => RatatuiColor::Cyan,
618        Color::White => RatatuiColor::White,
619        Color::Grey => RatatuiColor::Gray,
620        Color::Rgb { r, g, b } => RatatuiColor::Rgb(r, g, b),
621        Color::AnsiValue(value) => RatatuiColor::Indexed(value),
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use ratatui::Terminal;
628    use ratatui::backend::{Backend, TestBackend};
629
630    use super::*;
631    use crate::app::Entry;
632    use crate::cli::Cli;
633    use crate::renderer::row::{CursorCoord, Row};
634    use crate::renderer::style::Span;
635
636    #[test]
637    fn layout_pins_composer_to_bottom() {
638        let area = Rect::new(2, 3, 80, 24);
639        let layout = AlternateLayout::new(area, 5);
640
641        assert_eq!(layout.transcript, Rect::new(2, 3, 80, 19));
642        assert_eq!(layout.composer, Rect::new(2, 22, 80, 5));
643        assert_eq!(layout.composer.bottom(), area.bottom());
644    }
645
646    #[test]
647    fn growing_composer_only_shrinks_transcript() {
648        let area = Rect::new(0, 0, 80, 24);
649        let one_row = AlternateLayout::new(area, 1);
650        let eight_rows = AlternateLayout::new(area, 8);
651
652        assert_eq!(one_row.composer.bottom(), eight_rows.composer.bottom());
653        assert_eq!(one_row.transcript.height, 23);
654        assert_eq!(eight_rows.transcript.height, 16);
655    }
656
657    #[test]
658    fn tiny_terminal_keeps_one_clamped_composer() {
659        let area = Rect::new(0, 0, 20, 1);
660        let layout = AlternateLayout::new(area, 8);
661
662        assert_eq!(layout.transcript.height, 0);
663        assert_eq!(layout.composer, area);
664    }
665
666    #[test]
667    fn zero_height_terminal_stays_in_bounds() {
668        let area = Rect::new(4, 7, 20, 0);
669        let layout = AlternateLayout::new(area, 3);
670
671        assert_eq!(layout.transcript, area);
672        assert_eq!(layout.composer, area);
673    }
674
675    #[test]
676    fn adapter_bottom_aligns_clipped_frame_and_translates_cursor() {
677        let backend = TestBackend::new(8, 2);
678        let mut terminal = Terminal::new(backend).expect("test terminal");
679        let mut logical = Frame::new(8);
680        logical.push(Row::padded(vec![Span::plain("old")], 8, CellStyle::default()));
681        logical.push(Row::padded(vec![Span::plain("prompt")], 8, CellStyle::default()));
682        logical.push(Row::padded(
683            vec![Span::styled("status", CellStyle::new().fg(Color::Green).bold())],
684            8,
685            CellStyle::default(),
686        ));
687        logical.set_cursor(CursorCoord::new(1, 6));
688
689        terminal
690            .draw(|frame| render_logical_frame(frame, &logical))
691            .expect("draw logical frame");
692
693        let buffer = terminal.backend().buffer();
694        let first_row = (0..8).map(|x| buffer[(x, 0)].symbol()).collect::<String>();
695        let second_row = (0..8).map(|x| buffer[(x, 1)].symbol()).collect::<String>();
696        assert_eq!(first_row, "  prompt");
697        assert_eq!(second_row, "  status");
698        assert_eq!(buffer[(2, 1)].fg, RatatuiColor::LightGreen);
699        assert!(buffer[(2, 1)].modifier.contains(Modifier::BOLD));
700        assert_eq!(terminal.backend_mut().get_cursor_position(), Ok(Position::new(6, 0)));
701    }
702
703    #[test]
704    fn chronological_projection_does_not_partition_later_settled_entries_first() {
705        let mut app = App::from_cli(&Cli::default());
706        app.session_writer = None;
707        app.transcript = vec![
708            Entry::Agent { text: "first mutable".to_string(), streaming: true },
709            Entry::Status { text: "second settled".to_string() },
710        ];
711
712        let view = RendererView::build(&app, 80, 24);
713        let text = view
714            .transcript
715            .rows
716            .iter()
717            .flat_map(|row| row.spans.iter().map(|span| span.text.as_str()))
718            .collect::<String>();
719        let first = text.find("first mutable").expect("mutable entry");
720        let second = text.find("second settled").expect("settled entry");
721        assert!(first < second, "transcript rows must remain chronological");
722    }
723
724    #[test]
725    fn anchored_reader_stays_on_same_entry_while_new_content_arrives() {
726        let mut app = App::from_cli(&Cli::default());
727        app.session_writer = None;
728        app.transcript = (0..12)
729            .map(|index| Entry::Status { text: format!("entry {index}") })
730            .collect();
731        let mut viewport = AlternateViewport::default();
732        viewport.build_frame(&app, 48, 12);
733        viewport.page_up();
734        let anchored = viewport.state();
735
736        app.transcript
737            .push(Entry::Agent { text: "streaming tail".to_string(), streaming: true });
738        viewport.build_frame(&app, 48, 12);
739
740        assert_eq!(viewport.state(), anchored);
741        assert!(matches!(
742            anchored,
743            ViewportState::Anchored(TranscriptPosition::Entry { .. })
744        ));
745    }
746
747    #[test]
748    fn anchored_entry_identity_survives_width_changes() {
749        let mut app = App::from_cli(&Cli::default());
750        app.session_writer = None;
751        app.transcript = (0..8)
752            .map(|index| Entry::Status {
753                text: format!("entry {index} with enough text to wrap across a narrow viewport"),
754            })
755            .collect();
756        let mut viewport = AlternateViewport::default();
757        viewport.build_frame(&app, 32, 10);
758        viewport.half_page_up();
759        let before = viewport.state();
760
761        viewport.build_frame(&app, 100, 20);
762
763        let (
764            ViewportState::Anchored(TranscriptPosition::Entry { entry_index: before_entry, .. }),
765            ViewportState::Anchored(TranscriptPosition::Entry { entry_index: after_entry, .. }),
766        ) = (before, viewport.state())
767        else {
768            panic!("expected an entry anchor before and after resize");
769        };
770        assert_eq!(before_entry, after_entry);
771    }
772
773    #[test]
774    fn following_tail_frame_keeps_composer_cursor_in_terminal_bounds() {
775        let mut app = App::from_cli(&Cli::default());
776        app.session_writer = None;
777        app.input.insert_str("one\ntwo\nthree\nwide 🦀 text");
778        let frame = AlternateViewport::default().build_frame(&app, 24, 9);
779
780        assert_eq!(frame.rows.len(), 9);
781        let cursor = frame.cursor.expect("prompt cursor");
782        assert!(cursor.row < 9);
783        assert!(cursor.col < 24);
784    }
785
786    #[test]
787    fn focused_overlay_keeps_page_navigation_for_itself() {
788        let cli = Cli { model: "fake-agent".to_string(), ..Cli::default() };
789        let mut app = App::from_cli(&cli);
790        app.session_writer = None;
791        app.first_run_recovery = None;
792        app.prompt_accessory = PromptAccessory::Help;
793        let mut viewport = AlternateViewport::default();
794        let event = Event::Key(crossterm::event::KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
795
796        assert!(!viewport.handle_navigation(&app, &event));
797        assert_eq!(viewport.state(), ViewportState::FollowingTail);
798
799        app.prompt_accessory = PromptAccessory::None;
800        viewport.build_frame(&app, 80, 12);
801        assert!(viewport.handle_navigation(&app, &event));
802    }
803
804    #[test]
805    fn submitted_entry_does_not_move_an_anchored_reader() {
806        let mut app = App::from_cli(&Cli::default());
807        app.session_writer = None;
808        app.transcript = (0..10)
809            .map(|index| Entry::Status { text: format!("entry {index}") })
810            .collect();
811        let mut viewport = AlternateViewport::default();
812        viewport.build_frame(&app, 40, 10);
813        viewport.page_up();
814        let anchor = viewport.state();
815
816        app.transcript.push(Entry::User { text: "new submission".to_string() });
817        viewport.build_frame(&app, 40, 10);
818
819        assert_eq!(viewport.state(), anchor);
820    }
821}