mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
@@ -678,6 +678,28 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_in_memory_config_from_disk_updates_resize_reflow_config() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"
|
||||
[tui]
|
||||
terminal_resize_reflow_max_rows = 9000
|
||||
"#,
|
||||
)?;
|
||||
|
||||
app.refresh_in_memory_config_from_disk().await?;
|
||||
|
||||
assert_eq!(
|
||||
app.config.terminal_resize_reflow.max_rows,
|
||||
crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(9000)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error()
|
||||
-> Result<()> {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain
|
||||
//! actions are delegated to focused app submodules so the central match remains the routing layer.
|
||||
|
||||
use super::resize_reflow::trailing_run_start;
|
||||
use super::*;
|
||||
|
||||
const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2);
|
||||
@@ -178,6 +179,9 @@ impl App {
|
||||
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::BeginInitialHistoryReplayBuffer => {
|
||||
self.begin_initial_history_replay_buffer();
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let cell: Arc<dyn HistoryCell> = cell.into();
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
@@ -185,23 +189,82 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_cells.push(cell.clone());
|
||||
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||
if !display.is_empty() {
|
||||
// Only insert a separating blank line for new cells that are not
|
||||
// part of an ongoing stream. Streaming continuations should not
|
||||
// accrue extra blank lines between chunks.
|
||||
if !cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
} else {
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
if self.initial_history_replay_buffer.as_ref().is_some() {
|
||||
self.insert_history_cell_lines_with_initial_replay_buffer(
|
||||
tui,
|
||||
cell.as_ref(),
|
||||
tui.terminal.last_known_screen_size.width,
|
||||
);
|
||||
} else {
|
||||
self.insert_history_cell_lines(
|
||||
tui,
|
||||
cell.as_ref(),
|
||||
tui.terminal.last_known_screen_size.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEvent::EndInitialHistoryReplayBuffer => {
|
||||
self.finish_initial_history_replay_buffer(tui);
|
||||
}
|
||||
AppEvent::ConsolidateAgentMessage { source, cwd } => {
|
||||
if !self.terminal_resize_reflow_enabled() {
|
||||
self.transcript_reflow.clear();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
let end = self.transcript_cells.len();
|
||||
let start =
|
||||
trailing_run_start::<history_cell::AgentMessageCell>(&self.transcript_cells);
|
||||
if start < end {
|
||||
let consolidated: Arc<dyn HistoryCell> =
|
||||
Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd));
|
||||
self.transcript_cells
|
||||
.splice(start..end, std::iter::once(consolidated.clone()));
|
||||
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.consolidate_cells(start..end, consolidated.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
|
||||
self.maybe_finish_stream_reflow(tui)?;
|
||||
} else {
|
||||
self.maybe_finish_stream_reflow(tui)?;
|
||||
}
|
||||
}
|
||||
AppEvent::ConsolidateProposedPlan(source) => {
|
||||
if !self.terminal_resize_reflow_enabled() {
|
||||
self.transcript_reflow.clear();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
let end = self.transcript_cells.len();
|
||||
let start = trailing_run_start::<history_cell::ProposedPlanStreamCell>(
|
||||
&self.transcript_cells,
|
||||
);
|
||||
let consolidated: Arc<dyn HistoryCell> =
|
||||
Arc::new(history_cell::new_proposed_plan(source, &self.config.cwd));
|
||||
|
||||
if start < end {
|
||||
self.transcript_cells
|
||||
.splice(start..end, std::iter::once(consolidated.clone()));
|
||||
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.consolidate_cells(start..end, consolidated.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
self.finish_required_stream_reflow(tui)?;
|
||||
} else {
|
||||
self.transcript_cells.push(consolidated.clone());
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.insert_cell(consolidated.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.insert_history_cell_lines(
|
||||
tui,
|
||||
consolidated.as_ref(),
|
||||
tui.terminal.last_known_screen_size.width,
|
||||
);
|
||||
|
||||
self.maybe_finish_stream_reflow(tui)?;
|
||||
}
|
||||
}
|
||||
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||
|
||||
@@ -83,10 +83,16 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn reset_app_ui_state_after_clear(&mut self) {
|
||||
self.reset_transcript_state_after_clear();
|
||||
}
|
||||
|
||||
pub(super) fn reset_transcript_state_after_clear(&mut self) {
|
||||
self.overlay = None;
|
||||
self.transcript_cells.clear();
|
||||
self.deferred_history_lines.clear();
|
||||
self.has_emitted_history_lines = false;
|
||||
self.transcript_reflow.clear();
|
||||
self.initial_history_replay_buffer = None;
|
||||
self.backtrack = BacktrackState::default();
|
||||
self.backtrack_render_pending = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Connects terminal resize events to source-backed transcript scrollback rebuilds.
|
||||
//!
|
||||
//! The app stores conversation history as `HistoryCell`s, but it also writes finalized history into
|
||||
//! terminal scrollback for the normal chat view. When the terminal width changes, this module uses
|
||||
//! the stored cells as source, clears the Codex-owned terminal history, and re-emits the transcript
|
||||
//! for the new terminal size.
|
||||
//!
|
||||
//! Streaming output is the fragile part of this lifecycle. Active streams first appear as transient
|
||||
//! stream cells, then consolidate into source-backed finalized cells. Resize work that happens
|
||||
//! before consolidation is marked as stream-time work so consolidation can force one final rebuild
|
||||
//! from the finalized source.
|
||||
//!
|
||||
//! The row cap is enforced while rendering from `HistoryCell` source, not after writing to the
|
||||
//! terminal. Initial resume replay uses the same display-line buffering contract so large sessions
|
||||
//! do not write more retained rows than resize replay would later be willing to rebuild.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_features::Feature;
|
||||
use color_eyre::eyre::Result;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use super::App;
|
||||
use super::InitialHistoryReplayBuffer;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE;
|
||||
use crate::tui;
|
||||
|
||||
struct ReflowCellDisplay {
|
||||
lines: Vec<Line<'static>>,
|
||||
is_stream_continuation: bool,
|
||||
}
|
||||
|
||||
/// Rendered transcript lines ready to be replayed into terminal scrollback.
|
||||
///
|
||||
/// This is intentionally line-oriented rather than cell-oriented because the terminal only accepts
|
||||
/// already-wrapped rows. Callers should keep treating `transcript_cells` as the source of truth; the
|
||||
/// rows here are a transient render product for a single terminal width.
|
||||
pub(super) struct ReflowRenderResult {
|
||||
pub(super) lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
pub(super) fn trailing_run_start<T: 'static>(transcript_cells: &[Arc<dyn HistoryCell>]) -> usize {
|
||||
let end = transcript_cells.len();
|
||||
let mut start = end;
|
||||
|
||||
while start > 0
|
||||
&& transcript_cells[start - 1].is_stream_continuation()
|
||||
&& transcript_cells[start - 1].as_any().is::<T>()
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
|
||||
if start > 0
|
||||
&& transcript_cells[start - 1].as_any().is::<T>()
|
||||
&& !transcript_cells[start - 1].is_stream_continuation()
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
|
||||
start
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn reset_history_emission_state(&mut self) {
|
||||
self.has_emitted_history_lines = false;
|
||||
self.deferred_history_lines.clear();
|
||||
}
|
||||
|
||||
fn display_lines_for_history_insert(
|
||||
&mut self,
|
||||
cell: &dyn HistoryCell,
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut display = cell.display_lines(width);
|
||||
if !display.is_empty() && !cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
} else {
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
}
|
||||
display
|
||||
}
|
||||
|
||||
pub(super) fn insert_history_cell_lines(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
cell: &dyn HistoryCell,
|
||||
width: u16,
|
||||
) {
|
||||
let display = self.display_lines_for_history_insert(cell, width);
|
||||
if display.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn terminal_resize_reflow_enabled(&self) -> bool {
|
||||
self.config.features.enabled(Feature::TerminalResizeReflow)
|
||||
}
|
||||
|
||||
/// Start retaining initial resume replay rows before they are written to scrollback.
|
||||
///
|
||||
/// Resume replay can insert thousands of already-finalized history cells before the first draw.
|
||||
/// When resize reflow is enabled, buffering here lets the same row cap used by resize rebuilds
|
||||
/// apply to the startup write. Starting this buffer while an overlay owns rendering would split
|
||||
/// transcript ownership, so overlay replay continues through the normal deferred-history path.
|
||||
pub(super) fn begin_initial_history_replay_buffer(&mut self) {
|
||||
if self.terminal_resize_reflow_enabled() && self.overlay.is_none() {
|
||||
self.initial_history_replay_buffer = Some(Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush retained initial resume replay rows into terminal scrollback.
|
||||
///
|
||||
/// The buffer stores display lines, not cells, because the cap is measured in terminal rows.
|
||||
/// This mirrors terminal scrollback behavior and avoids making startup replay cheaper or more
|
||||
/// expensive than a later resize rebuild of the same transcript.
|
||||
pub(super) fn finish_initial_history_replay_buffer(&mut self, tui: &mut tui::Tui) {
|
||||
let Some(buffer) = self.initial_history_replay_buffer.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if buffer.retained_lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let retained_lines = buffer.retained_lines.into_iter().collect::<Vec<_>>();
|
||||
tui.insert_history_lines(retained_lines);
|
||||
}
|
||||
|
||||
pub(super) fn insert_history_cell_lines_with_initial_replay_buffer(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
cell: &dyn HistoryCell,
|
||||
width: u16,
|
||||
) {
|
||||
let display = self.display_lines_for_history_insert(cell, width);
|
||||
|
||||
if display.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_rows = self.resize_reflow_max_rows();
|
||||
if let Some(buffer) = &mut self.initial_history_replay_buffer {
|
||||
if let Some(max_rows) = max_rows {
|
||||
Self::buffer_initial_history_replay_display_lines(buffer, display, max_rows);
|
||||
} else if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain only the newest rendered rows for initial resume replay.
|
||||
///
|
||||
/// The oldest rows are dropped first because terminal scrollback caps preserve the tail of the
|
||||
/// transcript. Keeping this policy local to display lines is important: trimming source cells
|
||||
/// here would make copy, transcript overlay, and future replay paths disagree about history.
|
||||
pub(super) fn buffer_initial_history_replay_display_lines(
|
||||
buffer: &mut InitialHistoryReplayBuffer,
|
||||
display: Vec<Line<'static>>,
|
||||
max_rows: usize,
|
||||
) {
|
||||
buffer.retained_lines.extend(display);
|
||||
while buffer.retained_lines.len() > max_rows {
|
||||
buffer.retained_lines.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_resize_reflow(&mut self, target_width: Option<u16>) -> bool {
|
||||
debug_assert!(self.terminal_resize_reflow_enabled());
|
||||
self.transcript_reflow.schedule_debounced(target_width)
|
||||
}
|
||||
|
||||
fn resize_reflow_max_rows(&self) -> Option<usize> {
|
||||
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
|
||||
/// `AgentMarkdownCell` or found to need no replacement. If a resize happened while the stream
|
||||
/// was active or while its transient cells were still present, this method runs an immediate
|
||||
/// source-backed reflow so terminal scrollback reflects the finalized cell instead of the
|
||||
/// transient stream rows.
|
||||
pub(super) fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
if !self.terminal_resize_reflow_enabled() {
|
||||
self.transcript_reflow.clear();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.transcript_reflow.take_stream_finish_reflow_needed() {
|
||||
self.schedule_immediate_resize_reflow(tui);
|
||||
self.maybe_run_resize_reflow(tui)?;
|
||||
} else if self.transcript_reflow.pending_is_due(Instant::now()) {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schedule_immediate_resize_reflow(&mut self, tui: &mut tui::Tui) {
|
||||
if !self.terminal_resize_reflow_enabled() {
|
||||
self.transcript_reflow.clear();
|
||||
return;
|
||||
}
|
||||
self.transcript_reflow.schedule_immediate();
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Force stream-finalized output through the resize reflow path.
|
||||
///
|
||||
/// Proposed plan consolidation uses this stricter path because a completed plan is inserted or
|
||||
/// replaced as one styled source-backed cell. If this reflow is skipped after a stream-time
|
||||
/// resize, the visible scrollback can keep the pre-consolidation wrapping.
|
||||
pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
if !self.terminal_resize_reflow_enabled() {
|
||||
self.transcript_reflow.clear();
|
||||
return Ok(());
|
||||
}
|
||||
self.schedule_immediate_resize_reflow(tui);
|
||||
self.maybe_run_resize_reflow(tui)?;
|
||||
if !self.transcript_reflow.has_pending_reflow() {
|
||||
self.transcript_reflow.clear_stream_flags();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record terminal size changes and schedule any resize-sensitive transcript work.
|
||||
///
|
||||
/// Width changes need a rebuild because transcript wrapping changes. Height changes can expose,
|
||||
/// hide, or shift rows around the inline viewport, so they also rebuild from source-backed
|
||||
/// cells. The first observed width initializes resize tracking without scheduling a rebuild,
|
||||
/// because there is no previously emitted width to repair yet.
|
||||
pub(super) fn handle_draw_size_change(
|
||||
&mut self,
|
||||
size: ratatui::layout::Size,
|
||||
last_known_screen_size: ratatui::layout::Size,
|
||||
frame_requester: &tui::FrameRequester,
|
||||
) -> bool {
|
||||
let width = self.transcript_reflow.note_width(size.width);
|
||||
let reflow_needed = self.transcript_reflow.reflow_needed_for_width(size.width);
|
||||
let height_changed = size.height != last_known_screen_size.height;
|
||||
let should_rebuild_transcript = reflow_needed || height_changed;
|
||||
if width.changed || width.initialized {
|
||||
self.chat_widget.on_terminal_resize(size.width);
|
||||
}
|
||||
if should_rebuild_transcript {
|
||||
if self.terminal_resize_reflow_enabled() {
|
||||
if reflow_needed && self.should_mark_reflow_as_stream_time() {
|
||||
self.transcript_reflow.mark_resize_requested_during_stream();
|
||||
}
|
||||
let target_width = reflow_needed.then_some(size.width);
|
||||
if self.schedule_resize_reflow(target_width) {
|
||||
frame_requester.schedule_frame();
|
||||
} else {
|
||||
frame_requester.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE);
|
||||
}
|
||||
} else if !self.terminal_resize_reflow_enabled() && width.changed {
|
||||
self.transcript_reflow.clear();
|
||||
}
|
||||
}
|
||||
if size != last_known_screen_size {
|
||||
self.refresh_status_line();
|
||||
}
|
||||
if self.terminal_resize_reflow_enabled() {
|
||||
self.maybe_clear_resize_reflow_without_terminal();
|
||||
}
|
||||
should_rebuild_transcript
|
||||
}
|
||||
|
||||
fn maybe_clear_resize_reflow_without_terminal(&mut self) {
|
||||
if !self.terminal_resize_reflow_enabled() {
|
||||
self.transcript_reflow.clear();
|
||||
return;
|
||||
}
|
||||
let Some(deadline) = self.transcript_reflow.pending_until() else {
|
||||
return;
|
||||
};
|
||||
if Instant::now() < deadline || self.overlay.is_some() || !self.transcript_cells.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.transcript_reflow.clear_pending_reflow();
|
||||
self.reset_history_emission_state();
|
||||
}
|
||||
|
||||
pub(super) fn handle_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
let size = tui.terminal.size()?;
|
||||
let should_rebuild_transcript = self.handle_draw_size_change(
|
||||
size,
|
||||
tui.terminal.last_known_screen_size,
|
||||
&tui.frame_requester(),
|
||||
);
|
||||
if should_rebuild_transcript && self.terminal_resize_reflow_enabled() {
|
||||
// Resize-sensitive history inserts queued before this frame may be wrapped for the old
|
||||
// viewport or targeted at rows no longer visible. Drop them and let resize reflow
|
||||
// rebuild from transcript cells.
|
||||
tui.clear_pending_history_lines();
|
||||
}
|
||||
self.maybe_run_resize_reflow(tui)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a pending transcript reflow when its debounce deadline has arrived.
|
||||
///
|
||||
/// Reflow is deferred while an overlay is active because the overlay owns the current draw
|
||||
/// surface. Callers must keep using `HistoryCell` source as the rebuild input; attempting to
|
||||
/// reuse terminal-wrapped output here would preserve exactly the stale wrapping this feature is
|
||||
/// meant to remove.
|
||||
pub(super) fn maybe_run_resize_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
if !self.terminal_resize_reflow_enabled() {
|
||||
self.transcript_reflow.clear();
|
||||
return Ok(());
|
||||
}
|
||||
let Some(deadline) = self.transcript_reflow.pending_until() else {
|
||||
return Ok(());
|
||||
};
|
||||
let now = Instant::now();
|
||||
if now < deadline {
|
||||
// Later resize events push the reflow deadline out, while the frame scheduler coalesces
|
||||
// delayed draws to the earliest requested instant. If an early draw arrives before the
|
||||
// latest quiet-period deadline, re-arm the draw so the pending reflow cannot get stuck
|
||||
// until the next keypress.
|
||||
tui.frame_requester().schedule_frame_in(deadline - now);
|
||||
return Ok(());
|
||||
}
|
||||
if self.overlay.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.transcript_reflow.clear_pending_reflow();
|
||||
|
||||
// Track that a reflow happened during an active stream or while trailing
|
||||
// unconsolidated AgentMessageCells are still pending consolidation so
|
||||
// ConsolidateAgentMessage can schedule a follow-up reflow.
|
||||
let reflow_ran_during_stream =
|
||||
!self.transcript_cells.is_empty() && self.should_mark_reflow_as_stream_time();
|
||||
|
||||
let width = self.reflow_transcript_now(tui)?;
|
||||
self.transcript_reflow.mark_reflowed_width(width);
|
||||
|
||||
if reflow_ran_during_stream {
|
||||
self.transcript_reflow.mark_ran_during_stream();
|
||||
}
|
||||
// Some terminals settle their final reported width after the repaint that handled the
|
||||
// last resize event. Request one cheap follow-up draw so `handle_draw_pre_render` can
|
||||
// sample that width and schedule a final reflow if needed.
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {
|
||||
let width = tui.terminal.size()?.width;
|
||||
if self.transcript_cells.is_empty() {
|
||||
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
|
||||
tui.clear_pending_history_lines();
|
||||
self.reset_history_emission_state();
|
||||
return Ok(width);
|
||||
}
|
||||
|
||||
let reflow_result = self.render_transcript_lines_for_reflow(width);
|
||||
let reflowed_lines = reflow_result.lines;
|
||||
|
||||
// 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(reflowed_lines);
|
||||
}
|
||||
|
||||
Ok(width)
|
||||
}
|
||||
|
||||
/// Render transcript cells for the current resize rebuild.
|
||||
///
|
||||
/// Rendering walks backward from the transcript tail so row-capped sessions avoid formatting the
|
||||
/// full backlog. If the retained suffix begins inside a stream-continuation run, the walk extends
|
||||
/// to include the run's first cell; otherwise separators would be inserted as if the continuation
|
||||
/// were a new top-level history item. The final row trim happens after separators are restored,
|
||||
/// so the returned rows obey the cap exactly.
|
||||
pub(super) fn render_transcript_lines_for_reflow(&mut self, width: u16) -> ReflowRenderResult {
|
||||
let row_cap = self.resize_reflow_max_rows();
|
||||
let mut cell_displays = VecDeque::new();
|
||||
let mut rendered_rows = 0usize;
|
||||
let mut start = self.transcript_cells.len();
|
||||
|
||||
while start > 0 {
|
||||
start -= 1;
|
||||
let cell = self.transcript_cells[start].clone();
|
||||
let lines = cell.display_lines(width);
|
||||
rendered_rows += lines.len();
|
||||
cell_displays.push_front(ReflowCellDisplay {
|
||||
lines,
|
||||
is_stream_continuation: cell.is_stream_continuation(),
|
||||
});
|
||||
|
||||
if row_cap.is_some_and(|max_rows| rendered_rows > max_rows) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while start > 0
|
||||
&& cell_displays
|
||||
.front()
|
||||
.is_some_and(|display| display.is_stream_continuation)
|
||||
{
|
||||
start -= 1;
|
||||
let cell = self.transcript_cells[start].clone();
|
||||
cell_displays.push_front(ReflowCellDisplay {
|
||||
lines: cell.display_lines(width),
|
||||
is_stream_continuation: cell.is_stream_continuation(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut has_emitted_history_lines = false;
|
||||
let mut reflowed_lines = Vec::new();
|
||||
for display in cell_displays {
|
||||
if !display.lines.is_empty() && !display.is_stream_continuation {
|
||||
if has_emitted_history_lines {
|
||||
reflowed_lines.push(Line::from(""));
|
||||
} else {
|
||||
has_emitted_history_lines = true;
|
||||
}
|
||||
}
|
||||
reflowed_lines.extend(display.lines);
|
||||
}
|
||||
if let Some(max_rows) = row_cap
|
||||
&& reflowed_lines.len() > max_rows
|
||||
{
|
||||
let trimmed_line_count = reflowed_lines.len() - max_rows;
|
||||
reflowed_lines = reflowed_lines.split_off(trimmed_line_count);
|
||||
}
|
||||
self.has_emitted_history_lines = !reflowed_lines.is_empty();
|
||||
|
||||
ReflowRenderResult {
|
||||
lines: reflowed_lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether current transcript state should be treated as stream-time resize state.
|
||||
///
|
||||
/// The active stream controllers cover normal streaming. The trailing-cell checks cover the
|
||||
/// narrow window after a controller has stopped but before the app has processed the
|
||||
/// consolidation event that replaces transient stream cells with source-backed cells.
|
||||
pub(super) fn should_mark_reflow_as_stream_time(&self) -> bool {
|
||||
self.chat_widget.has_active_agent_stream()
|
||||
|| self.chat_widget.has_active_plan_stream()
|
||||
|| trailing_run_start::<history_cell::AgentMessageCell>(&self.transcript_cells)
|
||||
< self.transcript_cells.len()
|
||||
|| trailing_run_start::<history_cell::ProposedPlanStreamCell>(&self.transcript_cells)
|
||||
< self.transcript_cells.len()
|
||||
}
|
||||
}
|
||||
@@ -385,13 +385,8 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
self.overlay = None;
|
||||
self.transcript_cells.clear();
|
||||
self.deferred_history_lines.clear();
|
||||
self.reset_transcript_state_after_clear();
|
||||
tui.clear_pending_history_lines();
|
||||
self.has_emitted_history_lines = false;
|
||||
self.backtrack = BacktrackState::default();
|
||||
self.backtrack_render_pending = false;
|
||||
Self::clear_terminal_for_thread_switch(&mut tui.terminal)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ pub(super) async fn make_test_app() -> App {
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::chatwidget::tests::set_fast_mode_test_catalog;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_session_info;
|
||||
use crate::multi_agents::AgentPickerThreadEntry;
|
||||
@@ -22,6 +23,7 @@ use assert_matches::assert_matches;
|
||||
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
use crate::legacy_core::config::ConfigOverrides;
|
||||
use crate::legacy_core::config::TerminalResizeReflowMaxRows;
|
||||
use codex_app_server_protocol::AdditionalFileSystemPermissions;
|
||||
use codex_app_server_protocol::AdditionalNetworkPermissions;
|
||||
use codex_app_server_protocol::AdditionalPermissionProfile;
|
||||
@@ -3645,6 +3647,8 @@ async fn make_test_app() -> App {
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
@@ -3702,6 +3706,8 @@ async fn make_test_app_with_channels() -> (
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
@@ -3759,6 +3765,147 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_terminal_resize_reflow(app: &mut App) {
|
||||
app.config
|
||||
.features
|
||||
.set_enabled(Feature::TerminalResizeReflow, /*enabled*/ true)
|
||||
.expect("feature should be configurable");
|
||||
}
|
||||
|
||||
fn plain_line_cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc<dyn HistoryCell>
|
||||
}
|
||||
|
||||
fn rendered_line_text(line: &Line<'static>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capped_resize_reflow_renders_recent_suffix_only() {
|
||||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||||
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(5);
|
||||
app.transcript_cells = (0..20)
|
||||
.map(|i| plain_line_cell(format!("cell {i}")))
|
||||
.collect();
|
||||
|
||||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||||
|
||||
assert_eq!(rendered.lines.len(), 5);
|
||||
assert_eq!(
|
||||
rendered
|
||||
.lines
|
||||
.iter()
|
||||
.map(rendered_line_text)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"cell 17".to_string(),
|
||||
String::new(),
|
||||
"cell 18".to_string(),
|
||||
String::new(),
|
||||
"cell 19".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() {
|
||||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||||
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
|
||||
app.transcript_cells = (0..20)
|
||||
.map(|i| plain_line_cell(format!("cell {i}")))
|
||||
.collect();
|
||||
|
||||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||||
|
||||
assert_eq!(rendered.lines.len(), 39);
|
||||
assert_eq!(rendered_line_text(&rendered.lines[0]), "cell 0");
|
||||
assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
|
||||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||||
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100);
|
||||
app.transcript_cells = (0..3)
|
||||
.map(|i| plain_line_cell(format!("cell {i}")))
|
||||
.collect();
|
||||
|
||||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||||
|
||||
assert_eq!(
|
||||
rendered
|
||||
.lines
|
||||
.iter()
|
||||
.map(rendered_line_text)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"cell 0".to_string(),
|
||||
String::new(),
|
||||
"cell 1".to_string(),
|
||||
String::new(),
|
||||
"cell 2".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
|
||||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||||
enable_terminal_resize_reflow(&mut app);
|
||||
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(3);
|
||||
|
||||
app.begin_initial_history_replay_buffer();
|
||||
for index in 0..5 {
|
||||
App::buffer_initial_history_replay_display_lines(
|
||||
app.initial_history_replay_buffer
|
||||
.as_mut()
|
||||
.expect("initial replay buffer active"),
|
||||
vec![Line::from(format!("line {index}"))],
|
||||
/*max_rows*/ 3,
|
||||
);
|
||||
}
|
||||
|
||||
let buffer = app
|
||||
.initial_history_replay_buffer
|
||||
.as_ref()
|
||||
.expect("initial replay buffer should remain active");
|
||||
assert_eq!(
|
||||
buffer
|
||||
.retained_lines
|
||||
.iter()
|
||||
.map(rendered_line_text)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"line 2".to_string(),
|
||||
"line 3".to_string(),
|
||||
"line 4".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn height_shrink_schedules_resize_reflow() {
|
||||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||||
enable_terminal_resize_reflow(&mut app);
|
||||
let frame_requester = crate::tui::FrameRequester::test_dummy();
|
||||
|
||||
assert!(!app.handle_draw_size_change(
|
||||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
||||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
||||
&frame_requester,
|
||||
));
|
||||
|
||||
assert!(app.handle_draw_size_change(
|
||||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 24),
|
||||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
||||
&frame_requester,
|
||||
));
|
||||
assert!(app.transcript_reflow.has_pending_reflow());
|
||||
}
|
||||
|
||||
fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItem>) -> Turn {
|
||||
Turn {
|
||||
id: turn_id.to_string(),
|
||||
|
||||
@@ -671,6 +671,7 @@ impl App {
|
||||
}
|
||||
AppCommandView::ReloadUserConfig => {
|
||||
app_server.reload_user_config().await?;
|
||||
self.refresh_in_memory_config_from_disk().await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommandView::OverrideTurnContext { .. } => Ok(true),
|
||||
@@ -1036,8 +1037,18 @@ impl App {
|
||||
self.chat_widget
|
||||
.set_initial_user_message_submit_suppressed(/*suppressed*/ true);
|
||||
self.chat_widget.handle_thread_session(session);
|
||||
let should_buffer_initial_replay =
|
||||
self.terminal_resize_reflow_enabled() && !turns.is_empty();
|
||||
if should_buffer_initial_replay {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginInitialHistoryReplayBuffer);
|
||||
}
|
||||
self.chat_widget
|
||||
.replay_thread_turns(turns, ReplayKind::ResumeInitialMessages);
|
||||
if should_buffer_initial_replay {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::EndInitialHistoryReplayBuffer);
|
||||
}
|
||||
let pending = std::mem::take(&mut self.pending_primary_events);
|
||||
for pending_event in pending {
|
||||
match pending_event {
|
||||
|
||||
Reference in New Issue
Block a user