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:
Benjamin Carlsson
2026-08-19 21:42:38 +00:00
committed by copyberry
parent 4bb7804a23
commit da6e68951b
5 changed files with 284 additions and 34 deletions

View File

@@ -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");

View File

@@ -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,
)?;

View File

@@ -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,

View 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;

View 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
");
}