mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Preserve inline TUI scrollback in Windows Terminal (#39619)
## Why Windows Terminal can discard rows scrolled through a partial DEC scroll region instead of retaining them in terminal scrollback. ## What changed - Detect Windows Terminal, including via `WT_SESSION`, and use full-screen scrolling for inline viewport growth and history insertion. - Keep the existing terminal-wrapped history behavior for Zellij while centralizing terminal-specific scrollback selection. ## Testing Add VT100-backed tests covering strategy selection and preservation of scrollback during history insertion and viewport growth. GitOrigin-RevId: 209a7a8053a1fcf911d6e0aee764e563b8725bd1
This commit is contained in:
committed by
copyberry
parent
4bb7804a23
commit
da6e68951b
@@ -48,13 +48,12 @@ pub enum HistoryLineWrapPolicy {
|
||||
|
||||
/// Selects the terminal escape strategy used when writing history above the viewport.
|
||||
///
|
||||
/// Raw lines intentionally remain unbroken so terminal selection copies their source faithfully.
|
||||
/// Zellij does not constrain soft-wrapped continuation rows to Codex's scroll region, so its raw
|
||||
/// path appends history through the terminal and reserves blank rows for the next viewport draw.
|
||||
/// Full-screen insertion preserves terminal-native scrollback when partial scroll regions are
|
||||
/// unreliable and keeps terminal-managed soft wrapping intact for Zellij.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InsertHistoryMode {
|
||||
Standard,
|
||||
ZellijRaw,
|
||||
FullScreen,
|
||||
}
|
||||
|
||||
/// Insert `lines` above the viewport using the terminal's backend writer
|
||||
@@ -133,7 +132,7 @@ where
|
||||
let (wrapped, wrapped_rows) = wrap_history_hyperlink_lines(lines, wrap_width, wrap_policy);
|
||||
let wrapped_lines = wrapped_rows as u16;
|
||||
match mode {
|
||||
InsertHistoryMode::ZellijRaw => {
|
||||
InsertHistoryMode::FullScreen => {
|
||||
// The existing viewport is immediately replaced in the same draw pass. Clear it
|
||||
// before terminal scrolling can move composer contents into scrollback.
|
||||
terminal.clear_after_position(area.as_position())?;
|
||||
@@ -1033,7 +1032,7 @@ mod tests {
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
&mut term,
|
||||
vec![line],
|
||||
InsertHistoryMode::ZellijRaw,
|
||||
InsertHistoryMode::FullScreen,
|
||||
HistoryLineWrapPolicy::Terminal,
|
||||
)
|
||||
.expect("insert Zellij raw history");
|
||||
@@ -1069,7 +1068,7 @@ mod tests {
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
&mut term,
|
||||
vec![line],
|
||||
InsertHistoryMode::ZellijRaw,
|
||||
InsertHistoryMode::FullScreen,
|
||||
HistoryLineWrapPolicy::Terminal,
|
||||
)
|
||||
.expect("replay Zellij raw history");
|
||||
|
||||
@@ -48,7 +48,6 @@ use self::input_boundary::terminal_input_is_readable;
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use crate::notifications::DesktopNotificationBackend;
|
||||
use crate::notifications::detect_backend;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
@@ -58,6 +57,7 @@ use crate::tui::event_stream::TuiEventStream;
|
||||
#[cfg(unix)]
|
||||
use crate::tui::job_control::SuspendContext;
|
||||
use crate::tui::screen_size::ScreenSizePolicy;
|
||||
use crate::tui::scrollback::ScrollbackStrategy;
|
||||
use codex_config::types::NotificationCondition;
|
||||
use codex_config::types::NotificationMethod;
|
||||
|
||||
@@ -70,6 +70,7 @@ mod input_boundary;
|
||||
mod job_control;
|
||||
mod keyboard_modes;
|
||||
mod screen_size;
|
||||
mod scrollback;
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "tui_startup_tests.rs"]
|
||||
mod startup_tests;
|
||||
@@ -594,8 +595,7 @@ pub struct Tui {
|
||||
enhanced_keys_supported: bool,
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
notification_condition: NotificationCondition,
|
||||
// Raw terminal-wrapped history needs a non-scroll-region insertion path in Zellij.
|
||||
is_zellij: bool,
|
||||
scrollback: ScrollbackStrategy,
|
||||
// When false, enter_alt_screen() becomes a no-op.
|
||||
alt_screen_enabled: bool,
|
||||
// Keeps unmanaged process stderr writes out of the inline viewport.
|
||||
@@ -631,7 +631,7 @@ impl Tui {
|
||||
// Cache this to avoid contention with the event reader.
|
||||
supports_color::on_cached(supports_color::Stream::Stdout);
|
||||
let _ = crate::terminal_palette::default_colors();
|
||||
let is_zellij = codex_terminal_detection::terminal_info().is_zellij();
|
||||
let scrollback = ScrollbackStrategy::detect(&codex_terminal_detection::terminal_info());
|
||||
|
||||
Self {
|
||||
frame_requester,
|
||||
@@ -650,7 +650,7 @@ impl Tui {
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
||||
notification_condition: NotificationCondition::default(),
|
||||
is_zellij,
|
||||
scrollback,
|
||||
alt_screen_enabled: true,
|
||||
_stderr_guard: stderr_guard,
|
||||
}
|
||||
@@ -893,6 +893,7 @@ impl Tui {
|
||||
terminal: &mut Terminal,
|
||||
height: u16,
|
||||
screen_size: Size,
|
||||
scrollback: ScrollbackStrategy,
|
||||
) -> Result<bool> {
|
||||
let terminal_height_shrank = screen_size.height < terminal.last_known_screen_size.height;
|
||||
let terminal_height_grew = screen_size.height > terminal.last_known_screen_size.height;
|
||||
@@ -908,9 +909,7 @@ impl Tui {
|
||||
if area.bottom() > screen_size.height {
|
||||
let scroll_by = area.bottom() - screen_size.height;
|
||||
if !terminal_height_shrank {
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..area.top(), scroll_by)?;
|
||||
scrollback.grow_viewport(terminal, area.top(), screen_size, scroll_by)?;
|
||||
}
|
||||
area.y = screen_size.height - area.height;
|
||||
} else if terminal_height_grew && viewport_was_bottom_aligned {
|
||||
@@ -931,7 +930,7 @@ impl Tui {
|
||||
fn flush_pending_history_lines(
|
||||
terminal: &mut Terminal,
|
||||
pending_history_lines: &mut Vec<PendingHistoryLines>,
|
||||
is_zellij: bool,
|
||||
scrollback: ScrollbackStrategy,
|
||||
screen_size: Size,
|
||||
) -> Result<()> {
|
||||
if pending_history_lines.is_empty() {
|
||||
@@ -939,11 +938,7 @@ impl Tui {
|
||||
}
|
||||
|
||||
for batch in pending_history_lines.iter() {
|
||||
let mode = if is_zellij && batch.wrap_policy == HistoryLineWrapPolicy::Terminal {
|
||||
InsertHistoryMode::ZellijRaw
|
||||
} else {
|
||||
InsertHistoryMode::Standard
|
||||
};
|
||||
let mode = scrollback.history_insertion_mode(batch.wrap_policy);
|
||||
crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
&batch.lines,
|
||||
@@ -992,9 +987,12 @@ impl Tui {
|
||||
area.width = screen_size.width;
|
||||
// If the viewport has expanded, scroll everything else up to make room.
|
||||
if area.bottom() > screen_size.height {
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..area.top(), area.bottom() - screen_size.height)?;
|
||||
self.scrollback.grow_viewport(
|
||||
terminal,
|
||||
area.top(),
|
||||
screen_size,
|
||||
area.bottom() - screen_size.height,
|
||||
)?;
|
||||
area.y = screen_size.height - area.height;
|
||||
}
|
||||
if area != terminal.viewport_area {
|
||||
@@ -1007,7 +1005,7 @@ impl Tui {
|
||||
Self::flush_pending_history_lines(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
self.scrollback,
|
||||
screen_size,
|
||||
)?;
|
||||
|
||||
@@ -1114,8 +1112,12 @@ impl Tui {
|
||||
}
|
||||
|
||||
let terminal = &mut self.terminal;
|
||||
let needs_full_repaint =
|
||||
Self::update_inline_viewport_for_resize_reflow(terminal, height, screen_size)?;
|
||||
let needs_full_repaint = Self::update_inline_viewport_for_resize_reflow(
|
||||
terminal,
|
||||
height,
|
||||
screen_size,
|
||||
self.scrollback,
|
||||
)?;
|
||||
// A zero- or one-row history region cannot isolate raw history writes from the
|
||||
// viewport, so replayed rows can leave stale cells inside the composer.
|
||||
let history_can_overlap_viewport =
|
||||
@@ -1123,7 +1125,7 @@ impl Tui {
|
||||
Self::flush_pending_history_lines(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
self.scrollback,
|
||||
screen_size,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -31,14 +31,10 @@ impl Tui {
|
||||
Self::flush_pending_history_lines(
|
||||
&mut self.terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
self.scrollback,
|
||||
screen_size,
|
||||
)?;
|
||||
let mode = if self.is_zellij && wrap_policy == HistoryLineWrapPolicy::Terminal {
|
||||
InsertHistoryMode::ZellijRaw
|
||||
} else {
|
||||
InsertHistoryMode::Standard
|
||||
};
|
||||
let mode = self.scrollback.history_insertion_mode(wrap_policy);
|
||||
let replaced = replace_visible_terminal_history_tail(
|
||||
&mut self.terminal,
|
||||
previous_lines,
|
||||
|
||||
84
codex-rs/tui/src/tui/scrollback.rs
Normal file
84
codex-rs/tui/src/tui/scrollback.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! Choose terminal-safe strategies for growing the viewport and inserting history.
|
||||
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::queue;
|
||||
use crossterm::style::Print;
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Size;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum ScrollbackStrategy {
|
||||
Standard,
|
||||
Zellij,
|
||||
FullScreen,
|
||||
}
|
||||
|
||||
impl ScrollbackStrategy {
|
||||
pub(super) fn detect(terminal: &TerminalInfo) -> Self {
|
||||
if terminal.is_zellij() {
|
||||
Self::Zellij
|
||||
} else if terminal.name == TerminalName::WindowsTerminal
|
||||
|| std::env::var_os("WT_SESSION").is_some()
|
||||
{
|
||||
Self::FullScreen
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn history_insertion_mode(
|
||||
self,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> InsertHistoryMode {
|
||||
match self {
|
||||
Self::FullScreen => InsertHistoryMode::FullScreen,
|
||||
Self::Zellij if wrap_policy == HistoryLineWrapPolicy::Terminal => {
|
||||
InsertHistoryMode::FullScreen
|
||||
}
|
||||
Self::Standard | Self::Zellij => InsertHistoryMode::Standard,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn grow_viewport<B>(
|
||||
self,
|
||||
terminal: &mut Terminal<B>,
|
||||
viewport_top: u16,
|
||||
screen_size: Size,
|
||||
scroll_by: u16,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
match self {
|
||||
Self::FullScreen => {
|
||||
// Partial DEC scroll regions can discard rows instead of moving them into Windows
|
||||
// Terminal's scrollback. Clear the stale composer, then scroll the entire screen.
|
||||
terminal.clear_after_position(Position::new(/*x*/ 0, viewport_top))?;
|
||||
let writer = terminal.backend_mut();
|
||||
queue!(
|
||||
writer,
|
||||
MoveTo(/*x*/ 0, screen_size.height.saturating_sub(/*rhs*/ 1))
|
||||
)?;
|
||||
for _ in 0..scroll_by {
|
||||
queue!(writer, Print("\r\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Standard | Self::Zellij => terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..viewport_top, scroll_by),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scrollback_tests.rs"]
|
||||
mod tests;
|
||||
169
codex-rs/tui/src/tui/scrollback_tests.rs
Normal file
169
codex-rs/tui/src/tui/scrollback_tests.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use super::ScrollbackStrategy;
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use crate::insert_history::insert_history_lines_with_mode_and_wrap_policy;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::queue;
|
||||
use crossterm::style::Print;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::text::Line;
|
||||
|
||||
#[test]
|
||||
fn windows_terminal_uses_full_screen_unless_zellij_is_active() {
|
||||
let mut terminal = TerminalInfo {
|
||||
name: TerminalName::WindowsTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
};
|
||||
let mut strategy = ScrollbackStrategy::detect(&terminal);
|
||||
|
||||
assert_eq!(
|
||||
[
|
||||
strategy.history_insertion_mode(HistoryLineWrapPolicy::PreWrap),
|
||||
strategy.history_insertion_mode(HistoryLineWrapPolicy::Terminal),
|
||||
],
|
||||
[InsertHistoryMode::FullScreen, InsertHistoryMode::FullScreen]
|
||||
);
|
||||
|
||||
terminal.multiplexer = Some(Multiplexer::Zellij { version: None });
|
||||
strategy = ScrollbackStrategy::detect(&terminal);
|
||||
assert_eq!(strategy, ScrollbackStrategy::Zellij);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zellij_only_uses_full_screen_insertion_for_terminal_wrapped_history() {
|
||||
assert_eq!(
|
||||
[
|
||||
ScrollbackStrategy::Zellij.history_insertion_mode(HistoryLineWrapPolicy::PreWrap),
|
||||
ScrollbackStrategy::Zellij.history_insertion_mode(HistoryLineWrapPolicy::Terminal),
|
||||
],
|
||||
[InsertHistoryMode::Standard, InsertHistoryMode::FullScreen]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_history_insertion_preserves_terminal_scrollback() {
|
||||
let width = 24;
|
||||
let height = 6;
|
||||
let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal with scrollback");
|
||||
terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ height - 2,
|
||||
width,
|
||||
/*height*/ 2,
|
||||
));
|
||||
|
||||
for (row, line) in [
|
||||
"oldest-history-row",
|
||||
"history-row-2",
|
||||
"history-row-3",
|
||||
"history-row-4",
|
||||
"stale-composer-1",
|
||||
"stale-composer-2",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
queue!(
|
||||
terminal.backend_mut(),
|
||||
MoveTo(/*x*/ 0, row as u16),
|
||||
Print(line)
|
||||
)
|
||||
.expect("seed terminal row");
|
||||
}
|
||||
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
&mut terminal,
|
||||
vec![Line::from("new-history-row")],
|
||||
ScrollbackStrategy::FullScreen.history_insertion_mode(HistoryLineWrapPolicy::PreWrap),
|
||||
HistoryLineWrapPolicy::PreWrap,
|
||||
)
|
||||
.expect("insert history through the full screen");
|
||||
|
||||
let visible = terminal.backend().vt100().screen().contents();
|
||||
let mut scrollback_screen = terminal.backend().vt100().screen().clone();
|
||||
scrollback_screen.set_scrollback(/*rows*/ usize::MAX);
|
||||
let scrollback = scrollback_screen.contents();
|
||||
|
||||
insta::assert_snapshot!(format!("SCROLLBACK:\n{scrollback}\nVISIBLE:\n{visible}"), @r"
|
||||
SCROLLBACK:
|
||||
oldest-history-row
|
||||
history-row-2
|
||||
history-row-3
|
||||
history-row-4
|
||||
new-history-row
|
||||
VISIBLE:
|
||||
history-row-2
|
||||
history-row-3
|
||||
history-row-4
|
||||
new-history-row
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_viewport_growth_preserves_terminal_scrollback() {
|
||||
let width = 24;
|
||||
let height = 6;
|
||||
let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal with scrollback");
|
||||
terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ height - 2,
|
||||
width,
|
||||
/*height*/ 2,
|
||||
));
|
||||
|
||||
for (row, line) in [
|
||||
"oldest-history-row",
|
||||
"history-row-2",
|
||||
"history-row-3",
|
||||
"history-row-4",
|
||||
"stale-composer-1",
|
||||
"stale-composer-2",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
queue!(
|
||||
terminal.backend_mut(),
|
||||
MoveTo(/*x*/ 0, row as u16),
|
||||
Print(line)
|
||||
)
|
||||
.expect("seed terminal row");
|
||||
}
|
||||
|
||||
ScrollbackStrategy::FullScreen
|
||||
.grow_viewport(
|
||||
&mut terminal,
|
||||
/*viewport_top*/ height - 2,
|
||||
Size::new(width, height),
|
||||
/*scroll_by*/ 2,
|
||||
)
|
||||
.expect("grow viewport through full-screen scrolling");
|
||||
|
||||
let visible = terminal.backend().vt100().screen().contents();
|
||||
let mut scrollback_screen = terminal.backend().vt100().screen().clone();
|
||||
scrollback_screen.set_scrollback(/*rows*/ usize::MAX);
|
||||
let scrollback = scrollback_screen.contents();
|
||||
|
||||
insta::assert_snapshot!(format!("SCROLLBACK:\n{scrollback}\nVISIBLE:\n{visible}"), @r"
|
||||
SCROLLBACK:
|
||||
oldest-history-row
|
||||
history-row-2
|
||||
history-row-3
|
||||
history-row-4
|
||||
VISIBLE:
|
||||
history-row-3
|
||||
history-row-4
|
||||
");
|
||||
}
|
||||
Reference in New Issue
Block a user