1use 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#[derive(Debug)]
40pub struct AlternateScreenSession {
41 mouse_capture: bool,
42 active: bool,
43}
44
45impl AlternateScreenSession {
46 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum TranscriptPosition {
140 Banner { row: usize },
142 Entry { entry_index: usize, row_in_entry: usize },
144}
145
146#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
148pub enum ViewportState {
149 #[default]
151 FollowingTail,
152 Anchored(TranscriptPosition),
154}
155
156#[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 pub fn state(&self) -> ViewportState {
235 self.state
236 }
237
238 pub fn follow_tail(&mut self) {
240 self.state = ViewportState::FollowingTail;
241 }
242
243 pub fn reset(&mut self) {
245 *self = Self::default();
246 }
247
248 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 pub fn line_up(&mut self) {
310 self.scroll_up(1);
311 }
312
313 pub fn line_down(&mut self) {
315 self.scroll_down(1);
316 }
317
318 pub fn half_page_up(&mut self) {
320 self.scroll_up(self.last_page_rows.div_ceil(2).max(1));
321 }
322
323 pub fn half_page_down(&mut self) {
325 self.scroll_down(self.last_page_rows.div_ceil(2).max(1));
326 }
327
328 pub fn page_up(&mut self) {
330 self.scroll_up(self.last_page_rows.max(1));
331 }
332
333 pub fn page_down(&mut self) {
335 self.scroll_down(self.last_page_rows.max(1));
336 }
337
338 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 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
411fn 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
496pub struct AlternateLayout {
497 pub transcript: Rect,
499 pub composer: Rect,
501}
502
503impl AlternateLayout {
504 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
528pub 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}