From 319b2f72b1d442c3fe2fd4b4d07d0eb9ffde879a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 17 Aug 2026 23:42:01 +0000 Subject: [PATCH] Make the agents overview an interactive task dashboard (#39112) ## What changed - Let users start tasks, open root sessions, rename tasks, and stop active work directly from the agents overview. - Show details for the selected task on wide terminals and limit the overview to root sessions while still reflecting background activity. - Preserve draft input and pending server requests when switching sessions so running roots and their subagents can continue in the background. - Apply the selected project's working directory when dispatching from project grouping, including remote workspaces. ## Testing - Expand overview rendering and interaction coverage for root filtering, dispatch, search, selection, renaming, and responsive input. - Cover switching away from roots with running subagents and retaining background server requests. GitOrigin-RevId: b7b0d33b7ecc077498efb756a0ad2fd425158b17 --- codex-rs/tui/src/app/agents_overview.rs | 394 ++++++++++++++++++ codex-rs/tui/src/app/agents_overview_tests.rs | 325 +++++++++++++-- codex-rs/tui/src/app/agents_overview_view.rs | 325 +++++++++++++-- codex-rs/tui/src/app/app_server_events.rs | 78 +++- codex-rs/tui/src/app/app_server_requests.rs | 28 +- codex-rs/tui/src/app/event_dispatch.rs | 28 ++ codex-rs/tui/src/app/session_lifecycle.rs | 11 +- codex-rs/tui/src/app/working_directory.rs | 4 +- codex-rs/tui/src/app_event.rs | 20 +- codex-rs/tui/src/app_server_session.rs | 9 +- ...ents_overview__tests__agents_overview.snap | 47 +-- 11 files changed, 1130 insertions(+), 139 deletions(-) diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index d5a4bc77d3..94c139d2f9 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -4,6 +4,7 @@ use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewView; use super::*; +use crate::chatwidget::ThreadInputStateRestoreMode; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; @@ -15,6 +16,9 @@ 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_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; use codex_protocol::protocol::SubAgentSource; pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview"; @@ -27,6 +31,8 @@ pub(super) struct AgentsOverviewState { pub(super) visible_thread_ids: Vec, pub(super) view_state: Arc>, + pub(super) input_states: HashMap, + pub(super) dispatched_requests: HashMap>, } impl App { @@ -190,6 +196,14 @@ impl App { }; let view = self.agents_overview_view(threads, selected_thread_id); self.agents_overview.visible_thread_ids = view.thread_ids(); + if selected_thread_id + .is_some_and(|thread_id| !self.agents_overview.visible_thread_ids.contains(&thread_id)) + && let Ok(mut state) = self.agents_overview.view_state.lock() + && state.renaming + { + state.renaming = false; + state.input.clear(); + } 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) { @@ -253,6 +267,386 @@ impl App { Arc::clone(&self.agents_overview.view_state), ) } + + pub(super) async fn select_agents_overview_thread( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + root_thread_id: ThreadId, + ) -> color_eyre::Result { + if self.current_displayed_thread_id() == Some(root_thread_id) { + return Ok(AppRunControl::Continue); + } + + if self.primary_thread_id != Some(root_thread_id) { + let previous_displayed_thread_id = self.current_displayed_thread_id(); + let mut previous_thread_ids = + Vec::from_iter(self.thread_event_channels.keys().copied()); + previous_thread_ids.extend( + self.agent_navigation + .tracked_thread_ids() + .into_iter() + .filter(|thread_id| !self.thread_event_channels.contains_key(thread_id)), + ); + let mut previous_running_thread_ids = Vec::new(); + let mut previous_pending_requests = Vec::new(); + for thread_id in &previous_thread_ids { + if self.side_threads.contains_key(thread_id) { + continue; + } + if let Some(channel) = self.thread_event_channels.get(thread_id) { + let store = channel.store.lock().await; + if store.active_turn_id().is_some() { + previous_running_thread_ids.push(*thread_id); + } + if let Some(input_state) = store.input_state.clone() { + self.agents_overview + .input_states + .insert(*thread_id, input_state); + } + let requests = store.pending_replay_requests(); + if !requests.is_empty() { + previous_pending_requests.push((*thread_id, requests)); + } + } else if self + .agent_navigation + .get(thread_id) + .is_some_and(|entry| entry.is_running) + { + previous_running_thread_ids.push(*thread_id); + } + } + if let Some(active_thread_id) = self.current_displayed_thread_id() + && let Some(input_state) = self.chat_widget.capture_thread_input_state() + { + self.agents_overview + .input_states + .insert(active_thread_id, input_state); + } + + let target_thread = match app_server + .thread_read(root_thread_id, /*include_turns*/ false) + .await + { + Ok(thread) => thread, + Err(error) => { + self.chat_widget.add_error_message(format!( + "Agent session {root_thread_id} is unavailable: {error}" + )); + return Ok(AppRunControl::Continue); + } + }; + let config_cwd = if self.app_server_target.uses_remote_workspace() { + self.config.cwd.to_path_buf() + } else { + target_thread.cwd.to_path_buf() + }; + let current_cwd = self.config.cwd.to_path_buf(); + let resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, config_cwd) + .await + { + Ok(config) => config, + Err(error) => { + self.chat_widget + .add_error_message(format!("Failed to load task settings: {error}")); + return Ok(AppRunControl::Continue); + } + }; + let baseline_approval = resume_config.permissions.approval_policy.value(); + let baseline_permissions = + RuntimePermissionProfileOverride::from_config(&resume_config); + let resumed = match app_server + .resume_thread( + resume_config.clone(), + root_thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await + { + Ok(resumed) => resumed, + Err(error) => { + self.chat_widget + .add_error_message(format!("Failed to attach to task: {error}")); + return Ok(AppRunControl::Continue); + } + }; + if !previous_running_thread_ids.is_empty() { + for side_thread_id in Vec::from_iter(self.side_threads.keys().copied()) { + if !self.discard_side_thread(app_server, side_thread_id).await { + let _ = app_server.thread_unsubscribe(root_thread_id).await; + return Ok(AppRunControl::Continue); + } + } + } + for (thread_id, requests) in previous_pending_requests { + self.agents_overview + .dispatched_requests + .entry(thread_id) + .or_default() + .extend(requests); + } + if !previous_running_thread_ids.is_empty() { + for thread_id in &previous_thread_ids { + if !self.side_threads.contains_key(thread_id) { + self.agents_overview + .dispatched_requests + .entry(*thread_id) + .or_default(); + } + } + } + if previous_running_thread_ids.is_empty() { + self.shutdown_current_thread(app_server).await; + } + self.runtime_approval_policy_override = None; + self.runtime_permission_profile_override = None; + self.config = resume_config; + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + if let Err(error) = self + .replace_chat_widget_with_app_server_thread( + tui, + resumed, + super::session_lifecycle::ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await + { + self.chat_widget + .add_error_message(format!("Failed to attach to task: {error}")); + return Ok(AppRunControl::Continue); + } + let mut destination_config = self.chat_widget.config_ref().clone(); + if self.app_server_target.uses_remote_workspace() { + destination_config.cwd.clone_from(&self.config.cwd); + destination_config + .workspace_roots + .clone_from(&self.config.workspace_roots); + destination_config + .permissions + .set_workspace_roots(self.config.permissions.workspace_roots().to_vec()); + } + self.config = destination_config; + if self.config.permissions.approval_policy.value() != baseline_approval { + self.runtime_approval_policy_override = + Some(self.config.permissions.approval_policy.value().into()); + } + let destination_permissions = + RuntimePermissionProfileOverride::from_config(&self.config); + if destination_permissions != baseline_permissions { + self.runtime_permission_profile_override = Some(destination_permissions); + } + if !self + .backfill_loaded_subagent_threads(app_server) + .await + .completed + { + self.backfill_loaded_subagent_threads(app_server).await; + } + for thread_id in previous_thread_ids { + if previous_running_thread_ids.is_empty() + && thread_id != root_thread_id + && Some(thread_id) != previous_displayed_thread_id + && let Err(error) = app_server.thread_unsubscribe(thread_id).await + { + tracing::warn!(%thread_id, %error, "failed to unsubscribe previous agent thread"); + } + } + } + + if self.current_displayed_thread_id() != Some(root_thread_id) { + self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id) + .await?; + } + self.replay_agents_overview_requests(app_server, root_thread_id) + .await; + if self.current_displayed_thread_id() == Some(root_thread_id) + && let Some(input_state) = self.agents_overview.input_states.remove(&root_thread_id) + { + let preserve_in_flight_turn = self + .active_turn_id_for_thread(root_thread_id) + .await + .is_some(); + self.chat_widget.restore_thread_input_state( + Some(input_state), + ThreadInputStateRestoreMode { + preserve_in_flight_turn, + }, + ); + if !preserve_in_flight_turn { + self.chat_widget.maybe_send_next_queued_input(); + } + } + self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id) + .await; + + Ok(AppRunControl::Continue) + } + + pub(super) async fn replay_agents_overview_requests( + &mut self, + app_server: &AppServerSession, + root_thread_id: ThreadId, + ) { + let requests = self + .agents_overview + .dispatched_requests + .extract_if(|id, _| *id == root_thread_id || self.agent_navigation.get(id).is_some()) + .flat_map(|(_, requests)| requests) + .collect::>(); + for request in requests { + self.handle_app_server_event( + app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new(request)), + ) + .await; + } + } + + pub(super) async fn dispatch_agents_overview_task( + &mut self, + app_server: &mut AppServerSession, + prompt: String, + cwd: Option, + ) { + self.refresh_in_memory_config_from_disk_best_effort("starting a background task") + .await; + let remote_cwd = cwd + .as_ref() + .filter(|_| app_server.uses_remote_workspace()) + .map(AbsolutePathBuf::to_path_buf); + let mut config = match cwd { + Some(cwd) if app_server.uses_remote_workspace() => { + let mut config = self.fresh_session_config(); + config.cwd = cwd; + config + } + Some(cwd) => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { + Ok(config) => config, + Err(error) => { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.input = prompt; + } + return self + .chat_widget + .add_error_message(format!("Failed to load project settings: {error}")); + } + }, + None => self.fresh_session_config(), + }; + if let Some(profile) = self.runtime_permission_profile_override.as_ref() + && profile.active_permission_profile.is_some() + && (RuntimePermissionProfileOverride::from_config(&config) != *profile + || config.permissions.profile_workspace_roots() + != self.config.permissions.profile_workspace_roots()) + { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.input = prompt; + } + return self + .chat_widget + .add_error_message("Permission profile has different settings.".to_string()); + } + self.apply_runtime_policy_overrides(&mut config); + apply_managed_new_thread_defaults( + &mut config, + app_server.managed_new_thread_defaults(), + &self.cli_kv_overrides, + &self.harness_overrides, + ); + match app_server + .start_thread_with_session_start_source( + &config, + /*session_start_source*/ None, + remote_cwd.as_deref(), + ) + .await + { + Ok(started) => { + let thread_id = started.session.thread_id; + self.agents_overview + .dispatched_requests + .insert(thread_id, Vec::new()); + self.submit_agents_overview_prompt(app_server, thread_id, prompt) + .await; + } + Err(error) => { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.input = prompt; + } + self.chat_widget + .add_error_message(format!("Failed to start background task: {error}")); + } + } + } + + pub(super) async fn submit_agents_overview_prompt( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + prompt: String, + ) { + let result = app_server + .request_handle() + .request_typed::(ClientRequest::TurnStart { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![UserInput::Text { + text: prompt.clone(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await; + if let Err(error) = result { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.input = prompt; + } + self.chat_widget + .add_error_message(format!("Failed to send task message: {error}")); + } + } + + pub(super) async fn stop_agents_overview_thread( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) { + let active_turn_id = match self.active_turn_id_for_thread(thread_id).await { + Some(turn_id) => Some(turn_id), + None => match app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + { + Ok(thread) => thread + .turns + .into_iter() + .find(|turn| turn.status == TurnStatus::InProgress) + .map(|turn| turn.id), + Err(error) => { + self.chat_widget + .add_error_message(format!("Failed to stop background task: {error}")); + self.refresh_agents_overview_threads(app_server); + return; + } + }, + }; + let Some(turn_id) = active_turn_id else { + return; + }; + if let Err(error) = app_server.turn_interrupt(thread_id, turn_id).await { + self.chat_widget + .add_error_message(format!("Failed to stop background task: {error}")); + self.refresh_agents_overview_threads(app_server); + } + } } #[cfg(test)] diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 37e54132f7..9eb42048f9 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1,68 +1,220 @@ -use crate::app::agents_overview_view::AgentsOverviewGroup; +use super::*; use crate::app::test_support::make_test_app; use crate::bottom_pane::BottomPaneView; use crate::chatwidget::tests::helpers::render_bottom_popup; +use crate::render::renderable::Renderable; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use crate::test_support::test_path_display; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::CurrentTimeReadParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SessionSource; 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 codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::ThreadUnsubscribeStatus; +use codex_protocol::protocol::SubAgentSource; + +static OVERVIEW_TIMESTAMP: std::sync::LazyLock = + std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120); + +fn overview_thread( + thread_id: ThreadId, + parent_thread_id: Option, + name: &str, + status: ThreadStatus, +) -> Thread { + Thread { + id: thread_id.to_string(), + extra: None, + project_id: None, + session_id: parent_thread_id.unwrap_or(thread_id).to_string(), + forked_from_id: None, + parent_thread_id: None, + preview: name.to_string(), + ephemeral: false, + section: None, + section_entered_at: None, + history_mode: Default::default(), + model_provider: "openai".to_string(), + created_at: *OVERVIEW_TIMESTAMP, + updated_at: *OVERVIEW_TIMESTAMP, + recency_at: Some(*OVERVIEW_TIMESTAMP), + status, + path: None, + cwd: test_path_buf("/tmp/project").abs(), + cli_version: "0.0.0".to_string(), + source: parent_thread_id.map_or(SessionSource::Cli, |parent_thread_id| { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }) + }), + can_accept_direct_input: Some(parent_thread_id.is_none()), + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some(name.to_string()), + turns: Vec::new(), + } +} #[tokio::test] -async fn shared_agents_dashboard_is_rendered() { +async fn shared_overview_shows_only_root_sessions() { 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 first_root = ThreadId::from_string("00000000-0000-0000-0000-000000000101").unwrap(); + let [child, second_root, side_thread] = std::array::from_fn(|_| ThreadId::new()); + app.primary_thread_id = Some(first_root); + + let mut threads = vec![ + overview_thread( + first_root, + /*parent_thread_id*/ None, + "Build the dashboard", + ThreadStatus::Idle, + ), + overview_thread( + child, + Some(first_root), + "Inspect keyboard shortcuts", + ThreadStatus::Active { + active_flags: vec![ThreadActiveFlag::WaitingOnApproval], + }, + ), + overview_thread( + second_root, + /*parent_thread_id*/ None, + "Repair authentication", + ThreadStatus::Active { + active_flags: Vec::new(), + }, + ), + ]; + let mut side = overview_thread( + side_thread, + /*parent_thread_id*/ None, + "Ephemeral side", + ThreadStatus::Idle, + ); + side.ephemeral = true; + threads.push(side); + let view = app.agents_overview_view(threads, /*selected_thread_id*/ None); + + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut action_view = AgentsOverviewView::new( + view.rows.clone(), + Some(first_root), + /*exit_on_cancel*/ false, + crate::app_event_sender::AppEventSender::new(event_tx), + app.keymap.clone(), + Arc::clone(&app.agents_overview.view_state), + ); let state = &app.agents_overview.view_state; assert!(!state.lock().unwrap().status_grouping); - view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + action_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)); + assert!( + action_view.handle_paste("Use \u{1b}[31mthe\u{1b}[0m current project\u{7}".to_string()) + ); + action_view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None }) + if prompt == "Use the current project" + )); + action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(action_view.handle_paste("Fix the flaky tests after all retries complete".to_string())); + let area = ratatui::layout::Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, + ); + let mut buffer = ratatui::buffer::Buffer::empty(area); + action_view.render(area, &mut buffer); + let prompt = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(prompt.contains("complete")); + action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: Some(cwd) }) + if prompt == "Fix the flaky tests after all retries complete" + && cwd == test_path_buf("/tmp/project").abs() + )); + assert!(action_view.handle_paste(" ".to_string())); + action_view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); + assert!(action_view.handle_paste("Repair authentication".to_string())); + action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second_root + )); + assert!(action_view.handle_paste("Continue working".to_string())); + action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. }) + if prompt.trim() == "Continue working" + )); + action_view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + crate::chatwidget::tests::helpers::set_active_cell( + &mut app.chat_widget, + Box::new(crate::history_cell::PlainHistoryCell::new(vec![ + Line::from("Previous session status"), + ])), + ); app.chat_widget.show_bottom_pane_view(Box::new(view)); - + let project = test_path_display("/tmp/project"); + let normalized_project_group = format!( + "/tmp/project 2{}", + " ".repeat(project.len().saturating_sub("/tmp/project".len())) + ); 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") + .replace(&format!("{project} 2"), &normalized_project_group) + .replace(&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; + let thread_id = if index == 0 { + first_root + } else { + ThreadId::new() + }; + let status = if index == 0 { + ThreadStatus::Active { + active_flags: vec![ThreadActiveFlag::WaitingOnApproval], + } + } else { + ThreadStatus::Idle + }; + let mut candidate = overview_thread( + thread_id, + /*parent_thread_id*/ None, + &format!("Task {index}"), + status, + ); + if index == 0 { + candidate.name = None; + candidate.preview = "Inspect unnamed task".to_string(); } - candidate.name = (index != 0).then(|| format!("Task {index}")); candidate.updated_at = index; candidate.cwd = if index == 0 { test_path_buf("/tmp/project-selected").abs() @@ -77,10 +229,12 @@ async fn shared_agents_dashboard_is_rendered() { 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") - )); + assert!( + rendered + .lines() + .any(|line| line.contains("› ● Inspect unnamed task current") + && line.contains("Needs input")) + ); app.transcript_cells.push(std::sync::Arc::new( crate::history_cell::PlainHistoryCell::new(vec![ratatui::text::Line::from( @@ -98,6 +252,95 @@ async fn shared_agents_dashboard_is_rendered() { .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"); +} + +#[tokio::test] +async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + app.primary_thread_id = Some(ThreadId::new()); + app.ensure_thread_channel(ThreadId::new()) + .store + .lock() + .await + .active_turn_id = Some("running-turn".to_string()); + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.select_agents_overview_thread(&mut tui, &mut app_server, ThreadId::new()) + .await?; + + assert!(app.agents_overview.dispatched_requests.is_empty()); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let previous = app_server.start_thread(&app.config).await?; + let previous_root_id = previous.session.thread_id; + app.enqueue_primary_thread_session(previous.session, previous.turns) + .await?; + let target_thread_id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Target task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialize target rollout"), + )?; + let target = app_server + .resume_thread( + app.config.clone(), + target_thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + let child_id = ThreadId::new(); + let idle_child_id = ThreadId::new(); + app.ensure_thread_channel(idle_child_id); + app.upsert_agent_picker_thread( + child_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, + ); + app.agent_navigation.mark_running(child_id); + app.active_thread_id = Some(child_id); + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.select_agents_overview_thread(&mut tui, &mut app_server, target.session.thread_id) + .await?; + + assert!( + app.agents_overview + .dispatched_requests + .contains_key(&idle_child_id) + ); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerRequest(Box::new(ServerRequest::CurrentTimeRead { + request_id: RequestId::Integer(99), + params: CurrentTimeReadParams { + thread_id: idle_child_id.to_string(), + }, + })), + ) + .await; + assert!(app.agents_overview.dispatched_requests[&idle_child_id].is_empty()); + let response: ThreadUnsubscribeResponse = app_server + .request_handle() + .request_typed(ClientRequest::ThreadUnsubscribe { + request_id: RequestId::String("verify-root-subscription".to_string()), + params: ThreadUnsubscribeParams { + thread_id: previous_root_id.to_string(), + }, + }) + .await?; + assert_eq!(response.status, ThreadUnsubscribeStatus::Unsubscribed); + app_server.shutdown().await?; + Ok(()) } diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 6354c02871..e6334037d9 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -1,10 +1,11 @@ -//! Read-only dashboard of the daemon's active tasks. +//! Dashboard for inspecting and managing 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::key_hint::is_plain_text_key_event; use crate::keymap::KeymapContext; use crate::keymap::KeymapContextSet; use crate::keymap::ListAction; @@ -23,14 +24,19 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use std::sync::Arc; use std::sync::Mutex; +use std::sync::MutexGuard; use std::sync::PoisonError; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub(super) enum AgentsOverviewGroup { @@ -76,10 +82,12 @@ pub(super) struct AgentsOverviewRow { #[derive(Clone, Default)] pub(super) struct AgentsOverviewViewState { + pub(super) input: String, search: String, searching: bool, pub(super) status_grouping: bool, pub(super) refresh_task: Option, + pub(super) renaming: bool, } pub(super) struct AgentsOverviewView { @@ -139,6 +147,16 @@ impl AgentsOverviewView { self.rows.iter().map(|row| row.thread_id).collect() } + fn state(&self) -> MutexGuard<'_, AgentsOverviewViewState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn selected_row(&self) -> Option<&AgentsOverviewRow> { + self.rows + .get(self.selected) + .filter(|_| self.visible_indices().contains(&self.selected)) + } + fn visible_indices(&self) -> Vec { let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); let search = state.search.to_lowercase(); @@ -169,6 +187,9 @@ impl AgentsOverviewView { } fn move_selection(&mut self, forward: bool) { + if self.state().renaming { + return; + } let visible = self.visible_indices(); if visible.is_empty() { return; @@ -184,8 +205,67 @@ impl AgentsOverviewView { }; } + fn activate(&mut self) { + let state = self.state().clone(); + let input = state.input.clone(); + if !state.searching && !input.is_empty() && input.trim().is_empty() { + return; + } + if !state.searching && !input.trim().is_empty() { + if state.renaming { + if let Some(row) = self.selected_row() { + self.app_event_tx + .send(AppEvent::RenameAgentsOverviewThread { + thread_id: row.thread_id, + name: input.trim().to_string(), + }); + } + self.state().renaming = false; + } else { + self.app_event_tx + .send(AppEvent::DispatchAgentsOverviewTask { + prompt: input, + cwd: (!state.status_grouping) + .then(|| self.selected_row().map(|row| row.thread.cwd.clone())) + .flatten(), + }); + } + self.state().input.clear(); + } else if let Some(row) = self.selected_row().filter(|_| !state.renaming) { + self.app_event_tx + .send(AppEvent::SelectAgentsOverviewThread { + thread_id: row.thread_id, + }); + if state.searching { + let mut state = self.state(); + state.search.clear(); + state.searching = false; + } + self.completion = Some(ViewCompletion::Accepted); + } + } + + fn edit_input(&mut self, edit: impl FnOnce(&mut String)) -> bool { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + let searching = state.searching; + edit(if searching { + &mut state.search + } else { + &mut state.input + }); + drop(state); + if searching { + self.selected = self + .visible_indices() + .first() + .copied() + .unwrap_or(usize::MAX); + } + true + } + fn status(row: &AgentsOverviewRow) -> (&'static str, Span<'static>) { - match AgentsOverviewGroup::for_status(&row.thread.status) { + match row.group { AgentsOverviewGroup::NeedsYou => ("Needs input", "●".red()), AgentsOverviewGroup::Working => ("Working", "●".green()), AgentsOverviewGroup::Ready => ("Ready", "○".cyan()), @@ -284,6 +364,49 @@ impl AgentsOverviewView { offset += 1; } } + + fn render_details(&self, area: Rect, buf: &mut Buffer) { + let Some(row) = self.selected_row() else { + return; + }; + let (status, dot) = Self::status(row); + let mut lines = vec![ + Line::from("Task details".bold()), + Line::default(), + Line::from(row.thread.name.as_deref().unwrap_or("Untitled task").bold()), + Line::from(vec![dot, " ".into(), status.into()]), + Line::default(), + Line::from("Project".dim()), + Line::from(row.thread.cwd.display().to_string()), + ]; + if let Some(branch) = row + .thread + .git_info + .as_ref() + .and_then(|git| git.branch.as_ref()) + { + lines.push(Line::default()); + lines.push("Branch".dim().into()); + lines.push(branch.clone().into()); + } + let preview = crate::text_formatting::truncate_text( + &row.thread.preview, + usize::from(area.width) * usize::from(area.height), + ); + lines.extend([ + Line::default(), + Line::from("Latest activity".dim()), + Line::from(match preview.as_str() { + "" => "No activity yet.", + preview => preview, + }), + ]); + Paragraph::new(crate::wrapping::word_wrap_lines( + lines, + usize::from(area.width), + )) + .render(area, buf); + } } impl BottomPaneView for AgentsOverviewView { @@ -311,22 +434,70 @@ impl BottomPaneView for AgentsOverviewView { true } + fn handle_paste(&mut self, pasted: String) -> bool { + self.edit_input(|input| { + input.push_str(&crate::history_cell::sanitize_user_text(pasted.into())) + }) + } + 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; + if key.modifiers == KeyModifiers::CONTROL + && matches!(key.code, KeyCode::Char('f' | 'n' | 'r' | 's' | 'x')) + { + match key.code { + KeyCode::Char('s') => { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.status_grouping = !state.status_grouping; + } + KeyCode::Char('f') => { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if !state.renaming { + state.searching = !state.searching; + if !state.searching { + state.search.clear(); + } + } + } + KeyCode::Char('n') => { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.search.clear(); + state.searching = false; + state.renaming = false; + state.input.clear(); + } + KeyCode::Char('r') => { + if let Some(row) = self.selected_row() { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if state.input.is_empty() { + state.input = row.thread.name.clone().unwrap_or_default(); + state.search.clear(); + state.searching = false; + state.renaming = true; + } + } + } + KeyCode::Char('x') => { + if let Some(row) = self.selected_row() + && matches!(row.thread.status, ThreadStatus::Active { .. }) + { + self.app_event_tx.send(AppEvent::StopAgentsOverviewThread { + thread_id: row.thread_id, + }); + } + } + _ => {} + } return; } - if let Some(action) = self.keymap.action_for(key) - && (!matches!(key.code, KeyCode::Char(_)) - || self.state.lock().is_ok_and(|state| !state.searching)) + if !is_plain_text_key_event(key) + && let Some(action) = self.keymap.action_for(key) { + if self.state().renaming + && matches!(action, ListAction::JumpTop | ListAction::JumpBottom) + { + return; + } match action { ListAction::MoveUp => self.move_selection(/*forward*/ false), ListAction::MoveDown => self.move_selection(/*forward*/ true), @@ -336,12 +507,16 @@ impl BottomPaneView for AgentsOverviewView { ListAction::JumpBottom => { self.selected = self.visible_indices().last().copied().unwrap_or(0); } + ListAction::Accept => self.activate(), 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 !state.input.is_empty() || state.renaming { + state.input.clear(); + state.renaming = false; } else { if self.exit_on_cancel { self.app_event_tx @@ -355,32 +530,24 @@ impl BottomPaneView for AgentsOverviewView { self.move_selection(action == ListAction::PageDown); } } - ListAction::Accept | ListAction::MoveLeft | ListAction::MoveRight => {} + 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(); + self.edit_input(|input| { + input.pop(); + }); } KeyCode::Char(character) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { - state.search.push(character); + self.edit_input(|input| input.push(character)); } - _ => return, + _ => {} } - drop(state); - self.selected = self - .visible_indices() - .first() - .copied() - .unwrap_or(usize::MAX); } } @@ -389,17 +556,34 @@ impl Renderable for AgentsOverviewView { 24 } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let state = self.state().clone(); + let (label, input) = if state.searching { + (" Search › ", &state.search) + } else if state.renaming { + (" Rename › ", &state.input) + } else { + (" New task › ", &state.input) + }; + let x = area + .x + .saturating_add((label.width() + input.width()) as u16) + .min(area.right().saturating_sub(3)); + Some((x, area.bottom().saturating_sub(2))) + } + 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([ + let [header, summary, divider, body, prompt, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), + Constraint::Length(1), ]) .areas(area); let inset = @@ -419,22 +603,77 @@ impl Renderable for AgentsOverviewView { .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(), + let body = inset(body); + if body.width >= 90 { + let [list, gap, details] = Layout::horizontal([ + Constraint::Min(46), + Constraint::Length(3), + Constraint::Length(38), ]) + .areas(body); + for y in gap.y..gap.bottom() { + buf[(gap.x + 1, y)] + .set_symbol("│") + .set_style(Style::new().dim()); + } + self.render_rows(list, buf); + self.render_details(details, buf); + } else { + self.render_rows(body, buf); + } + let state = self.state().clone(); + let (label, input) = if state.searching { + ("Search › ", &state.search) + } else if state.renaming { + ("Rename › ", &state.input) + } else { + ("New task › ", &state.input) }; - footer_line.render(inset(footer), buf); + let placeholder = if input.is_empty() && !state.searching && !state.renaming { + "Describe a task and press enter to dispatch it" + } else { + "" + }; + let available_width = usize::from(inset(prompt).width) + .saturating_sub(label.width()) + .saturating_sub(1); + let mut visible_start = input.len(); + let mut visible_width = 0; + for (index, character) in input.char_indices().rev() { + let width = character.width().unwrap_or(0); + if visible_width + width > available_width { + break; + } + visible_width += width; + visible_start = index; + } + let input = &input[visible_start..]; + Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()]) + .render(inset(prompt), buf); + let stop_hint = if self + .selected_row() + .is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })) + { + "ctrl+x".bold() + } else { + "ctrl+x".dim() + }; + Line::from(vec![ + "↑↓".bold(), + " navigate ".dim(), + "enter".bold(), + " open ".dim(), + "ctrl+f".bold(), + " search ".dim(), + "ctrl+s".bold(), + " group ".dim(), + "ctrl+r".bold(), + " rename ".dim(), + stop_hint, + " stop ".dim(), + "esc".bold(), + " back".dim(), + ]) + .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 7ebcf4ab9c..6b13bc0313 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -87,6 +87,21 @@ impl App { app_server_client: &AppServerSession, notification: ServerNotification, ) { + if let ServerNotification::ThreadStarted(started) = ¬ification + && let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = &started.thread.source + && self + .agents_overview + .dispatched_requests + .contains_key(parent_thread_id) + && let Ok(thread_id) = codex_protocol::ThreadId::from_string(&started.thread.id) + { + self.agents_overview + .dispatched_requests + .entry(thread_id) + .or_default(); + } if matches!( ¬ification, ServerNotification::ThreadStarted(_) @@ -105,6 +120,9 @@ impl App { !matches!(event, ThreadBufferedEvent::Request(request) if request.id() == ¬ification.request_id) }); + for requests in self.agents_overview.dispatched_requests.values_mut() { + requests.retain(|request| request.id() != ¬ification.request_id); + } if let Some(request) = self .pending_app_server_requests .resolve_notification(¬ification.request_id) @@ -289,9 +307,32 @@ impl App { .push_back(ThreadBufferedEvent::Request(Box::new(request))); return; } + let unsupported_request = matches!( + &request, + ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } + | ServerRequest::CurrentTimeRead { .. } + | ServerRequest::ApplyPatchApproval { .. } + | ServerRequest::ExecCommandApproval { .. } + ); + if self + .pending_app_server_requests + .contains_server_request(&request) + { + return; + } + if let Some(thread_id) = thread_id + && self.primary_thread_id != Some(thread_id) + && !unsupported_request + && let Some(requests) = self.agents_overview.dispatched_requests.get_mut(&thread_id) + { + requests.push(request); + return; + } if thread_id.is_some() && self.primary_thread_id.is_none() && !self.pending_startup_thread_start + && !unsupported_request { return; } @@ -301,6 +342,7 @@ impl App { && !self.thread_event_channels.contains_key(&thread_id) && self.agent_navigation.get(&thread_id).is_none() && !self.side_threads.contains_key(&thread_id) + && !unsupported_request { let thread = app_server_client .request_handle() @@ -312,18 +354,30 @@ impl App { }, }) .await; - if !matches!( - thread, - Ok(ThreadReadResponse { thread }) - if matches!(thread.source, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - .. - }) if self.primary_thread_id == Some(parent_thread_id) - || self.thread_event_channels.contains_key(&parent_thread_id) - || self.agent_navigation.get(&parent_thread_id).is_some() - ) - ) { + let Ok(ThreadReadResponse { thread }) = thread else { + return; + }; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = thread.source + else { + return; + }; + if self.primary_thread_id != Some(parent_thread_id) + && !self.thread_event_channels.contains_key(&parent_thread_id) + && self.agent_navigation.get(&parent_thread_id).is_none() + { + if self + .agents_overview + .dispatched_requests + .contains_key(&parent_thread_id) + { + self.agents_overview + .dispatched_requests + .entry(thread_id) + .or_default() + .push(request); + } return; } } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index b36f1d4a38..99d907e2f2 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -355,12 +355,12 @@ impl PendingAppServerRequests { .mcp_requests .values() .any(|pending_request_id| pending_request_id == request_id), + ServerRequest::ChatgptAuthTokensRefresh { .. } => true, ServerRequest::DynamicToolCall { .. } - | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::AttestationGenerate { .. } | ServerRequest::CurrentTimeRead { .. } | ServerRequest::ApplyPatchApproval { .. } - | ServerRequest::ExecCommandApproval { .. } => true, + | ServerRequest::ExecCommandApproval { .. } => false, } } @@ -721,18 +721,20 @@ mod tests { #[test] fn rejects_dynamic_tool_calls_as_unsupported() { let mut pending = PendingAppServerRequests::default(); + let request = ServerRequest::DynamicToolCall { + request_id: AppServerRequestId::Integer(99), + params: codex_app_server_protocol::DynamicToolCallParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + call_id: "tool-1".to_string(), + namespace: None, + tool: "tool".to_string(), + arguments: json!({}), + }, + }; + assert!(!pending.contains_server_request(&request)); let unsupported = pending - .note_server_request(&ServerRequest::DynamicToolCall { - request_id: AppServerRequestId::Integer(99), - params: codex_app_server_protocol::DynamicToolCallParams { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - call_id: "tool-1".to_string(), - namespace: None, - tool: "tool".to_string(), - arguments: json!({}), - }, - }) + .note_server_request(&request) .expect("dynamic tool calls should be rejected"); assert_eq!(unsupported.request_id, AppServerRequestId::Integer(99)); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1266071d10..be21f4280a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -445,6 +445,7 @@ impl App { app_server .start_thread_with_session_start_source( &config, /*session_start_source*/ None, + /*remote_cwd_override*/ None, ) .await } @@ -2320,6 +2321,33 @@ impl App { AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { self.apply_agents_overview_thread_refresh(app_server, request_id, result); } + AppEvent::SelectAgentsOverviewThread { thread_id } => { + match self + .select_agents_overview_thread(tui, app_server, thread_id) + .await? + { + AppRunControl::Continue => {} + AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), + } + } + AppEvent::DispatchAgentsOverviewTask { prompt, cwd } => { + self.dispatch_agents_overview_task(app_server, prompt, cwd) + .await; + } + AppEvent::RenameAgentsOverviewThread { thread_id, name } => { + if let Err(error) = app_server.thread_set_name(thread_id, name.clone()).await { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.input = name; + state.renaming = true; + } + self.chat_widget + .add_error_message(format!("Failed to rename task: {error}")); + } + } + AppEvent::StopAgentsOverviewThread { thread_id } => { + self.stop_agents_overview_thread(app_server, thread_id) + .await; + } AppEvent::OpenAgentPicker => { self.open_agent_picker(app_server).await; } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 696fbfe5c7..60e6c54004 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -529,6 +529,9 @@ impl App { &mut snapshot, ) .await; + if snapshot.input_state.is_none() { + snapshot.input_state = self.agents_overview.input_states.remove(&thread_id); + } let blocks_direct_input = self.agent_navigation.is_parent_owned(thread_id); self.active_thread_id = Some(thread_id); @@ -728,7 +731,11 @@ impl App { } self.config = config.clone(); match app_server - .start_thread_with_session_start_source(&config, session_start_source) + .start_thread_with_session_start_source( + &config, + session_start_source, + /*remote_cwd_override*/ None, + ) .await { Ok(mut started) => { @@ -1091,6 +1098,8 @@ impl App { { Ok(()) => { self.backfill_loaded_subagent_threads(app_server).await; + self.replay_agents_overview_requests(app_server, resumed_thread_id) + .await; if let Some(summary) = summary { let mut lines: Vec> = Vec::new(); if let Some(usage_line) = summary.usage_line { diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index 7f4dad99b8..3212f9085a 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -128,7 +128,9 @@ impl App { .await } else { app_server - .start_thread_with_session_start_source(&config, /*session_start_source*/ None) + .start_thread_with_session_start_source( + &config, /*session_start_source*/ None, /*remote_cwd_override*/ None, + ) .await }; let transitioned = match transitioned { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 64a4643506..506778538a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -195,13 +195,31 @@ 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. + /// Open the daemon-wide overview of loaded root sessions. OpenAgentsOverview, /// Update the daemon-wide overview after a background thread listing finishes. AgentsOverviewThreadsLoaded { request_id: Uuid, result: Result, String>, }, + /// Switch to a root session selected from the shared dashboard. + SelectAgentsOverviewThread { + thread_id: ThreadId, + }, + /// Start a background task directly from the shared dashboard. + DispatchAgentsOverviewTask { + prompt: String, + cwd: Option, + }, + /// Rename a task directly from the shared dashboard. + RenameAgentsOverviewThread { + thread_id: ThreadId, + name: String, + }, + /// Interrupt a task directly from the shared dashboard. + StopAgentsOverviewThread { + thread_id: ThreadId, + }, /// 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/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 491c35ce52..420c1fdabf 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -602,21 +602,24 @@ impl AppServerSession { #[cfg(test)] pub(crate) async fn start_thread(&mut self, config: &Config) -> Result { - self.start_thread_with_session_start_source(config, /*session_start_source*/ None) - .await + self.start_thread_with_session_start_source( + config, /*session_start_source*/ None, /*remote_cwd_override*/ None, + ) + .await } pub(crate) async fn start_thread_with_session_start_source( &mut self, config: &Config, session_start_source: Option, + remote_cwd_override: Option<&std::path::Path>, ) -> Result { let request_id = self.next_request_id(); let session_config = self.session_config_with_effective_service_tier(config); let mut params = thread_start_params_from_config( &session_config, self.thread_params_mode(), - self.remote_cwd_override.as_deref(), + remote_cwd_override.or(self.remote_cwd_override.as_deref()), session_start_source, ); if self.history_support == ThreadHistorySupport::LegacyOnly { 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 index 3434395801..19c033e869 100644 --- 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 @@ -1,29 +1,28 @@ --- source: tui/src/app/agents_overview_tests.rs -assertion_line: 49 -expression: "render_bottom_popup(&app.chat_widget, 96)" +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")" --- Agent command center - 1 need input 0 working 0 ready + 1 need input 1 working 0 ready ──────────────────────────────────────────────────────────────────────────────────────────── - /tmp/project 1 - › ● Example task current Needs input - - - - - - - - - - - - - - - - - - - ↑↓ navigate ctrl+f search ctrl+s group esc back + /tmp/project 2 │ Task details + › ● Build the dashboard current Needs input │ + ● Repair authentication Working │ Build the dashboard + │ ● Needs input + │ + │ Project + │ /tmp/project + │ + │ Latest activity + │ Build the dashboard + │ + │ + │ + │ + │ + │ + │ + │ + │ + New task › Describe a task and press enter to dispatch it + ↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back