From 516f2780fd227a80cd9fe89488f5039245090b71 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 13 Sep 2026 16:07:09 +0000 Subject: [PATCH] Open new sessions directly from the command center (#45255) ## What changed - Replace the inline task composer with a session list: `n` opens a blank session in the selected checkout without sending an initial turn or interrupting running agents. - Use single-letter action shortcuts and `Enter` to open a session. `Esc` cancels search or rename without closing the list; `Ctrl+C` quits from the list. - Load destination settings for new sessions while carrying over explicit permission choices. Retain blank sessions and their drafts across navigation until the first turn. ## Testing Update tests and snapshots for command center navigation, shortcuts, blank draft restoration, destination defaults, permission carryover, and directory trust. GitOrigin-RevId: bddd04fdbe6ac2f5b7bd70293fd04f673b97ba02 --- codex-rs/config/src/tui_keymap.rs | 2 +- codex-rs/core/config.schema.json | 2 +- codex-rs/tui/src/app/agents_overview.rs | 355 ++++------ .../tui/src/app/agents_overview_actions.rs | 3 +- .../src/app/agents_overview_actions_tests.rs | 33 +- .../tui/src/app/agents_overview_composer.rs | 83 --- .../tui/src/app/agents_overview_errors.rs | 19 +- codex-rs/tui/src/app/agents_overview_input.rs | 146 +--- codex-rs/tui/src/app/agents_overview_new.rs | 71 ++ .../tui/src/app/agents_overview_render.rs | 104 +-- codex-rs/tui/src/app/agents_overview_tests.rs | 658 +++++------------- .../tui/src/app/agents_overview_threads.rs | 27 + codex-rs/tui/src/app/agents_overview_view.rs | 155 +---- codex-rs/tui/src/app/config_persistence.rs | 11 + codex-rs/tui/src/app/event_dispatch.rs | 28 +- codex-rs/tui/src/app/input.rs | 17 +- codex-rs/tui/src/app/permission_shortcuts.rs | 3 +- codex-rs/tui/src/app/reconnect.rs | 2 + codex-rs/tui/src/app/resume_config.rs | 3 +- ...tions__agents_custom_lifecycle_chords.snap | 14 +- ...view__tests__agents_overview_markdown.snap | 10 +- ...__agents_overview_markdown_long_lines.snap | 12 +- ...tests__agents_overview_markdown_table.snap | 12 +- ...tests__agents_overview_model_grouping.snap | 14 +- ...__tests__older_server_narrow_overview.snap | 9 +- ...sts__overview_empty_prompt_right_hint.snap | 2 +- ...ts__overview_reopened_with_list_focus.snap | 28 - ...view__tests__overview_right_open_hint.snap | 2 +- ...__tests__usage__agents_overview_usage.snap | 12 +- codex-rs/tui/src/app/startup.rs | 3 +- .../src/app/tests/agents_navigation_tests.rs | 4 - .../tests/background_task_defaults_tests.rs | 628 ++++++++++------- .../tui/src/app/tests/disconnect_tests.rs | 21 +- codex-rs/tui/src/app/tests/key_chords.rs | 38 +- .../app/tests/navigation_reconnect_tests.rs | 21 +- .../app/tests/session_lifecycle_requests.rs | 5 +- ...nd_exit_tests__remote_disconnect_exit.snap | 2 +- ...and_center_pending_server_permissions.snap | 10 + ...s__command_center_session_start_error.snap | 11 + ...s__disconnect__offline_command_center.snap | 10 +- ...connect__daemon_command_center_failed.snap | 10 +- ...t__daemon_command_center_reconnecting.snap | 10 +- ...daemon_command_center_vanished_rename.snap | 10 +- ...__agents_command_center_after_archive.snap | 11 +- codex-rs/tui/src/app/thread_settings.rs | 3 + codex-rs/tui/src/app_event.rs | 9 +- codex-rs/tui/src/app_server_session.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 - .../chat_composer/agents_navigation.rs | 16 - .../bottom_pane/chat_composer/reconnect.rs | 7 +- codex-rs/tui/src/chatwidget.rs | 4 - codex-rs/tui/src/chatwidget/interaction.rs | 9 + codex-rs/tui/src/key_hint.rs | 2 + codex-rs/tui/src/keymap.rs | 74 +- codex-rs/tui/src/keymap/chords.rs | 3 +- codex-rs/tui/src/keymap/conflict_tests.rs | 14 +- codex-rs/tui/src/keymap_setup/actions.rs | 2 +- ...ents_overview__tests__agents_overview.snap | 12 +- ...iew__tests__agents_overview_attention.snap | 12 +- ...__tests__agents_overview_empty_narrow.snap | 14 +- ...__tests__agents_overview_last_message.snap | 10 +- ..._tests__agents_overview_live_activity.snap | 10 +- ...w__tests__agents_overview_unsent_task.snap | 13 - ...ts__agents_overview_worktree_grouping.snap | 6 +- ...setup__tests__keymap_debug_view_match.snap | 1 - ...__tests__keymap_picker_agents_actions.snap | 16 +- codex-rs/tui/tests/suite/directory_trust.rs | 14 +- 67 files changed, 1143 insertions(+), 1742 deletions(-) delete mode 100644 codex-rs/tui/src/app/agents_overview_composer.rs create mode 100644 codex-rs/tui/src/app/agents_overview_new.rs delete mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index b0fba208760e..b983b9a121c5 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -437,7 +437,7 @@ pub struct TuiAgentsKeymap { pub resume: Option, /// Search the available agent tasks. pub search: Option, - /// Start composing a new agent task. + /// Open a new session in the selected checkout. pub new_task: Option, /// Rename the selected task. pub rename: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 84f6ce9a5575..c8bae1fb7127 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4429,7 +4429,7 @@ "$ref": "#/definitions/KeybindingsSpec" } ], - "description": "Start composing a new agent task." + "description": "Open a new session in the selected checkout." }, "rename": { "allOf": [ diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index f75d36029999..049c1a99893d 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1,13 +1,12 @@ //! Daemon-wide overview of recent and locally retained sessions and their subagents. //! Tasks owned by another app server open as frozen, read-only history snapshots. -#[path = "agents_overview_composer.rs"] -mod composer; +#[path = "agents_overview_new.rs"] +mod new; #[path = "agents_overview_errors.rs"] mod errors; -use super::agents_overview_view::AgentsOverviewFocus; use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewView; @@ -18,18 +17,10 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; use crate::chatwidget::ThreadInputStateRestoreMode; -use crate::chatwidget::UserMessage; -use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::UserInput; -use codex_protocol::models::snapshot_local_user_input; -use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::SubAgentSource; -use codex_protocol::user_input::UserInput as CoreUserInput; pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview"; @@ -45,7 +36,6 @@ pub(super) struct AgentsOverviewState { pub(super) usage_disabled: bool, pub(super) activity: HashMap, pub(super) initialized: bool, - pub(super) unsent_prompt: Option, pub(super) request_id: Option, pub(super) refresh_pending: bool, pub(super) refresh_thread_ids: HashSet, @@ -55,6 +45,10 @@ pub(super) struct AgentsOverviewState { pub(super) visible_thread_ids: Vec, pub(super) view_state: Arc>, + /// Explicit permission-profile choices for new-session carryover, retained across navigation. + pub(super) selected_permission_profiles: HashMap, + /// Keep new tasks subscribed and reusable until a first turn makes them resumable. + pub(super) blank_sessions: HashMap, pub(super) input_states: HashMap, pub(super) dispatched_requests: HashMap>, } @@ -68,11 +62,7 @@ impl Drop for AgentsOverviewState { } impl App { - pub(super) fn open_agents_overview( - &mut self, - app_server: &AppServerSession, - focus: AgentsOverviewFocus, - ) { + pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) { if matches!(self.app_server_target, AppServerTarget::Embedded) { let workload_identity_selected = codex_login::is_workload_identity_selected(); self.chat_widget.show_selection_view(SelectionViewParams { @@ -122,19 +112,6 @@ impl App { return; } - { - let mut state = self - .agents_overview - .view_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // Launch into composition; returning from a task starts in browsing mode. - // Populated refreshes preserve any subsequent, explicit focus change. - match focus { - AgentsOverviewFocus::Composer => state.focus_composer(), - AgentsOverviewFocus::List => state.focus = AgentsOverviewFocus::List, - } - } let threads = self .agents_overview .threads @@ -308,19 +285,6 @@ impl App { }); } - self.sync_agents_overview_composer(); - // Wait for discovery and retained metadata before treating the list as empty. - if self.agents_overview.initialized - && rows.is_empty() - && self.agents_overview.threads.iter().all(|(id, thread)| { - thread.is_some() || self.agents_overview.hidden_threads.contains(id) - }) - && let Ok(mut state) = self.agents_overview.view_state.lock() - && state.focus == AgentsOverviewFocus::List - { - state.focus_composer(); - } - AgentsOverviewView::new( rows, selected_thread_id, @@ -337,10 +301,21 @@ impl App { } pub(super) async fn select_agents_overview_thread( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> Result { + Box::pin(self.attach_agents_overview_thread(tui, app_server, thread_id, /*started*/ None)) + .await + } + + async fn attach_agents_overview_thread( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, root_thread_id: ThreadId, + started: Option<(Config, crate::app_server_session::AppServerStartedThread)>, ) -> color_eyre::Result { if self.windows_sandbox_blocks_thread_switch() { return Ok(AppRunControl::Continue); @@ -358,6 +333,13 @@ impl App { } if self.primary_thread_id != Some(root_thread_id) { let previous_displayed_thread_id = self.current_displayed_thread_id(); + if let Some(id) = previous_displayed_thread_id + && let Some(blank) = self.agents_overview.blank_sessions.get_mut(&id) + && let Some(channel) = self.thread_event_channels.get(&id) + && let Some(session) = channel.store.lock().await.session.as_ref() + { + blank.session = session.clone(); + } let mut previous_thread_ids = Vec::from_iter(self.thread_event_channels.keys().copied()); previous_thread_ids.extend( @@ -418,7 +400,13 @@ impl App { target_thread.status, codex_app_server_protocol::ThreadStatus::NotLoaded ); - let (mut resume_config, mut local_settings) = if unloaded { + let preserve_explicit_permissions = unloaded || started.is_some(); + let (mut resume_config, mut local_settings) = if let Some((config, _)) = &started { + ( + config.clone(), + crate::local_settings::LocalSettings::from(config), + ) + } else if unloaded { let target_session = SessionTarget { path: target_thread.path.clone(), thread_id: root_thread_id, @@ -452,7 +440,7 @@ impl App { } } }; - if !unloaded { + if !unloaded && started.is_none() { if let Err(control) = self .confirm_directory_trust( tui, @@ -502,33 +490,49 @@ impl App { crate::app_server_session::ResumeModelSettings::PreserveExistingThread } }; - let (resumed, read_only) = match app_server - .resume_thread( - &local_settings, - resume_config.clone(), - root_thread_id, - resume_model_settings, - ) - .await + let (resumed, read_only) = if let Some((_, started)) = started { + (started, false) + } else if !unloaded + && let Some(blank) = self.agents_overview.blank_sessions.get(&root_thread_id) { - Ok(resumed) => (resumed, false), - Err(error) if crate::app_server_session::is_active_writer_error(&error) => { - match app_server - .read_thread_for_viewing(&resume_config, &local_settings, root_thread_id) - .await - { - Ok(thread) => (thread, true), - Err(error) => { - self.add_agents_overview_error(format!( - "Failed to view task open elsewhere: {error}" - )); - return Ok(AppRunControl::Continue); + // An untouched task has no rollout for thread/resume yet. Its live + // subscription and saved settings are sufficient to restore the editor. + (blank.clone(), false) + } else { + match app_server + .resume_thread( + &local_settings, + resume_config.clone(), + root_thread_id, + resume_model_settings, + ) + .await + { + Ok(resumed) => (resumed, false), + Err(error) if crate::app_server_session::is_active_writer_error(&error) => { + match app_server + .read_thread_for_viewing( + &resume_config, + &local_settings, + root_thread_id, + ) + .await + { + Ok(thread) => (thread, true), + Err(error) => { + self.add_agents_overview_error(format!( + "Failed to view task open elsewhere: {error}" + )); + return Ok(AppRunControl::Continue); + } } } - } - Err(error) => { - self.add_agents_overview_error(format!("Failed to attach to task: {error}")); - return Ok(AppRunControl::Continue); + Err(error) => { + self.add_agents_overview_error(format!( + "Failed to attach to task: {error}" + )); + return Ok(AppRunControl::Continue); + } } }; if !previous_running_thread_ids.is_empty() { @@ -556,19 +560,23 @@ impl App { } } } - if previous_running_thread_ids.is_empty() { + if previous_running_thread_ids.is_empty() + && !previous_displayed_thread_id + .is_some_and(|id| self.agents_overview.blank_sessions.contains_key(&id)) + { self.shutdown_current_thread(app_server).await; } - // Explicit user choices carry across cold resumes; inherited task settings do not. + // Explicit choices carry across cold resumes and new sessions. self.runtime_approval_policy_override = self.runtime_approval_policy_override.filter(|policy| { - unloaded && matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_)) + preserve_explicit_permissions + && matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_)) }); self.runtime_permission_profile_override = self .runtime_permission_profile_override .take() .filter(|profile| { - unloaded + preserve_explicit_permissions && profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox }); @@ -637,6 +645,7 @@ impl App { if previous_running_thread_ids.is_empty() && thread_id != root_thread_id && Some(thread_id) != previous_displayed_thread_id + && !self.agents_overview.blank_sessions.contains_key(&thread_id) && let Err(error) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!(%thread_id, %error, "failed to unsubscribe previous agent thread"); @@ -702,36 +711,46 @@ impl App { } } - pub(super) async fn dispatch_agents_overview_task( + async fn agents_overview_session_config( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, - prompt: UserMessage, cwd: Option, - ) { - self.refresh_in_memory_config_from_disk_best_effort("starting a background task") - .await; + ) -> Option<(Config, Option)> { + if self + .chat_widget + .thread_id() + .is_some_and(|thread_id| self.pending_server_profiles.contains_key(&thread_id)) + { + self.add_agents_overview_error( + "Wait for permissions to update before starting a session.".into(), + ); + return None; + } + let remote = app_server.uses_remote_workspace(); let remote_cwd = cwd .as_ref() - .filter(|_| app_server.uses_remote_workspace()) + .filter(|_| remote) .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 + let preserve_service_tier = remote || cwd.is_none(); + let local_cwd = if remote { + self.config.cwd.to_path_buf() + } else { + cwd.map_or_else( + || self.chat_widget.config_ref().cwd.to_path_buf(), + |cwd| cwd.to_path_buf(), + ) + }; + let mut config = match self.rebuild_config_for_cwd(local_cwd).await { + Ok(config) => config, + Err(error) => { + self.add_agents_overview_error(format!("Failed to load project settings: {error}")); + return None; } - Some(cwd) => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { - Ok(config) => config, - Err(error) => { - self.restore_agents_overview_prompt(prompt); - return self.add_agents_overview_error(format!( - "Failed to load project settings: {error}" - )); - } - }, - None => self.fresh_session_config(), }; + if preserve_service_tier { + config.service_tier = self.chat_widget.configured_service_tier(); + } let trust_cwd = config.cwd.to_path_buf(); if self .confirm_directory_trust( @@ -744,21 +763,26 @@ impl App { .await .is_err() { - self.restore_agents_overview_prompt(prompt); - return; + return None; } if let Some(profile) = self.runtime_permission_profile_override.as_ref() - && profile.active_permission_profile.is_some() + && profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + && profile + .active_permission_profile + .as_ref() + .is_some_and(|active| !active.id.starts_with(':')) && (!profile.matches_config(&config) || config.permissions.profile_workspace_roots() != self.config.permissions.profile_workspace_roots()) { - self.restore_agents_overview_prompt(prompt); - return self.add_agents_overview_error( + self.add_agents_overview_error( "Permission profile has different settings.".to_string(), ); + return None; } - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); + // New sessions use the destination settings plus explicit user choices, not + // a permission snapshot inherited when attaching to another task. + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly); let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(), crate::app_server_session::ThreadParamsMode::Remote => remote_cwd @@ -805,10 +829,10 @@ impl App { } Ok(None) => {} Err(error) => { - self.restore_agents_overview_prompt(prompt); - return self.add_agents_overview_error(format!( - "Failed to load background task settings: {error}" + self.add_agents_overview_error(format!( + "Failed to load new session settings: {error}" )); + return None; } } apply_managed_new_thread_defaults( @@ -831,130 +855,7 @@ impl App { .or_else(|| self.model_catalog.models.first()) .map(|model| model.model.clone()); } - let model = config.model.as_deref().or_else(|| { - self.model_catalog - .models - .iter() - .find(|model| model.is_default) - .or_else(|| self.model_catalog.models.first()) - .map(|model| model.model.as_str()) - }); - if !prompt.local_images.is_empty() - && let Some(model) = model - && self.model_catalog.models.iter().any(|preset| { - preset.model == model && !preset.input_modalities.contains(&InputModality::Image) - }) - { - let message = format!( - "Model {model} does not support image inputs. Remove images or switch models." - ); - self.restore_agents_overview_prompt(prompt); - self.add_agents_overview_error(message); - return; - } - let images = prompt - .local_images - .iter() - .map(|image| { - Ok(CoreUserInput::LocalImage { - path: std::path::absolute(&image.path)?, - detail: None, - }) - }) - .collect::>>(); - let images = match images { - Ok(images) => images, - Err(error) => { - self.restore_agents_overview_prompt(prompt); - self.add_agents_overview_error(format!("Failed to prepare image: {error}")); - return; - } - }; - let images = if app_server.uses_remote_workspace() && !images.is_empty() { - match tokio::task::spawn_blocking(move || { - let mut images = images; - for image in &mut images { - snapshot_local_user_input(image)?; - } - Ok::<_, std::io::Error>(images) - }) - .await - .map_err(std::io::Error::other) - .and_then(|result| result) - { - Ok(images) => images, - Err(error) => { - self.restore_agents_overview_prompt(prompt); - self.add_agents_overview_error(format!("Failed to prepare image: {error}")); - return; - } - } - } else { - images - }; - match app_server - .start_thread_with_session_start_source( - &self.local_settings, - &config, - /*session_start_source*/ None, - remote_cwd.as_deref(), - /*selected_profile*/ None, - ) - .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, - images.into_iter().map(Into::into).collect(), - ) - .await; - } - Err(error) => { - self.restore_agents_overview_prompt(prompt); - self.add_agents_overview_error(format!("Failed to start background task: {error}")); - } - } - } - - pub(super) async fn submit_agents_overview_prompt( - &mut self, - app_server: &AppServerSession, - thread_id: ThreadId, - prompt: UserMessage, - mut input: Vec, - ) { - if !prompt.text.is_empty() { - input.push(UserInput::Text { - text: prompt.text.clone(), - text_elements: prompt - .text_elements - .iter() - .cloned() - .map(Into::into) - .collect(), - }); - } - 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, - ..Default::default() - }, - }) - .await; - if let Err(error) = result { - self.restore_agents_overview_prompt(prompt); - self.add_agents_overview_error(format!("Failed to send task message: {error}")); - } + Some((config, remote_cwd)) } pub(super) async fn stop_agents_overview_thread( diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 7af12e85cfd3..dd9cbd77b34f 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -3,7 +3,6 @@ //! Removing the current root leaves an unattached dashboard, even when it is empty. use super::App; -use super::agents_overview_view::AgentsOverviewFocus; use crate::app_event::AgentsOverviewAction; use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; @@ -284,7 +283,7 @@ impl App { /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.open_agents_overview(app_server, AgentsOverviewFocus::List); + self.open_agents_overview(app_server); } else { self.repaint_agents_overview(); if attempted { diff --git a/codex-rs/tui/src/app/agents_overview_actions_tests.rs b/codex-rs/tui/src/app/agents_overview_actions_tests.rs index 79fc6d4e43b1..94f53e63ecbf 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -70,11 +70,6 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { active_flags: Vec::new(), }, ] { - app.agents_overview - .view_state - .lock() - .unwrap() - .focus_composer(); let target = ThreadId::new(); let mut view = app.agents_overview_view( vec![ @@ -89,7 +84,7 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { Some(target), ); view.handle_key_event(KeyCode::Esc.into()); - view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); for character in "Target".chars() { view.handle_key_event(KeyCode::Char(character).into()); } @@ -133,7 +128,7 @@ async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resum app.chat_widget.show_bottom_pane_view(Box::new(view)); app.chat_widget.handle_key_event(KeyCode::Esc.into()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + .handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let hide = std::iter::from_fn(|| rx.try_recv().ok()) .find(|event| matches!(event, AppEvent::HideAgentsOverviewThread { .. })) .expect("shortcut requests hiding the task"); @@ -236,9 +231,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb (AgentsOverviewAction::Delete, "delete_task", true), ] { let key = match action { - AgentsOverviewAction::Archive => { - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL) - } + AgentsOverviewAction::Archive => KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), AgentsOverviewAction::Delete => KeyCode::Delete.into(), }; let (mut app, mut rx, _op_rx) = @@ -357,7 +350,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb }; let mut tui = crate::tui::test_support::make_test_tui()?; tui.pause_events(); - app.open_agents_overview(&app_server, AgentsOverviewFocus::List); + app.open_agents_overview(&app_server); if action == AgentsOverviewAction::Archive { let rollout = app_server .thread_read(id, /*include_turns*/ false) @@ -402,7 +395,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb .await?; app.enqueue_primary_thread_session(resumed.session, resumed.turns) .await?; - app.open_agents_overview(&app_server, AgentsOverviewFocus::List); + app.open_agents_overview(&app_server); } let background = ThreadId::from_string( &app_test_support::create_fake_rollout( @@ -586,20 +579,28 @@ async fn disabled_footer_shortcuts_stay_bold_when_wrapped() { let mut buffer = ratatui::buffer::Buffer::empty(area); view.render(area, &mut buffer); let delete_key = crate::key_hint::plain(KeyCode::Delete).display_label(); - for key in ["ctrl+x", "ctrl+w", "ctrl+e", delete_key.as_str()] { + for (key, label) in [ + ("x", "x stop"), + ("h", "h hide"), + ("a", "a archive"), + (delete_key.as_str(), delete_key.as_str()), + ] { let cells = buffer .content() - .windows(key.len()) + .windows(label.len()) .find(|cells| { cells .iter() .map(ratatui::buffer::Cell::symbol) .collect::() - == key + == label }) .expect("footer shortcut"); assert_eq!( - cells.iter().map(|cell| cell.modifier).collect::>(), + cells[..key.len()] + .iter() + .map(|cell| cell.modifier) + .collect::>(), vec![ratatui::style::Modifier::BOLD | ratatui::style::Modifier::DIM; key.len()] ); } diff --git a/codex-rs/tui/src/app/agents_overview_composer.rs b/codex-rs/tui/src/app/agents_overview_composer.rs deleted file mode 100644 index 394390403b58..000000000000 --- a/codex-rs/tui/src/app/agents_overview_composer.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Retained composer setup and failed-dispatch recovery for the agent dashboard. -//! Refreshes rebind runtime settings without replacing drafts or editor state. - -use super::*; -use crate::bottom_pane::ChatComposer; -use crate::bottom_pane::ChatComposerConfig; -use crate::chatwidget::UserMessage; - -impl App { - pub(super) fn sync_agents_overview_composer(&self) { - let mut state = self - .agents_overview - .view_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let composer = state.composer.get_or_insert_with(|| { - let mut composer = ChatComposer::new_with_config( - /*has_input_focus*/ true, - self.app_event_tx.clone(), - self.enhanced_keys_supported, - "Describe a new task".to_string(), - self.config.disable_paste_burst, - ChatComposerConfig { - trim_submission: false, - image_paste_enabled: true, - ..ChatComposerConfig::plain_text() - }, - ); - // A new task has no context usage to report yet. - composer.set_context_window_pending(/*pending*/ true); - composer.set_footer_hint_override(Some(Vec::new())); - composer - }); - composer.set_app_event_sender(self.app_event_tx.clone()); - composer.set_disable_paste_burst(self.config.disable_paste_burst); - let vim_enabled = self.chat_widget.composer_is_vim_enabled(); - if composer.is_vim_enabled() != vim_enabled { - composer.set_vim_enabled(vim_enabled); - composer.resume_text_entry(); - } - composer.set_keymap_bindings(&self.keymap); - } - - pub(super) fn restore_agents_overview_prompt(&mut self, prompt: UserMessage) { - if let Ok(mut state) = self.agents_overview.view_state.lock() - && let Some(composer) = state.composer.as_mut() - && composer.is_empty() - && !composer.is_in_paste_burst() - && !composer.popup_active() - { - composer.set_text_content( - prompt.text, - prompt.text_elements, - prompt - .local_images - .into_iter() - .map(|image| image.path) - .collect(), - ); - composer.move_cursor_to_end(); - return; - } - let hint = (!prompt.local_images.is_empty()).then(|| { - format!( - "Reattach images from: {}", - prompt - .local_images - .iter() - .map(|image| image.path.display().to_string()) - .collect::>() - .join(", ") - ) - }); - let mut text = prompt.text.clone(); - if let Some(hint) = &hint { - text.push_str("\n\n"); - text.push_str(hint); - } - self.agents_overview.unsent_prompt = Some(text); - self.chat_widget - .add_info_message(format!("Unsent task: {}", prompt.text), hint); - } -} diff --git a/codex-rs/tui/src/app/agents_overview_errors.rs b/codex-rs/tui/src/app/agents_overview_errors.rs index eccdb08c67a7..5dc356b8d10c 100644 --- a/codex-rs/tui/src/app/agents_overview_errors.rs +++ b/codex-rs/tui/src/app/agents_overview_errors.rs @@ -1,5 +1,4 @@ -//! Reports command-center action failures above the retained dashboard, with access -//! to unsent drafts when restoring them would overwrite newer input. +//! Reports command-center action failures above the retained dashboard. use super::*; use crate::wrapping::word_wrap_lines; @@ -23,30 +22,16 @@ impl Renderable for AgentsOverviewErrorHeader { impl App { pub(in crate::app) fn add_agents_overview_error(&mut self, message: String) { - let unsent_prompt = self.agents_overview.unsent_prompt.take(); if self .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .is_some() { - let mut items = vec![SelectionItem { + let items = vec![SelectionItem { name: "Return to command center".to_string(), dismiss_on_select: true, ..Default::default() }]; - if let Some(text) = unsent_prompt { - items.push(SelectionItem { - name: "View unsent task".to_string(), - description: Some( - "Your newer draft has been kept in the composer.".to_string(), - ), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::ViewAgentsOverviewUnsentPrompt(text.clone())); - })], - dismiss_on_select: false, - ..Default::default() - }); - } self.chat_widget.show_selection_view(SelectionViewParams { header: Box::new(AgentsOverviewErrorHeader(vec![ Line::from("Unable to complete action".bold()), diff --git a/codex-rs/tui/src/app/agents_overview_input.rs b/codex-rs/tui/src/app/agents_overview_input.rs index 680307c13129..a2f6b10e7b13 100644 --- a/codex-rs/tui/src/app/agents_overview_input.rs +++ b/codex-rs/tui/src/app/agents_overview_input.rs @@ -1,90 +1,12 @@ -//! Focus and input routing for the agent dashboard. Refreshes retain the shared -//! composer; Right opens the selected task from an empty editor with no pending input. +//! Layout for the agent list and its optional search or rename field. use super::*; -use crate::bottom_pane::InputResult; -use crate::chatwidget::UserMessage; -use crate::clipboard_paste::paste_image_to_temp_png; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; impl AgentsOverviewView { - pub(super) fn handle_composer_key(&mut self, key: KeyEvent) { - let mut state = self.state(); - let offline = state.connection_notice.is_some(); - let grouping = state.grouping; - if !offline - && crate::key_hint::plain(KeyCode::Right).is_press(key) - && state - .composer - .as_ref() - .is_some_and(ChatComposer::can_leave_empty_prompt_with_right) - { - drop(state); - self.activate(); - return; - } - if key.code == KeyCode::Esc && !state.composer_owns_escape() { - state.focus = AgentsOverviewFocus::List; - return; - } - let Some(composer) = state.composer.as_mut() else { - return; - }; - if key.kind == KeyEventKind::Press - && matches!(key.code, KeyCode::Char('v' | 'V')) - && key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - { - match paste_image_to_temp_png() { - Ok((path, _)) => composer.attach_image(path), - Err(error) => self - .app_event_tx - .send(AppEvent::AgentsOverviewError(format!( - "Failed to paste image: {error}" - ))), - } - return; - } - if offline - && !composer.popup_active() - && (self.composer_keymap.submit.is_pressed(key) - || self.composer_keymap.queue.is_pressed(key)) - { - if key.code == KeyCode::Enter { - composer.handle_paste_enter(std::time::Instant::now()); - } - return; - } - let (result, _) = composer.handle_key_event(key); - let prompt = if let InputResult::Submitted { - text, - text_elements, - } = result - { - Some(UserMessage { - text, - text_elements, - local_images: composer.take_recent_submission_images_with_placeholders(), - remote_image_urls: Vec::new(), - mention_bindings: Vec::new(), - }) - } else { - None - }; - drop(state); - if let Some(prompt) = prompt { - self.app_event_tx - .send(AppEvent::DispatchAgentsOverviewTask { - prompt, - cwd: (grouping == AgentsOverviewGrouping::Project) - .then(|| self.selected_row().map(|row| row.thread.cwd.clone())) - .flatten(), - }); - } - } - pub(super) fn layout_areas(&self, area: Rect) -> [Rect; 7] { + pub(super) fn layout_areas(&self, area: Rect) -> [Rect; 6] { + let metadata_height = u16::from(self.state().editing_metadata()); + let footer_height = (self.footer_lines(area.width.saturating_sub(4)).len() as u16) + .min(area.height.saturating_sub(6 + metadata_height)); let header_height = self .state() .server_version_notice @@ -94,63 +16,17 @@ impl AgentsOverviewView { as u16 }) .unwrap_or(1) - .min(area.height.saturating_sub(7).max(1)); - let footer_height = if self.state().composing() { - 0 - } else { - (self.footer_lines(area.width.saturating_sub(4)).len() as u16) - .min(area.height.saturating_sub(7)) - }; - let mut state = self.state(); - let composing = state.composing(); - let mut hints = if state.connection_notice.is_some() && composing { - vec![("esc".to_string(), "tasks · dispatch paused".to_string())] - } else if composing { - self.composer_hints.clone() - } else { - Vec::new() - }; - if let Some((key, _)) = hints.last_mut() - && state.composer_owns_escape() - { - *key = "esc esc".to_string(); - } - if composing - && state.connection_notice.is_none() - && state - .composer - .as_ref() - .is_some_and(ChatComposer::can_leave_empty_prompt_with_right) - && self.rows.get(self.selected).is_some() - { - hints.push(("→".to_string(), "open task".to_string())); - } - if area.width < 60 && hints.len() > 2 { - hints.remove(/*index*/ 1); - } - if composing && let Some(override_hints) = &state.key_chord_hint { - hints = override_hints.clone(); - } - let metadata = state.editing_metadata(); - let input_height = if metadata { - 1 - } else if let Some(composer) = state.composer.as_mut() { - composer.set_footer_hint_override(Some(hints)); - composer - .desired_height(area.width) - .min((area.height / 3).max(/*other*/ 5)) - .min(area.height.saturating_sub(/*rhs*/ 7)) - .max(/*other*/ 3) - } else { - 3 - }; + .min( + area.height + .saturating_sub(footer_height + metadata_height + 3) + .max(1), + ); Layout::vertical([ Constraint::Length(header_height), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), - Constraint::Length(u16::from(!metadata)), - Constraint::Length(input_height), + Constraint::Length(metadata_height), Constraint::Length(footer_height), ]) .areas(area) diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs new file mode 100644 index 000000000000..a840e087e04d --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -0,0 +1,71 @@ +//! Creates empty sessions from the command center without interrupting other agents. + +use super::*; + +impl App { + pub(in crate::app) async fn new_agents_overview_session( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + cwd: Option, + ) -> Result { + if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() { + return Ok(AppRunControl::Continue); + } + let Some((config, remote_cwd)) = self + .agents_overview_session_config(tui, app_server, cwd) + .await + else { + return Ok(AppRunControl::Continue); + }; + let selected_profile = self.chat_widget.thread_id().and_then(|thread_id| { + let source = self.chat_widget.config_ref(); + let active = source.permissions.active_permission_profile()?; + (self + .agents_overview + .selected_permission_profiles + .get(&thread_id) + == Some(&active.id)) + .then(|| PermissionProfileSelection { + profile_id: active.id.clone(), + approval_policy: Some(source.permissions.approval_policy.value().into()), + approvals_reviewer: Some(source.approvals_reviewer), + display_label: active.id, + }) + }); + let started = match app_server + .start_thread_with_session_start_source( + &crate::local_settings::LocalSettings::from(&config), + &config, + /*session_start_source*/ None, + remote_cwd.as_deref(), + selected_profile.as_ref(), + ) + .await + { + Ok(started) => started, + Err(error) => { + self.add_agents_overview_error(format!("Failed to start session: {error}")); + return Ok(AppRunControl::Continue); + } + }; + let thread_id = started.session.thread_id; + if let Some(selected) = selected_profile { + self.agents_overview + .selected_permission_profiles + .insert(thread_id, selected.profile_id); + } + self.agents_overview + .blank_sessions + .insert(thread_id, started.clone()); + // Preserve running agents and unsent input without sending an initial turn. + let control = self + .attach_agents_overview_thread(tui, app_server, thread_id, Some((config, started))) + .await?; + if self.current_displayed_thread_id() != Some(thread_id) { + self.agents_overview.blank_sessions.remove(&thread_id); + let _ = app_server.thread_unsubscribe(thread_id).await; + } + Ok(control) + } +} diff --git a/codex-rs/tui/src/app/agents_overview_render.rs b/codex-rs/tui/src/app/agents_overview_render.rs index 311783cb0fc5..94f4c5ddc714 100644 --- a/codex-rs/tui/src/app/agents_overview_render.rs +++ b/codex-rs/tui/src/app/agents_overview_render.rs @@ -1,25 +1,67 @@ //! Layout rendering and cursor placement for the agent dashboard. -//! The prompt and its cursor reserve the same height for wrapped footer hints. +//! Only search and rename reserve an editor row; list actions share a wrapped footer. use super::*; -use crossterm::cursor::SetCursorStyle; impl AgentsOverviewView { pub(super) fn footer_lines(&self, width: u16) -> Vec> { - if self.state().connection_notice.is_some() { - return vec![ - "ctrl+c clear input, then quit · actions paused until the list is refreshed" - .dim() - .into(), - ]; + let state = self.state(); + let message = if let Some(items) = &state.key_chord_hint { + Some( + items + .iter() + .map(|(key, label)| format!("{key} {label}")) + .collect::>() + .join(" "), + ) + } else if state.editing_metadata() && state.connection_notice.is_some() { + Some("esc cancel · actions paused until reconnected".into()) + } else if state.editing_metadata() { + Some(format!( + "{} {} esc cancel", + self.keymap + .primary_hint(ListAction::Accept) + .map(crate::key_hint::ShortcutHint::display_label) + .unwrap_or_default(), + if state.renaming { "rename" } else { "open" } + )) + } else if state.connection_notice.is_some() { + Some("ctrl+c quit · actions paused until the list is refreshed".into()) + } else { + None + }; + drop(state); + if let Some(message) = message { + return textwrap::wrap(&message, usize::from(width.max(1))) + .into_iter() + .map(|line| line.into_owned().dim().into()) + .collect(); + } + if width < 24 { + let hints = [ + ("new_task", &self.agents_keymap.new_task, "new"), + ("search", &self.agents_keymap.search, "search"), + ] + .into_iter() + .filter_map(|(action, bindings, label)| { + self.agents_keymap + .primary_hint(action, bindings) + .map(|hint| format!("{} {label}", hint.display_label())) + }); + return hints + .chain(["ctrl+c quit".into()]) + .flat_map(|hint| { + textwrap::wrap(&hint, usize::from(width.max(1))) + .into_iter() + .map(|line| line.into_owned().dim().into()) + .collect::>() + }) + .collect(); } let list_hint = |action| { self.keymap.primary_hint(action).filter(|hint| { !matches!(hint, ShortcutHint::Single(binding) - if is_plain_text_key_event(KeyEvent::new( - binding.parts().0, - binding.parts().1, - )) || [ + if [ &self.agents_keymap.resume, &self.agents_keymap.search, &self.agents_keymap.new_task, @@ -75,15 +117,12 @@ impl AgentsOverviewView { "resume", true, ); - let open_hint = (!self.state().editing_metadata()) - .then(|| list_hint(ListAction::MoveRight)) - .flatten() - .or_else(|| list_hint(ListAction::Accept)); + let open_hint = list_hint(ListAction::Accept); add_hint(open_hint, "open", true); add_hint( self.agents_keymap .primary_hint("new_task", &self.agents_keymap.new_task), - "new task", + "new", true, ); add_hint( @@ -126,7 +165,10 @@ impl AgentsOverviewView { self.selected_row().is_some(), ); } - add_hint(list_hint(ListAction::Cancel), "back", true); + if self.state().editing_metadata() { + add_hint(list_hint(ListAction::Cancel), "cancel", true); + } + hints.push(vec!["ctrl+c".bold(), " quit".dim()].into()); let separator = if hints.iter().map(Line::width).sum::() + hints.len().saturating_sub(1) * 2 <= usize::from(width) @@ -157,26 +199,12 @@ impl Renderable for AgentsOverviewView { 24 } - fn cursor_style(&self, area: Rect) -> SetCursorStyle { - let state = self.state(); - if state.composing() - && let Some(composer) = &state.composer - { - composer.cursor_style(area) - } else { - SetCursorStyle::DefaultUserShape - } - } - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if area.width < 12 || area.height < 8 { return None; } - let [_, _, _, _, _, prompt, _] = self.layout_areas(area); + let [_, _, _, _, prompt, _] = self.layout_areas(area); let state = self.state(); - if state.composing() { - return state.composer.as_ref()?.cursor_pos(prompt); - } if !state.editing_metadata() { return None; } @@ -197,7 +225,7 @@ impl Renderable for AgentsOverviewView { return; } Clear.render(area, buf); - let [header, summary, divider, body, title, prompt, footer] = self.layout_areas(area); + let [header, summary, divider, body, prompt, footer] = self.layout_areas(area); let inset = |rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0)); if let Some(notice) = &self.state().server_version_notice { @@ -276,14 +304,6 @@ impl Renderable for AgentsOverviewView { if state.editing_metadata() { Line::from(vec![label.cyan().bold(), input[visible_start..].into()]) .render(inset(prompt), buf); - } else { - Line::from("New task".dim()).render(inset(title), buf); - if let Some(composer) = &state.composer { - composer.render(prompt, buf); - } - } - if state.composing() { - return; } drop(state); Paragraph::new(self.footer_lines(inset(footer).width)).render(inset(footer), buf); diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 30018a0db3a2..b958e6dc528f 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -83,7 +83,7 @@ async fn older_server_notice_falls_back_in_short_overview() { app.update_server_version_overview_notice("0.153.0", Some("0.152.1")); let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); let area = ratatui::layout::Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 8, + /*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 8, ); let mut buffer = ratatui::buffer::Buffer::empty(area); view.render(area, &mut buffer); @@ -95,11 +95,9 @@ async fn older_server_notice_falls_back_in_short_overview() { .collect::(); insta::assert_snapshot!(header.trim_end(), @" Old srv"); } -use crate::app::agents_overview_view::AgentsOverviewFocus; use crate::app::test_support::make_test_app; use crate::app_event::AgentsOverviewThreadRefresh; use crate::bottom_pane::BottomPaneView; -use crate::bottom_pane::CancellationEvent; use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::render::renderable::Renderable; use crate::test_support::PathBufExt; @@ -108,6 +106,7 @@ use crate::test_support::test_path_display; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::CurrentTimeReadParams; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; +use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadActiveFlag; @@ -128,7 +127,6 @@ use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SubAgentSource; -use crossterm::cursor::SetCursorStyle; use pretty_assertions::assert_eq; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -137,72 +135,6 @@ use ratatui::layout::Rect; static OVERVIEW_TIMESTAMP: std::sync::LazyLock = std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120); -fn overview_draft(app: &App) -> (String, usize) { - let state = app.agents_overview.view_state.lock().unwrap(); - let composer = state.composer.as_ref().unwrap(); - (composer.current_text_with_pending(), composer.cursor()) -} - -#[tokio::test] -async fn overview_escape_returns_from_list_to_composer() { - for (vim, offline) in [(false, false), (true, false), (false, true), (true, true)] { - let mut app = make_test_app().await; - app.config.disable_paste_burst = true; - if vim { - app.chat_widget.toggle_vim_mode_and_notify(); - } - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - app.app_event_tx = AppEventSender::new(tx); - let view = app.agents_overview_view( - vec![overview_thread( - ThreadId::new(), - /*parent_thread_id*/ None, - "Existing task", - ThreadStatus::Idle, - )], - /*selected_thread_id*/ None, - ); - app.chat_widget.show_bottom_pane_view(Box::new(view)); - app.chat_widget.handle_paste("task draft".into()); - app.chat_widget.handle_key_event(KeyCode::Home.into()); - let draft = overview_draft(&app); - if offline { - app.agents_overview - .view_state - .lock() - .unwrap() - .connection_notice = Some("Reconnecting"); - } - if vim { - app.chat_widget.handle_key_event(KeyCode::Esc.into()); - } - app.chat_widget.handle_key_event(KeyCode::Esc.into()); - assert!(matches!( - app.agents_overview.view_state.lock().unwrap().focus, - AgentsOverviewFocus::List - )); - app.chat_widget.handle_key_event(KeyCode::Down.into()); - app.chat_widget.handle_key_event(KeyCode::Esc.into()); - - assert!(matches!( - app.agents_overview.view_state.lock().unwrap().focus, - AgentsOverviewFocus::Composer - )); - assert_eq!( - app.chat_widget - .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), - Some(0) - ); - assert_eq!(overview_draft(&app), draft); - assert!(rx.try_recv().is_err()); - if !vim && !offline { - insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" enter create task ctrl+j newline esc tasks"); - } - app.chat_widget.handle_key_event(KeyCode::Char('!').into()); - assert_eq!(overview_draft(&app).0, "!task draft"); - } -} - #[tokio::test] async fn overview_right_opens_current_or_highlighted_task() { let mut app = make_test_app().await; @@ -271,12 +203,8 @@ async fn overview_right_preserves_editors_and_offline_state() { )], Some(thread_id), ); - view.handle_paste("ab".into()); - view.handle_key_event(KeyCode::Home.into()); - view.handle_key_event(KeyCode::Right.into()); - assert_eq!(overview_draft(&app), ("ab".into(), 1)); view.handle_key_event(KeyCode::Esc.into()); - view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); view.handle_key_event(KeyCode::Right.into()); assert!(app.agents_overview.view_state.lock().unwrap().renaming); view.handle_key_event(KeyCode::Esc.into()); @@ -286,284 +214,13 @@ async fn overview_right_preserves_editors_and_offline_state() { .unwrap() .connection_notice = Some("Offline"); view.handle_key_event(KeyCode::Right.into()); - view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); view.on_ctrl_c(); view.handle_key_event(KeyCode::Right.into()); assert!(!view.is_complete()); assert!(rx.try_recv().is_err()); } -#[tokio::test] -async fn overview_open_preserves_draft_and_uses_requested_focus() -> Result<()> { - for vim in [false, true] { - let mut app = make_test_app().await; - app.config.disable_paste_burst = true; - if vim { - app.chat_widget.toggle_vim_mode_and_notify(); - } - let server = crate::start_embedded_app_server_for_picker(&app.config).await?; - app.app_server_target = AppServerTarget::LocalDaemon { - endpoint: crate::RemoteAppServerEndpoint::UnixSocket { - socket_path: test_path_buf("/tmp/unused.sock").abs(), - }, - }; - for (id, title) in [(1, "First task"), (2, "Second task")] { - let id = ThreadId::from_u128(id); - app.agents_overview.threads.insert( - id, - Some(overview_thread( - id, - /*parent_thread_id*/ None, - title, - ThreadStatus::Idle, - )), - ); - } - app.open_agents_overview(&server, AgentsOverviewFocus::Composer); - assert!(matches!( - app.agents_overview.view_state.lock().unwrap().focus, - AgentsOverviewFocus::Composer - )); - app.chat_widget.handle_paste("Unsent draft".into()); - let draft = overview_draft(&app); - - app.open_agents_overview(&server, AgentsOverviewFocus::List); - app.repaint_agents_overview(); - let down = if vim { - KeyCode::Char('j') - } else { - KeyCode::Down - }; - app.chat_widget.handle_key_event(down.into()); - assert_eq!( - app.chat_widget - .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), - Some(1) - ); - assert_eq!(overview_draft(&app), draft); - if !vim { - let project = test_path_display("/tmp/project"); - let group = format!( - "/tmp/project 2{}", - " ".repeat(project.len().saturating_sub("/tmp/project".len())) - ); - insta::assert_snapshot!( - "overview_reopened_with_list_focus", - render_bottom_popup(&app.chat_widget, /*width*/ 96) - .replace(&format!("{project} 2"), &group) - .replace(&project, "/tmp/project") - .replace("fwd del", "del") - ); - } - - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); - app.repaint_agents_overview(); - app.chat_widget.handle_key_event(KeyCode::Left.into()); - assert_eq!(overview_draft(&app), (draft.0, draft.1 - 1)); - - app.agents_overview.threads.clear(); - app.open_agents_overview(&server, AgentsOverviewFocus::List); - assert!(matches!( - app.agents_overview.view_state.lock().unwrap().focus, - AgentsOverviewFocus::List - )); - app.agents_overview.initialized = true; - let pending = ThreadId::new(); - app.agents_overview.threads.insert(pending, None); - app.repaint_agents_overview(); - assert!(matches!( - app.agents_overview.view_state.lock().unwrap().focus, - AgentsOverviewFocus::List - )); - app.agents_overview.threads.clear(); - app.repaint_agents_overview(); - app.chat_widget.handle_key_event(KeyCode::Char('x').into()); - assert_eq!(overview_draft(&app), ("Unsent drafxt".into(), 12)); - server.shutdown().await?; - } - Ok(()) -} - -#[tokio::test] -async fn overview_composer_preserves_editing_and_routes_focus() { - let mut app = make_test_app().await; - app.config.disable_paste_burst = true; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - app.app_event_tx = AppEventSender::new(tx); - let thread_id = ThreadId::new(); - let threads = vec![overview_thread( - thread_id, - /*parent_thread_id*/ None, - "Existing task", - ThreadStatus::Idle, - )]; - let mut view = app.agents_overview_view(threads.clone(), Some(thread_id)); - view.handle_paste(" /first @file $skill\nthird \t".into()); - view.handle_key_event(KeyCode::Home.into()); - view.handle_paste("!second\n".into()); - let expected = overview_draft(&app); - assert_eq!( - expected, - (" /first @file $skill\n!second\nthird \t".into(), 32) - ); - view = app.agents_overview_view(threads.clone(), Some(thread_id)); - assert_eq!(overview_draft(&app), expected); - view.handle_key_event(KeyCode::Esc.into()); - view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); - view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - view.handle_paste(" renamed".into()); - view.handle_key_event(KeyCode::Enter.into()); - assert!( - matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name == "Existing task renamed") - ); - view = app.agents_overview_view(threads, Some(thread_id)); - view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); - view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - view.handle_key_event(KeyCode::Esc.into()); - view.handle_key_event(KeyCode::Tab.into()); - assert!( - matches!(rx.try_recv(), Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. }) if prompt.text == expected.0) - ); - view.handle_key_event(KeyCode::Up.into()); - assert_eq!(overview_draft(&app).0, expected.0); - assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); - view.handle_paste(" \t\n".into()); - view.handle_key_event(KeyCode::Enter.into()); - assert!(rx.try_recv().is_err()); - assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); - let mut server = crate::start_embedded_app_server_for_picker(&app.config) - .await - .unwrap(); - app.cli_kv_overrides = vec![("model".into(), toml::Value::Integer(1))]; - app.dispatch_agents_overview_task( - &mut crate::tui::test_support::make_test_tui().expect("test tui"), - &mut server, - "retry me".into(), - Some(app.config.cwd.clone()), - ) - .await; - view.handle_paste(" later".into()); - app.submit_agents_overview_prompt(&server, thread_id, "older failure".into(), Vec::new()) - .await; - assert_eq!(overview_draft(&app).0, "retry me later"); - server.shutdown().await.unwrap(); - while rx.try_recv().is_ok() {} - view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - view.handle_paste(format!( - "x{}", - " ".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS) - )); - view.handle_key_event(KeyCode::Enter.into()); - assert!(matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)))); - assert!(view.next_frame_delay().is_some()); - app.chat_widget.show_bottom_pane_view(Box::new(view)); - insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" Message too long; limit 1048576 characters"); -} - -#[tokio::test] -async fn overview_composer_preserves_pastes_and_editor_bindings() { - let mut app = make_test_app().await; - app.config.disable_paste_burst = true; - app.chat_widget.toggle_vim_mode_and_notify(); - let mut keymap = TuiKeymap::default(); - keymap.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string()))); - keymap.editor.insert_newline = Some(KeybindingsSpec::One(KeybindingSpec("f7".to_string()))); - app.keymap = RuntimeKeymap::from_config(&keymap).unwrap(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - app.app_event_tx = AppEventSender::new(tx); - let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - let pasted = "long paste ".repeat(/*n*/ 200); - view.handle_paste(pasted.clone()); - view.handle_key_event(KeyCode::F(7).into()); - view.handle_paste("last line".into()); - view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 24, - ); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| view.render(frame.area(), frame.buffer_mut())) - .unwrap(); - insta::assert_snapshot!(terminal.backend().to_string().lines().last().unwrap(), @r#"" f8 create task f7 newline esc esc tasks ""#); - assert_eq!(view.cursor_style(area), SetCursorStyle::SteadyBar); - view.handle_key_event(KeyCode::Home.into()); - view.handle_key_event(KeyCode::Esc.into()); - assert_eq!(view.cursor_style(area), SetCursorStyle::DefaultUserShape); - view.handle_key_event(KeyCode::Char('d').into()); - view.handle_key_event(KeyCode::Esc.into()); - assert!(view.cursor_pos(area).is_some()); - view.handle_key_event(KeyCode::Esc.into()); - assert_eq!(view.cursor_pos(area), None); - view.handle_key_event(KeyCode::Char('x').into()); - assert_eq!(view.cursor_pos(area), None); - view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); - assert_eq!(view.cursor_style(area), SetCursorStyle::SteadyBar); - view.handle_key_event(KeyCode::Esc.into()); - view.handle_key_event(KeyCode::Esc.into()); - view.handle_paste("abc".into()); - view.handle_key_event(KeyCode::Char('x').into()); - view.handle_key_event(KeyCode::F(8).into()); - assert!( - matches!(rx.try_recv(), Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. }) if prompt.text == format!("{pasted}\nabcxlast line")) - ); -} - -#[tokio::test] -async fn overview_composer_flushes_pending_typing_after_refresh() { - for disable_paste_burst in [false, true] { - let mut app = make_test_app().await; - app.config.disable_paste_burst = false; - let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - view.handle_key_event(KeyCode::Char('x').into()); - app.config.disable_paste_burst = disable_paste_burst; - if !disable_paste_burst { - app.chat_widget.toggle_vim_mode_and_notify(); - } - view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - assert_eq!(overview_draft(&app).0, "x"); - view.handle_key_event(KeyCode::Esc.into()); - if !disable_paste_burst { - view.handle_key_event(KeyCode::Esc.into()); - } - assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); - view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); - view.handle_key_event(KeyCode::Up.into()); - assert_eq!(overview_draft(&app).0, "x"); - } -} - -#[tokio::test] -async fn offline_overview_preserves_unbracketed_paste_newlines() { - let mut app = make_test_app().await; - app.config.disable_paste_burst = false; - let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - app.agents_overview - .view_state - .lock() - .unwrap() - .connection_notice = Some("Reconnecting"); - for ch in "a\nbc\nd".chars() { - view.handle_key_event( - if ch == '\n' { - KeyCode::Enter - } else { - KeyCode::Char(ch) - } - .into(), - ); - } - view.handle_key_event(KeyCode::Tab.into()); - assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); - view.handle_key_event(KeyCode::Up.into()); - view.handle_key_event(KeyCode::Enter.into()); - assert_eq!(overview_draft(&app).0, "a\nbc\nd"); - view.handle_key_event(KeyCode::Esc.into()); - app.chat_widget.show_bottom_pane_view(Box::new(view)); - insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" ctrl+c clear input, then quit · actions paused until the list is refreshed"); -} - fn overview_thread( thread_id: ThreadId, parent_thread_id: Option, @@ -842,7 +499,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res let created = app_server.start_thread(&config).await?.session.thread_id; // Closing the view must not cancel a metadata refresh or forget unloaded entries. app.primary_thread_id = Some(ids[0]); - app.open_agents_overview(&app_server, AgentsOverviewFocus::List); + app.open_agents_overview(&app_server); let visible: HashSet<_> = app .agents_overview .visible_thread_ids @@ -882,7 +539,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res .await?; app.enqueue_primary_thread_session(resumed.session, resumed.turns) .await?; - app.open_agents_overview(&app_server, AgentsOverviewFocus::List); + app.open_agents_overview(&app_server); finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; expected.insert(ids[1]); let visible: HashSet<_> = app @@ -1257,8 +914,7 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm .insert(thread_id, Some(thread.clone())); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 8)); - let mut view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); - view.handle_paste("Keep this draft".into()); + let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); app.agents_overview.visible_thread_ids = view.thread_ids(); app.chat_widget.show_bottom_pane_view(Box::new(view)); for delta in ["**Checking", " cold-start regressions**\nFurther reasoning"] { @@ -1430,7 +1086,7 @@ async fn overview_model_grouping_shows_details_and_preserves_selection() { let mut view = app.agents_overview_view(threads, Some(selected)); view.handle_key_event(KeyCode::Esc.into()); for _ in 0..2 { - view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); } assert_eq!( app.agents_overview.view_state.lock().unwrap().grouping, @@ -1529,82 +1185,12 @@ async fn shared_overview_shows_only_root_sessions() { state.lock().unwrap().grouping, AgentsOverviewGrouping::Project ); - action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); - assert_eq!( - state.lock().unwrap().grouping, - AgentsOverviewGrouping::Status - ); - app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - assert_eq!( - state.lock().unwrap().grouping, - AgentsOverviewGrouping::Status - ); - 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::Enter, KeyModifiers::NONE)); - assert!(matches!( - event_rx.try_recv(), - Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None }) - if prompt.text == "Use the current project" - )); - action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); - assert_eq!( - state.lock().unwrap().grouping, - AgentsOverviewGrouping::Model - ); - assert!(action_view.handle_paste("Use the default project".to_string())); + action_view.handle_key_event(KeyCode::Char('f').into()); + assert!(action_view.handle_paste("Repair authentication".into())); action_view.handle_key_event(KeyCode::Enter.into()); - assert!(matches!( - event_rx.try_recv(), - Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None }) - if prompt.text == "Use the default project" - )); - action_view.handle_key_event(KeyCode::Esc.into()); - action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); - assert_eq!( - state.lock().unwrap().grouping, - AgentsOverviewGrouping::Project - ); - 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, + assert!( + matches!(event_rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second_root) ); - 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.text == "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::Esc, KeyModifiers::NONE)); - 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.text.trim() == "Continue working" - )); - action_view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); - action_view.handle_paste("First line\nSecond line".into()); crate::chatwidget::tests::helpers::set_active_cell( &mut app.chat_widget, Box::new(crate::history_cell::PlainHistoryCell::new(vec![ @@ -1699,7 +1285,7 @@ async fn embedded_sessions_offer_to_start_a_background_server_without_migrating( .await .expect("embedded app server"); - app.open_agents_overview(&app_server, AgentsOverviewFocus::List); + app.open_agents_overview(&app_server); insta::with_settings!({snapshot_path => "../snapshots"}, { insta::assert_snapshot!( @@ -1759,6 +1345,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { keymap.agents.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string()))); keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string()))); keymap.agents.resume = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string()))); + keymap.list.cancel = Some(KeybindingsSpec::One(KeybindingSpec("f7".into()))); keymap.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("v".into()))); app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); let first = ThreadId::new(); @@ -1795,7 +1382,6 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)); assert!(event_rx.try_recv().is_err()); - assert!(view.handle_paste("Do not dispatch this draft".to_string())); view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); view.handle_key_event(KeyCode::Esc.into()); for (key, expected) in [('v', second), ('k', first), ('j', first)] { @@ -1803,7 +1389,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { let selected = &view.rows[view.selected_index().unwrap()]; assert_eq!(selected.thread_id, expected); } - assert_eq!(overview_draft(&app).0, "Do not dispatch this draft"); + view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); assert!(view.handle_paste("Second task".to_string())); view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); @@ -1823,6 +1409,19 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second )); assert!(event_rx.try_recv().is_err()); + view.handle_key_event(KeyCode::Esc.into()); + for offline in [false, true] { + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice = offline.then_some("Offline"); + view.handle_key_event(KeyCode::F(6).into()); + view.handle_paste("no matching task".into()); + assert_eq!(view.selected_index(), Some(usize::MAX)); + view.handle_key_event(KeyCode::F(7).into()); + assert_eq!(view.selected_index(), Some(0)); + } } #[tokio::test] @@ -2320,12 +1919,10 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> app.chat_widget.show_bottom_pane_view(Box::new(view)); for key in [ KeyCode::Esc.into(), - KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), - KeyCode::Esc.into(), - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE), + KeyCode::Esc.into(), ] { app.chat_widget.handle_key_event(key); } @@ -2335,7 +1932,7 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID); while event_rx.try_recv().is_ok() {} app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + .handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); assert!(matches!( event_rx.try_recv(), Ok(AppEvent::OpenResumePicker) @@ -2358,7 +1955,6 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), selection ); - assert_eq!(overview_draft(&app).0, "d"); } Ok(()) } @@ -2375,9 +1971,9 @@ async fn command_center_cursor_tracks_wrapped_footer() { )], /*selected_thread_id*/ None, ); - for (key, label) in [('n', "› Describe"), ('f', "Search ›"), ('r', "Rename ›")] { + for (key, label) in [('f', "Search ›"), ('r', "Rename ›")] { view.handle_key_event(KeyCode::Esc.into()); - view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); + view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); for width in [48, 96, 120] { let area = ratatui::layout::Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 24); @@ -2412,7 +2008,7 @@ async fn empty_command_center_can_open_resume_picker() { app.chat_widget.handle_key_event(KeyCode::Esc.into()); while event_rx.try_recv().is_ok() {} app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + .handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); assert!(matches!( event_rx.try_recv(), Ok(AppEvent::OpenResumePicker) @@ -2717,10 +2313,8 @@ async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result< let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); let mut view = app.agents_overview_view(vec![thread], Some(thread_id)); - view.handle_paste("Keep this draft".into()); view.handle_key_event(KeyCode::Esc.into()); app.chat_widget.show_bottom_pane_view(Box::new(view)); - let draft = overview_draft(&app); let selection = app .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID); @@ -2756,14 +2350,13 @@ async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result< app.handle_key_event(&mut tui, &mut server, KeyCode::Esc.into()) .await; - assert_eq!(overview_draft(&app), draft); + assert_eq!( app.chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), selection ); - app.chat_widget.handle_paste(" and this paste".into()); - assert_eq!(overview_draft(&app).0, "Keep this draft and this paste"); + // Opening the displayed task returns to the same frozen snapshot without retrying. Box::pin(app.handle_event( &mut tui, @@ -2837,7 +2430,8 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res let mut app = make_test_app().await; let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - view.handle_paste("Keep typing".into()); + view.handle_key_event(KeyCode::Char('f').into()); + view.handle_paste("Keep searching".into()); app.chat_widget.show_bottom_pane_view(Box::new(view)); let before = render_bottom_popup(&app.chat_widget, /*width*/ 48); for result in [ @@ -2884,59 +2478,14 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res Ok(()) } -#[tokio::test] -async fn command_center_failed_send_keeps_newer_draft_and_exposes_unsent_task() -> Result<()> { - let (mut app, mut rx, _op_rx) = crate::app::tests::make_test_app_with_channels().await; - let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; - let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - view.handle_paste("Newer draft".into()); - app.chat_widget.show_bottom_pane_view(Box::new(view)); - let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); - let draft = overview_draft(&app); - let unsent = "Unsent draft that wraps across multiple lines in a narrow terminal.\nKeep this second line too."; - let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?; - app.submit_agents_overview_prompt(&server, thread_id, unsent.into(), Vec::new()) - .await; - let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); - insta::with_settings!({snapshot_path => "../snapshots"}, { - insta::assert_snapshot!("agents_overview_unsent_task", rendered); - }); - app.chat_widget.handle_key_event(KeyCode::Down.into()); - app.chat_widget.handle_key_event(KeyCode::Enter.into()); - let text = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event { - AppEvent::ViewAgentsOverviewUnsentPrompt(text) => Some(text), - _ => None, - }); - assert_eq!(text, Some(unsent.into())); - let mut tui = crate::tui::test_support::make_test_tui()?; - Box::pin(app.handle_event( - &mut tui, - &mut server, - AppEvent::ViewAgentsOverviewUnsentPrompt(text.unwrap()), - )) - .await?; - let Some(Overlay::Static(mut overlay)) = app.overlay.take() else { - panic!("unsent task opens a static pager"); - }; - overlay.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('q').into()))?; - assert!(overlay.is_done()); - app.chat_widget.handle_key_event(KeyCode::Esc.into()); - assert_eq!(overview_draft(&app), draft); - assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); - server.shutdown().await?; - Ok(()) -} - #[tokio::test] async fn command_center_action_failures_remain_visible() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let mut tui = crate::tui::test_support::make_test_tui()?; let thread_id = ThreadId::new(); - let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - view.handle_paste("Keep this draft".into()); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); - let draft = overview_draft(&app); for (event, expected) in [ ( AppEvent::SelectAgentsOverviewThread { thread_id }, @@ -2953,10 +2502,6 @@ async fn command_center_action_failures_remain_visible() -> Result<()> { AppEvent::StopAgentsOverviewThread { thread_id }, "Failed to stop background task", ), - ( - AppEvent::AgentsOverviewError("Failed to paste image: clipboard unavailable".into()), - "Failed to paste image", - ), ] { Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); @@ -2966,7 +2511,6 @@ async fn command_center_action_failures_remain_visible() -> Result<()> { assert!( render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Agent command center") ); - assert_eq!(overview_draft(&app), draft); } // The retained primary task can also fail in the nested agent-attachment path. app.primary_thread_id = Some(thread_id); @@ -2998,3 +2542,125 @@ fn trust_fixture_folders(app: &mut App) { #[path = "agents_overview_usage_tests.rs"] mod usage; + +#[tokio::test] +async fn command_center_escape_cancels_editors_and_never_closes_list() { + for (vim, offline, empty, running) in [ + (false, false, false, false), + (true, false, false, false), + (false, true, false, false), + (false, false, true, false), + (false, false, false, true), + ] { + let (mut app, mut rx, mut op_rx) = crate::app::tests::make_test_app_with_channels().await; + if vim { + app.chat_widget.toggle_vim_mode_and_notify(); + } + if running { + app.chat_widget.handle_server_notification( + ServerNotification::TurnStarted( + codex_app_server_protocol::TurnStartedNotification { + thread_id: ThreadId::new().to_string(), + turn: codex_app_server_protocol::Turn { + id: "running".into(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + ), + /*replay_kind*/ None, + ); + assert!(app.chat_widget.is_task_running_for_test()); + } + while rx.try_recv().is_ok() {} + let rows = if empty { + Vec::new() + } else { + vec![overview_thread( + ThreadId::new(), + /*parent_thread_id*/ None, + "Task", + ThreadStatus::Idle, + )] + }; + let mut view = app.agents_overview_view(rows, /*selected_thread_id*/ None); + if offline { + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice = Some("Offline"); + } + view.handle_key_event(KeyCode::Char('f').into()); + view.handle_paste("nwogrxfha".into()); + assert!(rx.try_recv().is_err()); + view.handle_key_event(KeyCode::Esc.into()); + if !empty { + assert_eq!(view.selected_index(), Some(0)); + } + assert_eq!( + view.on_ctrl_c(), + crate::bottom_pane::CancellationEvent::NotHandled + ); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + for _ in 0..3 { + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + } + assert!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + ); + assert!(rx.try_recv().is_err()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(op_rx.try_recv().is_err()); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::Exit(crate::app_event::ExitMode::ShutdownFirst)) + )); + } +} + +#[tokio::test] +async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone() { + let mut app = make_test_app().await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let id = ThreadId::new(); + let mut target = overview_thread( + id, + /*parent_thread_id*/ None, + "Task", + ThreadStatus::Idle, + ); + target.cwd = test_path_buf("/tmp/checkout/subdir").abs(); + let mut view = app.agents_overview_view(vec![target.clone()], Some(id)); + for _ in 0..3 { + view.handle_key_event(KeyCode::Char('n').into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewSession { cwd: Some(cwd) }) if cwd == target.cwd) + ); + view.handle_key_event(KeyCode::Char('g').into()); + } + view.handle_key_event(KeyCode::Char('r').into()); + for character in "nwogrxfha".chars() { + view.handle_key_event(KeyCode::Char(character).into()); + } + assert!(rx.try_recv().is_err()); + view.handle_key_event(KeyCode::Enter.into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name.ends_with("nwogrxfha")) + ); + let mut empty = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + empty.handle_key_event(KeyCode::Char('n').into()); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::NewAgentsOverviewSession { cwd: None }) + )); +} diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index c2c4d9496c35..04f1a0a6f555 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -41,6 +41,15 @@ impl App { else { return; }; + if matches!( + notification, + ServerNotification::TurnStarted(_) + | ServerNotification::ThreadClosed(_) + | ServerNotification::ThreadArchived(_) + | ServerNotification::ThreadDeleted(_) + ) { + self.agents_overview.blank_sessions.remove(&thread_id); + } self.track_agents_overview_activity(thread_id, notification); let thread = self .agents_overview @@ -67,6 +76,9 @@ impl App { self.agents_overview.threads.insert(thread_id, Some(thread)); } ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => { + self.agents_overview + .selected_permission_profiles + .remove(&thread_id); self.agents_overview.activity.remove(&thread_id); self.agents_overview.last_messages.remove(&thread_id); self.agents_overview.usage.remove(&thread_id); @@ -101,6 +113,21 @@ impl App { } } ServerNotification::ThreadSettingsUpdated(settings) => { + if !self.pending_server_profiles.contains_key(&thread_id) + && self + .agents_overview + .selected_permission_profiles + .get(&thread_id) + != settings + .thread_settings + .active_permission_profile + .as_ref() + .map(|profile| &profile.id) + { + self.agents_overview + .selected_permission_profiles + .remove(&thread_id); + } if let Some(thread) = thread { thread.cwd.clone_from(&settings.thread_settings.cwd); thread.model = Some(settings.thread_settings.model.clone()); diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 13bbf5cf84d8..1d3f8a2afb4b 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -1,5 +1,5 @@ //! Dashboard for inspecting and managing the TUI's retained daemon tasks. -//! The shared view state retains the new-task editor across metadata refreshes. +//! Search and rename input survive metadata refreshes; root Escape never exits. #[path = "agents_overview_grouping.rs"] mod grouping; @@ -18,7 +18,6 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; -use crate::bottom_pane::ChatComposer; use crate::bottom_pane::ViewCompletion; use crate::key_hint::KeyBindingListExt; use crate::key_hint::ShortcutHint; @@ -133,11 +132,8 @@ impl AgentsOverviewProjectGroup { #[derive(Default)] pub(super) struct AgentsOverviewViewState { - // Search and rename never borrow the new-task draft. pub(super) input: String, - pub(super) composer: Option, pub(super) key_chord_hint: Option>, - pub(super) focus: AgentsOverviewFocus, pub(super) refresh_failed: bool, pub(super) connection_notice: Option<&'static str>, pub(super) server_version_notice: Option, @@ -149,38 +145,10 @@ pub(super) struct AgentsOverviewViewState { pub(super) completion: Option, } -#[derive(Clone, Copy, Default, PartialEq, Eq)] -pub(super) enum AgentsOverviewFocus { - #[default] - Composer, - List, -} - impl AgentsOverviewViewState { - pub(super) fn focus_composer(&mut self) { - self.focus = AgentsOverviewFocus::Composer; - if let Some(composer) = self.composer.as_mut() { - composer.resume_text_entry(); - } - } - - fn editing_metadata(&self) -> bool { + pub(super) fn editing_metadata(&self) -> bool { self.searching || self.renaming } - - fn composing(&self) -> bool { - self.focus == AgentsOverviewFocus::Composer && !self.searching && !self.renaming - } - - fn composer_owns_escape(&self) -> bool { - self.composer.as_ref().is_some_and(|composer| { - composer.popup_active() - || (composer.is_vim_enabled() - && !composer - .keymap_contexts() - .contains(KeymapContext::VimNormal)) - }) - } } pub(super) struct AgentsOverviewView { @@ -192,8 +160,6 @@ pub(super) struct AgentsOverviewView { app_event_tx: AppEventSender, keymap: ListKeymap, agents_keymap: AgentsKeymap, - composer_hints: Vec<(String, String)>, - composer_keymap: crate::keymap::ComposerKeymap, } impl AgentsOverviewView { @@ -210,18 +176,6 @@ impl AgentsOverviewView { .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 composer_hints = [ - (KeymapContext::Composer, "submit", "create task"), - (KeymapContext::Editor, "insert_newline", "newline"), - ] - .into_iter() - .filter_map(|(context, action, label)| { - keymap - .primary_hint(context, action) - .map(|hint| (hint.display_label().replace(" + ", "+"), label.to_string())) - }) - .chain([("esc".to_string(), "tasks".to_string())]) - .collect(); let project_groups = rows .iter() .map(|row| AgentsOverviewProjectGroup::for_thread(&row.thread, worktrees_enabled)) @@ -235,8 +189,6 @@ impl AgentsOverviewView { app_event_tx, keymap: keymap.list, agents_keymap: keymap.agents, - composer_hints, - composer_keymap: keymap.composer, }; view.state().completion = None; let visible = view.visible_indices(); @@ -543,10 +495,6 @@ impl AgentsOverviewView { } impl BottomPaneView for AgentsOverviewView { - fn next_frame_delay(&self) -> Option { - self.state().composer.as_ref()?.footer_flash_delay() - } - fn view_id(&self) -> Option<&'static str> { Some(AGENTS_OVERVIEW_VIEW_ID) } @@ -556,15 +504,7 @@ impl BottomPaneView for AgentsOverviewView { } fn keymap_contexts(&self) -> KeymapContextSet { - let state = self.state(); - if state.composing() { - state - .composer - .as_ref() - .map_or_else(KeymapContextSet::default, ChatComposer::keymap_contexts) - } else { - KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents) - } + KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents) } fn completion(&self) -> Option { @@ -586,13 +526,14 @@ impl BottomPaneView for AgentsOverviewView { state.renaming = false; state.search.clear(); state.input.clear(); - return CancellationEvent::Handled; - } - if let Some(composer) = state.composer.as_mut() - && (composer.cancel_vim_search() - || composer.cancel_history_search() - || composer.clear_for_ctrl_c().is_some()) - { + drop(state); + if self.selected >= self.rows.len() { + self.selected = self + .visible_indices() + .first() + .copied() + .unwrap_or(usize::MAX); + } return CancellationEvent::Handled; } CancellationEvent::NotHandled @@ -604,39 +545,19 @@ impl BottomPaneView for AgentsOverviewView { input.push_str(&crate::history_cell::sanitize_user_text(pasted.into())) }); } - let mut state = self.state(); - if state.focus == AgentsOverviewFocus::List { - state.focus_composer(); - } - state - .composer - .as_mut() - .is_some_and(|composer| composer.handle_paste(pasted)) - } - - fn flush_paste_burst_if_due(&mut self) -> bool { - self.state() - .composer - .as_mut() - .is_some_and(ChatComposer::flush_paste_burst_if_due) - } - - fn is_in_paste_burst(&self) -> bool { - self.state() - .composer - .as_ref() - .is_some_and(ChatComposer::is_in_paste_burst) + false } fn handle_key_event(&mut self, key: KeyEvent) { if key.kind == crossterm::event::KeyEventKind::Release { return; } - if self.state().composing() { - self.handle_composer_key(key); + if key.code == KeyCode::Esc { + self.on_ctrl_c(); return; } if key.code == KeyCode::Backspace + && self.state().editing_metadata() && key.modifiers.is_empty() && self.keymap.action_for(key).is_none() { @@ -647,29 +568,13 @@ impl BottomPaneView for AgentsOverviewView { } if is_plain_text_key_event(key) && let KeyCode::Char(character) = key.code + && self.state().editing_metadata() { - if self.state().editing_metadata() { - self.edit_input(|input| input.push(character)); - return; - } - if !self - .state() - .composer - .as_ref() - .is_some_and(ChatComposer::is_vim_enabled) - { - self.state().focus_composer(); - self.handle_composer_key(key); - return; - } + self.edit_input(|input| input.push(character)); + return; } - if self.agents_keymap.search.is_pressed(key) || { - let state = self.state(); - state.connection_notice.is_some() - && state.searching - && self.keymap.action_for(key) == Some(ListAction::Cancel) - } { + if self.agents_keymap.search.is_pressed(key) { let mut state = self.state(); if !state.renaming { state.searching = !state.searching; @@ -681,7 +586,6 @@ impl BottomPaneView for AgentsOverviewView { } if self.state().connection_notice.is_some() - && !self.agents_keymap.new_task.is_pressed(key) && self.keymap.action_for(key) != Some(ListAction::Cancel) { match self.keymap.action_for(key) { @@ -706,12 +610,9 @@ impl BottomPaneView for AgentsOverviewView { return; } if self.agents_keymap.new_task.is_pressed(key) { - let mut state = self.state(); - state.search.clear(); - state.searching = false; - state.renaming = false; - state.input.clear(); - state.focus_composer(); + self.app_event_tx.send(AppEvent::NewAgentsOverviewSession { + cwd: self.selected_row().map(|row| row.thread.cwd.clone()), + }); return; } if self.agents_keymap.rename.is_pressed(key) { @@ -777,17 +678,7 @@ impl BottomPaneView for AgentsOverviewView { } 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 { - state.focus_composer(); - } + self.on_ctrl_c(); } ListAction::PageUp | ListAction::PageDown => { for _ in 0..5 { diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 7d3f0f25a5ef..47d9066d487e 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -212,6 +212,11 @@ impl App { self.chat_widget.set_approvals_reviewer(reviewer); } self.chat_widget.set_permission_network(network); + if let Some(thread_id) = self.chat_widget.thread_id() { + self.agents_overview + .selected_permission_profiles + .insert(thread_id, profile_id.clone()); + } self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() @@ -290,6 +295,9 @@ impl App { .approvals_reviewer .is_none_or(|reviewer| config.approvals_reviewer == reviewer) { + self.agents_overview + .selected_permission_profiles + .insert(thread_id, selection.profile_id); return; } let params = ThreadSettingsUpdateParams { @@ -301,6 +309,9 @@ impl App { }; match app_server.thread_settings_update(params).await { Ok(true) => { + self.agents_overview + .selected_permission_profiles + .insert(thread_id, selection.profile_id.clone()); self.pending_server_profiles .insert(thread_id, selection.clone()); self.chat_widget.add_info_message( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index e48049de8384..3e71d23e8cdd 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3,7 +3,6 @@ //! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain //! actions are delegated to focused app submodules so the central match remains the routing layer. -use super::agents_overview_view::AgentsOverviewFocus; use super::rate_limit_refresh::RateLimitReadStatus; use super::rate_limit_refresh::RateLimitRefreshOutcome; use super::resize_reflow::trailing_run_start; @@ -36,8 +35,6 @@ impl App { &event, AppEvent::InsertHistoryCell(_) | AppEvent::CommitRealtimeTranscriptHistory - | AppEvent::AgentsOverviewError(_) - | AppEvent::ViewAgentsOverviewUnsentPrompt(_) | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ManagedWorktreeCreated(_) | AppEvent::AppendMessageHistoryEntry { .. } @@ -2428,19 +2425,7 @@ impl App { } } AppEvent::OpenAgentsOverview => { - self.open_agents_overview(app_server, AgentsOverviewFocus::List); - } - AppEvent::AgentsOverviewError(message) => { - self.add_agents_overview_error(message); - } - AppEvent::ViewAgentsOverviewUnsentPrompt(text) => { - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_static_with_lines( - text.lines().map(|line| Line::from(line.to_string())).collect(), - "Unsent task".to_string(), - self.keymap.pager.clone(), - )); - tui.frame_requester().schedule_frame(); + self.open_agents_overview(app_server); } AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { self.apply_agents_overview_thread_refresh(app_server, request_id, result); @@ -2453,15 +2438,14 @@ impl App { AppRunControl::Continue if self.primary_thread_id.is_none() && self.chat_widget.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID).is_none() => { - self.open_agents_overview(app_server, AgentsOverviewFocus::List); + self.open_agents_overview(app_server); } AppRunControl::Continue => {} AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), } } - AppEvent::DispatchAgentsOverviewTask { prompt, cwd } => { - self.dispatch_agents_overview_task(tui, app_server, prompt, cwd) - .await; + AppEvent::NewAgentsOverviewSession { cwd } => { + return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await; } AppEvent::RenameAgentsOverviewThread { thread_id, name } => { match app_server.thread_set_name(thread_id, name.clone()).await { @@ -3253,7 +3237,7 @@ impl App { /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.open_agents_overview(app_server, AgentsOverviewFocus::List); + self.open_agents_overview(app_server); AppRunControl::Continue } Err(err) => { @@ -3313,7 +3297,7 @@ impl App { /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.open_agents_overview(app_server, AgentsOverviewFocus::List); + self.open_agents_overview(app_server); AppRunControl::Continue } Err(err) => { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index c968c87be3b1..59718fcd73c1 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -3,7 +3,6 @@ //! This module owns global key bindings that sit above ChatWidget, including transcript overlay //! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts. -use super::agents_overview_view::AgentsOverviewFocus; use super::*; use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE; use crate::keymap::bindings_for_action; @@ -57,6 +56,18 @@ impl App { ) -> Option { let contexts = self.active_keymap_contexts(); let was_pending = self.key_chord_matcher.is_pending(); + if !was_pending + && contexts.contains(crate::keymap::KeymapContext::Agents) + && self + .agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .editing_metadata() + && crate::key_hint::is_plain_text_key_event(key_event) + { + return Some(key_event); + } match self.key_chord_matcher.advance( key_event, &self.keymap.chords, @@ -256,7 +267,7 @@ impl App { && modifiers == KeyModifiers::NONE && !matches!(self.app_server_target, AppServerTarget::Embedded) { - self.open_agents_overview(app_server, AgentsOverviewFocus::List); + self.open_agents_overview(app_server); return; } let quit = match key_event.code { @@ -537,7 +548,7 @@ impl App { } if self.keymap.app.open_agents.is_pressed(key_event) { - self.open_agents_overview(app_server, AgentsOverviewFocus::List); + self.open_agents_overview(app_server); return true; } diff --git a/codex-rs/tui/src/app/permission_shortcuts.rs b/codex-rs/tui/src/app/permission_shortcuts.rs index a5f61da52381..e7c81e66ad7c 100644 --- a/codex-rs/tui/src/app/permission_shortcuts.rs +++ b/codex-rs/tui/src/app/permission_shortcuts.rs @@ -39,7 +39,7 @@ impl App { if !app_server.thread_settings_update(ThreadSettingsUpdateParams { thread_id: thread_id.to_string(), - permissions: Some(selection.profile_id), + permissions: Some(selection.profile_id.clone()), approval_policy: selection.approval_policy, approvals_reviewer: selection.approvals_reviewer.map(Into::into), ..Default::default() @@ -55,6 +55,7 @@ impl App { self.chat_widget.set_approval_policy(AskForApproval::from(config.permissions.approval_policy.value())); self.config.permissions = config.permissions.clone(); self.set_approvals_reviewer_in_app_and_widget(config.approvals_reviewer); + self.agents_overview.selected_permission_profiles.insert(thread_id, selection.profile_id); self.runtime_approval_policy_override = selection.approval_policy.map(RuntimeApprovalPolicyOverride::Explicit); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&config)); self.sync_active_thread_permission_settings_to_cached_session().await; diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 632d486a793e..b811bd54b1cd 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -182,6 +182,8 @@ impl App { self.chat_widget.restore_user_message_to_composer(message); } self.reconnect.offline = true; + // Cached blank sessions are usable only while this connection owns a subscription. + self.agents_overview.blank_sessions.clear(); self.reconnect.failed = false; if self.pending_server_version_notice.take().is_some() { self.reconnect.seen_version_notice = None; diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 3af80bfd6b06..0c1352069f0d 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -2,7 +2,6 @@ //! Keeps CLI/runtime cwd precedence, remote-workspace checks, and interactive prompts aligned. //! Carries local preferences alongside the resolved configuration for session replacement. -use super::agents_overview_view::AgentsOverviewFocus; use super::*; use crate::onboarding::onboarding_screen::check_directory_trust; use crate::startup_hooks_review::StartupHooksReviewOutcome; @@ -183,7 +182,7 @@ impl App { .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .is_none() { - self.open_agents_overview(app_server, AgentsOverviewFocus::List); + self.open_agents_overview(app_server); } return Err(AppRunControl::Continue); } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap index b28a1047c914..b17072f716da 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap @@ -17,12 +17,12 @@ expression: "render_bottom_popup(&app.chat_widget,\n48).replace(&test_path_displ - New task -› Describe a new task - ↑↓ navigate ctrl+o resume → open - ctrl+n new task ctrl+f search - ctrl+s group: project ctrl+r rename - ctrl+x stop f5 f8 hide f5 f6 archive - f5 f7 delete esc back + + + + ↑↓ navigate o resume enter open n new + f search g group: project r rename x stop + f5 f8 hide f5 f6 archive f5 f7 delete + ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap index f817fbe01afd..5387011184db 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap @@ -37,8 +37,8 @@ expression: "cached.replace(&format!(\"{project} 1\"),\n&format!(\"/tmp/project " │ " " │ " " │ " -" New task " -" " -"› Describe a new task " -" " -" enter create task ctrl+j newline esc tasks → open task " +" │ " +" │ " +" │ " +" ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide " +" a archive del delete ctrl+c quit " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap index 5cddd57bb24f..46cfc084888a 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap @@ -12,7 +12,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ Last message │ let explanation = "A long code line - │ … + │ should wrap inside the task details + │ panel."; │ │ Project │ /tmp/project @@ -21,8 +22,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Prompt │ long prompt long prompt long prompt │ … - New task - -› Describe a new task - - enter create task ctrl+j newline esc tasks → open task + │ + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap index 54f427419d45..ebae5141bdc9 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap @@ -12,7 +12,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ Last message │ Check Result - │ … + │ ━━━━━━━━ ━━━━━━━━ + │ Parser Fixed │ │ Project │ /tmp/project @@ -21,8 +22,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Prompt │ long prompt long prompt long prompt │ … - New task - -› Describe a new task - - enter create task ctrl+j newline esc tasks → open task + │ + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap index 50d1d944fe48..d2ea49a4a93e 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap @@ -1,6 +1,6 @@ --- source: tui/src/app/agents_overview_tests.rs -expression: "render_bottom_popup(&app.chat_widget,\n100).replace(&test_path_display(\"/tmp/project\"),\n\"/tmp/project\").replace(\"fwd del\", \"del\")" +expression: "render_bottom_popup(&app.chat_widget,\n100).replace(&test_path_display(\"/tmp/project\"),\n\"/tmp/project\").replace(\"del\", \"del\")" --- Agent command center 0 need input 0 working 4 ready @@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n100).replace(&test_path_disp │ │ │ - New task - -› Describe a new task - - ↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group: model ctrl+r rename - ctrl+x stop ctrl+w hide ctrl+e archive del delete esc back + │ + │ + │ + │ + ↑↓ navigate o resume enter open n new f search g group: model r rename x stop h hide a archive + del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap index d955c407adaf..6828109c76ef 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap @@ -1,6 +1,5 @@ --- source: tui/src/app/agents_overview_tests.rs -assertion_line: 38 expression: "render_bottom_popup(&app.chat_widget, 12)" --- Service @@ -22,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 12)" - New task -› Describe - - enter cre + n new + f search + ctrl+c + quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap index 12858275b5d0..3476b2d35bef 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap @@ -2,4 +2,4 @@ source: tui/src/app/agents_overview_tests.rs expression: rendered.lines().last().unwrap() --- - enter create task ctrl+j newline esc tasks → open task + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap deleted file mode 100644 index 79120030f583..000000000000 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: tui/src/app/agents_overview_tests.rs -expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"fwd del\", \"del\")" ---- - Agent command center - 0 need input 0 working 2 ready - ──────────────────────────────────────────────────────────────────────────────────────────── - /tmp/project 2 │ Task details - ○ First task Ready │ - › ○ Second task Ready │ Second task - │ ○ Ready - │ - │ Project - │ /tmp/project - │ Model: Unknown - │ - │ Prompt - │ Second task - │ - │ - │ - │ - New task - -› Unsent draft - - ↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group: project - ctrl+r rename ctrl+x stop ctrl+w hide ctrl+e archive del delete esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap index 85a456fe0237..066edac8b9b7 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap @@ -2,4 +2,4 @@ source: tui/src/app/agents_overview_tests.rs expression: "rendered.lines().find(|line| line.contains(\"open\")).unwrap()" --- - ↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group: project + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap index 853fb96813e0..2f2063786e5c 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap @@ -1,6 +1,6 @@ --- source: tui/src/app/agents_overview_usage_tests.rs -expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"fwd del\", \"del\")" +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"del\", \"del\")" --- Agent command center 1 need input 0 working 1 ready @@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Tokens: 13K in · 4K out │ Est. usage: 5 credits · ~$0.20 │ + │ Prompt + │ Review parser │ - New task - -› Describe a new task - - enter create task ctrl+j newline esc tasks → open task + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index ae79ecc64219..7d709218ce99 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -3,7 +3,6 @@ //! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input //! remains isolated from protected interactive requests until the initialized composer owns it. -use super::agents_overview_view::AgentsOverviewFocus; use super::reconnect::ReconnectState; use super::*; use crate::session_start::SessionStartAction; @@ -835,7 +834,7 @@ See the Codex keymap documentation for supported actions and examples." ); } if start_in_agents_overview { - app.open_agents_overview(&app_server, AgentsOverviewFocus::Composer); + app.open_agents_overview(&app_server); } else if !matches!(app.app_server_target, AppServerTarget::Embedded) { app.refresh_agents_overview_threads(&app_server); } diff --git a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs index 93393e3b61ac..4dcd62f07b82 100644 --- a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs +++ b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs @@ -36,10 +36,6 @@ async fn agents_navigation_requires_local_daemon() -> Result<()> { assert_matches!(event, AppEvent::OpenAgentsOverview); app.handle_event(&mut tui, &mut app_server, event).await?; assert!(!app.chat_widget.no_modal_or_popup_active()); - assert!(matches!( - app.agents_overview.view_state.lock().unwrap().focus, - crate::app::agents_overview_view::AgentsOverviewFocus::List - )); } else { assert!(events.try_recv().is_err()); assert!(app.chat_widget.no_modal_or_popup_active()); diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index d68ac425ab67..406ddf3e86e5 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -1,21 +1,14 @@ -//! Request-level coverage for background-task server defaults and dispatch recovery. +//! Request-level coverage for command-center session defaults. use super::*; use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use crate::app::tests::session_lifecycle_requests::HistoryCapabilities; use crate::app::tests::session_lifecycle_requests::recorded_params; use crate::app::tests::session_lifecycle_requests::start_recording_app_server_with_history; -use crate::bottom_pane::BottomPaneView; -use crate::bottom_pane::LocalImageAttachment; -use crate::chatwidget::UserMessage; -use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::model_catalog::ModelCatalog; use crate::test_support::PathBufExt; use crate::tui::test_support::make_test_tui; -use codex_app_server_protocol::UserInput; -use codex_protocol::openai_models::InputModality; use codex_state::SqliteConfig; -use crossterm::event::KeyCode; use pretty_assertions::assert_eq; fn trust_launch_folder(app: &mut App) { @@ -31,225 +24,7 @@ fn trust_launch_folder(app: &mut App) { } #[tokio::test] -async fn background_task_sends_pasted_image_with_first_prompt() -> Result<()> { - let mut tui = make_test_tui()?; - let (mut app, mut events, _) = make_test_app_with_channels().await; - trust_launch_folder(&mut app); - let image_dir = tempdir()?; - let image_path = image_dir.path().join("pasted.png"); - image::RgbImage::new(1, 1).save(&image_path)?; - let pasted_path = pathdiff::diff_paths(&image_path, std::env::current_dir()?) - .unwrap_or_else(|| image_path.clone()); - let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - assert!(view.handle_paste(pasted_path.to_string_lossy().into_owned())); - assert!(view.handle_paste("Describe this".into())); - let rendered_view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - app.chat_widget - .show_bottom_pane_view(Box::new(rendered_view)); - insta::assert_snapshot!( - render_bottom_popup(&app.chat_widget, /*width*/ 80) - .lines() - .find(|line| line.contains("[Image #1]")) - .expect("image attachment visible"), - @"› [Image #1] Describe this" - ); - view.handle_key_event(KeyCode::Enter.into()); - let prompt = match events.try_recv()? { - AppEvent::DispatchAgentsOverviewTask { prompt, .. } => prompt, - event => panic!("expected task dispatch, got {event:?}"), - }; - assert_eq!(prompt.local_images[0].path, pasted_path); - assert_eq!( - prompt.text_elements[0].placeholder(&prompt.text), - Some("[Image #1]") - ); - - let (mut server, requests, proxy) = start_recording_app_server_with_history( - &app.config, - HistoryCapabilities::Current, - /*blocked_thread_list*/ None, - /*failed_thread_name*/ None, - crate::app_server_session::ThreadParamsMode::Embedded, - LoaderOverrides::default(), - ) - .await?; - app.dispatch_agents_overview_task(&mut tui, &mut server, prompt.clone(), /*cwd*/ None) - .await; - let turns = recorded_params(&requests, "turn/start"); - assert_eq!(turns.len(), 1); - assert_eq!( - turns[0]["input"], - serde_json::to_value(vec![ - UserInput::LocalImage { - path: std::path::absolute(&pasted_path)?, - detail: None - }, - UserInput::Text { - text: prompt.text.clone(), - text_elements: prompt - .text_elements - .iter() - .cloned() - .map(Into::into) - .collect(), - }, - ])? - ); - - app.cli_kv_overrides = vec![("model".into(), toml::Value::Integer(1))]; - app.dispatch_agents_overview_task( - &mut tui, - &mut server, - prompt.clone(), - Some(app.config.cwd.clone()), - ) - .await; - let restored = app - .agents_overview - .view_state - .lock() - .unwrap() - .composer - .as_ref() - .unwrap() - .draft_snapshot(); - assert_eq!( - (restored.text, restored.text_elements, restored.local_images), - ( - prompt.text.clone(), - prompt.text_elements.clone(), - prompt.local_images.clone() - ) - ); - view.handle_key_event(KeyCode::Enter.into()); - assert!(view.handle_paste("A newer draft".into())); - app.dispatch_agents_overview_task( - &mut tui, - &mut server, - prompt.clone(), - Some(app.config.cwd.clone()), - ) - .await; - let current = app - .agents_overview - .view_state - .lock() - .unwrap() - .composer - .as_ref() - .unwrap() - .draft_snapshot(); - assert_eq!(current.text, "A newer draft"); - let notice = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => { - Some(lines_to_single_string(&cell.display_lines(/*width*/ 200))) - } - _ => None, - }) - .find(|message| message.contains("Reattach images from:")) - .expect("attachment recovery notice"); - assert!(notice.contains(&pasted_path.display().to_string())); - server.shutdown().await?; - proxy.await??; - Ok(()) -} - -#[tokio::test] -async fn remote_background_task_sends_clipboard_image_bytes() -> Result<()> { - let mut tui = make_test_tui()?; - let mut app = make_test_app_with_channels().await.0; - trust_launch_folder(&mut app); - let image_dir = tempdir()?; - let image_path = image_dir.path().join("pasted.png"); - image::RgbImage::new(1, 1).save(&image_path)?; - let mut prompt = UserMessage::from("Describe this image"); - prompt.local_images.push(LocalImageAttachment { - placeholder: "[Image #1]".into(), - path: image_path, - }); - let (mut server, requests, proxy) = start_recording_app_server_with_history( - &app.config, - HistoryCapabilities::Current, - /*blocked_thread_list*/ None, - /*failed_thread_name*/ None, - crate::app_server_session::ThreadParamsMode::Remote, - LoaderOverrides::default(), - ) - .await?; - app.dispatch_agents_overview_task(&mut tui, &mut server, prompt, /*cwd*/ None) - .await; - let turns = recorded_params(&requests, "turn/start"); - assert_eq!(turns.len(), 1); - let image = &turns[0]["input"][0]; - assert_eq!(image["type"], "image"); - assert!( - image["url"] - .as_str() - .is_some_and(|url| url.starts_with("data:image/png;base64,")) - ); - assert_eq!(turns[0]["input"][1]["text"], "Describe this image"); - server.shutdown().await?; - proxy.await??; - Ok(()) -} - -#[tokio::test] -async fn background_task_rejects_images_for_text_only_model() -> Result<()> { - let mut tui = make_test_tui()?; - let (mut app, mut events, _) = make_test_app_with_channels().await; - trust_launch_folder(&mut app); - let mut preset = app.model_catalog.models[0].clone(); - preset.model = "text-only-test-model".into(); - preset.input_modalities = vec![InputModality::Text]; - app.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); - app.harness_overrides.model = Some("text-only-test-model".into()); - let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - app.chat_widget.show_bottom_pane_view(Box::new(view)); - let mut prompt = UserMessage::from("[Image #1] Describe this"); - prompt.local_images.push(LocalImageAttachment { - placeholder: "[Image #1]".into(), - path: "/tmp/test-pasted.png".into(), - }); - let (mut server, requests, proxy) = start_recording_app_server_with_history( - &app.config, - HistoryCapabilities::Current, - /*blocked_thread_list*/ None, - /*failed_thread_name*/ None, - crate::app_server_session::ThreadParamsMode::Embedded, - LoaderOverrides::default(), - ) - .await?; - app.dispatch_agents_overview_task(&mut tui, &mut server, prompt.clone(), /*cwd*/ None) - .await; - assert!(recorded_params(&requests, "thread/start").is_empty()); - let draft = app - .agents_overview - .view_state - .lock() - .unwrap() - .composer - .as_ref() - .unwrap() - .draft_snapshot(); - assert_eq!(draft.local_images, prompt.local_images); - let error = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => { - Some(lines_to_single_string(&cell.display_lines(/*width*/ 100))) - } - _ => None, - }) - .find(|message| message.contains("does not support image inputs")) - .expect("visible model rejection"); - insta::assert_snapshot!(error, @"■ Model text-only-test-model does not support image inputs. Remove images or switch models."); - server.shutdown().await?; - proxy.await??; - Ok(()) -} - -#[tokio::test] -async fn background_task_reads_server_defaults_for_actual_destination() -> Result<()> { +async fn command_center_new_reads_server_defaults_for_actual_destination() -> Result<()> { let mut tui = make_test_tui()?; for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [ ("local", false, false, "launch", "server-model"), @@ -264,6 +39,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul "server-model", ), ("remote", true, true, "destination", "destination-model"), + ("remote", true, false, "destination", "destination-model"), ("remote", false, true, "launch", "server-model"), ("remote", false, false, ".", "server-model"), ("remote-null-fast", false, false, ".", ""), @@ -370,6 +146,24 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul if launch_override { server = server.with_remote_cwd_override(Some(launch.path().to_path_buf())); } + if mode.starts_with("remote") && !launch_override { + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:1".into(), + auth_token: None, + }, + }; + let mut restored = app.config.clone(); + restored + .permissions + .set_permission_profile(codex_protocol::models::PermissionProfile::Disabled)?; + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&restored), + ); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + codex_app_server_protocol::AskForApproval::Never, + )); + } let bootstrap = server.bootstrap(&app.config).await?; let expected_model = if mode == "remote-null-fast" { let default_model = bootstrap @@ -385,13 +179,12 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul } else { expected_model.to_string() }; - app.dispatch_agents_overview_task( + app.new_agents_overview_session( &mut tui, &mut server, - "background prompt".into(), explicit_cwd.then(|| destination.path().to_path_buf().abs()), ) - .await; + .await?; let cwd = match expected_cwd { "launch" => launch.path().display().to_string(), "destination" => destination.path().display().to_string(), @@ -436,7 +229,12 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul ), "{mode} {expected_cwd}" ); - assert_eq!(recorded_params(&requests, "turn/start").len(), 1); + if mode.starts_with("remote") && !launch_override { + assert_eq!(app.config.cwd, launch.path().to_path_buf().abs()); + assert_ne!(starts[0]["sandbox"], "danger-full-access"); + assert_ne!(starts[0]["approvalPolicy"], "never"); + } + assert_eq!(recorded_params(&requests, "turn/start").len(), 0); server.shutdown().await?; proxy.await??; } @@ -444,7 +242,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul } #[tokio::test] -async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Result<()> { +async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> Result<()> { let mut tui = make_test_tui()?; for (choice, expected_model, expected_effort) in [ ("saved", "server-model", "high"), @@ -516,13 +314,8 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re ) .await?; server.bootstrap(&app.config).await?; - app.dispatch_agents_overview_task( - &mut tui, - &mut server, - "background prompt".into(), - /*cwd*/ None, - ) - .await; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; let starts = recorded_params(&requests, "thread/start"); assert_eq!(starts.len(), 1, "{choice}"); assert_eq!( @@ -538,7 +331,7 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re ); assert_eq!( recorded_params(&requests, "turn/start").len(), - 1, + 0, "{choice}" ); server.shutdown().await?; @@ -548,10 +341,11 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re } #[tokio::test] -async fn background_task_read_failure_keeps_prompt_and_does_not_start() -> Result<()> { +async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> Result<()> { let mut tui = make_test_tui()?; for capability in [ HistoryCapabilities::ConfigReadFails, + HistoryCapabilities::ThreadStartFails, HistoryCapabilities::ConfigReadUnsupported(-32600), HistoryCapabilities::ConfigReadUnsupported(-32601), ] { @@ -569,36 +363,23 @@ async fn background_task_read_failure_keeps_prompt_and_does_not_start() -> Resul LoaderOverrides::default(), ) .await?; - app.dispatch_agents_overview_task( - &mut tui, - &mut server, - "retry background prompt".into(), - /*cwd*/ None, - ) - .await; - let failed = capability == HistoryCapabilities::ConfigReadFails; + let source_colors = !app.config.tui_status_line_use_colors; + app.local_settings.tui.status_line_use_colors = source_colors; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let failed = matches!( + capability, + HistoryCapabilities::ConfigReadFails | HistoryCapabilities::ThreadStartFails + ); assert_eq!(recorded_params(&requests, "config/read").len(), 1); assert_eq!( recorded_params(&requests, "thread/start").len(), - usize::from(!failed) - ); - assert_eq!( - recorded_params(&requests, "turn/start").len(), - usize::from(!failed) + usize::from(capability != HistoryCapabilities::ConfigReadFails) ); + assert_eq!(recorded_params(&requests, "turn/start").len(), 0); if failed { + assert_eq!(app.local_settings.tui.status_line_use_colors, source_colors); assert!(app.agents_overview.dispatched_requests.is_empty()); - assert_eq!( - app.agents_overview - .view_state - .lock() - .unwrap() - .composer - .as_ref() - .unwrap() - .current_text_with_pending(), - "retry background prompt" - ); assert!( app.chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) @@ -611,9 +392,19 @@ async fn background_task_read_failure_keeps_prompt_and_does_not_start() -> Resul } _ => None, }) - .find(|message| message.contains("Failed to load background task settings")) + .find(|message| message.contains("Failed to")) .expect("visible read error"); - insta::assert_snapshot!(error, @"■ Failed to load background task settings: config/read failed in TUI"); + if capability == HistoryCapabilities::ConfigReadFails { + insta::assert_snapshot!(error, @"■ Failed to load new session settings: config/read failed in TUI"); + } else { + insta::assert_snapshot!( + "command_center_session_start_error", + crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 80, + ) + ); + } } else { assert_eq!( recorded_params(&requests, "thread/start")[0]["model"], @@ -625,3 +416,306 @@ async fn background_task_read_failure_keeps_prompt_and_does_not_start() -> Resul } Ok(()) } + +#[tokio::test] +async fn command_center_new_preserves_permissions_across_sessions() -> Result<()> { + let mut app = make_test_app_with_channels().await.0; + trust_launch_folder(&mut app); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + assert!( + app.apply_permission_profile_selection(PermissionProfileSelection { + profile_id: ":read-only".into(), + approval_policy: Some(AskForApproval::UnlessTrusted), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }) + .await + ); + let mut tui = make_test_tui()?; + for _ in 0..2 { + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_eq!( + ( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + app.chat_widget + .config_ref() + .permissions + .approval_policy + .value(), + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + ), + ( + &codex_protocol::models::PermissionProfile::read_only(), + codex_protocol::protocol::AskForApproval::UnlessTrusted, + Some(ActivePermissionProfile::new(":read-only")), + ), + ); + } + assert_eq!(recorded_params(&requests, "thread/start").len(), 2); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn command_center_new_preserves_only_selected_server_profiles() -> Result<()> { + let mut app = make_test_app_with_channels().await.0; + trust_launch_folder(&mut app); + let home = tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n", + )?; + let server_config = ConfigBuilder::default() + .codex_home(home.path().into()) + .build() + .await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:8765")?, + }; + let client = crate::start_embedded_app_server( + codex_arg0::Arg0DispatchPaths::default(), + server_config, + Vec::new(), + LoaderOverrides::default(), + /*strict_config*/ false, + CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + /*state_db*/ None, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ) + .await?; + let mut server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess(client), + crate::app_server_session::ThreadParamsMode::Remote, + ); + let selection = PermissionProfileSelection { + profile_id: "server-only".into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "server-only".into(), + }; + let started = server + .start_thread_with_session_start_source( + &app.local_settings, + &app.config, + /*session_start_source*/ None, + /*remote_cwd_override*/ None, + Some(&selection), + ) + .await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()), + ); + let mut tui = make_test_tui()?; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + None + ); + let thread_id = app.chat_widget.thread_id().expect("new session"); + let other = server.start_thread(&app.config).await?.session.thread_id; + // Exercise the persisted-history attachment path rather than blank-task reuse. + app.agents_overview.blank_sessions.remove(&thread_id); + for id in [thread_id, other] { + server.thread_inject_items(id, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "saved history"}] + }))?]).await?; + } + app.select_permission_profile(&mut server, selection.clone()) + .await; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + insta::assert_snapshot!( + "command_center_pending_server_permissions", + crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + let settings = next_thread_settings_updated(&mut server, thread_id).await; + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + for target in [other, thread_id] { + app.select_agents_overview_thread(&mut tui, &mut server, target) + .await?; + assert_eq!( + app.chat_widget.thread_id(), + Some(target), + "{}", + crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 120 + ) + ); + } + for attempt in 0..3 { + let previous_thread_id = app.chat_widget.thread_id(); + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_ne!(app.chat_widget.thread_id(), previous_thread_id); + let config = app.chat_widget.config_ref(); + assert_eq!( + ( + config.permissions.active_permission_profile(), + config.permissions.permission_profile(), + config.permissions.approval_policy.value(), + ), + ( + Some(ActivePermissionProfile { + id: "server-only".into(), + extends: Some(":read-only".into()), + }), + &PermissionProfile::read_only(), + codex_protocol::protocol::AskForApproval::OnRequest, + ) + ); + if attempt == 1 { + app.agents_overview + .selected_permission_profiles + .remove(&app.chat_widget.thread_id().unwrap()); + app.select_permission_profile(&mut server, selection.clone()) + .await; + } + } + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + trust_launch_folder(&mut app); + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:8765")?, + }; + app.cli_kv_overrides.push(( + "approvals_reviewer".into(), + TomlValue::String("auto_review".into()), + )); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + LoaderOverrides::default(), + ) + .await?; + let mut tui = make_test_tui()?; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let first = app.chat_widget.thread_id().unwrap(); + app.chat_widget.insert_str("Keep this unsent draft"); + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let other = app.chat_widget.thread_id().unwrap(); + app.select_agents_overview_thread(&mut tui, &mut server, first) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(first)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Keep this unsent draft" + ); + insta::assert_snapshot!( + crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80) + .lines().next().unwrap(), @"› Keep this unsent draft"); + // Seed persisted history without sending the user's draft. Subsequent navigation + // must exercise thread/resume, including its restoration of permission settings. + for id in [first, other] { + server.thread_inject_items(id, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "saved history"}] + }))?]).await?; + app.agents_overview.blank_sessions.remove(&id); + } + for profile_id in [":read-only", ":workspace"] { + app.select_agents_overview_thread(&mut tui, &mut server, first) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(first)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Keep this unsent draft" + ); + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + profile_id: profile_id.into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: profile_id.into(), + }, + ) + .await; + while let Ok(event) = events.try_recv() { + if matches!(event, AppEvent::CodexOp(_)) { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + } + // Test both immediate creation and creation after A -> B -> A. + for switch in [false, true] { + if switch { + for target in [other, first] { + app.select_agents_overview_thread(&mut tui, &mut server, target) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(target)); + } + } + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let starts = recorded_params(&requests, "thread/start"); + let params = starts.last().unwrap(); + assert_eq!( + (¶ms["permissions"], ¶ms["approvalsReviewer"]), + (&serde_json::json!(profile_id), &serde_json::json!("user")) + ); + assert_eq!( + ( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + app.chat_widget.config_ref().approvals_reviewer + ), + ( + Some(ActivePermissionProfile::new(profile_id)), + ApprovalsReviewer::User + ), + ); + } + } + app.select_agents_overview_thread(&mut tui, &mut server, first) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Keep this unsent draft" + ); + assert!(recorded_params(&requests, "turn/start").is_empty()); + server.shutdown().await?; + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index c6117fdfa218..dec6448256af 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -206,7 +206,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< app.handle_tui_event( &mut tui, &mut session, - TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)), ) .await?; app.handle_tui_event( @@ -218,9 +218,9 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< assert!(app.begin_reconnect()); for key in [ KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), ] { app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) .await?; @@ -233,21 +233,10 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), ) .await?; - assert_eq!( - app.agents_overview - .view_state - .lock() - .unwrap() - .composer - .as_ref() - .unwrap() - .current_text_with_pending(), - "task draft!" - ); assert!( !std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( event, - AppEvent::DispatchAgentsOverviewTask { .. } | AppEvent::OpenResumePicker + AppEvent::NewAgentsOverviewSession { .. } | AppEvent::OpenResumePicker )) ); assert_snapshot!( diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 2ace3cbe9c4a..9b33abd62b41 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -586,10 +586,9 @@ async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<( #[tokio::test] async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<()> { let mut app = make_test_app().await; - app.keymap = RuntimeKeymap::from_config(&toml::from_str( - "[editor]\ninsert_newline = [\"ctrl-x n\"]", - )?) - .unwrap(); + app.keymap = + RuntimeKeymap::from_config(&toml::from_str("[agents]\nnew_task = [\"ctrl-x n\"]")?) + .unwrap(); let mut tui = crate::tui::test_support::make_test_tui()?; let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); @@ -598,7 +597,7 @@ async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result< let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); insta::assert_snapshot!( render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), - @" ctrl + x … waiting for next key esc cancel" + @" ctrl + x … waiting for next key esc cancel" ); assert_eq!( app.route_key_chord_event(&mut tui, KeyCode::Esc.into()), @@ -607,3 +606,32 @@ async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result< assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 80), before); Ok(()) } + +#[tokio::test] +async fn command_center_chords_do_not_capture_search_text() -> Result<()> { + let mut app = make_test_app().await; + app.keymap = RuntimeKeymap::from_config(&toml::from_str( + "[agents]\nnew_task = 'n n'\n[ list ]\naccept = 'ctrl-x enter'", + )?) + .unwrap(); + let mut tui = crate::tui::test_support::make_test_tui()?; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.chat_widget.handle_key_event(KeyCode::Char('f').into()); + for key in "new".chars() { + let event = KeyCode::Char(key).into(); + assert_eq!(app.route_key_chord_event(&mut tui, event), Some(event)); + app.chat_widget.handle_key_event(event); + } + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Search › new")); + assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None); + assert!(app.key_chord_matcher.is_pending()); + app.route_key_chord_event(&mut tui, KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert_eq!( + app.route_key_chord_event(&mut tui, KeyCode::Char('n').into()), + None + ); + assert!(app.key_chord_matcher.is_pending()); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 11033006669a..a83dd9c085b0 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -143,20 +143,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ); app.agents_overview.visible_thread_ids = view.thread_ids(); app.chat_widget.show_bottom_pane_view(Box::new(view)); - if previous_thread.is_some() { - app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); - } else { - app.chat_widget.handle_paste("Keep this task draft".into()); - } - app.agents_overview.view_state.lock().unwrap().renaming = previous_thread.is_some(); - let draft = |app: &App| { - let state = app.agents_overview.view_state.lock().unwrap(); - if previous_thread.is_some() { - state.input.clone() - } else { - state.composer.as_ref().unwrap().current_text_with_pending() - } - }; + app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); + app.agents_overview.view_state.lock().unwrap().renaming = true; + let draft = |app: &App| app.agents_overview.view_state.lock().unwrap().input.clone(); let stale_request = Uuid::new_v4(); app.agents_overview.request_id = Some(stale_request); app.agents_overview.refresh_pending = true; @@ -413,7 +402,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv .await?; assert!( !std::iter::from_fn(|| events.try_recv().ok()) - .any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. })) + .any(|event| matches!(event, AppEvent::NewAgentsOverviewSession { .. })) ); } assert!( @@ -452,7 +441,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv .await?; assert!(app.chat_widget.has_active_view()); - // Esc returns to the overview composer. Dismiss the retained view explicitly + // Esc stays in the overview. Dismiss the retained view explicitly // to inspect the unavailable conversation and its cached draft below. app.agents_overview.view_state.lock().unwrap().completion = Some(crate::bottom_pane::ViewCompletion::Accepted); diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 0b0d64159ffa..efacbcf5df15 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2877,10 +2877,7 @@ async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable if attempt == 0 { app.refresh_agents_overview_threads(&app_server); } else { - app.open_agents_overview( - &app_server, - crate::app::agents_overview_view::AgentsOverviewFocus::List, - ); + app.open_agents_overview(&app_server); } let Some(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }) = tokio::time::timeout(Duration::from_secs(10), rx.recv()).await? diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap index 8a0a99e23a5c..b3ae684ab6a8 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap @@ -4,4 +4,4 @@ expression: "lines.join(\"\\n\")" --- Disconnected from this task. Work may still be running. Reconnect: codex --remote wss://example.com:443/ resume 123e4567-e89b-12d3-a456-426614174000 -Stop the current turn: run codex --remote wss://example.com:443/ agents, select this task, and press ctrl + x. +Stop the current turn: run codex --remote wss://example.com:443/ agents, select this task, and press x. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap new file mode 100644 index 000000000000..980c304f2ddf --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/background_task_defaults_tests.rs +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)" +--- + Unable to complete action + Wait for permissions to update before starting a session. + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap new file mode 100644 index 000000000000..6041f1da37b9 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/background_task_defaults_tests.rs +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80,)" +--- + Unable to complete action + Failed to start session: thread/start failed during TUI bootstrap: thread/ + start failed: replacement unavailable (code -32603) + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap index 62c482207865..8f1849f991e3 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - New task - -› task draft! - - esc tasks · dispatch paused + │ + │ + │ + │ + ctrl+c quit · actions paused until the list is refreshed diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap index 5b2bfd1d02ab..5459120cb9af 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - New task - -› Keep this task draft! - - esc tasks · dispatch paused + │ + │ + │ + Rename › Keep this task draft! + esc cancel · actions paused until reconnected diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap index 7ba8cdaf08d5..5f7c1bef638c 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - New task - -› Keep this task draft! - - esc tasks · dispatch paused + │ + │ + │ + Rename › Keep this task draft! + esc cancel · actions paused until reconnected diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap index e7b1664d6a67..d2fd87fcf406 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - New task - -› Describe a new task - - enter create task ctrl+j newline esc tasks → open task + │ + │ + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive + del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap index 030cc194aad4..4b5d8bec0dbe 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap @@ -1,6 +1,5 @@ --- source: tui/src/app/tests/session_lifecycle_requests.rs -assertion_line: 906 expression: "render_bottom_popup(&app.chat_widget, 100)" --- Agent command center @@ -22,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - New task - -› Describe a new task - - enter create task ctrl+j newline esc tasks + │ + │ + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive + del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index aac4a06395ee..16ef4d3ddb9b 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -195,6 +195,9 @@ impl App { thread_id: ThreadId, settings: &ThreadSettings, ) { + if let Some(blank) = self.agents_overview.blank_sessions.get_mut(&thread_id) { + apply_thread_settings_to_session(&mut blank.session, settings); + } if self.primary_thread_id == Some(thread_id) && let Some(session) = self.primary_session_configured.as_mut() { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 61dfd920ebe1..cd699d77ca84 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -274,10 +274,6 @@ pub(crate) enum AppEvent { CloseMisalignmentReview, /// Open the daemon-wide overview of recent and locally retained root sessions. OpenAgentsOverview, - /// Present a failed command-center action above the retained dashboard. - AgentsOverviewError(String), - /// Show a failed draft without replacing newer composer input. - ViewAgentsOverviewUnsentPrompt(String), /// Update the daemon-wide overview after a background thread listing finishes. AgentsOverviewThreadsLoaded { request_id: Uuid, @@ -287,9 +283,8 @@ pub(crate) enum AppEvent { SelectAgentsOverviewThread { thread_id: ThreadId, }, - /// Start a background task directly from the shared dashboard. - DispatchAgentsOverviewTask { - prompt: UserMessage, + /// Open an empty session in the selected checkout. + NewAgentsOverviewSession { cwd: Option, }, /// Rename a task directly from the shared dashboard. diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ac427a4bbbd1..29922e62139b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -351,7 +351,7 @@ impl ThreadParamsMode { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct AppServerStartedThread { pub(crate) session: ThreadSessionState, pub(crate) turns: Vec, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 47afa3038649..7c3d64193bc6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -18,7 +18,6 @@ //! # Key Event Routing //! //! Plain Left opens agents when the local-daemon composer is empty and available for input. -//! The agents dashboard uses the matching empty-editor guards for Right to open a task. //! Explicit editor remaps take precedence. //! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a //! popup-specific handler if a popup is visible and otherwise to diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs index bace61118c60..f2fdbf0dd236 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs @@ -2,26 +2,10 @@ //! //! Only local-daemon sessions enable this shortcut. Draft contents, transient input surfaces, //! and explicit editor remaps take precedence over navigation. -//! The dashboard can also leave an empty editor with Right under the same input guards. use super::*; impl ChatComposer { - pub(crate) fn can_leave_empty_prompt_with_right(&self) -> bool { - let move_right = if self.draft.textarea.is_vim_normal_mode() { - &self.vim_normal_keymap.move_right - } else { - &self.editor_keymap.move_right - }; - move_right.is_pressed(KeyCode::Right.into()) - && self.has_focus - && self.draft.input_enabled - && self.is_empty() - && !self.is_in_paste_burst() - && !self.popup_active() - && !self.draft.textarea.is_vim_operator_pending() - } - pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { self.agents_navigation_enabled = enabled; } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs index c0669203d558..5dfeaa8dfaf4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -1,14 +1,9 @@ -//! Offline editing and event-channel rebinding retain the draft in place. +//! Offline editing retains the draft in place. //! Paste Enter handling is shared with normal submission so buffered newlines survive both paths. use super::*; impl ChatComposer { - /// Rebind retained editors after the app replaces its event channel. - pub(crate) fn set_app_event_sender(&mut self, sender: AppEventSender) { - self.app_event_tx = sender; - } - /// Preserve Enter inside a paste burst without attempting submission. pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool { let in_slash_context = self.slash_commands_enabled() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 627e88489206..9dd0cadb5485 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1737,10 +1737,6 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst() } - pub(crate) fn composer_is_vim_enabled(&self) -> bool { - self.bottom_pane.composer_is_vim_enabled() - } - #[cfg(test)] pub(crate) fn is_task_running_for_test(&self) -> bool { self.bottom_pane.is_task_running() diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index c56a0011a0f3..536ff52d4f75 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -576,6 +576,15 @@ impl ChatWidget { return; } + if self + .bottom_pane + .selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + self.request_quit_without_confirmation(); + return; + } + if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if self.is_cancellable_work_active() { self.quit_shortcut_expires_at = None; diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 1862d8e64e93..e8fb2fae25c4 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -107,6 +107,8 @@ impl KeyBinding { pub(crate) fn display_label(&self) -> String { let modifiers = modifiers_to_string(self.modifiers); let key = match self.key { + #[cfg(test)] + KeyCode::Delete => "del".to_string(), KeyCode::Enter => "enter".to_string(), KeyCode::Char(' ') => "space".to_string(), KeyCode::Up => "↑".to_string(), diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index f3b85c456fb7..41a3a70f73ff 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -1274,20 +1274,8 @@ impl RuntimeKeymap { chord_hints: Arc::clone(&chords), }; - let resume_default_is_shadowed = keymap.agents.resume.is_none() - && (configured_context_alias_is_used(&keymap.agents, "ctrl-o") - || configured_context_alias_is_used(&keymap.list, "ctrl-o") - || chords.bindings.iter().any(|binding| { - binding.action.context.overlaps(KeymapContext::Agents) - && binding.chord.prefix.parts() - == key_hint::ctrl(KeyCode::Char('o')).parts() - })); let mut agents = AgentsKeymap { - resume: if resume_default_is_shadowed { - Vec::new() - } else { - resolve_local!(keymap, defaults, agents, resume) - }, + resume: resolve_local!(keymap, defaults, agents, resume), search: resolve_local!(keymap, defaults, agents, search), new_task: resolve_local!(keymap, defaults, agents, new_task), rename: resolve_local!(keymap, defaults, agents, rename), @@ -1299,15 +1287,21 @@ impl RuntimeKeymap { chord_hints: Arc::clone(&chords), }; - // Newly added defaults yield to existing user bindings in the dashboard. + // New defaults yield to explicit bindings, including existing list shortcuts. for (configured, bindings, alias) in [ + (keymap.agents.resume.as_ref(), &mut agents.resume, "o"), + (keymap.agents.search.as_ref(), &mut agents.search, "f"), + (keymap.agents.new_task.as_ref(), &mut agents.new_task, "n"), + (keymap.agents.rename.as_ref(), &mut agents.rename, "r"), + (keymap.agents.stop.as_ref(), &mut agents.stop, "x"), + (keymap.agents.archive.as_ref(), &mut agents.archive, "a"), + (keymap.agents.delete.as_ref(), &mut agents.delete, "delete"), + (keymap.agents.hide.as_ref(), &mut agents.hide, "h"), ( - keymap.agents.archive.as_ref(), - &mut agents.archive, - "ctrl-e", + keymap.agents.toggle_grouping.as_ref(), + &mut agents.toggle_grouping, + "g", ), - (keymap.agents.delete.as_ref(), &mut agents.delete, "delete"), - (keymap.agents.hide.as_ref(), &mut agents.hide, "ctrl-w"), ] { if configured.is_none() && (configured_context_alias_is_used(&keymap.agents, alias) @@ -1816,15 +1810,15 @@ impl RuntimeKeymap { chord_hints: Arc::default(), }, agents: AgentsKeymap { - resume: default_bindings![ctrl(KeyCode::Char('o'))], - search: default_bindings![ctrl(KeyCode::Char('f'))], - new_task: default_bindings![ctrl(KeyCode::Char('n'))], - rename: default_bindings![ctrl(KeyCode::Char('r'))], - stop: default_bindings![ctrl(KeyCode::Char('x'))], - archive: default_bindings![ctrl(KeyCode::Char('e'))], + resume: default_bindings![plain(KeyCode::Char('o'))], + search: default_bindings![plain(KeyCode::Char('f'))], + new_task: default_bindings![plain(KeyCode::Char('n'))], + rename: default_bindings![plain(KeyCode::Char('r'))], + stop: default_bindings![plain(KeyCode::Char('x'))], + archive: default_bindings![plain(KeyCode::Char('a'))], delete: default_bindings![plain(KeyCode::Delete)], - hide: default_bindings![ctrl(KeyCode::Char('w'))], - toggle_grouping: default_bindings![ctrl(KeyCode::Char('s'))], + hide: default_bindings![plain(KeyCode::Char('h'))], + toggle_grouping: default_bindings![plain(KeyCode::Char('g'))], chord_hints: Arc::default(), }, approval: ApprovalKeymap { @@ -2203,14 +2197,12 @@ impl RuntimeKeymap { )); } if bindings.iter().any(|binding| { - matches!(binding.parts(), (KeyCode::Char(_), modifiers) - if modifiers.is_empty() - || modifiers == KeyModifiers::SHIFT - || crate::key_hint::is_altgr(modifiers)) - || binding.parts() == (KeyCode::Backspace, KeyModifiers::NONE) + let (code, modifiers) = binding.normalized_parts(); + (code == KeyCode::Backspace && modifiers == KeyModifiers::NONE) + || (matches!(code, KeyCode::Char(_)) && crate::key_hint::is_altgr(modifiers)) }) { return Err(format!( - "tui.keymap.agents.{action}: printable keys and backspace are reserved for task input" + "tui.keymap.agents.{action}: AltGr and backspace are reserved for editing" )); } } @@ -3579,15 +3571,11 @@ mod tests { let runtime = RuntimeKeymap::from_config(&keymap).expect("list shortcut remains usable"); assert_eq!( runtime.agents.rename, - vec![key_hint::ctrl(KeyCode::Char('r'))] + vec![key_hint::plain(KeyCode::Char('r'))] ); keymap.agents.search = Some(one("s")); - assert!( - RuntimeKeymap::from_config(&keymap) - .expect_err("printable shortcut is reserved for input") - .contains("printable keys") - ); + assert!(RuntimeKeymap::from_config(&keymap).is_ok()); keymap.agents.search = None; keymap.agents.stop = Some(one("backspace")); @@ -3616,18 +3604,22 @@ mod tests { .expect_err("backspace is reserved for task input") .contains("backspace") ); + for binding in ["alt-backspace", "ctrl-backspace"] { + keymap.agents.stop = Some(one(binding)); + assert!(RuntimeKeymap::from_config(&keymap).is_ok()); + } } #[test] fn agents_resume_default_yields_to_existing_custom_shortcuts() { - for binding in ["ctrl-o", "ctrl-o f6"] { + for binding in ["o", "o f6"] { let mut keymap = TuiKeymap::default(); keymap.agents.search = Some(one(binding)); let runtime = RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid"); assert!(runtime.agents.resume.is_empty()); - keymap.agents.resume = Some(one("ctrl-o")); + keymap.agents.resume = Some(one("o")); assert!(RuntimeKeymap::from_config(&keymap).is_err()); } } diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 5a2848cae559..862f14c7d803 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -467,9 +467,10 @@ Windows. Choose a different chord and retry.", if matches!(prefix_key, KeyCode::Char(_)) && !crate::key_hint::has_ctrl_or_alt(prefix_modifiers) && !binding.action.context.allows_plain_chord_prefix() + && binding.action.context != KeymapContext::Agents { return Err(format!( - "Invalid `{path}` = `{}`: a chord prefix outside Vim must use ctrl, \ + "Invalid `{path}` = `{}`: a chord prefix outside Vim or the command center must use ctrl, \ alt, or a non-character key so ordinary text input is not intercepted.", binding.spec )); diff --git a/codex-rs/tui/src/keymap/conflict_tests.rs b/codex-rs/tui/src/keymap/conflict_tests.rs index 45fce93f6f6b..47b65a3c81d6 100644 --- a/codex-rs/tui/src/keymap/conflict_tests.rs +++ b/codex-rs/tui/src/keymap/conflict_tests.rs @@ -40,11 +40,7 @@ See the Codex keymap documentation for supported actions and examples." #[test] fn new_agents_defaults_preserve_existing_custom_bindings() { - for (action, alias) in [ - ("archive", "ctrl-e"), - ("delete", "delete"), - ("hide", "ctrl-w"), - ] { + for (action, alias) in [("archive", "a"), ("delete", "delete"), ("hide", "h")] { for (context, existing, suffix) in [ ("agents", "stop", ""), ("list", "move_down", ""), @@ -52,12 +48,14 @@ fn new_agents_defaults_preserve_existing_custom_bindings() { ("agents", "stop", " f12"), ("list", "move_down", " f12"), ] { - // Ctrl+E is reserved by the resume picker for list-context chords. - if context == "list" && alias == "ctrl-e" && !suffix.is_empty() { + // Plain keys are allowed in the dashboard, but global actions and list + // chord prefixes still share text-entry surfaces. + if alias != "delete" && (context == "global" || context == "list" && !suffix.is_empty()) + { continue; } let keymap: TuiKeymap = serde_json::from_value( - json!({context: {existing: format!("{alias}{suffix}")}, "approval": {"open_fullscreen": "f12"}, + json!({context: {existing: format!("{alias}{suffix}")}, "approval": {"open_fullscreen": "f12", "approve_for_session": []}, "editor": {"move_line_start": [], "move_line_end": [], "delete_backward_word": [], "delete_forward": []}}), ) .unwrap(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 89181adf77ae..a0aa2a8d684e 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -215,7 +215,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("list", "List", "cancel", "Cancel and close selection views."), action("agents", "Agents", "resume", "Open the session resume picker."), action("agents", "Agents", "search", "Search the available agent tasks."), - action("agents", "Agents", "new_task", "Start composing a new agent task."), + action("agents", "Agents", "new_task", "Open a new session in the selected checkout."), action("agents", "Agents", "rename", "Rename the selected task."), action("agents", "Agents", "stop", "Stop the selected running task."), action("agents", "Agents", "archive", "Archive the selected task and its child agents."), 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 2ff62c6bd720..1c9a33e535af 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 @@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ Prompt │ Build the dashboard - New task - -› First line - Second line - - enter create task ctrl+j newline esc tasks + │ + │ + │ + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap index 63b8f8a48129..6ee8cb311379 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap @@ -13,7 +13,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Agent: Check dependencies │ Which dependency version should I use? │ Open task to review. - │ … + │ Waiting for approval. + │ Waiting for your response. │ │ Project │ /tmp/project @@ -21,8 +22,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ Prompt │ Repair authentication - New task - -› Describe a new task - - enter create task ctrl+j newline esc tasks → open task + │ + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap index 3d0ece610430..f5d9f67550ef 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap @@ -17,12 +17,12 @@ expression: rendered - New task -› Describe a new task - ↑↓ navigate ctrl+o resume → open - ctrl+n new task ctrl+f search - ctrl+s group: project ctrl+r rename - ctrl+x stop ctrl+w hide ctrl+e archive - del delete esc back + + + + + ↑↓ navigate o resume enter open n new + f search g group: project r rename x stop + h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap index 2484674f0f6e..59994ecbe464 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap @@ -12,6 +12,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ Last message │ Found the regression in the parser. + │ Found the regression in the parser. + │ Found the regression in the parser. + │ Found the regression in the parser. │ … │ │ Project @@ -21,8 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Prompt │ Investigate parser │ … - New task - -› Describe a new task - - enter create task ctrl+j newline esc tasks → open task + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap index eeee9fb40947..82f41d52bd3e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ No prompt available. │ │ - New task - -› Keep this draft - - enter create task ctrl+j newline esc tasks + │ + │ + │ + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap deleted file mode 100644 index c71dbb711b52..000000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/app/agents_overview_tests.rs -assertion_line: 2326 -expression: rendered ---- - Unable to complete action - Failed to send task message: turn/start failed: thread not found: 00000000-0000-0000-0000- - 000000000001 (code -32600) - -› 1. Return to command center - 2. View unsent task Your newer draft has been kept in the composer. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap index da7d114dadff..c96c8c3f085c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap @@ -21,8 +21,8 @@ expression: grouping - New task -› Describe a new task - enter create task ctrl+j newline esc tasks → open task + + + ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap index 989945991e34..95c9a4786652 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap @@ -12,4 +12,3 @@ Raw event: code=Char('o'), modifiers=ctrl, kind=Press Assigned actions: - global.copy (Copy) - Copy the last agent response to the clipboard. [Default] - - agents.resume (Resume) - Open the session resume picker. [Default] diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap index bb6866291ea5..08b1ac14058a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap @@ -2,12 +2,12 @@ source: tui/src/keymap_setup.rs expression: agents --- -Resume | ctrl-o | Agents resume Resume Open the session resume picker. ctrl-o Default -Search | ctrl-f | Agents search Search Search the available agent tasks. ctrl-f Default -New Task | ctrl-n | Agents new_task New Task Start composing a new agent task. ctrl-n Default -Rename | ctrl-r | Agents rename Rename Rename the selected task. ctrl-r Default -Stop | ctrl-x | Agents stop Stop Stop the selected running task. ctrl-x Default -Archive | ctrl-e | Agents archive Archive Archive the selected task and its child agents. ctrl-e Default +Resume | o | Agents resume Resume Open the session resume picker. o Default +Search | f | Agents search Search Search the available agent tasks. f Default +New Task | n | Agents new_task New Task Open a new session in the selected checkout. n Default +Rename | r | Agents rename Rename Rename the selected task. r Default +Stop | x | Agents stop Stop Stop the selected running task. x Default +Archive | a | Agents archive Archive Archive the selected task and its child agents. a Default Delete | delete | Agents delete Delete Permanently delete the selected task and its child agents. delete Default -Hide | ctrl-w | Agents hide Hide Hide the selected task until explicitly resumed. ctrl-w Default -Toggle Grouping | ctrl-s | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, status, or model. ctrl-s Default +Hide | h | Agents hide Hide Hide the selected task until explicitly resumed. h Default +Toggle Grouping | g | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, status, or model. g Default diff --git a/codex-rs/tui/tests/suite/directory_trust.rs b/codex-rs/tui/tests/suite/directory_trust.rs index 51ef7f29f28a..9c4326a4af12 100644 --- a/codex-rs/tui/tests/suite/directory_trust.rs +++ b/codex-rs/tui/tests/suite/directory_trust.rs @@ -153,21 +153,17 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() -> }; for (expected, input) in [ (prompt, b"\x1b".as_slice()), - ("create task", b"\x1b"), - ("Launch-folder task", b"\x1b[B\x0e"), - ("create task", b"new task"), - ("› new task", b"\r"), + ("n new", b"\x1b"), + ("Launch-folder task", b"\x1b[Bn"), (prompt, b"\x1b"), - ("› new task", b"\r"), + ("n new", b"n"), ("You are in", b"\x1b"), - ("› new task", b"\x1b"), - ("ctrl+o resume", b"\x0f"), + ("o resume", b"o"), ("Resume a previous session", b"\x1b[C"), ("Untrusted saved task", b"\r"), ("Open existing task", b"\r"), ("moved-folder", b"\x1b"), - ("ctrl+o resume", b"\x0e"), - ("› new task", b"\r"), + ("o resume", b"n"), (prompt, b"\r"), ] { let is_consent = expected == prompt