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