Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codex-rs/tui/src/app/history_pagination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ impl App {
self.scrollback_has_older_history = app_server.has_older_history(thread_id);
let mut continue_to_start = false;
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
let index = overlay.prepend(cells.clone(), width);
let index = overlay.prepend(cells.clone());
self.transcript_cells.splice(index..index, cells);
let previous_state = overlay.set_history_state(if self.scrollback_has_older_history {
TranscriptHistoryState::Partial
Expand Down
7 changes: 3 additions & 4 deletions codex-rs/tui/src/app/tests/session_lifecycle_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2651,10 +2651,6 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
.await;
assert!(app.scrollback_has_older_history);
if let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() {
overlay.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
)?;
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 100, /*height*/ 16,
);
Expand All @@ -2670,6 +2666,9 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
.collect::<Vec<_>>()
.join("\n")
};
// Inspect the loaded start without Home, which now requests all older pages.
render_overlay(overlay);
overlay.scroll(isize::MIN);
let partial = render_overlay(overlay);
assert!(partial.contains("Earlier messages are available — scroll up to load them"));
assert!(!partial.contains("OpenAI Codex"));
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/app_backtrack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ impl App {
let close_overlay = t.is_done();
if !close_overlay
&& active_key.is_some_and(|key| key.animation_tick.is_some())
&& t.is_scrolled_to_bottom()
&& t.live_tail_visible()
{
tui.frame_requester()
.schedule_frame_in(std::time::Duration::from_millis(50));
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/app_backtrack/legacy_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ impl App {
event: TuiEvent,
) -> Result<bool> {
if let TuiEvent::Key(key_event) = &event
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref()
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
&& (overlay.should_load_older(*key_event)
|| (self.backtrack.overlay_preview_active
&& self.backtrack.nth_user_message == 0
Expand Down
186 changes: 11 additions & 175 deletions codex-rs/tui/src/pager_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::keymap::PagerKeymap;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::tui;
Expand All @@ -50,35 +48,37 @@ 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),
Static(StaticOverlay),
Transcript(Box<TranscriptOverlay>),
Static(Box<StaticOverlay>),
Analytics(Box<crate::analytics::AnalyticsView>),
}

impl Overlay {
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self::Transcript(TranscriptOverlay::new(cells, keymap))
Self::Transcript(Box::new(TranscriptOverlay::new(cells, keymap)))
}

pub(crate) fn new_static_with_lines(
lines: Vec<Line<'static>>,
title: String,
keymap: PagerKeymap,
) -> Self {
Self::Static(StaticOverlay::with_title(lines, title, keymap))
Self::Static(Box::new(StaticOverlay::with_title(lines, title, keymap)))
}

pub(crate) fn new_static_with_renderables(
renderables: Vec<Box<dyn Renderable>>,
title: String,
keymap: PagerKeymap,
) -> Self {
Self::Static(StaticOverlay::with_renderables(renderables, title, keymap))
Self::Static(Box::new(StaticOverlay::with_renderables(
renderables,
title,
keymap,
)))
}

pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
Expand Down Expand Up @@ -159,11 +159,6 @@ struct PagerView {
title: String,
keymap: PagerKeymap,
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
/// Percentages are meaningful only when the full scrollable history is known.
scroll_percentage_visible: bool,
/// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>,
}

impl PagerView {
Expand All @@ -179,9 +174,6 @@ impl PagerView {
title,
keymap,
last_content_height: None,
last_rendered_height: None,
scroll_percentage_visible: true,
pending_scroll_chunk: None,
}
}

Expand All @@ -198,12 +190,6 @@ impl PagerView {
let content_area = self.content_area(area);
self.update_last_content_height(content_area.height);
let content_height = self.content_height(content_area.width);
self.last_rendered_height = Some(content_height);
// If there is a pending request to scroll a specific chunk into view,
// satisfy it now that wrapping is up to date for this width.
if let Some(idx) = self.pending_scroll_chunk.take() {
self.ensure_chunk_visible(idx, content_area);
}
self.scroll_offset = self
.scroll_offset
.min(content_height.saturating_sub(content_area.height as usize));
Expand Down Expand Up @@ -270,9 +256,6 @@ impl PagerView {
Span::from("─".repeat(sep_rect.width as usize))
.dim()
.render(sep_rect, buf);
if !self.scroll_percentage_visible {
return;
}
let percent = if total_len == 0 {
100
} else {
Expand Down Expand Up @@ -359,53 +342,6 @@ impl PagerView {
}
}

impl PagerView {
fn is_scrolled_to_bottom(&self) -> bool {
if self.scroll_offset == usize::MAX {
return true;
}
let Some(height) = self.last_content_height else {
return false;
};
if self.renderables.is_empty() {
return true;
}
let Some(total_height) = self.last_rendered_height else {
return false;
};
if total_height <= height {
return true;
}
let max_scroll = total_height.saturating_sub(height);
self.scroll_offset >= max_scroll
}

/// Request that the given text chunk index be scrolled into view on next render.
fn scroll_chunk_into_view(&mut self, chunk_index: usize) {
self.pending_scroll_chunk = Some(chunk_index);
}

fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
if area.height == 0 || idx >= self.renderables.len() {
return;
}
let first = self
.renderables
.iter()
.take(idx)
.map(|r| r.desired_height(area.width) as usize)
.sum();
let last = first + self.renderables[idx].desired_height(area.width) as usize;
let current_top = self.scroll_offset;
let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
if first < current_top {
self.scroll_offset = first;
} else if last > current_bottom {
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
}
}
}

/// A renderable that caches its desired height.
struct CachedRenderable {
renderable: Box<dyn Renderable>,
Expand Down Expand Up @@ -454,14 +390,14 @@ pub(crate) enum TranscriptHistoryState {
}

impl TranscriptHistoryState {
fn has_unloaded_history(self) -> bool {
pub(crate) fn has_unloaded_history(self) -> bool {
matches!(
self,
Self::LoadingOlder | Self::LoadingBeginning | Self::Partial | Self::Failed
)
}

fn session_header_placeholder(self) -> Option<&'static str> {
pub(crate) fn session_header_placeholder(self) -> Option<&'static str> {
match self {
Self::LoadingOlder | Self::LoadingBeginning => Some("Loading earlier messages..."),
Self::Partial => Some("Earlier messages are available — scroll up to load them"),
Expand Down Expand Up @@ -605,26 +541,6 @@ mod tests {
);
}

fn buffer_to_text(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
let symbol = buf[(x, y)].symbol();
if symbol.is_empty() {
out.push(' ');
} else {
out.push(symbol.chars().next().unwrap_or(' '));
}
}
// Trim trailing spaces for stability.
while out.ends_with(' ') {
out.pop();
}
out.push('\n');
}
out
}

#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title
Expand Down Expand Up @@ -665,84 +581,4 @@ mod tests {

assert_eq!(pv.content_height(/*width*/ 80), 5);
}

#[test]
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 1),
paragraph_block("b", /*lines*/ 3),
paragraph_block("c", /*lines*/ 3),
],
"T",
/*scroll_offset*/ 0,
);
let area = Rect::new(0, 0, 20, 8);

pv.scroll_offset = 0;
let content_area = pv.content_area(area);
pv.ensure_chunk_visible(/*idx*/ 2, content_area);

let mut buf = Buffer::empty(area);
pv.render(area, &mut buf);
let rendered = buffer_to_text(&buf, area);

assert!(
rendered.contains("c0"),
"expected chunk top in view: {rendered:?}"
);
assert!(
rendered.contains("c1"),
"expected chunk middle in view: {rendered:?}"
);
assert!(
rendered.contains("c2"),
"expected chunk bottom in view: {rendered:?}"
);
}

#[test]
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 2),
paragraph_block("b", /*lines*/ 3),
paragraph_block("c", /*lines*/ 3),
],
"T",
/*scroll_offset*/ 0,
);
let area = Rect::new(0, 0, 20, 3);

pv.scroll_offset = 6;
pv.ensure_chunk_visible(/*idx*/ 0, area);

assert_eq!(pv.scroll_offset, 0);
}

#[test]
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
let mut pv = pager_view(
vec![paragraph_block("a", /*lines*/ 10)],
"T",
/*scroll_offset*/ 0,
);
let area = Rect::new(0, 0, 20, 8);
let mut buf = Buffer::empty(area);

pv.render(area, &mut buf);

assert!(
!pv.is_scrolled_to_bottom(),
"expected view to report not at bottom when offset < max"
);

pv.scroll_offset = usize::MAX;
pv.render(area, &mut buf);

assert!(
pv.is_scrolled_to_bottom(),
"expected view to report at bottom after scrolling to end"
);
}
}
Loading
Loading