diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e080b8cde..5f765e24e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -23,6 +23,10 @@ use crate::history_cell::UpdateAvailableHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; +use crate::multi_agents::AgentPickerThreadEntry; +use crate::multi_agents::agent_picker_status_dot_spans; +use crate::multi_agents::format_agent_picker_item_name; +use crate::multi_agents::sort_agent_picker_threads; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -576,6 +580,7 @@ pub(crate) struct App { windows_sandbox: WindowsSandboxState, thread_event_channels: HashMap, + agent_picker_threads: HashMap, active_thread_id: Option, active_thread_rx: Option>, primary_thread_id: Option, @@ -868,50 +873,55 @@ impl App { async fn open_agent_picker(&mut self) { let thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); - let mut agent_threads = Vec::new(); for thread_id in thread_ids { match self.server.get_thread(thread_id).await { Ok(thread) => { let session_source = thread.config_snapshot().await.session_source; - agent_threads.push(( + self.upsert_agent_picker_thread( thread_id, session_source.get_nickname(), session_source.get_agent_role(), - )); + false, + ); } Err(_) => { - self.thread_event_channels.remove(&thread_id); + self.mark_agent_picker_thread_closed(thread_id); } } } - if agent_threads.is_empty() { + if self.agent_picker_threads.is_empty() { self.chat_widget .add_info_message("No agents available yet.".to_string(), None); return; } - agent_threads.sort_by(|(left, ..), (right, ..)| left.to_string().cmp(&right.to_string())); + let mut agent_threads: Vec<(ThreadId, AgentPickerThreadEntry)> = self + .agent_picker_threads + .iter() + .map(|(thread_id, entry)| (*thread_id, entry.clone())) + .collect(); + sort_agent_picker_threads(&mut agent_threads); let mut initial_selected_idx = None; let items: Vec = agent_threads .iter() .enumerate() - .map(|(idx, (thread_id, agent_nickname, agent_role))| { + .map(|(idx, (thread_id, entry))| { if self.active_thread_id == Some(*thread_id) { initial_selected_idx = Some(idx); } let id = *thread_id; let is_primary = self.primary_thread_id == Some(*thread_id); let name = format_agent_picker_item_name( - *thread_id, - agent_nickname.as_deref(), - agent_role.as_deref(), + entry.agent_nickname.as_deref(), + entry.agent_role.as_deref(), is_primary, ); let uuid = thread_id.to_string(); SelectionItem { name: name.clone(), + name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed), description: Some(uuid.clone()), is_current: self.active_thread_id == Some(*thread_id), actions: vec![Box::new(move |tx| { @@ -934,20 +944,51 @@ impl App { }); } + fn upsert_agent_picker_thread( + &mut self, + thread_id: ThreadId, + agent_nickname: Option, + agent_role: Option, + is_closed: bool, + ) { + self.agent_picker_threads.insert( + thread_id, + AgentPickerThreadEntry { + agent_nickname, + agent_role, + is_closed, + }, + ); + } + + fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) { + if let Some(entry) = self.agent_picker_threads.get_mut(&thread_id) { + entry.is_closed = true; + } else { + self.upsert_agent_picker_thread(thread_id, None, None, true); + } + } + async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { if self.active_thread_id == Some(thread_id) { return Ok(()); } - let thread = match self.server.get_thread(thread_id).await { - Ok(thread) => thread, + let live_thread = match self.server.get_thread(thread_id).await { + Ok(thread) => Some(thread), Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to attach to agent thread {thread_id}: {err}" - )); - return Ok(()); + if self.thread_event_channels.contains_key(&thread_id) { + self.mark_agent_picker_thread_closed(thread_id); + None + } else { + self.chat_widget.add_error_message(format!( + "Failed to attach to agent thread {thread_id}: {err}" + )); + return Ok(()); + } } }; + let is_replay_only = live_thread.is_none(); let previous_thread_id = self.active_thread_id; self.store_active_thread_receiver().await; @@ -965,11 +1006,22 @@ impl App { self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - let codex_op_tx = crate::chatwidget::spawn_op_forwarder(thread); + let codex_op_tx = if let Some(thread) = live_thread { + crate::chatwidget::spawn_op_forwarder(thread) + } else { + let (tx, _rx) = unbounded_channel(); + tx + }; self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot); + if is_replay_only { + self.chat_widget.add_info_message( + format!("Agent thread {thread_id} is closed. Replaying saved transcript."), + None, + ); + } self.drain_active_thread_events(tui).await?; Ok(()) @@ -989,6 +1041,7 @@ impl App { fn reset_thread_event_state(&mut self) { self.thread_event_channels.clear(); + self.agent_picker_threads.clear(); self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; @@ -1294,6 +1347,7 @@ impl App { pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), + agent_picker_threads: HashMap::new(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, @@ -2752,7 +2806,7 @@ impl App { if let Some((closed_thread_id, primary_thread_id)) = self.active_non_primary_shutdown_target(&event.msg) { - self.thread_event_channels.remove(&closed_thread_id); + self.mark_agent_picker_thread_closed(closed_thread_id); self.select_agent_thread(tui, primary_thread_id).await?; if self.active_thread_id == Some(primary_thread_id) { self.chat_widget.add_info_message( @@ -2794,6 +2848,12 @@ impl App { } }; let config_snapshot = thread.config_snapshot().await; + self.upsert_agent_picker_thread( + thread_id, + config_snapshot.session_source.get_nickname(), + config_snapshot.session_source.get_agent_role(), + false, + ); let event = Event { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -3078,28 +3138,6 @@ impl App { } } -fn format_agent_picker_item_name( - _thread_id: ThreadId, - agent_nickname: Option<&str>, - agent_role: Option<&str>, - is_primary: bool, -) -> String { - if is_primary { - return "Main [default]".to_string(); - } - - let agent_nickname = agent_nickname - .map(str::trim) - .filter(|nickname| !nickname.is_empty()); - let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); - match (agent_nickname, agent_role) { - (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), - (Some(agent_nickname), None) => agent_nickname.to_string(), - (None, Some(agent_role)) => format!("[{agent_role}]"), - (None, None) => "Agent".to_string(), - } -} - #[cfg(test)] mod tests { use super::*; @@ -3268,7 +3306,7 @@ mod tests { } #[tokio::test] - async fn open_agent_picker_prunes_missing_threads() -> Result<()> { + async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.thread_event_channels @@ -3276,7 +3314,44 @@ mod tests { app.open_agent_picker().await; - assert_eq!(app.thread_event_channels.contains_key(&thread_id), false); + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!( + app.agent_picker_threads.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + is_closed: true, + }) + ); + Ok(()) + } + + #[tokio::test] + async fn open_agent_picker_keeps_cached_closed_threads() -> Result<()> { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(1)); + app.agent_picker_threads.insert( + thread_id, + AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + is_closed: false, + }, + ); + + app.open_agent_picker().await; + + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!( + app.agent_picker_threads.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + is_closed: true, + }) + ); Ok(()) } @@ -3287,27 +3362,27 @@ mod tests { let snapshot = [ format!( "{} | {}", - format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), true), + format_agent_picker_item_name(Some("Robie"), Some("explorer"), true), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), false), + format_agent_picker_item_name(Some("Robie"), Some("explorer"), false), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, Some("Robie"), None, false), + format_agent_picker_item_name(Some("Robie"), None, false), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, None, Some("explorer"), false), + format_agent_picker_item_name(None, Some("explorer"), false), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, None, None, false), + format_agent_picker_item_name(None, None, false), thread_id ), ] @@ -3548,6 +3623,7 @@ mod tests { pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), + agent_picker_threads: HashMap::new(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, @@ -3606,6 +3682,7 @@ mod tests { pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), + agent_picker_threads: HashMap::new(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1c2291465..48b6ffedc 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -220,6 +220,7 @@ impl CommandPopup { }; GenericDisplayRow { name, + name_prefix_spans: Vec::new(), match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), display_shortcut: None, description: Some(description), diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 9eb8121b6..76f8bc1e1 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -119,6 +119,7 @@ impl WidgetRef for &FileSearchPopup { .iter() .map(|m| GenericDisplayRow { name: m.path.to_string_lossy().to_string(), + name_prefix_spans: Vec::new(), match_indices: m .indices .as_ref() 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 ad40c1660..2f566079a 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -112,6 +112,7 @@ pub(crate) type OnCancelCallback = Option>, pub display_shortcut: Option, pub description: Option, pub selected_description: Option, @@ -372,7 +373,9 @@ impl ListSelectionView { format!("{prefix} {n}. ") }; let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); - let display_name = format!("{wrap_prefix}{name_with_marker}"); + let mut name_prefix_spans = Vec::new(); + name_prefix_spans.push(wrap_prefix.into()); + name_prefix_spans.extend(item.name_prefix_spans.clone()); let description = is_selected .then(|| item.selected_description.clone()) .flatten() @@ -380,7 +383,8 @@ impl ListSelectionView { let wrap_indent = description.is_none().then_some(wrap_prefix_width); let is_disabled = item.is_disabled || item.disabled_reason.is_some(); GenericDisplayRow { - name: display_name, + name: name_with_marker, + name_prefix_spans, display_shortcut: item.display_shortcut, match_indices: None, description, diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 6b55125c9..6b526de47 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -28,6 +28,7 @@ use super::scroll_state::ScrollState; #[derive(Default)] pub(crate) struct GenericDisplayRow { pub name: String, + pub name_prefix_spans: Vec>, pub display_shortcut: Option, pub match_indices: Option>, // indices to bold (char positions) pub description: Option, // optional grey text after the name @@ -242,7 +243,8 @@ fn compute_desc_col( .skip(start_idx) .take(visible_items) .map(|(_, row)| { - let mut spans: Vec = vec![row.name.clone().into()]; + let mut spans = row.name_prefix_spans.clone(); + spans.push(row.name.clone().into()); if row.disabled_reason.is_some() { spans.push(" (disabled)".dim()); } @@ -253,7 +255,8 @@ fn compute_desc_col( ColumnWidthMode::AutoAllRows => rows_all .iter() .map(|row| { - let mut spans: Vec = vec![row.name.clone().into()]; + let mut spans = row.name_prefix_spans.clone(); + spans.push(row.name.clone().into()); if row.disabled_reason.is_some() { spans.push(" (disabled)".dim()); } @@ -291,6 +294,7 @@ fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool { && row.match_indices.is_none() && row.display_shortcut.is_none() && row.category_tag.is_none() + && row.name_prefix_spans.is_empty() } fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec> { @@ -508,9 +512,10 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { // Enforce single-line name: allow at most desc_col - 2 cells for name, // reserving two spaces before the description column. + let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width(); let name_limit = combined_description .as_ref() - .map(|_| desc_col.saturating_sub(2)) + .map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width)) .unwrap_or(usize::MAX); let mut name_spans: Vec = Vec::with_capacity(row.name.len()); @@ -558,8 +563,9 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { name_spans.push(" (disabled)".dim()); } - let this_name_width = Line::from(name_spans.clone()).width(); - let mut full_spans: Vec = name_spans; + let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width(); + let mut full_spans: Vec = row.name_prefix_spans.clone(); + full_spans.extend(name_spans); if let Some(display_shortcut) = row.display_shortcut { full_spans.push(" (".into()); full_spans.push(display_shortcut.into()); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index e3a6c9880..c848bca9e 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -101,6 +101,7 @@ impl SkillPopup { let description = mention.description.clone().unwrap_or_default(); GenericDisplayRow { name, + name_prefix_spans: Vec::new(), match_indices: indices, display_shortcut: None, description: Some(description).filter(|desc| !desc.is_empty()), diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 806400f2e..48eaab734 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -22,6 +22,13 @@ const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentPickerThreadEntry { + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, + pub(crate) is_closed: bool, +} + #[derive(Clone, Copy)] struct AgentLabel<'a> { thread_id: Option, @@ -29,6 +36,44 @@ struct AgentLabel<'a> { role: Option<&'a str>, } +pub(crate) fn agent_picker_status_dot_spans(is_closed: bool) -> Vec> { + let dot = if is_closed { + "•".dark_gray() + } else { + "•".green() + }; + vec![dot, " ".into()] +} + +pub(crate) fn format_agent_picker_item_name( + agent_nickname: Option<&str>, + agent_role: Option<&str>, + is_primary: bool, +) -> String { + if is_primary { + return "Main [default]".to_string(); + } + + let agent_nickname = agent_nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); + match (agent_nickname, agent_role) { + (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), + (Some(agent_nickname), None) => agent_nickname.to_string(), + (None, Some(agent_role)) => format!("[{agent_role}]"), + (None, None) => "Agent".to_string(), + } +} + +pub(crate) fn sort_agent_picker_threads(agent_threads: &mut [(ThreadId, AgentPickerThreadEntry)]) { + agent_threads.sort_by(|(left_id, left), (right_id, right)| { + left.is_closed + .cmp(&right.is_closed) + .then_with(|| left_id.to_string().cmp(&right_id.to_string())) + }); +} + pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { call_id: _,