From f029bb795ccbbd8471511f5a8b93e56d8f2b6d31 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 28 Jul 2026 00:33:42 +0000 Subject: [PATCH] Refresh the subagent picker in the background (#35693) ## Why Opening the subagent picker could wait on thread metadata and live event-store locks, delaying terminal input. Its cached entries could also omit descendants that were not observed in the current TUI session. ## What changed - Render the picker immediately from cached navigation state and refresh root descendants asynchronously with `thread/list`. - Merge discovered descendants into an open picker while preserving its selection and live status, and coalesce concurrent refresh requests. - Ignore refresh responses from a previous session and avoid blocking on busy event stores. ## Testing - Extend session lifecycle coverage for nonblocking picker opens, refresh coalescing, descendant discovery, selection preservation, and status updates. - Add coverage for rejecting a refresh response after navigation state is cleared. GitOrigin-RevId: 7eae7f696f8d2d323a47b3fd2800ea1c1cdbf2a0 --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/agent_navigation.rs | 39 +++++ codex-rs/tui/src/app/agent_picker.rs | 146 ++++++++++++++++++ codex-rs/tui/src/app/event_dispatch.rs | 7 + codex-rs/tui/src/app/session_lifecycle.rs | 87 ++++++++--- .../app/tests/session_lifecycle_requests.rs | 129 +++++++++++++++- codex-rs/tui/src/app_event.rs | 8 + .../src/bottom_pane/list_selection_view.rs | 10 +- codex-rs/tui/src/bottom_pane/mod.rs | 7 + codex-rs/tui/src/chatwidget/interaction.rs | 18 +++ 10 files changed, 415 insertions(+), 37 deletions(-) create mode 100644 codex-rs/tui/src/app/agent_picker.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c70d1ea5d6..401510d931 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -201,6 +201,7 @@ use toml::Value as TomlValue; use uuid::Uuid; mod agent_message_consolidation; mod agent_navigation; +mod agent_picker; mod agent_status_feed; mod app_server_event_targets; mod app_server_events; diff --git a/codex-rs/tui/src/app/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs index cbd90480d6..2795d636db 100644 --- a/codex-rs/tui/src/app/agent_navigation.rs +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -28,6 +28,7 @@ use codex_protocol::ThreadId; use ratatui::text::Span; use std::collections::HashMap; use std::collections::HashSet; +use uuid::Uuid; /// Small state container for multi-agent picker ordering and labeling. /// @@ -48,6 +49,8 @@ pub(crate) struct AgentNavigationState { stopped_threads: HashSet, /// Spawned child threads whose instructions are owned by their parent agent. parent_owned_threads: HashSet, + /// Coalesces root refreshes while rejecting replies from a previous session. + picker_refresh: Option<(ThreadId, Uuid)>, } /// Direction of keyboard traversal through the stable picker order. @@ -60,6 +63,23 @@ pub(crate) enum AgentNavigationDirection { } impl AgentNavigationState { + pub(crate) fn begin_picker_refresh(&mut self, thread_id: ThreadId) -> Option { + if self.picker_refresh.is_some() { + return None; + } + let request_id = Uuid::new_v4(); + self.picker_refresh = Some((thread_id, request_id)); + Some(request_id) + } + + pub(crate) fn finish_picker_refresh(&mut self, thread_id: ThreadId, request_id: Uuid) -> bool { + if self.picker_refresh != Some((thread_id, request_id)) { + return false; + } + self.picker_refresh = None; + true + } + /// Returns the cached picker entry for a specific thread id. /// /// Callers use this when they already know which thread they care about and need the last @@ -203,6 +223,7 @@ impl AgentNavigationState { self.order.clear(); self.stopped_threads.clear(); self.parent_owned_threads.clear(); + self.picker_refresh = None; } /// Removes a tracked thread entirely from picker metadata and traversal order. @@ -433,6 +454,24 @@ mod tests { assert!(!state.is_parent_owned(second_agent_id)); } + #[test] + fn picker_refresh_rejects_responses_from_before_clear() { + let mut state = AgentNavigationState::default(); + let thread_id = ThreadId::new(); + let stale_request = state + .begin_picker_refresh(thread_id) + .expect("first picker refresh"); + + assert_eq!(state.begin_picker_refresh(thread_id), None); + state.clear(); + let current_request = state + .begin_picker_refresh(thread_id) + .expect("refresh after session reset"); + + assert!(!state.finish_picker_refresh(thread_id, stale_request)); + assert!(state.finish_picker_refresh(thread_id, current_request)); + } + #[test] fn adjacent_thread_id_wraps_in_spawn_order() { let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state(); diff --git a/codex-rs/tui/src/app/agent_picker.rs b/codex-rs/tui/src/app/agent_picker.rs new file mode 100644 index 0000000000..5113182659 --- /dev/null +++ b/codex-rs/tui/src/app/agent_picker.rs @@ -0,0 +1,146 @@ +//! Root-scoped background refresh for the agent picker. + +use super::*; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSourceKind; +use codex_app_server_protocol::ThreadStatus; +use std::collections::HashSet; + +pub(super) const AGENT_PICKER_VIEW_ID: &str = "agent-picker"; +const AGENT_PICKER_PAGE_SIZE: u32 = 100; +const AGENT_PICKER_MAX_THREADS: usize = 1_000; + +impl App { + pub(super) fn refresh_agent_picker_threads( + &mut self, + app_server: &AppServerSession, + root: ThreadId, + ) { + let Some(request_id) = self.agent_navigation.begin_picker_refresh(root) else { + return; + }; + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = async { + let mut threads = Vec::new(); + let mut cursor = None; + let mut seen_cursors = HashSet::new(); + while threads.len() < AGENT_PICKER_MAX_THREADS + && seen_cursors.insert(cursor.clone()) + { + let page = match request_handle + .request_typed::(ClientRequest::ThreadList { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadListParams { + cursor, + limit: Some(AGENT_PICKER_PAGE_SIZE), + sort_key: None, + sort_direction: Some(SortDirection::Desc), + model_providers: Some(vec![]), + source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), + archived: None, + is_pinned: None, + cwd: None, + use_state_db_only: true, + search_term: None, + parent_thread_id: None, + ancestor_thread_id: Some(root.to_string()), + }, + }) + .await + { + Ok(page) => page, + Err(err) if threads.is_empty() => return Err(err.to_string()), + Err(err) => { + tracing::warn!(%err, "failed to refresh remaining agent picker descendants"); + break; + } + }; + threads.extend( + page.data + .into_iter() + .take(AGENT_PICKER_MAX_THREADS - threads.len()), + ); + let Some(next_cursor) = page.next_cursor else { + break; + }; + cursor = Some(next_cursor); + } + threads.reverse(); + Ok(threads) + } + .await; + + app_event_tx.send(AppEvent::AgentPickerThreadsLoaded { + primary_thread_id: root, + request_id, + result, + }); + }); + } + + pub(super) fn apply_agent_picker_thread_refresh( + &mut self, + root: ThreadId, + request_id: Uuid, + result: Result, String>, + ) { + if !self + .agent_navigation + .finish_picker_refresh(root, request_id) + || self.primary_thread_id != Some(root) + { + return; + } + let threads = match result { + Ok(threads) => threads, + Err(err) => { + tracing::warn!(%err, "failed to refresh agent picker descendants"); + return; + } + }; + let selected = self + .chat_widget + .selected_index_for_present_view(AGENT_PICKER_VIEW_ID); + for thread in threads { + let Ok(thread_id) = ThreadId::from_string(&thread.id) else { + continue; + }; + let live = self + .thread_event_channels + .get(&thread_id) + .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live); + let previous = self.agent_navigation.get(&thread_id); + let is_running = matches!(thread.status, ThreadStatus::Active { .. }); + let update_liveness = previous.is_none() || !is_running; + let is_closed = !live && matches!(thread.status, ThreadStatus::NotLoaded); + if !is_closed && previous.is_some_and(|entry| entry.is_closed) { + continue; + } + let agent_path = crate::app_server_session::source_agent_path(&thread.source); + let agent_nickname = thread + .agent_nickname + .or_else(|| previous.and_then(|entry| entry.agent_nickname.clone())); + let agent_role = thread + .agent_role + .or_else(|| previous.and_then(|entry| entry.agent_role.clone())); + if thread.can_accept_direct_input == Some(false) { + self.agent_navigation.mark_parent_owned(thread_id); + } + self.upsert_agent_picker_thread(thread_id, agent_nickname, agent_role, is_closed); + self.agent_navigation.set_agent_path(thread_id, agent_path); + if !live && update_liveness { + self.agent_navigation.set_running(thread_id, is_running); + } + } + + let params = self.agent_picker_selection_view_params(selected); + self.chat_widget + .replace_selection_view_if_present(AGENT_PICKER_VIEW_ID, params); + } +} diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b8b1bce2b4..7fb1c06798 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2004,6 +2004,13 @@ impl App { AppEvent::OpenAgentPicker => { self.open_agent_picker(app_server).await; } + AppEvent::AgentPickerThreadsLoaded { + primary_thread_id, + request_id, + result, + } => { + self.apply_agent_picker_thread_refresh(primary_thread_id, request_id, result); + } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread_and_discard_side(tui, app_server, thread_id) .await?; diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index f31c63c96b..0d28e37cbb 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -4,6 +4,7 @@ //! resuming/forking saved sessions, replacing ChatWidget instances, and maintaining the agent picker //! cache used for multi-agent navigation. +use super::agent_picker::AGENT_PICKER_VIEW_ID; use super::*; use crate::app_server_session::source_agent_path; use crate::app_server_session::thread_blocks_direct_input; @@ -26,7 +27,11 @@ pub(super) struct LoadedSubagentBackfill { impl App { pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) { - let backfill = self.backfill_loaded_subagent_threads(app_server).await; + let backfill = if self.primary_thread_id.is_none() { + self.backfill_loaded_subagent_threads(app_server).await + } else { + LoadedSubagentBackfill::default() + }; // V2 subagents are identified by canonical paths observed from activity events or loaded // thread metadata. A buffered active turn is positive liveness evidence; a completed // snapshot is terminal evidence. An empty store does not clear a successful spawn hint. @@ -40,22 +45,23 @@ impl App { if let Some(channel) = self.thread_event_channels.get(&thread_id) && channel.attachment() == ThreadEventAttachment::Live { - let (has_active_turn, has_terminal_snapshot) = { - let store = channel.store.lock().await; - ( - store.active_turn_id().is_some(), - store - .turns - .last() - .is_some_and(|turn| !matches!(turn.status, TurnStatus::InProgress)), - ) + let Ok(store) = channel.store.try_lock() else { + continue; }; + let has_active_turn = store.active_turn_id().is_some(); + let has_terminal_snapshot = store + .turns + .last() + .is_some_and(|turn| !matches!(turn.status, TurnStatus::InProgress)); + drop(store); if has_active_turn { self.agent_navigation.mark_running(thread_id); } else if has_terminal_snapshot { self.agent_navigation.mark_stopped(thread_id); } - } else if !backfill.refreshed_thread_ids.contains(&thread_id) { + } else if self.primary_thread_id.is_none() + && !backfill.refreshed_thread_ids.contains(&thread_id) + { self.refresh_agent_picker_thread_liveness(app_server, thread_id) .await; } @@ -76,10 +82,16 @@ impl App { let mut entries = Vec::new(); for (thread_id, agent_path) in running_threads { let preview = if let Some(channel) = self.thread_event_channels.get(&thread_id) { - let store = channel.store.lock().await; - super::agent_status_feed::AgentStatusThreadPreview::from_store( - agent_path, &store, - ) + match channel.store.try_lock() { + Ok(store) => { + super::agent_status_feed::AgentStatusThreadPreview::from_store( + agent_path, &store, + ) + } + Err(_) => { + super::agent_status_feed::AgentStatusThreadPreview::empty(agent_path) + } + } } else { super::agent_status_feed::AgentStatusThreadPreview::empty(agent_path) }; @@ -105,11 +117,14 @@ impl App { { continue; } - if !self - .refresh_agent_picker_thread_liveness(app_server, thread_id) - .await - { - continue; + if self.primary_thread_id.is_none() { + self.refresh_agent_picker_thread_liveness(app_server, thread_id) + .await; + } else if self.agent_navigation.get(&thread_id).is_none() { + self.upsert_agent_picker_thread( + thread_id, /*agent_nickname*/ None, /*agent_role*/ None, + /*is_closed*/ false, + ); } } @@ -117,6 +132,9 @@ impl App { .agent_navigation .has_non_primary_thread(self.primary_thread_id); if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread { + if let Some(primary_thread_id) = self.primary_thread_id { + self.refresh_agent_picker_threads(app_server, primary_thread_id); + } self.chat_widget.open_multi_agent_enable_prompt(); return; } @@ -127,14 +145,34 @@ impl App { return; } - let mut initial_selected_idx = None; + let selected = self + .chat_widget + .selected_index_for_present_view(AGENT_PICKER_VIEW_ID); + let params = self.agent_picker_selection_view_params(selected); + if !self + .chat_widget + .replace_selection_view_if_present(AGENT_PICKER_VIEW_ID, params) + { + let params = self.agent_picker_selection_view_params(selected); + self.chat_widget.show_selection_view(params); + } + if let Some(primary_thread_id) = self.primary_thread_id { + self.refresh_agent_picker_threads(app_server, primary_thread_id); + } + } + + pub(super) fn agent_picker_selection_view_params( + &self, + selected: Option, + ) -> SelectionViewParams { + let mut initial_selected_idx = selected; let items: Vec = self .agent_navigation .ordered_threads() .into_iter() .enumerate() .map(|(idx, (thread_id, entry))| { - if self.active_thread_id == Some(thread_id) { + if initial_selected_idx.is_none() && self.active_thread_id == Some(thread_id) { initial_selected_idx = Some(idx); } let id = thread_id; @@ -168,14 +206,15 @@ impl App { }) .collect(); - self.chat_widget.show_selection_view(SelectionViewParams { + SelectionViewParams { + view_id: Some(AGENT_PICKER_VIEW_ID), title: Some("Subagents".to_string()), subtitle: Some(AgentNavigationState::picker_subtitle()), footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx, ..Default::default() - }); + } } pub(super) fn is_terminal_thread_read_error(err: &color_eyre::Report) -> bool { 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 41161eb4ee..e980727a54 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -7,6 +7,8 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadStatus; use codex_protocol::AgentPath; use codex_state::SqliteConfig; use futures::SinkExt; @@ -14,6 +16,7 @@ use futures::StreamExt; use pretty_assertions::assert_eq; use std::sync::Mutex; use tokio::net::TcpListener; +use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; @@ -36,6 +39,7 @@ 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, + mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>, ) -> Result<( AppServerSession, Arc>>, @@ -93,6 +97,14 @@ async fn start_recording_app_server( 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() + { + 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 response = match embedded.request(request).await? { Ok(result) => JSONRPCMessage::Response(JSONRPCResponse { id: request_id, @@ -155,7 +167,7 @@ fn fresh_session_applies_requested_name() -> Result<()> { app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); let (mut app_server, requests, proxy) = - start_recording_app_server(&app.config).await?; + start_recording_app_server(&app.config, /*blocked_thread_list*/ None).await?; let mut tui = crate::tui::test_support::make_test_tui()?; app.start_fresh_session_with_summary_hint( @@ -206,7 +218,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { .enable_all() .build()?; runtime.block_on(async { - let mut app = make_test_app().await; + 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 = @@ -250,8 +262,13 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { root_timestamp, &root_thread_id.to_string(), ); - let (mut app_server, requests, proxy) = - start_recording_app_server(&app.config).await?; + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + Some((root_thread_id, started_tx, release_rx)), + ) + .await?; let root = app_server .resume_thread( app.config.clone(), @@ -336,11 +353,107 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { }) ); - Box::pin(app.open_agent_picker(&mut app_server)).await; + let child_store = Arc::clone( + &app.thread_event_channels + .entry(child_thread_id) + .or_insert_with(|| ThreadEventChannel::new(/*capacity*/ 1)) + .store, + ); + let child_store_guard = child_store.lock().await; + futures::FutureExt::now_or_never(app.open_agent_picker(&mut app_server)) + .expect("opening the agent picker waited for the app server"); + drop(child_store_guard); + insta::assert_snapshot!( + render_bottom_popup(&app.chat_widget, /*width*/ 80) + .replace(&root_thread_id.to_string(), "[root]") + .replace(&child_thread_id.to_string(), "[child]"), + @r###" + Subagents + Select an agent to watch. ⌥ + ← previous, ⌥ + → next. - // The picker refreshes the primary thread once. Discovered children were already - // refreshed by the picker's initial backfill and must not be read a second time. - assert_eq!(take_backfill_counts(&requests), (1, expected_reads + 1)); + › 1. • Main [default] (current) [root] + 2. • /root/worker [child] + + Press enter to confirm or esc to go back + "### + ); + assert_eq!(take_backfill_counts(&requests), (0, 0)); + tokio::time::timeout(Duration::from_secs(5), started_rx).await??; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + futures::FutureExt::now_or_never(app.open_agent_picker(&mut app_server)) + .expect("reopening the agent picker waited for the app server"); + assert_eq!( + requests + .lock() + .expect("request recorder lock") + .iter() + .filter(|method| *method == "thread/list") + .count(), + 1 + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget.handle_server_request( + exec_approval_request( + root_thread_id, + "turn", + "item", + /*approval_id*/ None, + ), + /*replay_kind*/ None, + ); + app.agent_navigation.mark_stopped(child_thread_id); + release_tx.send(()).expect("release blocked thread list"); + let discovered_thread_id = ThreadId::new(); + let mut completion = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = app_event_rx.recv().await.expect("app event channel"); + if matches!(event, AppEvent::AgentPickerThreadsLoaded { .. }) { + break event; + } + } + }) + .await?; + if let AppEvent::AgentPickerThreadsLoaded { + result: Ok(threads), + .. + } = &mut completion + { + let child = threads + .iter_mut() + .find(|thread| thread.id == child_thread_id.to_string()) + .expect("root-scoped response includes the cached child"); + let mut discovered = child.clone(); + discovered.id = discovered_thread_id.to_string(); + discovered.can_accept_direct_input = None; + child.status = ThreadStatus::Active { + active_flags: Vec::new(), + }; + threads.push(discovered); + } + app.handle_event(&mut tui, &mut app_server, completion) + .await?; + assert_eq!( + app.agent_navigation + .ordered_threads() + .last() + .map(|(thread_id, _)| *thread_id), + Some(discovered_thread_id) + ); + assert!(!app.agent_navigation.is_parent_owned(discovered_thread_id)); + assert_eq!( + app.chat_widget.selected_index_for_present_view( + super::super::agent_picker::AGENT_PICKER_VIEW_ID + ), + Some(1) + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("echo hello")); + assert!( + app.agent_navigation + .get(&child_thread_id) + .is_some_and(|entry| !entry.is_running) + ); app_server.shutdown().await?; proxy.await??; Ok(()) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4f731958c3..989eff7716 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -30,6 +30,7 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadGoalStatus; use codex_connectors::AppInfo; use codex_file_search::FileMatch; @@ -38,6 +39,7 @@ use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; +use uuid::Uuid; use crate::app_command::AppCommand; use crate::app_server_session::AppServerStartedThread; @@ -179,6 +181,12 @@ pub(crate) enum KeymapEditIntent { pub(crate) enum AppEvent { /// Open the agent picker for switching active threads. OpenAgentPicker, + /// Merge a completed root-scoped agent-picker refresh without blocking terminal input. + AgentPickerThreadsLoaded { + primary_thread_id: ThreadId, + request_id: Uuid, + result: Result, String>, + }, /// Switch the active thread to the selected agent. SelectAgentThread(ThreadId), diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index ee8051a72e..347c1c3d9a 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -486,6 +486,11 @@ impl ListSelectionView { let previously_selected = self .selected_actual_idx() .filter(|actual_idx| self.enabled_actual_idx(*actual_idx).is_some()) + .or_else(|| { + self.initial_selected_idx + .take() + .filter(|actual_idx| self.enabled_actual_idx(*actual_idx).is_some()) + }) .or_else(|| { (!self.is_searchable) .then(|| { @@ -494,11 +499,6 @@ impl ListSelectionView { .position(|item| item.is_current && Self::item_is_enabled(item)) }) .flatten() - }) - .or_else(|| { - self.initial_selected_idx - .take() - .filter(|actual_idx| self.enabled_actual_idx(*actual_idx).is_some()) }); if self.is_searchable && !self.search_query.is_empty() { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 102ff56209..693a3300b1 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1236,6 +1236,13 @@ impl BottomPane { .and_then(|view| view.selected_index()) } + pub(crate) fn selected_index_for_present_view(&self, view_id: &'static str) -> Option { + self.view_stack + .iter() + .rfind(|view| view.view_id() == Some(view_id)) + .and_then(|view| view.selected_index()) + } + pub(crate) fn active_tab_id_for_active_view(&self, view_id: &'static str) -> Option<&str> { self.view_stack .last() diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index c59a818851..58cde58ed3 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -233,6 +233,24 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn selected_index_for_present_view(&self, view_id: &'static str) -> Option { + self.bottom_pane.selected_index_for_present_view(view_id) + } + + pub(crate) fn replace_selection_view_if_present( + &mut self, + view_id: &'static str, + params: SelectionViewParams, + ) -> bool { + let replaced = self + .bottom_pane + .replace_selection_view_if_present(view_id, params); + if replaced { + self.refresh_plan_mode_nudge(); + } + replaced + } + pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.bottom_pane.no_modal_or_popup_active() }