mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
fix(tui): replay history during resize reflow
This commit is contained in:
@@ -223,20 +223,6 @@ impl App {
|
||||
crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow)
|
||||
}
|
||||
|
||||
fn clear_terminal_for_resize_replay(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
if tui.is_alt_screen_active() {
|
||||
tui.terminal.clear_visible_screen()?;
|
||||
} else {
|
||||
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
|
||||
}
|
||||
let mut area = tui.terminal.viewport_area;
|
||||
if area.y > 0 {
|
||||
area.y = 0;
|
||||
tui.terminal.set_viewport_area(area);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish stream consolidation by repairing any resize work that happened during streaming.
|
||||
///
|
||||
/// This is called after agent-message stream cells have either been replaced by an
|
||||
@@ -312,7 +298,7 @@ impl App {
|
||||
}
|
||||
// Reflow immediately on the draw that observes the resize. Leaving a debounced
|
||||
// gap lets the terminal emulator's native scrollback reflow show stale rows under
|
||||
// Codex's next table repaint, especially in xterm.js-backed split panes.
|
||||
// Codex's next table repaint.
|
||||
self.transcript_reflow.schedule_immediate();
|
||||
frame_requester.schedule_frame();
|
||||
} else if !self.terminal_resize_reflow_enabled() && width.changed {
|
||||
@@ -417,6 +403,7 @@ impl App {
|
||||
if self.transcript_cells.is_empty() {
|
||||
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
|
||||
tui.clear_pending_history_lines();
|
||||
tui.replay_history_lines_after_resize(Vec::new());
|
||||
self.reset_history_emission_state();
|
||||
return Ok(width);
|
||||
}
|
||||
@@ -426,15 +413,9 @@ impl App {
|
||||
|
||||
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
|
||||
tui.clear_pending_history_lines();
|
||||
self.clear_terminal_for_resize_replay(tui)?;
|
||||
|
||||
self.deferred_history_lines.clear();
|
||||
if !reflowed_lines.is_empty() {
|
||||
tui.insert_history_lines_with_wrap_policy(
|
||||
reflowed_lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
tui.replay_history_lines_after_resize(reflowed_lines);
|
||||
|
||||
Ok(width)
|
||||
}
|
||||
|
||||
@@ -116,40 +116,8 @@ where
|
||||
let last_cursor_pos = terminal.last_known_cursor_pos;
|
||||
let writer = terminal.backend_mut();
|
||||
|
||||
// Pre-wrap lines for terminal scrollback. Three paths:
|
||||
//
|
||||
// - URL-only-ish lines are kept intact (no hard newlines inserted) so that
|
||||
// terminal emulators can match them as clickable links. The
|
||||
// terminal will character-wrap these lines at the viewport
|
||||
// boundary.
|
||||
// - Mixed lines (URL + non-URL prose) are adaptively wrapped so
|
||||
// non-URL text still wraps naturally while URL tokens remain
|
||||
// unsplit.
|
||||
// - Non-URL lines also flow through adaptive wrapping; behavior is
|
||||
// equivalent to standard wrapping when no URL is present.
|
||||
let wrap_width = area.width.max(1) as usize;
|
||||
let mut wrapped = Vec::new();
|
||||
let mut wrapped_rows = 0usize;
|
||||
|
||||
for line in &lines {
|
||||
let line_wrapped = match wrap_policy {
|
||||
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
|
||||
HistoryLineWrapPolicy::PreWrap
|
||||
if is_preformatted_box_table_line(line)
|
||||
|| (line_contains_url_like(line)
|
||||
&& !line_has_mixed_url_and_non_url_tokens(line)) =>
|
||||
{
|
||||
vec![line.clone()]
|
||||
}
|
||||
HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)),
|
||||
};
|
||||
wrapped_rows += line_wrapped
|
||||
.iter()
|
||||
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
|
||||
.sum::<usize>();
|
||||
wrapped.extend(line_wrapped);
|
||||
}
|
||||
let wrapped_lines = wrapped_rows as u16;
|
||||
let (wrapped, wrapped_lines) = wrap_history_lines(&lines, wrap_width, wrap_policy);
|
||||
|
||||
match mode {
|
||||
InsertHistoryMode::Zellij => {
|
||||
@@ -248,6 +216,84 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replay history rows after the caller has cleared terminal scrollback/screen.
|
||||
///
|
||||
/// Unlike [`insert_history_lines_with_mode`], this does not use scroll regions or
|
||||
/// reverse-index insertion. Resize reflow already owns replacing the visible
|
||||
/// transcript from source-backed cells, so this path writes the rebuilt rows
|
||||
/// from the top of the terminal and leaves the inline viewport directly after
|
||||
/// the visible history tail.
|
||||
pub(crate) fn replay_history_lines_after_clear<B>(
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
lines: Vec<Line<'static>>,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
let mut area = terminal.viewport_area;
|
||||
area.width = screen_size.width;
|
||||
area.y = 0;
|
||||
|
||||
let wrap_width = area.width.max(1) as usize;
|
||||
let (wrapped, wrapped_rows) =
|
||||
wrap_history_lines(&lines, wrap_width, HistoryLineWrapPolicy::PreWrap);
|
||||
let max_visible_history_rows = screen_size.height.saturating_sub(area.height);
|
||||
area.y = wrapped_rows.min(max_visible_history_rows);
|
||||
terminal.set_viewport_area(area);
|
||||
|
||||
if wrapped_rows == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let writer = terminal.backend_mut();
|
||||
queue!(writer, MoveTo(/*x*/ 0, /*y*/ 0))?;
|
||||
for (index, line) in wrapped.iter().enumerate() {
|
||||
if index > 0 {
|
||||
queue!(writer, Print("\r\n"))?;
|
||||
}
|
||||
write_history_line(writer, line, wrap_width)?;
|
||||
}
|
||||
for _ in 0..area.height {
|
||||
queue!(writer, Print("\r\n"), Clear(ClearType::UntilNewLine))?;
|
||||
}
|
||||
|
||||
let _ = writer;
|
||||
terminal.note_history_rows_inserted(wrapped_rows);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wrap_history_lines<'a>(
|
||||
lines: &'a [Line<'a>],
|
||||
wrap_width: usize,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> (Vec<Line<'a>>, u16) {
|
||||
let mut wrapped = Vec::new();
|
||||
let mut wrapped_rows = 0usize;
|
||||
|
||||
for line in lines {
|
||||
let line_wrapped = match wrap_policy {
|
||||
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
|
||||
HistoryLineWrapPolicy::PreWrap
|
||||
if is_preformatted_box_table_line(line)
|
||||
|| (line_contains_url_like(line)
|
||||
&& !line_has_mixed_url_and_non_url_tokens(line)) =>
|
||||
{
|
||||
vec![line.clone()]
|
||||
}
|
||||
HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)),
|
||||
};
|
||||
wrapped_rows += line_wrapped
|
||||
.iter()
|
||||
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
|
||||
.sum::<usize>();
|
||||
wrapped.extend(line_wrapped);
|
||||
}
|
||||
|
||||
(wrapped, wrapped_rows as u16)
|
||||
}
|
||||
|
||||
fn is_preformatted_box_table_line(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
@@ -924,4 +970,65 @@ mod tests {
|
||||
assert_eq!(term.viewport_area, Rect::new(0, 5, width, 2));
|
||||
assert_eq!(term.visible_history_rows(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_resize_replay_replaces_cleared_history_without_incremental_insert() {
|
||||
let width: u16 = 36;
|
||||
let height: u16 = 14;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
term.set_viewport_area(Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, width, /*height*/ 3,
|
||||
));
|
||||
|
||||
insert_history_lines(&mut term, vec![Line::from("stale row that must disappear")])
|
||||
.expect("insert stale history");
|
||||
term.clear_scrollback_and_visible_screen_ansi()
|
||||
.expect("clear before replay");
|
||||
|
||||
let lines = vec![
|
||||
Line::from("┌─────────┬────────────────────┐"),
|
||||
Line::from("│ Label │ Alpha │"),
|
||||
Line::from("│ Content │ first value │"),
|
||||
Line::from("├─────────┼────────────────────┤"),
|
||||
Line::from("│ Label │ Beta │"),
|
||||
Line::from("│ Content │ second value │"),
|
||||
Line::from("└─────────┴────────────────────┘"),
|
||||
];
|
||||
replay_history_lines_after_clear(&mut term, lines).expect("replay history");
|
||||
|
||||
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
|
||||
assert_eq!(term.viewport_area, Rect::new(0, 7, width, 3));
|
||||
assert_eq!(term.visible_history_rows(), 7);
|
||||
assert!(rows[0].contains("┌─────────┬────────────────────┐"));
|
||||
assert!(rows[6].contains("└─────────┴────────────────────┘"));
|
||||
assert!(
|
||||
!rows.iter().any(|row| row.contains("stale row")),
|
||||
"stale history survived resize replay: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_resize_replay_keeps_visible_tail_above_viewport_when_history_overflows() {
|
||||
let width: u16 = 20;
|
||||
let height: u16 = 8;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
term.set_viewport_area(Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, width, /*height*/ 2,
|
||||
));
|
||||
|
||||
let lines = (1..=10)
|
||||
.map(|index| Line::from(format!("history row {index:02}")))
|
||||
.collect();
|
||||
replay_history_lines_after_clear(&mut term, lines).expect("replay overflowing history");
|
||||
|
||||
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
|
||||
assert_eq!(term.viewport_area, Rect::new(0, 6, width, 2));
|
||||
assert_eq!(term.visible_history_rows(), 6);
|
||||
assert!(rows[0].contains("history row 05"), "rows: {rows:?}");
|
||||
assert!(rows[5].contains("history row 10"), "rows: {rows:?}");
|
||||
assert!(rows[6].trim().is_empty(), "rows: {rows:?}");
|
||||
assert!(rows[7].trim().is_empty(), "rows: {rows:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+40
-4
@@ -420,6 +420,7 @@ pub struct Tui {
|
||||
event_broker: Arc<EventBroker>,
|
||||
pub(crate) terminal: Terminal,
|
||||
pending_history_lines: Vec<PendingHistoryLines>,
|
||||
pending_resize_replay_lines: Option<Vec<Line<'static>>>,
|
||||
alt_saved_viewport: Option<ratatui::layout::Rect>,
|
||||
#[cfg(unix)]
|
||||
suspend_context: SuspendContext,
|
||||
@@ -475,6 +476,7 @@ impl Tui {
|
||||
event_broker: Arc::new(EventBroker::new()),
|
||||
terminal,
|
||||
pending_history_lines: vec![],
|
||||
pending_resize_replay_lines: None,
|
||||
alt_saved_viewport: None,
|
||||
#[cfg(unix)]
|
||||
suspend_context: SuspendContext::new(),
|
||||
@@ -671,6 +673,12 @@ impl Tui {
|
||||
self.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn replay_history_lines_after_resize(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.pending_history_lines.clear();
|
||||
self.pending_resize_replay_lines = Some(lines);
|
||||
self.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub fn clear_pending_history_lines(&mut self) {
|
||||
self.pending_history_lines.clear();
|
||||
}
|
||||
@@ -812,6 +820,25 @@ impl Tui {
|
||||
Ok(is_zellij)
|
||||
}
|
||||
|
||||
fn flush_pending_resize_replay(
|
||||
terminal: &mut Terminal,
|
||||
pending_resize_replay_lines: &mut Option<Vec<Line<'static>>>,
|
||||
alt_screen_active: bool,
|
||||
) -> Result<bool> {
|
||||
let Some(lines) = pending_resize_replay_lines.take() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if alt_screen_active {
|
||||
terminal.clear_visible_screen()?;
|
||||
} else {
|
||||
terminal.clear_scrollback_and_visible_screen_ansi()?;
|
||||
}
|
||||
crate::insert_history::replay_history_lines_after_clear(terminal, lines)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
height: u16,
|
||||
@@ -898,12 +925,21 @@ impl Tui {
|
||||
let terminal = &mut self.terminal;
|
||||
let mut needs_full_repaint =
|
||||
Self::update_inline_viewport_for_resize_reflow(terminal, height, self.is_zellij)?;
|
||||
let flushed_history = Self::flush_pending_history_lines(
|
||||
let replayed_history = Self::flush_pending_resize_replay(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
&mut self.pending_resize_replay_lines,
|
||||
self.alt_screen_active.load(Ordering::Relaxed),
|
||||
)?;
|
||||
needs_full_repaint |= flushed_history;
|
||||
if replayed_history {
|
||||
self.pending_history_lines.clear();
|
||||
} else {
|
||||
needs_full_repaint |= Self::flush_pending_history_lines(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
)?;
|
||||
}
|
||||
needs_full_repaint |= replayed_history;
|
||||
|
||||
if needs_full_repaint {
|
||||
terminal.invalidate_viewport();
|
||||
|
||||
Reference in New Issue
Block a user