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
This commit is contained in:
Eric Traut authored and copyberry committed 2026-09-13 16:14:41 +00:00
1 parent 16537b20a5
commit 516f2780fd
67 files changed
+1145 -1744

No files matched your search

+1 -1
View File
@@ -437,7 +437,7 @@ pub struct TuiAgentsKeymap {
pub resume: Option<KeybindingsSpec>, pub resume: Option<KeybindingsSpec>,
/// Search the available agent tasks. /// Search the available agent tasks.
pub search: Option<KeybindingsSpec>, pub search: Option<KeybindingsSpec>,
/// Start composing a new agent task. /// Open a new session in the selected checkout.
pub new_task: Option<KeybindingsSpec>, pub new_task: Option<KeybindingsSpec>,
/// Rename the selected task. /// Rename the selected task.
pub rename: Option<KeybindingsSpec>, pub rename: Option<KeybindingsSpec>,
+1 -1
View File
@@ -4429,7 +4429,7 @@
"$ref": "#/definitions/KeybindingsSpec" "$ref": "#/definitions/KeybindingsSpec"
} }
], ],
"description": "Start composing a new agent task." "description": "Open a new session in the selected checkout."
}, },
"rename": { "rename": {
"allOf": [ "allOf": [
+129 -228
View File
@@ -1,13 +1,12 @@
//! Daemon-wide overview of recent and locally retained sessions and their subagents. //! 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. //! Tasks owned by another app server open as frozen, read-only history snapshots.
#[path = "agents_overview_composer.rs"] #[path = "agents_overview_new.rs"]
mod composer; mod new;
#[path = "agents_overview_errors.rs"] #[path = "agents_overview_errors.rs"]
mod errors; mod errors;
use super::agents_overview_view::AgentsOverviewFocus;
use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewGroup;
use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewRow;
use super::agents_overview_view::AgentsOverviewView; use super::agents_overview_view::AgentsOverviewView;
@@ -18,18 +17,10 @@ use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
use crate::chatwidget::ThreadInputStateRestoreMode; 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::SessionSource;
use codex_app_server_protocol::Thread; use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadHistoryMode; 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::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput as CoreUserInput;
pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview"; 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) usage_disabled: bool,
pub(super) activity: HashMap<ThreadId, super::agents_overview_details::AgentsOverviewActivity>, pub(super) activity: HashMap<ThreadId, super::agents_overview_details::AgentsOverviewActivity>,
pub(super) initialized: bool, pub(super) initialized: bool,
pub(super) unsent_prompt: Option<String>,
pub(super) request_id: Option<Uuid>, pub(super) request_id: Option<Uuid>,
pub(super) refresh_pending: bool, pub(super) refresh_pending: bool,
pub(super) refresh_thread_ids: HashSet<ThreadId>, pub(super) refresh_thread_ids: HashSet<ThreadId>,
@@ -55,6 +45,10 @@ pub(super) struct AgentsOverviewState {
pub(super) visible_thread_ids: Vec<ThreadId>, pub(super) visible_thread_ids: Vec<ThreadId>,
pub(super) view_state: pub(super) view_state:
Arc<std::sync::Mutex<super::agents_overview_view::AgentsOverviewViewState>>, Arc<std::sync::Mutex<super::agents_overview_view::AgentsOverviewViewState>>,
/// Explicit permission-profile choices for new-session carryover, retained across navigation.
pub(super) selected_permission_profiles: HashMap<ThreadId, String>,
/// Keep new tasks subscribed and reusable until a first turn makes them resumable.
pub(super) blank_sessions: HashMap<ThreadId, crate::app_server_session::AppServerStartedThread>,
pub(super) input_states: HashMap<ThreadId, ThreadInputState>, pub(super) input_states: HashMap<ThreadId, ThreadInputState>,
pub(super) dispatched_requests: HashMap<ThreadId, Vec<ServerRequest>>, pub(super) dispatched_requests: HashMap<ThreadId, Vec<ServerRequest>>,
} }
@@ -68,11 +62,7 @@ impl Drop for AgentsOverviewState {
} }
impl App { impl App {
pub(super) fn open_agents_overview( pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) {
&mut self,
app_server: &AppServerSession,
focus: AgentsOverviewFocus,
) {
if matches!(self.app_server_target, AppServerTarget::Embedded) { if matches!(self.app_server_target, AppServerTarget::Embedded) {
let workload_identity_selected = codex_login::is_workload_identity_selected(); let workload_identity_selected = codex_login::is_workload_identity_selected();
self.chat_widget.show_selection_view(SelectionViewParams { self.chat_widget.show_selection_view(SelectionViewParams {
@@ -122,19 +112,6 @@ impl App {
return; 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 let threads = self
.agents_overview .agents_overview
.threads .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( AgentsOverviewView::new(
rows, rows,
selected_thread_id, selected_thread_id,
@@ -337,10 +301,21 @@ impl App {
} }
pub(super) async fn select_agents_overview_thread( pub(super) async fn select_agents_overview_thread(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<AppRunControl> {
Box::pin(self.attach_agents_overview_thread(tui, app_server, thread_id, /*started*/ None))
.await
}
async fn attach_agents_overview_thread(
&mut self, &mut self,
tui: &mut tui::Tui, tui: &mut tui::Tui,
app_server: &mut AppServerSession, app_server: &mut AppServerSession,
root_thread_id: ThreadId, root_thread_id: ThreadId,
started: Option<(Config, crate::app_server_session::AppServerStartedThread)>,
) -> color_eyre::Result<AppRunControl> { ) -> color_eyre::Result<AppRunControl> {
if self.windows_sandbox_blocks_thread_switch() { if self.windows_sandbox_blocks_thread_switch() {
return Ok(AppRunControl::Continue); return Ok(AppRunControl::Continue);
@@ -358,6 +333,13 @@ impl App {
} }
if self.primary_thread_id != Some(root_thread_id) { if self.primary_thread_id != Some(root_thread_id) {
let previous_displayed_thread_id = self.current_displayed_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 = let mut previous_thread_ids =
Vec::from_iter(self.thread_event_channels.keys().copied()); Vec::from_iter(self.thread_event_channels.keys().copied());
previous_thread_ids.extend( previous_thread_ids.extend(
@@ -418,7 +400,13 @@ impl App {
target_thread.status, target_thread.status,
codex_app_server_protocol::ThreadStatus::NotLoaded 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 { let target_session = SessionTarget {
path: target_thread.path.clone(), path: target_thread.path.clone(),
thread_id: root_thread_id, thread_id: root_thread_id,
@@ -452,7 +440,7 @@ impl App {
} }
} }
}; };
if !unloaded { if !unloaded && started.is_none() {
if let Err(control) = self if let Err(control) = self
.confirm_directory_trust( .confirm_directory_trust(
tui, tui,
@@ -502,33 +490,49 @@ impl App {
crate::app_server_session::ResumeModelSettings::PreserveExistingThread crate::app_server_session::ResumeModelSettings::PreserveExistingThread
} }
}; };
let (resumed, read_only) = match app_server let (resumed, read_only) = if let Some((_, started)) = started {
.resume_thread( (started, false)
&local_settings, } else if !unloaded
resume_config.clone(), && let Some(blank) = self.agents_overview.blank_sessions.get(&root_thread_id)
root_thread_id,
resume_model_settings,
)
.await
{ {
Ok(resumed) => (resumed, false), // An untouched task has no rollout for thread/resume yet. Its live
Err(error) if crate::app_server_session::is_active_writer_error(&error) => { // subscription and saved settings are sufficient to restore the editor.
match app_server (blank.clone(), false)
.read_thread_for_viewing(&resume_config, &local_settings, root_thread_id) } else {
.await match app_server
{ .resume_thread(
Ok(thread) => (thread, true), &local_settings,
Err(error) => { resume_config.clone(),
self.add_agents_overview_error(format!( root_thread_id,
"Failed to view task open elsewhere: {error}" resume_model_settings,
)); )
return Ok(AppRunControl::Continue); .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) => {
Err(error) => { self.add_agents_overview_error(format!(
self.add_agents_overview_error(format!("Failed to attach to task: {error}")); "Failed to attach to task: {error}"
return Ok(AppRunControl::Continue); ));
return Ok(AppRunControl::Continue);
}
} }
}; };
if !previous_running_thread_ids.is_empty() { 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; 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 =
self.runtime_approval_policy_override.filter(|policy| { 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 self.runtime_permission_profile_override = self
.runtime_permission_profile_override .runtime_permission_profile_override
.take() .take()
.filter(|profile| { .filter(|profile| {
unloaded preserve_explicit_permissions
&& profile.turn_override && profile.turn_override
== RuntimePermissionProfileTurnOverride::LegacySandbox == RuntimePermissionProfileTurnOverride::LegacySandbox
}); });
@@ -637,6 +645,7 @@ impl App {
if previous_running_thread_ids.is_empty() if previous_running_thread_ids.is_empty()
&& thread_id != root_thread_id && thread_id != root_thread_id
&& Some(thread_id) != previous_displayed_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 && let Err(error) = app_server.thread_unsubscribe(thread_id).await
{ {
tracing::warn!(%thread_id, %error, "failed to unsubscribe previous agent thread"); 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, &mut self,
tui: &mut tui::Tui, tui: &mut tui::Tui,
app_server: &mut AppServerSession, app_server: &mut AppServerSession,
prompt: UserMessage,
cwd: Option<AbsolutePathBuf>, cwd: Option<AbsolutePathBuf>,
) { ) -> Option<(Config, Option<PathBuf>)> {
self.refresh_in_memory_config_from_disk_best_effort("starting a background task") if self
.await; .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 let remote_cwd = cwd
.as_ref() .as_ref()
.filter(|_| app_server.uses_remote_workspace()) .filter(|_| remote)
.map(AbsolutePathBuf::to_path_buf); .map(AbsolutePathBuf::to_path_buf);
let mut config = match cwd { let preserve_service_tier = remote || cwd.is_none();
Some(cwd) if app_server.uses_remote_workspace() => { let local_cwd = if remote {
let mut config = self.fresh_session_config(); self.config.cwd.to_path_buf()
config.cwd = cwd; } else {
config cwd.map_or_else(
} || self.chat_widget.config_ref().cwd.to_path_buf(),
Some(cwd) => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { |cwd| cwd.to_path_buf(),
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(),
}; };
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;
}
};
if preserve_service_tier {
config.service_tier = self.chat_widget.configured_service_tier();
}
let trust_cwd = config.cwd.to_path_buf(); let trust_cwd = config.cwd.to_path_buf();
if self if self
.confirm_directory_trust( .confirm_directory_trust(
@@ -744,21 +763,26 @@ impl App {
.await .await
.is_err() .is_err()
{ {
self.restore_agents_overview_prompt(prompt); return None;
return;
} }
if let Some(profile) = self.runtime_permission_profile_override.as_ref() 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) && (!profile.matches_config(&config)
|| config.permissions.profile_workspace_roots() || config.permissions.profile_workspace_roots()
!= self.config.permissions.profile_workspace_roots()) != self.config.permissions.profile_workspace_roots())
{ {
self.restore_agents_overview_prompt(prompt); self.add_agents_overview_error(
return self.add_agents_overview_error(
"Permission profile has different settings.".to_string(), "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() { let defaults_cwd = match app_server.thread_params_mode() {
crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(), crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(),
crate::app_server_session::ThreadParamsMode::Remote => remote_cwd crate::app_server_session::ThreadParamsMode::Remote => remote_cwd
@@ -805,10 +829,10 @@ impl App {
} }
Ok(None) => {} Ok(None) => {}
Err(error) => { Err(error) => {
self.restore_agents_overview_prompt(prompt); self.add_agents_overview_error(format!(
return self.add_agents_overview_error(format!( "Failed to load new session settings: {error}"
"Failed to load background task settings: {error}"
)); ));
return None;
} }
} }
apply_managed_new_thread_defaults( apply_managed_new_thread_defaults(
@@ -831,130 +855,7 @@ impl App {
.or_else(|| self.model_catalog.models.first()) .or_else(|| self.model_catalog.models.first())
.map(|model| model.model.clone()); .map(|model| model.model.clone());
} }
let model = config.model.as_deref().or_else(|| { Some((config, remote_cwd))
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::<std::io::Result<Vec<_>>>();
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<UserInput>,
) {
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::<TurnStartResponse>(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}"));
}
} }
pub(super) async fn stop_agents_overview_thread( pub(super) async fn stop_agents_overview_thread(
@@ -3,7 +3,6 @@
//! Removing the current root leaves an unattached dashboard, even when it is empty. //! Removing the current root leaves an unattached dashboard, even when it is empty.
use super::App; use super::App;
use super::agents_overview_view::AgentsOverviewFocus;
use crate::app_event::AgentsOverviewAction; use crate::app_event::AgentsOverviewAction;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerSession;
@@ -284,7 +283,7 @@ impl App {
/*initial_user_message*/ None, /*initial_user_message*/ None,
); );
self.replace_chat_widget(ChatWidget::new_with_app_event(init)); 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 { } else {
self.repaint_agents_overview(); self.repaint_agents_overview();
if attempted { if attempted {
@@ -70,11 +70,6 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
active_flags: Vec::new(), active_flags: Vec::new(),
}, },
] { ] {
app.agents_overview
.view_state
.lock()
.unwrap()
.focus_composer();
let target = ThreadId::new(); let target = ThreadId::new();
let mut view = app.agents_overview_view( let mut view = app.agents_overview_view(
vec![ vec![
@@ -89,7 +84,7 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
Some(target), Some(target),
); );
view.handle_key_event(KeyCode::Esc.into()); 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() { for character in "Target".chars() {
view.handle_key_event(KeyCode::Char(character).into()); 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.show_bottom_pane_view(Box::new(view));
app.chat_widget.handle_key_event(KeyCode::Esc.into()); app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget 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()) let hide = std::iter::from_fn(|| rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::HideAgentsOverviewThread { .. })) .find(|event| matches!(event, AppEvent::HideAgentsOverviewThread { .. }))
.expect("shortcut requests hiding the task"); .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), (AgentsOverviewAction::Delete, "delete_task", true),
] { ] {
let key = match action { let key = match action {
AgentsOverviewAction::Archive => { AgentsOverviewAction::Archive => KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)
}
AgentsOverviewAction::Delete => KeyCode::Delete.into(), AgentsOverviewAction::Delete => KeyCode::Delete.into(),
}; };
let (mut app, mut rx, _op_rx) = 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()?; let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events(); tui.pause_events();
app.open_agents_overview(&app_server, AgentsOverviewFocus::List); app.open_agents_overview(&app_server);
if action == AgentsOverviewAction::Archive { if action == AgentsOverviewAction::Archive {
let rollout = app_server let rollout = app_server
.thread_read(id, /*include_turns*/ false) .thread_read(id, /*include_turns*/ false)
@@ -402,7 +395,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
.await?; .await?;
app.enqueue_primary_thread_session(resumed.session, resumed.turns) app.enqueue_primary_thread_session(resumed.session, resumed.turns)
.await?; .await?;
app.open_agents_overview(&app_server, AgentsOverviewFocus::List); app.open_agents_overview(&app_server);
} }
let background = ThreadId::from_string( let background = ThreadId::from_string(
&app_test_support::create_fake_rollout( &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); let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer); view.render(area, &mut buffer);
let delete_key = crate::key_hint::plain(KeyCode::Delete).display_label(); 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 let cells = buffer
.content() .content()
.windows(key.len()) .windows(label.len())
.find(|cells| { .find(|cells| {
cells cells
.iter() .iter()
.map(ratatui::buffer::Cell::symbol) .map(ratatui::buffer::Cell::symbol)
.collect::<String>() .collect::<String>()
== key == label
}) })
.expect("footer shortcut"); .expect("footer shortcut");
assert_eq!( assert_eq!(
cells.iter().map(|cell| cell.modifier).collect::<Vec<_>>(), cells[..key.len()]
.iter()
.map(|cell| cell.modifier)
.collect::<Vec<_>>(),
vec![ratatui::style::Modifier::BOLD | ratatui::style::Modifier::DIM; key.len()] vec![ratatui::style::Modifier::BOLD | ratatui::style::Modifier::DIM; key.len()]
); );
} }
@@ -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::<Vec<_>>()
.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);
}
}
+2 -17
View File
@@ -1,5 +1,4 @@
//! Reports command-center action failures above the retained dashboard, with access //! Reports command-center action failures above the retained dashboard.
//! to unsent drafts when restoring them would overwrite newer input.
use super::*; use super::*;
use crate::wrapping::word_wrap_lines; use crate::wrapping::word_wrap_lines;
@@ -23,30 +22,16 @@ impl Renderable for AgentsOverviewErrorHeader {
impl App { impl App {
pub(in crate::app) fn add_agents_overview_error(&mut self, message: String) { pub(in crate::app) fn add_agents_overview_error(&mut self, message: String) {
let unsent_prompt = self.agents_overview.unsent_prompt.take();
if self if self
.chat_widget .chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some() .is_some()
{ {
let mut items = vec![SelectionItem { let items = vec![SelectionItem {
name: "Return to command center".to_string(), name: "Return to command center".to_string(),
dismiss_on_select: true, dismiss_on_select: true,
..Default::default() ..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 { self.chat_widget.show_selection_view(SelectionViewParams {
header: Box::new(AgentsOverviewErrorHeader(vec![ header: Box::new(AgentsOverviewErrorHeader(vec![
Line::from("Unable to complete action".bold()), Line::from("Unable to complete action".bold()),
+11 -135
View File
@@ -1,90 +1,12 @@
//! Focus and input routing for the agent dashboard. Refreshes retain the shared //! Layout for the agent list and its optional search or rename field.
//! composer; Right opens the selected task from an empty editor with no pending input.
use super::*; 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 { impl AgentsOverviewView {
pub(super) fn handle_composer_key(&mut self, key: KeyEvent) { pub(super) fn layout_areas(&self, area: Rect) -> [Rect; 6] {
let mut state = self.state(); let metadata_height = u16::from(self.state().editing_metadata());
let offline = state.connection_notice.is_some(); let footer_height = (self.footer_lines(area.width.saturating_sub(4)).len() as u16)
let grouping = state.grouping; .min(area.height.saturating_sub(6 + metadata_height));
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] {
let header_height = self let header_height = self
.state() .state()
.server_version_notice .server_version_notice
@@ -94,63 +16,17 @@ impl AgentsOverviewView {
as u16 as u16
}) })
.unwrap_or(1) .unwrap_or(1)
.min(area.height.saturating_sub(7).max(1)); .min(
let footer_height = if self.state().composing() { area.height
0 .saturating_sub(footer_height + metadata_height + 3)
} else { .max(1),
(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
};
Layout::vertical([ Layout::vertical([
Constraint::Length(header_height), Constraint::Length(header_height),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(u16::from(!metadata)), Constraint::Length(metadata_height),
Constraint::Length(input_height),
Constraint::Length(footer_height), Constraint::Length(footer_height),
]) ])
.areas(area) .areas(area)
@@ -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<AbsolutePathBuf>,
) -> Result<AppRunControl> {
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)
}
}
+62 -42
View File
@@ -1,25 +1,67 @@
//! Layout rendering and cursor placement for the agent dashboard. //! 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 super::*;
use crossterm::cursor::SetCursorStyle;
impl AgentsOverviewView { impl AgentsOverviewView {
pub(super) fn footer_lines(&self, width: u16) -> Vec<Line<'static>> { pub(super) fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.state().connection_notice.is_some() { let state = self.state();
return vec![ let message = if let Some(items) = &state.key_chord_hint {
"ctrl+c clear input, then quit · actions paused until the list is refreshed" Some(
.dim() items
.into(), .iter()
]; .map(|(key, label)| format!("{key} {label}"))
.collect::<Vec<_>>()
.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::<Vec<_>>()
})
.collect();
} }
let list_hint = |action| { let list_hint = |action| {
self.keymap.primary_hint(action).filter(|hint| { self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding) !matches!(hint, ShortcutHint::Single(binding)
if is_plain_text_key_event(KeyEvent::new( if [
binding.parts().0,
binding.parts().1,
)) || [
&self.agents_keymap.resume, &self.agents_keymap.resume,
&self.agents_keymap.search, &self.agents_keymap.search,
&self.agents_keymap.new_task, &self.agents_keymap.new_task,
@@ -75,15 +117,12 @@ impl AgentsOverviewView {
"resume", "resume",
true, true,
); );
let open_hint = (!self.state().editing_metadata()) let open_hint = list_hint(ListAction::Accept);
.then(|| list_hint(ListAction::MoveRight))
.flatten()
.or_else(|| list_hint(ListAction::Accept));
add_hint(open_hint, "open", true); add_hint(open_hint, "open", true);
add_hint( add_hint(
self.agents_keymap self.agents_keymap
.primary_hint("new_task", &self.agents_keymap.new_task), .primary_hint("new_task", &self.agents_keymap.new_task),
"new task", "new",
true, true,
); );
add_hint( add_hint(
@@ -126,7 +165,10 @@ impl AgentsOverviewView {
self.selected_row().is_some(), 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::<usize>() let separator = if hints.iter().map(Line::width).sum::<usize>()
+ hints.len().saturating_sub(1) * 2 + hints.len().saturating_sub(1) * 2
<= usize::from(width) <= usize::from(width)
@@ -157,26 +199,12 @@ impl Renderable for AgentsOverviewView {
24 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)> { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.width < 12 || area.height < 8 { if area.width < 12 || area.height < 8 {
return None; return None;
} }
let [_, _, _, _, _, prompt, _] = self.layout_areas(area); let [_, _, _, _, prompt, _] = self.layout_areas(area);
let state = self.state(); let state = self.state();
if state.composing() {
return state.composer.as_ref()?.cursor_pos(prompt);
}
if !state.editing_metadata() { if !state.editing_metadata() {
return None; return None;
} }
@@ -197,7 +225,7 @@ impl Renderable for AgentsOverviewView {
return; return;
} }
Clear.render(area, buf); 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 = let inset =
|rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0)); |rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
if let Some(notice) = &self.state().server_version_notice { if let Some(notice) = &self.state().server_version_notice {
@@ -276,14 +304,6 @@ impl Renderable for AgentsOverviewView {
if state.editing_metadata() { if state.editing_metadata() {
Line::from(vec![label.cyan().bold(), input[visible_start..].into()]) Line::from(vec![label.cyan().bold(), input[visible_start..].into()])
.render(inset(prompt), buf); .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); drop(state);
Paragraph::new(self.footer_lines(inset(footer).width)).render(inset(footer), buf); Paragraph::new(self.footer_lines(inset(footer).width)).render(inset(footer), buf);
+162 -496
View File
@@ -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")); 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 view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
let area = ratatui::layout::Rect::new( 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); let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer); view.render(area, &mut buffer);
@@ -95,11 +95,9 @@ async fn older_server_notice_falls_back_in_short_overview() {
.collect::<String>(); .collect::<String>();
insta::assert_snapshot!(header.trim_end(), @" Old srv"); 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::test_support::make_test_app;
use crate::app_event::AgentsOverviewThreadRefresh; use crate::app_event::AgentsOverviewThreadRefresh;
use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::test_support::PathBufExt; 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_client::AppServerEvent;
use codex_app_server_protocol::CurrentTimeReadParams; use codex_app_server_protocol::CurrentTimeReadParams;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadActiveFlag; use codex_app_server_protocol::ThreadActiveFlag;
@@ -128,7 +127,6 @@ use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap; use codex_config::types::TuiKeymap;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::SubAgentSource;
use crossterm::cursor::SetCursorStyle;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
@@ -137,72 +135,6 @@ use ratatui::layout::Rect;
static OVERVIEW_TIMESTAMP: std::sync::LazyLock<i64> = static OVERVIEW_TIMESTAMP: std::sync::LazyLock<i64> =
std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120); 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] #[tokio::test]
async fn overview_right_opens_current_or_highlighted_task() { async fn overview_right_opens_current_or_highlighted_task() {
let mut app = make_test_app().await; let mut app = make_test_app().await;
@@ -271,12 +203,8 @@ async fn overview_right_preserves_editors_and_offline_state() {
)], )],
Some(thread_id), 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(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()); view.handle_key_event(KeyCode::Right.into());
assert!(app.agents_overview.view_state.lock().unwrap().renaming); assert!(app.agents_overview.view_state.lock().unwrap().renaming);
view.handle_key_event(KeyCode::Esc.into()); view.handle_key_event(KeyCode::Esc.into());
@@ -286,284 +214,13 @@ async fn overview_right_preserves_editors_and_offline_state() {
.unwrap() .unwrap()
.connection_notice = Some("Offline"); .connection_notice = Some("Offline");
view.handle_key_event(KeyCode::Right.into()); 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.on_ctrl_c();
view.handle_key_event(KeyCode::Right.into()); view.handle_key_event(KeyCode::Right.into());
assert!(!view.is_complete()); assert!(!view.is_complete());
assert!(rx.try_recv().is_err()); 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( fn overview_thread(
thread_id: ThreadId, thread_id: ThreadId,
parent_thread_id: Option<ThreadId>, parent_thread_id: Option<ThreadId>,
@@ -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; let created = app_server.start_thread(&config).await?.session.thread_id;
// Closing the view must not cancel a metadata refresh or forget unloaded entries. // Closing the view must not cancel a metadata refresh or forget unloaded entries.
app.primary_thread_id = Some(ids[0]); 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 let visible: HashSet<_> = app
.agents_overview .agents_overview
.visible_thread_ids .visible_thread_ids
@@ -882,7 +539,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res
.await?; .await?;
app.enqueue_primary_thread_session(resumed.session, resumed.turns) app.enqueue_primary_thread_session(resumed.session, resumed.turns)
.await?; .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; finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
expected.insert(ids[1]); expected.insert(ids[1]);
let visible: HashSet<_> = app 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())); .insert(thread_id, Some(thread.clone()));
app.thread_event_channels app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 8)); .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 8));
let mut view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id));
view.handle_paste("Keep this draft".into());
app.agents_overview.visible_thread_ids = view.thread_ids(); app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view)); app.chat_widget.show_bottom_pane_view(Box::new(view));
for delta in ["**Checking", " cold-start regressions**\nFurther reasoning"] { 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)); let mut view = app.agents_overview_view(threads, Some(selected));
view.handle_key_event(KeyCode::Esc.into()); view.handle_key_event(KeyCode::Esc.into());
for _ in 0..2 { 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!( assert_eq!(
app.agents_overview.view_state.lock().unwrap().grouping, app.agents_overview.view_state.lock().unwrap().grouping,
@@ -1529,82 +1185,12 @@ async fn shared_overview_shows_only_root_sessions() {
state.lock().unwrap().grouping, state.lock().unwrap().grouping,
AgentsOverviewGrouping::Project AgentsOverviewGrouping::Project
); );
action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); action_view.handle_key_event(KeyCode::Char('f').into());
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); assert!(action_view.handle_paste("Repair authentication".into()));
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::Enter.into()); action_view.handle_key_event(KeyCode::Enter.into());
assert!(matches!( assert!(
event_rx.try_recv(), matches!(event_rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second_root)
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,
);
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::<String>();
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( crate::chatwidget::tests::helpers::set_active_cell(
&mut app.chat_widget, &mut app.chat_widget,
Box::new(crate::history_cell::PlainHistoryCell::new(vec![ 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 .await
.expect("embedded app server"); .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::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!( 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.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string())));
keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string()))); keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string())));
keymap.agents.resume = Some(KeybindingsSpec::One(KeybindingSpec("f8".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()))); keymap.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("v".into())));
app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
let first = ThreadId::new(); 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)); view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
assert!(event_rx.try_recv().is_err()); 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(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
view.handle_key_event(KeyCode::Esc.into()); view.handle_key_event(KeyCode::Esc.into());
for (key, expected) in [('v', second), ('k', first), ('j', first)] { 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()]; let selected = &view.rows[view.selected_index().unwrap()];
assert_eq!(selected.thread_id, expected); 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)); view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
assert!(view.handle_paste("Second task".to_string())); assert!(view.handle_paste("Second task".to_string()));
view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); 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 Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second
)); ));
assert!(event_rx.try_recv().is_err()); 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] #[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)); app.chat_widget.show_bottom_pane_view(Box::new(view));
for key in [ for key in [
KeyCode::Esc.into(), KeyCode::Esc.into(),
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
KeyCode::Esc.into(),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE),
KeyCode::Esc.into(),
] { ] {
app.chat_widget.handle_key_event(key); 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); .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID);
while event_rx.try_recv().is_ok() {} while event_rx.try_recv().is_ok() {}
app.chat_widget 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!( assert!(matches!(
event_rx.try_recv(), event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker) 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), .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
selection selection
); );
assert_eq!(overview_draft(&app).0, "d");
} }
Ok(()) Ok(())
} }
@@ -2375,9 +1971,9 @@ async fn command_center_cursor_tracks_wrapped_footer() {
)], )],
/*selected_thread_id*/ None, /*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(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] { for width in [48, 96, 120] {
let area = let area =
ratatui::layout::Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 24); 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()); app.chat_widget.handle_key_event(KeyCode::Esc.into());
while event_rx.try_recv().is_ok() {} while event_rx.try_recv().is_ok() {}
app.chat_widget 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!( assert!(matches!(
event_rx.try_recv(), event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker) 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(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx); app.app_event_tx = AppEventSender::new(tx);
let mut view = app.agents_overview_view(vec![thread], Some(thread_id)); 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()); view.handle_key_event(KeyCode::Esc.into());
app.chat_widget.show_bottom_pane_view(Box::new(view)); app.chat_widget.show_bottom_pane_view(Box::new(view));
let draft = overview_draft(&app);
let selection = app let selection = app
.chat_widget .chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID); .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()) app.handle_key_event(&mut tui, &mut server, KeyCode::Esc.into())
.await; .await;
assert_eq!(overview_draft(&app), draft);
assert_eq!( assert_eq!(
app.chat_widget app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
selection 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. // Opening the displayed task returns to the same frozen snapshot without retrying.
Box::pin(app.handle_event( Box::pin(app.handle_event(
&mut tui, &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 mut app = make_test_app().await;
let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).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); 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)); app.chat_widget.show_bottom_pane_view(Box::new(view));
let before = render_bottom_popup(&app.chat_widget, /*width*/ 48); let before = render_bottom_popup(&app.chat_widget, /*width*/ 48);
for result in [ for result in [
@@ -2884,59 +2478,14 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res
Ok(()) 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] #[tokio::test]
async fn command_center_action_failures_remain_visible() -> Result<()> { async fn command_center_action_failures_remain_visible() -> Result<()> {
let mut app = Box::pin(make_test_app()).await; 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 server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?; let mut tui = crate::tui::test_support::make_test_tui()?;
let thread_id = ThreadId::new(); let thread_id = ThreadId::new();
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_paste("Keep this draft".into());
app.chat_widget.show_bottom_pane_view(Box::new(view)); app.chat_widget.show_bottom_pane_view(Box::new(view));
let draft = overview_draft(&app);
for (event, expected) in [ for (event, expected) in [
( (
AppEvent::SelectAgentsOverviewThread { thread_id }, AppEvent::SelectAgentsOverviewThread { thread_id },
@@ -2953,10 +2502,6 @@ async fn command_center_action_failures_remain_visible() -> Result<()> {
AppEvent::StopAgentsOverviewThread { thread_id }, AppEvent::StopAgentsOverviewThread { thread_id },
"Failed to stop background task", "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?; Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
@@ -2966,7 +2511,6 @@ async fn command_center_action_failures_remain_visible() -> Result<()> {
assert!( assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Agent command center") 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. // The retained primary task can also fail in the nested agent-attachment path.
app.primary_thread_id = Some(thread_id); app.primary_thread_id = Some(thread_id);
@@ -2998,3 +2542,125 @@ fn trust_fixture_folders(app: &mut App) {
#[path = "agents_overview_usage_tests.rs"] #[path = "agents_overview_usage_tests.rs"]
mod usage; 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 })
));
}
@@ -41,6 +41,15 @@ impl App {
else { else {
return; 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); self.track_agents_overview_activity(thread_id, notification);
let thread = self let thread = self
.agents_overview .agents_overview
@@ -67,6 +76,9 @@ impl App {
self.agents_overview.threads.insert(thread_id, Some(thread)); self.agents_overview.threads.insert(thread_id, Some(thread));
} }
ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => { ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => {
self.agents_overview
.selected_permission_profiles
.remove(&thread_id);
self.agents_overview.activity.remove(&thread_id); self.agents_overview.activity.remove(&thread_id);
self.agents_overview.last_messages.remove(&thread_id); self.agents_overview.last_messages.remove(&thread_id);
self.agents_overview.usage.remove(&thread_id); self.agents_overview.usage.remove(&thread_id);
@@ -101,6 +113,21 @@ impl App {
} }
} }
ServerNotification::ThreadSettingsUpdated(settings) => { 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 { if let Some(thread) = thread {
thread.cwd.clone_from(&settings.thread_settings.cwd); thread.cwd.clone_from(&settings.thread_settings.cwd);
thread.model = Some(settings.thread_settings.model.clone()); thread.model = Some(settings.thread_settings.model.clone());
+23 -132
View File
@@ -1,5 +1,5 @@
//! Dashboard for inspecting and managing the TUI's retained daemon tasks. //! 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"] #[path = "agents_overview_grouping.rs"]
mod grouping; mod grouping;
@@ -18,7 +18,6 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::ViewCompletion; use crate::bottom_pane::ViewCompletion;
use crate::key_hint::KeyBindingListExt; use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint; use crate::key_hint::ShortcutHint;
@@ -133,11 +132,8 @@ impl AgentsOverviewProjectGroup {
#[derive(Default)] #[derive(Default)]
pub(super) struct AgentsOverviewViewState { pub(super) struct AgentsOverviewViewState {
// Search and rename never borrow the new-task draft.
pub(super) input: String, pub(super) input: String,
pub(super) composer: Option<ChatComposer>,
pub(super) key_chord_hint: Option<Vec<(String, String)>>, pub(super) key_chord_hint: Option<Vec<(String, String)>>,
pub(super) focus: AgentsOverviewFocus,
pub(super) refresh_failed: bool, pub(super) refresh_failed: bool,
pub(super) connection_notice: Option<&'static str>, pub(super) connection_notice: Option<&'static str>,
pub(super) server_version_notice: Option<String>, pub(super) server_version_notice: Option<String>,
@@ -149,38 +145,10 @@ pub(super) struct AgentsOverviewViewState {
pub(super) completion: Option<ViewCompletion>, pub(super) completion: Option<ViewCompletion>,
} }
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub(super) enum AgentsOverviewFocus {
#[default]
Composer,
List,
}
impl AgentsOverviewViewState { impl AgentsOverviewViewState {
pub(super) fn focus_composer(&mut self) { pub(super) fn editing_metadata(&self) -> bool {
self.focus = AgentsOverviewFocus::Composer;
if let Some(composer) = self.composer.as_mut() {
composer.resume_text_entry();
}
}
fn editing_metadata(&self) -> bool {
self.searching || self.renaming 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 { pub(super) struct AgentsOverviewView {
@@ -192,8 +160,6 @@ pub(super) struct AgentsOverviewView {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
keymap: ListKeymap, keymap: ListKeymap,
agents_keymap: AgentsKeymap, agents_keymap: AgentsKeymap,
composer_hints: Vec<(String, String)>,
composer_keymap: crate::keymap::ComposerKeymap,
} }
impl AgentsOverviewView { impl AgentsOverviewView {
@@ -210,18 +176,6 @@ impl AgentsOverviewView {
.and_then(|thread_id| rows.iter().position(|row| row.thread_id == thread_id)) .and_then(|thread_id| rows.iter().position(|row| row.thread_id == thread_id))
.or_else(|| rows.iter().position(|row| row.is_current)) .or_else(|| rows.iter().position(|row| row.is_current))
.unwrap_or(0); .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 let project_groups = rows
.iter() .iter()
.map(|row| AgentsOverviewProjectGroup::for_thread(&row.thread, worktrees_enabled)) .map(|row| AgentsOverviewProjectGroup::for_thread(&row.thread, worktrees_enabled))
@@ -235,8 +189,6 @@ impl AgentsOverviewView {
app_event_tx, app_event_tx,
keymap: keymap.list, keymap: keymap.list,
agents_keymap: keymap.agents, agents_keymap: keymap.agents,
composer_hints,
composer_keymap: keymap.composer,
}; };
view.state().completion = None; view.state().completion = None;
let visible = view.visible_indices(); let visible = view.visible_indices();
@@ -543,10 +495,6 @@ impl AgentsOverviewView {
} }
impl BottomPaneView for AgentsOverviewView { impl BottomPaneView for AgentsOverviewView {
fn next_frame_delay(&self) -> Option<std::time::Duration> {
self.state().composer.as_ref()?.footer_flash_delay()
}
fn view_id(&self) -> Option<&'static str> { fn view_id(&self) -> Option<&'static str> {
Some(AGENTS_OVERVIEW_VIEW_ID) Some(AGENTS_OVERVIEW_VIEW_ID)
} }
@@ -556,15 +504,7 @@ impl BottomPaneView for AgentsOverviewView {
} }
fn keymap_contexts(&self) -> KeymapContextSet { fn keymap_contexts(&self) -> KeymapContextSet {
let state = self.state(); KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
if state.composing() {
state
.composer
.as_ref()
.map_or_else(KeymapContextSet::default, ChatComposer::keymap_contexts)
} else {
KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
}
} }
fn completion(&self) -> Option<ViewCompletion> { fn completion(&self) -> Option<ViewCompletion> {
@@ -586,13 +526,14 @@ impl BottomPaneView for AgentsOverviewView {
state.renaming = false; state.renaming = false;
state.search.clear(); state.search.clear();
state.input.clear(); state.input.clear();
return CancellationEvent::Handled; drop(state);
} if self.selected >= self.rows.len() {
if let Some(composer) = state.composer.as_mut() self.selected = self
&& (composer.cancel_vim_search() .visible_indices()
|| composer.cancel_history_search() .first()
|| composer.clear_for_ctrl_c().is_some()) .copied()
{ .unwrap_or(usize::MAX);
}
return CancellationEvent::Handled; return CancellationEvent::Handled;
} }
CancellationEvent::NotHandled CancellationEvent::NotHandled
@@ -604,39 +545,19 @@ impl BottomPaneView for AgentsOverviewView {
input.push_str(&crate::history_cell::sanitize_user_text(pasted.into())) input.push_str(&crate::history_cell::sanitize_user_text(pasted.into()))
}); });
} }
let mut state = self.state(); false
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)
} }
fn handle_key_event(&mut self, key: KeyEvent) { fn handle_key_event(&mut self, key: KeyEvent) {
if key.kind == crossterm::event::KeyEventKind::Release { if key.kind == crossterm::event::KeyEventKind::Release {
return; return;
} }
if self.state().composing() { if key.code == KeyCode::Esc {
self.handle_composer_key(key); self.on_ctrl_c();
return; return;
} }
if key.code == KeyCode::Backspace if key.code == KeyCode::Backspace
&& self.state().editing_metadata()
&& key.modifiers.is_empty() && key.modifiers.is_empty()
&& self.keymap.action_for(key).is_none() && self.keymap.action_for(key).is_none()
{ {
@@ -647,29 +568,13 @@ impl BottomPaneView for AgentsOverviewView {
} }
if is_plain_text_key_event(key) if is_plain_text_key_event(key)
&& let KeyCode::Char(character) = key.code && let KeyCode::Char(character) = key.code
&& self.state().editing_metadata()
{ {
if self.state().editing_metadata() { self.edit_input(|input| input.push(character));
self.edit_input(|input| input.push(character)); return;
return;
}
if !self
.state()
.composer
.as_ref()
.is_some_and(ChatComposer::is_vim_enabled)
{
self.state().focus_composer();
self.handle_composer_key(key);
return;
}
} }
if self.agents_keymap.search.is_pressed(key) || { 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)
} {
let mut state = self.state(); let mut state = self.state();
if !state.renaming { if !state.renaming {
state.searching = !state.searching; state.searching = !state.searching;
@@ -681,7 +586,6 @@ impl BottomPaneView for AgentsOverviewView {
} }
if self.state().connection_notice.is_some() if self.state().connection_notice.is_some()
&& !self.agents_keymap.new_task.is_pressed(key)
&& self.keymap.action_for(key) != Some(ListAction::Cancel) && self.keymap.action_for(key) != Some(ListAction::Cancel)
{ {
match self.keymap.action_for(key) { match self.keymap.action_for(key) {
@@ -706,12 +610,9 @@ impl BottomPaneView for AgentsOverviewView {
return; return;
} }
if self.agents_keymap.new_task.is_pressed(key) { if self.agents_keymap.new_task.is_pressed(key) {
let mut state = self.state(); self.app_event_tx.send(AppEvent::NewAgentsOverviewSession {
state.search.clear(); cwd: self.selected_row().map(|row| row.thread.cwd.clone()),
state.searching = false; });
state.renaming = false;
state.input.clear();
state.focus_composer();
return; return;
} }
if self.agents_keymap.rename.is_pressed(key) { if self.agents_keymap.rename.is_pressed(key) {
@@ -777,17 +678,7 @@ impl BottomPaneView for AgentsOverviewView {
} }
ListAction::Accept => self.activate(), ListAction::Accept => self.activate(),
ListAction::Cancel => { ListAction::Cancel => {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); self.on_ctrl_c();
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();
}
} }
ListAction::PageUp | ListAction::PageDown => { ListAction::PageUp | ListAction::PageDown => {
for _ in 0..5 { for _ in 0..5 {
@@ -212,6 +212,11 @@ impl App {
self.chat_widget.set_approvals_reviewer(reviewer); self.chat_widget.set_approvals_reviewer(reviewer);
} }
self.chat_widget.set_permission_network(network); 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 = self.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&self.config)); Some(RuntimePermissionProfileOverride::from_config(&self.config));
self.sync_active_thread_permission_settings_to_cached_session() self.sync_active_thread_permission_settings_to_cached_session()
@@ -290,6 +295,9 @@ impl App {
.approvals_reviewer .approvals_reviewer
.is_none_or(|reviewer| config.approvals_reviewer == reviewer) .is_none_or(|reviewer| config.approvals_reviewer == reviewer)
{ {
self.agents_overview
.selected_permission_profiles
.insert(thread_id, selection.profile_id);
return; return;
} }
let params = ThreadSettingsUpdateParams { let params = ThreadSettingsUpdateParams {
@@ -301,6 +309,9 @@ impl App {
}; };
match app_server.thread_settings_update(params).await { match app_server.thread_settings_update(params).await {
Ok(true) => { Ok(true) => {
self.agents_overview
.selected_permission_profiles
.insert(thread_id, selection.profile_id.clone());
self.pending_server_profiles self.pending_server_profiles
.insert(thread_id, selection.clone()); .insert(thread_id, selection.clone());
self.chat_widget.add_info_message( self.chat_widget.add_info_message(
+6 -22
View File
@@ -3,7 +3,6 @@
//! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain //! 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. //! 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::RateLimitReadStatus;
use super::rate_limit_refresh::RateLimitRefreshOutcome; use super::rate_limit_refresh::RateLimitRefreshOutcome;
use super::resize_reflow::trailing_run_start; use super::resize_reflow::trailing_run_start;
@@ -36,8 +35,6 @@ impl App {
&event, &event,
AppEvent::InsertHistoryCell(_) AppEvent::InsertHistoryCell(_)
| AppEvent::CommitRealtimeTranscriptHistory | AppEvent::CommitRealtimeTranscriptHistory
| AppEvent::AgentsOverviewError(_)
| AppEvent::ViewAgentsOverviewUnsentPrompt(_)
| AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ResetTranscriptForThreadSwitch
| AppEvent::ManagedWorktreeCreated(_) | AppEvent::ManagedWorktreeCreated(_)
| AppEvent::AppendMessageHistoryEntry { .. } | AppEvent::AppendMessageHistoryEntry { .. }
@@ -2428,19 +2425,7 @@ impl App {
} }
} }
AppEvent::OpenAgentsOverview => { AppEvent::OpenAgentsOverview => {
self.open_agents_overview(app_server, AgentsOverviewFocus::List); self.open_agents_overview(app_server);
}
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();
} }
AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => {
self.apply_agents_overview_thread_refresh(app_server, request_id, result); self.apply_agents_overview_thread_refresh(app_server, request_id, result);
@@ -2453,15 +2438,14 @@ impl App {
AppRunControl::Continue AppRunControl::Continue
if self.primary_thread_id.is_none() if self.primary_thread_id.is_none()
&& self.chat_widget.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_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::Continue => {}
AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)),
} }
} }
AppEvent::DispatchAgentsOverviewTask { prompt, cwd } => { AppEvent::NewAgentsOverviewSession { cwd } => {
self.dispatch_agents_overview_task(tui, app_server, prompt, cwd) return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await;
.await;
} }
AppEvent::RenameAgentsOverviewThread { thread_id, name } => { AppEvent::RenameAgentsOverviewThread { thread_id, name } => {
match app_server.thread_set_name(thread_id, name.clone()).await { match app_server.thread_set_name(thread_id, name.clone()).await {
@@ -3253,7 +3237,7 @@ impl App {
/*initial_user_message*/ None, /*initial_user_message*/ None,
); );
self.replace_chat_widget(ChatWidget::new_with_app_event(init)); 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 AppRunControl::Continue
} }
Err(err) => { Err(err) => {
@@ -3313,7 +3297,7 @@ impl App {
/*initial_user_message*/ None, /*initial_user_message*/ None,
); );
self.replace_chat_widget(ChatWidget::new_with_app_event(init)); 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 AppRunControl::Continue
} }
Err(err) => { Err(err) => {
+14 -3
View File
@@ -3,7 +3,6 @@
//! This module owns global key bindings that sit above ChatWidget, including transcript overlay //! This module owns global key bindings that sit above ChatWidget, including transcript overlay
//! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts. //! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts.
use super::agents_overview_view::AgentsOverviewFocus;
use super::*; use super::*;
use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE; use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE;
use crate::keymap::bindings_for_action; use crate::keymap::bindings_for_action;
@@ -57,6 +56,18 @@ impl App {
) -> Option<KeyEvent> { ) -> Option<KeyEvent> {
let contexts = self.active_keymap_contexts(); let contexts = self.active_keymap_contexts();
let was_pending = self.key_chord_matcher.is_pending(); 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( match self.key_chord_matcher.advance(
key_event, key_event,
&self.keymap.chords, &self.keymap.chords,
@@ -256,7 +267,7 @@ impl App {
&& modifiers == KeyModifiers::NONE && modifiers == KeyModifiers::NONE
&& !matches!(self.app_server_target, AppServerTarget::Embedded) && !matches!(self.app_server_target, AppServerTarget::Embedded)
{ {
self.open_agents_overview(app_server, AgentsOverviewFocus::List); self.open_agents_overview(app_server);
return; return;
} }
let quit = match key_event.code { let quit = match key_event.code {
@@ -537,7 +548,7 @@ impl App {
} }
if self.keymap.app.open_agents.is_pressed(key_event) { 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; return true;
} }
+2 -1
View File
@@ -39,7 +39,7 @@ impl App {
if !app_server.thread_settings_update(ThreadSettingsUpdateParams { if !app_server.thread_settings_update(ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(), thread_id: thread_id.to_string(),
permissions: Some(selection.profile_id), permissions: Some(selection.profile_id.clone()),
approval_policy: selection.approval_policy, approval_policy: selection.approval_policy,
approvals_reviewer: selection.approvals_reviewer.map(Into::into), approvals_reviewer: selection.approvals_reviewer.map(Into::into),
..Default::default() ..Default::default()
@@ -55,6 +55,7 @@ impl App {
self.chat_widget.set_approval_policy(AskForApproval::from(config.permissions.approval_policy.value())); self.chat_widget.set_approval_policy(AskForApproval::from(config.permissions.approval_policy.value()));
self.config.permissions = config.permissions.clone(); self.config.permissions = config.permissions.clone();
self.set_approvals_reviewer_in_app_and_widget(config.approvals_reviewer); 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_approval_policy_override = selection.approval_policy.map(RuntimeApprovalPolicyOverride::Explicit);
self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&config)); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&config));
self.sync_active_thread_permission_settings_to_cached_session().await; self.sync_active_thread_permission_settings_to_cached_session().await;
+2
View File
@@ -182,6 +182,8 @@ impl App {
self.chat_widget.restore_user_message_to_composer(message); self.chat_widget.restore_user_message_to_composer(message);
} }
self.reconnect.offline = true; 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; self.reconnect.failed = false;
if self.pending_server_version_notice.take().is_some() { if self.pending_server_version_notice.take().is_some() {
self.reconnect.seen_version_notice = None; self.reconnect.seen_version_notice = None;
+1 -2
View File
@@ -2,7 +2,6 @@
//! Keeps CLI/runtime cwd precedence, remote-workspace checks, and interactive prompts aligned. //! Keeps CLI/runtime cwd precedence, remote-workspace checks, and interactive prompts aligned.
//! Carries local preferences alongside the resolved configuration for session replacement. //! Carries local preferences alongside the resolved configuration for session replacement.
use super::agents_overview_view::AgentsOverviewFocus;
use super::*; use super::*;
use crate::onboarding::onboarding_screen::check_directory_trust; use crate::onboarding::onboarding_screen::check_directory_trust;
use crate::startup_hooks_review::StartupHooksReviewOutcome; use crate::startup_hooks_review::StartupHooksReviewOutcome;
@@ -183,7 +182,7 @@ impl App {
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_none() .is_none()
{ {
self.open_agents_overview(app_server, AgentsOverviewFocus::List); self.open_agents_overview(app_server);
} }
return Err(AppRunControl::Continue); return Err(AppRunControl::Continue);
} }
@@ -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 ↑↓ navigate o resume enter open n new
f5 f7 delete esc back f search g group: project r rename x stop
f5 f8 hide f5 f6 archive f5 f7 delete
ctrl+c quit
@@ -37,8 +37,8 @@ expression: "cached.replace(&format!(\"{project} 1\"),\n&format!(\"/tmp/project
" │ " " │ "
" │ " " │ "
" │ " " │ "
" New task " " │ "
" " " │ "
"› Describe a new task " " │ "
" " " ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide "
" enter create task ctrl+j newline esc tasks → open task " " a archive del delete ctrl+c quit "
@@ -12,7 +12,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ │
│ Last message │ Last message
│ let explanation = "A long code line │ let explanation = "A long code line
│ … │ should wrap inside the task details
│ panel.";
│ │
│ Project │ Project
│ /tmp/project │ /tmp/project
@@ -21,8 +22,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Prompt │ Prompt
│ long prompt long prompt long prompt │ long prompt long prompt long prompt
│ … │ …
New task │
│
› Describe a new 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
enter create task ctrl+j newline esc tasks → open task
@@ -12,7 +12,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ │
│ Last message │ Last message
│ Check Result │ Check Result
│ … │ ━━━━━━━━ ━━━━━━━━
│ Parser Fixed
│ │
│ Project │ Project
│ /tmp/project │ /tmp/project
@@ -21,8 +22,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Prompt │ Prompt
│ long prompt long prompt long prompt │ long prompt long prompt long prompt
│ … │ …
New task │
│
› Describe a new 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
enter create task ctrl+j newline esc tasks → open task
@@ -1,6 +1,6 @@
--- ---
source: tui/src/app/agents_overview_tests.rs 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 Agent command center
0 need input 0 working 4 ready 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 ↑↓ navigate o resume enter open n new f search g group: model r rename x stop h hide a archive
ctrl+x stop ctrl+w hide ctrl+e archive del delete esc back del delete ctrl+c quit
@@ -1,6 +1,5 @@
--- ---
source: tui/src/app/agents_overview_tests.rs source: tui/src/app/agents_overview_tests.rs
assertion_line: 38
expression: "render_bottom_popup(&app.chat_widget, 12)" expression: "render_bottom_popup(&app.chat_widget, 12)"
--- ---
Service Service
@@ -22,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 12)"
New task
› Describe n new
f search
enter cre ctrl+c
quit
@@ -2,4 +2,4 @@
source: tui/src/app/agents_overview_tests.rs source: tui/src/app/agents_overview_tests.rs
expression: rendered.lines().last().unwrap() expression: rendered.lines().last().unwrap()
--- ---
enter create task ctrl+j newline esc tasks → open task a archive del delete ctrl+c quit
@@ -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
@@ -2,4 +2,4 @@
source: tui/src/app/agents_overview_tests.rs source: tui/src/app/agents_overview_tests.rs
expression: "rendered.lines().find(|line| line.contains(\"open\")).unwrap()" 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
@@ -1,6 +1,6 @@
--- ---
source: tui/src/app/agents_overview_usage_tests.rs 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 Agent command center
1 need input 0 working 1 ready 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 │ Tokens: 13K in · 4K out
│ Est. usage: 5 credits · ~$0.20 │ Est. usage: 5 credits · ~$0.20
│ │
│ Prompt
│ Review parser
│ │
New task │
↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide
› Describe a new task a archive del delete ctrl+c quit
enter create task ctrl+j newline esc tasks → open task
+1 -2
View File
@@ -3,7 +3,6 @@
//! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input //! 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. //! remains isolated from protected interactive requests until the initialized composer owns it.
use super::agents_overview_view::AgentsOverviewFocus;
use super::reconnect::ReconnectState; use super::reconnect::ReconnectState;
use super::*; use super::*;
use crate::session_start::SessionStartAction; use crate::session_start::SessionStartAction;
@@ -835,7 +834,7 @@ See the Codex keymap documentation for supported actions and examples."
); );
} }
if start_in_agents_overview { 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) { } else if !matches!(app.app_server_target, AppServerTarget::Embedded) {
app.refresh_agents_overview_threads(&app_server); app.refresh_agents_overview_threads(&app_server);
} }
@@ -36,10 +36,6 @@ async fn agents_navigation_requires_local_daemon() -> Result<()> {
assert_matches!(event, AppEvent::OpenAgentsOverview); assert_matches!(event, AppEvent::OpenAgentsOverview);
app.handle_event(&mut tui, &mut app_server, event).await?; app.handle_event(&mut tui, &mut app_server, event).await?;
assert!(!app.chat_widget.no_modal_or_popup_active()); 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 { } else {
assert!(events.try_recv().is_err()); assert!(events.try_recv().is_err());
assert!(app.chat_widget.no_modal_or_popup_active()); assert!(app.chat_widget.no_modal_or_popup_active());
@@ -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 super::*;
use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use crate::app::tests::session_lifecycle_requests::HistoryCapabilities; use crate::app::tests::session_lifecycle_requests::HistoryCapabilities;
use crate::app::tests::session_lifecycle_requests::recorded_params; use crate::app::tests::session_lifecycle_requests::recorded_params;
use crate::app::tests::session_lifecycle_requests::start_recording_app_server_with_history; 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::model_catalog::ModelCatalog;
use crate::test_support::PathBufExt; use crate::test_support::PathBufExt;
use crate::tui::test_support::make_test_tui; 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 codex_state::SqliteConfig;
use crossterm::event::KeyCode;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
fn trust_launch_folder(app: &mut App) { fn trust_launch_folder(app: &mut App) {
@@ -31,225 +24,7 @@ fn trust_launch_folder(app: &mut App) {
} }
#[tokio::test] #[tokio::test]
async fn background_task_sends_pasted_image_with_first_prompt() -> Result<()> { async fn command_center_new_reads_server_defaults_for_actual_destination() -> 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<()> {
let mut tui = make_test_tui()?; let mut tui = make_test_tui()?;
for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [ for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [
("local", false, false, "launch", "server-model"), ("local", false, false, "launch", "server-model"),
@@ -264,6 +39,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
"server-model", "server-model",
), ),
("remote", true, true, "destination", "destination-model"), ("remote", true, true, "destination", "destination-model"),
("remote", true, false, "destination", "destination-model"),
("remote", false, true, "launch", "server-model"), ("remote", false, true, "launch", "server-model"),
("remote", false, false, ".", "server-model"), ("remote", false, false, ".", "server-model"),
("remote-null-fast", false, false, ".", ""), ("remote-null-fast", false, false, ".", ""),
@@ -370,6 +146,24 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
if launch_override { if launch_override {
server = server.with_remote_cwd_override(Some(launch.path().to_path_buf())); 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 bootstrap = server.bootstrap(&app.config).await?;
let expected_model = if mode == "remote-null-fast" { let expected_model = if mode == "remote-null-fast" {
let default_model = bootstrap let default_model = bootstrap
@@ -385,13 +179,12 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
} else { } else {
expected_model.to_string() expected_model.to_string()
}; };
app.dispatch_agents_overview_task( app.new_agents_overview_session(
&mut tui, &mut tui,
&mut server, &mut server,
"background prompt".into(),
explicit_cwd.then(|| destination.path().to_path_buf().abs()), explicit_cwd.then(|| destination.path().to_path_buf().abs()),
) )
.await; .await?;
let cwd = match expected_cwd { let cwd = match expected_cwd {
"launch" => launch.path().display().to_string(), "launch" => launch.path().display().to_string(),
"destination" => destination.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}" "{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?; server.shutdown().await?;
proxy.await??; proxy.await??;
} }
@@ -444,7 +242,7 @@ async fn background_task_reads_server_defaults_for_actual_destination() -> Resul
} }
#[tokio::test] #[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()?; let mut tui = make_test_tui()?;
for (choice, expected_model, expected_effort) in [ for (choice, expected_model, expected_effort) in [
("saved", "server-model", "high"), ("saved", "server-model", "high"),
@@ -516,13 +314,8 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re
) )
.await?; .await?;
server.bootstrap(&app.config).await?; server.bootstrap(&app.config).await?;
app.dispatch_agents_overview_task( app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None)
&mut tui, .await?;
&mut server,
"background prompt".into(),
/*cwd*/ None,
)
.await;
let starts = recorded_params(&requests, "thread/start"); let starts = recorded_params(&requests, "thread/start");
assert_eq!(starts.len(), 1, "{choice}"); assert_eq!(starts.len(), 1, "{choice}");
assert_eq!( assert_eq!(
@@ -538,7 +331,7 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re
); );
assert_eq!( assert_eq!(
recorded_params(&requests, "turn/start").len(), recorded_params(&requests, "turn/start").len(),
1, 0,
"{choice}" "{choice}"
); );
server.shutdown().await?; server.shutdown().await?;
@@ -548,10 +341,11 @@ async fn background_task_preserves_explicit_choices_and_managed_defaults() -> Re
} }
#[tokio::test] #[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()?; let mut tui = make_test_tui()?;
for capability in [ for capability in [
HistoryCapabilities::ConfigReadFails, HistoryCapabilities::ConfigReadFails,
HistoryCapabilities::ThreadStartFails,
HistoryCapabilities::ConfigReadUnsupported(-32600), HistoryCapabilities::ConfigReadUnsupported(-32600),
HistoryCapabilities::ConfigReadUnsupported(-32601), HistoryCapabilities::ConfigReadUnsupported(-32601),
] { ] {
@@ -569,36 +363,23 @@ async fn background_task_read_failure_keeps_prompt_and_does_not_start() -> Resul
LoaderOverrides::default(), LoaderOverrides::default(),
) )
.await?; .await?;
app.dispatch_agents_overview_task( let source_colors = !app.config.tui_status_line_use_colors;
&mut tui, app.local_settings.tui.status_line_use_colors = source_colors;
&mut server, app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None)
"retry background prompt".into(), .await?;
/*cwd*/ None, let failed = matches!(
) capability,
.await; HistoryCapabilities::ConfigReadFails | HistoryCapabilities::ThreadStartFails
let failed = capability == HistoryCapabilities::ConfigReadFails; );
assert_eq!(recorded_params(&requests, "config/read").len(), 1); assert_eq!(recorded_params(&requests, "config/read").len(), 1);
assert_eq!( assert_eq!(
recorded_params(&requests, "thread/start").len(), recorded_params(&requests, "thread/start").len(),
usize::from(!failed) usize::from(capability != HistoryCapabilities::ConfigReadFails)
);
assert_eq!(
recorded_params(&requests, "turn/start").len(),
usize::from(!failed)
); );
assert_eq!(recorded_params(&requests, "turn/start").len(), 0);
if failed { if failed {
assert_eq!(app.local_settings.tui.status_line_use_colors, source_colors);
assert!(app.agents_overview.dispatched_requests.is_empty()); 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!( assert!(
app.chat_widget app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .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, _ => None,
}) })
.find(|message| message.contains("Failed to load background task settings")) .find(|message| message.contains("Failed to"))
.expect("visible read error"); .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 { } else {
assert_eq!( assert_eq!(
recorded_params(&requests, "thread/start")[0]["model"], 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(()) 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!(
(&params["permissions"], &params["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(())
}
+5 -16
View File
@@ -206,7 +206,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
app.handle_tui_event( app.handle_tui_event(
&mut tui, &mut tui,
&mut session, &mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)),
) )
.await?; .await?;
app.handle_tui_event( app.handle_tui_event(
@@ -218,9 +218,9 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
assert!(app.begin_reconnect()); assert!(app.begin_reconnect());
for key in [ for key in [
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
] { ] {
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
.await?; .await?;
@@ -233,21 +233,10 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
) )
.await?; .await?;
assert_eq!(
app.agents_overview
.view_state
.lock()
.unwrap()
.composer
.as_ref()
.unwrap()
.current_text_with_pending(),
"task draft!"
);
assert!( assert!(
!std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( !std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!(
event, event,
AppEvent::DispatchAgentsOverviewTask { .. } | AppEvent::OpenResumePicker AppEvent::NewAgentsOverviewSession { .. } | AppEvent::OpenResumePicker
)) ))
); );
assert_snapshot!( assert_snapshot!(
+33 -5
View File
@@ -586,10 +586,9 @@ async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<(
#[tokio::test] #[tokio::test]
async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<()> { async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<()> {
let mut app = make_test_app().await; let mut app = make_test_app().await;
app.keymap = RuntimeKeymap::from_config(&toml::from_str( app.keymap =
"[editor]\ninsert_newline = [\"ctrl-x n\"]", RuntimeKeymap::from_config(&toml::from_str("[agents]\nnew_task = [\"ctrl-x n\"]")?)
)?) .unwrap();
.unwrap();
let mut tui = crate::tui::test_support::make_test_tui()?; let mut tui = crate::tui::test_support::make_test_tui()?;
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); 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.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); let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
insta::assert_snapshot!( insta::assert_snapshot!(
render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), 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!( assert_eq!(
app.route_key_chord_event(&mut tui, KeyCode::Esc.into()), 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); assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 80), before);
Ok(()) 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(())
}
@@ -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.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view)); 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();
app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); app.agents_overview.view_state.lock().unwrap().renaming = true;
} else { let draft = |app: &App| app.agents_overview.view_state.lock().unwrap().input.clone();
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()
}
};
let stale_request = Uuid::new_v4(); let stale_request = Uuid::new_v4();
app.agents_overview.request_id = Some(stale_request); app.agents_overview.request_id = Some(stale_request);
app.agents_overview.refresh_pending = true; app.agents_overview.refresh_pending = true;
@@ -413,7 +402,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
.await?; .await?;
assert!( assert!(
!std::iter::from_fn(|| events.try_recv().ok()) !std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. })) .any(|event| matches!(event, AppEvent::NewAgentsOverviewSession { .. }))
); );
} }
assert!( assert!(
@@ -452,7 +441,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
.await?; .await?;
assert!(app.chat_widget.has_active_view()); 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. // to inspect the unavailable conversation and its cached draft below.
app.agents_overview.view_state.lock().unwrap().completion = app.agents_overview.view_state.lock().unwrap().completion =
Some(crate::bottom_pane::ViewCompletion::Accepted); Some(crate::bottom_pane::ViewCompletion::Accepted);
@@ -2877,10 +2877,7 @@ async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable
if attempt == 0 { if attempt == 0 {
app.refresh_agents_overview_threads(&app_server); app.refresh_agents_overview_threads(&app_server);
} else { } else {
app.open_agents_overview( app.open_agents_overview(&app_server);
&app_server,
crate::app::agents_overview_view::AgentsOverviewFocus::List,
);
} }
let Some(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }) = let Some(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }) =
tokio::time::timeout(Duration::from_secs(10), rx.recv()).await? tokio::time::timeout(Duration::from_secs(10), rx.recv()).await?
@@ -4,4 +4,4 @@ expression: "lines.join(\"\\n\")"
--- ---
Disconnected from this task. Work may still be running. Disconnected from this task. Work may still be running.
Reconnect: codex --remote wss://example.com:443/ resume 123e4567-e89b-12d3-a456-426614174000 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.
@@ -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
@@ -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
@@ -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
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ │
│ │
│ │
New task │
│
› Keep this task draft! │
Rename › Keep this task draft!
esc tasks · dispatch paused esc cancel · actions paused until reconnected
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ │
│ │
│ │
New task │
│
› Keep this task draft! │
Rename › Keep this task draft!
esc tasks · dispatch paused esc cancel · actions paused until reconnected
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ │
│ │
│ │
New task │
│
› Describe a new task │
↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive
enter create task ctrl+j newline esc tasks → open task del delete ctrl+c quit
@@ -1,6 +1,5 @@
--- ---
source: tui/src/app/tests/session_lifecycle_requests.rs source: tui/src/app/tests/session_lifecycle_requests.rs
assertion_line: 906
expression: "render_bottom_popup(&app.chat_widget, 100)" expression: "render_bottom_popup(&app.chat_widget, 100)"
--- ---
Agent command center Agent command center
@@ -22,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ │
│ │
│ │
New task │
│
› Describe a new task │
↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive
enter create task ctrl+j newline esc tasks del delete ctrl+c quit
+3
View File
@@ -195,6 +195,9 @@ impl App {
thread_id: ThreadId, thread_id: ThreadId,
settings: &ThreadSettings, 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) if self.primary_thread_id == Some(thread_id)
&& let Some(session) = self.primary_session_configured.as_mut() && let Some(session) = self.primary_session_configured.as_mut()
{ {
+2 -7
View File
@@ -274,10 +274,6 @@ pub(crate) enum AppEvent {
CloseMisalignmentReview, CloseMisalignmentReview,
/// Open the daemon-wide overview of recent and locally retained root sessions. /// Open the daemon-wide overview of recent and locally retained root sessions.
OpenAgentsOverview, 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. /// Update the daemon-wide overview after a background thread listing finishes.
AgentsOverviewThreadsLoaded { AgentsOverviewThreadsLoaded {
request_id: Uuid, request_id: Uuid,
@@ -287,9 +283,8 @@ pub(crate) enum AppEvent {
SelectAgentsOverviewThread { SelectAgentsOverviewThread {
thread_id: ThreadId, thread_id: ThreadId,
}, },
/// Start a background task directly from the shared dashboard. /// Open an empty session in the selected checkout.
DispatchAgentsOverviewTask { NewAgentsOverviewSession {
prompt: UserMessage,
cwd: Option<AbsolutePathBuf>, cwd: Option<AbsolutePathBuf>,
}, },
/// Rename a task directly from the shared dashboard. /// Rename a task directly from the shared dashboard.
+1 -1
View File
@@ -351,7 +351,7 @@ impl ThreadParamsMode {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub(crate) struct AppServerStartedThread { pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState, pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>, pub(crate) turns: Vec<Turn>,
@@ -18,7 +18,6 @@
//! # Key Event Routing //! # Key Event Routing
//! //!
//! Plain Left opens agents when the local-daemon composer is empty and available for input. //! 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. //! Explicit editor remaps take precedence.
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a //! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
//! popup-specific handler if a popup is visible and otherwise to //! popup-specific handler if a popup is visible and otherwise to
@@ -2,26 +2,10 @@
//! //!
//! Only local-daemon sessions enable this shortcut. Draft contents, transient input surfaces, //! Only local-daemon sessions enable this shortcut. Draft contents, transient input surfaces,
//! and explicit editor remaps take precedence over navigation. //! 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::*; use super::*;
impl ChatComposer { 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) { pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) {
self.agents_navigation_enabled = enabled; self.agents_navigation_enabled = enabled;
} }
@@ -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. //! Paste Enter handling is shared with normal submission so buffered newlines survive both paths.
use super::*; use super::*;
impl ChatComposer { 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. /// Preserve Enter inside a paste burst without attempting submission.
pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool { pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool {
let in_slash_context = self.slash_commands_enabled() let in_slash_context = self.slash_commands_enabled()
-4
View File
@@ -1737,10 +1737,6 @@ impl ChatWidget {
self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst() 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)] #[cfg(test)]
pub(crate) fn is_task_running_for_test(&self) -> bool { pub(crate) fn is_task_running_for_test(&self) -> bool {
self.bottom_pane.is_task_running() self.bottom_pane.is_task_running()
@@ -576,6 +576,15 @@ impl ChatWidget {
return; 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 !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if self.is_cancellable_work_active() { if self.is_cancellable_work_active() {
self.quit_shortcut_expires_at = None; self.quit_shortcut_expires_at = None;
+2
View File
@@ -107,6 +107,8 @@ impl KeyBinding {
pub(crate) fn display_label(&self) -> String { pub(crate) fn display_label(&self) -> String {
let modifiers = modifiers_to_string(self.modifiers); let modifiers = modifiers_to_string(self.modifiers);
let key = match self.key { let key = match self.key {
#[cfg(test)]
KeyCode::Delete => "del".to_string(),
KeyCode::Enter => "enter".to_string(), KeyCode::Enter => "enter".to_string(),
KeyCode::Char(' ') => "space".to_string(), KeyCode::Char(' ') => "space".to_string(),
KeyCode::Up => "↑".to_string(), KeyCode::Up => "↑".to_string(),
+34 -42
View File
@@ -1274,20 +1274,8 @@ impl RuntimeKeymap {
chord_hints: Arc::clone(&chords), 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 { let mut agents = AgentsKeymap {
resume: if resume_default_is_shadowed { resume: resolve_local!(keymap, defaults, agents, resume),
Vec::new()
} else {
resolve_local!(keymap, defaults, agents, resume)
},
search: resolve_local!(keymap, defaults, agents, search), search: resolve_local!(keymap, defaults, agents, search),
new_task: resolve_local!(keymap, defaults, agents, new_task), new_task: resolve_local!(keymap, defaults, agents, new_task),
rename: resolve_local!(keymap, defaults, agents, rename), rename: resolve_local!(keymap, defaults, agents, rename),
@@ -1299,15 +1287,21 @@ impl RuntimeKeymap {
chord_hints: Arc::clone(&chords), 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 [ for (configured, bindings, alias) in [
( (keymap.agents.resume.as_ref(), &mut agents.resume, "o"),
keymap.agents.archive.as_ref(), (keymap.agents.search.as_ref(), &mut agents.search, "f"),
&mut agents.archive, (keymap.agents.new_task.as_ref(), &mut agents.new_task, "n"),
"ctrl-e", (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.delete.as_ref(), &mut agents.delete, "delete"),
(keymap.agents.hide.as_ref(), &mut agents.hide, "ctrl-w"), (keymap.agents.hide.as_ref(), &mut agents.hide, "h"),
(
keymap.agents.toggle_grouping.as_ref(),
&mut agents.toggle_grouping,
"g",
),
] { ] {
if configured.is_none() if configured.is_none()
&& (configured_context_alias_is_used(&keymap.agents, alias) && (configured_context_alias_is_used(&keymap.agents, alias)
@@ -1816,15 +1810,15 @@ impl RuntimeKeymap {
chord_hints: Arc::default(), chord_hints: Arc::default(),
}, },
agents: AgentsKeymap { agents: AgentsKeymap {
resume: default_bindings![ctrl(KeyCode::Char('o'))], resume: default_bindings![plain(KeyCode::Char('o'))],
search: default_bindings![ctrl(KeyCode::Char('f'))], search: default_bindings![plain(KeyCode::Char('f'))],
new_task: default_bindings![ctrl(KeyCode::Char('n'))], new_task: default_bindings![plain(KeyCode::Char('n'))],
rename: default_bindings![ctrl(KeyCode::Char('r'))], rename: default_bindings![plain(KeyCode::Char('r'))],
stop: default_bindings![ctrl(KeyCode::Char('x'))], stop: default_bindings![plain(KeyCode::Char('x'))],
archive: default_bindings![ctrl(KeyCode::Char('e'))], archive: default_bindings![plain(KeyCode::Char('a'))],
delete: default_bindings![plain(KeyCode::Delete)], delete: default_bindings![plain(KeyCode::Delete)],
hide: default_bindings![ctrl(KeyCode::Char('w'))], hide: default_bindings![plain(KeyCode::Char('h'))],
toggle_grouping: default_bindings![ctrl(KeyCode::Char('s'))], toggle_grouping: default_bindings![plain(KeyCode::Char('g'))],
chord_hints: Arc::default(), chord_hints: Arc::default(),
}, },
approval: ApprovalKeymap { approval: ApprovalKeymap {
@@ -2203,14 +2197,12 @@ impl RuntimeKeymap {
)); ));
} }
if bindings.iter().any(|binding| { if bindings.iter().any(|binding| {
matches!(binding.parts(), (KeyCode::Char(_), modifiers) let (code, modifiers) = binding.normalized_parts();
if modifiers.is_empty() (code == KeyCode::Backspace && modifiers == KeyModifiers::NONE)
|| modifiers == KeyModifiers::SHIFT || (matches!(code, KeyCode::Char(_)) && crate::key_hint::is_altgr(modifiers))
|| crate::key_hint::is_altgr(modifiers))
|| binding.parts() == (KeyCode::Backspace, KeyModifiers::NONE)
}) { }) {
return Err(format!( 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"); let runtime = RuntimeKeymap::from_config(&keymap).expect("list shortcut remains usable");
assert_eq!( assert_eq!(
runtime.agents.rename, runtime.agents.rename,
vec![key_hint::ctrl(KeyCode::Char('r'))] vec![key_hint::plain(KeyCode::Char('r'))]
); );
keymap.agents.search = Some(one("s")); keymap.agents.search = Some(one("s"));
assert!( assert!(RuntimeKeymap::from_config(&keymap).is_ok());
RuntimeKeymap::from_config(&keymap)
.expect_err("printable shortcut is reserved for input")
.contains("printable keys")
);
keymap.agents.search = None; keymap.agents.search = None;
keymap.agents.stop = Some(one("backspace")); keymap.agents.stop = Some(one("backspace"));
@@ -3616,18 +3604,22 @@ mod tests {
.expect_err("backspace is reserved for task input") .expect_err("backspace is reserved for task input")
.contains("backspace") .contains("backspace")
); );
for binding in ["alt-backspace", "ctrl-backspace"] {
keymap.agents.stop = Some(one(binding));
assert!(RuntimeKeymap::from_config(&keymap).is_ok());
}
} }
#[test] #[test]
fn agents_resume_default_yields_to_existing_custom_shortcuts() { 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(); let mut keymap = TuiKeymap::default();
keymap.agents.search = Some(one(binding)); keymap.agents.search = Some(one(binding));
let runtime = let runtime =
RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid"); RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid");
assert!(runtime.agents.resume.is_empty()); 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()); assert!(RuntimeKeymap::from_config(&keymap).is_err());
} }
} }
+2 -1
View File
@@ -467,9 +467,10 @@ Windows. Choose a different chord and retry.",
if matches!(prefix_key, KeyCode::Char(_)) if matches!(prefix_key, KeyCode::Char(_))
&& !crate::key_hint::has_ctrl_or_alt(prefix_modifiers) && !crate::key_hint::has_ctrl_or_alt(prefix_modifiers)
&& !binding.action.context.allows_plain_chord_prefix() && !binding.action.context.allows_plain_chord_prefix()
&& binding.action.context != KeymapContext::Agents
{ {
return Err(format!( 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.", alt, or a non-character key so ordinary text input is not intercepted.",
binding.spec binding.spec
)); ));
+6 -8
View File
@@ -40,11 +40,7 @@ See the Codex keymap documentation for supported actions and examples."
#[test] #[test]
fn new_agents_defaults_preserve_existing_custom_bindings() { fn new_agents_defaults_preserve_existing_custom_bindings() {
for (action, alias) in [ for (action, alias) in [("archive", "a"), ("delete", "delete"), ("hide", "h")] {
("archive", "ctrl-e"),
("delete", "delete"),
("hide", "ctrl-w"),
] {
for (context, existing, suffix) in [ for (context, existing, suffix) in [
("agents", "stop", ""), ("agents", "stop", ""),
("list", "move_down", ""), ("list", "move_down", ""),
@@ -52,12 +48,14 @@ fn new_agents_defaults_preserve_existing_custom_bindings() {
("agents", "stop", " f12"), ("agents", "stop", " f12"),
("list", "move_down", " f12"), ("list", "move_down", " f12"),
] { ] {
// Ctrl+E is reserved by the resume picker for list-context chords. // Plain keys are allowed in the dashboard, but global actions and list
if context == "list" && alias == "ctrl-e" && !suffix.is_empty() { // chord prefixes still share text-entry surfaces.
if alias != "delete" && (context == "global" || context == "list" && !suffix.is_empty())
{
continue; continue;
} }
let keymap: TuiKeymap = serde_json::from_value( 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": []}}), "editor": {"move_line_start": [], "move_line_end": [], "delete_backward_word": [], "delete_forward": []}}),
) )
.unwrap(); .unwrap();
+1 -1
View File
@@ -215,7 +215,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("list", "List", "cancel", "Cancel and close selection views."), action("list", "List", "cancel", "Cancel and close selection views."),
action("agents", "Agents", "resume", "Open the session resume picker."), action("agents", "Agents", "resume", "Open the session resume picker."),
action("agents", "Agents", "search", "Search the available agent tasks."), 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", "rename", "Rename the selected task."),
action("agents", "Agents", "stop", "Stop the selected running task."), action("agents", "Agents", "stop", "Stop the selected running task."),
action("agents", "Agents", "archive", "Archive the selected task and its child agents."), action("agents", "Agents", "archive", "Archive the selected task and its child agents."),
@@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ │
│ Prompt │ Prompt
│ Build the dashboard │ Build the dashboard
New task │
│
› First line │
Second line │
↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide
enter create task ctrl+j newline esc tasks a archive del delete ctrl+c quit
@@ -13,7 +13,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Agent: Check dependencies │ Agent: Check dependencies
│ Which dependency version should I use? │ Which dependency version should I use?
│ Open task to review. │ Open task to review.
│ … │ Waiting for approval.
│ Waiting for your response.
│ │
│ Project │ Project
│ /tmp/project │ /tmp/project
@@ -21,8 +22,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ │
│ Prompt │ Prompt
│ Repair authentication │ Repair authentication
New task │
│
› Describe a new 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
enter create task ctrl+j newline esc tasks → open task
@@ -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
@@ -12,6 +12,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ │
│ Last message │ 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.
│ Found the regression in the parser.
│ … │ …
│ │
│ Project │ Project
@@ -21,8 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Prompt │ Prompt
│ Investigate parser │ Investigate parser
│ … │ …
New 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
› Describe a new task
enter create task ctrl+j newline esc tasks → open task
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ No prompt available. │ No prompt available.
│ │
│ │
New task │
│
› Keep this draft │
↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide
enter create task ctrl+j newline esc tasks a archive del delete ctrl+c quit
@@ -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
@@ -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
@@ -12,4 +12,3 @@ Raw event: code=Char('o'), modifiers=ctrl, kind=Press
Assigned actions: Assigned actions:
- global.copy (Copy) - Copy the last agent response to the clipboard. [Default] - global.copy (Copy) - Copy the last agent response to the clipboard. [Default]
- agents.resume (Resume) - Open the session resume picker. [Default]
@@ -2,12 +2,12 @@
source: tui/src/keymap_setup.rs source: tui/src/keymap_setup.rs
expression: agents expression: agents
--- ---
Resume | ctrl-o | Agents resume Resume Open the session resume picker. ctrl-o Default Resume | o | Agents resume Resume Open the session resume picker. o Default
Search | ctrl-f | Agents search Search Search the available agent tasks. ctrl-f Default Search | f | Agents search Search Search the available agent tasks. f Default
New Task | ctrl-n | Agents new_task New Task Start composing a new agent task. ctrl-n Default New Task | n | Agents new_task New Task Open a new session in the selected checkout. n Default
Rename | ctrl-r | Agents rename Rename Rename the selected task. ctrl-r Default Rename | r | Agents rename Rename Rename the selected task. r Default
Stop | ctrl-x | Agents stop Stop Stop the selected running task. ctrl-x Default Stop | x | Agents stop Stop Stop the selected running task. x Default
Archive | ctrl-e | Agents archive Archive Archive the selected task and its child agents. ctrl-e 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 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 Hide | h | Agents hide Hide Hide the selected task until explicitly resumed. h Default
Toggle Grouping | ctrl-s | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, status, or model. ctrl-s Default Toggle Grouping | g | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, status, or model. g Default
+5 -9
View File
@@ -153,21 +153,17 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
}; };
for (expected, input) in [ for (expected, input) in [
(prompt, b"\x1b".as_slice()), (prompt, b"\x1b".as_slice()),
("create task", b"\x1b"), ("n new", b"\x1b"),
("Launch-folder task", b"\x1b[B\x0e"), ("Launch-folder task", b"\x1b[Bn"),
("create task", b"new task"),
("› new task", b"\r"),
(prompt, b"\x1b"), (prompt, b"\x1b"),
("› new task", b"\r"), ("n new", b"n"),
("You are in", b"\x1b"), ("You are in", b"\x1b"),
("› new task", b"\x1b"), ("o resume", b"o"),
("ctrl+o resume", b"\x0f"),
("Resume a previous session", b"\x1b[C"), ("Resume a previous session", b"\x1b[C"),
("Untrusted saved task", b"\r"), ("Untrusted saved task", b"\r"),
("Open existing task", b"\r"), ("Open existing task", b"\r"),
("moved-folder", b"\x1b"), ("moved-folder", b"\x1b"),
("ctrl+o resume", b"\x0e"), ("o resume", b"n"),
("› new task", b"\r"),
(prompt, b"\r"), (prompt, b"\r"),
] { ] {
let is_consent = expected == prompt let is_consent = expected == prompt