Add archive and delete actions to the agents overview (#44433)

## What changed

- Add `Ctrl+E` to archive the selected task and its child agents, and `Delete` to permanently delete their history. Both actions require confirmation, with Cancel selected by default.
- Expose `agents.archive` and `agents.delete` in keymap configuration and the shortcut picker. Default bindings yield to existing custom bindings and chords.
- Show progress while requests run, keeping rendering responsive and blocking task switching. Refresh overview state afterward and keep the dashboard open when the current task is removed.
- Report failures and preserve the current attachment and draft when a rejected operation leaves the task loaded.

## Testing

Add coverage for confirmation and cancellation, active and background task removal, child-agent attachments, stale refresh invalidation, failure recovery, and draft preservation. Add progress input-isolation tests, shortcut conflict tests, and snapshots for confirmation, errors, progress, and keymap rendering.

GitOrigin-RevId: b327bb8834699d0058f9ffb903ace525ae270500
This commit is contained in:
Eric Traut
2026-09-10 06:06:21 +00:00
committed by copyberry
parent bf5ebd98c5
commit b348fc2667
32 changed files with 1095 additions and 46 deletions
+4
View File
@@ -443,6 +443,10 @@ pub struct TuiAgentsKeymap {
pub rename: Option<KeybindingsSpec>,
/// Stop the selected running task.
pub stop: Option<KeybindingsSpec>,
/// Archive the selected task and its child agents after confirmation.
pub archive: Option<KeybindingsSpec>,
/// Permanently delete the selected task and its child agents after confirmation.
pub delete: Option<KeybindingsSpec>,
/// Hide the selected task until explicitly resumed or the TUI restarts.
pub hide: Option<KeybindingsSpec>,
/// Toggle grouping tasks by status or project.
+20
View File
@@ -3993,6 +3993,8 @@
],
"default": {
"agents": {
"archive": null,
"delete": null,
"hide": null,
"new_task": null,
"rename": null,
@@ -4304,6 +4306,22 @@
"additionalProperties": false,
"description": "Shortcuts specific to the shared agents overview.",
"properties": {
"archive": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Archive the selected task and its child agents after confirmation."
},
"delete": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Permanently delete the selected task and its child agents after confirmation."
},
"hide": {
"allOf": [
{
@@ -4817,6 +4835,8 @@
}
],
"default": {
"archive": null,
"delete": null,
"hide": null,
"new_task": null,
"rename": null,
+1
View File
@@ -206,6 +206,7 @@ mod agent_navigation;
mod agent_picker;
mod agent_status_feed;
mod agents_overview;
mod agents_overview_actions;
mod agents_overview_details;
mod agents_overview_threads;
mod agents_overview_view;
@@ -0,0 +1,67 @@
//! Progress rendering and input isolation while lifecycle requests are pending.
use super::*;
use futures::stream;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::collections::VecDeque;
use std::task::Poll;
use std::time::Duration;
#[tokio::test]
async fn modal_consumes_input_and_finishes_after_input_closes() -> color_eyre::Result<()> {
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events();
let (finished_input, wait_for_input) = tokio::sync::oneshot::channel();
let mut finished_input = Some(finished_input);
let mut events = VecDeque::from([
tui::TuiEvent::Key(crossterm::event::KeyCode::Esc.into()),
tui::TuiEvent::Paste("ignore this input".into()),
tui::TuiEvent::Draw,
tui::TuiEvent::Resize(ratatui::layout::Size::new(80, 24)),
tui::TuiEvent::FocusGained,
]);
let events = stream::poll_fn(move |_| {
if let Some(event) = events.pop_front() {
Poll::Ready(Some(event))
} else {
if let Some(finished_input) = finished_input.take() {
let _ = finished_input.send(());
}
Poll::Ready(None)
}
});
let result = tokio::time::timeout(
Duration::from_secs(5),
run_lifecycle_modal(
&mut tui,
AgentsOverviewAction::Archive,
async {
wait_for_input.await.expect("all modal input was consumed");
tokio::task::yield_now().await;
"operation finished"
},
events,
),
)
.await??;
assert_eq!(result, "operation finished");
Ok(())
}
#[test]
fn lifecycle_progress_wraps_at_narrow_widths() {
for (action, name) in [
(AgentsOverviewAction::Archive, "archive_progress"),
(AgentsOverviewAction::Delete, "delete_progress"),
] {
let progress = LifecycleProgress(action);
let mut terminal =
Terminal::new(TestBackend::new(36, progress.desired_height(/*width*/ 36))).unwrap();
terminal
.draw(|frame| progress.render(frame.area(), frame.buffer_mut()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
}
@@ -0,0 +1,322 @@
//! Server RPCs own lifecycle outcomes; successful actions invalidate dashboard group caches.
//! A modal keeps rendering during RPCs while deferring navigation and task notifications.
//! Removing the current root leaves an unattached dashboard, even when it is empty.
use super::App;
use crate::app_event::AgentsOverviewAction;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::chatwidget::ChatWidget;
use crate::render::renderable::Renderable;
use crate::tui;
use crate::wrapping::word_wrap_lines;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use std::collections::HashSet;
use std::future::Future;
use tokio_stream::Stream;
use tokio_stream::StreamExt;
struct LifecycleHeader(Vec<Line<'static>>);
impl Renderable for LifecycleHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
Renderable::render(
&Paragraph::new(word_wrap_lines(&self.0, usize::from(area.width))),
area,
buf,
);
}
fn desired_height(&self, width: u16) -> u16 {
word_wrap_lines(&self.0, usize::from(width)).len() as u16
}
}
struct LifecycleProgress(AgentsOverviewAction);
impl LifecycleProgress {
fn header(&self) -> LifecycleHeader {
let title = match self.0 {
AgentsOverviewAction::Archive => "Archiving task…",
AgentsOverviewAction::Delete => "Deleting task…",
};
LifecycleHeader(vec![
title.bold().into(),
"Please wait. Task switching is unavailable until this finishes."
.dim()
.into(),
])
}
}
impl Renderable for LifecycleProgress {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.header().render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.header().desired_height(width)
}
}
/// Keep the terminal responsive without dispatching navigation or queued task events.
async fn run_lifecycle_modal<T>(
tui: &mut tui::Tui,
action: AgentsOverviewAction,
operation: impl Future<Output = T>,
events: impl Stream<Item = tui::TuiEvent> + Unpin,
) -> color_eyre::Result<T> {
let progress = LifecycleProgress(action);
tui.draw(u16::MAX, |frame| {
progress.render(frame.area(), frame.buffer_mut());
})?;
let mut events = events.fuse();
tokio::pin!(operation);
let result = loop {
tokio::select! {
biased;
result = &mut operation => break result,
Some(event) = events.next() => {
tui.screen_size_for_event(&event)?;
match event {
tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost => {}
tui::TuiEvent::Draw | tui::TuiEvent::Resize(_) | tui::TuiEvent::Resume | tui::TuiEvent::FocusGained => {
tui.draw(u16::MAX, |frame| {
progress.render(frame.area(), frame.buffer_mut());
})?;
}
}
}
}
};
tui.frame_requester().schedule_frame();
Ok(result)
}
impl App {
pub(super) fn confirm_agents_overview_action(
&mut self,
thread_id: ThreadId,
action: AgentsOverviewAction,
) {
let Some(thread) = self
.agents_overview
.threads
.get(&thread_id)
.and_then(Option::as_ref)
else {
return;
};
let name = thread.name.as_deref().unwrap_or(&thread.preview);
let name = name
.trim()
.lines()
.next()
.filter(|name| !name.is_empty())
.unwrap_or("Untitled task");
let (title, description, label) = match action {
AgentsOverviewAction::Archive => (
format!("Archive “{name}”?"),
"This stops any running work in this task and its child agents, then archives them. Their history can be restored from the resume picker.",
"Archive task and child agents",
),
AgentsOverviewAction::Delete => (
format!("Permanently delete “{name}”?"),
"This stops any running work in this task and its child agents, then permanently deletes their history. This cannot be undone.",
"Permanently delete task and child agents",
),
};
self.chat_widget.show_selection_view(SelectionViewParams {
header: Box::new(LifecycleHeader(vec![
title.bold().into(),
description.dim().into(),
])),
items: vec![
SelectionItem {
name: "Cancel".to_string(),
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: label.to_string(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::RunAgentsOverviewAction { thread_id, action })
})],
dismiss_on_select: true,
require_explicit_confirmation: true,
..Default::default()
},
],
..Default::default()
});
}
pub(super) async fn run_agents_overview_action(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
thread_id: ThreadId,
action: AgentsOverviewAction,
) -> color_eyre::Result<()> {
// The overview may lack intermediate ancestors, or even the primary's metadata.
let mut removes_primary = self.primary_thread_id == Some(thread_id);
let mut attempted = false;
let primary_thread_id = self.primary_thread_id;
let events = tui.event_stream();
let operation = async {
let result = async {
if let Some(primary) = primary_thread_id
&& primary != thread_id
{
removes_primary = app_server
.thread_read(primary, /*include_turns*/ false)
.await?
.session_id
== thread_id.to_string();
}
attempted = true;
match action {
AgentsOverviewAction::Archive => app_server.thread_archive(thread_id).await,
AgentsOverviewAction::Delete => app_server.thread_delete(thread_id).await,
}
}
.await;
let mut detach_primary = removes_primary && attempted;
if detach_primary
&& result.is_err()
&& let Some(primary) = primary_thread_id
{
// A rejected operation may have left the attachment alive. Ask the server directly.
let mut cursor = None;
while let Ok(page) = app_server
.thread_loaded_list(ThreadLoadedListParams {
cursor,
limit: None,
})
.await
{
if page.data.contains(&primary.to_string()) {
detach_primary = false;
break;
}
let Some(next) = page.next_cursor else { break };
cursor = Some(next);
}
}
(result, detach_primary)
};
let (result, detach_primary) = run_lifecycle_modal(tui, action, operation, events).await?;
if attempted {
// A read started before the RPC can contain stale task and approval state.
if let Some(refresh) = self.agents_overview.refresh_task.take() {
refresh.abort();
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
self.agents_overview.refresh_notifications.clear();
}
if result.is_ok() {
// Invalidate cached details for the displayed group, without inferring child outcomes.
let mut removed = HashSet::from([thread_id]);
loop {
let previous_len = removed.len();
for (id, thread) in &self.agents_overview.threads {
let Some(thread) = thread else { continue };
let parent = thread
.parent_thread_id
.as_deref()
.and_then(|id| ThreadId::from_string(id).ok())
.or(match &thread.source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
..
}) => Some(*parent_thread_id),
_ => None,
});
if thread.session_id == thread_id.to_string()
|| parent.is_some_and(|parent| removed.contains(&parent))
{
removed.insert(*id);
}
}
if removed.len() == previous_len {
break;
}
}
if let Some(primary) = self.primary_thread_id
&& removes_primary
{
removed.insert(primary);
}
for removed_id in removed {
self.agents_overview.threads.remove(&removed_id);
self.agents_overview.activity.remove(&removed_id);
self.agents_overview.last_messages.remove(&removed_id);
self.agents_overview.refresh_thread_ids.remove(&removed_id);
self.agents_overview.input_states.remove(&removed_id);
self.agents_overview.dispatched_requests.remove(&removed_id);
}
}
if detach_primary {
self.reset_for_thread_switch(tui)?;
self.pending_thread_switch_resets += 1;
self.app_event_tx
.send(AppEvent::ResetTranscriptForThreadSwitch);
self.reset_thread_event_state();
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
/*initial_user_message*/ None,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.open_agents_overview(app_server);
} else {
self.repaint_agents_overview();
if attempted {
self.refresh_agents_overview_threads(app_server);
}
}
if let Err(error) = result {
let verb = match action {
AgentsOverviewAction::Archive => "archive",
AgentsOverviewAction::Delete => "delete",
};
let mut header = vec![
format!("Could not {verb} task").red().bold().into(),
format!("{error:#}").red().into(),
];
if attempted {
header.push(
"Work may have stopped. Resume the task to continue, or retry the action."
.dim()
.into(),
);
}
self.chat_widget.show_selection_view(SelectionViewParams {
header: Box::new(LifecycleHeader(header)),
items: vec![SelectionItem {
name: "Back to agents".to_string(),
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
});
}
Ok(())
}
}
#[cfg(test)]
#[path = "agents_overview_action_progress_tests.rs"]
mod progress_tests;
@@ -1,10 +1,22 @@
use super::*;
use crate::app_event::AgentsOverviewAction;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_model_provider_info::ModelProviderInfo;
use core_test_support::responses;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
let mut app = make_test_app().await;
let mut keymap = TuiKeymap::default();
keymap.agents.archive = Some(KeybindingsSpec::One(KeybindingSpec("f5".into())));
keymap.agents.delete = Some(KeybindingsSpec::One(KeybindingSpec("f6".into())));
keymap.agents.hide = Some(KeybindingsSpec::One(KeybindingSpec("f7".into())));
app.keymap = RuntimeKeymap::from_config(&keymap).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
@@ -39,6 +51,17 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
for character in "Target".chars() {
view.handle_key_event(KeyCode::Char(character).into());
}
for (key, expected) in [
(5, AgentsOverviewAction::Archive),
(6, AgentsOverviewAction::Delete),
] {
view.handle_key_event(KeyCode::F(key).into());
let event = rx.try_recv();
assert!(
matches!(event, Ok(AppEvent::ConfirmAgentsOverviewAction { thread_id, action }) if thread_id == target && action == expected),
"unexpected event: {event:?}"
);
}
view.handle_key_event(KeyCode::F(7).into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::HideAgentsOverviewThread { thread_id }) if thread_id == target)
@@ -129,12 +152,424 @@ async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resum
Ok(())
}
#[tokio::test]
async fn rejected_delete_preserves_a_live_attachment_and_draft() -> Result<()> {
let (mut app, _rx, _op_rx) = crate::app::tests::make_test_app_with_channels().await;
app.config.ephemeral = true;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let started = app_server.start_thread(&app.config).await?;
let id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
app.chat_widget
.apply_external_edit("Keep this draft".into());
let draft = app.chat_widget.capture_thread_input_state();
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events();
// Ephemeral tasks provide a deterministic rejection before server teardown.
Box::pin(app.run_agents_overview_action(
&mut tui,
&mut app_server,
id,
AgentsOverviewAction::Delete,
))
.await?;
assert_eq!(
(app.primary_thread_id, app.chat_widget.thread_id()),
(Some(id), Some(id))
);
assert_eq!(app.chat_widget.capture_thread_input_state(), draft);
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Could not delete task"));
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashboard() -> Result<()>
{
for (action, snapshot, attach_child) in [
(AgentsOverviewAction::Archive, "archive_task", false),
(AgentsOverviewAction::Delete, "delete_task", false),
(AgentsOverviewAction::Archive, "archive_task", true),
(AgentsOverviewAction::Delete, "delete_task", true),
] {
let key = match action {
AgentsOverviewAction::Archive => {
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)
}
AgentsOverviewAction::Delete => KeyCode::Delete.into(),
};
let (mut app, mut rx, _op_rx) =
Box::pin(crate::app::tests::make_test_app_with_channels()).await;
let (release, gate) = tokio::sync::oneshot::channel();
let (server, _completions) = start_streaming_sse_server(vec![vec![
StreamingSseChunk {
gate: None,
body: responses::sse(vec![responses::ev_response_created("running")]),
},
StreamingSseChunk {
gate: Some(gate),
body: responses::sse(vec![responses::ev_completed("running")]),
},
]])
.await;
app.config.model = Some("gpt-5.2".into());
app.config.model_provider_id = "lifecycle-test".into();
app.config.model_provider = ModelProviderInfo {
name: "Lifecycle test".into(),
base_url: Some(format!("{}/v1", server.uri())),
request_max_retries: Some(0),
stream_max_retries: Some(0),
..ModelProviderInfo::default()
};
app_test_support::MockResponsesConfig::new(server.uri())
.with_model("gpt-5.2")
.with_model_provider("lifecycle-test")
.with_provider_name("Lifecycle test")
.write(app.config.codex_home.as_path())?;
let mut app_server =
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
&app.config.codex_home,
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Current task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialize session"),
)?;
let primary = if attach_child {
let child = ThreadId::from_string(
&app_test_support::create_fake_parented_rollout_with_source(
&app.config.codex_home,
"2025-01-05T12-01-00",
"2025-01-05T12:01:00Z",
"Current child",
Some(&app.config.model_provider_id),
/*git_info*/ None,
codex_protocol::protocol::SessionSource::SubAgent(
SubAgentSource::ThreadSpawn {
parent_thread_id: id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
},
),
codex_protocol::SessionId::from(id),
id,
)
.expect("materialize child session"),
)?;
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(app.config.codex_home.clone()),
app.config.model_provider_id.clone(),
)
.await
.expect("initialize spawn state");
state_db
.upsert_thread_spawn_edge(
id,
child,
codex_state::DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("persist spawn edge");
app.agents_overview.threads.insert(
child,
Some(overview_thread(
child,
Some(id),
"Current child",
ThreadStatus::Idle,
)),
);
child
} else {
id
};
let resumed = Box::pin(app_server.resume_thread(
&app.local_settings,
app.config.clone(),
primary,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
))
.await?;
app.enqueue_primary_thread_session(resumed.session, resumed.turns)
.await?;
app.agents_overview.threads.insert(
id,
Some(overview_thread(
id,
/*parent_thread_id*/ None,
"Current task",
ThreadStatus::Idle,
)),
);
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: test_path_buf("/tmp/unused.sock").abs(),
},
};
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events();
app.open_agents_overview(&app_server);
if action == AgentsOverviewAction::Archive {
let rollout = app_server
.thread_read(id, /*include_turns*/ false)
.await?
.path
.expect("saved root history");
let blocked_archive = app
.config
.codex_home
.join("archived_sessions")
.join(rollout.file_name().unwrap());
std::fs::create_dir_all(&blocked_archive)?;
Box::pin(app.run_agents_overview_action(&mut tui, &mut app_server, id, action)).await?;
assert_eq!(
(app.primary_thread_id, app.chat_widget.thread_id()),
(None, None)
);
assert_eq!(app.agents_overview.visible_thread_ids, vec![id]);
assert_eq!(
app_server
.thread_read(primary, /*include_turns*/ false)
.await?
.status,
ThreadStatus::NotLoaded
);
let error = render_bottom_popup(&app.chat_widget, /*width*/ 40);
assert!(error.contains("Could not archive task"));
assert!(error.contains("Work may have stopped."));
app.chat_widget.handle_key_event(KeyCode::Enter.into());
assert!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
);
std::fs::remove_dir(&blocked_archive)?;
let resumed = Box::pin(app_server.resume_thread(
&app.local_settings,
app.config.clone(),
primary,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
))
.await?;
app.enqueue_primary_thread_session(resumed.session, resumed.turns)
.await?;
app.open_agents_overview(&app_server);
}
let background = ThreadId::from_string(
&app_test_support::create_fake_rollout(
&app.config.codex_home,
"2025-01-05T13-00-00",
"2025-01-05T13:00:00Z",
"Background task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialize background session"),
)?;
let child = ThreadId::new();
let grandchild = ThreadId::new();
for (target, parent) in [
(background, None),
(child, Some(background)),
(grandchild, Some(child)),
] {
app.agents_overview.threads.insert(
target,
Some(overview_thread(
target,
parent,
"Background task",
ThreadStatus::NotLoaded,
)),
);
}
// Cached details and approval state are invalidated as a group after success.
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
let stale_threads = app.agents_overview.threads.clone();
Box::pin(app.run_agents_overview_action(&mut tui, &mut app_server, background, action))
.await?;
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
threads: stale_threads,
last_messages: HashMap::new(),
recent_seed_complete: true,
}),
);
assert_eq!(
(app.primary_thread_id, app.chat_widget.thread_id()),
(Some(primary), Some(primary))
);
assert_eq!(app.agents_overview.visible_thread_ids, vec![id]);
assert_eq!(
app.agents_overview
.threads
.keys()
.copied()
.collect::<std::collections::HashSet<_>>(),
std::collections::HashSet::from([id, primary])
);
Box::pin(app.run_agents_overview_action(
&mut tui,
&mut app_server,
ThreadId::from_string("00000000-0000-0000-0000-000000000001")?,
action,
))
.await?;
assert_eq!(
(app.primary_thread_id, app.chat_widget.thread_id()),
(Some(primary), Some(primary))
);
insta::assert_snapshot!(
format!("{snapshot}_failure"),
render_bottom_popup(&app.chat_widget, /*width*/ 80)
);
app.chat_widget.handle_key_event(KeyCode::Enter.into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app_server
.request_handle()
.request_typed::<TurnStartResponse>(ClientRequest::TurnStart {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: TurnStartParams {
thread_id: primary.to_string(),
input: vec![codex_app_server_protocol::UserInput::Text {
text: "Keep working".into(),
text_elements: Vec::new(),
}],
..Default::default()
},
})
.await?;
tokio::time::timeout(
std::time::Duration::from_secs(/*secs*/ 5),
server.wait_for_request_count(/*count*/ 1),
)
.await?;
assert!(matches!(
app_server
.thread_read(primary, /*include_turns*/ false)
.await?
.status,
ThreadStatus::Active { .. }
));
app.chat_widget.handle_key_event(key);
let confirmation = std::iter::from_fn(|| rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::ConfirmAgentsOverviewAction { .. }))
.expect("shortcut requests confirmation");
Box::pin(app.handle_event(&mut tui, &mut app_server, confirmation)).await?;
insta::assert_snapshot!(
format!("{snapshot}_confirmation"),
render_bottom_popup(&app.chat_widget, /*width*/ 72)
);
app.chat_widget.handle_key_event(KeyCode::Enter.into());
assert!(
!std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::RunAgentsOverviewAction { .. }))
);
app.chat_widget.handle_key_event(key);
let confirmation = std::iter::from_fn(|| rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::ConfirmAgentsOverviewAction { .. }))
.expect("shortcut requests confirmation again");
Box::pin(app.handle_event(&mut tui, &mut app_server, confirmation)).await?;
app.chat_widget.handle_key_event(KeyCode::Down.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
let confirmed = std::iter::from_fn(|| rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::RunAgentsOverviewAction { .. }))
.expect("confirmation requests lifecycle action");
if attach_child {
// Removal must not depend on the overview having the primary or its ancestors cached.
app.agents_overview.threads.remove(&primary);
}
Box::pin(app.handle_event(&mut tui, &mut app_server, confirmed)).await?;
assert_eq!(
(
app.primary_thread_id,
app.current_displayed_thread_id(),
app.chat_widget.thread_id()
),
(None, None, None)
);
assert_eq!(
app.agents_overview.visible_thread_ids,
Vec::<ThreadId>::new()
);
assert_eq!(
app_server
.thread_loaded_list(ThreadLoadedListParams::default())
.await?
.data,
Vec::<String>::new()
);
assert!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
);
match action {
AgentsOverviewAction::Archive => {
app_server.thread_unarchive(id).await?;
}
AgentsOverviewAction::Delete => {
assert!(
app_server
.thread_read(id, /*include_turns*/ false)
.await
.is_err()
);
}
}
app_server.shutdown().await?;
drop(release);
server.shutdown().await;
}
Ok(())
}
#[tokio::test]
async fn disabled_footer_shortcuts_stay_bold_when_wrapped() {
let app = make_test_app().await;
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Esc.into());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 84, /*height*/ 24,
);
let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer);
let delete_key = crate::key_hint::plain(KeyCode::Delete).display_label();
for key in ["ctrl+x", "ctrl+w", "ctrl+e", delete_key.as_str()] {
let cells = buffer
.content()
.windows(key.len())
.find(|cells| {
cells
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
== key
})
.expect("footer shortcut");
assert_eq!(
cells.iter().map(|cell| cell.modifier).collect::<Vec<_>>(),
vec![ratatui::style::Modifier::BOLD | ratatui::style::Modifier::DIM; key.len()]
);
}
}
#[tokio::test]
async fn lifecycle_footer_keeps_custom_chords_with_labels() {
let mut app = make_test_app().await;
app.keymap = RuntimeKeymap::from_config(
&serde_json::from_value(serde_json::json!({
"agents": { "hide": "f5 f8" }
"agents": { "archive": "f5 f6", "delete": "f5 f7", "hide": "f5 f8" }
}))
.unwrap(),
)
@@ -164,11 +599,13 @@ async fn lifecycle_footer_keeps_custom_chords_with_labels() {
.to_string()
})
.collect::<Vec<_>>();
assert!(
lines
.iter()
.any(|line| line.contains("hide") && line.contains("f5"))
);
for label in ["archive", "delete", "hide"] {
assert!(
lines
.iter()
.any(|line| line.contains(label) && line.contains("f5"))
);
}
assert!(
lines
.iter()
+18 -7
View File
@@ -25,6 +25,8 @@ impl AgentsOverviewView {
&self.agents_keymap.new_task,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.archive,
&self.agents_keymap.delete,
&self.agents_keymap.hide,
&self.agents_keymap.toggle_grouping,
]
@@ -56,7 +58,11 @@ impl AgentsOverviewView {
let key = hint.display_label().replace(" + ", "+");
hints.push(
vec![
if enabled { key.bold() } else { key.dim() },
if enabled {
key.bold()
} else {
key.bold().dim()
},
format!(" {label}").dim(),
]
.into(),
@@ -105,12 +111,17 @@ impl AgentsOverviewView {
self.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })),
);
add_hint(
self.agents_keymap
.primary_hint("hide", &self.agents_keymap.hide),
"hide",
self.selected_row().is_some(),
);
for (action, bindings) in [
("hide", &self.agents_keymap.hide),
("archive", &self.agents_keymap.archive),
("delete", &self.agents_keymap.delete),
] {
add_hint(
self.agents_keymap.primary_hint(action, bindings),
action,
self.selected_row().is_some(),
);
}
add_hint(list_hint(ListAction::Cancel), "back", true);
let separator = if hints.iter().map(Line::width).sum::<usize>()
+ hints.len().saturating_sub(1) * 2
@@ -2037,8 +2037,10 @@ async fn empty_command_center_can_open_resume_picker() {
event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker)
));
// Crossterm labels forward Delete as "fwd del" on macOS and "del" elsewhere.
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 48).replace("fwd del", "del");
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_empty_narrow", render_bottom_popup(&app.chat_widget, /*width*/ 48));
insta::assert_snapshot!("agents_overview_empty_narrow", rendered);
});
}
@@ -7,6 +7,7 @@ mod input;
mod render;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use crate::app_event::AgentsOverviewAction;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
@@ -689,6 +690,21 @@ impl BottomPaneView for AgentsOverviewView {
}
return;
}
for (bindings, action) in [
(&self.agents_keymap.archive, AgentsOverviewAction::Archive),
(&self.agents_keymap.delete, AgentsOverviewAction::Delete),
] {
if bindings.is_pressed(key) {
if let Some(row) = self.selected_row() {
self.app_event_tx
.send(AppEvent::ConfirmAgentsOverviewAction {
thread_id: row.thread_id,
action,
});
}
return;
}
}
if self.agents_keymap.hide.is_pressed(key) {
if let Some(row) = self.selected_row() {
self.app_event_tx.send(AppEvent::HideAgentsOverviewThread {
+6
View File
@@ -2807,6 +2807,12 @@ impl App {
self.agents_overview.hidden_threads.insert(thread_id);
self.repaint_agents_overview();
}
AppEvent::ConfirmAgentsOverviewAction { thread_id, action } => {
self.confirm_agents_overview_action(thread_id, action);
}
AppEvent::RunAgentsOverviewAction { thread_id, action } => {
self.run_agents_overview_action(tui, app_server, thread_id, action).await?;
}
AppEvent::StopAgentsOverviewThread { thread_id } => {
self.stop_agents_overview_thread(app_server, thread_id)
.await;
@@ -25,4 +25,4 @@ expression: "render_bottom_popup(&app.chat_widget, 48)"
↑↓ navigate ctrl+o resume → open
ctrl+n new task ctrl+f search ctrl+s group
ctrl+r rename ctrl+x stop f5 f8 hide
esc back
f5 f6 archive f5 f7 delete esc back
@@ -0,0 +1,12 @@
---
source: tui/src/app/agents_overview_actions_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 72)"
---
Archive “Current task”?
This stops any running work in this task and its child agents, then
archives them. Their history can be restored from the resume picker.
› 1. Cancel
2. Archive task and child agents
Press enter to confirm or esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/app/agents_overview_actions_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 80)"
---
Could not archive task
failed to archive session: thread/archive failed: no rollout found for
thread id 00000000-0000-0000-0000-000000000001 (code -32600)
Work may have stopped. Resume the task to continue, or retry the action.
› 1. Back to agents
Press enter to confirm or esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/app/agents_overview_actions_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 72)"
---
Permanently delete “Current task”?
This stops any running work in this task and its child agents, then
permanently deletes their history. This cannot be undone.
› 1. Cancel
2. Permanently delete task and child agents
Press enter to confirm or esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/app/agents_overview_actions_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 80)"
---
Could not delete task
failed to delete session: thread/delete failed: no rollout found for thread
id 00000000-0000-0000-0000-000000000001 (code -32600)
Work may have stopped. Resume the task to continue, or retry the action.
› 1. Back to agents
Press enter to confirm or esc to go back
@@ -0,0 +1,8 @@
---
source: tui/src/app/agents_overview_action_progress_tests.rs
assertion_line: 62
expression: terminal.backend()
---
"Archiving task… "
"Please wait. Task switching is "
"unavailable until this finishes. "
@@ -0,0 +1,8 @@
---
source: tui/src/app/agents_overview_action_progress_tests.rs
assertion_line: 62
expression: terminal.backend()
---
"Deleting task… "
"Please wait. Task switching is "
"unavailable until this finishes. "
+17
View File
@@ -67,6 +67,13 @@ use codex_realtime_webrtc::StartedRealtimeWebrtcSession;
use crate::history_cell::HistoryCell;
/// Confirmed server lifecycle operations available from the agents dashboard.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AgentsOverviewAction {
Archive,
Delete,
}
/// Whether a managed checkout starts fresh or preserves the current conversation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ManagedWorktreeMode {
@@ -314,6 +321,16 @@ pub(crate) enum AppEvent {
HideAgentsOverviewThread {
thread_id: ThreadId,
},
/// Confirm a server lifecycle action for the selected dashboard task.
ConfirmAgentsOverviewAction {
thread_id: ThreadId,
action: AgentsOverviewAction,
},
/// Execute a confirmed dashboard lifecycle action.
RunAgentsOverviewAction {
thread_id: ThreadId,
action: AgentsOverviewAction,
},
/// Start the shared app-server daemon without moving the current embedded session.
#[cfg(any(unix, windows))]
StartAgentsDaemon,
+29 -11
View File
@@ -411,6 +411,8 @@ pub(crate) struct AgentsKeymap {
pub(crate) new_task: Vec<KeyBinding>,
pub(crate) rename: Vec<KeyBinding>,
pub(crate) stop: Vec<KeyBinding>,
pub(crate) archive: Vec<KeyBinding>,
pub(crate) delete: Vec<KeyBinding>,
pub(crate) hide: Vec<KeyBinding>,
pub(crate) toggle_grouping: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
@@ -1290,22 +1292,34 @@ impl RuntimeKeymap {
new_task: resolve_local!(keymap, defaults, agents, new_task),
rename: resolve_local!(keymap, defaults, agents, rename),
stop: resolve_local!(keymap, defaults, agents, stop),
archive: resolve_local!(keymap, defaults, agents, archive),
delete: resolve_local!(keymap, defaults, agents, delete),
hide: resolve_local!(keymap, defaults, agents, hide),
toggle_grouping: resolve_local!(keymap, defaults, agents, toggle_grouping),
chord_hints: Arc::clone(&chords),
};
// The new default yields to existing user bindings in the dashboard.
if keymap.agents.hide.is_none()
&& (configured_context_alias_is_used(&keymap.agents, "ctrl-w")
|| configured_context_alias_is_used(&keymap.list, "ctrl-w")
|| configured_context_alias_is_used(&keymap.global, "ctrl-w")
|| chords.bindings.iter().any(|chord| {
chord.action.context.overlaps(KeymapContext::Agents)
&& agents.hide.contains(&chord.chord.prefix)
}))
{
agents.hide.clear();
// Newly added defaults yield to existing user bindings in the dashboard.
for (configured, bindings, alias) in [
(
keymap.agents.archive.as_ref(),
&mut agents.archive,
"ctrl-e",
),
(keymap.agents.delete.as_ref(), &mut agents.delete, "delete"),
(keymap.agents.hide.as_ref(), &mut agents.hide, "ctrl-w"),
] {
if configured.is_none()
&& (configured_context_alias_is_used(&keymap.agents, alias)
|| configured_context_alias_is_used(&keymap.list, alias)
|| configured_context_alias_is_used(&keymap.global, alias)
|| chords.bindings.iter().any(|chord| {
chord.action.context.overlaps(KeymapContext::Agents)
&& bindings.contains(&chord.chord.prefix)
}))
{
bindings.clear();
}
}
let approval = ApprovalKeymap {
@@ -1440,6 +1454,8 @@ impl RuntimeKeymap {
(keymap.agents.new_task.as_ref(), &mut agents.new_task),
(keymap.agents.rename.as_ref(), &mut agents.rename),
(keymap.agents.stop.as_ref(), &mut agents.stop),
(keymap.agents.archive.as_ref(), &mut agents.archive),
(keymap.agents.delete.as_ref(), &mut agents.delete),
(keymap.agents.hide.as_ref(), &mut agents.hide),
(
keymap.agents.toggle_grouping.as_ref(),
@@ -1805,6 +1821,8 @@ impl RuntimeKeymap {
new_task: default_bindings![ctrl(KeyCode::Char('n'))],
rename: default_bindings![ctrl(KeyCode::Char('r'))],
stop: default_bindings![ctrl(KeyCode::Char('x'))],
archive: default_bindings![ctrl(KeyCode::Char('e'))],
delete: default_bindings![plain(KeyCode::Delete)],
hide: default_bindings![ctrl(KeyCode::Char('w'))],
toggle_grouping: default_bindings![ctrl(KeyCode::Char('s'))],
chord_hints: Arc::default(),
+2
View File
@@ -398,6 +398,8 @@ define_runtime_action_bindings! {
new_task,
rename,
stop,
archive,
delete,
hide,
toggle_grouping,
],
+34 -11
View File
@@ -40,16 +40,39 @@ See the Codex keymap documentation for supported actions and examples."
#[test]
fn new_agents_defaults_preserve_existing_custom_bindings() {
for context in ["agents", "list"] {
let existing = if context == "agents" {
"stop"
} else {
"move_down"
};
let keymap: TuiKeymap =
serde_json::from_value(json!({context: {existing: "ctrl-w"}})).unwrap();
let runtime =
RuntimeKeymap::from_config(&keymap).expect("existing configuration remains valid");
assert!(runtime.agents.hide.is_empty());
for (action, alias) in [
("archive", "ctrl-e"),
("delete", "delete"),
("hide", "ctrl-w"),
] {
for (context, existing, suffix) in [
("agents", "stop", ""),
("list", "move_down", ""),
("global", "copy", ""),
("agents", "stop", " f12"),
("list", "move_down", " f12"),
] {
// Ctrl+E is reserved by the resume picker for list-context chords.
if context == "list" && alias == "ctrl-e" && !suffix.is_empty() {
continue;
}
let keymap: TuiKeymap = serde_json::from_value(
json!({context: {existing: format!("{alias}{suffix}")}, "approval": {"open_fullscreen": "f12"},
"editor": {"move_line_start": [], "move_line_end": [], "delete_backward_word": [], "delete_forward": []}}),
)
.unwrap();
let runtime =
RuntimeKeymap::from_config(&keymap).expect("existing configuration remains valid");
let bindings = match action {
"archive" => &runtime.agents.archive,
"delete" => &runtime.agents.delete,
"hide" => &runtime.agents.hide,
_ => unreachable!(),
};
assert!(
bindings.is_empty(),
"{action} shadows {context}.{existing} = {alias}{suffix}"
);
}
}
}
+20
View File
@@ -1017,6 +1017,26 @@ mod tests {
.join("\n");
assert_snapshot!("keymap_picker_first_actions", snapshot);
let agents = all_tab
.items
.iter()
.filter(|item| {
item.search_value
.as_deref()
.unwrap_or_default()
.starts_with("Agents ")
})
.map(|item| {
format!(
"{} | {} | {}",
item.name,
item.description.as_deref().unwrap_or_default(),
item.search_value.as_deref().unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!("keymap_picker_agents_actions", agents);
}
#[test]
+4
View File
@@ -218,6 +218,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("agents", "Agents", "new_task", "Start composing a new agent task."),
action("agents", "Agents", "rename", "Rename the selected task."),
action("agents", "Agents", "stop", "Stop the selected running task."),
action("agents", "Agents", "archive", "Archive the selected task and its child agents."),
action("agents", "Agents", "delete", "Permanently delete the selected task and its child agents."),
action("agents", "Agents", "hide", "Hide the selected task until explicitly resumed."),
action("agents", "Agents", "toggle_grouping", "Group tasks by status or project."),
action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."),
@@ -396,6 +398,8 @@ pub(super) fn binding_slot<'a>(
("agents", "new_task") => Some(&mut keymap.agents.new_task),
("agents", "rename") => Some(&mut keymap.agents.rename),
("agents", "stop") => Some(&mut keymap.agents.stop),
("agents", "archive") => Some(&mut keymap.agents.archive),
("agents", "delete") => Some(&mut keymap.agents.delete),
("agents", "hide") => Some(&mut keymap.agents.hide),
("agents", "toggle_grouping") => Some(&mut keymap.agents.toggle_grouping),
("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen),
@@ -1,5 +1,6 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 2041
expression: "render_bottom_popup(&app.chat_widget, 48)"
---
Agent command center
@@ -25,4 +26,4 @@ expression: "render_bottom_popup(&app.chat_widget, 48)"
↑↓ navigate ctrl+o resume → open
ctrl+n new task ctrl+f search ctrl+s group
ctrl+r rename ctrl+x stop ctrl+w hide
esc back
ctrl+e archive del delete esc back
@@ -0,0 +1,14 @@
---
source: tui/src/keymap_setup.rs
assertion_line: 1039
expression: agents
---
Resume | ctrl-o | Agents resume Resume Open the session resume picker. ctrl-o Default
Search | ctrl-f | Agents search Search Search the available agent tasks. ctrl-f Default
New Task | ctrl-n | Agents new_task New Task Start composing a new agent task. ctrl-n Default
Rename | ctrl-r | Agents rename Rename Rename the selected task. ctrl-r Default
Stop | ctrl-x | Agents stop Stop Stop the selected running task. ctrl-x Default
Archive | ctrl-e | Agents archive Archive Archive the selected task and its child agents. ctrl-e Default
Delete | delete | Agents delete Delete Permanently delete the selected task and its child agents. delete Default
Hide | ctrl-w | Agents hide Hide Hide the selected task until explicitly resumed. ctrl-w Default
Toggle Grouping | ctrl-s | Agents toggle_grouping Toggle Grouping Group tasks by status or project. ctrl-s Default
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
143 actions, 1 customized, 5 unbound.
145 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
144 actions, 0 customized, 6 unbound.
146 actions, 0 customized, 6 unbound.
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,7 +2,7 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (143 selectable)
tab: All (145 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (5) (5 selectable)
@@ -11,7 +11,7 @@ tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (69 selectable)
tab: Navigation (20 selectable)
tab: Agents (7 selectable)
tab: Agents (9 selectable)
tab: Approval (8 selectable)
tab: Debug (1 selectable)
Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
143 actions, 1 customized, 6 unbound.
145 actions, 1 customized, 6 unbound.
[All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
143 actions, 0 customized, 5 unbound.
145 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim
Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
143 actions, 0 customized, 5 unbound.
145 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
143 actions, 0 customized, 5 unbound.
145 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug