mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +00:00
fix(tui): reflow scrollback on terminal resize (#18575)
Fixes multiple scrollback and terminal resize issues: #5538, #5576, #8352, #12223, #16165, and #15380. ## Why Codex writes finalized transcript output into terminal scrollback after wrapping it for the current viewport width. A later terminal resize could leave that scrollback shaped for the old width, so wider windows kept narrow output and narrower windows could show stale wrapping artifacts until enough new output replaced the visible area. This is also the foundation PR for responsive markdown tables. Table rendering needs finalized transcript content to be width-sensitive after insertion, not only while content is first streaming. Markdown table rendering itself stays in #18576. ## Stack - PR1: resize backlog reflow and interrupt cleanup - #18576: markdown table support ## What Changed - Rebuild source-backed transcript history when the terminal width changes. `terminal_resize_reflow` is introduced through the experimental feature system, but is enabled by default for this rollout so we can validate behavior across real terminals. - Preserve assistant and plan stream source so finalized streaming output can participate in resize reflow after consolidation. - Debounce resize work, but force a final source-backed reflow when a resize happened during active or unconsolidated streaming output. - Clear stale pending history lines on resize so old-width wrapped output is not emitted just before rebuilt scrollback. - Bound replay work with `[tui.terminal_resize_reflow].max_rows`: omitted uses terminal-specific defaults, `0` keeps all rendered rows, and a positive value sets an explicit cap. The cap applies both while initially replaying a resumed transcript into scrollback and when rebuilding scrollback after terminal resize. - Consolidate interrupted assistant streams before cleanup, then clear pending stream output and active-tail state consistently. - Move resize reflow and thread event buffering helpers out of `app.rs` into dedicated TUI modules. - Add focused coverage for resize reflow, feature-gated behavior, streaming source preservation, interrupted output cleanup, unicode-neutral text, terminal-specific row caps, and composer/layout stability. ## Runtime Bounds Resize reflow keeps only the most recent rendered rows when a row cap is active. The default is `auto`, which maps to the detected terminal's default scrollback size where Codex can identify it: VS Code `1000`, Windows Terminal `9001`, WezTerm `3500`, and Alacritty `10000`. Terminals without a dedicated mapping use the conservative fallback of `1000` rows. Users can override this with `[tui.terminal_resize_reflow] max_rows = N`, or set `max_rows = 0` to disable row limiting. ## Validation - `just fmt` - `git diff --check` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui reflow` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui transcript_reflow` - `just fix -p codex-tui` - PR CI in progress on the squashed branch
This commit is contained in:
1 parent
4e30281a13
commit
5591912f0b
40 files changed
+3427
-390
No files matched your search
+115
-1
@@ -31,6 +31,7 @@ use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::disable_raw_mode;
|
||||
use ratatui::crossterm::terminal::enable_raw_mode;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::text::Line;
|
||||
@@ -108,7 +109,7 @@ fn running_in_wsl() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn running_in_vscode_terminal() -> bool {
|
||||
pub(crate) fn running_in_vscode_terminal() -> bool {
|
||||
vscode_terminal_detected(
|
||||
std::env::var("TERM_PROGRAM").ok().as_deref(),
|
||||
windows_term_program().as_deref(),
|
||||
@@ -443,8 +444,16 @@ fn set_panic_hook() {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TuiEvent {
|
||||
/// A terminal key event after focus, paste, and protocol bookkeeping has been handled.
|
||||
Key(KeyEvent),
|
||||
/// A bracketed paste payload normalized by the app layer before it reaches the composer.
|
||||
Paste(String),
|
||||
/// A terminal size notification that should be handled as resize-sensitive draw work.
|
||||
///
|
||||
/// Resize is separate from `Draw` so the app can run feature-gated pre-render logic without
|
||||
/// changing the default draw path for scheduled frames.
|
||||
Resize,
|
||||
/// A scheduled repaint that does not necessarily correspond to a terminal size change.
|
||||
Draw,
|
||||
}
|
||||
|
||||
@@ -729,6 +738,54 @@ impl Tui {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize the inline viewport for the resize-reflow path.
|
||||
///
|
||||
/// Unlike the legacy draw path, this path does not scroll rows above the viewport when the
|
||||
/// terminal shrinks. Resize reflow owns rebuilding those rows from transcript source, so
|
||||
/// scrolling here would move the viewport once and then replay history into the wrong row.
|
||||
fn update_inline_viewport_for_resize_reflow(
|
||||
terminal: &mut Terminal,
|
||||
height: u16,
|
||||
is_zellij: bool,
|
||||
) -> Result<bool> {
|
||||
let size = terminal.size()?;
|
||||
let terminal_height_shrank = size.height < terminal.last_known_screen_size.height;
|
||||
let terminal_height_grew = size.height > terminal.last_known_screen_size.height;
|
||||
let viewport_was_bottom_aligned =
|
||||
terminal.viewport_area.bottom() == terminal.last_known_screen_size.height;
|
||||
let previous_area = terminal.viewport_area;
|
||||
|
||||
let mut area = terminal.viewport_area;
|
||||
area.height = height.min(size.height);
|
||||
area.width = size.width;
|
||||
let mut needs_full_repaint = false;
|
||||
|
||||
if area.bottom() > size.height {
|
||||
let scroll_by = area.bottom() - size.height;
|
||||
if !terminal_height_shrank {
|
||||
if is_zellij {
|
||||
Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?;
|
||||
} else {
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..area.top(), scroll_by)?;
|
||||
}
|
||||
}
|
||||
area.y = size.height - area.height;
|
||||
} else if terminal_height_grew && viewport_was_bottom_aligned {
|
||||
area.y = size.height - area.height;
|
||||
}
|
||||
|
||||
if area != terminal.viewport_area {
|
||||
let clear_position = Position::new(/*x*/ 0, previous_area.y.min(area.y));
|
||||
terminal.set_viewport_area(area);
|
||||
terminal.clear_after_position(clear_position)?;
|
||||
needs_full_repaint = true;
|
||||
}
|
||||
|
||||
Ok(needs_full_repaint)
|
||||
}
|
||||
|
||||
/// Write any buffered history lines above the viewport and clear the buffer.
|
||||
/// Returns `true` when Zellij mode was used, signaling that the caller must
|
||||
/// invalidate the diff buffer for a full repaint.
|
||||
@@ -810,6 +867,63 @@ impl Tui {
|
||||
})?
|
||||
}
|
||||
|
||||
/// Draw a frame using the resize-reflow viewport and history insertion rules.
|
||||
///
|
||||
/// This is the feature-gated counterpart to `draw`. It intentionally skips
|
||||
/// `pending_viewport_area`, whose cursor-position heuristic is part of the legacy path, and
|
||||
/// instead lets transcript reflow rebuild scrollback before the frame is rendered.
|
||||
pub fn draw_with_resize_reflow(
|
||||
&mut self,
|
||||
height: u16,
|
||||
draw_fn: impl FnOnce(&mut custom_terminal::Frame),
|
||||
) -> Result<()> {
|
||||
// If we are resuming from ^Z, we need to prepare the resume action now so we can apply it
|
||||
// in the synchronized update.
|
||||
#[cfg(unix)]
|
||||
let mut prepared_resume = self
|
||||
.suspend_context
|
||||
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
|
||||
|
||||
stdout().sync_update(|_| {
|
||||
#[cfg(unix)]
|
||||
if let Some(prepared) = prepared_resume.take() {
|
||||
prepared.apply(&mut self.terminal)?;
|
||||
}
|
||||
|
||||
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(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
)?;
|
||||
needs_full_repaint |= flushed_history;
|
||||
|
||||
if needs_full_repaint {
|
||||
terminal.invalidate_viewport();
|
||||
}
|
||||
|
||||
// Update the y position for suspending so Ctrl-Z can place the cursor correctly.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let area = terminal.viewport_area;
|
||||
let inline_area_bottom = if self.alt_screen_active.load(Ordering::Relaxed) {
|
||||
self.alt_saved_viewport
|
||||
.map(|r| r.bottom().saturating_sub(1))
|
||||
.unwrap_or_else(|| area.bottom().saturating_sub(1))
|
||||
} else {
|
||||
area.bottom().saturating_sub(1)
|
||||
};
|
||||
self.suspend_context.set_cursor_y(inline_area_bottom);
|
||||
}
|
||||
|
||||
terminal.draw(|frame| {
|
||||
draw_fn(frame);
|
||||
})
|
||||
})?
|
||||
}
|
||||
|
||||
fn pending_viewport_area(&mut self) -> Result<Option<Rect>> {
|
||||
let terminal = &mut self.terminal;
|
||||
let screen_size = terminal.size()?;
|
||||
|
||||
Reference in new issue
Block a user