Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ mod agent_message_consolidation;
mod agent_navigation;
mod agent_picker;
mod agent_status_feed;
mod agents_overview;
mod agents_overview_view;
pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID;
mod app_server_event_targets;
mod app_server_events;
pub(crate) mod app_server_requests;
Expand Down Expand Up @@ -592,6 +595,7 @@ pub(crate) struct App {
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
agent_navigation: AgentNavigationState,
agents_overview: agents_overview::AgentsOverviewState,
side_threads: HashMap<ThreadId, SideThreadState>,
abandoned_side_threads: HashSet<ThreadId>,
active_thread_id: Option<ThreadId>,
Expand Down Expand Up @@ -862,7 +866,24 @@ impl App {
}

fn render_chat_widget_frame(&mut self, tui: &mut tui::Tui, screen_size: Size) -> Result<Rect> {
let dashboard_visible = self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some();
if std::mem::replace(
&mut self.agents_overview.rendered_full_screen,
dashboard_visible,
) && !dashboard_visible
{
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui, screen_size)?;
}
self.with_chat_widget_frame(screen_size.width, |desired_height, chat_widget| {
let desired_height = if dashboard_visible {
screen_size.height
} else {
desired_height
};
let mut rendered_area = Rect::default();
tui.draw_with_resize_reflow(desired_height, screen_size, |frame| {
let area = frame.area();
Expand Down
270 changes: 270 additions & 0 deletions codex-rs/tui/src/app/agents_overview.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
//! Daemon-wide overview of loaded root 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 codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadReadParams;
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_protocol::protocol::SubAgentSource;

pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";

#[derive(Default)]
pub(super) struct AgentsOverviewState {
pub(super) request_id: Option<Uuid>,
pub(super) refresh_pending: bool,
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>>,
}

impl App {
pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) {
if matches!(self.app_server_target, AppServerTarget::Embedded) {
self.chat_widget.add_info_message(
"The shared agents dashboard requires a background app server.".to_string(),
/*hint*/ None,
);
return;
}

self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
let view = self.agents_overview_view(Vec::new(), /*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 refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) {
if self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_none()
{
return;
}
if self.agents_overview.request_id.is_some() {
self.agents_overview.refresh_pending = true;
return;
}

let request_id = Uuid::new_v4();
self.agents_overview.request_id = Some(request_id);
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let refresh_task = tokio::spawn(async move {
let result = async {
let mut threads = Vec::new();
let mut cursor = None;
while threads.len() < 1_000 {
let page = request_handle
.request_typed::<ThreadLoadedListResponse>(ClientRequest::ThreadLoadedList {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadLoadedListParams {
cursor,
limit: Some(100),
},
})
.await
.map_err(|error| error.to_string())?;
let mut loaded_threads = tokio::task::JoinSet::new();
for thread_id in page.data.into_iter().take(1_000 - threads.len()) {
let request_handle = request_handle.clone();
loaded_threads.spawn(async move {
match request_handle
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadReadParams {
thread_id: thread_id.clone(),
include_turns: false,
},
})
.await
{
Ok(mut response) => {
if let Ok(turns) = request_handle
.request_typed::<ThreadTurnsListResponse>(
ClientRequest::ThreadTurnsList {
request_id: RequestId::String(
Uuid::new_v4().to_string(),
),
params: ThreadTurnsListParams {
thread_id,
cursor: None,
limit: Some(1),
sort_direction: None,
items_view: None,
},
},
)
.await
&& let Some(ThreadItem::UserMessage { content, .. }) = turns
.data
.first()
.and_then(|turn| turn.items.first())
{
response.thread.preview =
ChatWidget::user_message_display_from_inputs(content)
.message;
}
Some(response.thread)
}
Err(error) => {
tracing::warn!(thread_id, %error, "failed to read loaded agent thread");
None
}
}
});
if loaded_threads.len() >= 16
&& let Some(Ok(Some(thread))) = loaded_threads.join_next().await
{
threads.push(thread);
}
}
while let Some(result) = loaded_threads.join_next().await {
if let Ok(Some(thread)) = result {
threads.push(thread);
}
}
let Some(next_cursor) = page.next_cursor else {
break;
};
cursor = Some(next_cursor);
}
threads.sort_by_key(|thread| std::cmp::Reverse(thread.updated_at));
Ok(threads)
}
.await;

app_event_tx.send(AppEvent::AgentsOverviewThreadsLoaded { request_id, result });
});
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.refresh_task = Some(refresh_task.abort_handle());
}
}

pub(super) fn apply_agents_overview_thread_refresh(
&mut self,
app_server: &AppServerSession,
request_id: Uuid,
result: Result<Vec<Thread>, String>,
) {
if self.agents_overview.request_id != Some(request_id) {
return;
}
self.agents_overview.request_id = None;
let Some(selected) = self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
else {
self.agents_overview.refresh_pending = false;
return;
};
let selected_thread_id = self
.agents_overview
.visible_thread_ids
.get(selected)
.copied();
let threads = match result {
Ok(threads) => threads,
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to load shared agents: {error}"));
if std::mem::take(&mut self.agents_overview.refresh_pending) {
self.refresh_agents_overview_threads(app_server);
}
return;
}
};
let view = self.agents_overview_view(threads, selected_thread_id);
self.agents_overview.visible_thread_ids = view.thread_ids();
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) {
self.refresh_agents_overview_threads(app_server);
}
}

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()
&& !matches!(thread.status, ThreadStatus::NotLoaded)
})
.map(|root| (root, agents_overview_group(root, &children)))
.collect::<Vec<_>>();
roots.sort_by_key(|(root, group)| (*group, std::cmp::Reverse(root.updated_at)));
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),
)
}
}

#[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)),
)
}
Loading
Loading