diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 1bbdb653ca..6430b0839e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -15,13 +15,14 @@ //! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell //! mutates in place or when its transcript output is time-dependent. +mod scrolling; + use std::io::Result; use std::sync::Arc; use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; use crate::history_cell::SessionInfoCell; -use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; @@ -30,10 +31,7 @@ use crate::keymap::PagerKeymap; use crate::render::Insets; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; -use crate::style::user_message_style; use crate::terminal_hyperlinks::HyperlinkLine; -use crate::terminal_hyperlinks::mark_buffer_hyperlinks; -use crate::terminal_hyperlinks::visible_lines_ref; use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; @@ -41,7 +39,6 @@ use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -50,6 +47,9 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; +use scrolling::CellRenderable; +use scrolling::HyperlinkLinesRenderable; +use scrolling::render_offset_content; pub(crate) enum Overlay { Transcript(TranscriptOverlay), @@ -417,6 +417,11 @@ impl Renderable for CachedRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { self.renderable.render(area, buf); } + + fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool { + self.renderable.render_scrolled(area, buf, scroll_offset) + } + fn desired_height(&self, width: u16) -> u16 { if self.last_width.get() != Some(width) { let height = self.renderable.desired_height(width); @@ -427,56 +432,6 @@ impl Renderable for CachedRenderable { } } -struct CellRenderable { - cell: Arc, - highlighted: bool, -} - -impl Renderable for CellRenderable { - fn render(&self, area: Rect, buf: &mut Buffer) { - let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width); - let style = if self.cell.as_any().is::() { - if self.highlighted { - user_message_style().reversed() - } else { - user_message_style() - } - } else { - Style::default() - }; - let p = Paragraph::new(Text::from(visible_lines_ref(&hyperlink_lines))) - .style(style) - .wrap(Wrap { trim: false }); - p.render(area, buf); - mark_buffer_hyperlinks(buf, area, &hyperlink_lines, /*scroll_rows*/ 0); - } - - fn desired_height(&self, width: u16) -> u16 { - self.cell.desired_transcript_height(width) - } -} - -struct HyperlinkLinesRenderable { - lines: Vec, -} - -impl Renderable for HyperlinkLinesRenderable { - fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(Text::from(visible_lines_ref(&self.lines))) - .wrap(Wrap { trim: false }) - .render(area, buf); - mark_buffer_hyperlinks(buf, area, &self.lines, /*scroll_rows*/ 0); - } - - fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(visible_lines_ref(&self.lines))) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(/*default*/ 0) - } -} - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum TranscriptHistoryState { #[default] @@ -1055,33 +1010,6 @@ impl StaticOverlay { } } -fn render_offset_content( - area: Rect, - buf: &mut Buffer, - renderable: &dyn Renderable, - scroll_offset: u16, -) -> u16 { - let height = renderable.desired_height(area.width); - let mut tall_buf = Buffer::empty(Rect::new( - 0, - 0, - area.width, - height.min(area.height + scroll_offset), - )); - renderable.render(*tall_buf.area(), &mut tall_buf); - let copy_height = area - .height - .min(tall_buf.area().height.saturating_sub(scroll_offset)); - for y in 0..copy_height { - let src_y = y + scroll_offset; - for x in 0..area.width { - buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone(); - } - } - - copy_height -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/pager_overlay/scrolling.rs b/codex-rs/tui/src/pager_overlay/scrolling.rs new file mode 100644 index 0000000000..6f21b16afc --- /dev/null +++ b/codex-rs/tui/src/pager_overlay/scrolling.rs @@ -0,0 +1,123 @@ +//! Viewport-aware transcript rendering and the fallback for generic pager content. + +use std::sync::Arc; + +use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::mark_buffer_hyperlinks; +use crate::terminal_hyperlinks::visible_lines_ref; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; + +/// Renders a committed history cell directly into the visible transcript viewport. +pub(super) struct CellRenderable { + pub(super) cell: Arc, + pub(super) highlighted: bool, +} + +impl Renderable for CellRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_scrolled(area, buf, /*scroll_offset*/ 0); + } + + /// Scroll visible text and hyperlink metadata together without rendering hidden rows. + fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool { + let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width); + let style = if self.cell.as_any().is::() { + if self.highlighted { + user_message_style().reversed() + } else { + user_message_style() + } + } else { + Style::default() + }; + let p = Paragraph::new(Text::from(visible_lines_ref(&hyperlink_lines))) + .style(style) + .wrap(Wrap { trim: false }) + .scroll((scroll_offset, 0)); + p.render(area, buf); + mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(scroll_offset)); + true + } + + fn desired_height(&self, width: u16) -> u16 { + self.cell.desired_transcript_height(width) + } +} + +/// Renders the optional in-flight transcript tail without allocating hidden rows. +pub(super) struct HyperlinkLinesRenderable { + pub(super) lines: Vec, +} + +impl Renderable for HyperlinkLinesRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_scrolled(area, buf, /*scroll_offset*/ 0); + } + + /// Keep live-tail hyperlinks aligned with the same visible rows as their text. + fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool { + Paragraph::new(Text::from(visible_lines_ref(&self.lines))) + .wrap(Wrap { trim: false }) + .scroll((scroll_offset, 0)) + .render(area, buf); + mark_buffer_hyperlinks(buf, area, &self.lines, usize::from(scroll_offset)); + true + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(visible_lines_ref(&self.lines))) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(/*default*/ 0) + } +} + +/// Render visible rows directly when supported, preserving the legacy scratch-buffer fallback. +pub(super) fn render_offset_content( + area: Rect, + buf: &mut Buffer, + renderable: &dyn Renderable, + scroll_offset: u16, +) -> u16 { + let height = renderable.desired_height(area.width); + let copy_height = area.height.min(height.saturating_sub(scroll_offset)); + if copy_height == 0 { + return 0; + } + + let visible_area = Rect::new(area.x, area.y, area.width, copy_height); + if renderable.render_scrolled(visible_area, buf, scroll_offset) { + return copy_height; + } + + let mut tall_buf = Buffer::empty(Rect::new( + /*x*/ 0, + /*y*/ 0, + area.width, + scroll_offset + copy_height, + )); + renderable.render(*tall_buf.area(), &mut tall_buf); + for y in 0..copy_height { + let src_y = y + scroll_offset; + for x in 0..area.width { + buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone(); + } + } + + copy_height +} + +#[cfg(test)] +#[path = "scrolling_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/pager_overlay/scrolling_tests.rs b/codex-rs/tui/src/pager_overlay/scrolling_tests.rs new file mode 100644 index 0000000000..ddec5e7059 --- /dev/null +++ b/codex-rs/tui/src/pager_overlay/scrolling_tests.rs @@ -0,0 +1,284 @@ +use super::super::CachedRenderable; +use super::super::TranscriptOverlay; +use super::CellRenderable; +use super::HyperlinkLinesRenderable; +use super::render_offset_content; +use crate::chatwidget::ActiveCellTranscriptKey; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::UserHistoryCell; +use crate::keymap::RuntimeKeymap; +use crate::render::Insets; +use crate::render::renderable::InsetRenderable; +use crate::render::renderable::Renderable; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::visible_lines; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::sync::Arc; + +#[derive(Debug)] +struct HyperlinkTestCell { + lines: Vec, +} + +impl HistoryCell for HyperlinkTestCell { + fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn transcript_hyperlink_lines(&self, _width: u16) -> Vec { + self.lines.clone() + } + + fn raw_lines(&self) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn has_stable_transcript_height(&self) -> bool { + false + } +} + +/// Forces the unchanged full-height fallback while preserving the wrapped renderable's output. +struct LegacyOnlyRenderable { + inner: Box, +} + +impl Renderable for LegacyOnlyRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.inner.render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.inner.desired_height(width) + } +} + +fn scrolled_hyperlink_lines() -> Vec { + let mut linked = HyperlinkLine::new(Line::from(vec![ + "prefix ".green(), + "漢字 ".bold(), + "ガ ".italic(), + ])); + linked.push_span( + "clickable 漢字 ガ".cyan().underlined(), + Some("https://example.com/a/wrapped-private-destination"), + ); + linked.push_span( + " suffix words that continue wrapping".blue(), + /*destination*/ None, + ); + + vec![ + HyperlinkLine::new(Line::from("first styled row".magenta())), + linked, + HyperlinkLine::new(Line::default()), + HyperlinkLine::new(Line::from("last 漢字 ガ row".red())), + ] +} + +fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box)> { + let cell: Arc = Arc::new(HyperlinkTestCell { + lines: lines.to_vec(), + }); + let user: Arc = Arc::new(UserHistoryCell { + message: "highlighted 漢字 user message that wraps\nsecond user line".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }); + + vec![ + ( + "uncached history cell", + Box::new(CellRenderable { + cell: cell.clone(), + highlighted: false, + }), + ), + ( + "highlighted cached user history cell", + Box::new(CachedRenderable::new(CellRenderable { + cell: user, + highlighted: true, + })), + ), + ( + "inset cached history cell", + Box::new(InsetRenderable::new( + Box::new(CachedRenderable::new(CellRenderable { + cell, + highlighted: false, + })) as Box, + Insets::tlbr( + /*top*/ 2, /*left*/ 1, /*bottom*/ 1, /*right*/ 1, + ), + )), + ), + ( + "live tail", + Box::new(HyperlinkLinesRenderable { + lines: lines.to_vec(), + }), + ), + ( + "inset cached live tail", + Box::new(InsetRenderable::new( + Box::new(CachedRenderable::new(HyperlinkLinesRenderable { + lines: lines.to_vec(), + })) as Box, + Insets::tlbr( + /*top*/ 1, /*left*/ 1, /*bottom*/ 1, /*right*/ 1, + ), + )), + ), + ( + "unsupported paragraph fallback", + Box::new( + Paragraph::new(Text::from(visible_lines(lines.to_vec()))) + .wrap(Wrap { trim: false }), + ), + ), + ] +} + +#[test] +fn scrolled_transcript_renderables_match_full_height_fallback() { + let lines = scrolled_hyperlink_lines(); + + for width in [5, 7, 13, 28] { + for ((name, renderable), (_, legacy_inner)) in scrolled_test_renderables(&lines) + .into_iter() + .zip(scrolled_test_renderables(&lines)) + { + let legacy = LegacyOnlyRenderable { + inner: legacy_inner, + }; + let height = renderable.desired_height(width); + for offset in [ + 0, + 1, + 2, + 3, + height.saturating_sub(/*rhs*/ 2), + height.saturating_sub(/*rhs*/ 1), + height, + ] { + for (x, y, visible_height) in [(0, 0, 4), (3, 2, 3)] { + let area = Rect::new(x, y, width, visible_height); + let full_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + area.right().saturating_add(/*rhs*/ 2), + area.bottom().saturating_add(/*rhs*/ 2), + ); + let mut expected = Buffer::empty(full_area); + let mut actual = Buffer::empty(full_area); + + let expected_height = + render_offset_content(area, &mut expected, &legacy, offset); + let actual_height = + render_offset_content(area, &mut actual, &*renderable, offset); + + assert_eq!( + (actual_height, actual), + (expected_height, expected), + "renderable={name}, width={width}, offset={offset}, area={area:?}", + ); + } + } + } + } +} + +#[test] +fn transcript_overlay_scrolled_cells_and_live_tail_match_full_height_fallback() { + let lines = scrolled_hyperlink_lines(); + let cell: Arc = Arc::new(HyperlinkTestCell { + lines: lines.clone(), + }); + + for width in [7, 13, 28] { + let cells: Vec> = vec![ + Arc::new(PlainHistoryCell::new(vec![Line::from( + "leading stable history", + )])), + cell.clone(), + ]; + let mut actual = TranscriptOverlay::new(cells.clone(), RuntimeKeymap::defaults().pager); + let mut expected = TranscriptOverlay::new(cells, RuntimeKeymap::defaults().pager); + for overlay in [&mut actual, &mut expected] { + overlay.sync_live_tail( + width, + Some(ActiveCellTranscriptKey { + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }), + |_| Some(lines.clone()), + ); + } + expected.view.renderables = expected + .view + .renderables + .into_iter() + .map(|inner| Box::new(LegacyOnlyRenderable { inner }) as Box) + .collect(); + + let area = Rect::new(/*x*/ 2, /*y*/ 1, width, /*height*/ 10); + let full_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + area.right().saturating_add(/*rhs*/ 1), + area.bottom().saturating_add(/*rhs*/ 1), + ); + let total_height = actual.view.content_height(width); + for offset in [0, 1, 3, total_height.saturating_sub(/*rhs*/ 2), usize::MAX] { + actual.view.scroll_offset = offset; + expected.view.scroll_offset = offset; + let mut actual_buffer = Buffer::empty(full_area); + let mut expected_buffer = Buffer::empty(full_area); + + actual.render(area, &mut actual_buffer); + expected.render(area, &mut expected_buffer); + + assert_eq!( + actual_buffer, expected_buffer, + "width={width}, offset={offset}", + ); + } + } +} + +#[test] +fn fallback_handles_offsets_near_maximum_height() { + struct MaximumHeightRenderable; + + impl Renderable for MaximumHeightRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + let last_row = area.bottom().saturating_sub(/*rhs*/ 1); + buf[(area.x, last_row)].set_symbol("x"); + } + + fn desired_height(&self, _width: u16) -> u16 { + u16::MAX + } + } + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 2, + ); + let mut actual = Buffer::empty(area); + let mut expected = Buffer::empty(area); + expected[(area.x, area.y)].set_symbol("x"); + let height = render_offset_content(area, &mut actual, &MaximumHeightRenderable, u16::MAX - 1); + + assert_eq!((height, actual), (1, expected)); +} diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index a35d0de2b2..c1e2009c86 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -16,6 +16,13 @@ use crate::render::RectExt as _; pub trait Renderable { fn render(&self, area: Rect, buf: &mut Buffer); fn desired_height(&self, width: u16) -> u16; + /// Renders visible rows after `scroll_offset` when direct scrolling is supported. + /// + /// Implementations returning `false` must leave `buf` unchanged so callers can use their + /// existing full-height rendering fallback. Supporting wrappers must forward this method. + fn render_scrolled(&self, _area: Rect, _buf: &mut Buffer, _scroll_offset: u16) -> bool { + false + } fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { None } @@ -44,6 +51,13 @@ impl<'a> Renderable for RenderableItem<'a> { } } + fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool { + match self { + RenderableItem::Owned(child) => child.render_scrolled(area, buf, scroll_offset), + RenderableItem::Borrowed(child) => child.render_scrolled(area, buf, scroll_offset), + } + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { match self { RenderableItem::Owned(child) => child.cursor_pos(area), @@ -479,6 +493,46 @@ impl<'a> Renderable for InsetRenderable<'a> { + self.insets.top + self.insets.bottom } + + /// Preserve clipped inset padding while forwarding only visible child rows. + fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool { + let top_padding = self.insets.top.saturating_sub(scroll_offset); + let child_width = area + .width + .saturating_sub(self.insets.left.saturating_add(self.insets.right)); + if child_width == 0 || top_padding >= area.height { + return true; + } + + let child_offset = scroll_offset.saturating_sub(self.insets.top); + // The fallback applies bottom padding to its clipped scratch buffer, even mid-scroll. + let child_height = self + .child + .desired_height(child_width) + .saturating_sub(child_offset) + .min( + area.height + .saturating_sub(top_padding) + .saturating_sub(self.insets.bottom), + ); + if child_height == 0 { + return true; + } + + let child_area = Rect::new( + area.x.saturating_add(self.insets.left), + area.y.saturating_add(top_padding), + child_width, + child_height, + ); + if child_offset == 0 { + self.child.render(child_area, buf); + true + } else { + self.child.render_scrolled(child_area, buf, child_offset) + } + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.child.cursor_pos(area.inset(self.insets)) }