mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
refactor(tui2): make transcript line metadata explicit (#8089)
This is a pure refactor only change.
Replace the flattened transcript line metadata from `Option<(usize,
usize)>` to an explicit
`TranscriptLineMeta::{CellLine { cell_index, line_in_cell }, Spacer}`
enum.
This makes spacer rows unambiguous, removes “tuple semantics” from call
sites, and keeps the
scroll anchoring model clearer and aligned with the viewport/history
design notes.
Changes:
- Introduce `TranscriptLineMeta` and update `TranscriptScroll` helpers
to consume it.
- Update `App::build_transcript_lines` and downstream consumers
(scrolling, row classification, ANSI rendering).
- Refresh scrolling module docs to describe anchors + spacer semantics
in context.
- Add tests and docs about the behavior
Tests:
- just fmt
- cargo test -p codex-tui2 tui::scrolling
Manual testing:
- Scroll the inline transcript with mouse wheel + PgUp/PgDn/Home/End,
then resize the terminal while staying scrolled up; verify the same
anchored content stays in view and you don’t jump to bottom
unexpectedly.
- Create a gap case (multiple non-continuation cells) and scroll so a
blank spacer row is at/near the top; verify scrolling doesn’t get stuck
on spacers and still anchors to nearby real lines.
- Start a selection while the assistant is streaming; verify the view
stops auto-following, the selection stays on the intended content, and
subsequent scrolling still behaves normally.
- Exit the TUI and confirm scrollback rendering still styles user rows
as blocks (background padding) and non-user rows as expected.
This commit is contained in:
1 parent
b9d1a087ee
commit
f074e5706b
3 files changed
+414
-148
No files matched your search
+47
-148
@@ -21,6 +21,8 @@ use crate::skill_error_prompt::SkillErrorPromptOutcome;
|
|||||||
use crate::skill_error_prompt::run_skill_error_prompt;
|
use crate::skill_error_prompt::run_skill_error_prompt;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
use crate::tui::TuiEvent;
|
use crate::tui::TuiEvent;
|
||||||
|
use crate::tui::scrolling::TranscriptLineMeta;
|
||||||
|
use crate::tui::scrolling::TranscriptScroll;
|
||||||
use crate::update_action::UpdateAction;
|
use crate::update_action::UpdateAction;
|
||||||
use crate::wrapping::RtOptions;
|
use crate::wrapping::RtOptions;
|
||||||
use crate::wrapping::word_wrap_line;
|
use crate::wrapping::word_wrap_line;
|
||||||
@@ -339,21 +341,6 @@ pub(crate) struct App {
|
|||||||
skip_world_writable_scan_once: bool,
|
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.
|
/// Content-relative selection within the inline transcript viewport.
|
||||||
///
|
///
|
||||||
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
|
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
|
||||||
@@ -494,7 +481,7 @@ impl App {
|
|||||||
file_search,
|
file_search,
|
||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
transcript_cells: Vec::new(),
|
transcript_cells: Vec::new(),
|
||||||
transcript_scroll: TranscriptScroll::ToBottom,
|
transcript_scroll: TranscriptScroll::default(),
|
||||||
transcript_selection: TranscriptSelection::default(),
|
transcript_selection: TranscriptSelection::default(),
|
||||||
transcript_view_top: 0,
|
transcript_view_top: 0,
|
||||||
transcript_total_lines: 0,
|
transcript_total_lines: 0,
|
||||||
@@ -562,13 +549,13 @@ impl App {
|
|||||||
let session_lines = if width == 0 {
|
let session_lines = if width == 0 {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} 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<bool> = app
|
let is_user_cell: Vec<bool> = app
|
||||||
.transcript_cells
|
.transcript_cells
|
||||||
.iter()
|
.iter()
|
||||||
.map(|cell| cell.as_any().is::<UserHistoryCell>())
|
.map(|cell| cell.as_any().is::<UserHistoryCell>())
|
||||||
.collect();
|
.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()?;
|
tui.terminal.clear()?;
|
||||||
@@ -676,7 +663,7 @@ impl App {
|
|||||||
) -> u16 {
|
) -> u16 {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
self.transcript_scroll = TranscriptScroll::ToBottom;
|
self.transcript_scroll = TranscriptScroll::default();
|
||||||
self.transcript_view_top = 0;
|
self.transcript_view_top = 0;
|
||||||
self.transcript_total_lines = 0;
|
self.transcript_total_lines = 0;
|
||||||
return area.bottom().saturating_sub(chat_height);
|
return area.bottom().saturating_sub(chat_height);
|
||||||
@@ -685,7 +672,7 @@ impl App {
|
|||||||
let chat_height = chat_height.min(area.height);
|
let chat_height = chat_height.min(area.height);
|
||||||
let max_transcript_height = area.height.saturating_sub(chat_height);
|
let max_transcript_height = area.height.saturating_sub(chat_height);
|
||||||
if max_transcript_height == 0 {
|
if max_transcript_height == 0 {
|
||||||
self.transcript_scroll = TranscriptScroll::ToBottom;
|
self.transcript_scroll = TranscriptScroll::default();
|
||||||
self.transcript_view_top = 0;
|
self.transcript_view_top = 0;
|
||||||
self.transcript_total_lines = 0;
|
self.transcript_total_lines = 0;
|
||||||
return area.y;
|
return area.y;
|
||||||
@@ -698,10 +685,10 @@ impl App {
|
|||||||
height: max_transcript_height,
|
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() {
|
if lines.is_empty() {
|
||||||
Clear.render_ref(transcript_area, frame.buffer);
|
Clear.render_ref(transcript_area, frame.buffer);
|
||||||
self.transcript_scroll = TranscriptScroll::ToBottom;
|
self.transcript_scroll = TranscriptScroll::default();
|
||||||
self.transcript_view_top = 0;
|
self.transcript_view_top = 0;
|
||||||
self.transcript_total_lines = 0;
|
self.transcript_total_lines = 0;
|
||||||
return area.y;
|
return area.y;
|
||||||
@@ -709,7 +696,7 @@ impl App {
|
|||||||
|
|
||||||
let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize);
|
let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize);
|
||||||
if wrapped.is_empty() {
|
if wrapped.is_empty() {
|
||||||
self.transcript_scroll = TranscriptScroll::ToBottom;
|
self.transcript_scroll = TranscriptScroll::default();
|
||||||
self.transcript_view_top = 0;
|
self.transcript_view_top = 0;
|
||||||
self.transcript_total_lines = 0;
|
self.transcript_total_lines = 0;
|
||||||
return area.y;
|
return area.y;
|
||||||
@@ -731,10 +718,10 @@ impl App {
|
|||||||
.initial_indent(base_opts.subsequent_indent.clone())
|
.initial_indent(base_opts.subsequent_indent.clone())
|
||||||
};
|
};
|
||||||
let seg_count = word_wrap_line(line, opts).len();
|
let seg_count = word_wrap_line(line, opts).len();
|
||||||
let is_user_row = meta
|
let is_user_row = line_meta
|
||||||
.get(idx)
|
.get(idx)
|
||||||
.and_then(Option::as_ref)
|
.and_then(TranscriptLineMeta::cell_index)
|
||||||
.map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false))
|
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count));
|
wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count));
|
||||||
first = false;
|
first = false;
|
||||||
@@ -745,30 +732,8 @@ impl App {
|
|||||||
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
|
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
|
||||||
let max_start = total_lines.saturating_sub(max_visible);
|
let max_start = total_lines.saturating_sub(max_visible);
|
||||||
|
|
||||||
let top_offset = match self.transcript_scroll {
|
let (scroll_state, top_offset) = self.transcript_scroll.resolve_top(&line_meta, max_start);
|
||||||
TranscriptScroll::ToBottom => max_start,
|
self.transcript_scroll = scroll_state;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.transcript_view_top = top_offset;
|
self.transcript_view_top = top_offset;
|
||||||
|
|
||||||
let transcript_visible_height = max_visible as u16;
|
let transcript_visible_height = max_visible as u16;
|
||||||
@@ -974,69 +939,10 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width);
|
let (_, line_meta) = Self::build_transcript_lines(&self.transcript_cells, width);
|
||||||
let total_lines = lines.len();
|
self.transcript_scroll =
|
||||||
if total_lines <= visible_lines {
|
self.transcript_scroll
|
||||||
self.transcript_scroll = TranscriptScroll::ToBottom;
|
.scrolled_by(delta_lines, &line_meta, visible_lines);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tui.frame_requester().schedule_frame();
|
tui.frame_requester().schedule_frame();
|
||||||
}
|
}
|
||||||
@@ -1053,8 +959,8 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width);
|
let (lines, line_meta) = Self::build_transcript_lines(&self.transcript_cells, width);
|
||||||
if lines.is_empty() || meta.is_empty() {
|
if lines.is_empty() || line_meta.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,22 +979,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut anchor = None;
|
if let Some(scroll_state) = TranscriptScroll::anchor_for(&line_meta, top_offset) {
|
||||||
if let Some((cell_index, line_in_cell)) = meta.iter().skip(top_offset).flatten().next() {
|
self.transcript_scroll = scroll_state;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,16 +988,17 @@ impl App {
|
|||||||
///
|
///
|
||||||
/// Returns both the visible `Line` buffer and a parallel metadata vector
|
/// Returns both the visible `Line` buffer and a parallel metadata vector
|
||||||
/// that maps each line back to its originating `(cell_index, line_in_cell)`
|
/// 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
|
/// pair (see `TranscriptLineMeta::CellLine`), or `TranscriptLineMeta::Spacer` for
|
||||||
/// to a specific history cell even as new content arrives or the viewport
|
/// synthetic spacer rows inserted between cells. This allows the scroll state
|
||||||
/// size changes, and gives exit transcript renderers enough structure to
|
/// to anchor to a specific history cell even as new content arrives or the
|
||||||
/// style user rows differently from agent rows.
|
/// viewport size changes, and gives exit transcript renderers enough structure
|
||||||
|
/// to style user rows differently from agent rows.
|
||||||
fn build_transcript_lines(
|
fn build_transcript_lines(
|
||||||
cells: &[Arc<dyn HistoryCell>],
|
cells: &[Arc<dyn HistoryCell>],
|
||||||
width: u16,
|
width: u16,
|
||||||
) -> (Vec<Line<'static>>, Vec<Option<(usize, usize)>>) {
|
) -> (Vec<Line<'static>>, Vec<TranscriptLineMeta>) {
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
let mut meta: Vec<Option<(usize, usize)>> = Vec::new();
|
let mut line_meta: Vec<TranscriptLineMeta> = Vec::new();
|
||||||
let mut has_emitted_lines = false;
|
let mut has_emitted_lines = false;
|
||||||
|
|
||||||
for (cell_index, cell) in cells.iter().enumerate() {
|
for (cell_index, cell) in cells.iter().enumerate() {
|
||||||
@@ -1117,19 +1010,22 @@ impl App {
|
|||||||
if !cell.is_stream_continuation() {
|
if !cell.is_stream_continuation() {
|
||||||
if has_emitted_lines {
|
if has_emitted_lines {
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
meta.push(None);
|
line_meta.push(TranscriptLineMeta::Spacer);
|
||||||
} else {
|
} else {
|
||||||
has_emitted_lines = true;
|
has_emitted_lines = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (line_in_cell, line) in cell_lines.into_iter().enumerate() {
|
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.push(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(lines, meta)
|
(lines, line_meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render flattened transcript lines into ANSI strings suitable for
|
/// Render flattened transcript lines into ANSI strings suitable for
|
||||||
@@ -1144,7 +1040,7 @@ impl App {
|
|||||||
/// and tools see consistent escape sequences.
|
/// and tools see consistent escape sequences.
|
||||||
fn render_lines_to_ansi(
|
fn render_lines_to_ansi(
|
||||||
lines: &[Line<'static>],
|
lines: &[Line<'static>],
|
||||||
meta: &[Option<(usize, usize)>],
|
line_meta: &[TranscriptLineMeta],
|
||||||
is_user_cell: &[bool],
|
is_user_cell: &[bool],
|
||||||
width: u16,
|
width: u16,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
@@ -1152,10 +1048,10 @@ impl App {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, line)| {
|
.map(|(idx, line)| {
|
||||||
let is_user_row = meta
|
let is_user_row = line_meta
|
||||||
.get(idx)
|
.get(idx)
|
||||||
.and_then(|entry| entry.as_ref())
|
.and_then(TranscriptLineMeta::cell_index)
|
||||||
.map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false))
|
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
|
let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
|
||||||
@@ -2262,7 +2158,7 @@ mod tests {
|
|||||||
active_profile: None,
|
active_profile: None,
|
||||||
file_search,
|
file_search,
|
||||||
transcript_cells: Vec::new(),
|
transcript_cells: Vec::new(),
|
||||||
transcript_scroll: TranscriptScroll::ToBottom,
|
transcript_scroll: TranscriptScroll::default(),
|
||||||
transcript_selection: TranscriptSelection::default(),
|
transcript_selection: TranscriptSelection::default(),
|
||||||
transcript_view_top: 0,
|
transcript_view_top: 0,
|
||||||
transcript_total_lines: 0,
|
transcript_total_lines: 0,
|
||||||
@@ -2306,7 +2202,7 @@ mod tests {
|
|||||||
active_profile: None,
|
active_profile: None,
|
||||||
file_search,
|
file_search,
|
||||||
transcript_cells: Vec::new(),
|
transcript_cells: Vec::new(),
|
||||||
transcript_scroll: TranscriptScroll::ToBottom,
|
transcript_scroll: TranscriptScroll::default(),
|
||||||
transcript_selection: TranscriptSelection::default(),
|
transcript_selection: TranscriptSelection::default(),
|
||||||
transcript_view_top: 0,
|
transcript_view_top: 0,
|
||||||
transcript_total_lines: 0,
|
transcript_total_lines: 0,
|
||||||
@@ -2576,11 +2472,14 @@ mod tests {
|
|||||||
fn render_lines_to_ansi_pads_user_rows_to_full_width() {
|
fn render_lines_to_ansi_pads_user_rows_to_full_width() {
|
||||||
let line: Line<'static> = Line::from("hi");
|
let line: Line<'static> = Line::from("hi");
|
||||||
let lines = vec![line];
|
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 is_user_cell = vec![true];
|
||||||
let width: u16 = 10;
|
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_eq!(rendered.len(), 1);
|
||||||
assert!(rendered[0].contains("hi"));
|
assert!(rendered[0].contains("hi"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ use crate::tui::job_control::SuspendContext;
|
|||||||
mod frame_requester;
|
mod frame_requester;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod job_control;
|
mod job_control;
|
||||||
|
pub(crate) mod scrolling;
|
||||||
|
|
||||||
/// A type alias for the terminal type used in this application
|
/// A type alias for the terminal type used in this application
|
||||||
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
||||||
|
|||||||
@@ -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<usize> {
|
||||||
|
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<Self> {
|
||||||
|
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<usize> {
|
||||||
|
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<TranscriptLineMeta> {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user