diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 13f3ea109c5f..8cb63995dae4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -535,6 +535,7 @@ pub(crate) struct App { has_emitted_history_lines: bool, transcript_reflow: TranscriptReflowState, initial_history_replay_buffer: Option, + pub(crate) scrollback_has_older_history: bool, pub(crate) enhanced_keys_supported: bool, pub(crate) keymap: RuntimeKeymap, @@ -1057,6 +1058,7 @@ See the Codex keymap documentation for supported actions and examples." has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, + scrollback_has_older_history: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 26034a4d8e0d..ce3319452358 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -9,6 +9,7 @@ use super::*; use crate::app_server_session::ForkGoalContinuation; use crate::config_update::format_config_error; use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome; +use crate::pager_overlay::TranscriptHistoryState; #[cfg(target_os = "windows")] use codex_config::types::WindowsSandboxModeToml; @@ -43,6 +44,12 @@ impl App { .await { app_server.cancel_older_history_page(thread_id); + if self.chat_widget.thread_id() == Some(thread_id) + && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() + { + overlay.set_history_state(TranscriptHistoryState::Failed); + tui.frame_requester().schedule_frame(); + } tracing::warn!(%thread_id, error = %err, "failed to load older transcript history"); } } @@ -282,12 +289,13 @@ impl App { self.refresh_in_memory_config_from_disk_best_effort("forking the thread") .await; let config = self.fresh_session_config(); - let started = match app_server - .thread_read(thread_id, /*include_turns*/ true) - .await - { - Ok(thread) => match crate::app_backtrack::backtrack_fork_before_turn_id( - &thread.turns, + let turns = match self.thread_event_channels.get(&thread_id) { + Some(channel) => Some(channel.store.lock().await.turns.clone()), + None => None, + }; + let started = match turns { + Some(turns) => match crate::app_backtrack::backtrack_fork_before_turn_id( + &turns, nth_user_message, &mut prompt, ) { @@ -296,7 +304,7 @@ impl App { || app_server.has_older_history(thread_id) => { let before_turn_id = before_turn_id - .or_else(|| thread.turns.first().map(|turn| turn.id.clone())); + .or_else(|| turns.first().map(|turn| turn.id.clone())); app_server .fork_thread_at( config.clone(), @@ -316,7 +324,9 @@ impl App { } Err(err) => Err(err), }, - Err(err) => Err(err), + None => Err(color_eyre::eyre::eyre!( + "the selected thread is no longer available for prompt editing" + )), }; match started { Ok(forked) => { @@ -352,6 +362,10 @@ impl App { self.insert_history_cell(tui, cell); } AppEvent::EndInitialHistoryReplayBuffer => { + self.scrollback_has_older_history = self + .chat_widget + .thread_id() + .is_some_and(|thread_id| app_server.has_older_history(thread_id)); self.finish_initial_history_replay_buffer(tui); } AppEvent::ConsolidateAgentMessage { diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index e83296d6327e..d47b5a13fd49 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -3,12 +3,56 @@ use std::collections::HashSet; use super::*; +use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT; +use crate::app_server_session::thread_items_page_params; use crate::history_cell::UserHistoryCell; +use crate::pager_overlay::TranscriptHistoryState; use crate::thread_transcript::RawReasoningVisibility; use crate::thread_transcript::thread_items_to_transcript_cells; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ThreadItemsListResponse; impl App { + /// Start one bounded page request shared by transcript-history navigation. + pub(crate) fn request_older_history_page( + &self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> bool { + let Some(cursor) = app_server.begin_older_history_page(thread_id) else { + return false; + }; + tracing::debug!( + %thread_id, + %cursor, + overlay = self.overlay.is_some(), + "loading older transcript history page" + ); + let request_id = app_server.next_request_id(); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed::(ClientRequest::ThreadItemsList { + request_id, + params: thread_items_page_params( + thread_id, + /*turn_id*/ None, + Some(cursor.clone()), + HISTORY_ITEM_PAGE_LIMIT, + ), + }) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::OlderThreadHistoryLoaded { + thread_id, + cursor, + result, + }); + }); + true + } + pub(super) async fn handle_older_history_page( &mut self, tui: &mut tui::Tui, @@ -173,9 +217,24 @@ impl App { .count(), ); } + self.scrollback_has_older_history = app_server.has_older_history(thread_id); + let mut continue_to_start = false; if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { let index = overlay.prepend(cells.clone(), width); self.transcript_cells.splice(index..index, cells); + let previous_state = overlay.set_history_state(if self.scrollback_has_older_history { + TranscriptHistoryState::Partial + } else { + TranscriptHistoryState::Complete + }); + continue_to_start = previous_state == TranscriptHistoryState::LoadingBeginning + && self.scrollback_has_older_history; + } + if continue_to_start + && self.request_older_history_page(app_server, thread_id) + && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() + { + overlay.set_history_state(TranscriptHistoryState::LoadingBeginning); } if self.backtrack.overlay_preview_active { self.apply_backtrack_selection_internal(self.backtrack.nth_user_message); diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index c707ef8d13c3..7c56bcc5c027 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -171,6 +171,7 @@ impl App { self.chat_widget.clear_pending_token_activity_refreshes(); self.chat_widget.clear_pending_rate_limit_reset_hint(); self.initial_history_replay_buffer = None; + self.scrollback_has_older_history = false; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; self.skill_load_warnings.clear(); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 617c2e53f007..f1a38e04b620 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -254,13 +254,11 @@ impl App { } if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) { - // Enter alternate screen and set viewport to full size. - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript( - self.transcript_cells.clone(), - self.keymap.pager.clone(), - )); - tui.frame_requester().schedule_frame(); + self.scrollback_has_older_history = self + .chat_widget + .thread_id() + .is_some_and(|thread_id| app_server.has_older_history(thread_id)); + self.open_transcript_overlay(tui); return; } diff --git a/codex-rs/tui/src/app/safety_buffering.rs b/codex-rs/tui/src/app/safety_buffering.rs index d9f223db0e44..9729a6f48741 100644 --- a/codex-rs/tui/src/app/safety_buffering.rs +++ b/codex-rs/tui/src/app/safety_buffering.rs @@ -3,9 +3,12 @@ use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_server_session::ForkGoalContinuation; +use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT; use crate::chatwidget::ThreadInputState; use crate::chatwidget::ThreadInputStateRestoreMode; use crate::chatwidget::UserMessage; +use codex_app_server_protocol::ThreadHistoryMode; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::UserInput; pub(super) struct SafetyBufferedRetry { @@ -76,9 +79,52 @@ impl App { return; } - let thread = match app_server - .thread_read(thread_id, /*include_turns*/ true) - .await + let thread = match async { + let mut thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await?; + 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?; + } else { + let page = app_server + .thread_turns_page(thread_id, /*cursor*/ None) + .await?; + thread.turns = page.data.into_iter().rev().collect(); + if let Some(turn_index) = thread.turns.iter().position(|turn| turn.id == turn_id) { + let page = app_server + .thread_items_page( + thread_id, + Some(&turn_id), + /*cursor*/ None, + HISTORY_ITEM_PAGE_LIMIT, + ) + .await?; + if page.next_cursor.is_some() { + color_eyre::eyre::bail!( + "Cannot safely retry a turn whose input exceeds the bounded history page." + ); + } + let turn = &mut thread.turns[turn_index]; + turn.items = page + .data + .into_iter() + .rev() + .map(|entry| entry.item) + .collect(); + turn.items_view = TurnItemsView::Full; + } + } + Ok::<_, color_eyre::Report>(thread) + } + .await { Ok(thread) => thread, Err(err) => { diff --git a/codex-rs/tui/src/app/safety_buffering_tests.rs b/codex-rs/tui/src/app/safety_buffering_tests.rs index 4d1c2b71965c..4586e7ab2165 100644 --- a/codex-rs/tui/src/app/safety_buffering_tests.rs +++ b/codex-rs/tui/src/app/safety_buffering_tests.rs @@ -31,3 +31,14 @@ fn retry_rejects_a_stale_or_in_progress_turn() { assert!(safety_retry_fork_point(&in_progress, "missing").is_err()); assert!(safety_retry_fork_point(&previous_in_progress, "turn-2").is_err()); } + +#[test] +fn retry_accepts_a_targeted_latest_turn_and_its_completed_predecessor() { + let turns = vec![ + turn("turn-1", TurnStatus::Completed), + turn("turn-2", TurnStatus::Interrupted), + ]; + + assert!(safety_retry_fork_point(&turns, "turn-2").is_ok()); + assert!(safety_retry_fork_point(&turns[1..], "turn-2").is_ok()); +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 608febaa27bf..51877ce2dc33 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -384,22 +384,24 @@ impl App { error = %resume_err, "failed to resume live thread for selection; falling back to thread/read" ); - let (thread, turns) = match app_server - .thread_read(thread_id, /*include_turns*/ true) + let mut thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await?; + match app_server + .hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + Some(&self.config), + crate::app_server_session::HistoryHydrationScope::Initial, + ) .await { - Ok(thread) => { - let turns = thread.turns.clone(); - (thread, turns) - } - Err(err) if Self::can_fallback_from_include_turns_error(&err) => { - let thread = app_server - .thread_read(thread_id, /*include_turns*/ false) - .await?; - (thread, Vec::new()) - } + Ok(()) => {} + Err(err) if Self::can_fallback_from_include_turns_error(&err) => {} Err(err) => return Err(err), - }; + } + let turns = thread.turns.clone(); if turns.is_empty() { // A `thread/read` fallback without turns would create a blank local replay // channel with no live listener attached, which blocks later real re-attach. diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index f52ef8c013f7..9d80356d8bf6 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -38,6 +38,7 @@ pub(super) async fn make_test_app() -> App { has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, + scrollback_has_older_history: false, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d6c6a6fafcb8..ce2fb4d900a5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4740,6 +4740,7 @@ async fn make_test_app() -> App { has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, + scrollback_has_older_history: false, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), @@ -4809,6 +4810,7 @@ async fn make_test_app_with_channels() -> ( has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, + scrollback_has_older_history: false, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), 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 b835095277fe..307e1182a818 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadItemsListParams; @@ -34,17 +35,27 @@ use tokio::task::JoinHandle; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; +type RecordedRequests = Arc>>; +type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle>); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HistoryCapabilities { + Current, + LegacyOnly, + LegacyOnlyUnsupportedVariant, +} + /// Returns and resets `(thread/loaded/list, thread/read)` request counts. -fn take_backfill_counts(requests: &Arc>>) -> (usize, usize) { +fn take_backfill_counts(requests: &RecordedRequests) -> (usize, usize) { let requests = std::mem::take(&mut *requests.lock().expect("request recorder lock")); ( requests .iter() - .filter(|method| *method == "thread/loaded/list") + .filter(|request| request.method == "thread/loaded/list") .count(), requests .iter() - .filter(|method| *method == "thread/read") + .filter(|request| request.method == "thread/read") .count(), ) } @@ -52,13 +63,25 @@ fn take_backfill_counts(requests: &Arc>>) -> (usize, usize) { /// Starts an embedded app server behind a loopback WebSocket proxy that records JSON-RPC methods. async fn start_recording_app_server( config: &Config, + blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>, + failed_thread_name: Option<&'static str>, +) -> Result { + start_recording_app_server_with_history( + config, + HistoryCapabilities::Current, + blocked_thread_list, + failed_thread_name, + ) + .await +} + +/// Proxies a real app server while optionally rejecting modern pagination like an older server. +async fn start_recording_app_server_with_history( + config: &Config, + history_capabilities: HistoryCapabilities, mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>, failed_thread_name: Option<&'static str>, -) -> Result<( - AppServerSession, - Arc>>, - JoinHandle>, -)> { +) -> Result { let state_db = crate::init_state_db_for_app_server_target(config, &crate::AppServerTarget::Embedded) .await?; @@ -107,42 +130,79 @@ async fn start_recording_app_server( request_sink .lock() .expect("request recorder lock") - .push(request.method.clone()); + .push(request.clone()); let request_id = request.id.clone(); - let request = - serde_json::from_value::(serde_json::to_value(request)?)?; - if let ClientRequest::ThreadList { params, .. } = &request - && let Some((root, started, release)) = blocked_thread_list.take() + let params = request.params.as_ref(); + let requires_pagination = match request.method.as_str() { + "thread/start" => params + .and_then(|params| params.get("historyMode")) + .is_some_and(|mode| !mode.is_null()), + "thread/resume" | "thread/fork" => params + .and_then(|params| params.get("excludeTurns")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + "thread/turns/list" | "thread/items/list" => true, + _ => false, + }; + let response = if matches!( + history_capabilities, + HistoryCapabilities::LegacyOnly + | HistoryCapabilities::LegacyOnlyUnsupportedVariant + ) && requires_pagination { - assert_eq!(params.ancestor_thread_id, Some(root.to_string())); - assert_eq!(params.sort_direction, Some(SortDirection::Desc)); - let _ = started.send(()); - let _ = release.await; - } - let force_failure = matches!( - &request, - ClientRequest::ThreadSetName { params, .. } - if failed_thread_name == Some(params.name.as_str()) - ); - let response = if force_failure { + let (code, message) = if history_capabilities + == HistoryCapabilities::LegacyOnlyUnsupportedVariant + && request.method == "thread/start" + { + (-32602, "unknown variant \"paginated\", expected \"legacy\"") + } else { + (-32601, "method not found") + }; JSONRPCMessage::Error(JSONRPCError { id: request_id, error: JSONRPCErrorError { - code: -32603, - message: "forced thread/name/set failure".to_string(), + code, data: None, + message: message.to_string(), }, }) } else { - match embedded.request(request).await? { - Ok(result) => JSONRPCMessage::Response(JSONRPCResponse { - id: request_id, - result, - }), - Err(error) => JSONRPCMessage::Error(JSONRPCError { + let request = serde_json::from_value::( + serde_json::to_value(request)?, + )?; + if let ClientRequest::ThreadList { params, .. } = &request + && let Some((root, started, release)) = blocked_thread_list.take() + { + assert_eq!(params.ancestor_thread_id, Some(root.to_string())); + assert_eq!(params.sort_direction, Some(SortDirection::Desc)); + let _ = started.send(()); + let _ = release.await; + } + let force_failure = matches!( + &request, + ClientRequest::ThreadSetName { params, .. } + if failed_thread_name == Some(params.name.as_str()) + ); + if force_failure { + JSONRPCMessage::Error(JSONRPCError { id: request_id, - error, - }), + error: JSONRPCErrorError { + code: -32603, + message: "forced thread/name/set failure".to_string(), + data: None, + }, + }) + } else { + match embedded.request(request).await? { + Ok(result) => JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result, + }), + Err(error) => JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error, + }), + } } }; websocket @@ -180,12 +240,40 @@ async fn start_recording_app_server( )) } -#[tokio::test] -async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> { +fn create_legacy_history_rollout(config: &Config) -> Result { + let thread_id = create_fake_rollout( + config.codex_home.as_path(), + "2026-01-02T00-00-00", + "2026-01-02T00:00:00Z", + "legacy history", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .map_err(|err| color_eyre::eyre::eyre!("failed to create history rollout: {err}"))?; + Ok(ThreadId::from_string(&thread_id)?) +} + +fn recorded_params(requests: &RecordedRequests, method: &str) -> Vec { + requests + .lock() + .expect("request recorder lock") + .iter() + .filter(|request| request.method == method) + .map(|request| request.params.clone().unwrap_or(serde_json::Value::Null)) + .collect() +} + +async fn make_history_test_app() -> Result<(App, tempfile::TempDir)> { let mut app = make_test_app().await; 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()); + Ok((app, codex_home)) +} + +#[tokio::test] +async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> { + let (mut app, codex_home) = make_history_test_app().await?; app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100); let thread_id = create_fake_paginated_rollout( codex_home.path(), @@ -377,6 +465,277 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Ok(()) } +#[tokio::test] +async fn transcript_home_loads_every_older_history_page() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + 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(2); + let thread_id = create_fake_paginated_rollout( + codex_home.path(), + "2026-01-02T00-00-00", + "2026-01-02T00:00:00Z", + "multi-page transcript", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .map_err(|error| color_eyre::eyre::eyre!("failed to create paginated rollout: {error}"))?; + let thread_id = ThreadId::from_string(&thread_id)?; + let path = rollout_path( + codex_home.path(), + "2026-01-02T00-00-00", + &thread_id.to_string(), + ); + let mut records = std::fs::read_to_string(&path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "multi-page-turn".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })) + .chain((0..305).map(|index| { + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: "multi-page-turn".to_string(), + item: TurnItem::AgentMessage(AgentMessageItem { + id: format!("history-item-{index}"), + content: vec![AgentMessageContent::Text { + text: format!("history output {index}"), + }], + phase: None, + memory_citation: None, + }), + started_at_ms: None, + completed_at_ms: 0, + }) + })); + for event in events { + records.push(serde_json::json!({ + "timestamp": "2026-01-02T00:00:00Z", + "ordinal": records.len(), + "type": "event_msg", + "payload": serde_json::to_value(event)?, + })); + } + let records = records + .into_iter() + .map(|record| record.to_string()) + .collect::>() + .join("\n"); + std::fs::write(path, format!("{records}\n"))?; + + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let started = app_server + .resume_thread( + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let initial_cells = crate::thread_transcript::thread_items_to_transcript_cells( + Some(thread_id), + &app.config.cwd, + started.turns.iter().flat_map(|turn| turn.items.clone()), + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(app.config.codex_home.as_path()), + ); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.transcript_cells = initial_cells; + app.scrollback_has_older_history = app_server.has_older_history(thread_id); + assert!(app.scrollback_has_older_history); + while app_event_rx.try_recv().is_ok() {} + let initial_page_requests = recorded_params(&requests, "thread/items/list").len(); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.open_transcript_overlay(&mut tui); + + app.handle_backtrack_overlay_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), + ) + .await?; + while app_server.has_older_history(thread_id) { + let event = tokio::time::timeout(Duration::from_secs(5), app_event_rx.recv()) + .await? + .ok_or_else(|| color_eyre::eyre::eyre!("history event channel closed"))?; + if matches!(event, AppEvent::OlderThreadHistoryLoaded { .. }) { + app.handle_event(&mut tui, &mut app_server, event).await?; + } + } + + assert!(recorded_params(&requests, "thread/items/list").len() >= initial_page_requests + 3); + assert!(app.transcript_cells.iter().any(|cell| { + cell.display_lines(/*width*/ 80) + .iter() + .any(|line| line.to_string().contains("history output 0")) + })); + let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() else { + panic!("expected transcript overlay after Home navigation"); + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12, + ); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let visible = (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(visible.contains("history output 0"), "{visible}"); + assert!(!visible.contains("history output 304"), "{visible}"); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let legacy_thread_id = create_legacy_history_rollout(&app.config)?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyOnly, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + + let started = app_server.start_thread(&app.config).await?; + let resumed = app_server + .resume_thread( + app.config.clone(), + legacy_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let forked = app_server + .fork_thread(app.config.clone(), legacy_thread_id) + .await?; + + assert_ne!(started.session.thread_id, legacy_thread_id); + assert_eq!(resumed.session.thread_id, legacy_thread_id); + assert_ne!(forked.session.thread_id, legacy_thread_id); + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 2); + assert_eq!(starts[0]["historyMode"], "paginated"); + assert_eq!(starts[1]["historyMode"], serde_json::Value::Null); + + for method in ["thread/resume", "thread/fork"] { + let params = recorded_params(&requests, method); + assert_eq!(params.len(), 1, "legacy {method} must not be reprobed"); + assert_ne!(params[0]["excludeTurns"], true); + } + assert!(recorded_params(&requests, "thread/turns/list").is_empty()); + assert!(recorded_params(&requests, "thread/items/list").is_empty()); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_legacy_history_start_retries_unsupported_paginated_variant() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyOnlyUnsupportedVariant, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + + app_server.start_thread(&app.config).await?; + + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 2); + assert_eq!(starts[0]["historyMode"], "paginated"); + assert_eq!(starts[1]["historyMode"], serde_json::Value::Null); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LegacyHistoryRequest { + Resume, + Fork, +} + +async fn assert_remote_legacy_history_retry(request: LegacyHistoryRequest) -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let legacy_thread_id = create_legacy_history_rollout(&app.config)?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyOnly, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + + let method = match request { + LegacyHistoryRequest::Resume => { + let resumed = app_server + .resume_thread( + app.config.clone(), + legacy_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.thread_id, legacy_thread_id); + "thread/resume" + } + LegacyHistoryRequest::Fork => { + let forked = app_server + .fork_thread(app.config.clone(), legacy_thread_id) + .await?; + assert_ne!(forked.session.thread_id, legacy_thread_id); + "thread/fork" + } + }; + let attempts = recorded_params(&requests, method); + if request == LegacyHistoryRequest::Resume { + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0]["excludeTurns"], true); + } else { + assert_eq!(attempts.len(), 1); + } + assert_ne!( + attempts.last().expect("history request")["excludeTurns"], + true + ); + assert!(recorded_params(&requests, "thread/turns/list").is_empty()); + assert!(recorded_params(&requests, "thread/items/list").is_empty()); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_legacy_history_resume_retries_generic_method_not_found() -> Result<()> { + assert_remote_legacy_history_retry(LegacyHistoryRequest::Resume).await +} + +#[tokio::test] +async fn remote_legacy_history_fork_avoids_unsupported_fields() -> Result<()> { + assert_remote_legacy_history_retry(LegacyHistoryRequest::Fork).await +} #[test] fn fresh_session_applies_requested_name() -> Result<()> { const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; @@ -420,7 +779,7 @@ fn fresh_session_applies_requested_name() -> Result<()> { .lock() .expect("request recorder lock") .iter() - .any(|method| method == "thread/name/set"), + .any(|request| request.method == "thread/name/set"), "fresh session should be named through the app server" ); let thread = app_server @@ -664,7 +1023,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { .lock() .expect("request recorder lock") .iter() - .filter(|method| *method == "thread/list") + .filter(|request| request.method == "thread/list") .count(), 1 ); diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 110226c6eda6..6eebdf2beaeb 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -27,8 +27,6 @@ use std::sync::Arc; 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; @@ -38,11 +36,10 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; +use crate::pager_overlay::TranscriptHistoryState; use crate::tui; use crate::tui::TuiEvent; -use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; @@ -104,30 +101,17 @@ impl App { && matches!(key_event.code, KeyCode::Esc | KeyCode::Left) && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat))) && let Some(thread_id) = self.chat_widget.thread_id() - && let Some(cursor) = app_server.begin_older_history_page(thread_id) + && app_server.has_older_history(thread_id) + && self.request_older_history_page(app_server, thread_id) { - let request_id = app_server.next_request_id(); - let request_handle = app_server.request_handle(); - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let result = request_handle - .request_typed::(ClientRequest::ThreadItemsList { - request_id, - params: thread_items_page_params( - thread_id, - /*turn_id*/ None, - Some(cursor.clone()), - HISTORY_ITEM_PAGE_LIMIT, - ), - }) - .await - .map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::OlderThreadHistoryLoaded { - thread_id, - cursor, - result, + if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { + overlay.set_history_state(if overlay.should_load_from_start(*key_event) { + TranscriptHistoryState::LoadingBeginning + } else { + TranscriptHistoryState::LoadingOlder }); - }); + } + tui.frame_requester().schedule_frame(); } if self.backtrack.overlay_preview_active { match event { @@ -237,6 +221,11 @@ impl App { self.transcript_cells.clone(), self.keymap.pager.clone(), )); + if self.scrollback_has_older_history + && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() + { + overlay.set_history_state(TranscriptHistoryState::Partial); + } tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d3bf82d2cd58..1a2a0833d7ec 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -143,6 +143,7 @@ use uuid::Uuid; const JSONRPC_INVALID_REQUEST: i64 = -32600; const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; +const JSONRPC_INVALID_PARAMS: i64 = -32602; pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again."; const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update"; @@ -158,10 +159,77 @@ enum ForkPresentation { SideConversation, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ThreadHistorySupport { + Paginated, + LegacyOnly, +} + fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") } +fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool { + if source.code == JSONRPC_METHOD_NOT_FOUND { + return true; + } + + if !matches!( + source.code, + JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS + ) { + return false; + } + + let message = source.message.to_ascii_lowercase(); + [ + "historymode", + "history mode", + "excludeturns", + "exclude turns", + "thread/turns/list", + "thread/items/list", + ] + .into_iter() + .any(|field| message.contains(field)) + || (message.contains("paginated") + && ["unknown variant", "unsupported variant", "invalid enum"] + .into_iter() + .any(|error| message.contains(error))) +} + +async fn request_thread_start_with_history_fallback( + request_handle: &AppServerRequestHandle, + request_id: RequestId, + mut params: ThreadStartParams, +) -> std::result::Result<(ThreadStartResponse, ThreadHistorySupport), TypedRequestError> { + match request_handle + .request_typed(ClientRequest::ThreadStart { + request_id, + params: params.clone(), + }) + .await + { + Ok(response) => Ok((response, ThreadHistorySupport::Paginated)), + Err(TypedRequestError::Server { source, .. }) + if params.history_mode.is_some() && is_history_pagination_unsupported(&source) => + { + params.history_mode = None; + let response = request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::String(format!( + "legacy-thread-start-{}", + Uuid::new_v4() + )), + params, + }) + .await?; + Ok((response, ThreadHistorySupport::LegacyOnly)) + } + Err(err) => Err(err), + } +} + fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { source.code == JSONRPC_METHOD_NOT_FOUND || (source.code == JSONRPC_INVALID_REQUEST @@ -196,6 +264,7 @@ pub(crate) struct AppServerSession { history_pagination: HashMap, remote_cwd_override: Option, thread_params_mode: ThreadParamsMode, + history_support: ThreadHistorySupport, thread_settings_update_supported: bool, default_model: Option, available_models: Vec, @@ -269,6 +338,7 @@ impl AppServerSession { history_pagination: HashMap::new(), remote_cwd_override: None, thread_params_mode, + history_support: ThreadHistorySupport::Paginated, thread_settings_update_supported: true, default_model: None, available_models: Vec::new(), @@ -523,21 +593,25 @@ impl AppServerSession { ) -> Result { let request_id = self.next_request_id(); let session_config = self.session_config_with_effective_service_tier(config); - let response: ThreadStartResponse = self - .client - .request_typed(ClientRequest::ThreadStart { - request_id, - params: thread_start_params_from_config( - &session_config, - self.thread_params_mode(), - self.remote_cwd_override.as_deref(), - session_start_source, - ), - }) - .await - .map_err(|err| { - bootstrap_request_error("thread/start failed during TUI bootstrap", err) - })?; + let mut params = thread_start_params_from_config( + &session_config, + self.thread_params_mode(), + self.remote_cwd_override.as_deref(), + session_start_source, + ); + if self.history_support == ThreadHistorySupport::LegacyOnly { + params.history_mode = None; + } + let request_handle = self.request_handle(); + let (response, history_support) = + request_thread_start_with_history_fallback(&request_handle, request_id, params) + .await + .map_err(|err| { + bootstrap_request_error("thread/start failed during TUI bootstrap", err) + })?; + if history_support == ThreadHistorySupport::LegacyOnly { + self.history_support = ThreadHistorySupport::LegacyOnly; + } started_thread_from_start_response(response, config, self.thread_params_mode()).await } @@ -553,29 +627,47 @@ impl AppServerSession { } else { self.session_config_with_effective_service_tier(&config) }; - let exclude_turns = self - .history_pagination - .get(&thread_id) - .is_some_and(|state| state.history_mode == ThreadHistoryMode::Paginated); - let mut response: ThreadResumeResponse = self + let mut params = thread_resume_params_from_config( + session_config, + thread_id, + self.thread_params_mode(), + self.remote_cwd_override.as_deref(), + model_settings, + ); + params.exclude_turns = self.history_support == ThreadHistorySupport::Paginated + && self + .history_pagination + .get(&thread_id) + .is_none_or(|state| state.history_mode == ThreadHistoryMode::Paginated); + let mut response: ThreadResumeResponse = match self .client .request_typed(ClientRequest::ThreadResume { request_id, - params: ThreadResumeParams { - exclude_turns, - ..thread_resume_params_from_config( - session_config, - thread_id, - self.thread_params_mode(), - self.remote_cwd_override.as_deref(), - model_settings, - ) - }, + params: params.clone(), }) .await - .map_err(|err| { - bootstrap_request_error("thread/resume failed during TUI bootstrap", err) - })?; + { + Ok(response) => response, + Err(TypedRequestError::Server { source, .. }) + if params.exclude_turns && is_history_pagination_unsupported(&source) => + { + self.history_support = ThreadHistorySupport::LegacyOnly; + params.exclude_turns = false; + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadResume { request_id, params }) + .await + .map_err(|err| { + bootstrap_request_error("thread/resume failed during TUI bootstrap", err) + })? + } + Err(err) => { + return Err(bootstrap_request_error( + "thread/resume failed during TUI bootstrap", + err, + )); + } + }; self.hydrate_initial_thread_history( &mut response.thread, response.turns_backwards_cursor.clone(), @@ -660,34 +752,54 @@ impl AppServerSession { .ok(), ForkPresentation::SideConversation => None, }; - let exclude_turns = fork_parent - .as_ref() - .is_some_and(|thread| thread.history_mode == ThreadHistoryMode::Paginated) - || presentation == ForkPresentation::SideConversation; + let exclude_turns = self.history_support == ThreadHistorySupport::Paginated + && (fork_parent + .as_ref() + .is_some_and(|thread| thread.history_mode == ThreadHistoryMode::Paginated) + || presentation == ForkPresentation::SideConversation); let request_id = self.next_request_id(); let session_config = self.session_config_with_effective_service_tier(&config); - let response: ThreadForkResponse = self + let mut params = ThreadForkParams { + last_turn_id, + before_turn_id, + defer_goal_continuation: goal_continuation == ForkGoalContinuation::DeferUntilNextTurn, + exclude_turns, + ..thread_fork_params_from_config( + session_config, + thread_id, + self.thread_params_mode(), + self.remote_cwd_override.as_deref(), + ) + }; + let response: ThreadForkResponse = match self .client .request_typed(ClientRequest::ThreadFork { request_id, - params: ThreadForkParams { - last_turn_id, - before_turn_id, - defer_goal_continuation: goal_continuation - == ForkGoalContinuation::DeferUntilNextTurn, - exclude_turns, - ..thread_fork_params_from_config( - session_config, - thread_id, - self.thread_params_mode(), - self.remote_cwd_override.as_deref(), - ) - }, + params: params.clone(), }) .await - .map_err(|err| { - bootstrap_request_error("thread/fork failed during TUI bootstrap", err) - })?; + { + Ok(response) => response, + Err(TypedRequestError::Server { source, .. }) + if params.exclude_turns && is_history_pagination_unsupported(&source) => + { + self.history_support = ThreadHistorySupport::LegacyOnly; + params.exclude_turns = false; + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadFork { request_id, params }) + .await + .map_err(|err| { + bootstrap_request_error("thread/fork failed during TUI bootstrap", err) + })? + } + Err(err) => { + return Err(bootstrap_request_error( + "thread/fork failed during TUI bootstrap", + err, + )); + } + }; let mut response = response; if presentation == ForkPresentation::Regular && !response.thread.ephemeral { self.hydrate_initial_thread_history( @@ -1351,18 +1463,19 @@ pub(crate) async fn start_thread_with_request_handle( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option, ) -> Result { - let response: ThreadStartResponse = request_handle - .request_typed(ClientRequest::ThreadStart { - request_id: RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())), - params: thread_start_params_from_config( - &config, - thread_params_mode, - remote_cwd_override.as_deref(), - /*session_start_source*/ None, - ), - }) - .await - .map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?; + let request_id = RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())); + let params = thread_start_params_from_config( + &config, + thread_params_mode, + remote_cwd_override.as_deref(), + /*session_start_source*/ None, + ); + let (response, _history_support) = + request_thread_start_with_history_fallback(&request_handle, request_id, params) + .await + .map_err(|err| { + bootstrap_request_error("thread/start failed during TUI bootstrap", err) + })?; started_thread_from_start_response(response, &config, thread_params_mode).await } @@ -1596,6 +1709,7 @@ fn thread_start_params_from_config( permissions, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), + history_mode: (!config.ephemeral).then_some(ThreadHistoryMode::Paginated), session_start_source, thread_source: Some(ThreadSource::User), developer_instructions: with_terminal_visualization_instructions( @@ -2069,6 +2183,75 @@ mod tests { } } + #[test] + fn history_pagination_compat_detects_unsupported_server_fields() { + let cases = [ + (JSONRPC_INVALID_PARAMS, "unknown field `historyMode`", true), + ( + JSONRPC_INVALID_REQUEST, + "thread/resume.excludeTurns requires experimentalApi capability", + true, + ), + ( + JSONRPC_INVALID_REQUEST, + "thread/fork.excludeTurns requires experimentalApi capability", + true, + ), + ( + JSONRPC_METHOD_NOT_FOUND, + "unknown method thread/turns/list", + true, + ), + (JSONRPC_METHOD_NOT_FOUND, "method not found", true), + ( + JSONRPC_INVALID_PARAMS, + "unknown variant \"paginated\", expected \"legacy\"", + true, + ), + ( + JSONRPC_INVALID_PARAMS, + "invalid enum value `paginated`", + true, + ), + ( + JSONRPC_INVALID_PARAMS, + "paginated thread was not found", + false, + ), + (JSONRPC_INVALID_PARAMS, "invalid thread id", false), + ]; + + for (code, message, expected) in cases { + let source = JSONRPCErrorError { + code, + data: None, + message: message.to_string(), + }; + assert_eq!( + is_history_pagination_unsupported(&source), + expected, + "{message}" + ); + } + } + + #[tokio::test] + async fn ephemeral_thread_start_does_not_request_paginated_history() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&temp_dir).await; + config.ephemeral = true; + + let params = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + + assert_eq!(params.ephemeral, Some(true)); + assert_eq!(params.history_mode, None); + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/app_server_session/history.rs b/codex-rs/tui/src/app_server_session/history.rs index 93a44f35f657..e42f5287c32f 100644 --- a/codex-rs/tui/src/app_server_session/history.rs +++ b/codex-rs/tui/src/app_server_session/history.rs @@ -130,7 +130,7 @@ impl AppServerSession { .wrap_err("failed to load a bounded thread item page") } - async fn thread_turns_page( + pub(crate) async fn thread_turns_page( &mut self, thread_id: ThreadId, cursor: Option, diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index c32c1b4ceda7..1bbdb653cadf 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -154,6 +154,8 @@ struct PagerView { keymap: PagerKeymap, last_content_height: Option, last_rendered_height: Option, + /// Percentages are meaningful only when the full scrollable history is known. + scroll_percentage_visible: bool, /// If set, on next render ensure this chunk is visible. pending_scroll_chunk: Option, } @@ -172,6 +174,7 @@ impl PagerView { keymap, last_content_height: None, last_rendered_height: None, + scroll_percentage_visible: true, pending_scroll_chunk: None, } } @@ -261,6 +264,9 @@ impl PagerView { Span::from("─".repeat(sep_rect.width as usize)) .dim() .render(sep_rect, buf); + if !self.scroll_percentage_visible { + return; + } let percent = if total_len == 0 { 100 } else { @@ -471,6 +477,35 @@ impl Renderable for HyperlinkLinesRenderable { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum TranscriptHistoryState { + #[default] + Idle, + LoadingOlder, + LoadingBeginning, + Partial, + Failed, + Complete, +} + +impl TranscriptHistoryState { + fn has_unloaded_history(self) -> bool { + matches!( + self, + Self::LoadingOlder | Self::LoadingBeginning | Self::Partial | Self::Failed + ) + } + + fn session_header_placeholder(self) -> Option<&'static str> { + match self { + Self::LoadingOlder | Self::LoadingBeginning => Some("Loading earlier messages..."), + Self::Partial => Some("Earlier messages are available — scroll up to load them"), + Self::Failed => Some("Earlier messages unavailable — scroll up to retry"), + Self::Idle | Self::Complete => None, + } + } +} + pub(crate) struct TranscriptOverlay { /// Pager UI state and the renderables currently displayed. /// @@ -482,6 +517,7 @@ pub(crate) struct TranscriptOverlay { highlight_cell: Option, /// Cache key for the render-only live tail appended after committed cells. live_tail_key: Option, + history_state: TranscriptHistoryState, is_done: bool, } @@ -508,7 +544,11 @@ impl TranscriptOverlay { pub(crate) fn new(transcript_cells: Vec>, keymap: PagerKeymap) -> Self { Self { view: PagerView::new( - Self::render_cells(&transcript_cells, /*highlight_cell*/ None), + Self::render_cells( + &transcript_cells, + /*highlight_cell*/ None, + TranscriptHistoryState::Idle, + ), "T R A N S C R I P T".to_string(), usize::MAX, keymap, @@ -516,18 +556,45 @@ impl TranscriptOverlay { cells: transcript_cells, highlight_cell: None, live_tail_key: None, + history_state: TranscriptHistoryState::Idle, is_done: false, } } + pub(crate) fn set_history_state( + &mut self, + state: TranscriptHistoryState, + ) -> TranscriptHistoryState { + let previous = self.history_state; + if previous == state { + return previous; + } + if previous == TranscriptHistoryState::LoadingBeginning + && state == TranscriptHistoryState::Complete + { + self.view.scroll_offset = 0; + } + self.history_state = state; + self.view.scroll_percentage_visible = !state.has_unloaded_history(); + if self + .cells + .iter() + .any(|cell| cell.as_any().is::()) + { + self.rebuild_renderables(); + } + previous + } + fn render_cells( cells: &[Arc], highlight_cell: Option, + history_state: TranscriptHistoryState, ) -> Vec> { cells .iter() .enumerate() - .map(|(i, cell)| Self::render_cell(cell, i, highlight_cell)) + .map(|(i, cell)| Self::render_cell(cell, i, highlight_cell, history_state)) .collect() } @@ -536,7 +603,13 @@ impl TranscriptOverlay { cell: &Arc, index: usize, highlight_cell: Option, + history_state: TranscriptHistoryState, ) -> Box { + if cell.as_any().is::() + && let Some(placeholder) = history_state.session_header_placeholder() + { + return Box::new(Line::from(placeholder).dim()); + } let cell_renderable = CellRenderable { cell: cell.clone(), highlighted: highlight_cell == Some(index), @@ -571,7 +644,12 @@ impl TranscriptOverlay { let follow_bottom = self.view.is_scrolled_to_bottom(); let had_prior_cells = !self.cells.is_empty(); let tail_renderable = self.take_live_tail_renderable(); - let cell_renderable = Self::render_cell(&cell, self.cells.len(), self.highlight_cell); + let cell_renderable = Self::render_cell( + &cell, + self.cells.len(), + self.highlight_cell, + self.history_state, + ); self.cells.push(cell); self.view.renderables.push(cell_renderable); if let Some(tail) = tail_renderable { @@ -600,7 +678,7 @@ impl TranscriptOverlay { /// Returns whether an upward navigation is close enough to request older history. pub(crate) fn should_load_older(&self, key_event: KeyEvent) -> bool { - self.view.keymap.jump_top.is_pressed(key_event) + self.should_load_from_start(key_event) || (self.view.scroll_offset <= self.view.last_content_height.unwrap_or(/*default*/ 0) && (self.view.keymap.scroll_up.is_pressed(key_event) @@ -608,6 +686,10 @@ impl TranscriptOverlay { || self.view.keymap.half_page_up.is_pressed(key_event))) } + pub(crate) fn should_load_from_start(&self, key_event: KeyEvent) -> bool { + self.view.keymap.jump_top.is_pressed(key_event) + } + /// Prepends history without moving visible content and returns its insertion index. pub(crate) fn prepend(&mut self, cells: Vec>, width: u16) -> usize { if cells.is_empty() { @@ -778,7 +860,8 @@ impl TranscriptOverlay { fn rebuild_renderables(&mut self) { let tail_renderable = self.take_live_tail_renderable(); - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.view.renderables = + Self::render_cells(&self.cells, self.highlight_cell, self.history_state); if let Some(tail) = tail_renderable { self.view.renderables.push(tail); } @@ -844,8 +927,32 @@ impl TranscriptOverlay { let top = Rect::new(area.x, area.y, area.width, top_h); let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); self.view.render(top, buf); + self.render_history_state(top, buf); self.render_hints(bottom, buf); } + + fn render_history_state(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 { + return; + } + let label = match self.history_state { + TranscriptHistoryState::Idle => return, + TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning => { + " loading older history... " + } + TranscriptHistoryState::Partial => " partial history | PgUp for earlier ", + TranscriptHistoryState::Failed => " history unavailable | PgUp to retry ", + TranscriptHistoryState::Complete => " start of history ", + }; + let width = (label.chars().count() as u16).min(area.width); + let status_area = Rect::new( + area.right().saturating_sub(width), + area.y, + width, + /*height*/ 1, + ); + Span::from(label).dim().render(status_area, buf); + } } impl TranscriptOverlay { @@ -1117,8 +1224,12 @@ mod tests { lines: vec![Line::from("recent")], })]); - assert!(overlay.should_load_older(KeyEvent::new( - KeyCode::Home, + let home = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE); + + assert!(overlay.should_load_older(home)); + assert!(overlay.should_load_from_start(home)); + assert!(!overlay.should_load_from_start(KeyEvent::new( + KeyCode::PageUp, crossterm::event::KeyModifiers::NONE, ))); } @@ -1142,6 +1253,31 @@ mod tests { ); } + #[test] + fn transcript_overlay_snapshots_paginated_history_states() { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: vec![Line::from("recent transcript")], + })]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 72, /*height*/ 10, + ); + let mut snapshots = String::new(); + + for (name, state) in [ + ("loading", TranscriptHistoryState::LoadingOlder), + ("partial", TranscriptHistoryState::Partial), + ("failed", TranscriptHistoryState::Failed), + ("complete", TranscriptHistoryState::Complete), + ] { + overlay.set_history_state(state); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + snapshots.push_str(&format!("--- {name} ---\n{}", buffer_to_text(&buf, area))); + } + + assert_snapshot!("transcript_overlay_paginated_history_states", snapshots); + } + #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap new file mode 100644 index 000000000000..075e70bccb39 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap @@ -0,0 +1,47 @@ +--- +source: tui/src/pager_overlay.rs +expression: snapshots +--- +--- loading --- +/ T R A N S C R I P T / / / / / / / / / / / / loading older history... +recent transcript +~ +~ +~ +~ +──────────────────────────────────────────────────────────────────────── + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q to quit esc to edit prev + +--- partial --- +/ T R A N S C R I P T / / / / / / / partial history | PgUp for earlier +recent transcript +~ +~ +~ +~ +──────────────────────────────────────────────────────────────────────── + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q to quit esc to edit prev + +--- failed --- +/ T R A N S C R I P T / / / / / / / history unavailable | PgUp to retry +recent transcript +~ +~ +~ +~ +──────────────────────────────────────────────────────────────────────── + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q to quit esc to edit prev + +--- complete --- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / start of history +recent transcript +~ +~ +~ +~ +───────────────────────────────────────────────────────────────── 100% ─ + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q to quit esc to edit prev