diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3f372d0454..d4c9608967 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -207,6 +207,9 @@ mod agent_message_consolidation; mod agent_navigation; mod agent_picker; mod agent_status_feed; +mod agents_overview; +mod agents_overview_view; +pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID; mod app_server_event_targets; mod app_server_events; pub(crate) mod app_server_requests; @@ -592,6 +595,7 @@ pub(crate) struct App { thread_event_channels: HashMap, thread_event_listener_tasks: HashMap>, agent_navigation: AgentNavigationState, + agents_overview: agents_overview::AgentsOverviewState, side_threads: HashMap, abandoned_side_threads: HashSet, active_thread_id: Option, @@ -862,7 +866,24 @@ impl App { } fn render_chat_widget_frame(&mut self, tui: &mut tui::Tui, screen_size: Size) -> Result { + let dashboard_visible = self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some(); + if std::mem::replace( + &mut self.agents_overview.rendered_full_screen, + dashboard_visible, + ) && !dashboard_visible + { + self.schedule_immediate_resize_reflow(tui); + self.maybe_run_resize_reflow(tui, screen_size)?; + } self.with_chat_widget_frame(screen_size.width, |desired_height, chat_widget| { + let desired_height = if dashboard_visible { + screen_size.height + } else { + desired_height + }; let mut rendered_area = Rect::default(); tui.draw_with_resize_reflow(desired_height, screen_size, |frame| { let area = frame.area(); diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs new file mode 100644 index 0000000000..d5a4bc77d3 --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -0,0 +1,270 @@ +//! Daemon-wide overview of loaded root sessions and their subagents. + +use super::agents_overview_view::AgentsOverviewGroup; +use super::agents_overview_view::AgentsOverviewRow; +use super::agents_overview_view::AgentsOverviewView; +use super::*; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_protocol::protocol::SubAgentSource; + +pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview"; + +#[derive(Default)] +pub(super) struct AgentsOverviewState { + pub(super) request_id: Option, + pub(super) refresh_pending: bool, + pub(super) rendered_full_screen: bool, + pub(super) visible_thread_ids: Vec, + pub(super) view_state: + Arc>, +} + +impl App { + pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) { + if matches!(self.app_server_target, AppServerTarget::Embedded) { + self.chat_widget.add_info_message( + "The shared agents dashboard requires a background app server.".to_string(), + /*hint*/ None, + ); + return; + } + + self.agents_overview.request_id = None; + self.agents_overview.refresh_pending = false; + let view = self.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + self.agents_overview.visible_thread_ids = view.thread_ids(); + self.chat_widget.show_bottom_pane_view(Box::new(view)); + self.refresh_agents_overview_threads(app_server); + } + + pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) { + if self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_none() + { + return; + } + if self.agents_overview.request_id.is_some() { + self.agents_overview.refresh_pending = true; + return; + } + + let request_id = Uuid::new_v4(); + self.agents_overview.request_id = Some(request_id); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + let refresh_task = tokio::spawn(async move { + let result = async { + let mut threads = Vec::new(); + let mut cursor = None; + while threads.len() < 1_000 { + let page = request_handle + .request_typed::(ClientRequest::ThreadLoadedList { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadLoadedListParams { + cursor, + limit: Some(100), + }, + }) + .await + .map_err(|error| error.to_string())?; + let mut loaded_threads = tokio::task::JoinSet::new(); + for thread_id in page.data.into_iter().take(1_000 - threads.len()) { + let request_handle = request_handle.clone(); + loaded_threads.spawn(async move { + match request_handle + .request_typed::(ClientRequest::ThreadRead { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }, + }) + .await + { + Ok(mut response) => { + if let Ok(turns) = request_handle + .request_typed::( + ClientRequest::ThreadTurnsList { + request_id: RequestId::String( + Uuid::new_v4().to_string(), + ), + params: ThreadTurnsListParams { + thread_id, + cursor: None, + limit: Some(1), + sort_direction: None, + items_view: None, + }, + }, + ) + .await + && let Some(ThreadItem::UserMessage { content, .. }) = turns + .data + .first() + .and_then(|turn| turn.items.first()) + { + response.thread.preview = + ChatWidget::user_message_display_from_inputs(content) + .message; + } + Some(response.thread) + } + Err(error) => { + tracing::warn!(thread_id, %error, "failed to read loaded agent thread"); + None + } + } + }); + if loaded_threads.len() >= 16 + && let Some(Ok(Some(thread))) = loaded_threads.join_next().await + { + threads.push(thread); + } + } + while let Some(result) = loaded_threads.join_next().await { + if let Ok(Some(thread)) = result { + threads.push(thread); + } + } + let Some(next_cursor) = page.next_cursor else { + break; + }; + cursor = Some(next_cursor); + } + threads.sort_by_key(|thread| std::cmp::Reverse(thread.updated_at)); + Ok(threads) + } + .await; + + app_event_tx.send(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }); + }); + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.refresh_task = Some(refresh_task.abort_handle()); + } + } + + pub(super) fn apply_agents_overview_thread_refresh( + &mut self, + app_server: &AppServerSession, + request_id: Uuid, + result: Result, String>, + ) { + if self.agents_overview.request_id != Some(request_id) { + return; + } + self.agents_overview.request_id = None; + let Some(selected) = self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + else { + self.agents_overview.refresh_pending = false; + return; + }; + let selected_thread_id = self + .agents_overview + .visible_thread_ids + .get(selected) + .copied(); + let threads = match result { + Ok(threads) => threads, + Err(error) => { + self.chat_widget + .add_error_message(format!("Failed to load shared agents: {error}")); + if std::mem::take(&mut self.agents_overview.refresh_pending) { + self.refresh_agents_overview_threads(app_server); + } + return; + } + }; + let view = self.agents_overview_view(threads, selected_thread_id); + self.agents_overview.visible_thread_ids = view.thread_ids(); + self.chat_widget + .replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view)); + if std::mem::take(&mut self.agents_overview.refresh_pending) { + self.refresh_agents_overview_threads(app_server); + } + } + + fn agents_overview_view( + &self, + mut threads: Vec, + selected_thread_id: Option, + ) -> AgentsOverviewView { + threads.retain(|thread| !thread.ephemeral); + for thread in &mut threads { + if thread.parent_thread_id.is_none() + && let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = &thread.source + { + thread.parent_thread_id = Some(parent_thread_id.to_string()); + } + } + let mut children: HashMap> = HashMap::new(); + for thread in &threads { + if let Some(parent_thread_id) = &thread.parent_thread_id { + children + .entry(parent_thread_id.clone()) + .or_default() + .push(thread); + } + } + + let mut roots = threads + .iter() + .filter(|thread| { + thread.parent_thread_id.is_none() + && !matches!(thread.status, ThreadStatus::NotLoaded) + }) + .map(|root| (root, agents_overview_group(root, &children))) + .collect::>(); + roots.sort_by_key(|(root, group)| (*group, std::cmp::Reverse(root.updated_at))); + let mut rows = Vec::new(); + for (root, group) in roots { + let Ok(thread_id) = ThreadId::from_string(&root.id) else { + continue; + }; + rows.push(AgentsOverviewRow { + thread: root.clone(), + thread_id, + group, + is_current: self.primary_thread_id == Some(thread_id), + }); + } + + AgentsOverviewView::new( + rows, + selected_thread_id, + self.primary_thread_id.is_none(), + self.app_event_tx.clone(), + self.keymap.clone(), + Arc::clone(&self.agents_overview.view_state), + ) + } +} + +#[cfg(test)] +#[path = "agents_overview_tests.rs"] +mod tests; + +fn agents_overview_group( + thread: &Thread, + children: &HashMap>, +) -> AgentsOverviewGroup { + children.get(&thread.id).into_iter().flatten().fold( + AgentsOverviewGroup::for_status(&thread.status), + |group, child| group.min(agents_overview_group(child, children)), + ) +} diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs new file mode 100644 index 0000000000..37e54132f7 --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -0,0 +1,103 @@ +use crate::app::agents_overview_view::AgentsOverviewGroup; +use crate::app::test_support::make_test_app; +use crate::bottom_pane::BottomPaneView; +use crate::chatwidget::tests::helpers::render_bottom_popup; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_buf; +use crate::test_support::test_path_display; +use codex_app_server_protocol::ThreadActiveFlag; +use codex_app_server_protocol::ThreadStatus; +use codex_protocol::ThreadId; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; + +#[tokio::test] +async fn shared_agents_dashboard_is_rendered() { + assert_eq!( + AgentsOverviewGroup::for_status(&ThreadStatus::SystemError), + AgentsOverviewGroup::NeedsYou + ); + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(&app.config) + .await + .expect("start example thread"); + let mut thread = app_server + .thread_read(started.session.thread_id, /*include_turns*/ false) + .await + .expect("read example thread"); + thread.name = Some("Example task".to_string()); + thread.preview = "Inspect the current project".to_string(); + thread.cwd = test_path_buf("/tmp/project").abs(); + thread.status = ThreadStatus::Active { + active_flags: vec![ThreadActiveFlag::WaitingOnApproval], + }; + app.primary_thread_id = Some(started.session.thread_id); + let mut view = app.agents_overview_view(vec![thread.clone()], /*selected_thread_id*/ None); + let state = &app.agents_overview.view_state; + assert!(!state.lock().unwrap().status_grouping); + view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(state.lock().unwrap().status_grouping); + app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + assert!(state.lock().unwrap().status_grouping); + view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!( + "agents_overview", + render_bottom_popup(&app.chat_widget, /*width*/ 96) + .replace(&test_path_display("/tmp/project"), "/tmp/project") + ); + }); + + let threads = (0..20) + .map(|index| { + let mut candidate = thread.clone(); + if index != 0 { + candidate.id = ThreadId::new().to_string(); + candidate.status = ThreadStatus::Idle; + } + candidate.name = (index != 0).then(|| format!("Task {index}")); + candidate.updated_at = index; + candidate.cwd = if index == 0 { + test_path_buf("/tmp/project-selected").abs() + } else { + test_path_buf(&format!("/tmp/project-{}", index % 3)).abs() + }; + candidate + }) + .collect(); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let view = app.agents_overview_view(threads, /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); + assert!(rendered.lines().any( + |line| line.contains("› ● Inspect the current project current") + && line.contains("Needs input") + )); + + app.transcript_cells.push(std::sync::Arc::new( + crate::history_cell::PlainHistoryCell::new(vec![ratatui::text::Line::from( + "Previous conversation", + )]), + )); + let mut tui = crate::tui::test_support::make_test_tui().expect("test terminal"); + let screen_size = tui.terminal.last_known_screen_size; + app.render_chat_widget_frame(&mut tui, screen_size) + .expect("render full-screen dashboard"); + assert_eq!(tui.terminal.viewport_area.height, screen_size.height); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.render_chat_widget_frame(&mut tui, screen_size) + .expect("restore conversation after closing dashboard"); + assert!(tui.terminal.viewport_area.height < screen_size.height); + assert!(app.last_rendered_history_tail.is_some()); + + app_server.shutdown().await.expect("shutdown app server"); +} diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs new file mode 100644 index 0000000000..6354c02871 --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -0,0 +1,440 @@ +//! Read-only dashboard of the daemon's active tasks. + +use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::ViewCompletion; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::Renderable; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadActiveFlag; +use codex_app_server_protocol::ThreadStatus; +use codex_protocol::ThreadId; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Margin; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Widget; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::PoisonError; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(super) enum AgentsOverviewGroup { + NeedsYou, + Working, + Ready, + Finished, +} + +impl AgentsOverviewGroup { + pub(super) fn for_status(status: &ThreadStatus) -> Self { + match status { + ThreadStatus::Active { active_flags } + if active_flags.contains(&ThreadActiveFlag::WaitingOnApproval) + || active_flags.contains(&ThreadActiveFlag::WaitingOnUserInput) => + { + Self::NeedsYou + } + ThreadStatus::Active { .. } => Self::Working, + ThreadStatus::Idle => Self::Ready, + ThreadStatus::SystemError => Self::NeedsYou, + ThreadStatus::NotLoaded => Self::Finished, + } + } + + fn label(self) -> &'static str { + match self { + Self::NeedsYou => "Needs input", + Self::Working => "Working", + Self::Ready => "Ready", + Self::Finished => "Finished", + } + } +} + +#[derive(Clone)] +pub(super) struct AgentsOverviewRow { + pub(super) thread: Thread, + pub(super) thread_id: ThreadId, + pub(super) group: AgentsOverviewGroup, + pub(super) is_current: bool, +} + +#[derive(Clone, Default)] +pub(super) struct AgentsOverviewViewState { + search: String, + searching: bool, + pub(super) status_grouping: bool, + pub(super) refresh_task: Option, +} + +pub(super) struct AgentsOverviewView { + pub(super) rows: Vec, + selected: usize, + state: Arc>, + exit_on_cancel: bool, + completion: Option, + app_event_tx: AppEventSender, + keymap: ListKeymap, +} + +impl Drop for AgentsOverviewView { + fn drop(&mut self) { + if let Some(task) = self + .state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .refresh_task + .take() + { + task.abort(); + } + } +} + +impl AgentsOverviewView { + pub(super) fn new( + rows: Vec, + selected_thread_id: Option, + exit_on_cancel: bool, + app_event_tx: AppEventSender, + keymap: RuntimeKeymap, + state: Arc>, + ) -> Self { + let selected = selected_thread_id + .and_then(|thread_id| rows.iter().position(|row| row.thread_id == thread_id)) + .or_else(|| rows.iter().position(|row| row.is_current)) + .unwrap_or(0); + let mut view = Self { + rows, + selected, + state, + exit_on_cancel, + completion: None, + app_event_tx, + keymap: keymap.list, + }; + let visible = view.visible_indices(); + if !visible.contains(&view.selected) { + view.selected = visible.first().copied().unwrap_or(usize::MAX); + } + view + } + + pub(super) fn thread_ids(&self) -> Vec { + self.rows.iter().map(|row| row.thread_id).collect() + } + + fn visible_indices(&self) -> Vec { + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + let search = state.search.to_lowercase(); + let mut visible = self + .rows + .iter() + .enumerate() + .filter_map(|(index, row)| { + let searchable = format!( + "{} {} {}", + row.thread.name.as_deref().unwrap_or_default(), + row.thread.preview, + row.thread.cwd.display(), + ) + .to_lowercase(); + (search.is_empty() || searchable.contains(&search)).then_some(index) + }) + .collect::>(); + if !state.status_grouping { + visible.sort_by_key(|index| { + ( + &self.rows[*index].thread.cwd, + std::cmp::Reverse(self.rows[*index].thread.updated_at), + ) + }); + } + visible + } + + fn move_selection(&mut self, forward: bool) { + let visible = self.visible_indices(); + if visible.is_empty() { + return; + } + let current = visible + .iter() + .position(|index| *index == self.selected) + .unwrap_or(0); + self.selected = if forward { + visible[(current + 1) % visible.len()] + } else { + visible[current.checked_sub(1).unwrap_or(visible.len() - 1)] + }; + } + + fn status(row: &AgentsOverviewRow) -> (&'static str, Span<'static>) { + match AgentsOverviewGroup::for_status(&row.thread.status) { + AgentsOverviewGroup::NeedsYou => ("Needs input", "●".red()), + AgentsOverviewGroup::Working => ("Working", "●".green()), + AgentsOverviewGroup::Ready => ("Ready", "○".cyan()), + AgentsOverviewGroup::Finished => ("Finished", "✓".dim()), + } + } + + fn render_rows(&self, area: Rect, buf: &mut Buffer) { + let mut offset = 0; + let mut previous_group: Option = None; + let project_grouping = !self + .state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .status_grouping; + let visible = self.visible_indices(); + let mut first = visible + .iter() + .position(|index| *index == self.selected) + .unwrap_or_default(); + let mut height = 2; + while first > 0 { + let previous = &self.rows[visible[first - 1]]; + let current = &self.rows[visible[first]]; + let group_changed = if project_grouping { + previous.thread.cwd != current.thread.cwd + } else { + previous.group != current.group + }; + let added_height = 1 + 2 * u16::from(group_changed); + if height + added_height > area.height { + break; + } + height += added_height; + first -= 1; + } + for index in visible.into_iter().skip(first) { + if offset >= area.height { + break; + } + let row = &self.rows[index]; + let group = if project_grouping { + row.thread.cwd.display().to_string() + } else { + row.group.label().to_string() + }; + if previous_group.as_deref() != Some(group.as_str()) { + offset += u16::from(previous_group.is_some()); + if offset >= area.height { + break; + } + let count = self + .rows + .iter() + .filter(|candidate| { + if project_grouping { + candidate.thread.cwd == row.thread.cwd + } else { + candidate.group == row.group + } + }) + .count(); + Line::from(vec![group.clone().bold(), format!(" {count}").dim()]) + .render(Rect::new(area.x, area.y + offset, area.width, 1), buf); + offset += 1; + previous_group = Some(group); + } + if offset >= area.height { + break; + } + let marker = if self.selected == index { + "›".cyan().bold() + } else { + " ".into() + }; + let title = row + .thread + .name + .as_deref() + .or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str())) + .unwrap_or("Untitled task"); + let (status, dot) = Self::status(row); + let current = if row.is_current { " current" } else { "" }; + let mut spans = vec![ + marker, + " ".into(), + dot, + " ".into(), + title.into(), + current.dim(), + ]; + if project_grouping { + spans.extend([" ".into(), status.dim()]); + } + Line::from(spans).render(Rect::new(area.x, area.y + offset, area.width, 1), buf); + offset += 1; + } + } +} + +impl BottomPaneView for AgentsOverviewView { + fn view_id(&self) -> Option<&'static str> { + Some(AGENTS_OVERVIEW_VIEW_ID) + } + + fn selected_index(&self) -> Option { + Some(self.selected) + } + + fn keymap_contexts(&self) -> KeymapContextSet { + KeymapContextSet::new(KeymapContext::List) + } + + fn completion(&self) -> Option { + self.completion + } + + fn is_complete(&self) -> bool { + self.completion.is_some() + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn handle_key_event(&mut self, key: KeyEvent) { + if key.code == KeyCode::Char('s') && key.modifiers == KeyModifiers::CONTROL { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.status_grouping = !state.status_grouping; + return; + } + if key.code == KeyCode::Char('f') && key.modifiers == KeyModifiers::CONTROL { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.searching = !state.searching; + return; + } + + if let Some(action) = self.keymap.action_for(key) + && (!matches!(key.code, KeyCode::Char(_)) + || self.state.lock().is_ok_and(|state| !state.searching)) + { + match action { + ListAction::MoveUp => self.move_selection(/*forward*/ false), + ListAction::MoveDown => self.move_selection(/*forward*/ true), + ListAction::JumpTop => { + self.selected = self.visible_indices().first().copied().unwrap_or(0); + } + ListAction::JumpBottom => { + self.selected = self.visible_indices().last().copied().unwrap_or(0); + } + ListAction::Cancel => { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if state.searching { + state.search.clear(); + state.searching = false; + self.selected = 0; + } else { + if self.exit_on_cancel { + self.app_event_tx + .send(AppEvent::Exit(crate::app::ExitMode::Immediate)); + } + self.completion = Some(ViewCompletion::Cancelled); + } + } + ListAction::PageUp | ListAction::PageDown => { + for _ in 0..5 { + self.move_selection(action == ListAction::PageDown); + } + } + ListAction::Accept | ListAction::MoveLeft | ListAction::MoveRight => {} + } + return; + } + + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if !state.searching { + return; + } + match key.code { + KeyCode::Backspace => { + state.search.pop(); + } + KeyCode::Char(character) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + state.search.push(character); + } + _ => return, + } + drop(state); + self.selected = self + .visible_indices() + .first() + .copied() + .unwrap_or(usize::MAX); + } +} + +impl Renderable for AgentsOverviewView { + fn desired_height(&self, _width: u16) -> u16 { + 24 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width < 12 || area.height < 8 { + return; + } + Clear.render(area, buf); + let [header, summary, divider, body, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + ]) + .areas(area); + let inset = + |rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0)); + Line::from("Agent command center".bold()).render(inset(header), buf); + let (needs_you, working, ready) = self.rows.iter().fold((0, 0, 0), |counts, row| { + let (needs_you, working, ready) = counts; + match row.group { + AgentsOverviewGroup::NeedsYou => (needs_you + 1, working, ready), + AgentsOverviewGroup::Working => (needs_you, working + 1, ready), + AgentsOverviewGroup::Ready => (needs_you, working, ready + 1), + AgentsOverviewGroup::Finished => counts, + } + }); + let attention = format!("{needs_you} need input"); + Line::from(format!("{attention} {working} working {ready} ready").dim()) + .render(inset(summary), buf); + Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim()) + .render(inset(divider), buf); + self.render_rows(inset(body), buf); + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + let footer_line = if state.searching { + Line::from(vec!["Search › ".cyan().bold(), state.search.clone().into()]) + } else { + Line::from(vec![ + "↑↓".bold(), + " navigate ".dim(), + "ctrl+f".bold(), + " search ".dim(), + "ctrl+s".bold(), + " group ".dim(), + "esc".bold(), + " back".dim(), + ]) + }; + footer_line.render(inset(footer), buf); + } +} diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index aba0a77106..7ebcf4ab9c 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -64,6 +64,7 @@ impl App { ); self.refresh_mcp_startup_expected_servers_from_config(); self.chat_widget.finish_mcp_startup_after_lag(); + self.refresh_agents_overview_threads(app_server_client); } AppServerEvent::ServerNotification(notification) => { self.handle_server_notification_event(app_server_client, *notification) @@ -86,6 +87,18 @@ impl App { app_server_client: &AppServerSession, notification: ServerNotification, ) { + if matches!( + ¬ification, + ServerNotification::ThreadStarted(_) + | ServerNotification::ThreadStatusChanged(_) + | ServerNotification::ThreadSettingsUpdated(_) + | ServerNotification::ThreadNameUpdated(_) + | ServerNotification::ThreadArchived(_) + | ServerNotification::ThreadDeleted(_) + | ServerNotification::ThreadClosed(_) + ) { + self.refresh_agents_overview_threads(app_server_client); + } match ¬ification { ServerNotification::ServerRequestResolved(notification) => { self.pending_primary_events.retain(|event| { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 17d86efaab..1266071d10 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2314,6 +2314,12 @@ impl App { AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } + AppEvent::OpenAgentsOverview => { + self.open_agents_overview(app_server); + } + AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { + self.apply_agents_overview_thread_refresh(app_server, request_id, result); + } AppEvent::OpenAgentPicker => { self.open_agent_picker(app_server).await; } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 5d2a244253..1039fd9472 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -434,6 +434,7 @@ See the Codex keymap documentation for supported actions and examples." thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), + agents_overview: Default::default(), side_threads: HashMap::new(), abandoned_side_threads: HashSet::new(), active_thread_id: None, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 2c39c9972e..acca925a6a 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -62,6 +62,7 @@ pub(super) async fn make_test_app() -> App { thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), + agents_overview: Default::default(), side_threads: HashMap::new(), abandoned_side_threads: HashSet::new(), active_thread_id: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 764ed6efd1..08c005abf8 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5092,6 +5092,7 @@ async fn make_test_app() -> App { thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), + agents_overview: Default::default(), side_threads: HashMap::new(), abandoned_side_threads: HashSet::new(), active_thread_id: None, @@ -5168,6 +5169,7 @@ async fn make_test_app_with_channels() -> ( thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), + agents_overview: Default::default(), side_threads: HashMap::new(), abandoned_side_threads: HashSet::new(), active_thread_id: None, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0e2791b75f..64a4643506 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -195,6 +195,13 @@ pub(crate) enum TranscriptExportDestination { #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { + /// Open the daemon-wide overview of loaded root sessions and their subagents. + OpenAgentsOverview, + /// Update the daemon-wide overview after a background thread listing finishes. + AgentsOverviewThreadsLoaded { + request_id: Uuid, + result: Result, String>, + }, /// Open the agent picker for switching active threads. OpenAgentPicker, /// Merge a completed root-scoped agent-picker refresh without blocking terminal input. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f11edd8bbd..a95226155d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -384,6 +384,7 @@ fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool { | SlashCommand::App | SlashCommand::Side | SlashCommand::Btw + | SlashCommand::Agents | SlashCommand::MultiAgents | SlashCommand::Vim | SlashCommand::Keymap @@ -4976,6 +4977,7 @@ mod tests { #[test] fn parent_owned_thread_allows_bare_navigation_commands() { for (command, expected) in [ + ("/agents", SlashCommand::Agents), ("/subagents", SlashCommand::MultiAgents), ("/side", SlashCommand::Side), ("/btw", SlashCommand::Btw), @@ -4996,13 +4998,13 @@ mod tests { fn parent_owned_thread_allows_safe_command_selected_from_prefix() { let (mut composer, _rx) = new_test_composer(); composer.set_parent_owned_thread(); - type_chars_humanlike(&mut composer, &['/', 's', 'u', 'b']); + type_chars_humanlike(&mut composer, &['/', 'a', 'g']); let result = composer .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .0; - assert_eq!(result, InputResult::Command(SlashCommand::MultiAgents)); + assert_eq!(result, InputResult::Command(SlashCommand::Agents)); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b002241f3b..6201e0513b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1222,6 +1222,21 @@ impl BottomPane { popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list) } + pub(crate) fn replace_view_if_present( + &mut self, + view_id: &'static str, + view: Box, + ) { + if let Some(index) = self + .view_stack + .iter() + .rposition(|existing| existing.view_id() == Some(view_id)) + { + self.view_stack[index] = view; + self.request_redraw(); + } + } + pub(crate) fn list_keymap(&self) -> crate::keymap::ListKeymap { self.keymap.list.clone() } @@ -1383,7 +1398,7 @@ impl BottomPane { .lines() .next() .and_then(parse_slash_name) - .is_some_and(|(name, _, _)| name == "subagents"); + .is_some_and(|(name, _, _)| matches!(name, "agents" | "subagents")); self.keymap.chat.interrupt_turn.is_pressed(key_event) && self.is_task_running diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 9d165445f1..52b980e93f 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -303,6 +303,7 @@ mod tests { commands, vec![ SlashCommand::Ide, + SlashCommand::Agents, SlashCommand::Copy, SlashCommand::Export, SlashCommand::Raw, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap index cb386ffbaf..fa06c625ab 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -23,6 +23,7 @@ expression: commands /app - continue this session in the Desktop app /init - create an AGENTS.md file with instructions for Codex /compact - summarize conversation to prevent hitting the context limit +/agents - view and switch between all active agent sessions /side - start a side conversation in an ephemeral fork /copy - copy last response as markdown /export - export the conversation as markdown diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index e61ceadc0d..f08da8e455 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -1,6 +1,7 @@ //! Key routing and composer-adjacent UI interaction for `ChatWidget`. use super::*; +use crate::bottom_pane::BottomPaneView; impl ChatWidget { pub(crate) fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { @@ -241,6 +242,21 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn show_bottom_pane_view(&mut self, view: Box) { + self.bottom_pane.show_view(view); + self.refresh_plan_mode_nudge(); + self.request_redraw(); + } + + pub(crate) fn replace_bottom_pane_view_if_present( + &mut self, + view_id: &'static str, + view: Box, + ) { + self.bottom_pane.replace_view_if_present(view_id, view); + self.refresh_plan_mode_nudge(); + } + pub(crate) fn selected_index_for_present_view(&self, view_id: &'static str) -> Option { self.bottom_pane.selected_index_for_present_view(view_id) } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 37685eed6a..67bb5005f0 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -7,6 +7,16 @@ use std::cell::Cell; impl ChatWidget { pub(crate) fn as_renderable(&self) -> RenderableItem<'_> { + if self + .bottom_pane + .selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + return self + .bottom_pane + .as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0); + } + let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols(); let active_cell_renderable = match &self.transcript.active_cell { Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index bcdf44ad7e..0c96c342e6 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -313,6 +313,9 @@ impl ChatWidget { SlashCommand::Side | SlashCommand::Btw => { self.request_empty_side_conversation(cmd); } + SlashCommand::Agents => { + self.app_event_tx.send(AppEvent::OpenAgentsOverview); + } SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); } @@ -1139,6 +1142,7 @@ impl ChatWidget { | SlashCommand::Side | SlashCommand::Btw | SlashCommand::Keymap + | SlashCommand::Agents | SlashCommand::MultiAgents | SlashCommand::Permissions | SlashCommand::ElevateSandbox diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 568bd8509a..cbfd820859 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -40,6 +40,7 @@ pub enum SlashCommand { Compact, Plan, Goal, + Agents, Side, Btw, Copy, @@ -126,6 +127,7 @@ impl SlashCommand { SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Plan => "switch to Plan mode", SlashCommand::Goal => "set or view the goal for a long-running task", + SlashCommand::Agents => "view and switch between all active agent sessions", SlashCommand::MultiAgents => "switch between this session's subagents", SlashCommand::Side | SlashCommand::Btw => { "start a side conversation in an ephemeral fork" @@ -187,6 +189,7 @@ impl SlashCommand { matches!( self, SlashCommand::Copy + | SlashCommand::Agents | SlashCommand::Export | SlashCommand::Raw | SlashCommand::Diff @@ -255,7 +258,7 @@ impl SlashCommand { | SlashCommand::Btw => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, - SlashCommand::MultiAgents => true, + SlashCommand::Agents | SlashCommand::MultiAgents => true, SlashCommand::Theme | SlashCommand::Pets => false, } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap new file mode 100644 index 0000000000..3434395801 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 49 +expression: "render_bottom_popup(&app.chat_widget, 96)" +--- + Agent command center + 1 need input 0 working 0 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 1 + › ● Example task current Needs input + + + + + + + + + + + + + + + + + + + ↑↓ navigate ctrl+f search ctrl+s group esc back