mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +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:
@@ -4,6 +4,7 @@ use super::agents_overview_view::AgentsOverviewGroup;
|
||||
use super::agents_overview_view::AgentsOverviewRow;
|
||||
use super::agents_overview_view::AgentsOverviewView;
|
||||
use super::*;
|
||||
use crate::chatwidget::ThreadInputStateRestoreMode;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SessionSource;
|
||||
use codex_app_server_protocol::Thread;
|
||||
@@ -15,6 +16,9 @@ use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_app_server_protocol::ThreadTurnsListParams;
|
||||
use codex_app_server_protocol::ThreadTurnsListResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
|
||||
pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
|
||||
@@ -27,6 +31,8 @@ pub(super) struct AgentsOverviewState {
|
||||
pub(super) visible_thread_ids: Vec<ThreadId>,
|
||||
pub(super) view_state:
|
||||
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 {
|
||||
@@ -190,6 +196,14 @@ impl App {
|
||||
};
|
||||
let view = self.agents_overview_view(threads, selected_thread_id);
|
||||
self.agents_overview.visible_thread_ids = view.thread_ids();
|
||||
if selected_thread_id
|
||||
.is_some_and(|thread_id| !self.agents_overview.visible_thread_ids.contains(&thread_id))
|
||||
&& let Ok(mut state) = self.agents_overview.view_state.lock()
|
||||
&& state.renaming
|
||||
{
|
||||
state.renaming = false;
|
||||
state.input.clear();
|
||||
}
|
||||
self.chat_widget
|
||||
.replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view));
|
||||
if std::mem::take(&mut self.agents_overview.refresh_pending) {
|
||||
@@ -253,6 +267,386 @@ impl App {
|
||||
Arc::clone(&self.agents_overview.view_state),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn select_agents_overview_thread(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
root_thread_id: ThreadId,
|
||||
) -> color_eyre::Result<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)]
|
||||
|
||||
@@ -1,68 +1,220 @@
|
||||
use crate::app::agents_overview_view::AgentsOverviewGroup;
|
||||
use super::*;
|
||||
use crate::app::test_support::make_test_app;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::test_support::PathBufExt;
|
||||
use crate::test_support::test_path_buf;
|
||||
use crate::test_support::test_path_display;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_protocol::CurrentTimeReadParams;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::SessionSource;
|
||||
use codex_app_server_protocol::ThreadActiveFlag;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_protocol::ThreadId;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeStatus;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
|
||||
static OVERVIEW_TIMESTAMP: std::sync::LazyLock<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]
|
||||
async fn shared_agents_dashboard_is_rendered() {
|
||||
async fn shared_overview_shows_only_root_sessions() {
|
||||
assert_eq!(
|
||||
AgentsOverviewGroup::for_status(&ThreadStatus::SystemError),
|
||||
AgentsOverviewGroup::NeedsYou
|
||||
);
|
||||
let mut app = make_test_app().await;
|
||||
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||||
.await
|
||||
.expect("embedded app server");
|
||||
let started = app_server
|
||||
.start_thread(&app.config)
|
||||
.await
|
||||
.expect("start example thread");
|
||||
let mut thread = app_server
|
||||
.thread_read(started.session.thread_id, /*include_turns*/ false)
|
||||
.await
|
||||
.expect("read example thread");
|
||||
thread.name = Some("Example task".to_string());
|
||||
thread.preview = "Inspect the current project".to_string();
|
||||
thread.cwd = test_path_buf("/tmp/project").abs();
|
||||
thread.status = ThreadStatus::Active {
|
||||
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
|
||||
};
|
||||
app.primary_thread_id = Some(started.session.thread_id);
|
||||
let mut view = app.agents_overview_view(vec![thread.clone()], /*selected_thread_id*/ None);
|
||||
let first_root = ThreadId::from_string("00000000-0000-0000-0000-000000000101").unwrap();
|
||||
let [child, second_root, side_thread] = std::array::from_fn(|_| ThreadId::new());
|
||||
app.primary_thread_id = Some(first_root);
|
||||
|
||||
let mut threads = vec![
|
||||
overview_thread(
|
||||
first_root,
|
||||
/*parent_thread_id*/ None,
|
||||
"Build the dashboard",
|
||||
ThreadStatus::Idle,
|
||||
),
|
||||
overview_thread(
|
||||
child,
|
||||
Some(first_root),
|
||||
"Inspect keyboard shortcuts",
|
||||
ThreadStatus::Active {
|
||||
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
|
||||
},
|
||||
),
|
||||
overview_thread(
|
||||
second_root,
|
||||
/*parent_thread_id*/ None,
|
||||
"Repair authentication",
|
||||
ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut side = overview_thread(
|
||||
side_thread,
|
||||
/*parent_thread_id*/ None,
|
||||
"Ephemeral side",
|
||||
ThreadStatus::Idle,
|
||||
);
|
||||
side.ephemeral = true;
|
||||
threads.push(side);
|
||||
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
|
||||
|
||||
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut action_view = AgentsOverviewView::new(
|
||||
view.rows.clone(),
|
||||
Some(first_root),
|
||||
/*exit_on_cancel*/ false,
|
||||
crate::app_event_sender::AppEventSender::new(event_tx),
|
||||
app.keymap.clone(),
|
||||
Arc::clone(&app.agents_overview.view_state),
|
||||
);
|
||||
let state = &app.agents_overview.view_state;
|
||||
assert!(!state.lock().unwrap().status_grouping);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert!(state.lock().unwrap().status_grouping);
|
||||
app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
assert!(state.lock().unwrap().status_grouping);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert!(
|
||||
action_view.handle_paste("Use \u{1b}[31mthe\u{1b}[0m current project\u{7}".to_string())
|
||||
);
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
event_rx.try_recv(),
|
||||
Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None })
|
||||
if prompt == "Use the current project"
|
||||
));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert!(action_view.handle_paste("Fix the flaky tests after all retries complete".to_string()));
|
||||
let area = ratatui::layout::Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
|
||||
);
|
||||
let mut buffer = ratatui::buffer::Buffer::empty(area);
|
||||
action_view.render(area, &mut buffer);
|
||||
let prompt = buffer
|
||||
.content()
|
||||
.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<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));
|
||||
|
||||
let project = test_path_display("/tmp/project");
|
||||
let normalized_project_group = format!(
|
||||
"/tmp/project 2{}",
|
||||
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
|
||||
);
|
||||
insta::with_settings!({snapshot_path => "../snapshots"}, {
|
||||
insta::assert_snapshot!(
|
||||
"agents_overview",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 96)
|
||||
.replace(&test_path_display("/tmp/project"), "/tmp/project")
|
||||
.replace(&format!("{project} 2"), &normalized_project_group)
|
||||
.replace(&project, "/tmp/project")
|
||||
);
|
||||
});
|
||||
|
||||
let threads = (0..20)
|
||||
.map(|index| {
|
||||
let mut candidate = thread.clone();
|
||||
if index != 0 {
|
||||
candidate.id = ThreadId::new().to_string();
|
||||
candidate.status = ThreadStatus::Idle;
|
||||
let thread_id = if index == 0 {
|
||||
first_root
|
||||
} else {
|
||||
ThreadId::new()
|
||||
};
|
||||
let status = if index == 0 {
|
||||
ThreadStatus::Active {
|
||||
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
|
||||
}
|
||||
} else {
|
||||
ThreadStatus::Idle
|
||||
};
|
||||
let mut candidate = overview_thread(
|
||||
thread_id,
|
||||
/*parent_thread_id*/ None,
|
||||
&format!("Task {index}"),
|
||||
status,
|
||||
);
|
||||
if index == 0 {
|
||||
candidate.name = None;
|
||||
candidate.preview = "Inspect unnamed task".to_string();
|
||||
}
|
||||
candidate.name = (index != 0).then(|| format!("Task {index}"));
|
||||
candidate.updated_at = index;
|
||||
candidate.cwd = if index == 0 {
|
||||
test_path_buf("/tmp/project-selected").abs()
|
||||
@@ -77,10 +229,12 @@ async fn shared_agents_dashboard_is_rendered() {
|
||||
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
|
||||
assert!(rendered.lines().any(
|
||||
|line| line.contains("› ● Inspect the current project current")
|
||||
&& line.contains("Needs input")
|
||||
));
|
||||
assert!(
|
||||
rendered
|
||||
.lines()
|
||||
.any(|line| line.contains("› ● Inspect unnamed task current")
|
||||
&& line.contains("Needs input"))
|
||||
);
|
||||
|
||||
app.transcript_cells.push(std::sync::Arc::new(
|
||||
crate::history_cell::PlainHistoryCell::new(vec![ratatui::text::Line::from(
|
||||
@@ -98,6 +252,95 @@ async fn shared_agents_dashboard_is_rendered() {
|
||||
.expect("restore conversation after closing dashboard");
|
||||
assert!(tui.terminal.viewport_area.height < screen_size.height);
|
||||
assert!(app.last_rendered_history_tail.is_some());
|
||||
|
||||
app_server.shutdown().await.expect("shutdown app server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
app.primary_thread_id = Some(ThreadId::new());
|
||||
app.ensure_thread_channel(ThreadId::new())
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.active_turn_id = Some("running-turn".to_string());
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
|
||||
app.select_agents_overview_thread(&mut tui, &mut app_server, ThreadId::new())
|
||||
.await?;
|
||||
|
||||
assert!(app.agents_overview.dispatched_requests.is_empty());
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let previous = app_server.start_thread(&app.config).await?;
|
||||
let previous_root_id = previous.session.thread_id;
|
||||
app.enqueue_primary_thread_session(previous.session, previous.turns)
|
||||
.await?;
|
||||
let target_thread_id = ThreadId::from_string(
|
||||
&app_test_support::create_fake_rollout(
|
||||
app.config.codex_home.as_path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Target task",
|
||||
Some(&app.config.model_provider_id),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("materialize target rollout"),
|
||||
)?;
|
||||
let target = app_server
|
||||
.resume_thread(
|
||||
app.config.clone(),
|
||||
target_thread_id,
|
||||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
|
||||
)
|
||||
.await?;
|
||||
let child_id = ThreadId::new();
|
||||
let idle_child_id = ThreadId::new();
|
||||
app.ensure_thread_channel(idle_child_id);
|
||||
app.upsert_agent_picker_thread(
|
||||
child_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false,
|
||||
);
|
||||
app.agent_navigation.mark_running(child_id);
|
||||
app.active_thread_id = Some(child_id);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
|
||||
app.select_agents_overview_thread(&mut tui, &mut app_server, target.session.thread_id)
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
app.agents_overview
|
||||
.dispatched_requests
|
||||
.contains_key(&idle_child_id)
|
||||
);
|
||||
app.handle_app_server_event(
|
||||
&app_server,
|
||||
AppServerEvent::ServerRequest(Box::new(ServerRequest::CurrentTimeRead {
|
||||
request_id: RequestId::Integer(99),
|
||||
params: CurrentTimeReadParams {
|
||||
thread_id: idle_child_id.to_string(),
|
||||
},
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert!(app.agents_overview.dispatched_requests[&idle_child_id].is_empty());
|
||||
let response: ThreadUnsubscribeResponse = app_server
|
||||
.request_handle()
|
||||
.request_typed(ClientRequest::ThreadUnsubscribe {
|
||||
request_id: RequestId::String("verify-root-subscription".to_string()),
|
||||
params: ThreadUnsubscribeParams {
|
||||
thread_id: previous_root_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(response.status, ThreadUnsubscribeStatus::Unsubscribed);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Read-only dashboard of the daemon's active tasks.
|
||||
//! Dashboard for inspecting and managing the daemon's active tasks.
|
||||
|
||||
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::bottom_pane::ViewCompletion;
|
||||
use crate::key_hint::is_plain_text_key_event;
|
||||
use crate::keymap::KeymapContext;
|
||||
use crate::keymap::KeymapContextSet;
|
||||
use crate::keymap::ListAction;
|
||||
@@ -23,14 +24,19 @@ use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Margin;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::MutexGuard;
|
||||
use std::sync::PoisonError;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(super) enum AgentsOverviewGroup {
|
||||
@@ -76,10 +82,12 @@ pub(super) struct AgentsOverviewRow {
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct AgentsOverviewViewState {
|
||||
pub(super) input: String,
|
||||
search: String,
|
||||
searching: bool,
|
||||
pub(super) status_grouping: bool,
|
||||
pub(super) refresh_task: Option<tokio::task::AbortHandle>,
|
||||
pub(super) renaming: bool,
|
||||
}
|
||||
|
||||
pub(super) struct AgentsOverviewView {
|
||||
@@ -139,6 +147,16 @@ impl AgentsOverviewView {
|
||||
self.rows.iter().map(|row| row.thread_id).collect()
|
||||
}
|
||||
|
||||
fn state(&self) -> MutexGuard<'_, AgentsOverviewViewState> {
|
||||
self.state.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn selected_row(&self) -> Option<&AgentsOverviewRow> {
|
||||
self.rows
|
||||
.get(self.selected)
|
||||
.filter(|_| self.visible_indices().contains(&self.selected))
|
||||
}
|
||||
|
||||
fn visible_indices(&self) -> Vec<usize> {
|
||||
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let search = state.search.to_lowercase();
|
||||
@@ -169,6 +187,9 @@ impl AgentsOverviewView {
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, forward: bool) {
|
||||
if self.state().renaming {
|
||||
return;
|
||||
}
|
||||
let visible = self.visible_indices();
|
||||
if visible.is_empty() {
|
||||
return;
|
||||
@@ -184,8 +205,67 @@ impl AgentsOverviewView {
|
||||
};
|
||||
}
|
||||
|
||||
fn activate(&mut self) {
|
||||
let state = self.state().clone();
|
||||
let input = state.input.clone();
|
||||
if !state.searching && !input.is_empty() && input.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if !state.searching && !input.trim().is_empty() {
|
||||
if state.renaming {
|
||||
if let Some(row) = self.selected_row() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::RenameAgentsOverviewThread {
|
||||
thread_id: row.thread_id,
|
||||
name: input.trim().to_string(),
|
||||
});
|
||||
}
|
||||
self.state().renaming = false;
|
||||
} else {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::DispatchAgentsOverviewTask {
|
||||
prompt: input,
|
||||
cwd: (!state.status_grouping)
|
||||
.then(|| self.selected_row().map(|row| row.thread.cwd.clone()))
|
||||
.flatten(),
|
||||
});
|
||||
}
|
||||
self.state().input.clear();
|
||||
} else if let Some(row) = self.selected_row().filter(|_| !state.renaming) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::SelectAgentsOverviewThread {
|
||||
thread_id: row.thread_id,
|
||||
});
|
||||
if state.searching {
|
||||
let mut state = self.state();
|
||||
state.search.clear();
|
||||
state.searching = false;
|
||||
}
|
||||
self.completion = Some(ViewCompletion::Accepted);
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_input(&mut self, edit: impl FnOnce(&mut String)) -> bool {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let searching = state.searching;
|
||||
edit(if searching {
|
||||
&mut state.search
|
||||
} else {
|
||||
&mut state.input
|
||||
});
|
||||
drop(state);
|
||||
if searching {
|
||||
self.selected = self
|
||||
.visible_indices()
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(usize::MAX);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn status(row: &AgentsOverviewRow) -> (&'static str, Span<'static>) {
|
||||
match AgentsOverviewGroup::for_status(&row.thread.status) {
|
||||
match row.group {
|
||||
AgentsOverviewGroup::NeedsYou => ("Needs input", "●".red()),
|
||||
AgentsOverviewGroup::Working => ("Working", "●".green()),
|
||||
AgentsOverviewGroup::Ready => ("Ready", "○".cyan()),
|
||||
@@ -284,6 +364,49 @@ impl AgentsOverviewView {
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_details(&self, area: Rect, buf: &mut Buffer) {
|
||||
let Some(row) = self.selected_row() else {
|
||||
return;
|
||||
};
|
||||
let (status, dot) = Self::status(row);
|
||||
let mut lines = vec![
|
||||
Line::from("Task details".bold()),
|
||||
Line::default(),
|
||||
Line::from(row.thread.name.as_deref().unwrap_or("Untitled task").bold()),
|
||||
Line::from(vec![dot, " ".into(), status.into()]),
|
||||
Line::default(),
|
||||
Line::from("Project".dim()),
|
||||
Line::from(row.thread.cwd.display().to_string()),
|
||||
];
|
||||
if let Some(branch) = row
|
||||
.thread
|
||||
.git_info
|
||||
.as_ref()
|
||||
.and_then(|git| git.branch.as_ref())
|
||||
{
|
||||
lines.push(Line::default());
|
||||
lines.push("Branch".dim().into());
|
||||
lines.push(branch.clone().into());
|
||||
}
|
||||
let preview = crate::text_formatting::truncate_text(
|
||||
&row.thread.preview,
|
||||
usize::from(area.width) * usize::from(area.height),
|
||||
);
|
||||
lines.extend([
|
||||
Line::default(),
|
||||
Line::from("Latest activity".dim()),
|
||||
Line::from(match preview.as_str() {
|
||||
"" => "No activity yet.",
|
||||
preview => preview,
|
||||
}),
|
||||
]);
|
||||
Paragraph::new(crate::wrapping::word_wrap_lines(
|
||||
lines,
|
||||
usize::from(area.width),
|
||||
))
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for AgentsOverviewView {
|
||||
@@ -311,22 +434,70 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
self.edit_input(|input| {
|
||||
input.push_str(&crate::history_cell::sanitize_user_text(pasted.into()))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
if key.code == KeyCode::Char('s') && key.modifiers == KeyModifiers::CONTROL {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
state.status_grouping = !state.status_grouping;
|
||||
return;
|
||||
}
|
||||
if key.code == KeyCode::Char('f') && key.modifiers == KeyModifiers::CONTROL {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
state.searching = !state.searching;
|
||||
if key.modifiers == KeyModifiers::CONTROL
|
||||
&& matches!(key.code, KeyCode::Char('f' | 'n' | 'r' | 's' | 'x'))
|
||||
{
|
||||
match key.code {
|
||||
KeyCode::Char('s') => {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
state.status_grouping = !state.status_grouping;
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !state.renaming {
|
||||
state.searching = !state.searching;
|
||||
if !state.searching {
|
||||
state.search.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
state.search.clear();
|
||||
state.searching = false;
|
||||
state.renaming = false;
|
||||
state.input.clear();
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(row) = self.selected_row() {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if state.input.is_empty() {
|
||||
state.input = row.thread.name.clone().unwrap_or_default();
|
||||
state.search.clear();
|
||||
state.searching = false;
|
||||
state.renaming = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('x') => {
|
||||
if let Some(row) = self.selected_row()
|
||||
&& matches!(row.thread.status, ThreadStatus::Active { .. })
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::StopAgentsOverviewThread {
|
||||
thread_id: row.thread_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(action) = self.keymap.action_for(key)
|
||||
&& (!matches!(key.code, KeyCode::Char(_))
|
||||
|| self.state.lock().is_ok_and(|state| !state.searching))
|
||||
if !is_plain_text_key_event(key)
|
||||
&& let Some(action) = self.keymap.action_for(key)
|
||||
{
|
||||
if self.state().renaming
|
||||
&& matches!(action, ListAction::JumpTop | ListAction::JumpBottom)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match action {
|
||||
ListAction::MoveUp => self.move_selection(/*forward*/ false),
|
||||
ListAction::MoveDown => self.move_selection(/*forward*/ true),
|
||||
@@ -336,12 +507,16 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
ListAction::JumpBottom => {
|
||||
self.selected = self.visible_indices().last().copied().unwrap_or(0);
|
||||
}
|
||||
ListAction::Accept => self.activate(),
|
||||
ListAction::Cancel => {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if state.searching {
|
||||
state.search.clear();
|
||||
state.searching = false;
|
||||
self.selected = 0;
|
||||
} else if !state.input.is_empty() || state.renaming {
|
||||
state.input.clear();
|
||||
state.renaming = false;
|
||||
} else {
|
||||
if self.exit_on_cancel {
|
||||
self.app_event_tx
|
||||
@@ -355,32 +530,24 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
self.move_selection(action == ListAction::PageDown);
|
||||
}
|
||||
}
|
||||
ListAction::Accept | ListAction::MoveLeft | ListAction::MoveRight => {}
|
||||
ListAction::MoveLeft | ListAction::MoveRight => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !state.searching {
|
||||
return;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Backspace => {
|
||||
state.search.pop();
|
||||
self.edit_input(|input| {
|
||||
input.pop();
|
||||
});
|
||||
}
|
||||
KeyCode::Char(character)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||
{
|
||||
state.search.push(character);
|
||||
self.edit_input(|input| input.push(character));
|
||||
}
|
||||
_ => return,
|
||||
_ => {}
|
||||
}
|
||||
drop(state);
|
||||
self.selected = self
|
||||
.visible_indices()
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(usize::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,17 +556,34 @@ impl Renderable for AgentsOverviewView {
|
||||
24
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let state = self.state().clone();
|
||||
let (label, input) = if state.searching {
|
||||
(" Search › ", &state.search)
|
||||
} else if state.renaming {
|
||||
(" Rename › ", &state.input)
|
||||
} else {
|
||||
(" New task › ", &state.input)
|
||||
};
|
||||
let x = area
|
||||
.x
|
||||
.saturating_add((label.width() + input.width()) as u16)
|
||||
.min(area.right().saturating_sub(3));
|
||||
Some((x, area.bottom().saturating_sub(2)))
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width < 12 || area.height < 8 {
|
||||
return;
|
||||
}
|
||||
Clear.render(area, buf);
|
||||
let [header, summary, divider, body, footer] = Layout::vertical([
|
||||
let [header, summary, divider, body, prompt, footer] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
let inset =
|
||||
@@ -419,22 +603,77 @@ impl Renderable for AgentsOverviewView {
|
||||
.render(inset(summary), buf);
|
||||
Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim())
|
||||
.render(inset(divider), buf);
|
||||
self.render_rows(inset(body), buf);
|
||||
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let footer_line = if state.searching {
|
||||
Line::from(vec!["Search › ".cyan().bold(), state.search.clone().into()])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
"↑↓".bold(),
|
||||
" navigate ".dim(),
|
||||
"ctrl+f".bold(),
|
||||
" search ".dim(),
|
||||
"ctrl+s".bold(),
|
||||
" group ".dim(),
|
||||
"esc".bold(),
|
||||
" back".dim(),
|
||||
let body = inset(body);
|
||||
if body.width >= 90 {
|
||||
let [list, gap, details] = Layout::horizontal([
|
||||
Constraint::Min(46),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(38),
|
||||
])
|
||||
.areas(body);
|
||||
for y in gap.y..gap.bottom() {
|
||||
buf[(gap.x + 1, y)]
|
||||
.set_symbol("│")
|
||||
.set_style(Style::new().dim());
|
||||
}
|
||||
self.render_rows(list, buf);
|
||||
self.render_details(details, buf);
|
||||
} else {
|
||||
self.render_rows(body, buf);
|
||||
}
|
||||
let state = self.state().clone();
|
||||
let (label, input) = if state.searching {
|
||||
("Search › ", &state.search)
|
||||
} else if state.renaming {
|
||||
("Rename › ", &state.input)
|
||||
} else {
|
||||
("New task › ", &state.input)
|
||||
};
|
||||
footer_line.render(inset(footer), buf);
|
||||
let placeholder = if input.is_empty() && !state.searching && !state.renaming {
|
||||
"Describe a task and press enter to dispatch it"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let available_width = usize::from(inset(prompt).width)
|
||||
.saturating_sub(label.width())
|
||||
.saturating_sub(1);
|
||||
let mut visible_start = input.len();
|
||||
let mut visible_width = 0;
|
||||
for (index, character) in input.char_indices().rev() {
|
||||
let width = character.width().unwrap_or(0);
|
||||
if visible_width + width > available_width {
|
||||
break;
|
||||
}
|
||||
visible_width += width;
|
||||
visible_start = index;
|
||||
}
|
||||
let input = &input[visible_start..];
|
||||
Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()])
|
||||
.render(inset(prompt), buf);
|
||||
let stop_hint = if self
|
||||
.selected_row()
|
||||
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. }))
|
||||
{
|
||||
"ctrl+x".bold()
|
||||
} else {
|
||||
"ctrl+x".dim()
|
||||
};
|
||||
Line::from(vec![
|
||||
"↑↓".bold(),
|
||||
" navigate ".dim(),
|
||||
"enter".bold(),
|
||||
" open ".dim(),
|
||||
"ctrl+f".bold(),
|
||||
" search ".dim(),
|
||||
"ctrl+s".bold(),
|
||||
" group ".dim(),
|
||||
"ctrl+r".bold(),
|
||||
" rename ".dim(),
|
||||
stop_hint,
|
||||
" stop ".dim(),
|
||||
"esc".bold(),
|
||||
" back".dim(),
|
||||
])
|
||||
.render(inset(footer), buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,21 @@ impl App {
|
||||
app_server_client: &AppServerSession,
|
||||
notification: ServerNotification,
|
||||
) {
|
||||
if let ServerNotification::ThreadStarted(started) = ¬ification
|
||||
&& let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) = &started.thread.source
|
||||
&& self
|
||||
.agents_overview
|
||||
.dispatched_requests
|
||||
.contains_key(parent_thread_id)
|
||||
&& let Ok(thread_id) = codex_protocol::ThreadId::from_string(&started.thread.id)
|
||||
{
|
||||
self.agents_overview
|
||||
.dispatched_requests
|
||||
.entry(thread_id)
|
||||
.or_default();
|
||||
}
|
||||
if matches!(
|
||||
¬ification,
|
||||
ServerNotification::ThreadStarted(_)
|
||||
@@ -105,6 +120,9 @@ impl App {
|
||||
!matches!(event, ThreadBufferedEvent::Request(request)
|
||||
if request.id() == ¬ification.request_id)
|
||||
});
|
||||
for requests in self.agents_overview.dispatched_requests.values_mut() {
|
||||
requests.retain(|request| request.id() != ¬ification.request_id);
|
||||
}
|
||||
if let Some(request) = self
|
||||
.pending_app_server_requests
|
||||
.resolve_notification(¬ification.request_id)
|
||||
@@ -289,9 +307,32 @@ impl App {
|
||||
.push_back(ThreadBufferedEvent::Request(Box::new(request)));
|
||||
return;
|
||||
}
|
||||
let unsupported_request = matches!(
|
||||
&request,
|
||||
ServerRequest::DynamicToolCall { .. }
|
||||
| ServerRequest::AttestationGenerate { .. }
|
||||
| ServerRequest::CurrentTimeRead { .. }
|
||||
| ServerRequest::ApplyPatchApproval { .. }
|
||||
| ServerRequest::ExecCommandApproval { .. }
|
||||
);
|
||||
if self
|
||||
.pending_app_server_requests
|
||||
.contains_server_request(&request)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(thread_id) = thread_id
|
||||
&& self.primary_thread_id != Some(thread_id)
|
||||
&& !unsupported_request
|
||||
&& let Some(requests) = self.agents_overview.dispatched_requests.get_mut(&thread_id)
|
||||
{
|
||||
requests.push(request);
|
||||
return;
|
||||
}
|
||||
if thread_id.is_some()
|
||||
&& self.primary_thread_id.is_none()
|
||||
&& !self.pending_startup_thread_start
|
||||
&& !unsupported_request
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -301,6 +342,7 @@ impl App {
|
||||
&& !self.thread_event_channels.contains_key(&thread_id)
|
||||
&& self.agent_navigation.get(&thread_id).is_none()
|
||||
&& !self.side_threads.contains_key(&thread_id)
|
||||
&& !unsupported_request
|
||||
{
|
||||
let thread = app_server_client
|
||||
.request_handle()
|
||||
@@ -312,18 +354,30 @@ impl App {
|
||||
},
|
||||
})
|
||||
.await;
|
||||
if !matches!(
|
||||
thread,
|
||||
Ok(ThreadReadResponse { thread })
|
||||
if matches!(thread.source,
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
..
|
||||
}) if self.primary_thread_id == Some(parent_thread_id)
|
||||
|| self.thread_event_channels.contains_key(&parent_thread_id)
|
||||
|| self.agent_navigation.get(&parent_thread_id).is_some()
|
||||
)
|
||||
) {
|
||||
let Ok(ThreadReadResponse { thread }) = thread else {
|
||||
return;
|
||||
};
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) = thread.source
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.primary_thread_id != Some(parent_thread_id)
|
||||
&& !self.thread_event_channels.contains_key(&parent_thread_id)
|
||||
&& self.agent_navigation.get(&parent_thread_id).is_none()
|
||||
{
|
||||
if self
|
||||
.agents_overview
|
||||
.dispatched_requests
|
||||
.contains_key(&parent_thread_id)
|
||||
{
|
||||
self.agents_overview
|
||||
.dispatched_requests
|
||||
.entry(thread_id)
|
||||
.or_default()
|
||||
.push(request);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,12 +355,12 @@ impl PendingAppServerRequests {
|
||||
.mcp_requests
|
||||
.values()
|
||||
.any(|pending_request_id| pending_request_id == request_id),
|
||||
ServerRequest::ChatgptAuthTokensRefresh { .. } => true,
|
||||
ServerRequest::DynamicToolCall { .. }
|
||||
| ServerRequest::ChatgptAuthTokensRefresh { .. }
|
||||
| ServerRequest::AttestationGenerate { .. }
|
||||
| ServerRequest::CurrentTimeRead { .. }
|
||||
| ServerRequest::ApplyPatchApproval { .. }
|
||||
| ServerRequest::ExecCommandApproval { .. } => true,
|
||||
| ServerRequest::ExecCommandApproval { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,18 +721,20 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_dynamic_tool_calls_as_unsupported() {
|
||||
let mut pending = PendingAppServerRequests::default();
|
||||
let request = ServerRequest::DynamicToolCall {
|
||||
request_id: AppServerRequestId::Integer(99),
|
||||
params: codex_app_server_protocol::DynamicToolCallParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "tool-1".to_string(),
|
||||
namespace: None,
|
||||
tool: "tool".to_string(),
|
||||
arguments: json!({}),
|
||||
},
|
||||
};
|
||||
assert!(!pending.contains_server_request(&request));
|
||||
let unsupported = pending
|
||||
.note_server_request(&ServerRequest::DynamicToolCall {
|
||||
request_id: AppServerRequestId::Integer(99),
|
||||
params: codex_app_server_protocol::DynamicToolCallParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "tool-1".to_string(),
|
||||
namespace: None,
|
||||
tool: "tool".to_string(),
|
||||
arguments: json!({}),
|
||||
},
|
||||
})
|
||||
.note_server_request(&request)
|
||||
.expect("dynamic tool calls should be rejected");
|
||||
|
||||
assert_eq!(unsupported.request_id, AppServerRequestId::Integer(99));
|
||||
|
||||
@@ -445,6 +445,7 @@ impl App {
|
||||
app_server
|
||||
.start_thread_with_session_start_source(
|
||||
&config, /*session_start_source*/ None,
|
||||
/*remote_cwd_override*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2320,6 +2321,33 @@ impl App {
|
||||
AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => {
|
||||
self.apply_agents_overview_thread_refresh(app_server, request_id, result);
|
||||
}
|
||||
AppEvent::SelectAgentsOverviewThread { thread_id } => {
|
||||
match self
|
||||
.select_agents_overview_thread(tui, app_server, thread_id)
|
||||
.await?
|
||||
{
|
||||
AppRunControl::Continue => {}
|
||||
AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)),
|
||||
}
|
||||
}
|
||||
AppEvent::DispatchAgentsOverviewTask { prompt, cwd } => {
|
||||
self.dispatch_agents_overview_task(app_server, prompt, cwd)
|
||||
.await;
|
||||
}
|
||||
AppEvent::RenameAgentsOverviewThread { thread_id, name } => {
|
||||
if let Err(error) = app_server.thread_set_name(thread_id, name.clone()).await {
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.input = name;
|
||||
state.renaming = true;
|
||||
}
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to rename task: {error}"));
|
||||
}
|
||||
}
|
||||
AppEvent::StopAgentsOverviewThread { thread_id } => {
|
||||
self.stop_agents_overview_thread(app_server, thread_id)
|
||||
.await;
|
||||
}
|
||||
AppEvent::OpenAgentPicker => {
|
||||
self.open_agent_picker(app_server).await;
|
||||
}
|
||||
|
||||
@@ -529,6 +529,9 @@ impl App {
|
||||
&mut snapshot,
|
||||
)
|
||||
.await;
|
||||
if snapshot.input_state.is_none() {
|
||||
snapshot.input_state = self.agents_overview.input_states.remove(&thread_id);
|
||||
}
|
||||
let blocks_direct_input = self.agent_navigation.is_parent_owned(thread_id);
|
||||
|
||||
self.active_thread_id = Some(thread_id);
|
||||
@@ -728,7 +731,11 @@ impl App {
|
||||
}
|
||||
self.config = config.clone();
|
||||
match app_server
|
||||
.start_thread_with_session_start_source(&config, session_start_source)
|
||||
.start_thread_with_session_start_source(
|
||||
&config,
|
||||
session_start_source,
|
||||
/*remote_cwd_override*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut started) => {
|
||||
@@ -1091,6 +1098,8 @@ impl App {
|
||||
{
|
||||
Ok(()) => {
|
||||
self.backfill_loaded_subagent_threads(app_server).await;
|
||||
self.replay_agents_overview_requests(app_server, resumed_thread_id)
|
||||
.await;
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
if let Some(usage_line) = summary.usage_line {
|
||||
|
||||
@@ -128,7 +128,9 @@ impl App {
|
||||
.await
|
||||
} else {
|
||||
app_server
|
||||
.start_thread_with_session_start_source(&config, /*session_start_source*/ None)
|
||||
.start_thread_with_session_start_source(
|
||||
&config, /*session_start_source*/ None, /*remote_cwd_override*/ None,
|
||||
)
|
||||
.await
|
||||
};
|
||||
let transitioned = match transitioned {
|
||||
|
||||
@@ -195,13 +195,31 @@ pub(crate) enum TranscriptExportDestination {
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
/// Open the daemon-wide overview of loaded root sessions and their subagents.
|
||||
/// Open the daemon-wide overview of loaded root sessions.
|
||||
OpenAgentsOverview,
|
||||
/// Update the daemon-wide overview after a background thread listing finishes.
|
||||
AgentsOverviewThreadsLoaded {
|
||||
request_id: Uuid,
|
||||
result: Result<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.
|
||||
OpenAgentPicker,
|
||||
/// Merge a completed root-scoped agent-picker refresh without blocking terminal input.
|
||||
|
||||
@@ -602,21 +602,24 @@ impl AppServerSession {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
|
||||
self.start_thread_with_session_start_source(config, /*session_start_source*/ None)
|
||||
.await
|
||||
self.start_thread_with_session_start_source(
|
||||
config, /*session_start_source*/ None, /*remote_cwd_override*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_thread_with_session_start_source(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
session_start_source: Option<ThreadStartSource>,
|
||||
remote_cwd_override: Option<&std::path::Path>,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
let request_id = self.next_request_id();
|
||||
let session_config = self.session_config_with_effective_service_tier(config);
|
||||
let mut params = thread_start_params_from_config(
|
||||
&session_config,
|
||||
self.thread_params_mode(),
|
||||
self.remote_cwd_override.as_deref(),
|
||||
remote_cwd_override.or(self.remote_cwd_override.as_deref()),
|
||||
session_start_source,
|
||||
);
|
||||
if self.history_support == ThreadHistorySupport::LegacyOnly {
|
||||
|
||||
+23
-24
@@ -1,29 +1,28 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_tests.rs
|
||||
assertion_line: 49
|
||||
expression: "render_bottom_popup(&app.chat_widget, 96)"
|
||||
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")"
|
||||
---
|
||||
Agent command center
|
||||
1 need input 0 working 0 ready
|
||||
1 need input 1 working 0 ready
|
||||
────────────────────────────────────────────────────────────────────────────────────────────
|
||||
/tmp/project 1
|
||||
› ● Example task current Needs input
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
↑↓ navigate ctrl+f search ctrl+s group esc back
|
||||
/tmp/project 2 │ Task details
|
||||
› ● Build the dashboard current Needs input │
|
||||
● Repair authentication Working │ Build the dashboard
|
||||
│ ● Needs input
|
||||
│
|
||||
│ Project
|
||||
│ /tmp/project
|
||||
│
|
||||
│ Latest activity
|
||||
│ Build the dashboard
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Describe a task and press enter to dispatch it
|
||||
↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back
|
||||
|
||||
Reference in New Issue
Block a user