mirror of
https://github.com/openai/codex.git
synced 2026-08-28 13:58:49 +00:00
## Why Paginated sessions can have visible messages beyond the first item page, so bounded hydration could leave resumed history or transcript previews empty and the transcript viewer incomplete. ## What changed - Scan paginated history until the configured rendered-row budget is filled, while retaining the configured scrollback beyond the visible viewport. - Fully hydrate transcripts opened from the resume picker and build previews from bounded item pages, including when non-visible items precede messages. - Stop pagination on repeated cursors and let `Esc` cancel an in-flight transcript load. - Replay restored token usage for cold metadata-only resumes when its stored turn can be identified. ## Testing - Add coverage for paginated and legacy transcript loading, bounded resume hydration, repeated cursors, transcript cancellation, and resize reflow. GitOrigin-RevId: 2ce69f7d5eae88e2b09a27c333b00b1830ff17bf
332 lines
13 KiB
Rust
332 lines
13 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>,
|
|
visible_history_rows: Option<u16>,
|
|
ran_during_stream: bool,
|
|
resize_requested_during_stream: bool,
|
|
}
|
|
|
|
impl TranscriptReflowState {
|
|
/// Reset all width, pending deadline, and stream repair state.
|
|
///
|
|
/// Call this 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();
|
|
}
|
|
|
|
/// Cache the history rows left above the composer for the current terminal size.
|
|
pub(crate) fn set_visible_history_rows(&mut self, rows: u16) {
|
|
self.visible_history_rows = Some(rows.max(/*other*/ 1));
|
|
}
|
|
|
|
/// Return the last viewport budget without querying the terminal during cell replay.
|
|
pub(crate) fn visible_history_rows(&self) -> Option<u16> {
|
|
self.visible_history_rows
|
|
}
|
|
|
|
/// 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 visible_history_rows_are_cached_for_resize_replay() {
|
|
let mut state = TranscriptReflowState::default();
|
|
|
|
state.set_visible_history_rows(/*rows*/ 19);
|
|
|
|
assert_eq!(state.visible_history_rows(), Some(19));
|
|
}
|
|
|
|
#[test]
|
|
fn visible_history_rows_keep_one_row_for_a_full_height_composer() {
|
|
let mut state = TranscriptReflowState::default();
|
|
|
|
state.set_visible_history_rows(/*rows*/ 0);
|
|
|
|
assert_eq!(state.visible_history_rows(), Some(1));
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|