From ef2b09c61a12d0ffa02246f0e590702789d65195 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Fri, 13 Mar 2026 08:29:33 -0700 Subject: [PATCH] tui: preserve selected resume target path Co-authored-by: Codex --- codex-rs/tui/src/app.rs | 12 +++----- codex-rs/tui/src/app_event.rs | 3 ++ codex-rs/tui/src/chatwidget.rs | 46 +++++++++++++++++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 28 +++++++++++++++++ codex-rs/tui/src/resume_picker.rs | 2 +- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b93e7ad819..169f2c213f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2523,14 +2523,7 @@ impl App { AppEvent::OpenResumePicker => { match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? { SessionSelection::Resume(target_session) => { - self.chat_widget.handle_serialized_slash_command( - format!( - "/{} {}", - crate::slash_command::SlashCommand::Resume.command(), - target_session.thread_id - ) - .into(), - ); + self.chat_widget.handle_resume_selection(target_session); self.refresh_status_line(); } SessionSelection::Exit @@ -2544,6 +2537,9 @@ impl App { AppEvent::ResumeSession(thread_id) => { self.resume_session_by_thread_id(tui, thread_id).await?; } + AppEvent::ResumeSessionTarget(target_session) => { + self.resume_session_target(tui, target_session).await?; + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 10f14b57b0..04968f68d9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -101,6 +101,9 @@ pub(crate) enum AppEvent { /// Resume a saved session by thread id. ResumeSession(ThreadId), + /// Resume a saved session using the exact picker-selected rollout target. + ResumeSessionTarget(crate::resume_picker::SessionTarget), + /// Fork the current session into a new thread. ForkCurrentSession, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6f20d62574..362d6d15df 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -268,6 +268,7 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; +use crate::resume_picker::SessionTarget; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; @@ -735,6 +736,11 @@ pub(crate) struct ChatWidget { // drafts and are replayed through the slash-command evaluator instead of being submitted // directly as user turns. queued_user_messages: VecDeque, + // Exact rollout targets picked from the resume picker for queued `/resume ` drafts. + // + // The serialized draft stays human-readable, but replay consumes these targets in queue order + // so the selected rollout path is preserved instead of being re-resolved by thread id later. + queued_resume_targets: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -893,6 +899,7 @@ pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, queued_user_messages: VecDeque, + queued_resume_targets: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, agent_turn_running: bool, @@ -2268,6 +2275,7 @@ impl ChatWidget { .map(|steer| steer.user_message) .collect(); to_merge.extend(self.queued_user_messages.drain(..)); + self.queued_resume_targets.clear(); if has_existing_message { to_merge.push(existing_message); } @@ -2310,6 +2318,7 @@ impl ChatWidget { .map(|pending| pending.user_message.clone()) .collect(), queued_user_messages: self.queued_user_messages.clone(), + queued_resume_targets: self.queued_resume_targets.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), agent_turn_running: self.agent_turn_running, @@ -2352,6 +2361,7 @@ impl ChatWidget { self.queued_user_messages = input_state.pending_steers; self.queued_user_messages .extend(input_state.queued_user_messages); + self.queued_resume_targets = input_state.queued_resume_targets; } else { self.agent_turn_running = false; self.pending_steers.clear(); @@ -2364,6 +2374,7 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); self.queued_user_messages.clear(); + self.queued_resume_targets.clear(); } self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); @@ -3645,6 +3656,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_resume_targets: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3836,6 +3848,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + queued_resume_targets: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4011,6 +4024,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_resume_targets: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4145,6 +4159,12 @@ impl ChatWidget { && !self.queued_user_messages.is_empty() { if let Some(queued_message) = self.queued_user_messages.pop_back() { + if self + .parse_builtin_slash_command(&queued_message.text) + .is_some_and(|(cmd, _, _)| cmd == SlashCommand::Resume) + { + self.queued_resume_targets.pop_back(); + } self.restore_user_message_to_composer(queued_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -5324,7 +5344,20 @@ impl ChatWidget { } match ThreadId::from_string(&args[0]) { Ok(thread_id) => { - self.app_event_tx.send(AppEvent::ResumeSession(thread_id)); + if self + .queued_resume_targets + .front() + .is_some_and(|target| target.thread_id == thread_id) + { + if let Some(target_session) = self.queued_resume_targets.pop_front() { + self.app_event_tx + .send(AppEvent::ResumeSessionTarget(target_session)); + } else { + self.app_event_tx.send(AppEvent::ResumeSession(thread_id)); + } + } else { + self.app_event_tx.send(AppEvent::ResumeSession(thread_id)); + } QueueReplayControl::Stop } Err(_) => self.restore_invalid_inline_slash_command( @@ -5712,6 +5745,17 @@ impl ChatWidget { let _ = self.execute_serialized_slash_command(draft); } + pub(crate) fn handle_resume_selection(&mut self, target_session: SessionTarget) { + self.queued_resume_targets.push_back(target_session.clone()); + self.handle_serialized_slash_command( + SlashCommandInvocation::with_args( + SlashCommand::Resume, + [target_session.thread_id.to_string()], + ) + .into_user_message(), + ); + } + fn submit_plan_user_message(&mut self, user_message: UserMessage) { if self.is_session_configured() { self.reasoning_buffer.clear(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b1ac4ff02f..5437f0aa72 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1902,6 +1902,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + queued_resume_targets: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -3681,6 +3682,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { composer: None, pending_steers: VecDeque::new(), queued_user_messages: VecDeque::new(), + queued_resume_targets: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), agent_turn_running: true, @@ -5710,6 +5712,32 @@ async fn queued_plan_replay_stops_after_submitting_user_turn() { assert_no_submit_op(&mut op_rx); } +#[tokio::test] +async fn queued_resume_picker_selection_replays_exact_targets_in_order() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + + let thread_id = ThreadId::new(); + let first = crate::resume_picker::SessionTarget { + path: PathBuf::from("/tmp/first.rollout"), + thread_id, + }; + let second = crate::resume_picker::SessionTarget { + path: PathBuf::from("/tmp/second.rollout"), + thread_id, + }; + + chat.handle_resume_selection(first.clone()); + chat.handle_resume_selection(second.clone()); + + chat.bottom_pane.set_task_running(false); + chat.drain_queued_inputs_until_blocked(); + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(target)) if target == first); + + chat.drain_queued_inputs_until_blocked(); + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(target)) if target == second); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 793e0b947d..cc78ebd688 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -40,7 +40,7 @@ use unicode_width::UnicodeWidthStr; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionTarget { pub path: PathBuf, pub thread_id: ThreadId,