Files
codex/codex-rs/tui/src/app/agents_overview.rs
T
Eric Traut 9d57be71ba Separate TUI preferences from server configuration (#42202)
## 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
2026-09-02 04:20:38 +00:00

791 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(&notification);
}
}
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(&current_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)),
)
}