mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
## Why TUI preferences and persistence paths belong to the local client, while thread configuration and account requirements may come from the active app server. Keeping both in the same resolved `Config` can replace live local preferences when a thread is resumed, forked, reconnected, or switched. ## What changed - Add `LocalSettings` as the TUI-owned source for UI, history, notice, and local persistence settings, and preserve it across session lifecycle transitions. - Write preference changes to the selected user config file and reload local settings from disk when changing roots. - Use the app server's account response for authentication-dependent onboarding and status UI instead of the local model-provider configuration. - Retain platform family and OS metadata reported by remote app servers. ## Testing - Cover local setting defaults, overrides, persistence destinations, reloads, and preservation across widget replacement and root switching. - Cover remote platform metadata and server-controlled authentication UI. GitOrigin-RevId: dbc2965fb1d6cbecb3f973f6133f3dcd4467b753
791 lines
32 KiB
Rust
791 lines
32 KiB
Rust
//! Daemon-wide overview of recent and locally retained sessions and their subagents.
|
||
|
||
use super::agents_overview_view::AgentsOverviewGroup;
|
||
use super::agents_overview_view::AgentsOverviewRow;
|
||
use super::agents_overview_view::AgentsOverviewView;
|
||
use super::*;
|
||
use crate::app_event::AgentsOverviewThreadRefresh;
|
||
use crate::bottom_pane::SelectionDescriptionLayout;
|
||
use crate::bottom_pane::SelectionItem;
|
||
use crate::bottom_pane::SelectionViewParams;
|
||
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
|
||
use crate::chatwidget::ThreadInputStateRestoreMode;
|
||
use codex_app_server_protocol::RequestId;
|
||
use codex_app_server_protocol::SessionSource;
|
||
use codex_app_server_protocol::Thread;
|
||
use codex_app_server_protocol::ThreadHistoryMode;
|
||
use codex_app_server_protocol::TurnStartParams;
|
||
use codex_app_server_protocol::TurnStartResponse;
|
||
use codex_app_server_protocol::UserInput;
|
||
use codex_protocol::protocol::SubAgentSource;
|
||
|
||
pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
|
||
|
||
#[derive(Default)]
|
||
pub(super) struct AgentsOverviewState {
|
||
/// Missing metadata records a local resume until the next metadata refresh.
|
||
pub(super) threads: HashMap<ThreadId, Option<Thread>>,
|
||
pub(super) initialized: bool,
|
||
pub(super) request_id: Option<Uuid>,
|
||
pub(super) refresh_pending: bool,
|
||
pub(super) refresh_thread_ids: HashSet<ThreadId>,
|
||
pub(super) refresh_task: Option<tokio::task::AbortHandle>,
|
||
pub(super) refresh_notifications: HashMap<ThreadId, Vec<ServerNotification>>,
|
||
pub(super) rendered_full_screen: bool,
|
||
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 Drop for AgentsOverviewState {
|
||
fn drop(&mut self) {
|
||
if let Some(task) = self.refresh_task.take() {
|
||
task.abort();
|
||
}
|
||
}
|
||
}
|
||
|
||
impl App {
|
||
pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) {
|
||
if matches!(self.app_server_target, AppServerTarget::Embedded) {
|
||
let workload_identity_selected = codex_login::is_workload_identity_selected();
|
||
self.chat_widget.show_selection_view(SelectionViewParams {
|
||
title: Some("Shared agents unavailable".to_string()),
|
||
subtitle: Some(
|
||
if workload_identity_selected {
|
||
"The agents dashboard is unavailable while workload identity is active."
|
||
} else if cfg!(unix) {
|
||
"This session isn’t connected to a shared background server."
|
||
} else {
|
||
"Connect to a remote background server to use the agents dashboard."
|
||
}
|
||
.to_string(),
|
||
),
|
||
footer_note: (cfg!(unix) && !workload_identity_selected).then(|| {
|
||
Line::from(
|
||
"Starting a background server will not interrupt or move this session."
|
||
.dim(),
|
||
)
|
||
}),
|
||
footer_hint: Some(standard_popup_hint_line_for_keymap(&self.keymap.list)),
|
||
items: [
|
||
#[cfg(unix)]
|
||
(!workload_identity_selected).then(|| SelectionItem {
|
||
name: "Start background server".to_string(),
|
||
description: Some(
|
||
"Open `codex agents` in another terminal afterward.".to_string(),
|
||
),
|
||
actions: vec![Box::new(|tx| tx.send(AppEvent::StartAgentsDaemon))],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
}),
|
||
Some(SelectionItem {
|
||
name: "Return to this session".to_string(),
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
}),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.collect(),
|
||
description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow {
|
||
min_description_width: 28,
|
||
},
|
||
..Default::default()
|
||
});
|
||
return;
|
||
}
|
||
|
||
let threads = self
|
||
.agents_overview
|
||
.threads
|
||
.values()
|
||
.flatten()
|
||
.cloned()
|
||
.collect();
|
||
let view = self.agents_overview_view(threads, /*selected_thread_id*/ None);
|
||
self.agents_overview.visible_thread_ids = view.thread_ids();
|
||
self.chat_widget.show_bottom_pane_view(Box::new(view));
|
||
self.refresh_agents_overview_threads(app_server);
|
||
}
|
||
|
||
pub(super) fn apply_agents_overview_thread_refresh(
|
||
&mut self,
|
||
app_server: &AppServerSession,
|
||
request_id: Uuid,
|
||
result: Result<AgentsOverviewThreadRefresh, String>,
|
||
) {
|
||
if self.agents_overview.request_id != Some(request_id) {
|
||
return;
|
||
}
|
||
self.agents_overview.request_id = None;
|
||
self.agents_overview.refresh_task = None;
|
||
match result {
|
||
Ok(refresh) => {
|
||
self.agents_overview.initialized = refresh.recent_seed_complete;
|
||
for (thread_id, thread) in refresh.threads {
|
||
if let Some(mut thread) = thread {
|
||
if thread.ephemeral {
|
||
self.agents_overview.threads.remove(&thread_id);
|
||
continue;
|
||
}
|
||
thread.turns.clear();
|
||
self.agents_overview.threads.insert(thread_id, Some(thread));
|
||
} else {
|
||
self.agents_overview.threads.entry(thread_id).or_default();
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
tracing::warn!(%error, "failed to refresh shared agents");
|
||
if self
|
||
.chat_widget
|
||
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
|
||
.is_some()
|
||
{
|
||
self.chat_widget
|
||
.add_error_message(format!("Failed to load shared agents: {error}"));
|
||
}
|
||
}
|
||
}
|
||
for notifications in
|
||
std::mem::take(&mut self.agents_overview.refresh_notifications).into_values()
|
||
{
|
||
for notification in notifications {
|
||
self.track_agents_overview_notification(¬ification);
|
||
}
|
||
}
|
||
if std::mem::take(&mut self.agents_overview.refresh_pending) {
|
||
self.refresh_changed_agents_overview_threads(app_server);
|
||
}
|
||
self.repaint_agents_overview();
|
||
}
|
||
|
||
pub(super) fn repaint_agents_overview(&mut self) {
|
||
let Some(selected) = self
|
||
.chat_widget
|
||
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
|
||
else {
|
||
return;
|
||
};
|
||
let selected_thread_id = self
|
||
.agents_overview
|
||
.visible_thread_ids
|
||
.get(selected)
|
||
.copied();
|
||
let threads = self
|
||
.agents_overview
|
||
.threads
|
||
.values()
|
||
.flatten()
|
||
.cloned()
|
||
.collect();
|
||
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
|
||
{
|
||
self.chat_widget.add_info_message(
|
||
format!(
|
||
"The rename target disappeared. Unsubmitted title: {}",
|
||
state.input
|
||
),
|
||
/*hint*/ None,
|
||
);
|
||
state.renaming = false;
|
||
state.input.clear();
|
||
}
|
||
self.chat_widget
|
||
.replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view));
|
||
}
|
||
|
||
pub(super) fn agents_overview_view(
|
||
&self,
|
||
mut threads: Vec<Thread>,
|
||
selected_thread_id: Option<ThreadId>,
|
||
) -> AgentsOverviewView {
|
||
threads.retain(|thread| !thread.ephemeral);
|
||
for thread in &mut threads {
|
||
if thread.parent_thread_id.is_none()
|
||
&& let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||
parent_thread_id, ..
|
||
}) = &thread.source
|
||
{
|
||
thread.parent_thread_id = Some(parent_thread_id.to_string());
|
||
}
|
||
}
|
||
let mut children: HashMap<String, Vec<&Thread>> = HashMap::new();
|
||
for thread in &threads {
|
||
if let Some(parent_thread_id) = &thread.parent_thread_id {
|
||
children
|
||
.entry(parent_thread_id.clone())
|
||
.or_default()
|
||
.push(thread);
|
||
}
|
||
}
|
||
|
||
let mut roots = threads
|
||
.iter()
|
||
.filter(|thread| thread.parent_thread_id.is_none())
|
||
.map(|root| (root, agents_overview_group(root, &children)))
|
||
.collect::<Vec<_>>();
|
||
roots.sort_by(|(left, left_group), (right, right_group)| {
|
||
left_group
|
||
.cmp(right_group)
|
||
.then_with(|| right.updated_at.cmp(&left.updated_at))
|
||
.then_with(|| left.id.cmp(&right.id))
|
||
});
|
||
let mut rows = Vec::new();
|
||
for (root, group) in roots {
|
||
let Ok(thread_id) = ThreadId::from_string(&root.id) else {
|
||
continue;
|
||
};
|
||
rows.push(AgentsOverviewRow {
|
||
thread: root.clone(),
|
||
thread_id,
|
||
group,
|
||
is_current: self.primary_thread_id == Some(thread_id),
|
||
});
|
||
}
|
||
|
||
AgentsOverviewView::new(
|
||
rows,
|
||
selected_thread_id,
|
||
self.primary_thread_id.is_none(),
|
||
self.app_event_tx.clone(),
|
||
self.keymap.clone(),
|
||
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)
|
||
&& !self.thread_unavailable(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 unloaded = matches!(
|
||
target_thread.status,
|
||
codex_app_server_protocol::ThreadStatus::NotLoaded
|
||
);
|
||
let (mut resume_config, local_settings) = if unloaded {
|
||
let target_session = SessionTarget {
|
||
path: target_thread.path.clone(),
|
||
thread_id: root_thread_id,
|
||
history_mode: Some(target_thread.history_mode),
|
||
};
|
||
match self
|
||
.resume_config_for_target(tui, app_server, &target_session)
|
||
.await
|
||
{
|
||
Ok(config) => config,
|
||
Err(control) => return Ok(control),
|
||
}
|
||
} else {
|
||
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();
|
||
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 resume_model_settings = match target_thread.status {
|
||
codex_app_server_protocol::ThreadStatus::NotLoaded => {
|
||
self.apply_runtime_policy_overrides(
|
||
&mut resume_config,
|
||
RuntimePolicyOverrideScope::ExplicitOnly,
|
||
);
|
||
if matches!(self.runtime_approval_policy_override,
|
||
Some(RuntimeApprovalPolicyOverride::Explicit(policy))
|
||
if policy.to_core() != resume_config.permissions.approval_policy.value())
|
||
|| self
|
||
.runtime_permission_profile_override
|
||
.as_ref()
|
||
.is_some_and(|profile| {
|
||
profile.turn_override
|
||
== RuntimePermissionProfileTurnOverride::LegacySandbox
|
||
&& !profile.matches_config(&resume_config)
|
||
})
|
||
{
|
||
self.chat_widget.add_error_message(
|
||
"Cannot resume task without preserving the selected permissions."
|
||
.to_string(),
|
||
);
|
||
return Ok(AppRunControl::Continue);
|
||
}
|
||
self.resume_model_settings()
|
||
}
|
||
codex_app_server_protocol::ThreadStatus::Idle
|
||
| codex_app_server_protocol::ThreadStatus::Active { .. }
|
||
| codex_app_server_protocol::ThreadStatus::SystemError => {
|
||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread
|
||
}
|
||
};
|
||
let resumed = match app_server
|
||
.resume_thread(
|
||
&local_settings,
|
||
resume_config.clone(),
|
||
root_thread_id,
|
||
resume_model_settings,
|
||
)
|
||
.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;
|
||
}
|
||
// Explicit user choices carry across cold resumes; inherited task settings do not.
|
||
self.runtime_approval_policy_override =
|
||
self.runtime_approval_policy_override.filter(|policy| {
|
||
unloaded && matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_))
|
||
});
|
||
self.runtime_permission_profile_override = self
|
||
.runtime_permission_profile_override
|
||
.take()
|
||
.filter(|profile| {
|
||
unloaded
|
||
&& profile.turn_override
|
||
== RuntimePermissionProfileTurnOverride::LegacySandbox
|
||
});
|
||
self.local_settings = local_settings;
|
||
self.config = resume_config;
|
||
tui.set_notification_settings(
|
||
self.local_settings.tui.notification_settings.method,
|
||
self.local_settings.tui.notification_settings.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;
|
||
let approval = self.config.permissions.approval_policy.value();
|
||
if self
|
||
.runtime_approval_policy_override
|
||
.is_none_or(|policy| policy.policy().to_core() != approval)
|
||
{
|
||
self.runtime_approval_policy_override = (approval != baseline_approval)
|
||
.then_some(RuntimeApprovalPolicyOverride::Restored(approval.into()));
|
||
}
|
||
if self
|
||
.runtime_permission_profile_override
|
||
.as_ref()
|
||
.is_none_or(|profile| !profile.matches_config(&self.config))
|
||
{
|
||
self.runtime_permission_profile_override = (!baseline_permissions
|
||
.matches_config(&self.config))
|
||
.then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config));
|
||
}
|
||
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.thread_unavailable(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()
|
||
&& (!profile.matches_config(&config)
|
||
|| 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, RuntimePolicyOverrideScope::All);
|
||
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 async {
|
||
let thread = app_server
|
||
.thread_read(thread_id, /*include_turns*/ false)
|
||
.await?;
|
||
let turns = match thread.history_mode {
|
||
ThreadHistoryMode::Paginated if app_server.supports_paginated_history() => {
|
||
app_server
|
||
.thread_turns_page(thread_id, /*cursor*/ None)
|
||
.await?
|
||
.data
|
||
}
|
||
ThreadHistoryMode::Legacy | ThreadHistoryMode::Paginated => {
|
||
app_server
|
||
.thread_read(thread_id, /*include_turns*/ true)
|
||
.await?
|
||
.turns
|
||
}
|
||
};
|
||
Ok::<_, color_eyre::Report>(
|
||
turns
|
||
.into_iter()
|
||
.find(|turn| turn.status == TurnStatus::InProgress)
|
||
.map(|turn| turn.id),
|
||
)
|
||
}
|
||
.await
|
||
{
|
||
Ok(turn_id) => 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(unix)]
|
||
pub(super) fn start_agents_daemon(&self) {
|
||
let app_event_tx = self.app_event_tx.clone();
|
||
tokio::spawn(async move {
|
||
let result = async {
|
||
let current_executable =
|
||
std::env::current_exe().map_err(|error| error.to_string())?;
|
||
let executable = if current_executable
|
||
.file_stem()
|
||
.is_some_and(|name| name == "codex-tui")
|
||
{
|
||
current_executable.with_file_name("codex")
|
||
} else {
|
||
current_executable
|
||
};
|
||
let output = tokio::process::Command::new(executable)
|
||
.args(["app-server", "daemon", "start"])
|
||
.output()
|
||
.await
|
||
.map_err(|error| error.to_string())?;
|
||
if output.status.success() {
|
||
Ok(())
|
||
} else {
|
||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||
Err(if message.is_empty() {
|
||
format!("daemon process exited with {}", output.status)
|
||
} else {
|
||
message
|
||
})
|
||
}
|
||
}
|
||
.await;
|
||
|
||
app_event_tx.send(AppEvent::AgentsDaemonStarted { result });
|
||
});
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "agents_overview_tests.rs"]
|
||
mod tests;
|
||
|
||
fn agents_overview_group(
|
||
thread: &Thread,
|
||
children: &HashMap<String, Vec<&Thread>>,
|
||
) -> AgentsOverviewGroup {
|
||
children.get(&thread.id).into_iter().flatten().fold(
|
||
AgentsOverviewGroup::for_status(&thread.status),
|
||
|group, child| group.min(agents_overview_group(child, children)),
|
||
)
|
||
}
|