mirror of
https://github.com/openai/codex.git
synced 2026-05-16 01:02:48 +00:00
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
303 lines
12 KiB
Rust
303 lines
12 KiB
Rust
//! Tracks when Codex-owned transcript scrollback must be repaired after terminal resize.
|
|
//!
|
|
//! Terminal scrollback is not a retained widget tree: once Codex writes wrapped lines into the
|
|
//! terminal, the terminal owns those rows. Width resize reflow treats the in-memory transcript cells
|
|
//! as the source of truth, clears Codex-owned history, and re-emits the cells at the current width.
|
|
//! Height-only growth also schedules a rebuild so rows exposed above the inline viewport are
|
|
//! restored from the same source of truth.
|
|
//!
|
|
//! This module owns only scheduling and stream-time repair state. It does not know how to render
|
|
//! cells or clear terminal output; `app::resize_reflow` consumes this state and performs the
|
|
//! rebuild. The key invariant is that a reflow request which happens while streaming output is
|
|
//! active, or while transient stream cells are still waiting for consolidation, must trigger one
|
|
//! final source-backed reflow after the stream becomes source-backed history.
|
|
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
pub(crate) const TRANSCRIPT_REFLOW_DEBOUNCE: Duration = Duration::from_millis(75);
|
|
|
|
/// Tracks pending terminal-scrollback repair after a terminal resize.
|
|
///
|
|
/// The state intentionally separates observed terminal width from rebuilt terminal width. Terminal
|
|
/// emulators can report an intermediate size during drag-resize, then settle on the final size after
|
|
/// Codex has already rebuilt scrollback. Keeping those widths distinct lets the next draw request a
|
|
/// final rebuild instead of assuming the latest observed size has already been repaired.
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct TranscriptReflowState {
|
|
last_observed_width: Option<u16>,
|
|
last_reflow_width: Option<u16>,
|
|
pending_reflow_width: Option<u16>,
|
|
pending_until: Option<Instant>,
|
|
ran_during_stream: bool,
|
|
resize_requested_during_stream: bool,
|
|
}
|
|
|
|
impl TranscriptReflowState {
|
|
/// Reset all width, pending deadline, and stream repair state.
|
|
///
|
|
/// Call this when resize reflow is disabled or when the app discards the transcript state that
|
|
/// pending reflow work would have rebuilt. Leaving stale deadlines behind would make a later
|
|
/// draw attempt to rebuild history from unrelated cells.
|
|
pub(crate) fn clear(&mut self) {
|
|
*self = Self::default();
|
|
}
|
|
|
|
/// Record the width observed during a draw and report whether it is new or changed.
|
|
///
|
|
/// The first observed width initializes the state without scheduling a rebuild because no
|
|
/// old-width transcript has been emitted yet. Treating initialization as a real resize would
|
|
/// make the first draw do redundant scrollback work.
|
|
pub(crate) fn note_width(&mut self, width: u16) -> TranscriptWidthChange {
|
|
let previous_width = self.last_observed_width.replace(width);
|
|
if previous_width.is_none() {
|
|
self.last_reflow_width = Some(width);
|
|
}
|
|
TranscriptWidthChange {
|
|
changed: previous_width.is_some_and(|previous| previous != width),
|
|
initialized: previous_width.is_none(),
|
|
}
|
|
}
|
|
|
|
/// Return whether scrollback still needs to be rebuilt at `width`.
|
|
///
|
|
/// This compares against the width that actually rebuilt scrollback, not just the most recently
|
|
/// observed terminal width. A terminal can report the final size after the reflow that handled
|
|
/// the resize event, so the follow-up draw must be able to request one more reflow even if
|
|
/// the observed-width tracker already saw that value.
|
|
pub(crate) fn reflow_needed_for_width(&self, width: u16) -> bool {
|
|
self.last_reflow_width != Some(width) && self.pending_reflow_width != Some(width)
|
|
}
|
|
|
|
/// Schedule a trailing-debounced reflow and return whether it should run immediately.
|
|
///
|
|
/// Repeated resize events push the deadline out so dragging a terminal edge rebuilds scrollback
|
|
/// at the final observed width rather than at intermediate widths. `target_width` is present
|
|
/// only for width-changing rebuilds; height-only exposure still needs a rebuild, but it must not
|
|
/// suppress a later width repair for the same draw cycle.
|
|
pub(crate) fn schedule_debounced(&mut self, target_width: Option<u16>) -> bool {
|
|
let now = Instant::now();
|
|
if let Some(target_width) = target_width {
|
|
self.pending_reflow_width = Some(target_width);
|
|
}
|
|
self.pending_until = Some(now + TRANSCRIPT_REFLOW_DEBOUNCE);
|
|
false
|
|
}
|
|
|
|
/// Schedule an immediate reflow for the next draw opportunity.
|
|
///
|
|
/// This is used after stream consolidation when waiting for the debounce interval would leave
|
|
/// visible terminal-wrapped stream rows in the finalized transcript.
|
|
pub(crate) fn schedule_immediate(&mut self) {
|
|
self.pending_reflow_width = None;
|
|
self.pending_until = Some(Instant::now());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn set_due_for_test(&mut self) {
|
|
self.pending_until = Some(Instant::now() - Duration::from_millis(1));
|
|
}
|
|
|
|
pub(crate) fn pending_is_due(&self, now: Instant) -> bool {
|
|
self.pending_until.is_some_and(|deadline| now >= deadline)
|
|
}
|
|
|
|
pub(crate) fn pending_until(&self) -> Option<Instant> {
|
|
self.pending_until
|
|
}
|
|
|
|
pub(crate) fn has_pending_reflow(&self) -> bool {
|
|
self.pending_until.is_some()
|
|
}
|
|
|
|
pub(crate) fn clear_pending_reflow(&mut self) {
|
|
self.pending_until = None;
|
|
self.pending_reflow_width = None;
|
|
}
|
|
|
|
/// Remember the terminal width that actually rebuilt transcript scrollback.
|
|
///
|
|
/// Resize scheduling is driven by observed widths, but debounced redraws may run before a
|
|
/// terminal emulator has settled on its final size. Keeping the rendered width separate avoids
|
|
/// confusing "seen during a draw" with "scrollback has been repaired at this width".
|
|
pub(crate) fn mark_reflowed_width(&mut self, width: u16) -> bool {
|
|
self.last_reflow_width.replace(width) != Some(width)
|
|
}
|
|
|
|
/// Remember that a reflow actually rebuilt history before stream consolidation completed.
|
|
///
|
|
/// A mid-stream rebuild can only render the transient stream cells that exist at that moment.
|
|
/// The consolidation handler must later rebuild again from the finalized source-backed cell or
|
|
/// the transcript can keep old stream wrapping.
|
|
pub(crate) fn mark_ran_during_stream(&mut self) {
|
|
self.ran_during_stream = true;
|
|
}
|
|
|
|
/// Remember that the terminal width changed while streaming or pre-consolidation cells existed.
|
|
///
|
|
/// This captures the case where the debounce did not fire before the stream finished. Without
|
|
/// this flag, consolidation could complete without the final source-backed resize repair.
|
|
/// Marking the request rather than forcing immediate rendering keeps resize drag behavior
|
|
/// debounced while still guaranteeing that finalized stream cells replace transient rows.
|
|
pub(crate) fn mark_resize_requested_during_stream(&mut self) {
|
|
self.resize_requested_during_stream = true;
|
|
}
|
|
|
|
/// Return whether stream finalization needs a source-backed reflow and clear the request.
|
|
///
|
|
/// This is a draining read because each resize-during-stream episode should force at most one
|
|
/// post-consolidation repair. Calling it before consolidation would drop the repair request and
|
|
/// leave finalized scrollback shaped by transient stream rows.
|
|
pub(crate) fn take_stream_finish_reflow_needed(&mut self) -> bool {
|
|
let needed = self.ran_during_stream || self.resize_requested_during_stream;
|
|
self.ran_during_stream = false;
|
|
self.resize_requested_during_stream = false;
|
|
needed
|
|
}
|
|
|
|
/// Clear only the stream repair flags while preserving width and pending-deadline state.
|
|
///
|
|
/// Use this after a required final stream reflow has completed. Calling `clear()` here would
|
|
/// also forget the last observed width and make the next draw look like first initialization.
|
|
pub(crate) fn clear_stream_flags(&mut self) {
|
|
self.ran_during_stream = false;
|
|
self.resize_requested_during_stream = false;
|
|
}
|
|
}
|
|
|
|
/// Describes how the latest draw width relates to the previous observed draw width.
|
|
///
|
|
/// `initialized` means this was the first width observed by the state machine. `changed` means a
|
|
/// previously observed transcript width exists and differs from the new width.
|
|
pub(crate) struct TranscriptWidthChange {
|
|
pub(crate) changed: bool,
|
|
pub(crate) initialized: bool,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn schedule_debounced_postpones_existing_reflow() {
|
|
let mut state = TranscriptReflowState::default();
|
|
|
|
assert!(!state.schedule_debounced(/*target_width*/ None));
|
|
let first_deadline = state.pending_until().expect("pending reflow");
|
|
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
assert!(!state.schedule_debounced(/*target_width*/ None));
|
|
|
|
assert!(
|
|
state.pending_until().expect("pending reflow") > first_deadline,
|
|
"a later resize should push the debounce deadline out"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_debounced_postpones_due_existing_reflow() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.set_due_for_test();
|
|
let before_reschedule = Instant::now();
|
|
|
|
assert!(!state.schedule_debounced(/*target_width*/ None));
|
|
assert!(
|
|
state.pending_until().expect("pending reflow") > before_reschedule,
|
|
"a resize after the old deadline should start a fresh quiet period"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn first_observed_width_marks_reflow_baseline() {
|
|
let mut state = TranscriptReflowState::default();
|
|
|
|
let width = state.note_width(/*width*/ 80);
|
|
|
|
assert!(width.initialized);
|
|
assert_eq!(state.last_observed_width, Some(80));
|
|
assert_eq!(state.last_reflow_width, Some(80));
|
|
assert!(!state.reflow_needed_for_width(/*width*/ 80));
|
|
}
|
|
|
|
#[test]
|
|
fn mark_reflowed_width_records_actual_rebuild_width() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.note_width(/*width*/ 80);
|
|
|
|
assert!(state.mark_reflowed_width(/*width*/ 100));
|
|
|
|
assert_eq!(state.last_observed_width, Some(80));
|
|
assert_eq!(state.last_reflow_width, Some(100));
|
|
}
|
|
|
|
#[test]
|
|
fn reflow_needed_compares_against_actual_rebuild_width() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.note_width(/*width*/ 80);
|
|
state.mark_reflowed_width(/*width*/ 90);
|
|
state.note_width(/*width*/ 100);
|
|
|
|
assert!(state.reflow_needed_for_width(/*width*/ 100));
|
|
}
|
|
|
|
#[test]
|
|
fn pending_reflow_target_prevents_repeated_reschedule() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.note_width(/*width*/ 80);
|
|
|
|
assert!(state.reflow_needed_for_width(/*width*/ 100));
|
|
state.schedule_debounced(/*target_width*/ Some(100));
|
|
|
|
assert!(!state.reflow_needed_for_width(/*width*/ 100));
|
|
}
|
|
|
|
#[test]
|
|
fn clear_pending_reflow_allows_same_width_to_be_rescheduled() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.note_width(/*width*/ 80);
|
|
state.schedule_debounced(/*target_width*/ Some(100));
|
|
|
|
state.clear_pending_reflow();
|
|
|
|
assert!(state.reflow_needed_for_width(/*width*/ 100));
|
|
}
|
|
|
|
#[test]
|
|
fn mark_reflowed_width_reports_unchanged_width() {
|
|
let mut state = TranscriptReflowState::default();
|
|
assert!(state.mark_reflowed_width(/*width*/ 100));
|
|
|
|
assert!(!state.mark_reflowed_width(/*width*/ 100));
|
|
assert_eq!(state.last_reflow_width, Some(100));
|
|
}
|
|
|
|
#[test]
|
|
fn take_stream_finish_reflow_needed_drains_resize_request() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.mark_resize_requested_during_stream();
|
|
|
|
assert!(state.take_stream_finish_reflow_needed());
|
|
assert!(!state.take_stream_finish_reflow_needed());
|
|
}
|
|
|
|
#[test]
|
|
fn take_stream_finish_reflow_needed_drains_ran_during_stream() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.mark_ran_during_stream();
|
|
|
|
assert!(state.take_stream_finish_reflow_needed());
|
|
assert!(!state.take_stream_finish_reflow_needed());
|
|
}
|
|
|
|
#[test]
|
|
fn clear_resets_stream_reflow_flags() {
|
|
let mut state = TranscriptReflowState::default();
|
|
state.mark_ran_during_stream();
|
|
state.mark_resize_requested_during_stream();
|
|
|
|
state.clear();
|
|
|
|
assert!(!state.take_stream_finish_reflow_needed());
|
|
}
|
|
}
|