diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index ed957e3a1d..7cbd12273f 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -15,6 +15,7 @@ //! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell //! mutates in place or when its transcript output is time-dependent. +use std::any::TypeId; use std::cell::Cell as StdCell; use std::io::Result; use std::rc::Rc; @@ -31,6 +32,7 @@ use crate::footer_hints::render_footer_line_with_optional_right; use crate::footer_hints::render_footer_separator; use crate::history_cell::HistoryCell; use crate::history_cell::HistoryRenderMode; +use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -657,9 +659,11 @@ impl TranscriptOverlay { } fn user_prompt_positions(cells: &[Arc]) -> Vec { + let start = session_start_index(cells); cells .iter() .enumerate() + .skip(start) .filter_map(|(idx, cell)| cell.is_user_prompt().then_some(idx)) .collect() } @@ -679,10 +683,7 @@ impl TranscriptOverlay { let had_prior_cells = !self.cells.is_empty(); let tail_renderable = self.take_live_tail_renderable(); self.cells.push(cell); - if self.cells.last().is_some_and(|cell| cell.is_user_prompt()) { - self.user_prompt_positions - .push(self.cells.len().saturating_sub(1)); - } + self.user_prompt_positions = Self::user_prompt_positions(&self.cells); self.view.renderables = Self::render_cells( &self.cells, Rc::clone(&self.highlight_cell), @@ -1416,11 +1417,28 @@ fn render_offset_content( copy_height } +fn session_start_index(cells: &[Arc]) -> usize { + let session_start_type = TypeId::of::(); + let type_of = |cell: &Arc| cell.as_any().type_id(); + + cells + .iter() + .rposition(|cell| type_of(cell) == session_start_type) + .map_or(0, |idx| idx + 1) +} + #[cfg(test)] mod tests { use super::*; use crate::history_cell::ReviewDecision; + use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; + use codex_protocol::ThreadId; + use codex_protocol::config_types::ApprovalsReviewer; + use codex_protocol::models::PermissionProfile; + use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -1429,6 +1447,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; use std::time::Instant; + use tempfile::TempDir; use crate::diff_model::FileChange; use crate::exec_cell::CommandOutput; @@ -1436,6 +1455,9 @@ mod tests { use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; + use crate::history_cell::new_session_info; + use crate::legacy_core::config::ConfigBuilder; + use crate::session_state::ThreadSessionState; use codex_protocol::parse_command::ParsedCommand; use ratatui::Terminal as RatatuiTerminal; use ratatui::backend::TestBackend; @@ -1511,6 +1533,46 @@ mod tests { cells } + fn session_info_cell(cwd: &str) -> Arc { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let temp_dir = TempDir::new().expect("tempdir"); + let config = runtime + .block_on( + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build(), + ) + .expect("config"); + let session = ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf(cwd).abs(), + runtime_workspace_roots: Vec::new(), + instruction_source_paths: Vec::new(), + reasoning_effort: Some(ReasoningEffortConfig::High), + message_history: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }; + Arc::new(new_session_info( + &config, "gpt-test", &session, /*is_first_event*/ false, + /*tooltip_override*/ None, /*auth_plan*/ None, + /*show_fast_status*/ false, + )) as Arc + } + fn static_overlay(lines: Vec>, title: &str) -> StaticOverlay { StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap()) } @@ -1920,6 +1982,53 @@ mod tests { ); } + #[test] + fn transcript_prompt_selection_ignores_prompts_before_latest_session_header() { + let mut overlay = transcript_overlay(vec![ + user_cell("old prompt"), + Arc::new(AgentMessageCell::new( + vec![Line::from("old assistant")], + /*is_first_line*/ true, + )), + session_info_cell("/tmp/project"), + user_cell("current first"), + Arc::new(AgentMessageCell::new( + vec![Line::from("current assistant")], + /*is_first_line*/ true, + )), + user_cell("current second"), + ]); + + assert_eq!(overlay.user_prompt_count(), 2); + assert_eq!(overlay.header_title(), "Transcript"); + assert_eq!( + overlay.footer_progress_label( + /*content_height*/ 5, /*total_len*/ 12, /*width*/ 80 + ), + " 2 / 2 · 100% " + ); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!(overlay.selected_user_cell(), Some(5)); + assert_eq!( + overlay.footer_progress_label( + /*content_height*/ 5, /*total_len*/ 12, /*width*/ 80 + ), + " 2 / 2 · 100% " + ); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!(overlay.selected_user_cell(), Some(3)); + assert_eq!( + overlay.footer_progress_label( + /*content_height*/ 5, /*total_len*/ 12, /*width*/ 80 + ), + " 1 / 2 · 100% " + ); + + assert_eq!(overlay.set_highlighted_user_prompt(2), None); + } + #[test] fn selected_user_prompt_keeps_reversed_style_without_role_gutter() { let mut overlay = transcript_overlay(vec![user_cell("selected prompt")]);