//! 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, last_reflow_width: Option, pending_reflow_width: Option, pending_until: Option, 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) -> 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 { 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()); } }