Add an agents overview dashboard to the TUI (#39094)

## What changed

- Add `/agents` to open a full-screen dashboard of loaded root sessions from the shared app server, with subagent status reflected in each root session.
- Support search, navigation, and grouping sessions by project or status.
- Refresh the dashboard when relevant thread notifications arrive, while preserving its selection and view preferences.
- Show a clear message when the dashboard is unavailable with the embedded app server.

## Testing

- Add coverage for dashboard rendering, status grouping, persisted view state, and restoring the conversation viewport after closing the dashboard.
- Cover `/agents` command discovery and dispatch from parent-owned threads.

GitOrigin-RevId: 6651f4c47358116e1809da3ac8844a5842026a95
This commit is contained in:
Eric Traut
2026-08-17 22:50:25 +00:00
committed by copyberry
parent 83d015375e
commit 4617d4d21d
19 changed files with 949 additions and 4 deletions
+21
View File
@@ -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;
@@ -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>,
@@ -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();
+270
View File
@@ -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)),
)
}
@@ -0,0 +1,103 @@
use crate::app::agents_overview_view::AgentsOverviewGroup;
use crate::app::test_support::make_test_app;
use crate::bottom_pane::BottomPaneView;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::test_support::test_path_display;
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;
#[tokio::test]
async fn shared_agents_dashboard_is_rendered() {
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 state = &app.agents_overview.view_state;
assert!(!state.lock().unwrap().status_grouping);
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));
app.chat_widget.show_bottom_pane_view(Box::new(view));
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")
);
});
let threads = (0..20)
.map(|index| {
let mut candidate = thread.clone();
if index != 0 {
candidate.id = ThreadId::new().to_string();
candidate.status = ThreadStatus::Idle;
}
candidate.name = (index != 0).then(|| format!("Task {index}"));
candidate.updated_at = index;
candidate.cwd = if index == 0 {
test_path_buf("/tmp/project-selected").abs()
} else {
test_path_buf(&format!("/tmp/project-{}", index % 3)).abs()
};
candidate
})
.collect();
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
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")
));
app.transcript_cells.push(std::sync::Arc::new(
crate::history_cell::PlainHistoryCell::new(vec![ratatui::text::Line::from(
"Previous conversation",
)]),
));
let mut tui = crate::tui::test_support::make_test_tui().expect("test terminal");
let screen_size = tui.terminal.last_known_screen_size;
app.render_chat_widget_frame(&mut tui, screen_size)
.expect("render full-screen dashboard");
assert_eq!(tui.terminal.viewport_area.height, screen_size.height);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.render_chat_widget_frame(&mut tui, screen_size)
.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");
}
@@ -0,0 +1,440 @@
//! Read-only dashboard of 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::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use codex_app_server_protocol::Thread;
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 ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Widget;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) enum AgentsOverviewGroup {
NeedsYou,
Working,
Ready,
Finished,
}
impl AgentsOverviewGroup {
pub(super) fn for_status(status: &ThreadStatus) -> Self {
match status {
ThreadStatus::Active { active_flags }
if active_flags.contains(&ThreadActiveFlag::WaitingOnApproval)
|| active_flags.contains(&ThreadActiveFlag::WaitingOnUserInput) =>
{
Self::NeedsYou
}
ThreadStatus::Active { .. } => Self::Working,
ThreadStatus::Idle => Self::Ready,
ThreadStatus::SystemError => Self::NeedsYou,
ThreadStatus::NotLoaded => Self::Finished,
}
}
fn label(self) -> &'static str {
match self {
Self::NeedsYou => "Needs input",
Self::Working => "Working",
Self::Ready => "Ready",
Self::Finished => "Finished",
}
}
}
#[derive(Clone)]
pub(super) struct AgentsOverviewRow {
pub(super) thread: Thread,
pub(super) thread_id: ThreadId,
pub(super) group: AgentsOverviewGroup,
pub(super) is_current: bool,
}
#[derive(Clone, Default)]
pub(super) struct AgentsOverviewViewState {
search: String,
searching: bool,
pub(super) status_grouping: bool,
pub(super) refresh_task: Option<tokio::task::AbortHandle>,
}
pub(super) struct AgentsOverviewView {
pub(super) rows: Vec<AgentsOverviewRow>,
selected: usize,
state: Arc<Mutex<AgentsOverviewViewState>>,
exit_on_cancel: bool,
completion: Option<ViewCompletion>,
app_event_tx: AppEventSender,
keymap: ListKeymap,
}
impl Drop for AgentsOverviewView {
fn drop(&mut self) {
if let Some(task) = self
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.refresh_task
.take()
{
task.abort();
}
}
}
impl AgentsOverviewView {
pub(super) fn new(
rows: Vec<AgentsOverviewRow>,
selected_thread_id: Option<ThreadId>,
exit_on_cancel: bool,
app_event_tx: AppEventSender,
keymap: RuntimeKeymap,
state: Arc<Mutex<AgentsOverviewViewState>>,
) -> Self {
let selected = selected_thread_id
.and_then(|thread_id| rows.iter().position(|row| row.thread_id == thread_id))
.or_else(|| rows.iter().position(|row| row.is_current))
.unwrap_or(0);
let mut view = Self {
rows,
selected,
state,
exit_on_cancel,
completion: None,
app_event_tx,
keymap: keymap.list,
};
let visible = view.visible_indices();
if !visible.contains(&view.selected) {
view.selected = visible.first().copied().unwrap_or(usize::MAX);
}
view
}
pub(super) fn thread_ids(&self) -> Vec<ThreadId> {
self.rows.iter().map(|row| row.thread_id).collect()
}
fn visible_indices(&self) -> Vec<usize> {
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let search = state.search.to_lowercase();
let mut visible = self
.rows
.iter()
.enumerate()
.filter_map(|(index, row)| {
let searchable = format!(
"{} {} {}",
row.thread.name.as_deref().unwrap_or_default(),
row.thread.preview,
row.thread.cwd.display(),
)
.to_lowercase();
(search.is_empty() || searchable.contains(&search)).then_some(index)
})
.collect::<Vec<_>>();
if !state.status_grouping {
visible.sort_by_key(|index| {
(
&self.rows[*index].thread.cwd,
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
});
}
visible
}
fn move_selection(&mut self, forward: bool) {
let visible = self.visible_indices();
if visible.is_empty() {
return;
}
let current = visible
.iter()
.position(|index| *index == self.selected)
.unwrap_or(0);
self.selected = if forward {
visible[(current + 1) % visible.len()]
} else {
visible[current.checked_sub(1).unwrap_or(visible.len() - 1)]
};
}
fn status(row: &AgentsOverviewRow) -> (&'static str, Span<'static>) {
match AgentsOverviewGroup::for_status(&row.thread.status) {
AgentsOverviewGroup::NeedsYou => ("Needs input", "●".red()),
AgentsOverviewGroup::Working => ("Working", "●".green()),
AgentsOverviewGroup::Ready => ("Ready", "○".cyan()),
AgentsOverviewGroup::Finished => ("Finished", "✓".dim()),
}
}
fn render_rows(&self, area: Rect, buf: &mut Buffer) {
let mut offset = 0;
let mut previous_group: Option<String> = None;
let project_grouping = !self
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.status_grouping;
let visible = self.visible_indices();
let mut first = visible
.iter()
.position(|index| *index == self.selected)
.unwrap_or_default();
let mut height = 2;
while first > 0 {
let previous = &self.rows[visible[first - 1]];
let current = &self.rows[visible[first]];
let group_changed = if project_grouping {
previous.thread.cwd != current.thread.cwd
} else {
previous.group != current.group
};
let added_height = 1 + 2 * u16::from(group_changed);
if height + added_height > area.height {
break;
}
height += added_height;
first -= 1;
}
for index in visible.into_iter().skip(first) {
if offset >= area.height {
break;
}
let row = &self.rows[index];
let group = if project_grouping {
row.thread.cwd.display().to_string()
} else {
row.group.label().to_string()
};
if previous_group.as_deref() != Some(group.as_str()) {
offset += u16::from(previous_group.is_some());
if offset >= area.height {
break;
}
let count = self
.rows
.iter()
.filter(|candidate| {
if project_grouping {
candidate.thread.cwd == row.thread.cwd
} else {
candidate.group == row.group
}
})
.count();
Line::from(vec![group.clone().bold(), format!(" {count}").dim()])
.render(Rect::new(area.x, area.y + offset, area.width, 1), buf);
offset += 1;
previous_group = Some(group);
}
if offset >= area.height {
break;
}
let marker = if self.selected == index {
"›".cyan().bold()
} else {
" ".into()
};
let title = row
.thread
.name
.as_deref()
.or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str()))
.unwrap_or("Untitled task");
let (status, dot) = Self::status(row);
let current = if row.is_current { " current" } else { "" };
let mut spans = vec![
marker,
" ".into(),
dot,
" ".into(),
title.into(),
current.dim(),
];
if project_grouping {
spans.extend([" ".into(), status.dim()]);
}
Line::from(spans).render(Rect::new(area.x, area.y + offset, area.width, 1), buf);
offset += 1;
}
}
}
impl BottomPaneView for AgentsOverviewView {
fn view_id(&self) -> Option<&'static str> {
Some(AGENTS_OVERVIEW_VIEW_ID)
}
fn selected_index(&self) -> Option<usize> {
Some(self.selected)
}
fn keymap_contexts(&self) -> KeymapContextSet {
KeymapContextSet::new(KeymapContext::List)
}
fn completion(&self) -> Option<ViewCompletion> {
self.completion
}
fn is_complete(&self) -> bool {
self.completion.is_some()
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
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;
return;
}
if let Some(action) = self.keymap.action_for(key)
&& (!matches!(key.code, KeyCode::Char(_))
|| self.state.lock().is_ok_and(|state| !state.searching))
{
match action {
ListAction::MoveUp => self.move_selection(/*forward*/ false),
ListAction::MoveDown => self.move_selection(/*forward*/ true),
ListAction::JumpTop => {
self.selected = self.visible_indices().first().copied().unwrap_or(0);
}
ListAction::JumpBottom => {
self.selected = self.visible_indices().last().copied().unwrap_or(0);
}
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 self.exit_on_cancel {
self.app_event_tx
.send(AppEvent::Exit(crate::app::ExitMode::Immediate));
}
self.completion = Some(ViewCompletion::Cancelled);
}
}
ListAction::PageUp | ListAction::PageDown => {
for _ in 0..5 {
self.move_selection(action == ListAction::PageDown);
}
}
ListAction::Accept | 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();
}
KeyCode::Char(character)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
{
state.search.push(character);
}
_ => return,
}
drop(state);
self.selected = self
.visible_indices()
.first()
.copied()
.unwrap_or(usize::MAX);
}
}
impl Renderable for AgentsOverviewView {
fn desired_height(&self, _width: u16) -> u16 {
24
}
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([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(1),
])
.areas(area);
let inset =
|rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
Line::from("Agent command center".bold()).render(inset(header), buf);
let (needs_you, working, ready) = self.rows.iter().fold((0, 0, 0), |counts, row| {
let (needs_you, working, ready) = counts;
match row.group {
AgentsOverviewGroup::NeedsYou => (needs_you + 1, working, ready),
AgentsOverviewGroup::Working => (needs_you, working + 1, ready),
AgentsOverviewGroup::Ready => (needs_you, working, ready + 1),
AgentsOverviewGroup::Finished => counts,
}
});
let attention = format!("{needs_you} need input");
Line::from(format!("{attention} {working} working {ready} ready").dim())
.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(),
])
};
footer_line.render(inset(footer), buf);
}
}
+13
View File
@@ -64,6 +64,7 @@ impl App {
);
self.refresh_mcp_startup_expected_servers_from_config();
self.chat_widget.finish_mcp_startup_after_lag();
self.refresh_agents_overview_threads(app_server_client);
}
AppServerEvent::ServerNotification(notification) => {
self.handle_server_notification_event(app_server_client, *notification)
@@ -86,6 +87,18 @@ impl App {
app_server_client: &AppServerSession,
notification: ServerNotification,
) {
if matches!(
&notification,
ServerNotification::ThreadStarted(_)
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ThreadNameUpdated(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadDeleted(_)
| ServerNotification::ThreadClosed(_)
) {
self.refresh_agents_overview_threads(app_server_client);
}
match &notification {
ServerNotification::ServerRequestResolved(notification) => {
self.pending_primary_events.retain(|event| {
+6
View File
@@ -2314,6 +2314,12 @@ impl App {
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
AppEvent::OpenAgentsOverview => {
self.open_agents_overview(app_server);
}
AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => {
self.apply_agents_overview_thread_refresh(app_server, request_id, result);
}
AppEvent::OpenAgentPicker => {
self.open_agent_picker(app_server).await;
}
+1
View File
@@ -434,6 +434,7 @@ See the Codex keymap documentation for supported actions and examples."
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
agents_overview: Default::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
+1
View File
@@ -62,6 +62,7 @@ pub(super) async fn make_test_app() -> App {
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
agents_overview: Default::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
+2
View File
@@ -5092,6 +5092,7 @@ async fn make_test_app() -> App {
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
agents_overview: Default::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
@@ -5168,6 +5169,7 @@ async fn make_test_app_with_channels() -> (
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
agents_overview: Default::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
+7
View File
@@ -195,6 +195,13 @@ 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.
OpenAgentsOverview,
/// Update the daemon-wide overview after a background thread listing finishes.
AgentsOverviewThreadsLoaded {
request_id: Uuid,
result: Result<Vec<Thread>, String>,
},
/// Open the agent picker for switching active threads.
OpenAgentPicker,
/// Merge a completed root-scoped agent-picker refresh without blocking terminal input.
@@ -384,6 +384,7 @@ fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool {
| SlashCommand::App
| SlashCommand::Side
| SlashCommand::Btw
| SlashCommand::Agents
| SlashCommand::MultiAgents
| SlashCommand::Vim
| SlashCommand::Keymap
@@ -4976,6 +4977,7 @@ mod tests {
#[test]
fn parent_owned_thread_allows_bare_navigation_commands() {
for (command, expected) in [
("/agents", SlashCommand::Agents),
("/subagents", SlashCommand::MultiAgents),
("/side", SlashCommand::Side),
("/btw", SlashCommand::Btw),
@@ -4996,13 +4998,13 @@ mod tests {
fn parent_owned_thread_allows_safe_command_selected_from_prefix() {
let (mut composer, _rx) = new_test_composer();
composer.set_parent_owned_thread();
type_chars_humanlike(&mut composer, &['/', 's', 'u', 'b']);
type_chars_humanlike(&mut composer, &['/', 'a', 'g']);
let result = composer
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.0;
assert_eq!(result, InputResult::Command(SlashCommand::MultiAgents));
assert_eq!(result, InputResult::Command(SlashCommand::Agents));
}
#[test]
+16 -1
View File
@@ -1222,6 +1222,21 @@ impl BottomPane {
popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list)
}
pub(crate) fn replace_view_if_present(
&mut self,
view_id: &'static str,
view: Box<dyn BottomPaneView>,
) {
if let Some(index) = self
.view_stack
.iter()
.rposition(|existing| existing.view_id() == Some(view_id))
{
self.view_stack[index] = view;
self.request_redraw();
}
}
pub(crate) fn list_keymap(&self) -> crate::keymap::ListKeymap {
self.keymap.list.clone()
}
@@ -1383,7 +1398,7 @@ impl BottomPane {
.lines()
.next()
.and_then(parse_slash_name)
.is_some_and(|(name, _, _)| name == "subagents");
.is_some_and(|(name, _, _)| matches!(name, "agents" | "subagents"));
self.keymap.chat.interrupt_turn.is_pressed(key_event)
&& self.is_task_running
@@ -303,6 +303,7 @@ mod tests {
commands,
vec![
SlashCommand::Ide,
SlashCommand::Agents,
SlashCommand::Copy,
SlashCommand::Export,
SlashCommand::Raw,
@@ -23,6 +23,7 @@ expression: commands
/app - continue this session in the Desktop app
/init - create an AGENTS.md file with instructions for Codex
/compact - summarize conversation to prevent hitting the context limit
/agents - view and switch between all active agent sessions
/side - start a side conversation in an ephemeral fork
/copy - copy last response as markdown
/export - export the conversation as markdown
@@ -1,6 +1,7 @@
//! Key routing and composer-adjacent UI interaction for `ChatWidget`.
use super::*;
use crate::bottom_pane::BottomPaneView;
impl ChatWidget {
pub(crate) fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
@@ -241,6 +242,21 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn show_bottom_pane_view(&mut self, view: Box<dyn BottomPaneView>) {
self.bottom_pane.show_view(view);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
pub(crate) fn replace_bottom_pane_view_if_present(
&mut self,
view_id: &'static str,
view: Box<dyn BottomPaneView>,
) {
self.bottom_pane.replace_view_if_present(view_id, view);
self.refresh_plan_mode_nudge();
}
pub(crate) fn selected_index_for_present_view(&self, view_id: &'static str) -> Option<usize> {
self.bottom_pane.selected_index_for_present_view(view_id)
}
+10
View File
@@ -7,6 +7,16 @@ use std::cell::Cell;
impl ChatWidget {
pub(crate) fn as_renderable(&self) -> RenderableItem<'_> {
if self
.bottom_pane
.selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
return self
.bottom_pane
.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0);
}
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
let active_cell_renderable = match &self.transcript.active_cell {
Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
@@ -313,6 +313,9 @@ impl ChatWidget {
SlashCommand::Side | SlashCommand::Btw => {
self.request_empty_side_conversation(cmd);
}
SlashCommand::Agents => {
self.app_event_tx.send(AppEvent::OpenAgentsOverview);
}
SlashCommand::MultiAgents => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
}
@@ -1139,6 +1142,7 @@ impl ChatWidget {
| SlashCommand::Side
| SlashCommand::Btw
| SlashCommand::Keymap
| SlashCommand::Agents
| SlashCommand::MultiAgents
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
+4 -1
View File
@@ -40,6 +40,7 @@ pub enum SlashCommand {
Compact,
Plan,
Goal,
Agents,
Side,
Btw,
Copy,
@@ -126,6 +127,7 @@ impl SlashCommand {
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Goal => "set or view the goal for a long-running task",
SlashCommand::Agents => "view and switch between all active agent sessions",
SlashCommand::MultiAgents => "switch between this session's subagents",
SlashCommand::Side | SlashCommand::Btw => {
"start a side conversation in an ephemeral fork"
@@ -187,6 +189,7 @@ impl SlashCommand {
matches!(
self,
SlashCommand::Copy
| SlashCommand::Agents
| SlashCommand::Export
| SlashCommand::Raw
| SlashCommand::Diff
@@ -255,7 +258,7 @@ impl SlashCommand {
| SlashCommand::Btw => true,
SlashCommand::Rollout => true,
SlashCommand::TestApproval => true,
SlashCommand::MultiAgents => true,
SlashCommand::Agents | SlashCommand::MultiAgents => true,
SlashCommand::Theme | SlashCommand::Pets => false,
}
}
@@ -0,0 +1,29 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 49
expression: "render_bottom_popup(&app.chat_widget, 96)"
---
Agent command center
1 need input 0 working 0 ready
────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 1
› ● Example task current Needs input
↑↓ navigate ctrl+f search ctrl+s group esc back