Codex Handbook
tui/src/app/resize_reflow.rs 538 lines
//! 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 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::insert_history::HistoryLineWrapPolicy;use crate::terminal_hyperlinks::HyperlinkLine;use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE;use crate::tui;struct ReflowCellDisplay {    lines: Vec<HyperlinkLine>,    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<HyperlinkLine>,}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<HyperlinkLine> {        let mut display =            cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());        if !display.is_empty() && !cell.is_stream_continuation() {            if self.has_emitted_history_lines {                display.insert(/*index*/ 0, HyperlinkLine::new(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_hyperlink_lines_with_wrap_policy(                display,                self.history_line_wrap_policy(),            );        }    }    /// 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.    /// 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.overlay.is_none() {            self.initial_history_replay_buffer = Some(Default::default());        }    }    /// Start retaining a thread-switch transcript replay without rendering each historical cell.    ///    /// Thread switches already rebuild `transcript_cells` from source. When a row cap exists, we can    /// defer terminal writes until the replay is complete and reuse the resize-reflow tail renderer    /// so only the rows the terminal would retain are formatted and inserted.    pub(super) fn begin_thread_switch_history_replay_buffer(&mut self) {        if self.resize_reflow_max_rows().is_some() && self.overlay.is_none() {            self.initial_history_replay_buffer = Some(InitialHistoryReplayBuffer {                retained_lines: VecDeque::new(),                render_from_transcript_tail: true,            });        }    }    /// 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() {            if buffer.render_from_transcript_tail {                let width = tui.terminal.last_known_screen_size.width;                let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines;                if !reflowed_lines.is_empty() {                    tui.insert_history_hyperlink_lines_with_wrap_policy(                        reflowed_lines,                        self.history_line_wrap_policy(),                    );                }            }            return;        }        let retained_lines = buffer.retained_lines.into_iter().collect::<Vec<_>>();        tui.insert_history_hyperlink_lines_with_wrap_policy(            retained_lines,            self.history_line_wrap_policy(),        );    }    pub(super) fn insert_history_cell_lines_with_initial_replay_buffer(        &mut self,        tui: &mut tui::Tui,        cell: &dyn HistoryCell,        width: u16,    ) {        if self            .initial_history_replay_buffer            .as_ref()            .is_some_and(|buffer| buffer.render_from_transcript_tail)        {            return;        }        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_hyperlink_lines_with_wrap_policy(                    display,                    self.history_line_wrap_policy(),                );            }        }    }    pub(crate) fn history_line_wrap_policy(&self) -> HistoryLineWrapPolicy {        if self.chat_widget.raw_output_mode() {            HistoryLineWrapPolicy::Terminal        } else {            HistoryLineWrapPolicy::PreWrap        }    }    /// 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<HyperlinkLine>,        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 {        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.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) {        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<()> {        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 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);            }        }        if size != last_known_screen_size {            self.refresh_status_line();        }        self.maybe_clear_resize_reflow_without_terminal();        should_rebuild_transcript    }    fn maybe_clear_resize_reflow_without_terminal(&mut self) {        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 {            // 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<()> {        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(())    }    pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {        let terminal_width = tui.terminal.size()?.width;        let width = self.chat_widget.history_wrap_width(terminal_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(terminal_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_hyperlink_lines_with_wrap_policy(                reflowed_lines,                self.history_line_wrap_policy(),            );        }        Ok(terminal_width)    }    /// Rebuild scrollback after rollback removes transcript cells.    ///    /// Unlike resize reflow, rollback must clear the terminal even when no cells remain. Otherwise    /// the cancelled user prompt stays visible in scrollback despite being removed from the source    /// transcript.    pub(super) fn rebuild_transcript_after_backtrack(&mut self, tui: &mut tui::Tui) -> Result<()> {        let terminal_width = tui.terminal.size()?.width;        let width = self.chat_widget.history_wrap_width(terminal_width);        let reflowed_lines = if self.transcript_cells.is_empty() {            self.reset_history_emission_state();            Vec::new()        } else {            self.render_transcript_lines_for_reflow(width).lines        };        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_hyperlink_lines_with_wrap_policy(                reflowed_lines,                self.history_line_wrap_policy(),            );        }        Ok(())    }    /// 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_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());            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_hyperlink_lines_for_mode(                    width,                    self.chat_widget.history_render_mode(),                ),                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(HyperlinkLine::new(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()    }}