mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
¬ification,
|
||||
ServerNotification::ThreadStarted(_)
|
||||
| ServerNotification::ThreadStatusChanged(_)
|
||||
| ServerNotification::ThreadSettingsUpdated(_)
|
||||
| ServerNotification::ThreadNameUpdated(_)
|
||||
| ServerNotification::ThreadArchived(_)
|
||||
| ServerNotification::ThreadDeleted(_)
|
||||
| ServerNotification::ThreadClosed(_)
|
||||
) {
|
||||
self.refresh_agents_overview_threads(app_server_client);
|
||||
}
|
||||
match ¬ification {
|
||||
ServerNotification::ServerRequestResolved(notification) => {
|
||||
self.pending_primary_events.retain(|event| {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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
|
||||
Reference in New Issue
Block a user