mirror of
https://github.com/openai/codex.git
synced 2026-11-12 14:41:42 +00:00
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:
1 parent
16537b20a5
commit
516f2780fd
67 files changed
+1114
-1713
No files matched your search
@@ -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>,
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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,7 +490,16 @@ 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 {
|
||||||
|
(started, false)
|
||||||
|
} else if !unloaded
|
||||||
|
&& let Some(blank) = self.agents_overview.blank_sessions.get(&root_thread_id)
|
||||||
|
{
|
||||||
|
// An untouched task has no rollout for thread/resume yet. Its live
|
||||||
|
// subscription and saved settings are sufficient to restore the editor.
|
||||||
|
(blank.clone(), false)
|
||||||
|
} else {
|
||||||
|
match app_server
|
||||||
.resume_thread(
|
.resume_thread(
|
||||||
&local_settings,
|
&local_settings,
|
||||||
resume_config.clone(),
|
resume_config.clone(),
|
||||||
@@ -514,7 +511,11 @@ impl App {
|
|||||||
Ok(resumed) => (resumed, false),
|
Ok(resumed) => (resumed, false),
|
||||||
Err(error) if crate::app_server_session::is_active_writer_error(&error) => {
|
Err(error) if crate::app_server_session::is_active_writer_error(&error) => {
|
||||||
match app_server
|
match app_server
|
||||||
.read_thread_for_viewing(&resume_config, &local_settings, root_thread_id)
|
.read_thread_for_viewing(
|
||||||
|
&resume_config,
|
||||||
|
&local_settings,
|
||||||
|
root_thread_id,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(thread) => (thread, true),
|
Ok(thread) => (thread, true),
|
||||||
@@ -527,9 +528,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.add_agents_overview_error(format!("Failed to attach to task: {error}"));
|
self.add_agents_overview_error(format!(
|
||||||
|
"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() {
|
||||||
for side_thread_id in Vec::from_iter(self.side_threads.keys().copied()) {
|
for side_thread_id in Vec::from_iter(self.side_threads.keys().copied()) {
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut config = match self.rebuild_config_for_cwd(local_cwd).await {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.restore_agents_overview_prompt(prompt);
|
self.add_agents_overview_error(format!("Failed to load project settings: {error}"));
|
||||||
return self.add_agents_overview_error(format!(
|
return None;
|
||||||
"Failed to load project settings: {error}"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
None => self.fresh_session_config(),
|
|
||||||
};
|
};
|
||||||
|
if preserve_service_tier {
|
||||||
|
config.service_tier = self.chat_widget.configured_service_tier();
|
||||||
|
}
|
||||||
let trust_cwd = config.cwd.to_path_buf();
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,16 +504,8 @@ impl BottomPaneView for AgentsOverviewView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn keymap_contexts(&self) -> KeymapContextSet {
|
fn keymap_contexts(&self) -> KeymapContextSet {
|
||||||
let state = self.state();
|
|
||||||
if state.composing() {
|
|
||||||
state
|
|
||||||
.composer
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(KeymapContextSet::default, ChatComposer::keymap_contexts)
|
|
||||||
} else {
|
|
||||||
KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
|
KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn completion(&self) -> Option<ViewCompletion> {
|
fn completion(&self) -> Option<ViewCompletion> {
|
||||||
self.state().completion
|
self.state().completion
|
||||||
@@ -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() {
|
||||||
|
self.selected = self
|
||||||
|
.visible_indices()
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
}
|
}
|
||||||
if let Some(composer) = state.composer.as_mut()
|
|
||||||
&& (composer.cancel_vim_search()
|
|
||||||
|| composer.cancel_history_search()
|
|
||||||
|| composer.clear_for_ctrl_c().is_some())
|
|
||||||
{
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -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
|
||||||
+5
-5
@@ -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 "
|
||||||
+6
-6
@@ -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
|
|
||||||
+6
-6
@@ -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
|
|
||||||
+7
-7
@@ -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
|
||||||
+4
-5
@@ -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
|
||||||
+1
-1
@@ -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
|
||||||
-28
@@ -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
|
|
||||||
+1
-1
@@ -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
|
||||||
+6
-6
@@ -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
|
|
||||||
@@ -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!(
|
||||||
|
(¶ms["permissions"], ¶ms["approvalsReviewer"]),
|
||||||
|
(&serde_json::json!(profile_id), &serde_json::json!("user"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
app.chat_widget
|
||||||
|
.config_ref()
|
||||||
|
.permissions
|
||||||
|
.active_permission_profile(),
|
||||||
|
app.chat_widget.config_ref().approvals_reviewer
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(ActivePermissionProfile::new(profile_id)),
|
||||||
|
ApprovalsReviewer::User
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.select_agents_overview_thread(&mut tui, &mut server, first)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
app.chat_widget.composer_text_with_pending(),
|
||||||
|
"Keep this unsent draft"
|
||||||
|
);
|
||||||
|
assert!(recorded_params(&requests, "turn/start").is_empty());
|
||||||
|
server.shutdown().await?;
|
||||||
|
proxy.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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!(
|
||||||
|
|||||||
@@ -586,9 +586,8 @@ 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);
|
||||||
@@ -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();
|
||||||
} else {
|
app.agents_overview.view_state.lock().unwrap().renaming = true;
|
||||||
app.chat_widget.handle_paste("Keep this task draft".into());
|
let draft = |app: &App| app.agents_overview.view_state.lock().unwrap().input.clone();
|
||||||
}
|
|
||||||
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?
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
+10
@@ -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
|
||||||
+11
@@ -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
|
||||||
+5
-5
@@ -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
|
||||||
+5
-5
@@ -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
|
||||||
+5
-5
@@ -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
|
||||||
+5
-5
@@ -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
|
||||||
+5
-6
@@ -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
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
|||||||
+6
-6
@@ -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
|
||||||
+6
-6
@@ -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
|
|
||||||
+7
-7
@@ -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
|
||||||
+5
-5
@@ -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
|
|
||||||
+5
-5
@@ -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
|
||||||
-13
@@ -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
|
|
||||||
+3
-3
@@ -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
|
||||||
-1
@@ -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]
|
|
||||||
+8
-8
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user