diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..33f523298a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -45,13 +45,16 @@ use crate::pager_overlay::Overlay; use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; +use crate::resume_picker::PreferredSession; use crate::resume_picker::SessionSelection; +use crate::resume_picker::SessionTarget; #[cfg(test)] use crate::test_support::PathBufExt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; +use chrono::Utc; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; @@ -326,6 +329,29 @@ fn session_summary( }) } +fn session_transition_history_lines( + summary: Option, + include_resume_picker_hint: bool, +) -> Vec> { + let mut lines = Vec::new(); + if let Some(summary) = summary { + lines.push(summary.usage_line.into()); + if let Some(command) = summary.resume_command { + let spans = vec!["To continue this session, run ".into(), command.cyan()]; + lines.push(spans.into()); + } + } + if include_resume_picker_hint { + let spans = vec![ + "Previous session available via ".into(), + "/resume".magenta(), + ".".into(), + ]; + lines.push(spans.into()); + } + lines +} + fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { response .skills @@ -1002,6 +1028,7 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + last_displaced_session: Option, } #[derive(Default)] @@ -3263,6 +3290,24 @@ impl App { self.sync_active_agent_label(); } + fn displaced_session_for_resume(&self) -> Option { + let thread_id = self.chat_widget.thread_id()?; + let thread_name = self.chat_widget.thread_name(); + let path = self + .chat_widget + .rollout_path() + .filter(|path| !path.as_os_str().is_empty()); + Some(PreferredSession { + target: SessionTarget { path, thread_id }, + preview: thread_name + .clone() + .unwrap_or_else(|| String::from("Previous session")), + thread_name, + cwd: Some(self.config.cwd.to_path_buf()), + updated_at: Utc::now(), + }) + } + async fn start_fresh_session_with_summary_hint( &mut self, tui: &mut tui::Tui, @@ -3297,12 +3342,11 @@ impl App { self.chat_widget.add_error_message(format!( "Failed to attach to fresh app-server thread: {err}" )); - } else if let Some(summary) = summary { - let mut lines: Vec> = vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); - } + } else if summary.is_some() || self.last_displaced_session.is_some() { + let lines = session_transition_history_lines( + summary, + self.last_displaced_session.is_some(), + ); self.chat_widget.add_plain_history_lines(lines); } } @@ -3791,6 +3835,7 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + last_displaced_session: None, }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) @@ -4012,10 +4057,12 @@ impl App { ) -> Result { match event { AppEvent::NewSession => { + self.last_displaced_session = self.displaced_session_for_resume(); self.start_fresh_session_with_summary_hint(tui, app_server) .await; } AppEvent::ClearUi => { + self.last_displaced_session = self.displaced_session_for_resume(); self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); @@ -4049,6 +4096,7 @@ impl App { /*show_all*/ false, /*include_non_interactive*/ false, picker_app_server, + self.last_displaced_session.clone(), ) .await? { @@ -4110,16 +4158,10 @@ impl App { .await { Ok(()) => { - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } + self.last_displaced_session = None; + let lines = + session_transition_history_lines(summary, false); + if !lines.is_empty() { self.chat_widget.add_plain_history_lines(lines); } } @@ -4170,16 +4212,9 @@ impl App { .await { Ok(()) => { - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } + self.last_displaced_session = None; + let lines = session_transition_history_lines(summary, false); + if !lines.is_empty() { self.chat_widget.add_plain_history_lines(lines); } } @@ -9100,6 +9135,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + last_displaced_session: None, } } @@ -9154,6 +9190,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + last_displaced_session: None, }, rx, op_rx, @@ -10926,4 +10963,69 @@ guardian_approval = true Some("codex resume my-session".to_string()) ); } + + #[test] + fn session_transition_history_lines_include_resume_picker_hint() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let thread_id = + ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id"); + let summary = + session_summary(usage, Some(thread_id), Some("my-session".to_string())).expect("line"); + let rendered = session_transition_history_lines(Some(summary), /*include_hint*/ true) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert_snapshot!( + "session_transition_history_lines_with_resume_picker_hint", + rendered + ); + } + + #[tokio::test] + async fn displaced_session_for_resume_captures_current_thread_metadata() { + let mut app = make_test_app().await; + let thread_id = + ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id"); + let rollout_path = + PathBuf::from("/tmp/sessions/123e4567-e89b-12d3-a456-426614174000.jsonl"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); + app.chat_widget.handle_thread_session(ThreadSessionState { + thread_id, + forked_from_id: None, + thread_name: Some(String::from("saved-session")), + model: String::from("gpt-test"), + model_provider_id: String::from("test-provider"), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(rollout_path.clone()), + }); + + let displaced = app + .displaced_session_for_resume() + .expect("current thread should be captured"); + assert_eq!(displaced.target.thread_id, thread_id); + assert_eq!(displaced.target.path, Some(rollout_path)); + assert_eq!(displaced.thread_name, Some(String::from("saved-session"))); + assert_eq!(displaced.preview, String::from("saved-session")); + assert_eq!(displaced.cwd, Some(PathBuf::from("/tmp/project"))); + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 991c91c550..236de87416 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1258,6 +1258,7 @@ async fn run_ratatui_app( cli.resume_show_all, cli.resume_include_non_interactive, app_server, + None, ) .await? { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 53f33bfb0e..b212478662 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -69,6 +69,15 @@ pub enum SessionSelection { Exit, } +#[derive(Clone, Debug)] +pub(crate) struct PreferredSession { + pub(crate) target: SessionTarget, + pub(crate) preview: String, + pub(crate) thread_name: Option, + pub(crate) cwd: Option, + pub(crate) updated_at: DateTime, +} + #[derive(Clone, Copy, Debug)] pub enum SessionPickerAction { Resume, @@ -116,6 +125,13 @@ enum ProviderFilter { type PageLoader = Arc; +struct SessionPickerOptions { + show_all: bool, + action: SessionPickerAction, + is_remote: bool, + preferred_session: Option, +} + enum BackgroundEvent { PageLoaded { request_token: usize, @@ -138,6 +154,17 @@ struct PickerPage { reached_scan_cap: bool, } +struct PickerStateInit { + codex_home: PathBuf, + requester: FrameRequester, + page_loader: PageLoader, + provider_filter: ProviderFilter, + show_all: bool, + filter_cwd: Option, + action: SessionPickerAction, + preferred_session: Option, +} + /// Interactive session picker that lists recorded rollout files with simple /// search and pagination. /// @@ -169,6 +196,7 @@ pub async fn run_resume_picker_with_app_server( show_all: bool, include_non_interactive: bool, app_server: AppServerSession, + preferred_session: Option, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); @@ -180,11 +208,14 @@ pub async fn run_resume_picker_with_app_server( run_session_picker_with_loader( tui, config, - show_all, - SessionPickerAction::Resume, - is_remote, spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx), bg_rx, + SessionPickerOptions { + show_all, + action: SessionPickerAction::Resume, + is_remote, + preferred_session, + }, ) .await } @@ -205,13 +236,16 @@ pub async fn run_fork_picker_with_app_server( run_session_picker_with_loader( tui, config, - show_all, - SessionPickerAction::Fork, - is_remote, spawn_app_server_page_loader( app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx, ), bg_rx, + SessionPickerOptions { + show_all, + action: SessionPickerAction::Fork, + is_remote, + preferred_session: None, + }, ) .await } @@ -227,11 +261,14 @@ async fn run_session_picker( run_session_picker_with_loader( tui, config, - show_all, - action, - /*is_remote*/ false, spawn_rollout_page_loader(config, bg_tx), bg_rx, + SessionPickerOptions { + show_all, + action, + is_remote: false, + preferred_session: None, + }, ) .await } @@ -239,20 +276,18 @@ async fn run_session_picker( async fn run_session_picker_with_loader( tui: &mut Tui, config: &Config, - show_all: bool, - action: SessionPickerAction, - is_remote: bool, page_loader: PageLoader, bg_rx: mpsc::UnboundedReceiver, + options: SessionPickerOptions, ) -> Result { let alt = AltScreenGuard::enter(tui); - let provider_filter = if is_remote { + let provider_filter = if options.is_remote { ProviderFilter::Any } else { ProviderFilter::MatchDefault(config.model_provider_id.to_string()) }; let codex_home = config.codex_home.as_path(); - let filter_cwd = if show_all || is_remote { + let filter_cwd = if options.show_all || options.is_remote { // Remote sessions live in the server's filesystem namespace, so the client // process cwd is not a meaningful row filter. If the user provided an // explicit remote --cd, filtering is handled server-side in thread/list. @@ -261,15 +296,16 @@ async fn run_session_picker_with_loader( std::env::current_dir().ok() }; - let mut state = PickerState::new( - codex_home.to_path_buf(), - alt.tui.frame_requester(), + let mut state = PickerState::new(PickerStateInit { + codex_home: codex_home.to_path_buf(), + requester: alt.tui.frame_requester(), page_loader, provider_filter, - show_all, + show_all: options.show_all, filter_cwd, - action, - ); + action: options.action, + preferred_session: options.preferred_session, + }); state.start_initial_load(); state.request_frame(); @@ -444,6 +480,7 @@ struct PickerState { sort_key: ThreadSortKey, thread_name_cache: HashMap>, inline_error: Option, + preferred_row: Option, } struct PaginationState { @@ -537,6 +574,7 @@ struct Row { updated_at: Option>, cwd: Option, git_branch: Option, + is_preferred: bool, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -571,15 +609,17 @@ impl Row { } impl PickerState { - fn new( - codex_home: PathBuf, - requester: FrameRequester, - page_loader: PageLoader, - provider_filter: ProviderFilter, - show_all: bool, - filter_cwd: Option, - action: SessionPickerAction, - ) -> Self { + fn new(init: PickerStateInit) -> Self { + let PickerStateInit { + codex_home, + requester, + page_loader, + provider_filter, + show_all, + filter_cwd, + action, + preferred_session, + } = init; Self { codex_home, requester, @@ -608,6 +648,17 @@ impl PickerState { sort_key: ThreadSortKey::UpdatedAt, thread_name_cache: HashMap::new(), inline_error: None, + preferred_row: preferred_session.map(|session| Row { + path: session.target.path, + preview: session.preview, + thread_id: Some(session.target.thread_id), + thread_name: session.thread_name, + created_at: None, + updated_at: Some(session.updated_at), + cwd: session.cwd, + git_branch: None, + is_preferred: true, + }), } } @@ -722,6 +773,7 @@ impl PickerState { self.filtered_rows.clear(); self.seen_rows.clear(); self.selected = 0; + self.seed_preferred_row(); let search_token = if self.query.is_empty() { self.search_state = SearchState::Idle; @@ -737,7 +789,7 @@ impl PickerState { request_token, search_token, }); - self.request_frame(); + self.apply_filter(); (self.page_loader)(PageLoadRequest { cursor: None, @@ -780,6 +832,16 @@ impl PickerState { self.pagination.loading = LoadingState::Idle; } + fn seed_preferred_row(&mut self) { + let Some(row) = self.preferred_row.clone() else { + return; + }; + if let Some(seen_key) = row.seen_key() { + self.seen_rows.insert(seen_key); + } + self.all_rows.push(row); + } + fn ingest_page(&mut self, page: PickerPage) { if let Some(cursor) = page.next_cursor.clone() { self.pagination.next_cursor = Some(cursor); @@ -874,6 +936,9 @@ impl PickerState { } fn row_matches_filter(&self, row: &Row) -> bool { + if row.is_preferred { + return true; + } if self.show_all { return true; } @@ -1091,6 +1156,7 @@ fn head_to_row(item: &ThreadItem) -> Row { updated_at, cwd: item.cwd.clone(), git_branch: item.git_branch.clone(), + is_preferred: false, } } @@ -1118,6 +1184,7 @@ fn row_from_app_server_thread(thread: Thread) -> Option { .map(|dt| dt.with_timezone(&Utc)), cwd: Some(thread.cwd), git_branch: thread.git_info.and_then(|git_info| git_info.branch), + is_preferred: false, }) } @@ -1689,6 +1756,27 @@ mod tests { }) } + fn test_picker_state( + codex_home: PathBuf, + loader: PageLoader, + provider_filter: ProviderFilter, + show_all: bool, + filter_cwd: Option, + action: SessionPickerAction, + preferred_session: Option, + ) -> PickerState { + PickerState::new(PickerStateInit { + codex_home, + requester: FrameRequester::test_dummy(), + page_loader: loader, + provider_filter, + show_all, + filter_cwd, + action, + preferred_session, + }) + } + #[allow(dead_code)] fn set_rollout_mtime(path: &Path, updated_at: DateTime) { let times = FileTimes::new().set_modified(updated_at.into()); @@ -1857,6 +1945,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + is_preferred: false, }; assert_eq!(row.display_preview(), "My session"); @@ -1899,14 +1988,14 @@ mod tests { #[test] fn remote_picker_does_not_filter_rows_by_local_cwd() { let loader: PageLoader = Arc::new(|_| {}); - let state = PickerState::new( + let state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::Any, /*show_all*/ false, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); let row = Row { path: None, @@ -1917,6 +2006,7 @@ mod tests { updated_at: None, cwd: Some(PathBuf::from("/srv/remote-project")), git_branch: None, + is_preferred: false, }; assert!(state.row_matches_filter(&row)); @@ -1930,14 +2020,14 @@ mod tests { use ratatui::layout::Layout; let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); let now = Utc::now(); @@ -1951,6 +2041,7 @@ mod tests { updated_at: Some(now - Duration::seconds(42)), cwd: None, git_branch: None, + is_preferred: false, }, Row { path: Some(PathBuf::from("/tmp/b.jsonl")), @@ -1961,6 +2052,7 @@ mod tests { updated_at: Some(now - Duration::minutes(35)), cwd: None, git_branch: None, + is_preferred: false, }, Row { path: Some(PathBuf::from("/tmp/c.jsonl")), @@ -1971,6 +2063,7 @@ mod tests { updated_at: Some(now - Duration::hours(2)), cwd: None, git_branch: None, + is_preferred: false, }, ]; state.all_rows = rows.clone(); @@ -2009,14 +2102,14 @@ mod tests { use crate::test_backend::VT100Backend; let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); state.inline_error = Some(String::from( "Failed to read session metadata from /tmp/missing.jsonl", @@ -2245,14 +2338,14 @@ mod tests { std::fs::write(&session_index_path, out).expect("write session index"); let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( tempdir.path().to_path_buf(), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); let now = Utc::now(); @@ -2266,6 +2359,7 @@ mod tests { updated_at: Some(now - Duration::days(2)), cwd: None, git_branch: None, + is_preferred: false, }, Row { path: Some(PathBuf::from("/tmp/b.jsonl")), @@ -2276,6 +2370,7 @@ mod tests { updated_at: Some(now - Duration::days(3)), cwd: None, git_branch: None, + is_preferred: false, }, ]; state.all_rows = rows.clone(); @@ -2327,14 +2422,14 @@ mod tests { .expect("write session index"); let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( tempdir.path().to_path_buf(), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); state.all_rows = vec![Row { @@ -2346,6 +2441,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + is_preferred: false, }]; state.filtered_rows = state.all_rows.clone(); @@ -2362,16 +2458,149 @@ mod tests { } #[test] - fn pageless_scrolling_deduplicates_and_keeps_order() { + fn preferred_session_is_pinned_and_deduped() { + let thread_id = + ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id"); let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let now = Utc::now(); + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + Some(PreferredSession { + target: SessionTarget { + path: Some(PathBuf::from("/tmp/recent.jsonl")), + thread_id, + }, + preview: String::from("Previous session"), + thread_name: Some(String::from("Recovered thread")), + cwd: Some(PathBuf::from("/tmp/project")), + updated_at: now, + }), + ); + + state.start_initial_load(); + state.ingest_page(PickerPage { + rows: vec![ + Row { + path: Some(PathBuf::from("/tmp/recent.jsonl")), + preview: String::from("duplicate"), + thread_id: Some(thread_id), + thread_name: Some(String::from("Duplicate row")), + created_at: None, + updated_at: Some(now - Duration::minutes(1)), + cwd: None, + git_branch: None, + is_preferred: false, + }, + Row { + path: Some(PathBuf::from("/tmp/older.jsonl")), + preview: String::from("Older session"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: Some(now - Duration::hours(1)), + cwd: None, + git_branch: None, + is_preferred: false, + }, + ], + next_cursor: None, + num_scanned_files: 2, + reached_scan_cap: false, + }); + + assert_eq!(state.filtered_rows.len(), 2); + assert_eq!(state.selected, 0); + assert_eq!(state.filtered_rows[0].display_preview(), "Recovered thread"); + assert!(state.filtered_rows[0].is_preferred); + assert_eq!(state.filtered_rows[1].display_preview(), "Older session"); + } + + #[test] + fn preferred_session_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use ratatui::layout::Constraint; + use ratatui::layout::Layout; + + let loader: PageLoader = Arc::new(|_| {}); + let now = Utc::now(); + let mut state = test_picker_state( + PathBuf::from("/tmp"), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + Some(PreferredSession { + target: SessionTarget { + path: Some(PathBuf::from("/tmp/recent.jsonl")), + thread_id: ThreadId::new(), + }, + preview: String::from("Previous session"), + thread_name: Some(String::from("Recovered thread")), + cwd: Some(PathBuf::from("/tmp/project")), + updated_at: now, + }), + ); + state.start_initial_load(); + state.ingest_page(PickerPage { + rows: vec![Row { + path: Some(PathBuf::from("/tmp/older.jsonl")), + preview: String::from("Older session"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: Some(now - Duration::hours(1)), + cwd: None, + git_branch: None, + is_preferred: false, + }], + next_cursor: None, + num_scanned_files: 1, + reached_scan_cap: false, + }); + state.pagination.loading = LoadingState::Idle; + state.relative_time_reference = Some(now); + state.view_rows = Some(2); + state.update_view_rows(/*rows*/ 2); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now); + let width: u16 = 80; + let height: u16 = 4; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + let segments = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area); + render_column_headers(&mut frame, segments[0], &metrics, state.sort_key); + render_list(&mut frame, segments[1], &state, &metrics); + } + terminal.flush().expect("flush"); + + let snapshot = terminal.backend().to_string(); + assert_snapshot!("resume_picker_preferred_session", snapshot); + } + + #[test] + fn pageless_scrolling_deduplicates_and_keeps_order() { + let loader: PageLoader = Arc::new(|_| {}); + let mut state = test_picker_state( + PathBuf::from("/tmp"), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + None, ); state.reset_pagination(); @@ -2433,14 +2662,14 @@ mod tests { request_sink.lock().unwrap().push(req); }); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); state.reset_pagination(); state.ingest_page(page( @@ -2514,14 +2743,14 @@ mod tests { request_sink.lock().unwrap().push(req); }); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); state.start_initial_load(); @@ -2544,14 +2773,14 @@ mod tests { #[tokio::test] async fn page_navigation_uses_view_rows() { let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); let mut items = Vec::new(); @@ -2592,14 +2821,14 @@ mod tests { #[tokio::test] async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() { let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); let row = Row { @@ -2611,6 +2840,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + is_preferred: false, }; state.all_rows = vec![row.clone()]; state.filtered_rows = vec![row]; @@ -2632,14 +2862,14 @@ mod tests { #[tokio::test] async fn enter_on_pathless_thread_uses_thread_id() { let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); let thread_id = ThreadId::new(); let row = Row { @@ -2651,6 +2881,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + is_preferred: false, }; state.all_rows = vec![row.clone()]; state.filtered_rows = vec![row]; @@ -2702,14 +2933,14 @@ mod tests { #[tokio::test] async fn up_at_bottom_does_not_scroll_when_visible() { let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); let mut items = Vec::new(); @@ -2750,14 +2981,14 @@ mod tests { request_sink.lock().unwrap().push(req); }); - let mut state = PickerState::new( + let mut state = test_picker_state( PathBuf::from("/tmp"), - FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, SessionPickerAction::Resume, + None, ); state.reset_pagination(); state.ingest_page(page( diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__session_transition_history_lines_with_resume_picker_hint.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__session_transition_history_lines_with_resume_picker_hint.snap new file mode 100644 index 0000000000..baafc152fe --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__session_transition_history_lines_with_resume_picker_hint.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app.rs +expression: rendered +--- +Token usage: total=12 input=10 output=2 +To continue this session, run codex resume my-session +Previous session available via /resume. diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_preferred_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_preferred_session.snap new file mode 100644 index 0000000000..fc53a5dc8c --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_preferred_session.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- + Created Updated Branch CWD Conversation +> - 0 seconds ago - tmp/project Recovered thread + - 1 hour ago - - Older session