From 3b8d22ec2c75bf8fcd6048c34039344795ff7a0a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 4 Aug 2026 18:46:54 +0000 Subject: [PATCH] Improve paginated TUI history loading (#36949) ## 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 --- codex-rs/app-server/README.md | 4 +- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/resize_reflow.rs | 25 +- codex-rs/tui/src/app/resize_reflow_tests.rs | 144 +++++++++ .../app/tests/session_lifecycle_requests.rs | 1 + codex-rs/tui/src/app_backtrack.rs | 16 +- codex-rs/tui/src/app_server_session.rs | 6 + .../tui/src/app_server_session/history.rs | 163 +++++++--- .../src/app_server_session/history_tests.rs | 28 ++ codex-rs/tui/src/lib.rs | 117 +++++++ codex-rs/tui/src/resume_picker.rs | 297 ++++++++++++++---- codex-rs/tui/src/thread_transcript.rs | 15 +- codex-rs/tui/src/transcript_reflow.rs | 29 ++ 13 files changed, 721 insertions(+), 125 deletions(-) create mode 100644 codex-rs/tui/src/app/resize_reflow_tests.rs create mode 100644 codex-rs/tui/src/app_server_session/history_tests.rs diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 49d5ee83ae..9a01e92b9e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -349,7 +349,7 @@ Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When ` To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. -By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. +By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. A cold paginated resume can still replay persisted `thread/tokenUsage/updated` when it can identify the corresponding stored turn; resuming an already-loaded thread waits for the next live update. Paginated threads keep the same resume contract as legacy threads. A default resume materializes the full projected history into `thread.turns`; `excludeTurns: true` keeps that array empty and includes `turnsBackwardsCursor` and `itemsBackwardsCursor` for the durable history visible at the resume boundary. Pass each cursor directly to its matching list API with `sortDirection: "desc"`; the first page includes the cursor's head row, while newer records arrive through live notifications. Either cursor is `null` when there is no durable row yet. @@ -405,7 +405,7 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c { "method": "thread/started", "params": { "thread": { … } } } ``` -Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage. Ephemeral forks of paginated threads require `excludeTurns: true`. +Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. Metadata-only forks do not replay restored `thread/tokenUsage/updated`. Ephemeral forks of paginated threads require `excludeTurns: true`. ### Example: List threads (with pagination & filters) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 047cdd1736..13f3ea109c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1090,6 +1090,7 @@ See the Codex keymap documentation for supported actions and examples." if let Some(entry) = startup_hooks_browser { app.chat_widget.open_hooks_browser(entry); } + app.update_visible_history_rows(tui.terminal.last_known_screen_size); let initial_session_started_at = Instant::now(); if let Some(started) = initial_started_thread { let thread_id = started.session.thread_id; diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 266d3aa602..823596a12a 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -19,6 +19,7 @@ use std::sync::Arc; use std::time::Instant; use color_eyre::eyre::Result; +use ratatui::layout::Size; use ratatui::text::Line; use super::App; @@ -192,7 +193,8 @@ impl App { return; } - let max_rows = self.resize_reflow_max_rows(); + let max_rows = + crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow); 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); @@ -239,6 +241,19 @@ impl App { crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow) } + pub(super) fn update_visible_history_rows(&mut self, screen_size: Size) { + let width = screen_size.width.max(/*other*/ 1); + let viewport_height = self + .with_chat_widget_frame(width, |desired_height, _| desired_height) + .min(screen_size.height); + self.transcript_reflow.set_visible_history_rows( + screen_size + .height + .saturating_sub(viewport_height) + .max(/*other*/ 1), + ); + } + fn clear_terminal_for_resize_replay(&mut self, tui: &mut tui::Tui) -> Result<()> { if tui.is_alt_screen_active() { tui.terminal.clear_visible_screen()?; @@ -316,6 +331,10 @@ impl App { last_known_screen_size: ratatui::layout::Size, frame_requester: &tui::FrameRequester, ) -> bool { + if size != last_known_screen_size || self.transcript_reflow.visible_history_rows().is_none() + { + self.update_visible_history_rows(size); + } 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; @@ -570,3 +589,7 @@ impl App { < self.transcript_cells.len() } } + +#[cfg(test)] +#[path = "resize_reflow_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/resize_reflow_tests.rs b/codex-rs/tui/src/app/resize_reflow_tests.rs new file mode 100644 index 0000000000..8caa683033 --- /dev/null +++ b/codex-rs/tui/src/app/resize_reflow_tests.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::app::test_support::make_test_app; +use crate::history_cell::PlainHistoryCell; +use crate::legacy_core::config::TerminalResizeReflowMaxRows; +use pretty_assertions::assert_eq; + +fn plain_history_cells(count: usize) -> Vec> { + (0..count) + .map(|index| { + Arc::new(PlainHistoryCell::new(vec![Line::from(format!( + "cell {index}" + ))])) as Arc + }) + .collect() +} + +fn rendered_line_text(line: &HyperlinkLine) -> String { + line.line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[tokio::test] +async fn resize_reflow_preserves_configured_scrollback_beyond_the_visible_viewport() { + let mut app = make_test_app().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32); + app.transcript_cells = plain_history_cells(/*count*/ 64); + let screen_size = Size::new(/*width*/ 80, /*height*/ 24); + let chat_height = app.with_chat_widget_frame(screen_size.width, |height, _| height); + let visible_history_rows = screen_size + .height + .saturating_sub(chat_height) + .max(/*other*/ 1); + + app.update_visible_history_rows(screen_size); + let rendered = app.render_transcript_lines_for_reflow(screen_size.width); + + assert_eq!(app.resize_reflow_max_rows(), Some(32)); + assert_eq!(rendered.lines.len(), 32); + assert!(rendered.lines.len() > usize::from(visible_history_rows)); + assert_eq!( + rendered.lines.last().map(rendered_line_text), + Some("cell 63".to_string()) + ); +} + +#[tokio::test] +async fn initial_resume_replay_retains_scrollback_beyond_the_visible_viewport() -> Result<()> { + let mut app = make_test_app().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32); + let screen_size = Size::new(/*width*/ 80, /*height*/ 24); + app.update_visible_history_rows(screen_size); + let visible_history_rows = app + .transcript_reflow + .visible_history_rows() + .expect("visible history row budget"); + + app.begin_initial_history_replay_buffer(); + let mut tui = crate::tui::test_support::make_test_tui()?; + for cell in plain_history_cells(/*count*/ 24) { + app.insert_history_cell_lines_with_initial_replay_buffer( + &mut tui, + cell.as_ref(), + screen_size.width, + ); + } + + let retained_lines = &app + .initial_history_replay_buffer + .as_ref() + .expect("initial replay buffer should remain active") + .retained_lines; + assert_eq!(retained_lines.len(), 32); + assert!(retained_lines.len() > usize::from(visible_history_rows)); + insta::assert_snapshot!( + retained_lines + .iter() + .rev() + .take(/*n*/ 3) + .map(rendered_line_text) + .collect::>() + .join("\n"), + @r" + cell 23 + + cell 22 + " + ); + Ok(()) +} + +#[tokio::test] +async fn resize_reflow_preserves_configured_scrollback_when_the_terminal_height_changes() { + let mut app = make_test_app().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(48); + app.transcript_cells = plain_history_cells(/*count*/ 64); + + app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 24)); + let smaller = app.render_transcript_lines_for_reflow(/*width*/ 80); + app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 48)); + let larger = app.render_transcript_lines_for_reflow(/*width*/ 80); + + assert_eq!(smaller.lines.len(), 48); + assert_eq!(larger.lines.len(), 48); + assert_eq!( + smaller + .lines + .iter() + .map(rendered_line_text) + .collect::>(), + larger + .lines + .iter() + .map(rendered_line_text) + .collect::>() + ); + assert_eq!( + larger.lines.last().map(rendered_line_text), + Some("cell 63".to_string()) + ); +} + +#[tokio::test] +async fn resize_reflow_preserves_explicitly_unlimited_history() { + let mut app = make_test_app().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.transcript_cells = plain_history_cells(/*count*/ 20); + + app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 24)); + let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); + + assert_eq!(app.resize_reflow_max_rows(), None); + assert_eq!(rendered.lines.len(), 39); + assert_eq!( + rendered.lines.first().map(rendered_line_text), + Some("cell 0".to_string()) + ); + assert_eq!( + rendered.lines.last().map(rendered_line_text), + Some("cell 19".to_string()) + ); +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index d8af260df4..b835095277 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -186,6 +186,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100); let thread_id = create_fake_paginated_rollout( codex_home.path(), "2026-01-02T00-00-00", diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b85769b45b..110226c6ed 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -28,6 +28,7 @@ use crate::app::App; use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT; +use crate::app_server_session::thread_items_page_params; use crate::bottom_pane::LocalImageAttachment; use crate::chatwidget::ChatWidget; use crate::chatwidget::UserMessage; @@ -40,9 +41,7 @@ use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -114,13 +113,12 @@ impl App { let result = request_handle .request_typed::(ClientRequest::ThreadItemsList { request_id, - params: ThreadItemsListParams { - thread_id: thread_id.to_string(), - turn_id: None, - cursor: Some(cursor.clone()), - limit: Some(HISTORY_ITEM_PAGE_LIMIT), - sort_direction: Some(SortDirection::Desc), - }, + params: thread_items_page_params( + thread_id, + /*turn_id*/ None, + Some(cursor.clone()), + HISTORY_ITEM_PAGE_LIMIT, + ), }) .await .map_err(|err| err.to_string()); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 3c8db59e47..d3bf82d2cd 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -7,6 +7,9 @@ mod fs; mod history; pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT; +pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT; +pub(crate) use history::HistoryHydrationScope; +pub(crate) use history::thread_items_page_params; use crate::bottom_pane::FeedbackAudience; use crate::legacy_core::config::Config; @@ -578,6 +581,7 @@ impl AppServerSession { response.turns_backwards_cursor.clone(), response.items_backwards_cursor.clone(), Some(&config), + HistoryHydrationScope::Initial, ) .await?; let fork_parent_title = self @@ -691,6 +695,7 @@ impl AppServerSession { /*turn_cursor*/ None, /*item_cursor*/ None, Some(&config), + HistoryHydrationScope::Initial, ) .await?; } @@ -824,6 +829,7 @@ impl AppServerSession { /*turn_cursor*/ None, /*item_cursor*/ None, /*config*/ None, + HistoryHydrationScope::Initial, ) .await?; Ok(response.thread) diff --git a/codex-rs/tui/src/app_server_session/history.rs b/codex-rs/tui/src/app_server_session/history.rs index 01cf943da3..93a44f35f6 100644 --- a/codex-rs/tui/src/app_server_session/history.rs +++ b/codex-rs/tui/src/app_server_session/history.rs @@ -1,11 +1,13 @@ //! Bounded app-server transcript loading for resume, fork, and transcript views. +use std::collections::HashSet; + use super::AppServerSession; use crate::history_cell::HistoryRenderMode; use crate::legacy_core::config::Config; use crate::resize_reflow_cap::resize_reflow_max_rows; use crate::thread_transcript::RawReasoningVisibility; -use crate::thread_transcript::thread_to_transcript_cells; +use crate::thread_transcript::thread_items_to_transcript_cells; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::Thread; @@ -23,12 +25,47 @@ use color_eyre::eyre::WrapErr; pub(crate) const INITIAL_HISTORY_TURN_LIMIT: u32 = 5; pub(crate) const HISTORY_ITEM_PAGE_LIMIT: u32 = 100; +pub(crate) const HISTORY_ITEM_SCAN_LIMIT: usize = 4 * HISTORY_ITEM_PAGE_LIMIT as usize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistoryHydrationScope { + Initial, + Complete, +} + +pub(crate) fn thread_items_page_params( + thread_id: ThreadId, + turn_id: Option<&str>, + cursor: Option, + limit: u32, +) -> ThreadItemsListParams { + ThreadItemsListParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.map(str::to_string), + cursor, + limit: Some(limit), + sort_direction: Some(SortDirection::Desc), + } +} + +fn advancing_cursor( + current: Option<&str>, + next: Option, + seen_cursors: &mut HashSet, +) -> Option { + if let Some(current) = current { + seen_cursors.insert(current.to_string()); + } + next.filter(|next| seen_cursors.insert(next.clone())) +} #[derive(Clone, Debug, Default)] pub(crate) struct ThreadHistoryPagination { pub(super) history_mode: ThreadHistoryMode, next_turn_cursor: Option, next_item_cursor: Option, + seen_turn_cursors: HashSet, + seen_item_cursors: HashSet, loading_older: bool, } @@ -76,6 +113,23 @@ impl AppServerSession { Ok(items) } + pub(crate) async fn thread_items_page( + &mut self, + thread_id: ThreadId, + turn_id: Option<&str>, + cursor: Option, + limit: u32, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadItemsList { + request_id, + params: thread_items_page_params(thread_id, turn_id, cursor, limit), + }) + .await + .wrap_err("failed to load a bounded thread item page") + } + async fn thread_turns_page( &mut self, thread_id: ThreadId, @@ -104,15 +158,25 @@ impl AppServerSession { state: &mut ThreadHistoryPagination, turns: &mut Vec, ) -> Result> { - state.next_item_cursor = page.next_cursor; + state.next_item_cursor = advancing_cursor( + state.next_item_cursor.as_deref(), + page.next_cursor, + &mut state.seen_item_cursors, + ); let mut items = Vec::new(); for entry in page.data { while !turns.iter().any(|turn| turn.id == entry.turn_id) { let Some(cursor) = state.next_turn_cursor.take() else { break; }; - let page = self.thread_turns_page(thread_id, Some(cursor)).await?; - state.next_turn_cursor = page.next_cursor; + let page = self + .thread_turns_page(thread_id, Some(cursor.clone())) + .await?; + state.next_turn_cursor = advancing_cursor( + Some(&cursor), + page.next_cursor, + &mut state.seen_turn_cursors, + ); turns.splice(0..0, page.data.into_iter().rev()); } if let Some(turn) = turns.iter_mut().find(|turn| turn.id == entry.turn_id) @@ -133,6 +197,7 @@ impl AppServerSession { turn_cursor: Option, item_cursor: Option, config: Option<&Config>, + scope: HistoryHydrationScope, ) -> Result<()> { let thread_id = ThreadId::from_string(&thread.id) .wrap_err("invalid thread id in bounded history response")?; @@ -154,53 +219,57 @@ impl AppServerSession { next_item_cursor: item_cursor, ..ThreadHistoryPagination::default() }; - let row_budget = - config.and_then(|config| resize_reflow_max_rows(config.terminal_resize_reflow)); - let item_budget = if config.is_some() && row_budget.is_none() { - None - } else { - Some(HISTORY_ITEM_PAGE_LIMIT as usize) - }; let width = crossterm::terminal::size() .map(|(width, _)| width.max(/*other*/ 1)) .unwrap_or(/*default*/ 80); + let row_budget = + config.and_then(|config| resize_reflow_max_rows(config.terminal_resize_reflow)); + let item_budget = match (scope, config, row_budget) { + (HistoryHydrationScope::Complete, _, _) + | (HistoryHydrationScope::Initial, Some(_), None) => None, + (HistoryHydrationScope::Initial, Some(_), Some(max_rows)) => { + Some(max_rows.saturating_add(HISTORY_ITEM_SCAN_LIMIT)) + } + (HistoryHydrationScope::Initial, None, _) => Some(HISTORY_ITEM_PAGE_LIMIT as usize), + }; let mut scanned_items = 0; + let mut rendered_rows = 0; loop { - let remaining_rows = row_budget.map(|budget| { - budget.saturating_sub(config.map_or(/*default*/ 0, |config| { - rendered_history_rows(thread, config, width) - })) - }); + let remaining_rows = row_budget.map(|budget| budget.saturating_sub(rendered_rows)); let remaining_items = item_budget.map(|budget| budget.saturating_sub(scanned_items)); if remaining_rows == Some(0) || remaining_items == Some(0) { break; } - let limit = remaining_rows + let limit = remaining_items .unwrap_or(HISTORY_ITEM_PAGE_LIMIT as usize) - .min(remaining_items.unwrap_or(HISTORY_ITEM_PAGE_LIMIT as usize)) - .min(HISTORY_ITEM_PAGE_LIMIT as usize) as u32; - let request_id = self.next_request_id(); - let page: ThreadItemsListResponse = self - .client - .request_typed(ClientRequest::ThreadItemsList { - request_id, - params: ThreadItemsListParams { - thread_id: thread_id.to_string(), - turn_id: None, - cursor: state.next_item_cursor.clone(), - limit: Some(limit), - sort_direction: Some(SortDirection::Desc), - }, - }) - .await - .wrap_err("failed to load a bounded thread item page")?; + .min(HISTORY_ITEM_PAGE_LIMIT as usize); + let limit = if rendered_rows == 0 && scanned_items != 0 { + limit + } else { + limit.min(remaining_rows.unwrap_or(limit)) + } as u32; + let page = self + .thread_items_page( + thread_id, + /*turn_id*/ None, + state.next_item_cursor.clone(), + limit, + ) + .await?; if page.data.is_empty() { state.next_item_cursor = None; break; } scanned_items = scanned_items.saturating_add(page.data.len()); - self.merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns) + let items = self + .merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns) .await?; + if let Some(config) = config { + rendered_rows = + rendered_history_rows(thread_id, thread, items, config, width, rendered_rows); + } else { + rendered_rows = rendered_rows.saturating_add(items.len()); + } if state.next_item_cursor.is_none() { break; } @@ -212,10 +281,14 @@ impl AppServerSession { } } -fn rendered_history_rows(thread: &Thread, config: &Config, width: u16) -> usize { - if thread.turns.iter().all(|turn| turn.items.is_empty()) { - return 0; - } +fn rendered_history_rows( + thread_id: ThreadId, + thread: &Thread, + items: Vec, + config: &Config, + width: u16, + rendered_rows: usize, +) -> usize { let visibility = if config.show_raw_agent_reasoning { RawReasoningVisibility::Visible } else { @@ -226,14 +299,20 @@ fn rendered_history_rows(thread: &Thread, config: &Config, width: u16) -> usize } else { HistoryRenderMode::Rich }; - thread_to_transcript_cells( - thread.clone(), + thread_items_to_transcript_cells( + Some(thread_id), + &thread.cwd, + items, visibility, Some(config.codex_home.as_path()), ) .into_iter() - .fold(/*init*/ 0, |rows, cell| { + .fold(rendered_rows, |rows, cell| { let height = usize::from(cell.desired_height_for_mode(width, mode)); rows + height + usize::from(height != 0 && rows != 0 && !cell.is_stream_continuation()) }) } + +#[cfg(test)] +#[path = "history_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app_server_session/history_tests.rs b/codex-rs/tui/src/app_server_session/history_tests.rs new file mode 100644 index 0000000000..c498db3e8a --- /dev/null +++ b/codex-rs/tui/src/app_server_session/history_tests.rs @@ -0,0 +1,28 @@ +use super::advancing_cursor; +use pretty_assertions::assert_eq; +use std::collections::HashSet; + +#[test] +fn advancing_cursor_rejects_repeated_cursors() { + let mut seen_cursors = HashSet::new(); + assert_eq!( + advancing_cursor( + /*current*/ None, + Some("first".to_string()), + &mut seen_cursors, + ), + Some("first".to_string()) + ); + assert_eq!( + advancing_cursor(Some("first"), Some("second".to_string()), &mut seen_cursors,), + Some("second".to_string()) + ); + assert_eq!( + advancing_cursor(Some("second"), Some("first".to_string()), &mut seen_cursors,), + None + ); + assert_eq!( + advancing_cursor(Some("second"), /*next*/ None, &mut seen_cursors), + None + ); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1f7e563231..4e25dc90e2 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -3239,6 +3239,123 @@ mod tests { Ok(()) } + #[tokio::test] + async fn resume_picker_loads_complete_paginated_and_legacy_transcripts() + -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = build_config(&temp_dir).await?; + config.terminal_resize_reflow.max_rows = + crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(2); + let mut app_server = AppServerSession::new( + AppServerClient::InProcess(start_test_embedded_app_server(config.clone()).await?), + ThreadParamsMode::Embedded, + ); + let filename_ts = "2025-01-05T12-00-00"; + let rollout_line = |ordinal: usize, payload: serde_json::Value| { + serde_json::json!({ + "timestamp": "2025-01-05T12:00:00Z", + "type": "event_msg", + "payload": payload, + "ordinal": ordinal, + }) + }; + for (history_mode, create_rollout) in [ + app_test_support::create_fake_rollout, + app_test_support::create_fake_paginated_rollout, + ] + .into_iter() + .enumerate() + { + let thread_id = create_rollout( + temp_dir.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "message 0", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create session rollout"); + let path = app_test_support::rollout_path(temp_dir.path(), filename_ts, &thread_id); + let mut contents = std::fs::read_to_string(&path)?; + let started = rollout_line( + /*ordinal*/ 3, + serde_json::json!({ "type": "task_started", "turn_id": "history-turn", "model_context_window": null }), + ); + contents.push_str(&format!("{started}\n")); + for index in 0..=100 { + let message = format!("message {index}"); + let payload = if history_mode == 1 { + serde_json::json!({ + "type": "item_completed", + "thread_id": thread_id, + "turn_id": "history-turn", + "item": { "type": "UserMessage", "id": format!("user-{index}"), + "content": [{ "type": "text", "text": message }] }, + }) + } else { + serde_json::json!({ "type": "user_message", "message": message }) + }; + let item = rollout_line(index + 4, payload); + contents.push_str(&format!("{item}\n")); + } + if history_mode == 1 { + for index in 0..125 { + let item = rollout_line( + index + 105, + serde_json::json!({ + "type": "item_completed", + "thread_id": thread_id, + "turn_id": "history-turn", + "item": { "type": "Reasoning", "id": format!("hidden-{index}"), + "summary_text": [], "raw_content": [] }, + }), + ); + contents.push_str(&format!("{item}\n")); + } + } + std::fs::write(path, contents)?; + let thread_id = ThreadId::from_string(&thread_id)?; + let started = app_server + .resume_thread( + config.clone(), + thread_id, + app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + if history_mode == 1 { + assert!( + started + .turns + .iter() + .flat_map(|turn| &turn.items) + .any(|item| { + matches!( + item, + codex_app_server_protocol::ThreadItem::UserMessage { .. } + ) + }) + ); + let preview = crate::resume_picker::load_transcript_preview( + &mut app_server, + thread_id, + /*codex_home*/ None, + ) + .await?; + assert!(!preview.is_empty()); + } + let cells = crate::thread_transcript::load_session_transcript( + &mut app_server, + thread_id, + crate::thread_transcript::RawReasoningVisibility::Hidden, + /*codex_home*/ None, + ) + .await?; + assert!(cells.len() > 100); + } + app_server.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> { Box::pin(async { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 00f69e342f..2a0e13940b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5,6 +5,8 @@ use std::path::PathBuf; use std::sync::Arc; use crate::app_server_session::AppServerSession; +use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT; +use crate::app_server_session::HISTORY_ITEM_SCAN_LIMIT; use crate::clipboard_paste::normalize_pasted_search_query; use crate::color::blend; use crate::color::is_light; @@ -37,6 +39,7 @@ use crate::wrapping::adaptive_wrap_lines; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; @@ -62,6 +65,7 @@ use ratatui::text::Span; use ratatui::widgets::Clear; use ratatui::widgets::Widget; use tokio::sync::mpsc; +use tokio::sync::oneshot; use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::warn; @@ -159,8 +163,13 @@ struct PageLoadRequest { enum PickerLoadRequest { Page(PageLoadRequest), - Preview { thread_id: ThreadId }, - Transcript { thread_id: ThreadId }, + Preview { + thread_id: ThreadId, + }, + Transcript { + thread_id: ThreadId, + cancellation: oneshot::Receiver<()>, + }, } #[derive(Clone)] @@ -620,18 +629,24 @@ fn spawn_app_server_page_loader( .await; let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview }); } - PickerLoadRequest::Transcript { thread_id } => { - let transcript = load_session_transcript( - &mut app_server, - thread_id, - raw_reasoning_visibility, - codex_home.as_deref(), - ) - .await; - let _ = bg_tx.send(BackgroundEvent::Transcript { - thread_id, - transcript, - }); + PickerLoadRequest::Transcript { + thread_id, + cancellation, + } => { + tokio::select! { + transcript = load_session_transcript( + &mut app_server, + thread_id, + raw_reasoning_visibility, + codex_home.as_deref(), + ) => { + let _ = bg_tx.send(BackgroundEvent::Transcript { + thread_id, + transcript, + }); + } + _ = cancellation => {} + } } } } @@ -706,6 +721,7 @@ struct PickerState { transcript_previews: HashMap, transcript_cells: HashMap, pending_transcript_open: Option, + pending_transcript_cancellation: Option>, transcript_loading_frame_shown: bool, overlay: Option, pager_keymap: PagerKeymap, @@ -735,7 +751,7 @@ enum SessionTranscriptState { } #[derive(Clone)] -struct TranscriptPreviewLine { +pub(crate) struct TranscriptPreviewLine { speaker: TranscriptPreviewSpeaker, text: String, } @@ -785,81 +801,145 @@ async fn load_app_server_page( }) } -async fn load_transcript_preview( +pub(crate) async fn load_transcript_preview( app_server: &mut AppServerSession, thread_id: ThreadId, codex_home: Option<&Path>, ) -> std::io::Result> { const MAX_PREVIEW_LINES: usize = 6; - let thread = app_server - .thread_read(thread_id, /*include_turns*/ true) + let mut thread = app_server + .thread_read(thread_id, /*include_turns*/ false) .await .map_err(std::io::Error::other)?; + if thread.history_mode == ThreadHistoryMode::Legacy { + app_server + .hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + /*config*/ None, + crate::app_server_session::HistoryHydrationScope::Initial, + ) + .await + .map_err(std::io::Error::other)?; + } let cwd = thread.cwd.as_path(); let inline_visualization_context = codex_home.and_then(|codex_home| { ThreadId::from_string(&thread.id) .ok() .and_then(|thread_id| InlineVisualizationContext::new(codex_home, thread_id)) }); - let mut lines = thread - .turns - .iter() - .flat_map(|turn| turn.items.iter()) - .filter_map(|item| match item { - ThreadItem::UserMessage { content, .. } => Some(TranscriptPreviewLine { - speaker: TranscriptPreviewSpeaker::User, - text: content - .iter() - .filter_map(|input| match input { - codex_app_server_protocol::UserInput::Text { text, .. } => { - Some(text.as_str()) - } - _ => None, - }) - .collect::>() - .join(" "), - }), - ThreadItem::AgentMessage { text, .. } => { - let visible_markdown = parse_assistant_markdown(text, cwd).visible_markdown; - let rewritten = crate::inline_visualization::rewrite_inline_visualizations( - &visible_markdown, + let mut lines = if thread.history_mode == ThreadHistoryMode::Paginated { + let mut groups = Vec::new(); + let mut visible_lines = 0_usize; + let mut scanned_items = 0_usize; + let mut cursor = None; + let mut seen_cursors = HashSet::new(); + loop { + let page = app_server + .thread_items_page( + thread_id, + /*turn_id*/ None, + cursor.clone(), + HISTORY_ITEM_PAGE_LIMIT, + ) + .await + .map_err(std::io::Error::other)?; + scanned_items = scanned_items.saturating_add(page.data.len()); + for entry in page.data { + let item_lines = transcript_preview_lines_for_item( + &entry.item, + cwd, inline_visualization_context.as_ref(), ); - let mut text = rewritten.markdown.into_owned(); - for (placeholder, link) in &rewritten.trusted_file_links { - text = text.replace( - &format!( - "{} \n[{}]({placeholder})", - link.markdown_label, link.markdown_destination_label - ), - &format!("{} \n{}", link.display_label, link.destination), - ); + visible_lines = visible_lines.saturating_add(item_lines.len()); + if !item_lines.is_empty() { + groups.push(item_lines); + } + if visible_lines >= MAX_PREVIEW_LINES { + break; } - Some(TranscriptPreviewLine { - speaker: TranscriptPreviewSpeaker::Assistant, - text, - }) } - _ => None, - }) - .flat_map(|line| { - line.text - .lines() - .filter(|text| !text.trim().is_empty()) - .map(move |text| TranscriptPreviewLine { - speaker: line.speaker, - text: text.trim().to_string(), - }) - .collect::>() - }) - .collect::>(); + if visible_lines >= MAX_PREVIEW_LINES || scanned_items >= HISTORY_ITEM_SCAN_LIMIT { + break; + } + let Some(next_cursor) = page + .next_cursor + .filter(|next| seen_cursors.insert(next.clone())) + else { + break; + }; + cursor = Some(next_cursor); + } + groups.into_iter().rev().flatten().collect::>() + } else { + thread + .turns + .iter() + .flat_map(|turn| turn.items.iter()) + .flat_map(|item| { + transcript_preview_lines_for_item(item, cwd, inline_visualization_context.as_ref()) + }) + .collect::>() + }; if lines.len() > MAX_PREVIEW_LINES { lines.drain(..lines.len() - MAX_PREVIEW_LINES); } Ok(lines) } +fn transcript_preview_lines_for_item( + item: &ThreadItem, + cwd: &Path, + inline_visualization_context: Option<&InlineVisualizationContext>, +) -> Vec { + let line = match item { + ThreadItem::UserMessage { content, .. } => TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::User, + text: content + .iter() + .filter_map(|input| match input { + codex_app_server_protocol::UserInput::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" "), + }, + ThreadItem::AgentMessage { text, .. } => { + let visible_markdown = parse_assistant_markdown(text, cwd).visible_markdown; + let rewritten = crate::inline_visualization::rewrite_inline_visualizations( + &visible_markdown, + inline_visualization_context, + ); + let mut text = rewritten.markdown.into_owned(); + for (placeholder, link) in &rewritten.trusted_file_links { + text = text.replace( + &format!( + "{} \n[{}]({placeholder})", + link.markdown_label, link.markdown_destination_label + ), + &format!("{} \n{}", link.display_label, link.destination), + ); + } + TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text, + } + } + _ => return Vec::new(), + }; + + line.text + .lines() + .filter(|text| !text.trim().is_empty()) + .map(|text| TranscriptPreviewLine { + speaker: line.speaker, + text: text.trim().to_string(), + }) + .collect() +} + impl SearchState { fn active_token(&self) -> Option { match self { @@ -978,6 +1058,7 @@ impl PickerState { transcript_previews: HashMap::new(), transcript_cells: HashMap::new(), pending_transcript_open: None, + pending_transcript_cancellation: None, transcript_loading_frame_shown: false, overlay: None, pager_keymap: RuntimeKeymap::defaults().pager, @@ -1084,7 +1165,12 @@ impl PickerState { self.transcript_cells .insert(thread_id, SessionTranscriptState::Loading); self.begin_transcript_loading(thread_id); - (self.picker_loader)(PickerLoadRequest::Transcript { thread_id }); + let (cancellation_tx, cancellation) = oneshot::channel(); + self.pending_transcript_cancellation = Some(cancellation_tx); + (self.picker_loader)(PickerLoadRequest::Transcript { + thread_id, + cancellation, + }); } } } @@ -1096,6 +1182,22 @@ impl PickerState { modifiers, .. } if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit), + key if self.list_keymap.cancel.is_pressed(key) => { + if let Some(thread_id) = self.pending_transcript_open.take() + && matches!( + self.transcript_cells.get(&thread_id), + Some(SessionTranscriptState::Loading) + ) + { + self.transcript_cells.remove(&thread_id); + } + if let Some(cancellation) = self.pending_transcript_cancellation.take() { + let _ = cancellation.send(()); + } + self.transcript_loading_frame_shown = false; + self.request_frame(); + None + } _ => None, } } @@ -1404,6 +1506,7 @@ impl PickerState { self.transcript_cells .insert(thread_id, SessionTranscriptState::Loaded(cells.clone())); if should_open { + self.pending_transcript_cancellation = None; self.open_pending_transcript_if_ready(); } self.request_frame(); @@ -1412,6 +1515,7 @@ impl PickerState { self.transcript_cells .insert(thread_id, SessionTranscriptState::Failed); if self.pending_transcript_open == Some(thread_id) { + self.pending_transcript_cancellation = None; self.pending_transcript_open = None; self.transcript_loading_frame_shown = false; self.inline_error = Some("Could not load transcript preview".to_string()); @@ -4389,7 +4493,7 @@ mod tests { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); let loader: PickerLoader = Arc::new(move |request| { - if let PickerLoadRequest::Transcript { thread_id } = request { + if let PickerLoadRequest::Transcript { thread_id, .. } = request { request_sink.lock().unwrap().push(thread_id); } }); @@ -4480,6 +4584,61 @@ mod tests { assert_eq!(state.query, ""); } + #[tokio::test] + async fn escape_cancels_transcript_loading_and_restores_picker_navigation() { + let thread_id = ThreadId::new(); + let cancellation = Arc::new(Mutex::new(None)); + let cancellation_sink = cancellation.clone(); + let loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Transcript { cancellation, .. } = request { + *cancellation_sink.lock().unwrap() = Some(cancellation); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let mut first = make_row("/tmp/1.jsonl", "2026-05-02T12:00:00Z", "one"); + first.thread_id = Some(thread_id); + state.filtered_rows = vec![ + first, + make_row("/tmp/2.jsonl", "2026-05-02T12:00:00Z", "two"), + ]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + + state + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.pending_transcript_open, None); + assert!(!state.transcript_cells.contains_key(&thread_id)); + assert!( + cancellation + .lock() + .unwrap() + .as_mut() + .expect("transcript cancellation receiver") + .try_recv() + .is_ok() + ); + + state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 1); + } + #[tokio::test] async fn transcript_loading_still_allows_ctrl_c_exit() { let loader = page_only_loader(|_| {}); @@ -4571,7 +4730,7 @@ mod tests { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); let loader: PickerLoader = Arc::new(move |request| { - if let PickerLoadRequest::Transcript { thread_id } = request { + if let PickerLoadRequest::Transcript { thread_id, .. } = request { request_sink.lock().unwrap().push(thread_id); } }); diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index a2f74e7933..f2142cc1de 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::app_server_session::AppServerSession; +use crate::app_server_session::HistoryHydrationScope; use crate::git_action_directives::parse_assistant_markdown; use crate::history_cell::AgentMarkdownCell; use crate::history_cell::HistoryCell; @@ -35,8 +36,18 @@ pub(crate) async fn load_session_transcript( raw_reasoning_visibility: RawReasoningVisibility, codex_home: Option<&std::path::Path>, ) -> std::io::Result { - let thread = app_server - .thread_read(thread_id, /*include_turns*/ true) + let mut thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + .map_err(std::io::Error::other)?; + app_server + .hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + /*config*/ None, + HistoryHydrationScope::Complete, + ) .await .map_err(std::io::Error::other)?; Ok(thread_to_transcript_cells( diff --git a/codex-rs/tui/src/transcript_reflow.rs b/codex-rs/tui/src/transcript_reflow.rs index d95d99c944..3924e8ae1c 100644 --- a/codex-rs/tui/src/transcript_reflow.rs +++ b/codex-rs/tui/src/transcript_reflow.rs @@ -29,6 +29,7 @@ pub(crate) struct TranscriptReflowState { last_reflow_width: Option, pending_reflow_width: Option, pending_until: Option, + visible_history_rows: Option, ran_during_stream: bool, resize_requested_during_stream: bool, } @@ -43,6 +44,16 @@ impl TranscriptReflowState { *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 { + 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 @@ -178,6 +189,24 @@ pub(crate) struct TranscriptWidthChange { 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();