diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 032a228ec4..f89938f27f 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -21,6 +21,8 @@ use crate::skill_error_prompt::SkillErrorPromptOutcome; use crate::skill_error_prompt::run_skill_error_prompt; use crate::tui; use crate::tui::TuiEvent; +use crate::tui::scrolling::TranscriptLineMeta; +use crate::tui::scrolling::TranscriptScroll; use crate::update_action::UpdateAction; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; @@ -339,21 +341,6 @@ pub(crate) struct App { skip_world_writable_scan_once: bool, } -/// Scroll state for the inline transcript viewport. -/// -/// This tracks whether the transcript is pinned to the latest line or anchored -/// at a specific cell/line pair so later viewport changes can implement -/// scrollback without losing the notion of "bottom". -#[allow(dead_code)] -#[derive(Debug, Clone, Copy, Default)] -enum TranscriptScroll { - #[default] - ToBottom, - Scrolled { - cell_index: usize, - line_in_cell: usize, - }, -} /// Content-relative selection within the inline transcript viewport. /// /// Selection endpoints are expressed in terms of flattened, wrapped transcript @@ -494,7 +481,7 @@ impl App { file_search, enhanced_keys_supported, transcript_cells: Vec::new(), - transcript_scroll: TranscriptScroll::ToBottom, + transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_view_top: 0, transcript_total_lines: 0, @@ -562,13 +549,13 @@ impl App { let session_lines = if width == 0 { Vec::new() } else { - let (lines, meta) = Self::build_transcript_lines(&app.transcript_cells, width); + let (lines, line_meta) = Self::build_transcript_lines(&app.transcript_cells, width); let is_user_cell: Vec = app .transcript_cells .iter() .map(|cell| cell.as_any().is::()) .collect(); - Self::render_lines_to_ansi(&lines, &meta, &is_user_cell, width) + Self::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width) }; tui.terminal.clear()?; @@ -676,7 +663,7 @@ impl App { ) -> u16 { let area = frame.area(); if area.width == 0 || area.height == 0 { - self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; return area.bottom().saturating_sub(chat_height); @@ -685,7 +672,7 @@ impl App { let chat_height = chat_height.min(area.height); let max_transcript_height = area.height.saturating_sub(chat_height); if max_transcript_height == 0 { - self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; return area.y; @@ -698,10 +685,10 @@ impl App { height: max_transcript_height, }; - let (lines, meta) = Self::build_transcript_lines(cells, transcript_area.width); + let (lines, line_meta) = Self::build_transcript_lines(cells, transcript_area.width); if lines.is_empty() { Clear.render_ref(transcript_area, frame.buffer); - self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; return area.y; @@ -709,7 +696,7 @@ impl App { let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize); if wrapped.is_empty() { - self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; return area.y; @@ -731,10 +718,10 @@ impl App { .initial_indent(base_opts.subsequent_indent.clone()) }; let seg_count = word_wrap_line(line, opts).len(); - let is_user_row = meta + let is_user_row = line_meta .get(idx) - .and_then(Option::as_ref) - .map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false)) + .and_then(TranscriptLineMeta::cell_index) + .map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false)) .unwrap_or(false); wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count)); first = false; @@ -745,30 +732,8 @@ impl App { let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); let max_start = total_lines.saturating_sub(max_visible); - let top_offset = match self.transcript_scroll { - TranscriptScroll::ToBottom => max_start, - TranscriptScroll::Scrolled { - cell_index, - line_in_cell, - } => { - let mut anchor = None; - for (idx, entry) in meta.iter().enumerate() { - if let Some((ci, li)) = entry - && *ci == cell_index - && *li == line_in_cell - { - anchor = Some(idx); - break; - } - } - if let Some(idx) = anchor { - idx.min(max_start) - } else { - self.transcript_scroll = TranscriptScroll::ToBottom; - max_start - } - } - }; + let (scroll_state, top_offset) = self.transcript_scroll.resolve_top(&line_meta, max_start); + self.transcript_scroll = scroll_state; self.transcript_view_top = top_offset; let transcript_visible_height = max_visible as u16; @@ -974,69 +939,10 @@ impl App { return; } - let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width); - let total_lines = lines.len(); - if total_lines <= visible_lines { - self.transcript_scroll = TranscriptScroll::ToBottom; - return; - } - - let max_start = total_lines.saturating_sub(visible_lines); - - let current_top = match self.transcript_scroll { - TranscriptScroll::ToBottom => max_start, - TranscriptScroll::Scrolled { - cell_index, - line_in_cell, - } => { - let mut anchor = None; - for (idx, entry) in meta.iter().enumerate() { - if let Some((ci, li)) = entry - && *ci == cell_index - && *li == line_in_cell - { - anchor = Some(idx); - break; - } - } - anchor.unwrap_or(max_start).min(max_start) - } - }; - - if delta_lines == 0 { - return; - } - - let new_top = if delta_lines < 0 { - current_top.saturating_sub(delta_lines.unsigned_abs() as usize) - } else { - current_top - .saturating_add(delta_lines as usize) - .min(max_start) - }; - - if new_top == max_start { - self.transcript_scroll = TranscriptScroll::ToBottom; - } else { - let anchor = meta.iter().skip(new_top).find_map(|entry| *entry); - if let Some((cell_index, line_in_cell)) = anchor { - self.transcript_scroll = TranscriptScroll::Scrolled { - cell_index, - line_in_cell, - }; - } else if let Some(prev_idx) = (0..=new_top).rfind(|&idx| meta[idx].is_some()) { - if let Some((cell_index, line_in_cell)) = meta[prev_idx] { - self.transcript_scroll = TranscriptScroll::Scrolled { - cell_index, - line_in_cell, - }; - } else { - self.transcript_scroll = TranscriptScroll::ToBottom; - } - } else { - self.transcript_scroll = TranscriptScroll::ToBottom; - } - } + let (_, line_meta) = Self::build_transcript_lines(&self.transcript_cells, width); + self.transcript_scroll = + self.transcript_scroll + .scrolled_by(delta_lines, &line_meta, visible_lines); tui.frame_requester().schedule_frame(); } @@ -1053,8 +959,8 @@ impl App { return; } - let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width); - if lines.is_empty() || meta.is_empty() { + let (lines, line_meta) = Self::build_transcript_lines(&self.transcript_cells, width); + if lines.is_empty() || line_meta.is_empty() { return; } @@ -1073,22 +979,8 @@ impl App { } }; - let mut anchor = None; - if let Some((cell_index, line_in_cell)) = meta.iter().skip(top_offset).flatten().next() { - anchor = Some((*cell_index, *line_in_cell)); - } - if anchor.is_none() - && let Some((cell_index, line_in_cell)) = - meta[..top_offset].iter().rev().flatten().next() - { - anchor = Some((*cell_index, *line_in_cell)); - } - - if let Some((cell_index, line_in_cell)) = anchor { - self.transcript_scroll = TranscriptScroll::Scrolled { - cell_index, - line_in_cell, - }; + if let Some(scroll_state) = TranscriptScroll::anchor_for(&line_meta, top_offset) { + self.transcript_scroll = scroll_state; } } @@ -1096,16 +988,17 @@ impl App { /// /// Returns both the visible `Line` buffer and a parallel metadata vector /// that maps each line back to its originating `(cell_index, line_in_cell)` - /// pair, or `None` for spacer lines. This allows the scroll state to anchor - /// to a specific history cell even as new content arrives or the viewport - /// size changes, and gives exit transcript renderers enough structure to - /// style user rows differently from agent rows. + /// pair (see `TranscriptLineMeta::CellLine`), or `TranscriptLineMeta::Spacer` for + /// synthetic spacer rows inserted between cells. This allows the scroll state + /// to anchor to a specific history cell even as new content arrives or the + /// viewport size changes, and gives exit transcript renderers enough structure + /// to style user rows differently from agent rows. fn build_transcript_lines( cells: &[Arc], width: u16, - ) -> (Vec>, Vec>) { + ) -> (Vec>, Vec) { let mut lines: Vec> = Vec::new(); - let mut meta: Vec> = Vec::new(); + let mut line_meta: Vec = Vec::new(); let mut has_emitted_lines = false; for (cell_index, cell) in cells.iter().enumerate() { @@ -1117,19 +1010,22 @@ impl App { if !cell.is_stream_continuation() { if has_emitted_lines { lines.push(Line::from("")); - meta.push(None); + line_meta.push(TranscriptLineMeta::Spacer); } else { has_emitted_lines = true; } } for (line_in_cell, line) in cell_lines.into_iter().enumerate() { - meta.push(Some((cell_index, line_in_cell))); + line_meta.push(TranscriptLineMeta::CellLine { + cell_index, + line_in_cell, + }); lines.push(line); } } - (lines, meta) + (lines, line_meta) } /// Render flattened transcript lines into ANSI strings suitable for @@ -1144,7 +1040,7 @@ impl App { /// and tools see consistent escape sequences. fn render_lines_to_ansi( lines: &[Line<'static>], - meta: &[Option<(usize, usize)>], + line_meta: &[TranscriptLineMeta], is_user_cell: &[bool], width: u16, ) -> Vec { @@ -1152,10 +1048,10 @@ impl App { .iter() .enumerate() .map(|(idx, line)| { - let is_user_row = meta + let is_user_row = line_meta .get(idx) - .and_then(|entry| entry.as_ref()) - .map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false)) + .and_then(TranscriptLineMeta::cell_index) + .map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false)) .unwrap_or(false); let mut merged_spans: Vec> = line @@ -2262,7 +2158,7 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), - transcript_scroll: TranscriptScroll::ToBottom, + transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_view_top: 0, transcript_total_lines: 0, @@ -2306,7 +2202,7 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), - transcript_scroll: TranscriptScroll::ToBottom, + transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_view_top: 0, transcript_total_lines: 0, @@ -2576,11 +2472,14 @@ mod tests { fn render_lines_to_ansi_pads_user_rows_to_full_width() { let line: Line<'static> = Line::from("hi"); let lines = vec![line]; - let meta = vec![Some((0usize, 0usize))]; + let line_meta = vec![TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 0, + }]; let is_user_cell = vec![true]; let width: u16 = 10; - let rendered = App::render_lines_to_ansi(&lines, &meta, &is_user_cell, width); + let rendered = App::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width); assert_eq!(rendered.len(), 1); assert!(rendered[0].contains("hi")); } diff --git a/codex-rs/tui2/src/tui.rs b/codex-rs/tui2/src/tui.rs index 807c807c87..712c5cf553 100644 --- a/codex-rs/tui2/src/tui.rs +++ b/codex-rs/tui2/src/tui.rs @@ -49,6 +49,7 @@ use crate::tui::job_control::SuspendContext; mod frame_requester; #[cfg(unix)] mod job_control; +pub(crate) mod scrolling; /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; diff --git a/codex-rs/tui2/src/tui/scrolling.rs b/codex-rs/tui2/src/tui/scrolling.rs new file mode 100644 index 0000000000..95346793dc --- /dev/null +++ b/codex-rs/tui2/src/tui/scrolling.rs @@ -0,0 +1,366 @@ +//! Inline transcript scrolling primitives. +//! +//! The TUI renders the transcript as a list of logical *cells* (user prompts, agent responses, +//! banners, etc.). Each frame flattens those cells into a sequence of visual lines (after wrapping) +//! plus a parallel `line_meta` vector that maps each visual line back to its origin +//! (`TranscriptLineMeta`) (see `App::build_transcript_lines` and the design notes in +//! `codex-rs/tui2/docs/tui_viewport_and_history.md`). +//! +//! This module defines the scroll state for the inline transcript viewport and helpers to: +//! - Resolve that state into a concrete top-row offset for the current frame. +//! - Apply a scroll delta (mouse wheel / PgUp / PgDn) in terms of *visual lines*. +//! - Convert a concrete top-row offset back into a stable anchor. +//! +//! Why anchors instead of a raw "top row" index? +//! - When the transcript grows, a raw index drifts relative to the user's chosen content. +//! - By anchoring to a particular `(cell_index, line_in_cell)`, we can re-find the same content in +//! the newly flattened line list on the next frame. +//! +//! Spacer rows between non-continuation cells are represented as `TranscriptLineMeta::Spacer`. +//! They are not valid anchors; `anchor_for` will pick the nearest non-spacer line when needed. + +/// Per-flattened-line metadata for the transcript view. +/// +/// Each rendered line in the flattened transcript has a corresponding `TranscriptLineMeta` entry +/// describing where that visual line came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TranscriptLineMeta { + /// A visual line that belongs to a transcript cell. + CellLine { + cell_index: usize, + line_in_cell: usize, + }, + /// A synthetic spacer row inserted between non-continuation cells. + Spacer, +} + +impl TranscriptLineMeta { + pub(crate) fn cell_line(&self) -> Option<(usize, usize)> { + match *self { + Self::CellLine { + cell_index, + line_in_cell, + } => Some((cell_index, line_in_cell)), + Self::Spacer => None, + } + } + + pub(crate) fn cell_index(&self) -> Option { + match *self { + Self::CellLine { cell_index, .. } => Some(cell_index), + Self::Spacer => None, + } + } +} + +/// Scroll state for the inline transcript viewport. +/// +/// This tracks whether the transcript is pinned to the latest line or anchored +/// at a specific cell/line pair so later viewport changes can implement +/// scrollback without losing the notion of "bottom". +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum TranscriptScroll { + #[default] + /// Follow the most recent line in the transcript. + ToBottom, + /// Anchor the viewport to a specific transcript cell and line. + /// + /// `cell_index` indexes into the logical transcript cell list. `line_in_cell` is the 0-based + /// visual line index within that cell as produced by the current wrapping/layout. + Scrolled { + cell_index: usize, + line_in_cell: usize, + }, +} + +impl TranscriptScroll { + /// Resolve the top row for the current scroll state. + /// + /// `line_meta` is a line-parallel mapping of flattened transcript lines. + /// + /// `max_start` is the maximum valid top-row offset for the current viewport height (i.e. the + /// last scroll position that still yields a full viewport of content). + /// + /// Returns the (possibly updated) scroll state plus the resolved top-row offset. If the current + /// anchor can no longer be found in `line_meta` (for example because the transcript was + /// truncated), this falls back to `ToBottom` so the UI stays usable. + pub(crate) fn resolve_top( + self, + line_meta: &[TranscriptLineMeta], + max_start: usize, + ) -> (Self, usize) { + match self { + Self::ToBottom => (Self::ToBottom, max_start), + Self::Scrolled { + cell_index, + line_in_cell, + } => { + let anchor = anchor_index(line_meta, cell_index, line_in_cell); + match anchor { + Some(idx) => (self, idx.min(max_start)), + None => (Self::ToBottom, max_start), + } + } + } + } + + /// Apply a scroll delta and return the updated scroll state. + /// + /// `delta_lines` is in *visual lines* (after wrapping): negative deltas scroll upward into + /// scrollback, positive deltas scroll downward toward the latest content. + /// + /// See `resolve_top` for `line_meta` semantics. `visible_lines` is the viewport height in rows. + /// If all flattened lines fit in the viewport, this always returns `ToBottom`. + pub(crate) fn scrolled_by( + self, + delta_lines: i32, + line_meta: &[TranscriptLineMeta], + visible_lines: usize, + ) -> Self { + if delta_lines == 0 { + return self; + } + + let total_lines = line_meta.len(); + if total_lines <= visible_lines { + return Self::ToBottom; + } + + let max_start = total_lines.saturating_sub(visible_lines); + let current_top = match self { + Self::ToBottom => max_start, + Self::Scrolled { + cell_index, + line_in_cell, + } => anchor_index(line_meta, cell_index, line_in_cell) + .unwrap_or(max_start) + .min(max_start), + }; + + let new_top = if delta_lines < 0 { + current_top.saturating_sub(delta_lines.unsigned_abs() as usize) + } else { + current_top + .saturating_add(delta_lines as usize) + .min(max_start) + }; + + if new_top == max_start { + return Self::ToBottom; + } + + Self::anchor_for(line_meta, new_top).unwrap_or(Self::ToBottom) + } + + /// Anchor to the first available line at or near the given start offset. + /// + /// This is the inverse of "resolving a scroll state to a top-row offset": + /// given a concrete flattened line index, pick a stable `(cell_index, line_in_cell)` anchor. + /// + /// See `resolve_top` for `line_meta` semantics. This prefers the nearest line at or after `start` + /// (skipping spacer rows), falling back to the nearest line before it when needed. + pub(crate) fn anchor_for(line_meta: &[TranscriptLineMeta], start: usize) -> Option { + let anchor = + anchor_at_or_after(line_meta, start).or_else(|| anchor_at_or_before(line_meta, start)); + anchor.map(|(cell_index, line_in_cell)| Self::Scrolled { + cell_index, + line_in_cell, + }) + } +} + +/// Locate the flattened line index for a specific transcript cell and line. +/// +/// This scans `meta` for the exact `(cell_index, line_in_cell)` anchor. It returns `None` when the +/// anchor is not present in the current frame's flattened line list (for example if a cell was +/// removed or its displayed line count changed). +fn anchor_index( + line_meta: &[TranscriptLineMeta], + cell_index: usize, + line_in_cell: usize, +) -> Option { + line_meta + .iter() + .enumerate() + .find_map(|(idx, entry)| match *entry { + TranscriptLineMeta::CellLine { + cell_index: ci, + line_in_cell: li, + } if ci == cell_index && li == line_in_cell => Some(idx), + _ => None, + }) +} + +/// Find the first transcript line at or after the given flattened index. +fn anchor_at_or_after(line_meta: &[TranscriptLineMeta], start: usize) -> Option<(usize, usize)> { + if line_meta.is_empty() { + return None; + } + let start = start.min(line_meta.len().saturating_sub(1)); + line_meta + .iter() + .skip(start) + .find_map(TranscriptLineMeta::cell_line) +} + +/// Find the nearest transcript line at or before the given flattened index. +fn anchor_at_or_before(line_meta: &[TranscriptLineMeta], start: usize) -> Option<(usize, usize)> { + if line_meta.is_empty() { + return None; + } + let start = start.min(line_meta.len().saturating_sub(1)); + line_meta[..=start] + .iter() + .rev() + .find_map(TranscriptLineMeta::cell_line) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn meta(entries: &[TranscriptLineMeta]) -> Vec { + entries.to_vec() + } + + fn cell_line(cell_index: usize, line_in_cell: usize) -> TranscriptLineMeta { + TranscriptLineMeta::CellLine { + cell_index, + line_in_cell, + } + } + + #[test] + fn resolve_top_to_bottom_clamps_to_max_start() { + let meta = meta(&[ + cell_line(0, 0), + cell_line(0, 1), + TranscriptLineMeta::Spacer, + cell_line(1, 0), + ]); + + let (state, top) = TranscriptScroll::ToBottom.resolve_top(&meta, 3); + + assert_eq!(state, TranscriptScroll::ToBottom); + assert_eq!(top, 3); + } + + #[test] + fn resolve_top_scrolled_keeps_anchor_when_present() { + let meta = meta(&[ + cell_line(0, 0), + TranscriptLineMeta::Spacer, + cell_line(1, 0), + cell_line(1, 1), + ]); + let scroll = TranscriptScroll::Scrolled { + cell_index: 1, + line_in_cell: 0, + }; + + let (state, top) = scroll.resolve_top(&meta, 2); + + assert_eq!(state, scroll); + assert_eq!(top, 2); + } + + #[test] + fn resolve_top_scrolled_falls_back_when_anchor_missing() { + let meta = meta(&[cell_line(0, 0), TranscriptLineMeta::Spacer, cell_line(1, 0)]); + let scroll = TranscriptScroll::Scrolled { + cell_index: 2, + line_in_cell: 0, + }; + + let (state, top) = scroll.resolve_top(&meta, 1); + + assert_eq!(state, TranscriptScroll::ToBottom); + assert_eq!(top, 1); + } + + #[test] + fn scrolled_by_moves_upward_and_anchors() { + let meta = meta(&[ + cell_line(0, 0), + cell_line(0, 1), + cell_line(1, 0), + TranscriptLineMeta::Spacer, + cell_line(2, 0), + cell_line(2, 1), + ]); + + let state = TranscriptScroll::ToBottom.scrolled_by(-1, &meta, 3); + + assert_eq!( + state, + TranscriptScroll::Scrolled { + cell_index: 1, + line_in_cell: 0 + } + ); + } + + #[test] + fn scrolled_by_returns_to_bottom_when_scrolling_down() { + let meta = meta(&[ + cell_line(0, 0), + cell_line(0, 1), + cell_line(1, 0), + cell_line(2, 0), + ]); + let scroll = TranscriptScroll::Scrolled { + cell_index: 0, + line_in_cell: 0, + }; + + let state = scroll.scrolled_by(5, &meta, 2); + + assert_eq!(state, TranscriptScroll::ToBottom); + } + + #[test] + fn scrolled_by_to_bottom_when_all_lines_fit() { + let meta = meta(&[cell_line(0, 0), cell_line(0, 1)]); + + let state = TranscriptScroll::Scrolled { + cell_index: 0, + line_in_cell: 0, + } + .scrolled_by(-1, &meta, 5); + + assert_eq!(state, TranscriptScroll::ToBottom); + } + + #[test] + fn anchor_for_prefers_after_then_before() { + let meta = meta(&[ + TranscriptLineMeta::Spacer, + cell_line(0, 0), + TranscriptLineMeta::Spacer, + cell_line(1, 0), + ]); + + assert_eq!( + TranscriptScroll::anchor_for(&meta, 0), + Some(TranscriptScroll::Scrolled { + cell_index: 0, + line_in_cell: 0 + }) + ); + assert_eq!( + TranscriptScroll::anchor_for(&meta, 2), + Some(TranscriptScroll::Scrolled { + cell_index: 1, + line_in_cell: 0 + }) + ); + assert_eq!( + TranscriptScroll::anchor_for(&meta, 3), + Some(TranscriptScroll::Scrolled { + cell_index: 1, + line_in_cell: 0 + }) + ); + } +}