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
1 change: 1 addition & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ use toml::Value as TomlValue;
use uuid::Uuid;
mod agent_message_consolidation;
mod agent_navigation;
mod agent_picker;
mod agent_status_feed;
mod app_server_event_targets;
mod app_server_events;
Expand Down
39 changes: 39 additions & 0 deletions codex-rs/tui/src/app/agent_navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use codex_protocol::ThreadId;
use ratatui::text::Span;
use std::collections::HashMap;
use std::collections::HashSet;
use uuid::Uuid;

/// Small state container for multi-agent picker ordering and labeling.
///
Expand All @@ -48,6 +49,8 @@ pub(crate) struct AgentNavigationState {
stopped_threads: HashSet<ThreadId>,
/// Spawned child threads whose instructions are owned by their parent agent.
parent_owned_threads: HashSet<ThreadId>,
/// Coalesces root refreshes while rejecting replies from a previous session.
picker_refresh: Option<(ThreadId, Uuid)>,
}

/// Direction of keyboard traversal through the stable picker order.
Expand All @@ -60,6 +63,23 @@ pub(crate) enum AgentNavigationDirection {
}

impl AgentNavigationState {
pub(crate) fn begin_picker_refresh(&mut self, thread_id: ThreadId) -> Option<Uuid> {
if self.picker_refresh.is_some() {
return None;
}
let request_id = Uuid::new_v4();
self.picker_refresh = Some((thread_id, request_id));
Some(request_id)
}

pub(crate) fn finish_picker_refresh(&mut self, thread_id: ThreadId, request_id: Uuid) -> bool {
if self.picker_refresh != Some((thread_id, request_id)) {
return false;
}
self.picker_refresh = None;
true
}

/// Returns the cached picker entry for a specific thread id.
///
/// Callers use this when they already know which thread they care about and need the last
Expand Down Expand Up @@ -203,6 +223,7 @@ impl AgentNavigationState {
self.order.clear();
self.stopped_threads.clear();
self.parent_owned_threads.clear();
self.picker_refresh = None;
}

/// Removes a tracked thread entirely from picker metadata and traversal order.
Expand Down Expand Up @@ -433,6 +454,24 @@ mod tests {
assert!(!state.is_parent_owned(second_agent_id));
}

#[test]
fn picker_refresh_rejects_responses_from_before_clear() {
let mut state = AgentNavigationState::default();
let thread_id = ThreadId::new();
let stale_request = state
.begin_picker_refresh(thread_id)
.expect("first picker refresh");

assert_eq!(state.begin_picker_refresh(thread_id), None);
state.clear();
let current_request = state
.begin_picker_refresh(thread_id)
.expect("refresh after session reset");

assert!(!state.finish_picker_refresh(thread_id, stale_request));
assert!(state.finish_picker_refresh(thread_id, current_request));
}

#[test]
fn adjacent_thread_id_wraps_in_spawn_order() {
let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state();
Expand Down
146 changes: 146 additions & 0 deletions codex-rs/tui/src/app/agent_picker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Root-scoped background refresh for the agent picker.

use super::*;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStatus;
use std::collections::HashSet;

pub(super) const AGENT_PICKER_VIEW_ID: &str = "agent-picker";
const AGENT_PICKER_PAGE_SIZE: u32 = 100;
const AGENT_PICKER_MAX_THREADS: usize = 1_000;

impl App {
pub(super) fn refresh_agent_picker_threads(
&mut self,
app_server: &AppServerSession,
root: ThreadId,
) {
let Some(request_id) = self.agent_navigation.begin_picker_refresh(root) else {
return;
};
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = async {
let mut threads = Vec::new();
let mut cursor = None;
let mut seen_cursors = HashSet::new();
while threads.len() < AGENT_PICKER_MAX_THREADS
&& seen_cursors.insert(cursor.clone())
{
let page = match request_handle
.request_typed::<ThreadListResponse>(ClientRequest::ThreadList {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadListParams {
cursor,
limit: Some(AGENT_PICKER_PAGE_SIZE),
sort_key: None,
sort_direction: Some(SortDirection::Desc),
model_providers: Some(vec![]),
source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
archived: None,
is_pinned: None,
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: Some(root.to_string()),
},
})
.await
{
Ok(page) => page,
Err(err) if threads.is_empty() => return Err(err.to_string()),
Err(err) => {
tracing::warn!(%err, "failed to refresh remaining agent picker descendants");
break;
}
};
threads.extend(
page.data
.into_iter()
.take(AGENT_PICKER_MAX_THREADS - threads.len()),
);
let Some(next_cursor) = page.next_cursor else {
break;
};
cursor = Some(next_cursor);
}
threads.reverse();
Ok(threads)
}
.await;

app_event_tx.send(AppEvent::AgentPickerThreadsLoaded {
primary_thread_id: root,
request_id,
result,
});
});
}

pub(super) fn apply_agent_picker_thread_refresh(
&mut self,
root: ThreadId,
request_id: Uuid,
result: Result<Vec<Thread>, String>,
) {
if !self
.agent_navigation
.finish_picker_refresh(root, request_id)
|| self.primary_thread_id != Some(root)
{
return;
}
let threads = match result {
Ok(threads) => threads,
Err(err) => {
tracing::warn!(%err, "failed to refresh agent picker descendants");
return;
}
};
let selected = self
.chat_widget
.selected_index_for_present_view(AGENT_PICKER_VIEW_ID);
for thread in threads {
let Ok(thread_id) = ThreadId::from_string(&thread.id) else {
continue;
};
let live = self
.thread_event_channels
.get(&thread_id)
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live);
let previous = self.agent_navigation.get(&thread_id);
let is_running = matches!(thread.status, ThreadStatus::Active { .. });
let update_liveness = previous.is_none() || !is_running;
let is_closed = !live && matches!(thread.status, ThreadStatus::NotLoaded);
if !is_closed && previous.is_some_and(|entry| entry.is_closed) {
continue;
}
let agent_path = crate::app_server_session::source_agent_path(&thread.source);
let agent_nickname = thread
.agent_nickname
.or_else(|| previous.and_then(|entry| entry.agent_nickname.clone()));
let agent_role = thread
.agent_role
.or_else(|| previous.and_then(|entry| entry.agent_role.clone()));
if thread.can_accept_direct_input == Some(false) {
self.agent_navigation.mark_parent_owned(thread_id);
}
self.upsert_agent_picker_thread(thread_id, agent_nickname, agent_role, is_closed);
self.agent_navigation.set_agent_path(thread_id, agent_path);
if !live && update_liveness {
self.agent_navigation.set_running(thread_id, is_running);
}
}

let params = self.agent_picker_selection_view_params(selected);
self.chat_widget
.replace_selection_view_if_present(AGENT_PICKER_VIEW_ID, params);
}
}
7 changes: 7 additions & 0 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2004,6 +2004,13 @@ impl App {
AppEvent::OpenAgentPicker => {
self.open_agent_picker(app_server).await;
}
AppEvent::AgentPickerThreadsLoaded {
primary_thread_id,
request_id,
result,
} => {
self.apply_agent_picker_thread_refresh(primary_thread_id, request_id, result);
}
AppEvent::SelectAgentThread(thread_id) => {
self.select_agent_thread_and_discard_side(tui, app_server, thread_id)
.await?;
Expand Down
Loading
Loading